From da3066ddc834ece6587357433a3576afb6daae66 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 01:42:54 -0400 Subject: [PATCH 1/5] --- src/dc/svd.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/dc/svd.js b/src/dc/svd.js index 6b78606..0f76805 100644 --- a/src/dc/svd.js +++ b/src/dc/svd.js @@ -18,8 +18,13 @@ export default class SingularValueDecomposition { computeLeftSingularVectors = true, computeRightSingularVectors = true, autoTranspose = false, + maxIterations = 100, } = options; + if (!Number.isInteger(maxIterations) || maxIterations < 1) { + throw new RangeError('maxIterations must be a positive integer'); + } + let wantu = Boolean(computeLeftSingularVectors); let wantv = Boolean(computeRightSingularVectors); @@ -365,6 +370,14 @@ export default class SingularValueDecomposition { } e[p - 2] = f; iter = iter + 1; + // iter counts the sweeps spent on the singular value currently being + // split off, and resets once that value settles. an input the sweep + // cannot settle would otherwise keep this loop going forever + if (iter > maxIterations) { + throw new Error( + `SVD did not converge after ${maxIterations} iterations on a single singular value`, + ); + } break; } case 4: { From 7e7afdd8463d4f12b57b1aa80d0e04f6d9ba331f Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 01:43:09 -0400 Subject: [PATCH 2/5] feat: bound the number of sweeps the SVD spends on one singular value --- matrix.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/matrix.d.ts b/matrix.d.ts index c6f6153..b402885 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1478,6 +1478,15 @@ export interface ISVDOptions { * @default `false` */ autoTranspose?: boolean; + + /** + * Ceiling on the number of sweeps spent settling any single singular value. Reaching it + * throws rather than letting the sweep run on. A decomposition that converges takes a + * handful of sweeps per value regardless of the size of the input, so the default leaves + * a wide margin. Raise it only after seeing the error. + * @default `100` + */ + maxIterations?: number; } /** From d7e0881050fecbca819290c0e71d86f27027b05a Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 01:43:39 -0400 Subject: [PATCH 3/5] --- .../decompositions/svdMaxIterations.test.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/__tests__/decompositions/svdMaxIterations.test.js diff --git a/src/__tests__/decompositions/svdMaxIterations.test.js b/src/__tests__/decompositions/svdMaxIterations.test.js new file mode 100644 index 0000000..9fc62ac --- /dev/null +++ b/src/__tests__/decompositions/svdMaxIterations.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; + +import { Matrix, SingularValueDecomposition } from '../..'; + +const wellBehaved = new Matrix([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 10], + [2, 9, 4], +]); + +describe('SVD sweep ceiling', () => { + it('a decomposition that converges is untouched by the default', () => { + const svd = new SingularValueDecomposition(wellBehaved); + const product = svd.leftSingularVectors + .mmul(Matrix.diag(svd.diagonal)) + .mmul(svd.rightSingularVectors.transpose()); + for (let i = 0; i < wellBehaved.rows; i++) { + for (let j = 0; j < wellBehaved.columns; j++) { + expect(product.get(i, j)).toBeCloseTo(wellBehaved.get(i, j), 10); + } + } + }); + + it('a ceiling of one sweep is reported rather than passed over', () => { + expect( + () => + new SingularValueDecomposition(Matrix.rand(40, 25), { + maxIterations: 1, + }), + ).toThrow( + /^SVD did not converge after 1 iterations on a single singular value$/, + ); + }); + + it('the ceiling is counted per singular value, not over the whole run', () => { + // a handful of sweeps settles a value whatever the size of the input, so a + // larger matrix does not need a larger ceiling + expect( + () => + new SingularValueDecomposition(Matrix.rand(200, 150), { + maxIterations: 20, + }), + ).not.toThrow(); + }); + + it('a generous ceiling behaves like the default', () => { + const bounded = new SingularValueDecomposition(wellBehaved, { + maxIterations: 1000, + }); + const plain = new SingularValueDecomposition(wellBehaved); + expect(bounded.diagonal).toStrictEqual(plain.diagonal); + }); + + it('rejects a ceiling that is not a positive integer', () => { + for (const value of [0, -1, 2.5, '10', null]) { + expect( + () => + new SingularValueDecomposition(wellBehaved, { + maxIterations: value, + }), + ).toThrow(/^maxIterations must be a positive integer$/); + } + }); +}); From 52c9aece9c2e1108fcc2e7d93dd2d6c806964f7f Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 01:43:55 -0400 Subject: [PATCH 4/5] --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 0af64f0..2f09faf 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ const { LuDecomposition, CholeskyDecomposition, EigenvalueDecomposition, + SingularValueDecomposition, } = require('ml-matrix'); ``` #### Inverse and Pseudo-inverse @@ -285,6 +286,22 @@ var real = e.realEigenvalues; var imaginary = e.imaginaryEigenvalues; var vectors = e.eigenvectorMatrix; ``` +##### Singular Value Decomposition +```js +var A = new Matrix([ + [2, 3, 5], + [4, 1, 6], + [1, 3, 0], +]); + +var svd = new SingularValueDecomposition(A); +var U = svd.leftSingularVectors; +var s = svd.diagonal; +var V = svd.rightSingularVectors; +// U * diag(s) * V.transpose() gives A back +``` +The decomposition settles one singular value at a time through repeated sweeps. `maxIterations`, 100 by default, caps how many sweeps any one value gets. A decomposition that converges takes a handful per value whatever the size of the input, so reaching the cap means the sweep is stuck on that input, and it is reported rather than left running. + #### Linear dependencies ```js var A = new Matrix([ From 343443e397afa77389e253aab923c41aec0cc702 Mon Sep 17 00:00:00 2001 From: Sarthak Tayal Date: Thu, 6 Aug 2026 16:43:09 -0400 Subject: [PATCH 5/5] fix: make the SVD iteration limit deterministic --- README.md | 2 +- matrix.d.ts | 6 ++---- .../decompositions/svdMaxIterations.test.js | 13 ++++++++----- src/dc/svd.js | 17 +++++++++-------- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2f09faf..574a92d 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ var s = svd.diagonal; var V = svd.rightSingularVectors; // U * diag(s) * V.transpose() gives A back ``` -The decomposition settles one singular value at a time through repeated sweeps. `maxIterations`, 100 by default, caps how many sweeps any one value gets. A decomposition that converges takes a handful per value whatever the size of the input, so reaching the cap means the sweep is stuck on that input, and it is reported rather than left running. +The decomposition settles one singular value at a time through repeated sweeps. `maxIterations`, 100 by default, caps how many sweeps any one value gets. If another sweep would exceed the cap, the decomposition reports that it did not converge instead of continuing without a bound. #### Linear dependencies ```js diff --git a/matrix.d.ts b/matrix.d.ts index b402885..b1bb03e 100644 --- a/matrix.d.ts +++ b/matrix.d.ts @@ -1480,10 +1480,8 @@ export interface ISVDOptions { autoTranspose?: boolean; /** - * Ceiling on the number of sweeps spent settling any single singular value. Reaching it - * throws rather than letting the sweep run on. A decomposition that converges takes a - * handful of sweeps per value regardless of the size of the input, so the default leaves - * a wide margin. Raise it only after seeing the error. + * Maximum number of sweeps spent settling any single singular value. If another sweep + * would exceed the limit, the decomposition throws instead of continuing without a bound. * @default `100` */ maxIterations?: number; diff --git a/src/__tests__/decompositions/svdMaxIterations.test.js b/src/__tests__/decompositions/svdMaxIterations.test.js index 9fc62ac..68b02e1 100644 --- a/src/__tests__/decompositions/svdMaxIterations.test.js +++ b/src/__tests__/decompositions/svdMaxIterations.test.js @@ -1,3 +1,4 @@ +import { XSadd } from 'ml-xsadd'; import { describe, it, expect } from 'vitest'; import { Matrix, SingularValueDecomposition } from '../..'; @@ -9,6 +10,10 @@ const wellBehaved = new Matrix([ [2, 9, 4], ]); +function randomMatrix(rows, columns, seed) { + return Matrix.rand(rows, columns, { random: new XSadd(seed).random }); +} + describe('SVD sweep ceiling', () => { it('a decomposition that converges is untouched by the default', () => { const svd = new SingularValueDecomposition(wellBehaved); @@ -25,20 +30,18 @@ describe('SVD sweep ceiling', () => { it('a ceiling of one sweep is reported rather than passed over', () => { expect( () => - new SingularValueDecomposition(Matrix.rand(40, 25), { + new SingularValueDecomposition(randomMatrix(40, 25, 42), { maxIterations: 1, }), ).toThrow( - /^SVD did not converge after 1 iterations on a single singular value$/, + /^SVD did not converge after 1 iteration on a single singular value$/, ); }); it('the ceiling is counted per singular value, not over the whole run', () => { - // a handful of sweeps settles a value whatever the size of the input, so a - // larger matrix does not need a larger ceiling expect( () => - new SingularValueDecomposition(Matrix.rand(200, 150), { + new SingularValueDecomposition(randomMatrix(200, 150, 43), { maxIterations: 20, }), ).not.toThrow(); diff --git a/src/dc/svd.js b/src/dc/svd.js index 0f76805..3d44f5e 100644 --- a/src/dc/svd.js +++ b/src/dc/svd.js @@ -307,6 +307,15 @@ export default class SingularValueDecomposition { break; } case 3: { + // iter resets when the current singular value settles. Refuse the + // next sweep once this value has consumed its full allowance. + if (iter >= maxIterations) { + const iterationWord = + maxIterations === 1 ? 'iteration' : 'iterations'; + throw new Error( + `SVD did not converge after ${maxIterations} ${iterationWord} on a single singular value`, + ); + } const scale = Math.max( Math.abs(s[p - 1]), Math.abs(s[p - 2]), @@ -370,14 +379,6 @@ export default class SingularValueDecomposition { } e[p - 2] = f; iter = iter + 1; - // iter counts the sweeps spent on the singular value currently being - // split off, and resets once that value settles. an input the sweep - // cannot settle would otherwise keep this loop going forever - if (iter > maxIterations) { - throw new Error( - `SVD did not converge after ${maxIterations} iterations on a single singular value`, - ); - } break; } case 4: {