feat: implement invert for vectors and matrices
All checks were successful
/ test (pull_request) Successful in 20s

Here invert returns the pseudoinverse when the input is not invertible.
  To this end, also implements (rudimentary) conversion of Complex to NumberT,
  an identity method that produces identity matrices, determinant, and
  the adjoint operation on matrices (conjugate transpose).
This commit is contained in:
Glen Whitney 2025-05-06 16:53:11 -07:00
parent ed66ea7772
commit db86b2ecd0
5 changed files with 379 additions and 13 deletions

View file

@ -1,8 +1,10 @@
import {plain} from './helpers.js'
import {BooleanT} from '#boolean/BooleanT.js'
import {Returns} from '#core/Type.js'
import {NumberT} from './NumberT.js'
import {Returns, ReturnType} from '#core/Type.js'
import {match} from '#core/TypePatterns.js'
import {NumberT} from '#number/NumberT.js'
import {BooleanT} from '#boolean/BooleanT.js'
import {Complex} from '#complex/Complex.js'
const num = f => Returns(NumberT, f)
@ -10,5 +12,17 @@ export const number = [
plain(a => a),
// conversions from Boolean should be consistent with one and zero:
match(BooleanT, num(p => p ? NumberT.one : NumberT.zero)),
match([], num(() => 0))
match([], num(() => 0)),
match(Complex, (math, C) => {
const im = math.im.resolve(C)
const re = math.re.resolve(C)
const compNum = math.number.resolve(ReturnType(re))
const isZ = math.isZero.resolve(ReturnType(im))
return num(z => {
if (!isZ(im(z))) {
throw new RangeError(`can't convert Complex ${z} to number`)
}
return compNum(re(z))
})
})
]

View file

