From 211de6e0880720033862f94a9629e48ae1787109 Mon Sep 17 00:00:00 2001 From: Karl Holub Date: Mon, 4 Jan 2021 12:51:42 -0600 Subject: [PATCH] feat: add support for empty matrices (#116) --- matrix.d.ts | 5 + src/__tests__/decompositions/cholesky.js | 4 + src/__tests__/decompositions/evd.js | 5 + src/__tests__/decompositions/lu.js | 9 ++ src/__tests__/decompositions/qr.js | 8 ++ src/__tests__/decompositions/svd.js | 7 ++ src/__tests__/matrix/creation.js | 26 ++-- src/__tests__/matrix/flip.js | 15 +++ src/__tests__/matrix/kronecker.js | 19 +++ src/__tests__/matrix/mean.js | 14 +++ src/__tests__/matrix/minMax.js | 111 ++++++++++++++++- src/__tests__/matrix/product.js | 20 +++ src/__tests__/matrix/sum.js | 25 ++++ src/__tests__/matrix/utility.js | 149 +++++++++++++++++++++-- src/__tests__/stat/center.js | 45 +++++++ src/__tests__/stat/correlation.js | 12 ++ src/__tests__/stat/covariance.js | 7 ++ src/__tests__/stat/scale.js | 32 +++++ src/dc/evd.js | 4 + src/dc/svd.js | 4 + src/determinant.js | 4 + src/matrix.js | 64 +++++++--- src/pseudoInverse.js | 6 + src/util.js | 6 + 24 files changed, 568 insertions(+), 33 deletions(-) diff --git a/matrix.d.ts b/matrix.d.ts index aac305c4..795b41bb 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -304,6 +304,11 @@ export abstract class AbstractMatrix { */ isSquare(): boolean; + /** + * Returns whether the number of rows or columns (or both) is zero. + */ + isEmpty(): boolean; + /** * Returns whether the matrix is square and has the same values on both sides of the diagonal. */ diff --git a/src/__tests__/decompositions/cholesky.js b/src/__tests__/decompositions/cholesky.js index 0657f42c..83d27816 100644 --- a/src/__tests__/decompositions/cholesky.js +++ b/src/__tests__/decompositions/cholesky.js @@ -42,6 +42,10 @@ describe('Cholesky decomposition', () => { expect(choAtA.isPositiveDefinite()).toStrictEqual(false); expect(() => choAtA.solve(b)).toThrow('Matrix is not positive definite'); }); + it('should handle empty matrices', () => { + const decomp = new CHO([]); + expect(decomp.lowerTriangularMatrix.to2DArray()).toStrictEqual([]); + }); }); function checkTriangular(matrix) { diff --git a/src/__tests__/decompositions/evd.js b/src/__tests__/decompositions/evd.js index df4ac33e..e96a2e2d 100644 --- a/src/__tests__/decompositions/evd.js +++ b/src/__tests__/decompositions/evd.js @@ -13,4 +13,9 @@ describe('Eigenvalue decomposition', () => { [0, 3], ]); }); + + it('empty matrix', () => { + const matrix = new Matrix([]); + expect(() => new EVD(matrix)).toThrow('Matrix must be non-empty'); + }); }); diff --git a/src/__tests__/decompositions/lu.js b/src/__tests__/decompositions/lu.js index 033c563e..35a59b62 100644 --- a/src/__tests__/decompositions/lu.js +++ b/src/__tests__/decompositions/lu.js @@ -46,6 +46,15 @@ describe('LU decomposition', () => { ]).determinant, ).toThrow('Matrix must be square'); }); + + it('should handle empty matrices', () => { + const matrix = new Matrix([]); + const decomp = new LU(matrix); + expect(decomp.lowerTriangularMatrix.to2DArray()).toStrictEqual([]); + expect(decomp.upperTriangularMatrix.to2DArray()).toStrictEqual([]); + // https://en.wikipedia.org/wiki/Matrix_(mathematics)#Empty_matrices + expect(decomp.determinant).toStrictEqual(1); + }); }); function checkTriangular(matrix) { diff --git a/src/__tests__/decompositions/qr.js b/src/__tests__/decompositions/qr.js index c44a985a..8d75964d 100644 --- a/src/__tests__/decompositions/qr.js +++ b/src/__tests__/decompositions/qr.js @@ -63,4 +63,12 @@ describe('Qr decomposition', () => { const qR = Q.mmul(R); expect(qR.to2DArray()).toBeDeepCloseTo(A, 4); }); + + it('should work with empty matrices', () => { + const matrix = new Matrix([]); + const decomp = new QR(matrix); + expect(decomp.upperTriangularMatrix.to2DArray()).toStrictEqual([]); + expect(decomp.orthogonalMatrix.to2DArray()).toStrictEqual([]); + expect(decomp.isFullRank()).toStrictEqual(true); + }); }); diff --git a/src/__tests__/decompositions/svd.js b/src/__tests__/decompositions/svd.js index 06735f13..801f7d6f 100644 --- a/src/__tests__/decompositions/svd.js +++ b/src/__tests__/decompositions/svd.js @@ -334,4 +334,11 @@ describe('Singular value decomposition', () => { expect(actual).toBeDeepCloseTo(output, 8); }); }); + + describe('empty matrix', () => { + it('should throw for an empty matrix', () => { + const matrix = new Matrix([]); + expect(() => new SVD(matrix)).toThrow('Matrix must be non-empty'); + }); + }); }); diff --git a/src/__tests__/matrix/creation.js b/src/__tests__/matrix/creation.js index d75c1848..3d70531c 100644 --- a/src/__tests__/matrix/creation.js +++ b/src/__tests__/matrix/creation.js @@ -18,23 +18,29 @@ describe('Matrix creation', () => { expect(matrix).toStrictEqual(original); }); - it('should create an empty matrix', () => { + it('should create a zero matrix', () => { let matrix = new Matrix(3, 9); expect(matrix.rows).toBe(3); expect(matrix.columns).toBe(9); expect(matrix.get(0, 0)).toBe(0); }); + it('should create an empty matrix', () => { + const matrix00 = new Matrix(0, 0); + expect(matrix00.rows).toBe(0); + expect(matrix00.columns).toBe(0); + const matrix01 = new Matrix(0, 1); + expect(matrix01.rows).toBe(0); + expect(matrix01.columns).toBe(1); + const matrix00FromArray = new Matrix([[]]); + expect(matrix00FromArray.rows).toBe(1); + expect(matrix00FromArray.columns).toBe(0); + }); + it('should throw with wrong arguments', () => { expect(() => new Matrix(6, -1)).toThrow( /^nColumns must be a positive integer/, ); - expect(() => new Matrix(0, 0)).toThrow( - /^First argument must be a positive number or an array$/, - ); - expect(() => new Matrix([[]])).toThrow( - /^Data must be a 2D array with at least one element$/, - ); expect(() => new Matrix([0, 1, 2, 3])).toThrow(/^Data must be a 2D array/); expect( () => @@ -137,6 +143,9 @@ describe('Matrix creation', () => { [0, 1], [0, 0], ]); + + let eye0 = Matrix.eye(0, 0); + expect(eye0.to2DArray()).toStrictEqual([]); }); it('eye with other value than 1', () => { @@ -172,6 +181,9 @@ describe('Matrix creation', () => { [0, 0, 3, 0], [0, 0, 0, 0], ]); + expect(Matrix.diag([], 0, 0).to2DArray()).toStrictEqual([]); + expect(Matrix.diag([[]], 1, 0).to2DArray()).toStrictEqual([[]]); + expect(Matrix.diag([], 0, 2).to2DArray()).toStrictEqual([]); }); it('views should return new instances of Matrix', () => { diff --git a/src/__tests__/matrix/flip.js b/src/__tests__/matrix/flip.js index 3f393d32..087fd1ea 100644 --- a/src/__tests__/matrix/flip.js +++ b/src/__tests__/matrix/flip.js @@ -13,6 +13,13 @@ test('flip rows', () => { ]); }); +test('flip rows of 0 row matrix', () => { + const matrix = new Matrix([]); + const result = matrix.flipRows(); + expect(result).toBe(matrix); + expect(result.to2DArray()).toStrictEqual([]); +}); + test('flip columns', () => { const matrix = new Matrix([ [1, 2, 3], @@ -25,3 +32,11 @@ test('flip columns', () => { [1, 2, 3], ]); }); + +test('flip columns of 0 row matrix', () => { + const matrix = new Matrix(0, 5); + const result = matrix.flipColumns(); + expect(result).toBe(matrix); + expect(result.rows).toBe(0); + expect(result.columns).toBe(5); +}); diff --git a/src/__tests__/matrix/kronecker.js b/src/__tests__/matrix/kronecker.js index 43868085..400730ce 100644 --- a/src/__tests__/matrix/kronecker.js +++ b/src/__tests__/matrix/kronecker.js @@ -18,4 +18,23 @@ describe('Kronecker product', () => { [18, 21, 24, 28], ]); }); + + it('should compute on empty matrices', () => { + const matrix1 = new Matrix([[]]); + const matrix2 = new Matrix(0, 3); + const matrix3 = new Matrix([ + [0, 5], + [6, 7], + ]); + const product12 = matrix1.kroneckerProduct(matrix2); + const product13 = matrix1.kroneckerProduct(matrix3); + const product23 = matrix2.kroneckerProduct(matrix3); + + expect(product12.rows).toBe(0); + expect(product12.columns).toBe(0); + expect(product13.rows).toBe(2); + expect(product13.columns).toBe(0); + expect(product23.rows).toBe(0); + expect(product23.columns).toBe(6); + }); }); diff --git a/src/__tests__/matrix/mean.js b/src/__tests__/matrix/mean.js index d66f1c64..fd5d6977 100644 --- a/src/__tests__/matrix/mean.js +++ b/src/__tests__/matrix/mean.js @@ -5,6 +5,8 @@ describe('mean by row and columns', () => { [1, 2, 3], [4, 5, 6], ]); + const zeroRowMatrix = new Matrix(0, 2); + const zeroColumnMatrix = new Matrix(1, 0); it('mean by row', () => { expect(matrix.mean('row')).toStrictEqual([2, 5]); }); @@ -16,4 +18,16 @@ describe('mean by row and columns', () => { it('mean all', () => { expect(matrix.mean()).toBe(3.5); }); + + it('means of 0 row matrix', () => { + expect(zeroRowMatrix.mean('row')).toStrictEqual([]); + expect(zeroRowMatrix.mean('column')).toStrictEqual([NaN, NaN]); + expect(zeroRowMatrix.mean()).toStrictEqual(NaN); + }); + + it('means of 0 column matrix', () => { + expect(zeroColumnMatrix.mean('row')).toStrictEqual([NaN]); + expect(zeroColumnMatrix.mean('column')).toStrictEqual([]); + expect(zeroColumnMatrix.mean()).toStrictEqual(NaN); + }); }); diff --git a/src/__tests__/matrix/minMax.js b/src/__tests__/matrix/minMax.js index 0d048a01..99c4fbcd 100644 --- a/src/__tests__/matrix/minMax.js +++ b/src/__tests__/matrix/minMax.js @@ -1,6 +1,7 @@ import { Matrix } from '../..'; +import { getSquareMatrix } from '../../../testUtils'; -describe('min - max', () => { +describe('elementwise min - max', () => { const matrix1 = new Matrix([ [0, 1, 2], [3, 4, 5], @@ -10,6 +11,9 @@ describe('min - max', () => { [-6, 2, 12], ]); + const empty1 = new Matrix(2, 0); + const empty2 = new Matrix(2, 0); + it('min', () => { expect(Matrix.min(matrix1, matrix2).to2DArray()).toStrictEqual([ [0, 0, 2], @@ -23,4 +27,109 @@ describe('min - max', () => { [3, 4, 12], ]); }); + + it('empty matrix max', () => { + expect(Matrix.max(empty1, empty2).to2DArray()).toStrictEqual([[], []]); + }); + + it('empty matrix min', () => { + expect(Matrix.min(empty1, empty2).to2DArray()).toStrictEqual([[], []]); + }); +}); + +describe('matrix min/max', () => { + it('empty matrix', () => { + const emptyMatrix = new Matrix(0, 3); + const min = emptyMatrix.min(); + const max = emptyMatrix.max(); + + expect(min).toBe(NaN); + expect(max).toBe(NaN); + + expect(() => emptyMatrix.maxIndex()).toThrow( + 'Empty matrix has no elements to index', + ); + expect(() => emptyMatrix.minIndex()).toThrow( + 'Empty matrix has no elements to index', + ); + }); + + it('3x2 matrix', () => { + const mat = new Matrix([ + [1, 2], + [7, 3], + [-1, 5], + ]); + const min = mat.min(); + const max = mat.max(); + const minIndex = mat.minIndex(); + const maxIndex = mat.maxIndex(); + expect(min).toBe(-1); + expect(max).toBe(7); + expect(minIndex).toStrictEqual([2, 0]); + expect(maxIndex).toStrictEqual([1, 0]); + }); +}); + +describe('vector min/max', () => { + const emptyMatrix = new Matrix(0, 0); + const zeroRowMatrix = new Matrix(0, 2); + const zeroColumnMatrix = new Matrix(3, 0); + const squareMatrix = getSquareMatrix(); + + it('maxRowIndex', () => { + expect(() => emptyMatrix.maxRowIndex(0)).toThrow('Row index out of range'); + expect(() => zeroColumnMatrix.maxRowIndex(0)).toThrow( + 'Empty matrix has no elements to index', + ); + expect(squareMatrix.maxRowIndex(0)).toStrictEqual([0, 1]); + }); + + it('minRowIndex', () => { + expect(() => emptyMatrix.minRowIndex(0)).toThrow('Row index out of range'); + expect(() => zeroColumnMatrix.minRowIndex(0)).toThrow( + 'Empty matrix has no elements to index', + ); + expect(squareMatrix.minRowIndex(0)).toStrictEqual([0, 2]); + }); + + it('maxColumnIndex', () => { + expect(() => emptyMatrix.maxColumnIndex(0)).toThrow( + 'Column index out of range', + ); + expect(() => zeroRowMatrix.maxColumnIndex(0)).toThrow( + 'Empty matrix has no elements to index', + ); + expect(squareMatrix.maxColumnIndex(2)).toStrictEqual([1, 2]); + }); + + it('minColumnIndex', () => { + expect(() => emptyMatrix.minColumnIndex(0)).toThrow( + 'Column index out of range', + ); + expect(() => zeroRowMatrix.minColumnIndex(0)).toThrow( + 'Empty matrix has no elements to index', + ); + expect(squareMatrix.minColumnIndex(2)).toStrictEqual([2, 2]); + }); + + it('maxRow', () => { + expect(zeroColumnMatrix.maxRow(0)).toBe(NaN); + expect(squareMatrix.maxRow(1)).toBe(11); + }); + + it('minRow', () => { + expect(zeroColumnMatrix.minRow(0)).toBe(NaN); + expect(squareMatrix.minRow(1)).toBe(1); + }); + + it('maxColumn', () => { + expect(zeroRowMatrix.maxColumn(0)).toBe(NaN); + expect(squareMatrix.maxColumn(0)).toBe(9); + }); + + it('minColumn', () => { + expect(zeroRowMatrix.minColumn(0)).toBe(NaN); + expect(squareMatrix.minColumn(0)).toBe(1); + }); }); diff --git a/src/__tests__/matrix/product.js b/src/__tests__/matrix/product.js index 34305895..c9c599db 100644 --- a/src/__tests__/matrix/product.js +++ b/src/__tests__/matrix/product.js @@ -5,6 +5,10 @@ describe('product by row and columns', () => { [1, 2, 3], [4, 5, 6], ]); + const emptyMatrix = new Matrix(0, 0); + const zeroRowMatrix = new Matrix(0, 2); + const zeroColumnMatrix = new Matrix(3, 0); + it('product by row', () => { expect(matrix.product('row')).toStrictEqual([6, 120]); }); @@ -16,4 +20,20 @@ describe('product by row and columns', () => { it('product all', () => { expect(matrix.product()).toBe(720); }); + + it('product by row of empty matrix', () => { + expect(emptyMatrix.product('row')).toStrictEqual([]); + }); + + it('product by column of empty matrix', () => { + expect(emptyMatrix.product('column')).toStrictEqual([]); + }); + + it('product by column of 0 row matrix', () => { + expect(zeroRowMatrix.product('column')).toStrictEqual([1, 1]); + }); + + it('product by row of 0 column matrix', () => { + expect(zeroColumnMatrix.product('row')).toStrictEqual([1, 1, 1]); + }); }); diff --git a/src/__tests__/matrix/sum.js b/src/__tests__/matrix/sum.js index 86101941..f6d29a5b 100644 --- a/src/__tests__/matrix/sum.js +++ b/src/__tests__/matrix/sum.js @@ -5,6 +5,10 @@ describe('sum by row and columns', () => { [1, 2, 3], [4, 5, 6], ]); + const emptyMatrix = new Matrix(0, 0); + const zeroRowMatrix = new Matrix(0, 2); + const zeroColumnMatrix = new Matrix(3, 0); + it('sum by row', () => { expect(matrix.sum('row')).toStrictEqual([6, 15]); }); @@ -16,4 +20,25 @@ describe('sum by row and columns', () => { it('sum all', () => { expect(matrix.sum()).toBe(21); }); + + it('sum by row of 0x0 matrix', () => { + expect(emptyMatrix.sum('row')).toStrictEqual([]); + }); + + it('sum by column of 0x0 matrix', () => { + expect(emptyMatrix.sum('column')).toStrictEqual([]); + }); + + it('sum all of 0x0 matrix', () => { + expect(emptyMatrix.sum()).toStrictEqual(0); + }); + + /* these correspond to the empty sum: https://en.wikipedia.org/wiki/Empty_sum */ + it('sum by column of 0 row matrix', () => { + expect(zeroRowMatrix.sum('column')).toStrictEqual([0, 0]); + }); + + it('sum by row of 0 column matrix', () => { + expect(zeroColumnMatrix.sum('row')).toStrictEqual([0, 0, 0]); + }); }); diff --git a/src/__tests__/matrix/utility.js b/src/__tests__/matrix/utility.js index fece79b0..883c1de8 100644 --- a/src/__tests__/matrix/utility.js +++ b/src/__tests__/matrix/utility.js @@ -3,8 +3,12 @@ import * as util from '../../../testUtils'; describe('utility methods', () => { let squareMatrix; + let zeroColumnMatrix; + let zeroRowMatrix; beforeEach(() => { squareMatrix = util.getSquareMatrix(); + zeroColumnMatrix = new Matrix(3, 0); + zeroRowMatrix = new Matrix(0, 2); }); it('isMatrix', () => { @@ -30,6 +34,8 @@ describe('utility methods', () => { it('size', () => { expect(new Matrix(3, 4).size).toBe(12); expect(new Matrix(5, 5).size).toBe(25); + expect(zeroRowMatrix.size).toBe(0); + expect(zeroColumnMatrix.size).toBe(0); }); it('apply', () => { @@ -50,6 +56,10 @@ describe('utility methods', () => { matrix.apply(cb); expect(matrix.get(5, 4)).toBe(20); expect(called).toBe(30); + + called = 0; + zeroRowMatrix.apply(cb); + expect(called).toBe(0); }); it('should throw if apply is called without a callback', () => { @@ -81,6 +91,9 @@ describe('utility methods', () => { expect(array).toStrictEqual([9, 13, 5, 1, 11, 7, 2, 6, 3]); expect(array).not.toBeInstanceOf(Matrix); expect(Matrix.isMatrix(array)).toBe(false); + + expect(zeroRowMatrix.to1DArray()).toStrictEqual([]); + expect(zeroColumnMatrix.to1DArray()).toStrictEqual([]); }); it('to2DArray', () => { @@ -93,6 +106,9 @@ describe('utility methods', () => { expect(array[0]).toHaveLength(3); expect(array).not.toBeInstanceOf(Matrix); expect(Matrix.isMatrix(array)).toBe(false); + + expect(zeroRowMatrix.to2DArray()).toStrictEqual([]); + expect(zeroColumnMatrix.to2DArray()).toStrictEqual([[], [], []]); }); it('transpose square', () => { @@ -108,6 +124,8 @@ describe('utility methods', () => { let subMatrix = squareMatrix.selection([1, 2], [1, 2]); det = determinant(subMatrix); expect(det).toBe(-9); + + expect(determinant(new Matrix(0, 0))).toBe(1); }); it('determinant n>3', () => { @@ -129,15 +147,8 @@ describe('utility methods', () => { [1, 1, 1], ]); expect(m1.norm()).toBeCloseTo(5.7445626465380286, 2); - }); - it('norm Frobenius 2', () => { - let m1 = new Matrix([ - [1, 1, 1], - [3, 3, 3], - [1, 1, 1], - ]); - expect(m1.norm('frobenius')).toBeCloseTo(5.7445626465380286, 2); + expect(new Matrix(0, 0).norm()).toBe(0); }); it('norm max', () => { @@ -147,6 +158,8 @@ describe('utility methods', () => { [1, 1, 1], ]); expect(m1.norm('max')).toBe(3); + + expect(new Matrix(0, 0).norm('max')).toBe(NaN); }); it('transpose rectangular', () => { @@ -160,6 +173,14 @@ describe('utility methods', () => { expect(transpose.get(2, 1)).toBe(matrix.get(1, 2)); expect(transpose.rows).toBe(3); expect(transpose.columns).toBe(2); + + const zeroColumnTranspose = zeroColumnMatrix.transpose(); + expect(zeroColumnTranspose.rows).toBe(0); + expect(zeroColumnTranspose.columns).toBe(3); + + const zeroRowTranspose = zeroRowMatrix.transpose(); + expect(zeroRowTranspose.rows).toBe(2); + expect(zeroRowTranspose.columns).toBe(0); }); it('scale rows', () => { @@ -179,6 +200,12 @@ describe('utility methods', () => { [-2, -3 / 2, -1], [-2, -1, -5 / 3], ]); + expect(zeroRowMatrix.scaleRows().to2DArray()).toStrictEqual([]); + expect(zeroColumnMatrix.scaleRows().to2DArray()).toStrictEqual([ + [], + [], + [], + ]); expect(() => matrix.scaleRows({ min: 2, max: 1 })).toThrow( /^min must be smaller than max$/, ); @@ -207,6 +234,13 @@ describe('utility methods', () => { [-1, -1], ], ); + expect(zeroRowMatrix.scaleColumns().to2DArray()).toStrictEqual([]); + expect(zeroColumnMatrix.scaleColumns().to2DArray()).toStrictEqual([ + [], + [], + [], + ]); + expect(() => matrix.scaleColumns({ min: 2, max: 1 })).toThrow( /^min must be smaller than max$/, ); @@ -228,6 +262,14 @@ describe('utility methods', () => { [1, 10], [2, 10], ]); + expect( + matrix.setSubMatrix(new Matrix(0, 0), 2, 0).to2DArray(), + ).toStrictEqual([ + [1, 2], + [1, 10], + [2, 10], + ]); + expect(() => matrix.setSubMatrix([[1, 2]], 1, 1)).toThrow(RangeError); }); @@ -260,6 +302,12 @@ describe('utility methods', () => { [1, 2, 1, 2], [3, 4, 3, 4], ]); + expect( + zeroRowMatrix.repeat({ columns: 2, rows: 2 }).to2DArray(), + ).toStrictEqual([]); + expect( + zeroColumnMatrix.repeat({ columns: 2, rows: 2 }).to2DArray(), + ).toStrictEqual([[], [], [], [], [], []]); }); it('mmul strassen', () => { @@ -277,6 +325,18 @@ describe('utility methods', () => { ]); }); + it('mmul strassen on empty matrices', () => { + // https://github.com/mljs/matrix/issues/114 + // while the mathematically correct result is 0x0, we assert a 2x2 padded result that the current implementation produces + // (this call is actually just delegated to standard multiplication in mmul()) + expect( + new Matrix(0, 2).mmulStrassen(new Matrix(2, 0)).to2DArray(), + ).toStrictEqual([ + [0, 0], + [0, 0], + ]); + }); + it('mmul 2x2 and 3x3', () => { let matrix = new Matrix([ [2, 4], @@ -365,6 +425,12 @@ describe('utility methods', () => { expect(result[1][1]).toBeCloseTo(0.19512195, 5); expect(result[2][0]).toBeCloseTo(0.24390244, 5); expect(result[2][1]).toBeCloseTo(0.07317073, 5); + + result = pseudoInverse(zeroColumnMatrix).to2DArray(); + expect(result).toStrictEqual([]); + + result = pseudoInverse(zeroRowMatrix).to2DArray(); + expect(result).toStrictEqual([[], []]); }); it('isEchelonForm', () => { @@ -482,6 +548,13 @@ describe('utility methods', () => { expect(m.isSymmetric()).toBe(false); }); + it('isEmpty', () => { + expect(new Matrix(0, 0).isEmpty()).toBe(true); + expect(new Matrix(0, 1).isEmpty()).toBe(true); + expect(new Matrix(1, 0).isEmpty()).toBe(true); + expect(new Matrix(1, 1).isEmpty()).toBe(false); + }); + it('neg', () => { let m = new Matrix([ [-1, 0, 2], @@ -492,5 +565,65 @@ describe('utility methods', () => { [1, -0, -2], [-3, 42, -4], ]); + + zeroColumnMatrix.neg(); + expect(zeroColumnMatrix.to2DArray()).toStrictEqual([[], [], []]); + }); + + it('dot product', () => { + expect(new Matrix([[1, 2, 3]]).dot(new Matrix([[3, 2, 1]]))).toStrictEqual( + 10, + ); + }); + + it('simple multiplication', () => { + const empty1 = new Matrix(3, 0); + const empty2 = new Matrix(0, 3); + const mat1 = new Matrix([ + [1, 2], + [3, 4], + [5, 6], + ]); + const mat2 = new Matrix([ + [6, 5, 4], + [3, 2, 1], + ]); + + expect(mat2.mmul(mat1).to2DArray()).toStrictEqual([ + [6 * 1 + 5 * 3 + 4 * 5, 6 * 2 + 5 * 4 + 4 * 6], + [3 * 1 + 2 * 3 + 1 * 5, 3 * 2 + 2 * 4 + 1 * 6], + ]); + expect(empty1.mmul(empty2).to2DArray()).toStrictEqual( + Matrix.zeros(3, 3).to2DArray(), + ); + + const emptyMult = mat2.mmul(empty1); + expect(emptyMult.rows).toStrictEqual(2); + expect(emptyMult.columns).toStrictEqual(0); + }); + + it('columns and rows modification', () => { + expect(zeroRowMatrix.removeColumn(1).columns).toStrictEqual(1); + expect(zeroColumnMatrix.removeRow(2).rows).toStrictEqual(2); + + expect(squareMatrix.removeColumn(0).to2DArray()).toStrictEqual([ + [13, 5], + [11, 7], + [6, 3], + ]); + expect(squareMatrix.removeRow(1).to2DArray()).toStrictEqual([ + [13, 5], + [6, 3], + ]); + expect(squareMatrix.addRow(0, [1, 11]).to2DArray()).toStrictEqual([ + [1, 11], + [13, 5], + [6, 3], + ]); + expect(squareMatrix.addColumn(2, [2, 22, 222]).to2DArray()).toStrictEqual([ + [1, 11, 2], + [13, 5, 22], + [6, 3, 222], + ]); }); }); diff --git a/src/__tests__/stat/center.js b/src/__tests__/stat/center.js index bc21d3a6..cc5b8e86 100644 --- a/src/__tests__/stat/center.js +++ b/src/__tests__/stat/center.js @@ -1,6 +1,15 @@ import { Matrix } from '../..'; describe('Centering matrix', () => { + let emptyMatrix; + let zeroRowMatrix; + let zeroColumnMatrix; + beforeEach(() => { + emptyMatrix = new Matrix(0, 0); + zeroRowMatrix = new Matrix(0, 3); + zeroColumnMatrix = new Matrix(2, 0); + }); + it('center should work for centering rows, columns and the whole matrix', () => { let x = new Matrix([ [1, 2, 3, 4, 5], @@ -35,4 +44,40 @@ describe('Centering matrix', () => { Array.from(x.clone().center('column').data[2].map(Math.round)), ).toStrictEqual([5, 5, 5, 5, 5]); }); + + it('should center by row for an empty matrix', () => { + emptyMatrix.center('row'); + expect(emptyMatrix.rows).toBe(0); + expect(emptyMatrix.columns).toBe(0); + zeroRowMatrix.center('row'); + expect(zeroRowMatrix.rows).toBe(0); + expect(zeroRowMatrix.columns).toBe(3); + zeroColumnMatrix.center('row'); + expect(zeroColumnMatrix.rows).toBe(2); + expect(zeroColumnMatrix.columns).toBe(0); + }); + + it('should center by column for an empty matrix', () => { + emptyMatrix.center('column'); + expect(emptyMatrix.rows).toBe(0); + expect(emptyMatrix.columns).toBe(0); + zeroRowMatrix.center('column'); + expect(zeroRowMatrix.rows).toBe(0); + expect(zeroRowMatrix.columns).toBe(3); + zeroColumnMatrix.center('column'); + expect(zeroColumnMatrix.rows).toBe(2); + expect(zeroColumnMatrix.columns).toBe(0); + }); + + it('should center for an empty matrix', () => { + emptyMatrix.center(); + expect(emptyMatrix.rows).toBe(0); + expect(emptyMatrix.columns).toBe(0); + zeroRowMatrix.center(); + expect(zeroRowMatrix.rows).toBe(0); + expect(zeroRowMatrix.columns).toBe(3); + zeroColumnMatrix.center(); + expect(zeroColumnMatrix.rows).toBe(2); + expect(zeroColumnMatrix.columns).toBe(0); + }); }); diff --git a/src/__tests__/stat/correlation.js b/src/__tests__/stat/correlation.js index 257e6566..0ec3153e 100644 --- a/src/__tests__/stat/correlation.js +++ b/src/__tests__/stat/correlation.js @@ -50,4 +50,16 @@ describe('multivariate linear regression', () => { expect(x.to1DArray()).toStrictEqual([1, 2, 3, 4, 3, 6, 7, 1, 9]); expect(y.to1DArray()).toStrictEqual([5, 2, 3, 4, 1, 6, 7, 1, 7]); }); + it('correlation should work on empty matrices', () => { + const x = new Matrix(0, 0); + const y = new Matrix(0, 3); + const z = new Matrix(3, 0); + expect(correlation(x).to2DArray()).toStrictEqual([]); + expect(correlation(y).to2DArray()).toStrictEqual([ + [NaN, NaN, NaN], + [NaN, NaN, NaN], + [NaN, NaN, NaN], + ]); + expect(correlation(z).to2DArray()).toStrictEqual([]); + }); }); diff --git a/src/__tests__/stat/covariance.js b/src/__tests__/stat/covariance.js index 5cab7598..4911d82d 100644 --- a/src/__tests__/stat/covariance.js +++ b/src/__tests__/stat/covariance.js @@ -56,4 +56,11 @@ describe('multivariate linear regression', () => { expect(x.to1DArray()).toStrictEqual([1, 2, 3, 4, 3, 6, 7, 1, 9]); expect(y.to1DArray()).toStrictEqual([5, 2, 3, 4, 1, 6, 7, 1, 7]); }); + + it('covariance should work on empty matrices', () => { + const x = new Matrix(0, 0); + const z = new Matrix(3, 0); + expect(covariance(x).to2DArray()).toStrictEqual([]); + expect(covariance(z).to2DArray()).toStrictEqual([]); + }); }); diff --git a/src/__tests__/stat/scale.js b/src/__tests__/stat/scale.js index 38544e65..f1e60005 100644 --- a/src/__tests__/stat/scale.js +++ b/src/__tests__/stat/scale.js @@ -1,6 +1,14 @@ import { Matrix } from '../..'; describe('scale matrix', () => { + let emptyMatrix; + let zeroRowMatrix; + let zeroColumnMatrix; + beforeEach(() => { + emptyMatrix = new Matrix(0, 0); + zeroRowMatrix = new Matrix(0, 3); + zeroColumnMatrix = new Matrix(2, 0); + }); it('should scale by row', () => { const y = new Matrix([ [1, 2, 3, 4, 5], @@ -19,6 +27,18 @@ describe('scale matrix', () => { ]); }); + it('should scale by row for an empty matrix', () => { + emptyMatrix.scale('row'); + expect(emptyMatrix.rows).toBe(0); + expect(emptyMatrix.columns).toBe(0); + zeroRowMatrix.scale('row'); + expect(zeroRowMatrix.rows).toBe(0); + expect(zeroRowMatrix.columns).toBe(3); + zeroColumnMatrix.scale('row'); + expect(zeroColumnMatrix.rows).toBe(2); + expect(zeroColumnMatrix.columns).toBe(0); + }); + it('should scale by column', () => { const y = new Matrix([ [1, 2, 3, 4, 5], @@ -36,4 +56,16 @@ describe('scale matrix', () => { 0.26967994498529685, ]); }); + + it('should scale by column for an empty matrix', () => { + emptyMatrix.scale('column'); + expect(emptyMatrix.rows).toBe(0); + expect(emptyMatrix.columns).toBe(0); + zeroRowMatrix.scale('column'); + expect(zeroRowMatrix.rows).toBe(0); + expect(zeroRowMatrix.columns).toBe(3); + zeroColumnMatrix.scale('column'); + expect(zeroColumnMatrix.rows).toBe(2); + expect(zeroColumnMatrix.columns).toBe(0); + }); }); diff --git a/src/dc/evd.js b/src/dc/evd.js index f1e652cc..5d241baf 100644 --- a/src/dc/evd.js +++ b/src/dc/evd.js @@ -12,6 +12,10 @@ export default class EigenvalueDecomposition { throw new Error('Matrix is not a square matrix'); } + if (matrix.isEmpty()) { + throw new Error('Matrix must be non-empty'); + } + let n = matrix.columns; let V = new Matrix(n, n); let d = new Float64Array(n); diff --git a/src/dc/svd.js b/src/dc/svd.js index 3e6be694..20ca0021 100644 --- a/src/dc/svd.js +++ b/src/dc/svd.js @@ -7,6 +7,10 @@ export default class SingularValueDecomposition { constructor(value, options = {}) { value = WrapperMatrix2D.checkMatrix(value); + if (value.isEmpty()) { + throw new Error('Matrix must be non-empty'); + } + let m = value.rows; let n = value.columns; diff --git a/src/determinant.js b/src/determinant.js index 092d52f0..f781d12e 100644 --- a/src/determinant.js +++ b/src/determinant.js @@ -5,6 +5,10 @@ import MatrixSelectionView from './views/selection'; export function determinant(matrix) { matrix = Matrix.checkMatrix(matrix); if (matrix.isSquare()) { + if (matrix.columns === 0) { + return 1; + } + let a, b, c, d; if (matrix.columns === 2) { // 2 x 2 matrix diff --git a/src/matrix.js b/src/matrix.js index 2eb59732..adb874cc 100644 --- a/src/matrix.js +++ b/src/matrix.js @@ -29,6 +29,7 @@ import { checkColumnVector, checkRange, checkIndices, + checkNonEmpty, } from './util'; export class AbstractMatrix { @@ -219,6 +220,10 @@ export class AbstractMatrix { return this.rows === this.columns; } + isEmpty() { + return this.rows === 0 || this.columns === 0; + } + isSymmetric() { if (this.isSquare()) { for (let i = 0; i < this.rows; i++) { @@ -556,6 +561,9 @@ export class AbstractMatrix { } max() { + if (this.isEmpty()) { + return NaN; + } let v = this.get(0, 0); for (let i = 0; i < this.rows; i++) { for (let j = 0; j < this.columns; j++) { @@ -568,6 +576,7 @@ export class AbstractMatrix { } maxIndex() { + checkNonEmpty(this); let v = this.get(0, 0); let idx = [0, 0]; for (let i = 0; i < this.rows; i++) { @@ -583,6 +592,9 @@ export class AbstractMatrix { } min() { + if (this.isEmpty()) { + return NaN; + } let v = this.get(0, 0); for (let i = 0; i < this.rows; i++) { for (let j = 0; j < this.columns; j++) { @@ -595,6 +607,7 @@ export class AbstractMatrix { } minIndex() { + checkNonEmpty(this); let v = this.get(0, 0); let idx = [0, 0]; for (let i = 0; i < this.rows; i++) { @@ -611,6 +624,9 @@ export class AbstractMatrix { maxRow(row) { checkRowIndex(this, row); + if (this.isEmpty()) { + return NaN; + } let v = this.get(row, 0); for (let i = 1; i < this.columns; i++) { if (this.get(row, i) > v) { @@ -622,6 +638,7 @@ export class AbstractMatrix { maxRowIndex(row) { checkRowIndex(this, row); + checkNonEmpty(this); let v = this.get(row, 0); let idx = [row, 0]; for (let i = 1; i < this.columns; i++) { @@ -635,6 +652,9 @@ export class AbstractMatrix { minRow(row) { checkRowIndex(this, row); + if (this.isEmpty()) { + return NaN; + } let v = this.get(row, 0); for (let i = 1; i < this.columns; i++) { if (this.get(row, i) < v) { @@ -646,6 +666,7 @@ export class AbstractMatrix { minRowIndex(row) { checkRowIndex(this, row); + checkNonEmpty(this); let v = this.get(row, 0); let idx = [row, 0]; for (let i = 1; i < this.columns; i++) { @@ -659,6 +680,9 @@ export class AbstractMatrix { maxColumn(column) { checkColumnIndex(this, column); + if (this.isEmpty()) { + return NaN; + } let v = this.get(0, column); for (let i = 1; i < this.rows; i++) { if (this.get(i, column) > v) { @@ -670,6 +694,7 @@ export class AbstractMatrix { maxColumnIndex(column) { checkColumnIndex(this, column); + checkNonEmpty(this); let v = this.get(0, column); let idx = [0, column]; for (let i = 1; i < this.rows; i++) { @@ -683,6 +708,9 @@ export class AbstractMatrix { minColumn(column) { checkColumnIndex(this, column); + if (this.isEmpty()) { + return NaN; + } let v = this.get(0, column); for (let i = 1; i < this.rows; i++) { if (this.get(i, column) < v) { @@ -694,6 +722,7 @@ export class AbstractMatrix { minColumnIndex(column) { checkColumnIndex(this, column); + checkNonEmpty(this); let v = this.get(0, column); let idx = [0, column]; for (let i = 1; i < this.rows; i++) { @@ -1012,7 +1041,9 @@ export class AbstractMatrix { let newMatrix = new Matrix(this.rows, this.columns); for (let i = 0; i < this.rows; i++) { const row = this.getRow(i); - rescale(row, { min, max, output: row }); + if (row.length > 0) { + rescale(row, { min, max, output: row }); + } newMatrix.setRow(i, row); } return newMatrix; @@ -1029,11 +1060,13 @@ export class AbstractMatrix { let newMatrix = new Matrix(this.rows, this.columns); for (let i = 0; i < this.columns; i++) { const column = this.getColumn(i); - rescale(column, { - min: min, - max: max, - output: column, - }); + if (column.length) { + rescale(column, { + min: min, + max: max, + output: column, + }); + } newMatrix.setColumn(i, column); } return newMatrix; @@ -1176,6 +1209,9 @@ export class AbstractMatrix { setSubMatrix(matrix, startRow, startColumn) { matrix = Matrix.checkMatrix(matrix); + if (matrix.isEmpty()) { + return this; + } let endRow = startRow + matrix.rows - 1; let endColumn = startColumn + matrix.columns - 1; checkRange(this, startRow, endRow, startColumn, endColumn); @@ -1429,10 +1465,10 @@ export default class Matrix extends AbstractMatrix { if (Matrix.isMatrix(nRows)) { // eslint-disable-next-line no-constructor-return return nRows.clone(); - } else if (Number.isInteger(nRows) && nRows > 0) { + } else if (Number.isInteger(nRows) && nRows >= 0) { // Create an empty matrix this.data = []; - if (Number.isInteger(nColumns) && nColumns > 0) { + if (Number.isInteger(nColumns) && nColumns >= 0) { for (let i = 0; i < nRows; i++) { this.data.push(new Float64Array(nColumns)); } @@ -1443,8 +1479,8 @@ export default class Matrix extends AbstractMatrix { // Copy the values from the 2D array const arrayData = nRows; nRows = arrayData.length; - nColumns = arrayData[0].length; - if (typeof nColumns !== 'number' || nColumns === 0) { + nColumns = nRows ? arrayData[0].length : 0; + if (typeof nColumns !== 'number') { throw new TypeError( 'Data must be a 2D array with at least one element', ); @@ -1476,9 +1512,6 @@ export default class Matrix extends AbstractMatrix { removeRow(index) { checkRowIndex(this, index); - if (this.rows === 1) { - throw new RangeError('A matrix cannot have less than one row'); - } this.data.splice(index, 1); this.rows -= 1; return this; @@ -1490,7 +1523,7 @@ export default class Matrix extends AbstractMatrix { index = this.rows; } checkRowIndex(this, index, true); - array = Float64Array.from(checkRowVector(this, array, true)); + array = Float64Array.from(checkRowVector(this, array)); this.data.splice(index, 0, array); this.rows += 1; return this; @@ -1498,9 +1531,6 @@ export default class Matrix extends AbstractMatrix { removeColumn(index) { checkColumnIndex(this, index); - if (this.columns === 1) { - throw new RangeError('A matrix cannot have less than one column'); - } for (let i = 0; i < this.rows; i++) { const newRow = new Float64Array(this.columns - 1); for (let j = 0; j < index; j++) { diff --git a/src/pseudoInverse.js b/src/pseudoInverse.js index 3e563dd4..faed62f5 100644 --- a/src/pseudoInverse.js +++ b/src/pseudoInverse.js @@ -3,6 +3,12 @@ import Matrix from './matrix'; export function pseudoInverse(matrix, threshold = Number.EPSILON) { matrix = Matrix.checkMatrix(matrix); + if (matrix.isEmpty()) { + // with a zero dimension, the pseudo-inverse is the transpose, since all 0xn and nx0 matrices are singular + // (0xn)*(nx0)*(0xn) = 0xn + // (nx0)*(0xn)*(nx0) = nx0 + return matrix.transpose(); + } let svdSolution = new SVD(matrix, { autoTranspose: true }); let U = svdSolution.leftSingularVectors; diff --git a/src/util.js b/src/util.js index 9ab0d21a..c70d99b6 100644 --- a/src/util.js +++ b/src/util.js @@ -143,3 +143,9 @@ function checkNumber(name, value) { throw new TypeError(`${name} must be a number`); } } + +export function checkNonEmpty(matrix) { + if (matrix.isEmpty()) { + throw new Error('Empty matrix has no elements to index'); + } +}