diff --git a/README.md b/README.md index 0af64f0..662a5eb 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ var error = Matrix.sub(B, A.mmul(x)); // The error enables to evaluate the solut ``` #### Decompositions +`QrDecomposition` along with `LuDecomposition` need a matrix with at least as many rows as columns. A matrix carrying more columns than rows is rejected with a `RangeError`. + ##### QR Decomposition ```js var A = new Matrix([ diff --git a/matrix.d.ts b/matrix.d.ts index c6f6153..ddccbb0 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1570,6 +1570,9 @@ export class CholeskyDecomposition { export { CholeskyDecomposition as CHO }; /** + * The LU decomposition of a matrix with at least as many rows as columns. + * A matrix carrying more columns than rows is rejected with a `RangeError`. + * * @link https://github.com/lutzroeder/Mapack/blob/master/Source/LuDecomposition.cs */ export class LuDecomposition { @@ -1585,6 +1588,9 @@ export class LuDecomposition { export { LuDecomposition as LU }; /** + * The QR decomposition of a matrix with at least as many rows as columns. + * A matrix carrying more columns than rows is rejected with a `RangeError`. + * * @link https://github.com/lutzroeder/Mapack/blob/master/Source/QrDecomposition.cs */ export class QrDecomposition { diff --git a/src/__tests__/decompositions/lu.test.js b/src/__tests__/decompositions/lu.test.js index 39b2298..ba525ec 100644 --- a/src/__tests__/decompositions/lu.test.js +++ b/src/__tests__/decompositions/lu.test.js @@ -45,6 +45,15 @@ describe('LU decomposition', () => { new LU([ [0, 1, 2], [0, 1, 2], + ]), + ).toThrow('Matrix must have at least as many rows as columns'); + + expect( + () => + new LU([ + [0, 1], + [0, 1], + [0, 1], ]).determinant, ).toThrow('Matrix must be square'); }); diff --git a/src/__tests__/decompositions/shape.test.js b/src/__tests__/decompositions/shape.test.js new file mode 100644 index 0000000..1b98ee2 --- /dev/null +++ b/src/__tests__/decompositions/shape.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; + +import { Matrix, LuDecomposition, QrDecomposition, solve } from '../..'; + +const message = /^Matrix must have at least as many rows as columns$/; + +describe('LU and QR need at least as many rows as columns', () => { + const wide = new Matrix([ + [1, 2, 3], + [4, 5, 6], + ]); + + it('LU rejects a wide matrix', () => { + expect(() => new LuDecomposition(wide)).toThrow(message); + }); + + it('QR rejects a wide matrix', () => { + expect(() => new QrDecomposition(wide)).toThrow(message); + }); + + it('LU rejects a wide 2D array', () => { + expect(() => new LuDecomposition([[1, 2, 3, 4]])).toThrow(message); + }); + + it('QR rejects a wide 2D array', () => { + expect(() => new QrDecomposition([[1, 2, 3, 4]])).toThrow(message); + }); + + it('solve reports the shape rather than a rank problem', () => { + expect(() => solve(wide, Matrix.columnVector([1, 2]))).toThrow(message); + }); +}); + +describe('LU and QR keep working on the supported shapes', () => { + const tall = new Matrix([ + [1, 2], + [3, 4], + [5, 6], + ]); + const square = new Matrix([ + [4, 3], + [6, 3], + ]); + + it('QR on a tall matrix rebuilds the input', () => { + const qr = new QrDecomposition(tall); + const product = qr.orthogonalMatrix.mmul(qr.upperTriangularMatrix); + for (let i = 0; i < tall.rows; i++) { + for (let j = 0; j < tall.columns; j++) { + expect(product.get(i, j)).toBeCloseTo(tall.get(i, j), 10); + } + } + }); + + it('LU on a tall matrix reports a usable triangular pair', () => { + const lu = new LuDecomposition(tall); + expect(lu.lowerTriangularMatrix.rows).toBe(3); + expect(lu.upperTriangularMatrix.columns).toBe(2); + }); + + it('LU on a square matrix stays solvable', () => { + const lu = new LuDecomposition(square); + expect(lu.isSingular()).toBe(false); + const x = lu.solve(Matrix.columnVector([10, 12])); + const back = square.mmul(x); + expect(back.get(0, 0)).toBeCloseTo(10, 10); + expect(back.get(1, 0)).toBeCloseTo(12, 10); + }); + + it('an empty matrix is still accepted', () => { + expect(() => new LuDecomposition(new Matrix(0, 0))).not.toThrow(); + expect(() => new QrDecomposition(new Matrix(0, 0))).not.toThrow(); + }); +}); diff --git a/src/dc/lu.js b/src/dc/lu.js index 68b12b0..227cada 100644 --- a/src/dc/lu.js +++ b/src/dc/lu.js @@ -4,6 +4,9 @@ import WrapperMatrix2D from '../wrap/WrapperMatrix2D'; export default class LuDecomposition { constructor(matrix) { matrix = WrapperMatrix2D.checkMatrix(matrix); + if (matrix.rows < matrix.columns) { + throw new RangeError('Matrix must have at least as many rows as columns'); + } let lu = matrix.clone(); let rows = lu.rows; diff --git a/src/dc/qr.js b/src/dc/qr.js index ffce782..f3d7425 100644 --- a/src/dc/qr.js +++ b/src/dc/qr.js @@ -6,6 +6,9 @@ import { hypotenuse } from './util'; export default class QrDecomposition { constructor(value) { value = WrapperMatrix2D.checkMatrix(value); + if (value.rows < value.columns) { + throw new RangeError('Matrix must have at least as many rows as columns'); + } let qr = value.clone(); let m = value.rows;