@ -24,6 +24,30 @@ describe('Vector arithmetic functions', () => {
assert.deepStrictEqual(
add([[1, 2], [4, 2]], [0, -1]), [[1, 1], [4, 1]])
})
it('(pseudo)inverts matrices', () => {
const inv = math.invert
// inverses
assert.deepStrictEqual(inv([3, 4, 5]), [3/50, 2/25, 1/10])
assert.deepStrictEqual(inv([[4]]), [[0.25]])
assert.deepStrictEqual(inv([[5, 2], [-7, -3]]), [[3, 2], [-7, -5]])
assert(math.equal(
inv([[3, 0, 2], [2, 1, 0], [1, 4, 2]]),
[[1/10, 2/5, -1/10], [-1/5, 1/5, 1/5], [7/20, -3/5, 3/20]]))
// pseudoinverses
assert.deepStrictEqual(inv([[1, 0], [1, 0]]), [[1/2, 1/2], [0, 0]])
assert.deepStrictEqual(
inv([[1, 0], [0, 1], [0, 1]]), [[1, 0, 0], [0, 1/2, 1/2]])
assert.deepStrictEqual(
inv([[1, 0, 0, 0, 2],
[0, 0, 3, 0, 0],
[0, 0, 0, 0, 0],
[0, 4, 0, 0, 0]]),
[[1/5, 0, 0, 0],
[ 0, 0, 0, 1/4],
[ 0, 1/3, 0, 0],
[ 0, 0, 0, 0],
[2/5, 0, 0, 0]])
})
it('multiplies vectors and matrices', () => {
const mult = math.multiply
const pyth = [3, 4, 5]
@ -38,6 +62,10 @@ describe('Vector arithmetic functions', () => {
assert.deepStrictEqual(
mult(mat32, [[1, 2], [3, 4]]),
[[-8, -10], [-4, -4], [0, 2]])
assert(math.equal(
mult([[3, 0, 2], [2, 1, 0], [1, 4, 2]],
[[1/10, 2/5, -1/10], [-1/5, 1/5, 1/5], [7/20, -3/5, 3/20]]),
[[1, 0, 0], [0, 1, 0], [0, 0, 1]]))
})
it('negates a vector', () => {
assert.deepStrictEqual(math.negate([-3, 4, -5]), [3, -4, 5])

View file

@ -22,4 +22,24 @@ describe('Vector type functions', () => {
assert.deepStrictEqual(
tsp([[1, 2, 3], [4, 5, 6]]), [[1, 4], [2, 5], [3, 6]])
})
it('can take adjoint (conjugate transpose) of a matrix', () => {
const cx = math.complex
assert.deepStrictEqual(
math.adjoint([[cx(1, 1), cx(2, 2)], [cx(3, 3), cx(4, 4)]]),
[[cx(1, -1), cx(3, -3)], [cx(2, -2), cx(4, -4)]])
})
it('generates identity from an example matrix or a number of rows', () => {
const id = math.identity
const cx = math.complex
assert.deepStrictEqual(id(2), [[1, 0], [0, 1]])
assert.deepStrictEqual(id(cx(2)), [[cx(1), cx(0)], [cx(0), cx(1)]])
assert.deepStrictEqual(
id([[1, 2, 3]]),
[[1, 0 , 0], [0, 1, 0], [0, 0, 1]])
})
it('takes the determinant of a matrix', () => {
assert.strictEqual(
math.determinant([[6, 1, 1], [4, -2, 5], [2, 8, 7]]),
-306)
})
})

View file

@ -3,7 +3,7 @@ import {
} from './helpers.js'
import {Vector} from './Vector.js'
import {ReturnType} from '#core/Type.js'
import {Returns, ReturnType} from '#core/Type.js'
import {match} from '#core/TypePatterns.js'
import {ReturnsAs} from '#generic/helpers.js'
@ -19,6 +19,201 @@ export const abs = promoteUnary('abs')
export const add = promoteBinary('add')
export const dotMultiply = promoteBinary('multiply')
export const invert = match(Vector, (math, V, strategy) => {
if (V.vectorDepth > 2) {
throw new TypeError(
'invert not implemented for arrays of dimension > 2')
}
const normsq = math.normsq.resolve(V, strategy)
const NormT = ReturnType(normsq)
const zNorm = math.zero(NormT)
const isRealZ = math.isZero.resolve(NormT, strategy)
if (V.vectorDepth === 1) {
const invNorm = math.invert.resolve(NormT, strategy)
const scalarMult = math.multiply.resolve(
[V, ReturnType(invNorm)], strategy)
return ReturnsAs(scalarMult, v => {
const nsq = normsq(v)
if (isRealZ(nsq)) return Array(v.length).fill(zNorm)
return scalarMult(v, invNorm(nsq))
})
}
// usual matrix situation, want to find a matrix whose product with v
// is the identity, or as close as we can get to that if the rank is
// deficient. We use the Moore-Penrose pseudoinverse.
const clone = math.clone.resolve(V, strategy)
const VComp = V.Component
const Elt = VComp.Component
const invElt = math.invert.resolve(Elt, strategy)
const det = math.determinant.resolve(V, strategy)
const neg = math.negate.resolve(Elt, strategy)
const multMM = math.multiply.resolve([V, V], strategy)
const multMS = math.multiply.resolve([V, ReturnType(invElt)], strategy)
const multVS = math.multiply.resolve([VComp, ReturnType(invElt)], strategy)
const multSS = math.multiply.resolve([Elt, Elt], strategy)
const sub = math.subtract.resolve([Elt, Elt], strategy)
const id = math.identity.resolve(V, strategy)
const abs = math.abs.resolve(Elt, strategy)
const gt = math.larger.resolve([NormT, NormT], strategy)
const isEltZ = math.isZero.resolve(Elt, strategy)
const zElt = math.zero(Elt)
const adj = math.adjoint.resolve(V, strategy)
const methods = {
invElt, det, isRealZ, neg, multMM, multMS, multVS, multSS,
id, abs, isEltZ, zElt, gt, sub, adj, clone
}
if (ReturnType(abs) !== NormT) {
throw new TypeError('type inconsistency in matrix invert')
}
return Returns(V, m => {
const rows = m.length
const cols = m[0].length
const nsq = normsq(m)
if (isRealZ(nsq)) { // all-zero matrix
const retval = []
for (let ix = 0; ix < cols; ++ix) {
retval.push(Array(rows).fill(zNorm))
}
return retval
}
if (rows == cols) {
// the inv helper will return falsy if not invertible
const retval = inv(m, rows, methods)
if (retval) return retval
}
return pinv(m, rows, cols, methods)
})
})
// Returns the inverse of a rows×rows matrix or false if not invertible
// Note: destroys m in the inversion process.
function inv(
origm,
rows,
{
invElt, det, isRealZ, neg, multMS, multVS, multSS,
id, abs, isEltZ, zElt, gt, sub, clone
}
) {
switch (rows) {
case 1: return [[invElt(origm[0][0])]]
case 2: {
const dt = det(origm)
if (isRealZ(dt)) return false
const divisor = invElt(dt)
const [[a, b], [c, d]] = origm
return multMS([[d, neg(b)], [neg(c), a]], divisor)
}
default: { // Gauss-Jordan elimination
const m = clone(origm)
const B = id(m)
const cols = rows
// Loop over columns, performing row reductions:
for (let c = 0; c < cols; ++c) {
// Pivot: Find row r that has the largest entry in column c, and
// swap row c and r:
let colMax = abs(m[c][c])
let rMax = c
for (let r = c + 1; r < rows; ++r) {
const mag = abs(m[r][c])
if (gt(mag, colMax)) {
colMax = mag
rMax = r
}
}
if (isRealZ(colMax)) return false
if (rMax !== c) {
[m[c], m[rMax], B[c], B[rMax]]
= [m[rMax], m[c], B[rMax], B[c]]
}
// Normalize the cth row:
const normalizer = invElt(m[c][c])
const mc = multVS(m[c], normalizer)
m[c] = mc
const Bc = multVS(B[c], normalizer)
B[c] = Bc
// Eliminate nonzero values on other rows at column c
for (let r = 0; r < rows; ++r) {
if (r === c) continue
const mr = m[r]
const Br = B[r]
const mrc = mr[c]
if (!isEltZ(mr[c])) {
// Subtract Arc times row c from row r to eliminate A[r][c]
mr[c] = zElt
for (let s = c + 1; s < cols; ++s) {
mr[s] = sub(mr[s], multSS(mrc, mc[s]))
}
for (let s = 0; s < cols; ++s) {
Br[s] = sub(Br[s], multSS(mrc, Bc[s]))
}
}
}
}
return B
}}
}
// Calculates Moore-Penrose pseudoinverse
// uses rank factorization per mathjs; SVD appears to be considered better
// but not worth the effort to implement for this prototype
function pinv(m, rows, cols, methods) {
const {C, F} = rankFactorization(m, rows, cols, methods)
const {multMM, adj} = methods
const Cstar = adj(C)
const Cpinv = multMM(inv(multMM(Cstar, C), Cstar.length, methods), Cstar)
const Fstar = adj(F)
const Fpinv = multMM(Fstar, inv(multMM(F, Fstar), F.length, methods))
return multMM(Fpinv, Cpinv)
}
// warning: destroys m in computing the row-reduced echelon form
// TODO: this code should be merged with inv to the extent possible. It's
// a very similar process.
function rref(origm, rows, cols, methods) {
const {isEltZ, invElt, multVS, zElt, sub, multSS, clone} = methods
const m = clone(origm)
let lead = -1
for (let r = 0; r < rows && ++lead < cols; ++r) {
if (cols <= lead) return m
let i = r
while (isEltZ(m[i][lead])) {
if (++i === rows) {
i = r
if (++lead === cols) return m
}
}
if (i !== r) [m[i], m[r]] = [m[r], m[i]]
let normalizer = invElt(m[r][lead])
const mr = multVS(m[r], normalizer)
m[r] = mr
for (let i = 0; i < rows; ++i) {
if (i === r) continue
const mi = m[i]
const toRemove = mi[lead]
mi[lead] = zElt
for (let j = lead + 1; j < cols; ++j) {
mi[j] = sub(mi[j], multSS(toRemove, mr[j]))
}
}
}
return m
}
function rankFactorization(m, rows, cols, methods) {
const RREF = rref(m, rows, cols, methods)
const {isEltZ} = methods
const rankRows = RREF.map(row => row.some(elt => !isEltZ(elt)))
const C = m.map(row => row.filter((_, j) => j < rows && rankRows[j]))
const F = RREF.filter((_, i) => rankRows[i])
return {C, F}
}
export const multiply = [
distributeFirst('multiply'),
distributeSecond('multiply'),

View file

@ -1,5 +1,5 @@
import {Vector} from './Vector.js'
import {Returns, Unknown} from '#core/Type.js'
import {OneOf, Returns, ReturnType, Unknown} from '#core/Type.js'
import {Any, Multiple, match} from '#core/TypePatterns.js'
export const vector = match(Multiple(Any), (math, [TV]) => {
@ -9,16 +9,125 @@ export const vector = match(Multiple(Any), (math, [TV]) => {
return Returns(Vector(CompType), v => v)
})
export const transpose = match(Vector, (_math, V) => {
const wrapV = V.vectorDepth === 1
const Mat = wrapV ? Vector(V) : V
return Returns(Mat, v => {
if (wrapV) v = [v]
export const determinant = match(Vector(Vector), (math, M, strategy) => {
const Elt = M.Component.Component
const cloneElt = math.clone.resolve(Elt, strategy)
const mult = math.multiply.resolve([Elt, Elt], strategy)
const sub = math.subtract.resolve(
[ReturnType(mult), ReturnType(mult)], strategy)
const isZ = math.isZero.resolve(Elt, strategy)
const zElt = math.zero(Elt)
const clone = math.clone.resolve(M, strategy)
const div = math.divide.resolve([ReturnType(sub), Elt], strategy)
const neg = math.negate.resolve(Elt, strategy)
return Returns(OneOf(ReturnType(clone), ReturnType(sub), Elt), origm => {
const rows = origm.length
switch (rows) {
case 1: return cloneElt(origm[0][0])
case 2: {
const [[a, b], [c, d]] = origm
return sub(mult(a, d), mult(b, c))
}
default: { // Bareiss algorithm
const m = clone(origm)
let negated = false
const rowIndices = [...Array(rows).keys()] // track row indices
// because the algorithm may swap rows
for (let k = 0; k < rows; ++k) {
let k_ = rowIndices[k]
if (isZ(m[k_][k])) {
let _k = k
while (++_k < rows) {
if (!isZ(m[rowIndices[_k]][k])) {
k_ = rowIndices[_k]
rowIndices[_k] = rowIndices[k]
rowIndices[k] = k_
negated = !negated
break
}
}
if (_k === rows) return zElt
}
const piv = m[k_][k] // we now know nonzero
const piv_ = k === 0 ? 1 : m[rowIndices[k-1]][k-1]
for (let i = k + 1; i < rows; ++i) {
const i_ = rowIndices[i]
for (let j = k + 1; j < rows; ++j) {
m[i_][j] = div(
sub(mult(m[i_][j], piv), mult(m[i_][k], m[k_][j])),
piv_)
}
}
}
const det = m[rowIndices[rows - 1]][rows - 1]
return negated ? neg(det) : det
}}
})
})
function identitizer(cols, zero, one) {
const retval = []
for (let ix = 0; ix < cols; ++ix) {
const row = Array(cols).fill(zero)
row[ix] = one
retval.push(row)
}
return retval
}
export const identity = [
match(Any, (math, V) => {
const toNum = math.number.resolve(V)
const zero = math.zero(V)
const one = math.one(V)
return Returns(Vector(Vector(V)), n => identitizer(toNum(n), zero, one))
}),
match(Vector, (math, V) => {
switch (V.vectorDepth) {
case 1: {
const Elt = V.Component
const one = math.one(Elt)
return Returns(math.typeOf(one), () => one)
}
case 2: {
const Elt = V.Component.Component
const zero = math.zero(Elt)
const one = math.one(Elt)
return Returns(V, m => identitizer(
m.length ? m[0].length : 0, zero, one))
}
default:
throw new RangeError(
`'identity' not implemented on ${V.vectorDepth} dimensional arrays`)
}
})
]
// transposes a 2D matrix
function transposer(wrap, eltFun) {
return v => {
if (wrap) v = [v]
const cols = v.length ? v[0].length : 0
const retval = []
for (let ix = 0; ix < cols; ++ix) {
retval.push(v.map(row => row[ix]))
retval.push(v.map(row => eltFun(row[ix])))
}
return retval
})
}
}
export const transpose = match(Vector, (_math, V) => {
const wrapV = V.vectorDepth === 1
const Mat = wrapV ? Vector(V) : V
return Returns(Mat, transposer(wrapV, elt => elt))
})
// or with conjugation:
export const adjoint = match(Vector, (math, V, strategy) => {
const wrapV = V.vectorDepth === 1
const VComp = V.Component
const Elt = wrapV ? VComp : VComp.Component
const conj = math.conj.resolve(Elt, strategy)
const Mat = Vector(Vector(ReturnType(conj)))
return Returns(Mat, transposer(wrapV, conj))
})