diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b3c40a8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 5 + groups: + # One PR per month for routine bumps; anything that needs thought will fail CI + # on its own rather than being buried in a batch of five. + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49961ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + # Not `--all-targets`: that pulls in the bench targets, and criterion's + # smoke-run of them in a debug build takes minutes. + - name: Test + run: cargo test --lib --tests --examples + # `las2` is off by default, so the default run never compiles that module. + - name: Test (all features) + run: cargo test --all-features --lib --tests --examples + - name: Doctests + run: cargo test --all-features --doc + - name: Build benches + run: cargo build --all-features --benches + - name: Clippy + run: cargo clippy --all-features --all-targets -- -D warnings + - name: Format + run: cargo fmt --check + + # `rust-version` in Cargo.toml is a promise; without this job it is only a comment. + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.88.0 + # Dev-dependencies routinely need a newer compiler than the library does, so the + # promise is checked against the library and its public API, not the test suite. + - name: Build at the declared MSRV + run: cargo build --all-features + + # Plain cargo-audit rather than rustsec/audit-check: that action reports through the + # Checks API, which needs `checks: write` and is unavailable to fork PRs entirely. It + # degrades to printing the report and does not reliably fail the job. + audit: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install cargo-audit + run: cargo install cargo-audit --locked + # Exits non-zero on vulnerabilities. Unsoundness warnings are printed but do not + # fail the build — they are usually in transitive crates we cannot bump. + - name: Audit + run: cargo audit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e4103c3..081f4fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,8 @@ name: Publish to crates.io on: - push: - branches: - - master - tags: - - 'v*' + release: + types: [published] jobs: publish: diff --git a/.gitignore b/.gitignore index 41fd541..64d2e67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ /target + +# Editor state .idea -.idea/ \ No newline at end of file +.idea/ +.vscode/ +*.swp + +# macOS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..baa66ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## 2.0.0 + +A rewrite: different sparse backend, new solvers, and a handful of 1.x results that were +just wrong. + +### Breaking + +- `sprs` replaces `nalgebra-sparse`. `SvdMat` is `sprs::CsMatI`, + generic over both index types — 12 bytes per non-zero for `f64` instead of 16. + `nalgebra` is now dev-only. +- `u` is always `m × d` (numpy layout). 1.x returned `d × m` from Lanczos and `m × d` + from randomized. +- `SMat` is split into `SparseMat` (shape, matvec) and `SparseMatDense` (block products, + means, centering). +- `MaskedCSRMatrix` is now `MaskedCsMat`, and masks rows as well as columns. +- Solvers take config builders (`IrlbaConfig`, `RandomizedConfig`) instead of positional + arguments. +- `randomized` returns `single_svdlib::Result`, not `anyhow::Result`. +- `svd` and `svd_dim` use IRLBA, not LAS2. +- LAS2 is deprecated and behind the `las2` feature, off by default. +- IRLBA errors instead of returning an unconverged result. `allow_unconverged()` opts out. +- MSRV 1.88. + +### Added + +- `irlba` — restarted Lanczos bidiagonalization. The default solver. +- `dense::tsqr` — tall-skinny QR. +- `dense::small_svd` — one-sided Jacobi, replacing a dependency that lost accuracy on + badly scaled columns. +- Block Krylov sketching in `randomized`. +- `explained_variance`, `explained_variance_ratio`, `total_variance` and `scores` on + `SvdRec`. +- `MaskedCsMat::to_sparse` — copy a view into an owned sparse submatrix. +- `SvdRec::converged` and `max_residual`. +- `Diagnostics::matvecs`. +- Property tests, an edge-case suite, and benchmarks. + +### Fixed + +All checked against the published 1.0.9. + +- `randomized_svd` panicked for every built-in matrix type — four of five `SMat` methods + were `todo!()`. +- Mean centering computed the product of the sums instead of the sum of the products. +- `seed: None` meant seed 0, so unseeded sketches were all identical. +- Masked matrices used the unmasked matrix for small inputs. +- NaN or infinity in the input could hang forever. +- All-zero matrices reported a non-zero singular value. +- Rank-deficient matrices could fail on an absolute breakdown test. +- `min(rows, cols) == 1` was rejected instead of solved directly. +- The centered Frobenius norm cancelled on columns with a large offset — 51% off at an + `f32` offset of 1000. Now summed per entry. + +### Known issues + +LAS2 is still wrong: 18–100% error against LAPACK. One cause is fixed (`imtqlb` hoisted +its shift out of the loop), at least one remains in `ritvec`. Inherited from 1.x, not the +port. Its accuracy tests are `#[ignore]`d. Hence deprecated and gated. diff --git a/Cargo.lock b/Cargo.lock index 5b23bff..ff40c8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -23,17 +53,38 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.9.0" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.21.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -41,12 +92,135 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -59,9 +233,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -72,6 +246,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "either" version = "1.15.0" @@ -79,112 +259,141 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] -name = "getrandom" -version = "0.3.2" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "cfg-if", "libc", - "r-efi", - "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "glam" -version = "0.14.0" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "glam" -version = "0.15.2" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "glam" -version = "0.16.0" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "glam" -version = "0.17.3" +name = "futures-core" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] -name = "glam" -version = "0.18.0" +name = "futures-task" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] -name = "glam" -version = "0.19.0" +name = "futures-util" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] [[package]] -name = "glam" -version = "0.20.5" +name = "getrandom" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.2.0", + "wasi", +] [[package]] -name = "glam" -version = "0.21.3" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] [[package]] name = "glam" -version = "0.22.0" +version = "0.30.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" +checksum = "bd47b05dddf0005d850e5644cae7f2b14ac3df487979dbfff3b56f20b1a6ae46" [[package]] name = "glam" -version = "0.23.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" [[package]] name = "glam" -version = "0.24.2" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "glam" -version = "0.25.0" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +checksum = "7360bd2cd76e0cd9032d42cf2922155cecea2685b0cfa4630c3246df030bcfd6" [[package]] -name = "glam" -version = "0.27.0" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy 0.8.27", +] [[package]] -name = "glam" -version = "0.28.0" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] -name = "glam" -version = "0.29.3" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "glam" -version = "0.30.9" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd47b05dddf0005d850e5644cae7f2b14ac3df487979dbfff3b56f20b1a6ae46" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] [[package]] name = "libc" @@ -198,6 +407,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "matrixmultiply" version = "0.3.9" @@ -208,35 +423,28 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "nalgebra" -version = "0.34.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4d5b3eff5cd580f93da45e64715e8c20a3996342f1e466599cf7a267a0c2f5f" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" dependencies = [ "approx", - "glam 0.14.0", - "glam 0.15.2", - "glam 0.16.0", - "glam 0.17.3", - "glam 0.18.0", - "glam 0.19.0", - "glam 0.20.5", - "glam 0.21.3", - "glam 0.22.0", - "glam 0.23.0", - "glam 0.24.2", - "glam 0.25.0", - "glam 0.27.0", - "glam 0.28.0", - "glam 0.29.3", "glam 0.30.9", + "glam 0.31.1", + "glam 0.32.1", + "glam 0.33.3", "matrixmultiply", "nalgebra-macros", "num-complex", "num-rational", "num-traits", - "rayon", "simba", "typenum", ] @@ -249,24 +457,14 @@ checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "nalgebra-sparse" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df054d7815152d4e66955fc59a1f97f4036e5103134a381b6b54ec55babfa6b7" -dependencies = [ - "nalgebra", - "num-traits", + "syn 2.0.91", ] [[package]] name = "ndarray" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -275,6 +473,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "rawpointer", + "rayon", ] [[package]] @@ -327,10 +526,60 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] [[package]] name = "portable-atomic" @@ -365,6 +614,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.37" @@ -380,15 +654,31 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.9.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", - "zerocopy 0.8.24", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -398,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -407,17 +697,32 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.2", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand", + "rand 0.10.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", ] [[package]] @@ -428,9 +733,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -438,49 +743,168 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "safe_arch" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59" dependencies = [ "bytemuck", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simba" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" +checksum = "834a9952211d4a60ff10ac1d20cc01640a75bdc0a0c688d62f359ea7d17459f3" dependencies = [ "approx", "num-complex", "num-traits", - "paste", "wide", ] [[package]] name = "single-svdlib" -version = "1.0.9" +version = "2.0.0" dependencies = [ - "anyhow", + "approx", + "criterion", "nalgebra", - "nalgebra-sparse", "ndarray", "num-traits", - "rand", + "proptest", + "rand 0.10.2", "rand_distr", "rayon", "single-utilities", + "sprs", "thiserror", ] @@ -491,9 +915,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8da70cfdee29ff4e96dbbcf5268ba3cdbf0a3002d84826d10d2f5cd211595f87" dependencies = [ "anyhow", - "nalgebra", + "num-traits", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "sprs" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d5a5663aa9f18d287877b8a889ee6cc2314e3a3778193ccc580d048d7d4abc" +dependencies = [ "ndarray", + "num-complex", "num-traits", + "smallvec", ] [[package]] @@ -507,24 +953,58 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" -version = "2.0.9" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.9" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", ] [[package]] @@ -533,12 +1013,37 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" @@ -548,16 +1053,191 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.91", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wide" -version = "0.7.30" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58e6db2670d2be78525979e9a5f9c69d296fd7d670549fe9ebf70f8708cb5019" +checksum = "be99e8317aa9f08e7d16e13033ca43faab59ab582b1d0feab7b385424c42f8b1" dependencies = [ "bytemuck", "safe_arch", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -579,11 +1259,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.24" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive 0.8.27", ] [[package]] @@ -594,16 +1274,22 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "zerocopy-derive" -version = "0.8.24" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 628d005..e816817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,49 @@ [package] authors = ["Dave Farnham ", "Ian F. Diks "] -description = "A Rust port of LAS2 from SVDLIBC" -keywords = ["svd"] -categories = ["algorithms", "data-structures", "mathematics", "science"] +description = "Sparse SVD and PCA for Rust over sprs matrices: restarted Lanczos bidiagonalization (IRLBA) and randomized SVD, with column masking and mean-centering that never densify the input" +keywords = ["svd", "sparse", "lanczos", "randomized", "irlba"] +categories = ["algorithms", "mathematics", "science"] name = "single-svdlib" -version = "1.0.9" +version = "2.0.0" edition = "2021" -license-file = "SVDLIBC-LICENSE.txt" +rust-version = "1.88" +license = "BSD-3-Clause" +repository = "https://github.com/SingleRust/single-svdlib" +homepage = "https://github.com/SingleRust/single-svdlib" +documentation = "https://docs.rs/single-svdlib" +readme = "README.md" +exclude = [".vscode", ".github", "GEMINI.md"] [features] -# simd = ["dep:simba", "single-utilities/simd"] +las2 = [] + +[package.metadata.docs.rs] +all-features = true [dependencies] -anyhow = "1.0" -nalgebra-sparse = "0.11.0" +sprs = { version = "0.11.5", default-features = false } +ndarray = { version = "0.17.2", features = ["rayon"] } num-traits = "0.2.19" -rand = "0.9.0" -rand_distr = "0.5.1" -rayon = "1.10.0" -thiserror = "2.0.9" -ndarray = "0.16" -single-utilities = { version = "0.9.0", features = ["convert"] } -nalgebra = { version = "0.34", features = ["rayon"] } +rand = "0.10.2" +rand_distr = "0.6" +rayon = "1.12" +thiserror = "2.0.19" +single-utilities = "0.9.0" + +[dev-dependencies] +approx = "0.5" +criterion = "0.8" +nalgebra = "0.35" +proptest = "1" + +[[bench]] +name = "kernels" +harness = false + +[[bench]] +name = "solvers" +harness = false + +[profile.bench] +lto = "thin" +codegen-units = 1 diff --git a/README.md b/README.md index 3e8a66f..2512bfd 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,297 @@ -# Single-SVDLib: Singular Value Decomposition for Sparse Matrices +# single-svdlib [![Crate](https://img.shields.io/crates/v/single-svdlib.svg)](https://crates.io/crates/single-svdlib) [![Documentation](https://docs.rs/single-svdlib/badge.svg)](https://docs.rs/single-svdlib) -[![License](https://img.shields.io/crates/l/single-svdlib.svg)](LICENSE) -A high-performance Rust library for computing Singular Value Decomposition (SVD) on sparse matrices, with support for both Lanczos and randomized SVD algorithms. +Sparse SVD and PCA over [`sprs`](https://crates.io/crates/sprs) matrices. Built for +matrices too big to densify — you can mask columns and mean-center without copying or +touching the original. -## Features +```toml +[dependencies] +single-svdlib = "2.0" +``` -- **Multiple SVD algorithms**: - - Lanczos algorithm (based on SVDLIBC) - - Randomized SVD for very large and sparse matrices -- **Sparse matrix support**: - - Compressed Sparse Row (CSR) format - - Compressed Sparse Column (CSC) format - - Coordinate (COO) format -- **Performance optimizations**: - - Parallel execution with Rayon - - Adaptive tuning for highly sparse matrices - - Column masking for subspace SVD -- **Generic interface**: - - Works with both `f32` and `f64` precision -- **Comprehensive error handling and diagnostics** +## Quick start -## Installation +```rust +use single_svdlib::{sprs::TriMatI, SvdMat}; -Add this to your `Cargo.toml`: +let mut tri = TriMatI::::new((4, 3)); +tri.add_triplet(0, 0, 1.0); +tri.add_triplet(1, 1, 2.0); +tri.add_triplet(2, 2, 3.0); +tri.add_triplet(3, 0, 4.0); +let a: SvdMat = tri.to_csr::(); -```toml -[dependencies] -single-svdlib = "0.6.0" +let svd = single_svdlib::svd(&a, 2)?; + +// A ≈ u · diag(s) · vt +assert_eq!(svd.u.dim(), (4, 2)); // left vectors are columns +assert_eq!(svd.vt.dim(), (2, 3)); // right vectors are rows +# Ok::<(), single_svdlib::SvdLibError>(()) ``` -## Quick Start +## Picking a solver + +| module | method | use when | +|---|---|---| +| `irlba` | thick-restarted Lanczos bidiagonalization | **default.** Accurate, memory bounded by the rank you ask for | +| `randomized` | range finder, power iteration or block Krylov | huge inputs where an approximation is fine | +| `lanczos` | LAS2 from SVDLIBC | **deprecated, don't use.** Behind the `las2` feature, see [below](#las2-is-deprecated) | + +`single_svdlib::svd` calls `irlba`. ```rust -use nalgebra_sparse::{coo::CooMatrix, csr::CsrMatrix}; -use single_svdlib::laczos::svd_dim_seed; +use single_svdlib::{irlba, randomized}; +# use single_svdlib::{sprs::TriMatI, SvdMat}; +# let mut t = TriMatI::::new((40, 20)); +# for i in 0..40 { for j in 0..20 { t.add_triplet(i, j, ((i * 7 + j * 3) % 11) as f64); } } +# let a: SvdMat = t.to_csr::(); + +let exact = irlba::svd_seed(&a, 10, 42)?; // reproducible +let pca = irlba::svd_centered(&a, 10, Some(42))?; // mean-centered, no densifying +let approx = randomized::svd_seed(&a, 10, 42)?; // faster, less accurate +let better = randomized::svd_block_krylov(&a, 10, 3, Some(42))?; // slow spectra +# Ok::<(), single_svdlib::SvdLibError>(()) +``` + +`IrlbaConfig` and `RandomizedConfig` give you the knobs: -// Create a matrix in COO format -let mut coo = CooMatrix::::new(3, 3); -coo.push(0, 0, 1.0); coo.push(0, 1, 16.0); coo.push(0, 2, 49.0); -coo.push(1, 0, 4.0); coo.push(1, 1, 25.0); coo.push(1, 2, 64.0); -coo.push(2, 0, 9.0); coo.push(2, 1, 36.0); coo.push(2, 2, 81.0); +```rust +use single_svdlib::randomized::{svd_with, Normalizer, RandomizedConfig}; +# use single_svdlib::{sprs::TriMatI, SvdMat}; +# let mut t = TriMatI::::new((40, 20)); +# for i in 0..40 { for j in 0..20 { t.add_triplet(i, j, ((i * 5 + j) % 7) as f64); } } +# let a: SvdMat = t.to_csr::(); + +let cfg = RandomizedConfig::new(10) + .oversamples(15) + .power_iterations(4) + .normalizer(Normalizer::Tsqr) + .mean_center(true) + .seed(42); + +// Last argument is an optional progress callback, one call per stage. +let svd = svd_with(&a, &cfg, Some(&|stage: &str| eprintln!("{stage}")))?; +# Ok::<(), single_svdlib::SvdLibError>(()) +``` -// Convert to CSR for better performance -let csr = CsrMatrix::from(&coo); +## Memory -// Compute SVD with a fixed random seed -let svd = svd_dim_seed(&csr, 3, 42).unwrap(); +`SvdMat` is `CsMatI` — 32-bit column indices, 64-bit row pointers. That's +12 bytes per non-zero for `f64` instead of the 16 you get with `usize` everywhere, and +8 instead of 16 for `f32`. The wider pointer still handles more than `u32::MAX` +non-zeros. Need bigger indices? Name them: `SvdMat`. -// Access the results -let singular_values = &svd.s; -let left_singular_vectors = &svd.ut; // Note: These are transposed -let right_singular_vectors = &svd.vt; // Note: These are transposed +`irlba` restarts, so it holds `work + 1` right vectors and `work` left vectors +(`work = rank + 7` by default) no matter how many restarts it takes. Peak is known up +front: -// Reconstruct the original matrix -let reconstructed = svd.recompose(); +```text +(work + 1) · cols + work · rows scalars ``` -## SVD Methods +200 000 × 30 000, 4.9M non-zeros, rank 50, 10 cores: -### Lanczos Algorithm (LAS2) +| | time | peak RSS | +|---|---|---| +| `irlba` | 2.2 s | 514 MiB | +| `randomized`, 2 power iterations | 1.2 s | 654 MiB | +| `randomized`, block Krylov ×3 | 4.0 s | 1080 MiB | -The Lanczos algorithm is well-suited for sparse matrices of moderate size: +The matrix is 57.8 MiB, against 76.6 MiB with `usize` indices. Reproduce with +`cargo run --release --example scale irlba`. -```rust -use single_svdlib::laczos; - -// Basic SVD computation (uses defaults) -let svd = laczos::svd(&matrix)?; - -// SVD with specified target rank -let svd = laczos::svd_dim(&matrix, 10)?; - -// SVD with specified target rank and fixed random seed -let svd = laczos::svd_dim_seed(&matrix, 10, 42)?; - -// Full control over SVD parameters -let svd = laczos::svd_las2( - &matrix, - dimensions, // upper limit of desired number of dimensions - iterations, // number of Lanczos iterations - end_interval, // interval containing unwanted eigenvalues, e.g. [-1e-30, 1e-30] - kappa, // relative accuracy of eigenvalues, e.g. 1e-6 - random_seed, // random seed (0 for automatic) -)?; -``` +Block Krylov's basis is `blocks × (rank + oversamples)` wide, so its memory grows with +`blocks`. That's what its accuracy costs. -### Randomized SVD +## Big matrices, column subsets, PCA -For very large sparse matrices, the randomized SVD algorithm offers better performance: +The thing this crate exists for: ```rust -use single_svdlib::randomized; - -let svd = randomized::randomized_svd( - &matrix, - target_rank, // desired rank - n_oversamples, // oversampling parameter (typically 5-10) - n_power_iterations, // number of power iterations (typically 2-4) - randomized::PowerIterationNormalizer::QR, // normalization method - Some(42), // random seed (None for automatic) -)?; +use single_svdlib::{irlba, MaskedCsMat}; +# use single_svdlib::{sprs::TriMatI, SvdMat}; +# let mut t = TriMatI::::new((200, 60)); +# for i in 0..200 { for j in 0..60 { t.add_triplet(i, j, ((i * 7 + j) % 13) as f64); } } +# let counts: SvdMat = t.to_csr::(); +# let selected_genes: Vec = (0..60).step_by(3).collect(); + +// A view over the selected columns. No copy, `counts` untouched. +let view = MaskedCsMat::with_columns(&counts, &selected_genes); + +let pca = irlba::svd_centered(&view, 10, Some(42))?; +// pca.u — scores, cells x components +// pca.vt — loadings, components x selected genes +# Ok::<(), single_svdlib::SvdLibError>(()) ``` -### Column Masking +1 000 000 cells × 30 000 genes, 145M non-zeros, masked to 2238 genes, 50 components +(`cargo run --release --example pca_at_scale`): -For operations on specific columns of a matrix: +| | | +|---|---| +| matrix, sparse | 1.63 GiB | +| same matrix dense | 223.52 GiB — **137× bigger**, never materialised | +| building the view | 5 ms, no copy | +| PCA on the view | 44 s, converged | +| peak RSS | ~3.9 GiB | +| `‖A_c·vᵢ − σᵢ·uᵢ‖ / σ_max` | 1.8e-15 | -```rust -use single_svdlib::laczos::masked::MaskedCSRMatrix; +Three things to know: -// Create a mask for selected columns -let columns = vec![0, 2, 5, 7]; // Only use these columns -let masked_matrix = MaskedCSRMatrix::with_columns(&csr_matrix, &columns); +**A mask doesn't make products cheaper.** Every product still walks all the source's +non-zeros and checks each against the mask — only the output gets narrower. With a +restrictive mask and hundreds of products, extract once instead. It stays sparse: -// Compute SVD on the masked matrix -let svd = laczos::svd(&masked_matrix)?; +```rust +# use single_svdlib::{irlba, MaskedCsMat, sprs::TriMatI, SvdMat}; +# let mut t = TriMatI::::new((200, 60)); +# for i in 0..200 { for j in 0..60 { t.add_triplet(i, j, ((i * 7 + j) % 13) as f64); } } +# let counts: SvdMat = t.to_csr::(); +# let selected_genes: Vec = (0..60).step_by(3).collect(); +let sub = MaskedCsMat::with_columns(&counts, &selected_genes).to_sparse(); +let pca = irlba::svd_centered(&sub, 10, Some(42))?; +# Ok::<(), single_svdlib::SvdLibError>(()) ``` -## Result Structure +At 1M × 30k that copy took 146 ms and made the PCA 18% faster (44 s → 36 s). -The SVD result contains: +**An unconverged result is an error, not a value.** `irlba` won't hand back triplets that +missed `tol` — too easy to skip the diagnostics and carry a bad decomposition downstream. +The error tells you the residual and what to change. If you really want a best effort, +call `.allow_unconverged()` and check `converged()` / `max_residual()` yourself. -```rust -struct SvdRec { - d: usize, // Rank (number of singular values) - ut: Array2, // Transpose of left singular vectors (d x m) - s: Array1, // Singular values (d) - vt: Array2, // Transpose of right singular vectors (d x n) - diagnostics: Diagnostics, // Computation diagnostics +**Raise `work` on tall matrices.** With many rows it's re-orthogonalization that +dominates, not the sparse products. `rank + 30` was 2.2× faster than the default at the +same accuracy. `cargo run --release --example tune` prints the comparison for your shape. + +## Result + +```rust,ignore +pub struct SvdRec { + pub d: usize, // triplets returned + pub u: Array2, // m × d, left vectors are columns + pub s: Array1, // d, descending + pub vt: Array2, // d × n, right vectors are rows + pub total_squared_norm: T, // ‖A‖²_F of what was decomposed + pub diagnostics: Diagnostics, } ``` -Note that `ut` and `vt` are returned in transposed form. - -## Diagnostics +`A ≈ u · diag(s) · vt`, same layout as `numpy.linalg.svd`. `Diagnostics::matvecs` counts +sparse products (a block product against `k` columns counts as `k`), which is comparable +across solvers. `Diagnostics::detail` has per-algorithm stuff like `irlba`'s restart count. -Each SVD computation returns detailed diagnostics: +### Explained variance ```rust -let svd = laczos::svd(&matrix)?; -println!("Non-zero elements: {}", svd.diagnostics.non_zero); -println!("Transposed during computation: {}", svd.diagnostics.transposed); -println!("Lanczos steps: {}", svd.diagnostics.lanczos_steps); -println!("Significant values found: {}", svd.diagnostics.significant_values); +# use single_svdlib::{irlba, sprs::TriMatI, SvdMat}; +# let mut t = TriMatI::::new((200, 60)); +# for i in 0..200 { for j in 0..60 { t.add_triplet(i, j, ((i * 7 + j) % 13) as f64); } } +# let a: SvdMat = t.to_csr::(); +let pca = irlba::svd_centered(&a, 10, Some(42))?; + +let scores = pca.scores(); // m × d, u · diag(s) +let var = pca.explained_variance(); // sᵢ² / (m − 1), sklearn's convention +let ratio = pca.explained_variance_ratio(); // fraction of total, sums to ≤ 1 + +assert!(ratio.iter().sum::() <= 1.0 + 1e-9); +# Ok::<(), single_svdlib::SvdLibError>(()) ``` -## Performance Tips +The ratio needs a denominator a truncated SVD can't give you — the tail you threw away — +so every solver makes one extra pass over the non-zeros to record `total_squared_norm`. +That's nothing next to the hundreds of products a solve does. + +If the solver was centering, that norm is of the centered matrix, summed per entry so it +stays accurate on columns with a large offset. On a `MaskedCsMat` it covers only the +selected entries, so a masked PCA's ratios are relative to the submatrix you decomposed. + +## Accuracy + +The small reduced factors (IRLBA's `B`, randomized's `R`) decide the accuracy of the whole +result, so they're factored with a **one-sided Jacobi SVD** rather than bidiagonal QR. +Jacobi is accurate to the condition number *after* column scaling (Demmel & Veselić). On a +`5 × 2` operand with `κ ≈ 8·10⁶`, `nalgebra`'s Golub–Reinsch reconstructed to `1.3·10⁻⁹`; +Jacobi hits `< 1·10⁻¹⁵`. It's also why there's no linear-algebra backend in the dependency +tree — `sprs`, `ndarray`, `rayon`, `num-traits`, `rand` and `thiserror` is the lot. + +Three test suites: + +- **`tests/cross_algorithm.rs`** — every solver against a dense LAPACK-grade reference. +- **`tests/robustness.rs`** — degenerate and hostile inputs (all-zero, rank-deficient, + `NaN`, `∞`, huge dynamic range, empty masks) must give a correct answer or a typed + error. Never a panic, a hang, or a quietly wrong result. +- **`tests/properties.rs`** — invariants over shapes and distributions `proptest` picks: + agreement with the reference, orthonormality, `A·vᵢ = σᵢ·uᵢ`, truncation error equal to + the spectral tail, storage-order and index-width invariance, reproducibility, and the + same set again in `f32`. Push harder with + `PROPTEST_CASES=100000 cargo test --release --test properties`. + +### Storage order + +A compressed matrix can only be walked along its outer dimension, so the two product +directions differ. `A · D` on CSR writes output row `i` from sparse row `i` — threads own +disjoint rows, no scratch, no reduction. `Aᵀ · D` scatters, so threads collide and have to +accumulate. `transpose_view()` doesn't get you out of this; it relabels CSR as a CSC view +without changing what's traversable. What it does mean is that a CSC-stored matrix gets +the cheap kernel for `Aᵀ · D` for free. Store CSC if your workload is transpose-heavy. + +For the scatter direction there's one accumulator per thread, and if +`threads × cols × k` would blow past `DEFAULT_SCRATCH_BUDGET` (64 MiB) the dense columns +are processed in blocks. + +## Migrating from 1.x + +2.0 is a clean break. Full list in [CHANGELOG.md](CHANGELOG.md). + +| 1.x | 2.0 | +|---|---| +| `nalgebra_sparse::CsrMatrix` | `SvdMat` (`sprs::CsMatI`) | +| `lanczos::svd_dim_seed(&m, k, seed)` | `single_svdlib::svd_seed(&m, k, seed)` | +| `randomized::randomized_svd(&m, k, o, q, norm, center, seed, verbose)` | `randomized::svd_with(&m, &RandomizedConfig::new(k)…, progress)` | +| `MaskedCSRMatrix` | `MaskedCsMat` | +| `SMat` trait | `SparseMat` + `SparseMatDense` | +| `anyhow::Result` from `randomized` | `single_svdlib::Result` everywhere | +| `svd.u` orientation varied by solver | always `m × d` | + +Several 1.x results were wrong, not just differently shaped — `randomized` panicked for +every built-in matrix type, mean centering computed the wrong quantity, and `seed: None` +meant seed 0. If you have numbers from 1.x, re-check them. Details in the changelog. + +### LAS2 is deprecated + +`lanczos` is still there so 2.0 doesn't silently drop the API, but it's `#[deprecated]`, +behind an off-by-default feature, and **you shouldn't use it**. Against a dense LAPACK +reference it gets the largest singular value 18–100% wrong on every matrix class tested — +including `diag(n, n-1, …, 1)`, where asking for the full rank reports `32` when the +answer is `40`. -1. **Choose the right algorithm**: - - For matrices up to ~10,000 x 10,000 with moderate sparsity, use the Lanczos algorithm - - For larger matrices or very high sparsity (>99%), use randomized SVD - -2. **Matrix format matters**: - - Convert COO matrices to CSR or CSC for computation - - CSR typically performs better for row-oriented operations +```toml +# Only to keep a 1.x caller compiling while it migrates. +single-svdlib = { version = "2.0", features = ["las2"] } +``` -3. **Adjust parameters for very sparse matrices**: - - Increase power iterations in randomized SVD (e.g., 5-7) - - Use a higher `kappa` value in Lanczos for very sparse matrices +This came from 1.x, not the sprs port — running `single-svdlib 1.0.9` from crates.io on +the same fixtures reproduces the same wrong values. One cause is fixed: `imtqlb` hoisted +its shift `p = d[l]` out of the iteration loop, where EISPACK's `IMTQL1` assigns it inside +(label 120), so every eigenvalue after the first used a stale shift. That's where 1.x's +`imtqlb had some convergence issues` warnings came from. At least one more remains: +`ritvec` reads `s[k*js + i]` — row `k` — while `imtql2` stores eigenvectors as columns. +Transposing roughly halves the error but doesn't close it. -4. **Consider column masking** for operations that only need a subset of the data +Use `irlba`. It matches LAPACK to 1e-10 on the same fixtures, diagonal case included, and +its memory is bounded. `cargo test --release --all-features -- --ignored +lanczos::tests::report_accuracy_vs_lapack` prints the current error profile. -## License +## Licence -This crate is licensed under the BSD License, the same as the original SVDLIBC implementation. See the `SVDLIBC-LICENSE.txt` file for details. +BSD, same as the original SVDLIBC. See `SVDLIBC-LICENSE.txt`. ## Credits -- Original SVDLIBC implementation by Doug Rohde -- Rust port maintainer of SVDLIBC: Dave Farnham -- Extensions and modifications of the original algorithm: Ian F. Diks +- Original SVDLIBC by Doug Rohde +- Rust port of SVDLIBC by Dave Farnham +- Extensions and modifications by Ian F. Diks diff --git a/benches/common/mod.rs b/benches/common/mod.rs new file mode 100644 index 0000000..66e57a0 --- /dev/null +++ b/benches/common/mod.rs @@ -0,0 +1,78 @@ +//! Fixtures shared by the benchmark targets. +//! +//! Each bench target compiles its own copy, so not every builder is used by both. +#![allow(dead_code)] + +use single_svdlib::SvdMat; +use sprs::CsMatI; + +/// Self-contained LCG so fixtures don't shift with `rand` versions or across machines. +pub struct Lcg(u64); + +impl Lcg { + pub fn new(s: u64) -> Self { + Lcg(s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + pub fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + pub fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + pub fn signed(&mut self) -> f64 { + self.next_f64() * 2.0 - 1.0 + } + pub fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +/// Cells by genes with latent type structure, so the spectrum decays like real data's +/// instead of staying flat. +pub fn counts(rows: usize, cols: usize, per_row: usize, types: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let block = (cols / types.max(1)).max(1); + let mut indptr: Vec = Vec::with_capacity(rows + 1); + let mut indices: Vec = Vec::with_capacity(rows * per_row); + let mut data: Vec = Vec::with_capacity(rows * per_row); + let mut row: Vec<(u32, f64)> = Vec::with_capacity(per_row); + + indptr.push(0); + for _ in 0..rows { + let ty = rng.range(types.max(1)); + row.clear(); + for _ in 0..per_row { + let (g, w) = if rng.next_f64() < 0.7 { + (ty * block + rng.range(block), 8.0) + } else { + (rng.range(cols), 1.0) + }; + row.push((g as u32, (rng.next_f64() * w).floor() + 1.0)); + } + row.sort_unstable_by_key(|&(g, _)| g); + row.dedup_by_key(|&mut (g, _)| g); + for &(g, v) in &row { + indices.push(g); + data.push(v); + } + indptr.push(indices.len() as u64); + } + CsMatI::new((rows, cols), indptr, indices, data) +} + +/// A dense tall-skinny block — what TSQR and reorthogonalization see. +pub fn block(rows: usize, cols: usize, seed: u64) -> ndarray::Array2 { + let mut rng = Lcg::new(seed); + ndarray::Array2::from_shape_fn((rows, cols), |_| rng.signed()) +} + +/// Every `stride`-th column, the "highly variable gene" selection pattern. +pub fn every_nth(cols: usize, stride: usize) -> Vec { + (0..cols).step_by(stride.max(1)).collect() +} diff --git a/benches/kernels.rs b/benches/kernels.rs new file mode 100644 index 0000000..0b6e4e4 --- /dev/null +++ b/benches/kernels.rs @@ -0,0 +1,120 @@ +//! The primitives every solve is built from. A solve issues hundreds of these, so a +//! regression here is a regression everywhere. +//! +//! The two product directions are timed separately: `A·D` on CSR is write-disjoint and +//! needs no scratch, `Aᵀ·D` scatters and runs a different kernel. One combined number +//! would hide a regression in either. +//! +//! ```text +//! cargo bench --bench kernels +//! ``` + +mod common; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use ndarray::Array2; +use single_svdlib::{dense, MaskedCsMat, SparseMat, SparseMatDense}; +use std::hint::black_box; + +fn sparse_products(c: &mut Criterion) { + let a = common::counts(25_000, 2_500, 100, 16, 7); + let (rows, cols, nnz) = (a.rows(), a.cols(), a.nnz()); + + let mut group = c.benchmark_group("sparse_product"); + group.throughput(Throughput::Elements(nnz as u64)); + + let x = vec![1.0f64; cols]; + let mut y = vec![0.0f64; rows]; + group.bench_function("mul_vec/gather", |b| { + b.iter(|| a.mul_vec(black_box(&x), black_box(&mut y), false)) + }); + + let xt = vec![1.0f64; rows]; + let mut yt = vec![0.0f64; cols]; + group.bench_function("mul_vec/scatter", |b| { + b.iter(|| a.mul_vec(black_box(&xt), black_box(&mut yt), true)) + }); + + // Block width decides whether the scatter kernel splits into column blocks to stay + // inside its scratch budget, so sweep it. + for k in [8usize, 32, 64] { + let rhs = Array2::::from_elem((cols, k), 1.0); + let mut out = Array2::::zeros((rows, k)); + group.throughput(Throughput::Elements((nnz * k) as u64)); + group.bench_with_input(BenchmarkId::new("mul_dense/gather", k), &k, |b, _| { + b.iter(|| a.mul_dense(black_box(rhs.view()), black_box(out.view_mut()), false)) + }); + + let rhs_t = Array2::::from_elem((rows, k), 1.0); + let mut out_t = Array2::::zeros((cols, k)); + group.bench_with_input(BenchmarkId::new("mul_dense/scatter", k), &k, |b, _| { + b.iter(|| a.mul_dense(black_box(rhs_t.view()), black_box(out_t.view_mut()), true)) + }); + } + group.finish(); +} + +/// A view costs a lookup per non-zero; an extraction costs one copy. Which wins depends +/// on how many products follow, so time both. +fn masked_view(c: &mut Criterion) { + let a = common::counts(25_000, 2_500, 100, 16, 7); + let selected = common::every_nth(a.cols(), 8); + let view = MaskedCsMat::with_columns(&a, &selected); + let extracted = view.to_sparse(); + + let mut group = c.benchmark_group("masked"); + let x = vec![1.0f64; view.cols()]; + let mut y = vec![0.0f64; view.rows()]; + + group.bench_function("mul_vec/view", |b| { + b.iter(|| view.mul_vec(black_box(&x), black_box(&mut y), false)) + }); + group.bench_function("mul_vec/extracted", |b| { + b.iter(|| extracted.mul_vec(black_box(&x), black_box(&mut y), false)) + }); + group.bench_function("to_sparse", |b| b.iter(|| black_box(view.to_sparse()))); + group.finish(); +} + +/// The explained-variance denominator. The centered form also walks the column means, +/// so it is measurably more work than the plain one. +fn norms(c: &mut Criterion) { + let a = common::counts(25_000, 2_500, 100, 16, 7); + let means = a.col_means(); + + let mut group = c.benchmark_group("norm"); + group.throughput(Throughput::Elements(a.nnz() as u64)); + group.bench_function("squared_frobenius", |b| { + b.iter(|| black_box(a.squared_frobenius())) + }); + group.bench_function("centered_squared_frobenius", |b| { + b.iter(|| black_box(a.centered_squared_frobenius(means.view()))) + }); + group.bench_function("col_means", |b| b.iter(|| black_box(a.col_means()))); + group.finish(); +} + +/// TSQR only switches to panels above `MIN_ROWS_FOR_PANELS`, so cover both regimes. +fn tsqr(c: &mut Criterion) { + let mut group = c.benchmark_group("tsqr"); + for (rows, cols) in [(1_024usize, 32usize), (8_192, 32), (65_536, 64)] { + let m = common::block(rows, cols, 11); + group.throughput(Throughput::Elements((rows * cols) as u64)); + // `tsqr` factors in place, so batch a fresh copy per iteration. + group.bench_with_input( + BenchmarkId::from_parameter(format!("{rows}x{cols}")), + &m, + |b, m| { + b.iter_batched_ref( + || m.clone(), + |m| black_box(dense::tsqr(m).unwrap()), + criterion::BatchSize::LargeInput, + ) + }, + ); + } + group.finish(); +} + +criterion_group!(benches, sparse_products, masked_view, norms, tsqr); +criterion_main!(benches); diff --git a/benches/solvers.rs b/benches/solvers.rs new file mode 100644 index 0000000..19b8957 --- /dev/null +++ b/benches/solvers.rs @@ -0,0 +1,140 @@ +//! End-to-end solves, at the shape and rank a single-cell PCA uses. +//! +//! Slower than the kernel benches, and the only place a restart-policy regression shows +//! up — that leaves every kernel untouched but doubles the work. +//! +//! ```text +//! cargo bench --bench solvers +//! ``` + +mod common; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use single_svdlib::{irlba, randomized, MaskedCsMat, SparseMatDense}; +use std::hint::black_box; +use std::time::Duration; + +/// Big enough for the parallel paths to engage, small enough to finish in minutes. +fn fixture() -> single_svdlib::SvdMat { + common::counts(15_000, 1_500, 80, 16, 7) +} + +fn irlba_ranks(c: &mut Criterion) { + let a = fixture(); + let mut group = c.benchmark_group("irlba"); + group + .sample_size(10) + .measurement_time(Duration::from_secs(20)); + + for rank in [10usize, 30] { + group.bench_with_input(BenchmarkId::new("plain", rank), &rank, |b, &rank| { + b.iter(|| black_box(irlba::svd_seed(&a, rank, 42).unwrap())) + }); + group.bench_with_input(BenchmarkId::new("centered", rank), &rank, |b, &rank| { + b.iter(|| black_box(irlba::svd_centered(&a, rank, Some(42)).unwrap())) + }); + } + group.finish(); +} + +/// `work` moves total time more than anything else: a wider basis costs more per restart +/// but needs far fewer of them. Worth 2.2x at 400k x 30k. +fn irlba_work(c: &mut Criterion) { + let a = fixture(); + let rank = 30usize; + let means = a.col_means(); + + let mut group = c.benchmark_group("irlba_work"); + group + .sample_size(10) + .measurement_time(Duration::from_secs(20)); + // Deduped: these collide for some ranks, and criterion rejects duplicate IDs. + let mut widths = vec![rank + 7, rank + 30, 2 * rank]; + widths.sort_unstable(); + widths.dedup(); + + for work in widths { + group.bench_with_input(BenchmarkId::from_parameter(work), &work, |b, &work| { + b.iter(|| { + black_box( + irlba::svd_with( + &a, + &irlba::IrlbaConfig::new(rank) + .seed(42) + .work(work) + .mean_center(true), + Some(means.clone()), + ) + .unwrap(), + ) + }) + }); + } + group.finish(); +} + +fn randomized_sketches(c: &mut Criterion) { + let a = fixture(); + let rank = 30usize; + + let mut group = c.benchmark_group("randomized"); + group + .sample_size(10) + .measurement_time(Duration::from_secs(20)); + for q in [2usize, 4] { + group.bench_with_input(BenchmarkId::new("power", q), &q, |b, &q| { + b.iter(|| { + black_box( + randomized::svd_with( + &a, + &randomized::RandomizedConfig::new(rank) + .seed(42) + .power_iterations(q), + None, + ) + .unwrap(), + ) + }) + }); + } + group.bench_function("block_krylov/2", |b| { + b.iter(|| black_box(randomized::svd_block_krylov(&a, rank, 2, Some(42)).unwrap())) + }); + group.finish(); +} + +/// Extraction pays one copy and makes every later product cheaper, so time both routes. +fn masked_pca(c: &mut Criterion) { + let a = fixture(); + let selected = common::every_nth(a.cols(), 6); + let view = MaskedCsMat::with_columns(&a, &selected); + let extracted = view.to_sparse(); + let rank = 20usize; + + let mut group = c.benchmark_group("masked_pca"); + group + .sample_size(10) + .measurement_time(Duration::from_secs(20)); + group.bench_function("view", |b| { + b.iter(|| black_box(irlba::svd_centered(&view, rank, Some(42)).unwrap())) + }); + group.bench_function("extracted", |b| { + b.iter(|| black_box(irlba::svd_centered(&extracted, rank, Some(42)).unwrap())) + }); + group.bench_function("extract_then_solve", |b| { + b.iter(|| { + let sub = view.to_sparse(); + black_box(irlba::svd_centered(&sub, rank, Some(42)).unwrap()) + }) + }); + group.finish(); +} + +criterion_group!( + benches, + irlba_ranks, + irlba_work, + randomized_sketches, + masked_pca +); +criterion_main!(benches); diff --git a/examples/pca_at_scale.rs b/examples/pca_at_scale.rs new file mode 100644 index 0000000..6680add --- /dev/null +++ b/examples/pca_at_scale.rs @@ -0,0 +1,231 @@ +//! PCA on a very large sparse matrix, restricted to a subset of columns, without ever +//! densifying or modifying it. +//! +//! Shaped like a single-cell workload: cells x genes, a few hundred non-zeros per cell, +//! restricted to a "highly variable gene" subset, reduced to 50 components. +//! +//! ```text +//! cargo run --release --example pca_at_scale +//! cargo run --release --example pca_at_scale 500000 30000 200 2000 50 +//! ``` +//! +//! Arguments: `cells genes nnz_per_cell selected_genes components`. + +use single_svdlib::{irlba, MaskedCsMat, SparseMat, SparseMatDense, SvdMat}; +use sprs::CsMatI; +use std::time::Instant; + +struct Lcg(u64); +impl Lcg { + fn new(s: u64) -> Self { + Lcg(s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +fn gib(bytes: usize) -> f64 { + bytes as f64 / (1024.0 * 1024.0 * 1024.0) +} + +/// Build the CSR arrays directly rather than going through a triplet matrix, which +/// would need a second full copy of the data before conversion. +/// +/// The cells carry a latent factor structure — a handful of "types", each with its own +/// gene programme — so the spectrum decays the way real data's does. Uniform noise would +/// give a flat Marchenko-Pastur spectrum, which is a far harder case for any Krylov +/// method than anything measured on real counts. +fn build(cells: usize, genes: usize, per_cell: usize, types: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + + // Each type prefers a contiguous block of marker genes. + let block = (genes / types.max(1)).max(1); + + let nnz_est = cells * per_cell; + let mut indptr: Vec = Vec::with_capacity(cells + 1); + let mut indices: Vec = Vec::with_capacity(nnz_est); + let mut data: Vec = Vec::with_capacity(nnz_est); + let mut row: Vec<(u32, f64)> = Vec::with_capacity(per_cell); + + indptr.push(0); + for _ in 0..cells { + let ty = rng.range(types.max(1)); + row.clear(); + for _ in 0..per_cell { + // Most counts land in this cell type's programme; the rest is background. + let (g, weight) = if rng.next_f64() < 0.7 { + let lo = ty * block; + (lo + rng.range(block), 8.0) + } else { + (rng.range(genes), 1.0) + }; + let count = (rng.next_f64() * weight).floor() + 1.0; + row.push((g as u32, count)); + } + row.sort_unstable_by_key(|&(g, _)| g); + row.dedup_by_key(|&mut (g, _)| g); + for &(g, v) in &row { + indices.push(g); + data.push(v); + } + indptr.push(indices.len() as u64); + } + CsMatI::new((cells, genes), indptr, indices, data) +} + +fn main() { + let a: Vec = std::env::args() + .skip(1) + .filter_map(|s| s.parse().ok()) + .collect(); + let cells = a.first().copied().unwrap_or(1_000_000); + let genes = a.get(1).copied().unwrap_or(30_000); + let per_cell = a.get(2).copied().unwrap_or(150); + let n_selected = a.get(3).copied().unwrap_or(2_000); + let components = a.get(4).copied().unwrap_or(50); + + println!("building {cells} cells x {genes} genes, ~{per_cell} nnz/cell, 24 latent types ..."); + let t0 = Instant::now(); + let types = 24; + let matrix = build(cells, genes, per_cell, types, 7); + let build_time = t0.elapsed(); + + let bytes = matrix.indptr().len() * 8 + matrix.indices().len() * 4 + matrix.data().len() * 8; + let as_usize = matrix.indptr().len() * 8 + matrix.indices().len() * 8 + matrix.data().len() * 8; + let as_dense = cells * genes * 8; + println!(" {:?}, nnz = {}", build_time, matrix.nnz()); + println!(" sparse (u32/u64 indices) : {:>8.2} GiB", gib(bytes)); + println!( + " sparse (usize indices) : {:>8.2} GiB (+{:.0}%)", + gib(as_usize), + 100.0 * (as_usize as f64 / bytes as f64 - 1.0) + ); + println!( + " the same matrix dense : {:>8.2} GiB ({:.0}x larger)", + gib(as_dense), + as_dense as f64 / bytes as f64 + ); + + // "Highly variable genes": every k-th gene, plus the marker block. + let stride = (genes / n_selected).max(1); + let mut selected: Vec = (0..genes).step_by(stride).collect(); + selected.extend(0..256); + selected.sort_unstable(); + selected.dedup(); + println!("\nselecting {} of {genes} genes", selected.len()); + + // ---- the view: no copy, source untouched ---- + let t = Instant::now(); + let view = MaskedCsMat::with_columns(&matrix, &selected); + let view_build = t.elapsed(); + println!( + " view built in {:?}: {} x {}, {} nnz in mask ({:.1}% of the source)", + view_build, + view.rows(), + view.cols(), + view.nnz(), + 100.0 * view.nnz() as f64 / matrix.nnz() as f64 + ); + + println!("\nPCA on the view ({components} components, mean-centered) ..."); + let t = Instant::now(); + let pca_view = irlba::svd_centered(&view, components, Some(42)).expect("PCA on view failed"); + let view_time = t.elapsed(); + let (restarts, converged) = match pca_view.diagnostics.detail { + single_svdlib::Detail::Irlba { + restarts, + converged, + .. + } => (restarts, converged), + _ => unreachable!(), + }; + println!( + " {:?} restarts={restarts} converged={converged} matvecs={}", + view_time, pca_view.diagnostics.matvecs + ); + println!( + " scores (cells x PCs) = {:?}, loadings (PCs x genes) = {:?}", + pca_view.u.dim(), + pca_view.vt.dim() + ); + println!( + " sigma[0] = {:.4} sigma[{}] = {:.4}", + pca_view.s[0], + components - 1, + pca_view.s[components - 1] + ); + + // ---- the extraction: one sparse copy, then every product is cheaper ---- + println!("\nextracting the same submatrix (still sparse) ..."); + let t = Instant::now(); + let extracted = view.to_sparse(); + let extract_time = t.elapsed(); + let ex_bytes = + extracted.indptr().len() * 8 + extracted.indices().len() * 4 + extracted.data().len() * 8; + println!( + " {:?}, {} x {}, {} nnz, {:.2} GiB", + extract_time, + extracted.rows(), + extracted.cols(), + extracted.nnz(), + gib(ex_bytes) + ); + + let t = Instant::now(); + let pca_copy = + irlba::svd_centered(&extracted, components, Some(42)).expect("PCA on extraction failed"); + let copy_time = t.elapsed(); + println!( + " PCA {:?} matvecs={}", + copy_time, pca_copy.diagnostics.matvecs + ); + + // ---- agreement ---- + let worst = (0..components) + .map(|i| (pca_view.s[i] - pca_copy.s[i]).abs() / pca_view.s[0]) + .fold(0.0f64, f64::max); + println!("\nview vs extraction: max relative difference = {worst:.3e}"); + + // Independent check that this really is the PCA of the centered submatrix: + // ||(A_sub - 1*mean^T) v_i - sigma_i u_i|| must vanish. + let means = view.col_means(); + let mut worst_resid: f64 = 0.0; + for i in 0..components.min(5) { + let vi: Vec = pca_view.vt.row(i).to_vec(); + let mut av = vec![0.0; view.rows()]; + view.mul_vec(&vi, &mut av, false); + let shift: f64 = means.iter().zip(vi.iter()).map(|(&m, &v)| m * v).sum(); + let resid: f64 = av + .iter() + .zip(pca_view.u.column(i).iter()) + .map(|(&x, &ui)| { + let e = (x - shift) - pca_view.s[i] * ui; + e * e + }) + .sum::() + .sqrt(); + worst_resid = worst_resid.max(resid / pca_view.s[0]); + } + println!("centered residual ||A_c v - s u|| / sigma_max = {worst_resid:.3e}"); + + println!( + "\nsummary: view {:?} (no copy) | extract {:?} + PCA {:?} = {:?}", + view_time, + extract_time, + copy_time, + extract_time + copy_time + ); +} diff --git a/examples/scale.rs b/examples/scale.rs new file mode 100644 index 0000000..3da8dbc --- /dev/null +++ b/examples/scale.rs @@ -0,0 +1,157 @@ +//! Scale check at single-cell-like dimensions. +//! +//! Reports matrix footprint, wall time and agreement between the solvers on a +//! 200k × 30k operand. Run under `/usr/bin/time -l` (macOS) or `/usr/bin/time -v` +//! (Linux) to see peak RSS. +//! +//! Pass a solver name to measure one in isolation, so peak RSS is attributable: +//! +//! ```text +//! cargo run --release --example scale # all three +//! cargo run --release --example scale irlba +//! cargo run --release --example scale randomized +//! cargo run --release --example scale krylov +//! ``` + +use single_svdlib::{irlba, randomized, SvdMat}; +use sprs::TriMatI; +use std::time::Instant; + +struct Lcg(u64); +impl Lcg { + fn new(s: u64) -> Self { + Lcg(s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +fn mib(bytes: usize) -> f64 { + bytes as f64 / 1024.0 / 1024.0 +} + +fn main() { + let which = std::env::args().nth(1).unwrap_or_else(|| "all".into()); + let run = |name: &str| which == "all" || which == name; + + let (rows, cols) = (200_000usize, 30_000usize); + let nnz_per_row = 25usize; // ~0.083% dense, typical of a count matrix + let rank = 50usize; + + println!("building {rows} x {cols}, ~{} nnz ...", rows * nnz_per_row); + let t0 = Instant::now(); + let mut tri = TriMatI::::new((rows, cols)); + let mut rng = Lcg::new(7); + for i in 0..rows { + // A handful of "marker" columns carry extra weight, so the spectrum has real + // structure rather than being flat noise. + for _ in 0..nnz_per_row { + let j = if rng.next_f64() < 0.3 { + rng.range(64) + } else { + rng.range(cols) + }; + tri.add_triplet(i, j, rng.next_f64() * 10.0); + } + } + let a: SvdMat = tri.to_csr::(); + // Release the triplet buffers before solving so the reported peak reflects the + // solver, not the loader. + drop(tri); + println!(" built in {:?}", t0.elapsed()); + + let idx_bytes = std::mem::size_of_val(a.indices()); + let ptr_bytes = a.indptr().len() * std::mem::size_of::(); + let val_bytes = std::mem::size_of_val(a.data()); + let total = idx_bytes + ptr_bytes + val_bytes; + let usize_equiv = a.indices().len() * std::mem::size_of::() + + a.indptr().len() * std::mem::size_of::() + + val_bytes; + println!( + " nnz = {}, matrix = {:.1} MiB (u32/u64 indices)", + a.nnz(), + mib(total) + ); + println!( + " the same matrix with usize indices = {:.1} MiB ({:.0}% larger)", + mib(usize_equiv), + 100.0 * (usize_equiv as f64 / total as f64 - 1.0) + ); + + // Basis memory IRLBA will hold, known before the solve starts. + let work = rank + 7; + let basis = ((work + 1) * cols + work * rows) * std::mem::size_of::(); + println!( + " irlba basis (work = {work}) = {:.1} MiB, fixed\n", + mib(basis) + ); + + let mut by_irlba = None; + if run("irlba") { + println!("irlba rank {rank} ..."); + let t = Instant::now(); + let rec = irlba::svd_seed(&a, rank, 42).expect("irlba failed"); + let elapsed = t.elapsed(); + let (restarts, converged) = match rec.diagnostics.detail { + single_svdlib::Detail::Irlba { + restarts, + converged, + .. + } => (restarts, converged), + _ => unreachable!(), + }; + println!( + " {elapsed:?} restarts={restarts} converged={converged} matvecs={}", + rec.diagnostics.matvecs + ); + println!( + " sigma[0]={:.6} sigma[{}]={:.6}", + rec.s[0], + rank - 1, + rec.s[rank - 1] + ); + by_irlba = Some(rec); + } + + let mut others: Vec<(&str, single_svdlib::SvdRec)> = Vec::new(); + if run("randomized") { + println!("\nrandomized rank {rank}, 2 power iterations ..."); + let t = Instant::now(); + let rec = randomized::svd_seed(&a, rank, 42).expect("randomized failed"); + println!(" {:?} matvecs={}", t.elapsed(), rec.diagnostics.matvecs); + others.push(("randomized", rec)); + } + if run("krylov") { + println!("\nrandomized rank {rank}, block krylov x3 ..."); + let t = Instant::now(); + let rec = randomized::svd_block_krylov(&a, rank, 3, Some(42)).expect("block krylov failed"); + println!(" {:?} matvecs={}", t.elapsed(), rec.diagnostics.matvecs); + others.push(("block krylov", rec)); + } + + // IRLBA converged to a residual tolerance, so treat it as the reference. + if let Some(reference) = &by_irlba { + if !others.is_empty() { + println!("\nagreement against irlba (relative, over the top {rank}):"); + for (name, rec) in &others { + let worst = (0..rank) + .map(|i| (rec.s[i] - reference.s[i]).abs() / reference.s[i]) + .fold(0.0f64, f64::max); + println!(" {name:<14} max rel diff = {worst:.3e}"); + } + } + } +} diff --git a/examples/tune.rs b/examples/tune.rs new file mode 100644 index 0000000..970abd3 --- /dev/null +++ b/examples/tune.rs @@ -0,0 +1,187 @@ +//! Which configuration to use for a large cells-by-genes PCA. +//! +//! Builds one matrix, then times every reasonable way to get the same components out of +//! it, checking each against the most accurate result. Run it on a shape resembling your +//! own data and use the table it prints. +//! +//! ```text +//! cargo run --release --example tune # 400k x 30k, 2k genes, 50 PCs +//! cargo run --release --example tune 1000000 30000 150 2000 50 +//! ``` + +use single_svdlib::{irlba, randomized, MaskedCsMat, SparseMat, SparseMatDense, SvdMat, SvdRec}; +use sprs::CsMatI; +use std::time::{Duration, Instant}; + +struct Lcg(u64); +impl Lcg { + fn new(s: u64) -> Self { + Lcg(s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +/// Cells with a latent type structure, so the spectrum decays like real data's. +fn build(cells: usize, genes: usize, per_cell: usize, types: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let block = (genes / types.max(1)).max(1); + let mut indptr: Vec = Vec::with_capacity(cells + 1); + let mut indices: Vec = Vec::with_capacity(cells * per_cell); + let mut data: Vec = Vec::with_capacity(cells * per_cell); + let mut row: Vec<(u32, f64)> = Vec::with_capacity(per_cell); + + indptr.push(0); + for _ in 0..cells { + let ty = rng.range(types.max(1)); + row.clear(); + for _ in 0..per_cell { + let (g, w) = if rng.next_f64() < 0.7 { + (ty * block + rng.range(block), 8.0) + } else { + (rng.range(genes), 1.0) + }; + row.push((g as u32, (rng.next_f64() * w).floor() + 1.0)); + } + row.sort_unstable_by_key(|&(g, _)| g); + row.dedup_by_key(|&mut (g, _)| g); + for &(g, v) in &row { + indices.push(g); + data.push(v); + } + indptr.push(indices.len() as u64); + } + CsMatI::new((cells, genes), indptr, indices, data) +} + +fn main() { + let a: Vec = std::env::args() + .skip(1) + .filter_map(|s| s.parse().ok()) + .collect(); + let cells = a.first().copied().unwrap_or(400_000); + let genes = a.get(1).copied().unwrap_or(30_000); + let per_cell = a.get(2).copied().unwrap_or(150); + let n_sel = a.get(3).copied().unwrap_or(2_000); + let k = a.get(4).copied().unwrap_or(50); + + println!("building {cells} x {genes}, ~{per_cell} nnz/cell ..."); + let matrix = build(cells, genes, per_cell, 24, 7); + println!(" nnz = {}", matrix.nnz()); + + let stride = (genes / n_sel).max(1); + let mut sel: Vec = (0..genes).step_by(stride).collect(); + sel.extend(0..256); + sel.sort_unstable(); + sel.dedup(); + + let view = MaskedCsMat::with_columns(&matrix, &sel); + let t = Instant::now(); + let sub = view.to_sparse(); + let extract = t.elapsed(); + println!( + " mask keeps {} genes, {} nnz ({:.1}%); extraction took {:?}\n", + sel.len(), + view.nnz(), + 100.0 * view.nnz() as f64 / matrix.nnz() as f64, + extract + ); + + // The reference: IRLBA converges to a residual tolerance, so trust it. + println!("computing a reference (irlba, tight tolerance) ..."); + let reference = irlba::svd_with( + &sub, + &irlba::IrlbaConfig::new(k) + .seed(42) + .tol(1e-12) + .mean_center(true), + Some(sub.col_means()), + ) + .expect("reference failed"); + println!( + " sigma[0] = {:.2}, sigma[{}] = {:.2}\n", + reference.s[0], + k - 1, + reference.s[k - 1] + ); + + let err = |r: &SvdRec| { + (0..k) + .map(|i| (r.s[i] - reference.s[i]).abs() / reference.s[0]) + .fold(0.0f64, f64::max) + }; + + let mut rows: Vec<(String, Duration, f64, usize)> = Vec::new(); + let mut run = |label: String, f: &dyn Fn() -> SvdRec| { + let t = Instant::now(); + let r = f(); + let d = t.elapsed(); + println!(" {label:<34} {:>9.2?} rel {:.2e}", d, err(&r)); + rows.push((label, d, err(&r), r.diagnostics.matvecs)); + }; + + println!("on the masked VIEW (no copy):"); + run("irlba, default work".into(), &|| { + irlba::svd_centered(&view, k, Some(42)).unwrap() + }); + + println!("\non the EXTRACTED submatrix (one sparse copy):"); + for w in [k + 7, k + 30, 2 * k, 3 * k] { + run(format!("irlba, work={w}"), &|| { + irlba::svd_with( + &sub, + &irlba::IrlbaConfig::new(k) + .seed(42) + .work(w) + .mean_center(true), + Some(sub.col_means()), + ) + .unwrap() + }); + } + for q in [2usize, 4, 7] { + run(format!("randomized, {q} power iterations"), &|| { + randomized::svd_with( + &sub, + &randomized::RandomizedConfig::new(k) + .seed(42) + .power_iterations(q) + .mean_center(true), + None, + ) + .unwrap() + }); + } + for b in [2usize, 3] { + run(format!("randomized, block krylov x{b}"), &|| { + randomized::svd_with( + &sub, + &randomized::RandomizedConfig::new(k) + .seed(42) + .block_krylov(b) + .mean_center(true), + None, + ) + .unwrap() + }); + } + + rows.sort_by_key(|r| r.1); + println!("\nfastest first (relative error against the reference):"); + for (label, d, e, mv) in &rows { + println!(" {label:<34} {:>9.2?} rel {:.2e} matvecs {mv}", d, e); + } +} diff --git a/src/dense/jacobi.rs b/src/dense/jacobi.rs new file mode 100644 index 0000000..59434fe --- /dev/null +++ b/src/dense/jacobi.rs @@ -0,0 +1,375 @@ +//! One-sided Jacobi SVD. +//! +//! # Why not the bidiagonal QR the linear-algebra backend provides +//! +//! Golub–Reinsch (what `nalgebra::SVD` implements, and LAPACK's `gesvd`) first reduces +//! to bidiagonal form. That reduction mixes columns of wildly different norm, so the +//! small singular values inherit an absolute error proportional to `‖A‖` rather than to +//! themselves. On a `5 × 2` operand with `κ ≈ 8·10⁶` this crate measured +//! `‖A − UΣVᵀ‖ / ‖A‖ ≈ 1.3·10⁻⁹` — nine orders worse than a backward-stable +//! factorization should give, and independent of the iteration limit or tolerance. +//! +//! One-sided Jacobi never forms a bidiagonal. It rotates *pairs of columns* until they +//! are mutually orthogonal, at which point the column norms are the singular values. +//! Demmel & Veselić showed this is accurate to `O(ε · κ(A·D⁻¹))` — the condition number +//! *after* optimal column scaling — so a matrix that is merely badly scaled, which is +//! exactly what an ill-conditioned Krylov basis looks like, is factored to full relative +//! accuracy. +//! +//! That matters here because [`small_svd`](super::small_svd) is applied to the reduced +//! `B` (IRLBA) and `R` (randomized) factors, whose conditioning is inherited from the +//! operand and decides the accuracy of everything downstream. +//! +//! The operands are `l × l` with `l` on the order of the requested rank, so the extra +//! sweeps cost nothing measurable next to the sparse products. + +use ndarray::{Array1, Array2}; + +/// Sweeps before giving up. Jacobi converges quadratically; more than a dozen sweeps +/// means the operand is pathological. +const MAX_SWEEPS: usize = 60; + +/// A thin SVD computed by one-sided Jacobi: `a = u · diag(s) · vᵀ`, `s` descending. +pub struct JacobiSvd { + /// `m × n` with orthonormal columns. + pub u: Array2, + /// Length `n`, descending, non-negative. + pub s: Array1, + /// `n × n` orthogonal; the *right* vectors as columns. + pub v: Array2, +} + +/// One-sided Jacobi SVD of a tall-or-square matrix (`m >= n`). +/// +/// Returns `None` only on a non-finite operand. Exhausting the sweep budget is not an +/// error: the iterate in hand is a valid partial factorization with very nearly +/// orthogonal columns, and returning it beats failing the caller's whole decomposition. +pub fn jacobi_svd(a: &Array2) -> Option { + let (m, n) = a.dim(); + debug_assert!( + m >= n, + "one-sided Jacobi needs at least as many rows as columns" + ); + + let mut w = a.clone(); // becomes U·Σ + let mut v = Array2::::eye(n); + // Convergence threshold on the inter-column cosine. + let tol = f64::EPSILON * (m as f64).sqrt(); + + // Flush columns that are negligible against the largest to exact zero. + // + // A column with norm around `1e-156` next to one around `1e0` carries no information + // — every singular value it could contribute is far below the rounding floor of the + // rest — but it does drive the rotation arithmetic into the denormal range, where + // the pair can never satisfy any orthogonality test and the sweeps churn forever. + // Zeroing it here routes it through the rank-deficiency path instead, which gives it + // a proper orthonormal left vector. + { + let norms: Vec = (0..n) + .map(|j| (0..m).map(|i| w[[i, j]] * w[[i, j]]).sum::().sqrt()) + .collect(); + let biggest = norms.iter().copied().fold(0.0f64, f64::max); + if biggest > 0.0 { + let cutoff = biggest * f64::EPSILON * f64::EPSILON; + for j in 0..n { + if norms[j] <= cutoff { + for i in 0..m { + w[[i, j]] = 0.0; + } + } + } + } + } + + // Rotate every column pair until they are mutually orthogonal. + for _ in 0..MAX_SWEEPS { + let mut rotations = 0usize; + for p in 0..n.saturating_sub(1) { + for q in (p + 1)..n { + let mut app = 0.0; + let mut aqq = 0.0; + let mut apq = 0.0; + for i in 0..m { + let (x, y) = (w[[i, p]], w[[i, q]]); + app += x * x; + aqq += y * y; + apq += x * y; + } + if !apq.is_finite() || !app.is_finite() || !aqq.is_finite() { + return None; + } + // A zero column is orthogonal to everything by definition, and the + // relative test below would divide by its (zero) norm. + if app <= 0.0 || aqq <= 0.0 { + continue; + } + // Orthogonal enough already? The test is on the *cosine* between the two + // columns, relative to their norms — that is what makes the method + // relatively accurate, and it is the quantity that becomes an entry of + // `UᵀU` once the columns are normalised. + // + // The threshold carries a `sqrt(m)` factor rather than being bare `eps`. + // At exactly `eps` the cosine can hover on the boundary, each rotation + // re-injecting O(eps) error into the pair it just fixed, and the sweep + // never reports zero rotations. Do *not* instead skip on a small rotation + // angle: `s ≈ 1/(2·tau)` is tiny whenever the two columns differ greatly + // in norm, even when their cosine is nowhere near zero, so that test + // silently abandons badly scaled pairs and leaves `U` orthogonal to only + // ~1e-10. + // + // `sqrt(app) * sqrt(aqq)`, never `sqrt(app * aqq)`: the product + // underflows to exactly zero once the column norms are small enough + // (`app = 9e-312` against `aqq = 2e-158` is reachable from ordinary + // input), which turns the test into `|apq| <= 0`. No non-zero cosine can + // satisfy that, so the pair rotates on every sweep forever and the + // factorization reports non-convergence. + if apq == 0.0 || apq.abs() <= tol * app.sqrt() * aqq.sqrt() { + continue; + } + rotations += 1; + + // The rotation that zeroes the (p, q) entry of the 2x2 Gram matrix. + let tau = (aqq - app) / (2.0 * apq); + let t = if tau >= 0.0 { + 1.0 / (tau + (1.0 + tau * tau).sqrt()) + } else { + -1.0 / (-tau + (1.0 + tau * tau).sqrt()) + }; + let c = 1.0 / (1.0 + t * t).sqrt(); + let s = c * t; + + for i in 0..m { + let (x, y) = (w[[i, p]], w[[i, q]]); + w[[i, p]] = c * x - s * y; + w[[i, q]] = s * x + c * y; + } + for i in 0..n { + let (x, y) = (v[[i, p]], v[[i, q]]); + v[[i, p]] = c * x - s * y; + v[[i, q]] = s * x + c * y; + } + } + } + if rotations == 0 { + break; + } + } + // The column norms are the singular values; normalising gives U. + let mut sigma = Array1::::zeros(n); + for j in 0..n { + let norm = (0..m).map(|i| w[[i, j]] * w[[i, j]]).sum::().sqrt(); + if !norm.is_finite() { + return None; + } + sigma[j] = norm; + } + + // Descending, carrying the vectors along. + let mut order: Vec = (0..n).collect(); + order.sort_by(|&i, &j| { + sigma[j] + .partial_cmp(&sigma[i]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + // A column whose norm has fallen to the rounding floor holds noise, not direction: + // its entries are the residue of cancellation and are *not* orthogonal to the other + // columns. Normalising it would amplify that noise into a unit vector and destroy + // the orthonormality of `u` — on a matrix with a duplicated column this produced + // `||UᵀU − I|| = 0.6`. The test has to be relative to the largest singular value. + let smax = sigma.iter().copied().fold(0.0f64, f64::max); + let floor = f64::EPSILON * smax * (m as f64).sqrt(); + + let mut u = Array2::::zeros((m, n)); + let mut s_out = Array1::::zeros(n); + let mut v_out = Array2::::zeros((n, n)); + let mut deficient = Vec::new(); + for (new, &old) in order.iter().enumerate() { + s_out[new] = sigma[old]; + for i in 0..n { + v_out[[i, new]] = v[[i, old]]; + } + if sigma[old] > floor { + let inv = 1.0 / sigma[old]; + for i in 0..m { + u[[i, new]] = w[[i, old]] * inv; + } + } else { + deficient.push(new); + } + } + + // A numerically zero singular value leaves its left vector undefined. Callers rotate + // bases with `u`, so it has to be a genuine orthonormal basis rather than have holes + // in it: complete the deficient columns against the ones that are defined. + complete_orthonormal_basis(&mut u, &deficient); + + Some(JacobiSvd { + u, + s: s_out, + v: v_out, + }) +} + +/// Replace the listed columns of `u` with unit vectors orthogonal to every other column. +fn complete_orthonormal_basis(u: &mut Array2, deficient: &[usize]) { + let (m, n) = u.dim(); + if deficient.is_empty() { + return; + } + // For each hole, test *every* canonical direction and take the one that survives + // projection best. Accepting the first merely-nonzero candidate is not enough: a + // direction that is nearly dependent on the existing columns leaves a tiny residual, + // and normalising it scales the Gram-Schmidt error up by the reciprocal of that + // residual. Accepting a residual of `1e-8` therefore admits an orthogonality error + // of the same order, which showed up as `||UᵀU - I|| = 2e-7`. Picking the largest + // residual keeps the amplification at O(1). + let mut used = vec![false; m]; + for &j in deficient { + let mut best: Option<(f64, Array1, usize)> = None; + for cand in 0..m { + if used[cand] { + continue; + } + let mut trial = Array1::::zeros(m); + trial[cand] = 1.0; + + // Orthogonalise against every column already established, twice. + for _ in 0..2 { + for k in 0..n { + if k == j { + continue; + } + let dot: f64 = (0..m).map(|i| u[[i, k]] * trial[i]).sum(); + if dot != 0.0 { + for i in 0..m { + trial[i] -= dot * u[[i, k]]; + } + } + } + } + let norm = trial.iter().map(|x| x * x).sum::().sqrt(); + if best.as_ref().is_none_or(|(b, _, _)| norm > *b) { + best = Some((norm, trial, cand)); + } + // A residual this large cannot be improved on meaningfully; stop early. + if norm > 0.9 { + break; + } + } + if let Some((norm, trial, cand)) = best { + if norm > 0.0 { + used[cand] = true; + for i in 0..m { + u[[i, j]] = trial[i] / norm; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::Lcg; + + fn frob(a: &Array2) -> f64 { + a.iter().map(|v| v * v).sum::().sqrt() + } + + fn recompose(svd: &JacobiSvd) -> Array2 { + let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0)); + scaled.dot(&svd.v.t()) + } + + /// The exact counterexample proptest shrank to, on which nalgebra's Golub-Reinsch + /// reconstructs to only ~1.3e-9 relative. Jacobi must do far better. + #[test] + fn beats_bidiagonal_qr_on_the_ill_conditioned_counterexample() { + let a = ndarray::arr2(&[ + [0.0, 0.0], + [0.0, 0.0], + [0.0, 0.0], + [0.8977478193857099, -1.0], + [0.0, 7255691.862956913], + ]); + let svd = jacobi_svd(&a).expect("should converge"); + let err = frob(&(&recompose(&svd) - &a)) / frob(&a); + assert!( + err < 1e-15, + "Jacobi reconstruction {err:.3e} is no better than bidiagonal QR's 1.3e-9" + ); + let orth = crate::dense::orthogonality_error(&svd.u.view()); + assert!(orth < 1e-13, "||U^T U - I|| = {orth:.3e}"); + } + + #[test] + fn matches_a_reference_on_well_conditioned_input() { + let mut rng = Lcg::new(3); + let a = Array2::from_shape_fn((12, 8), |_| rng.signed()); + let svd = jacobi_svd(&a).unwrap(); + + let m = nalgebra::DMatrix::from_fn(12, 8, |i, j| a[[i, j]]); + let mut want: Vec = m.singular_values().iter().copied().collect(); + want.sort_by(|x, y| y.partial_cmp(x).unwrap()); + + for (i, &g) in svd.s.iter().enumerate() { + approx::assert_relative_eq!(g, want[i], max_relative = 1e-12); + } + let err = frob(&(&recompose(&svd) - &a)) / frob(&a); + assert!(err < 1e-14, "reconstruction {err:.3e}"); + } + + /// A rank-deficient operand: the zero singular values must still come with an + /// orthonormal `u`, since callers rotate bases with it. + #[test] + fn rank_deficient_still_yields_an_orthonormal_basis() { + // Column 2 is a copy of column 0, column 4 is zero. + let mut rng = Lcg::new(5); + let mut a = Array2::from_shape_fn((10, 5), |_| rng.signed()); + let c0 = a.column(0).to_owned(); + a.column_mut(2).assign(&c0); + a.column_mut(4).fill(0.0); + + let svd = jacobi_svd(&a).unwrap(); + assert!( + svd.s[4] < 1e-14, + "expected a zero singular value, got {}", + svd.s[4] + ); + + let orth = crate::dense::orthogonality_error(&svd.u.view()); + assert!( + orth < 1e-12, + "||U^T U - I|| = {orth:.3e} on a deficient operand" + ); + + let err = frob(&(&recompose(&svd) - &a)) / frob(&a); + assert!(err < 1e-14, "reconstruction {err:.3e}"); + } + + #[test] + fn all_zero_operand() { + let a = Array2::::zeros((6, 3)); + let svd = jacobi_svd(&a).unwrap(); + assert!(svd.s.iter().all(|&v| v == 0.0)); + let orth = crate::dense::orthogonality_error(&svd.u.view()); + assert!( + orth < 1e-12, + "||U^T U - I|| = {orth:.3e} on the zero matrix" + ); + } + + #[test] + fn single_column() { + let a = ndarray::arr2(&[[3.0], [4.0]]); + let svd = jacobi_svd(&a).unwrap(); + approx::assert_relative_eq!(svd.s[0], 5.0, max_relative = 1e-14); + } + + #[test] + fn rejects_non_finite() { + let a = ndarray::arr2(&[[1.0, 0.0], [0.0, f64::NAN]]); + assert!(jacobi_svd(&a).is_none()); + } +} diff --git a/src/dense/mod.rs b/src/dense/mod.rs new file mode 100644 index 0000000..5ea501f --- /dev/null +++ b/src/dense/mod.rs @@ -0,0 +1,196 @@ +//! Dense helpers: tall-skinny QR, and small factorizations on the reduced matrices the +//! Krylov and randomized methods produce. + +pub mod jacobi; +pub mod tsqr; + +pub use tsqr::{orthogonality_error, orthonormalize, tsqr}; + +use crate::error::{Result, SvdLibError}; +use crate::types::SvdFloat; +use ndarray::{Array1, Array2, ArrayView2}; + +/// A thin SVD of a small dense matrix: `a ≈ u · diag(s) · vt`, `s` descending. +pub struct SmallSvd { + /// `m × k`, `k = min(m, n)`. + pub u: Array2, + /// Length `k`, descending. + pub s: Array1, + /// `k × n`. + pub vt: Array2, +} + +/// Thin SVD of a small dense matrix. +/// +/// Always computed in `f64` and cast back, whatever `T` is. These operands are `l × l` +/// or `l × n` with `l` on the order of the requested rank, so the widening costs +/// nothing measurable and it keeps `f32` callers from losing accuracy in the one place +/// where the whole result's conditioning is decided. +/// +/// Uses [one-sided Jacobi](jacobi) rather than the bidiagonal QR a linear-algebra +/// backend would provide. Jacobi is accurate to the condition number *after* column +/// scaling, so a badly scaled reduced factor — which is what an ill-conditioned Krylov +/// basis produces — is still factored to full relative accuracy. Golub–Reinsch is not: +/// on a `5 × 2` operand with `κ ≈ 8·10⁶`, `nalgebra`'s implementation reconstructed to +/// only `1.3·10⁻⁹` relative, against Jacobi's `< 1·10⁻¹⁵`. +pub fn small_svd(a: ArrayView2) -> Result> { + let (m, n) = a.dim(); + if m == 0 || n == 0 { + return Err(SvdLibError::shape(format!( + "small_svd needs a non-empty matrix, got {m}x{n}" + ))); + } + let k = m.min(n); + + // Fail fast and legibly on a poisoned operand, rather than letting it reach the + // factorization and surface as an opaque non-convergence. + if let Some((i, j)) = a + .indexed_iter() + .find(|(_, v)| !num_traits::Float::is_finite(**v)) + .map(|(idx, _)| idx) + { + return Err(SvdLibError::DenseFactorization { + factorization: "SVD", + message: format!("operand is not finite at ({i}, {j})"), + }); + } + + let a64 = Array2::::from_shape_fn((m, n), |(i, j)| a[[i, j]].to_f64()); + let fail = || SvdLibError::DenseFactorization { + factorization: "SVD", + message: format!("one-sided Jacobi did not converge on a {m}x{n} operand"), + }; + + // Jacobi needs at least as many rows as columns. For a wide operand factor the + // transpose instead: `Aᵀ = U·Σ·Vᵀ` gives `A = V·Σ·Uᵀ`. + let (u64, s64, vt64) = if m >= n { + let j = jacobi::jacobi_svd(&a64).ok_or_else(fail)?; + (j.u, j.s, j.v.t().to_owned()) + } else { + let j = jacobi::jacobi_svd(&a64.t().to_owned()).ok_or_else(fail)?; + (j.v, j.s, j.u.t().to_owned()) + }; + debug_assert_eq!(u64.dim(), (m, k)); + debug_assert_eq!(vt64.dim(), (k, n)); + + // Jacobi already returns the singular values descending. + let mut out_u = Array2::::zeros((m, k)); + let mut out_s = Array1::::zeros(k); + let mut out_vt = Array2::::zeros((k, n)); + for idx in 0..k { + out_s[idx] = T::from_f64_val(s64[idx]); + for i in 0..m { + out_u[[i, idx]] = T::from_f64_val(u64[[i, idx]]); + } + for j in 0..n { + out_vt[[idx, j]] = T::from_f64_val(vt64[[idx, j]]); + } + } + Ok(SmallSvd { + u: out_u, + s: out_s, + vt: out_vt, + }) +} + +/// Flip the sign of each singular-vector pair so the dominant entry of every column of +/// `u` is positive. +/// +/// The SVD is only unique up to a per-triplet sign, so two runs can disagree on it for +/// no numerical reason. Pinning the sign makes results reproducible and comparable — +/// this is what `sklearn`'s `svd_flip` is for. +pub fn svd_flip(u: &mut Array2, vt: &mut Array2) { + let k = u.ncols().min(vt.nrows()); + for j in 0..k { + // Locate the largest-magnitude entry of column j. + let mut best = T::zero(); + let mut sign = T::one(); + for i in 0..u.nrows() { + let v = u[[i, j]]; + let mag = num_traits::Float::abs(v); + if mag > best { + best = mag; + sign = if v < T::zero() { -T::one() } else { T::one() }; + } + } + if sign < T::zero() { + for i in 0..u.nrows() { + u[[i, j]] = -u[[i, j]]; + } + for j2 in 0..vt.ncols() { + vt[[j, j2]] = -vt[[j, j2]]; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::Lcg; + + #[test] + fn small_svd_reconstructs_and_is_ordered() { + let mut rng = Lcg::new(11); + let a = Array2::from_shape_fn((9, 6), |_| rng.signed()); + let svd = small_svd(a.view()).unwrap(); + + assert_eq!(svd.u.dim(), (9, 6)); + assert_eq!(svd.s.len(), 6); + assert_eq!(svd.vt.dim(), (6, 6)); + + for w in svd.s.to_vec().windows(2) { + assert!(w[0] >= w[1], "singular values not descending: {:?}", svd.s); + } + + let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0)); + let recon = scaled.dot(&svd.vt); + let err: f64 = (&recon - &a).iter().map(|v| v * v).sum::().sqrt(); + let scale: f64 = a.iter().map(|v| v * v).sum::().sqrt(); + assert!( + err / scale < 1e-12, + "relative reconstruction {}", + err / scale + ); + } + + /// `f32` input must still be factored at `f64` precision internally. + #[test] + fn small_svd_promotes_f32() { + let mut rng = Lcg::new(13); + let a = Array2::from_shape_fn((7, 5), |_| rng.signed() as f32); + let svd = small_svd(a.view()).unwrap(); + let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0)); + let recon = scaled.dot(&svd.vt); + let err: f32 = (&recon - &a).iter().map(|v| v * v).sum::().sqrt(); + let scale: f32 = a.iter().map(|v| v * v).sum::().sqrt(); + assert!( + err / scale < 1e-5, + "relative reconstruction {}", + err / scale + ); + } + + #[test] + fn svd_flip_makes_signs_deterministic() { + let mut u = ndarray::arr2(&[[-3.0f64, 1.0], [1.0, -4.0]]); + let mut vt = ndarray::arr2(&[[1.0f64, 2.0], [3.0, 4.0]]); + let before = u.dot(&vt); + svd_flip(&mut u, &mut vt); + // Dominant entry of each column of u is now positive. + assert!(u[[0, 0]] > 0.0); + assert!(u[[1, 1]] > 0.0); + // The product is unchanged: flipping a column of u and the matching row of vt + // cancels. + let after = u.dot(&vt); + for (a, b) in before.iter().zip(after.iter()) { + approx::assert_relative_eq!(a, b, max_relative = 1e-14); + } + } + + #[test] + fn small_svd_rejects_empty() { + let a = Array2::::zeros((0, 3)); + assert!(small_svd(a.view()).is_err()); + } +} diff --git a/src/dense/tsqr.rs b/src/dense/tsqr.rs new file mode 100644 index 0000000..13ebcf8 --- /dev/null +++ b/src/dense/tsqr.rs @@ -0,0 +1,425 @@ +//! Tall-skinny QR. +//! +//! For an `m × n` matrix with `m ≫ n` — the shape every randomized range finder +//! produces — a conventional Householder QR is a sequence of `n` passes over all `m` +//! rows, each pass dependent on the last. TSQR (Demmel, Grigori, Hoemmen & Langou) +//! instead splits the rows into `P` independent panels: +//! +//! 1. each panel gets its own local QR, in parallel, yielding an `n × n` factor `Rᵢ`; +//! 2. the `Rᵢ` are stacked into a `Pn × n` matrix and reduced by one small QR, giving +//! the final `R` and a `Pn × n` factor `Q_s`; +//! 3. each panel's `Qᵢ` is multiplied by its `n × n` block of `Q_s`, in parallel. +//! +//! Work is `O(mn²/P)` and the only auxiliary storage is `O(Pn²)` — independent of `m`. +//! The alternative in 1.x was `nalgebra`'s dense QR on the full `m × l` matrix, which +//! is serial in `m` and materialises its own `m × l` factor. + +// Numeric kernels index several arrays in step from one loop variable, and +// offset arithmetic is load-bearing; iterator rewrites obscure which array an +// index belongs to. +#![allow(clippy::needless_range_loop)] + +use crate::error::{Result, SvdLibError}; +use crate::types::SvdFloat; +use ndarray::{s, Array2, ArrayView2, ArrayViewMut2, Axis}; +use num_traits::Float; +use rayon::prelude::*; + +/// Below this many rows, panel splitting costs more than it saves. +const MIN_ROWS_FOR_PANELS: usize = 2048; + +/// Build a Householder reflector `H = I - τ v vᵀ` with `v[0] == 1` such that +/// `H x = β e₁`. Returns `(β, τ)` and overwrites `x[1..]` with `v[1..]`. +/// +/// Mirrors LAPACK `dlarfg`, including the sign choice that avoids cancellation. +fn housegen(x: &mut [T]) -> (T, T) { + let n = x.len(); + if n == 0 { + return (T::zero(), T::zero()); + } + let alpha = x[0]; + if n == 1 { + return (alpha, T::zero()); + } + // ||x[1..]|| + let tail_norm = { + let mut acc = T::zero(); + for &v in &x[1..] { + acc += v * v; + } + acc.sqrt() + }; + if tail_norm.is_zero() { + return (alpha, T::zero()); + } + let norm = Float::hypot(alpha, tail_norm); + // Choose the sign opposite alpha so `alpha - beta` cannot cancel. + let beta = if alpha >= T::zero() { -norm } else { norm }; + let tau = (beta - alpha) / beta; + let scale = T::one() / (alpha - beta); + for v in &mut x[1..] { + *v *= scale; + } + (beta, tau) +} + +/// Apply `H = I - τ v vᵀ` on the left of `c`, where `v[0] == 1` and `v[1..] == v_tail`. +fn apply_reflector(tau: T, v_tail: &[T], mut c: ArrayViewMut2) { + if tau.is_zero() { + return; + } + let (p, q) = (c.nrows(), c.ncols()); + debug_assert_eq!(v_tail.len() + 1, p); + // w = cᵀ v + let mut w = vec![T::zero(); q]; + for (j, wj) in w.iter_mut().enumerate() { + let mut acc = c[[0, j]]; + for (i, &vi) in v_tail.iter().enumerate() { + acc += vi * c[[i + 1, j]]; + } + *wj = acc; + } + // c -= tau v wᵀ + for (j, &wj) in w.iter().enumerate() { + let f = tau * wj; + if f.is_zero() { + continue; + } + c[[0, j]] -= f; + for (i, &vi) in v_tail.iter().enumerate() { + c[[i + 1, j]] -= f * vi; + } + } +} + +/// Householder QR of `a` (`r × n`, `r >= n`), in place. +/// +/// On return the strict lower triangle of `a` holds the reflector tails and the upper +/// triangle holds `R`. `tau` receives the `n` reflector scalars. +fn qr_in_place(mut a: ArrayViewMut2, tau: &mut [T]) { + let (r, n) = a.dim(); + let k = n.min(r); + for j in 0..k { + // Generate the reflector from the column below and including the diagonal. + let mut col: Vec = a.slice(s![j.., j]).to_vec(); + let (beta, t) = housegen(&mut col); + tau[j] = t; + a[[j, j]] = beta; + for (i, &v) in col[1..].iter().enumerate() { + a[[j + 1 + i, j]] = v; + } + if j + 1 < n { + let v_tail: Vec = col[1..].to_vec(); + apply_reflector(t, &v_tail, a.slice_mut(s![j.., j + 1..])); + } + } +} + +/// Extract the `n × n` upper-triangular `R` from a factored panel. +fn extract_r(a: &ArrayView2, n: usize) -> Array2 { + let mut r = Array2::::zeros((n, n)); + for i in 0..n.min(a.nrows()) { + for j in i..n { + r[[i, j]] = a[[i, j]]; + } + } + r +} + +/// Overwrite a factored panel with its thin `Q` (`r × n`), from the reflectors and +/// `tau` produced by [`qr_in_place`]. Mirrors LAPACK `dorgqr`. +fn form_q_in_place(mut a: ArrayViewMut2, tau: &[T]) { + let (r, n) = a.dim(); + let k = n.min(r); + // Stash the reflector tails before overwriting with the identity. + let mut vs: Vec> = Vec::with_capacity(k); + for j in 0..k { + vs.push(a.slice(s![j + 1.., j]).to_vec()); + } + a.fill(T::zero()); + for j in 0..n.min(r) { + a[[j, j]] = T::one(); + } + // Apply H_0 H_1 ... H_{k-1} in reverse. + for j in (0..k).rev() { + apply_reflector(tau[j], &vs[j], a.slice_mut(s![j.., ..])); + } +} + +/// `panel = panel · block`, where `block` is `n × n`. One `n`-element row temp. +fn mul_panel_by_block(mut panel: ArrayViewMut2, block: &Array2) { + let n = panel.ncols(); + debug_assert_eq!(block.dim(), (n, n)); + let mut tmp = vec![T::zero(); n]; + for mut row in panel.rows_mut() { + for (j, t) in tmp.iter_mut().enumerate() { + let mut acc = T::zero(); + for i in 0..n { + acc += row[i] * block[[i, j]]; + } + *t = acc; + } + for (dst, &src) in row.iter_mut().zip(tmp.iter()) { + *dst = src; + } + } +} + +/// Thin QR of a tall matrix. +/// +/// Overwrites `a` (`m × n`, `m >= n`) with an orthonormal `Q` and returns the `n × n` +/// upper-triangular `R`, so that the original `a == Q · R`. +/// +/// # Errors +/// If `a` is wider than it is tall. +pub fn tsqr(a: &mut Array2) -> Result> { + let (m, n) = a.dim(); + if n > m { + return Err(SvdLibError::shape(format!( + "tsqr needs at least as many rows as columns, got {m}x{n}" + ))); + } + if n == 0 || m == 0 { + return Ok(Array2::zeros((n, n))); + } + + // How many panels can we cut while keeping each at least `n` rows deep? A panel + // shallower than `n` has a rank-deficient local R and buys nothing. + let threads = rayon::current_num_threads().max(1); + let panels = threads.min(m / n.max(1)).max(1); + + if panels == 1 || m < MIN_ROWS_FOR_PANELS { + let mut tau = vec![T::zero(); n]; + qr_in_place(a.view_mut(), &mut tau); + let r = extract_r(&a.view(), n); + form_q_in_place(a.view_mut(), &tau); + return Ok(r); + } + + // Panel boundaries, each at least `n` rows. + let base = m / panels; + let rem = m % panels; + let mut bounds = Vec::with_capacity(panels + 1); + bounds.push(0usize); + for p in 0..panels { + let take = base + usize::from(p < rem); + bounds.push(bounds[p] + take); + } + debug_assert_eq!(*bounds.last().unwrap(), m); + + // Stage 1: local QR per panel, in parallel. Each returns its own R and leaves the + // panel holding its local thin Q. + let mut panel_views: Vec> = Vec::with_capacity(panels); + { + let mut rest = a.view_mut(); + for p in 0..panels { + let take = bounds[p + 1] - bounds[p]; + let (head, tail) = rest.split_at(Axis(0), take); + panel_views.push(head); + rest = tail; + } + } + + // Factored in place through the view: no per-panel copy, so the only auxiliary + // storage is the stacked R factors. + let locals: Vec> = panel_views + .par_iter_mut() + .map(|panel| { + let mut tau = vec![T::zero(); n]; + qr_in_place(panel.view_mut(), &mut tau); + let r = extract_r(&panel.view(), n); + form_q_in_place(panel.view_mut(), &tau); + r + }) + .collect(); + + // Stage 2: one small QR of the stacked R factors. + let mut stacked = Array2::::zeros((panels * n, n)); + for (p, r) in locals.iter().enumerate() { + stacked.slice_mut(s![p * n..(p + 1) * n, ..]).assign(r); + } + let mut tau = vec![T::zero(); n]; + qr_in_place(stacked.view_mut(), &mut tau); + let r_final = extract_r(&stacked.view(), n); + form_q_in_place(stacked.view_mut(), &tau); + + // Stage 3: fold each block of Q_s back into its panel, in parallel. + let blocks: Vec> = (0..panels) + .map(|p| stacked.slice(s![p * n..(p + 1) * n, ..]).to_owned()) + .collect(); + panel_views + .par_iter_mut() + .zip(blocks.par_iter()) + .for_each(|(panel, block)| mul_panel_by_block(panel.view_mut(), block)); + + Ok(r_final) +} + +/// Orthonormalise the columns of `a` in place, discarding `R`. +/// +/// This is the operation a randomized range finder actually wants from a QR step. +pub fn orthonormalize(a: &mut Array2) -> Result<()> { + tsqr(a).map(|_| ()) +} + +/// `‖AᵀA − I‖_F`, the departure from orthonormality of `a`'s columns. +pub fn orthogonality_error(a: &ArrayView2) -> T { + let n = a.ncols(); + let mut acc = T::zero(); + for i in 0..n { + for j in 0..n { + let mut dot = T::zero(); + for k in 0..a.nrows() { + dot += a[[k, i]] * a[[k, j]]; + } + let target = if i == j { T::one() } else { T::zero() }; + let d = dot - target; + acc += d * d; + } + } + acc.sqrt() +} + +/// Row count above which [`tsqr`] engages panel splitting; exposed for tests that need +/// to exercise both paths. +#[doc(hidden)] +pub const PANEL_THRESHOLD: usize = MIN_ROWS_FOR_PANELS; + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::Lcg; + + fn random_tall(m: usize, n: usize, seed: u64) -> Array2 { + let mut rng = Lcg::new(seed); + Array2::from_shape_fn((m, n), |_| rng.signed()) + } + + fn frob(a: &Array2) -> f64 { + a.iter().map(|v| v * v).sum::().sqrt() + } + + fn check_qr(m: usize, n: usize, seed: u64) { + let original = random_tall(m, n, seed); + let mut q = original.clone(); + let r = tsqr(&mut q).unwrap(); + + assert_eq!(q.dim(), (m, n), "Q shape"); + assert_eq!(r.dim(), (n, n), "R shape"); + + // R upper triangular. + for i in 1..n { + for j in 0..i { + assert!( + r[[i, j]].abs() < 1e-12, + "R not upper triangular at ({i},{j}): {}", + r[[i, j]] + ); + } + } + + // Q orthonormal. + let orth = orthogonality_error(&q.view()); + assert!(orth < 1e-10, "||Q^T Q - I||_F = {orth:.3e} for {m}x{n}"); + + // A == Q R. + let recon = q.dot(&r); + let err = frob(&(&recon - &original)) / frob(&original).max(1e-30); + assert!(err < 1e-10, "||A - QR||/||A|| = {err:.3e} for {m}x{n}"); + } + + #[test] + fn serial_path_shapes() { + for (m, n) in [(8usize, 3usize), (64, 8), (100, 1), (50, 50), (257, 17)] { + check_qr(m, n, 42 + m as u64); + } + } + + /// Above `PANEL_THRESHOLD` the multi-panel reduction runs; it must agree with the + /// serial path to the same accuracy. + #[test] + fn panel_path_matches_serial_accuracy() { + for (m, n) in [(4096usize, 16usize), (5000, 32), (8192, 8)] { + check_qr(m, n, 7 + m as u64); + } + } + + /// The panelled and serial paths must produce the same factorization, up to the + /// column sign freedom that Householder QR leaves. + #[test] + fn panel_and_serial_agree_on_r_magnitude() { + let (m, n) = (4096, 12); + let original = random_tall(m, n, 99); + + let mut q_panel = original.clone(); + let r_panel = tsqr(&mut q_panel).unwrap(); + + // Force the serial path by running inside a single-thread pool. + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .unwrap(); + let (r_serial, q_serial) = pool.install(|| { + let mut q = original.clone(); + let r = tsqr(&mut q).unwrap(); + (r, q) + }); + + for i in 0..n { + for j in i..n { + approx::assert_relative_eq!( + r_panel[[i, j]].abs(), + r_serial[[i, j]].abs(), + max_relative = 1e-9, + epsilon = 1e-12 + ); + } + } + assert!(orthogonality_error(&q_serial.view()) < 1e-10); + } + + #[test] + fn rejects_wide_matrices() { + let mut a = random_tall(4, 9, 1); + assert!(matches!(tsqr(&mut a), Err(SvdLibError::ShapeMismatch(_)))); + } + + /// A rank-deficient operand still yields an orthonormal Q; the deficiency shows up + /// as (near-)zero diagonal entries of R, not as a loss of orthogonality. + #[test] + fn handles_rank_deficient_input() { + let (m, n) = (200, 6); + let mut a = random_tall(m, n, 3); + // Make column 4 a copy of column 1. + let c1 = a.column(1).to_owned(); + a.column_mut(4).assign(&c1); + let mut q = a.clone(); + let r = tsqr(&mut q).unwrap(); + let recon = q.dot(&r); + let err = frob(&(&recon - &a)) / frob(&a); + assert!(err < 1e-10, "||A - QR||/||A|| = {err:.3e}"); + assert!( + r[[4, 4]].abs() < 1e-10, + "expected a deficient pivot, got {}", + r[[4, 4]] + ); + } + + #[test] + fn f32_works() { + let mut rng = Lcg::new(5); + let original = Array2::from_shape_fn((512, 8), |_| rng.signed() as f32); + let mut q = original.clone(); + let r = tsqr(&mut q).unwrap(); + let orth = orthogonality_error(&q.view()); + assert!(orth < 1e-3, "f32 ||Q^T Q - I||_F = {orth:.3e}"); + let recon = q.dot(&r); + let num: f32 = (&recon - &original) + .iter() + .map(|v| v * v) + .sum::() + .sqrt(); + let den: f32 = original.iter().map(|v| v * v).sum::().sqrt(); + assert!(num / den < 1e-4, "f32 reconstruction {:.3e}", num / den); + } +} diff --git a/src/error.rs b/src/error.rs index 8309930..ca4c033 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,22 +1,69 @@ use thiserror::Error; -#[derive(Error, Debug, PartialEq)] +/// Everything this crate can fail with. +/// +/// In 1.x the Lanczos path returned `SvdLibError` while the randomized path returned +/// `anyhow::Error`; both now return this type. +#[derive(Error, Debug, Clone, PartialEq)] pub enum SvdLibError { - #[error("svdlibrs/imtqlb: {0}")] - ImtqlbError(String), + /// A caller-supplied parameter was rejected before any work started. + #[error("invalid argument: {0}")] + InvalidArgument(String), - #[error("svdlibrs/startv: {0}")] - StartvError(String), + /// The operands' shapes are inconsistent. + #[error("shape mismatch: {0}")] + ShapeMismatch(String), - #[error("svdlibrs/stpone: {0}")] - StponeError(String), + /// A tridiagonal/bidiagonal eigenproblem failed to converge. + /// + /// `stage` names the kernel (`imtqlb`, `imtql2`, ...) so the origin stays visible + /// without a separate variant per call site. + #[error("{stage}: no convergence after {iterations} iterations")] + NoConvergence { + stage: &'static str, + iterations: usize, + }, - #[error("svdlibrs/imtql2: {0}")] - Imtql2Error(String), + /// The algorithm ran but could not produce the requested number of dimensions. + #[error("{stage}: {message}")] + Failed { + stage: &'static str, + message: String, + }, - #[error("svdlibrs/svdLas2: {0}")] - Las2Error(String), + /// A dense factorization from the linear-algebra backend failed. + #[error("dense {factorization} failed: {message}")] + DenseFactorization { + factorization: &'static str, + message: String, + }, - #[error("svdlibrs/ndarray: {0}")] - NDArrayError(#[from] ndarray::ShapeError), -} \ No newline at end of file + #[error("ndarray shape error: {0}")] + Shape(String), +} + +impl From for SvdLibError { + fn from(e: ndarray::ShapeError) -> Self { + SvdLibError::Shape(e.to_string()) + } +} + +impl SvdLibError { + pub(crate) fn invalid(msg: impl Into) -> Self { + SvdLibError::InvalidArgument(msg.into()) + } + + pub(crate) fn failed(stage: &'static str, msg: impl Into) -> Self { + SvdLibError::Failed { + stage, + message: msg.into(), + } + } + + pub(crate) fn shape(msg: impl Into) -> Self { + SvdLibError::ShapeMismatch(msg.into()) + } +} + +/// Convenience alias used throughout the crate. +pub type Result = std::result::Result; diff --git a/src/irlba/mod.rs b/src/irlba/mod.rs new file mode 100644 index 0000000..8b60842 --- /dev/null +++ b/src/irlba/mod.rs @@ -0,0 +1,1101 @@ +//! Thick-restarted Lanczos bidiagonalization. +//! +//! Golub–Kahan–Lanczos bidiagonalization with augmented thick restarts, following +//! Baglama & Reichel (2005). This is the algorithm behind R's `irlba` and, in spirit, +//! `scipy.sparse.linalg.svds`. +//! +//! # Why this rather than [`crate::lanczos`] +//! +//! LAS2 keeps every Lanczos vector it generates, so its basis grows with the iteration +//! count — unbounded in practice, since `iterations` defaults to `min(rows, cols)`. +//! Here the basis is fixed at `work` vectors (`rank + 7` by default) no matter how many +//! restarts are needed, so peak memory is known before the solve starts: +//! +//! ```text +//! (work + 1) · cols + work · rows scalars +//! ``` +//! +//! For 200k × 30k at rank 50 that is about 95 MiB and it does not grow. +//! +//! # Method +//! +//! Each cycle extends the factorization +//! +//! ```text +//! A·V = U·B +//! Aᵀ·U = V·Bᵀ + β·v_next·eᵀ +//! ``` +//! +//! to `work` columns, where `B` is small and bidiagonal, then takes the SVD of `B`. +//! Its singular values are the Ritz estimates and `|β · P[work-1, i]|` is the residual +//! for triplet `i`. Unconverged cycles restart from the `rank` best Ritz vectors plus +//! the residual direction, which preserves the factorization's structure — the restart +//! costs one extra column in `B` rather than throwing the subspace away. + +use crate::dense::{small_svd, svd_flip}; +use crate::error::{Result, SvdLibError}; +use crate::matrix::{SparseMat, SparseMatDense}; +use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec}; +use ndarray::{s, Array1, Array2, ArrayView2}; +use num_traits::Float; +use rand::rngs::StdRng; +use rand::{rng, Rng, RngExt, SeedableRng}; + +/// Default relative residual tolerance. +pub const DEFAULT_TOL: f64 = 1e-10; +/// Default extra basis vectors beyond the requested rank. +pub const DEFAULT_EXTRA_WORK: usize = 7; +/// Default cap on restart cycles. +pub const DEFAULT_MAX_RESTARTS: usize = 1000; + +/// Configuration for [`svd_with`]. +#[derive(Debug, Clone)] +pub struct IrlbaConfig { + /// Number of singular triplets wanted. + pub rank: usize, + /// Basis size. Must exceed `rank`; defaults to `rank + 7`, clamped to + /// `min(rows, cols)`. + /// + /// # This is the knob that matters on tall matrices + /// + /// Each restart re-orthogonalises against the whole basis, so a step costs + /// `O(work · (rows + cols))` — on a matrix with a million rows that dominates the + /// sparse products by a wide margin. A larger `work` makes each step dearer but + /// converges in far fewer restarts, and the second effect wins comfortably. + /// + /// Measured on 400k × 30k restricted to 2238 columns, 50 components + /// (`cargo run --release --example tune`): + /// + /// | `work` | time | matvecs | accuracy | + /// |---|---|---|---| + /// | `rank + 7` (default) | 30.2 s | 1515 | 6.7e-17 | + /// | `rank + 30` | **13.7 s** | 701 | 1.9e-14 | + /// | `rank + 50` | 18.6 s | 601 | 2.0e-14 | + /// | `rank + 100` | 21.4 s | 501 | 2.2e-14 | + /// + /// So `rank + 30` was **2.2× faster** at the same accuracy. The cost is basis + /// memory, which is linear in `work`: `work · rows` scalars for the left basis, or + /// 600 MiB rather than 456 MiB at a million rows. The default stays conservative + /// because memory is the reason to choose this crate; raise it when you have the + /// headroom. + /// + /// Run the `tune` example on your own shape rather than trusting these numbers — + /// the optimum moves with the aspect ratio and the spectrum. + pub work: Option, + /// Relative residual tolerance: triplet `i` is accepted once its residual falls + /// below `tol · σ_max`. + pub tol: f64, + /// Cap on restart cycles before giving up. + pub max_restarts: usize, + /// Fixed seed for the starting vector; `None` draws from the OS. + pub seed: Option, + /// Subtract column means without materialising the centered matrix — i.e. PCA + /// rather than plain SVD. + pub mean_center: bool, + /// Refuse to return triplets that did not reach [`tol`](Self::tol). Defaults to + /// `true`. + /// + /// Exhausting the restart budget means the answer is a best effort of unknown + /// quality. Returning it as `Ok` puts the burden on every caller to remember to + /// inspect the diagnostics, and a pipeline that forgets gets a silently degraded + /// decomposition feeding whatever comes next. Failing loudly is the safer default; + /// set this to `false` if a best effort is genuinely what you want, then check + /// [`SvdRec::converged`]. + pub require_convergence: bool, +} + +impl IrlbaConfig { + /// Configuration for `rank` triplets, everything else defaulted. + pub fn new(rank: usize) -> Self { + Self { + rank, + work: None, + tol: DEFAULT_TOL, + max_restarts: DEFAULT_MAX_RESTARTS, + seed: None, + mean_center: false, + require_convergence: true, + } + } + /// Set the basis size. + pub fn work(mut self, work: usize) -> Self { + self.work = Some(work); + self + } + /// Set the relative residual tolerance. + pub fn tol(mut self, tol: f64) -> Self { + self.tol = tol; + self + } + /// Set the restart cap. + pub fn max_restarts(mut self, n: usize) -> Self { + self.max_restarts = n; + self + } + /// Fix the seed, making the result reproducible. + pub fn seed(mut self, seed: u64) -> Self { + self.seed = Some(seed); + self + } + /// Enable implicit mean centering. + pub fn mean_center(mut self, yes: bool) -> Self { + self.mean_center = yes; + self + } + /// Accept a best-effort result instead of failing when the restart budget runs out. + pub fn allow_unconverged(mut self) -> Self { + self.require_convergence = false; + self + } +} + +/// `rank` largest singular triplets, defaults throughout. +pub fn svd>(a: &M, rank: usize) -> Result> { + svd_with(a, &IrlbaConfig::new(rank), None) +} + +/// `rank` largest singular triplets with a fixed seed. +pub fn svd_seed>(a: &M, rank: usize, seed: u64) -> Result> { + svd_with(a, &IrlbaConfig::new(rank).seed(seed), None) +} + +/// PCA: `rank` largest singular triplets of the implicitly mean-centered matrix. +/// +/// Requires [`SparseMatDense`] only to obtain the column means; the solve itself uses +/// matrix-vector products throughout. +pub fn svd_centered>( + a: &M, + rank: usize, + seed: Option, +) -> Result> { + let means = a.col_means(); + let mut cfg = IrlbaConfig::new(rank).mean_center(true); + cfg.seed = seed; + svd_with(a, &cfg, Some(means)) +} + +/// An operand with optional implicit mean centering applied around its products. +/// +/// Centering a sparse matrix would destroy its sparsity, so the shift is folded into +/// each product as the rank-1 term it is. +struct Op<'a, T, M> { + a: &'a M, + /// Column means, when centering. + means: Option<&'a [T]>, + _p: std::marker::PhantomData, +} + +impl<'a, T: SvdFloat, M: SparseMat> Op<'a, T, M> { + fn rows(&self) -> usize { + self.a.rows() + } + fn cols(&self) -> usize { + self.a.cols() + } + + /// `y = A·x` (`trans == false`) or `y = Aᵀ·x` (`trans == true`), centered if + /// configured. + fn mul(&self, x: &[T], y: &mut [T], trans: bool) { + self.a.mul_vec(x, y, trans); + let Some(m) = self.means else { return }; + if !trans { + // (A − 1·mᵀ)x = A·x − 1·(m·x) + let c: T = m.iter().zip(x.iter()).map(|(&a, &b)| a * b).sum(); + for yi in y.iter_mut() { + *yi -= c; + } + } else { + // (A − 1·mᵀ)ᵀx = Aᵀ·x − m·(1ᵀx) + let sum: T = x.iter().copied().sum(); + for (yi, &mi) in y.iter_mut().zip(m.iter()) { + *yi -= mi * sum; + } + } + } +} + +/// Two-pass classical Gram–Schmidt against the first `count` rows of `basis`. +/// +/// One pass leaves `O(κ·eps)` non-orthogonality; twice is enough to reach machine +/// precision (Kahan–Parlett), and expressing it as BLAS-2 products keeps it far cheaper +/// than the `count` separate axpy pairs the equivalent loop would issue. +/// +/// `coeffs` is a caller-owned scratch buffer of at least `count` elements, and the +/// correction is accumulated straight into `w` via `general_mat_vec_mul`. Allocating +/// either of those here would mean two heap allocations per Lanczos step, one of them +/// the full length of `w`. +/// Returns the *total* coefficient removed along each basis vector, accumulated over +/// both passes. +/// +/// Callers must record these. In an undisturbed Krylov recurrence they are numerical +/// drift, around zero, and discarding them is harmless. After a breakdown restart they +/// are not: the injected random direction has genuine components along every previous +/// vector, and dropping them silently breaks `A·V = U·B`, so `B`'s spectrum stops being +/// `A`'s. That surfaced as a *skipped* singular value — ten real triplets returned, but +/// the ninth-largest missing — with every residual small enough to claim convergence. +fn reorthogonalize( + w: &mut Array1, + basis: &ArrayView2, + count: usize, + coeffs: &mut Array1, +) { + if count == 0 { + return; + } + let b = basis.slice(s![..count, ..]); + let mut total = Array1::::zeros(count); + { + let mut c = coeffs.slice_mut(s![..count]); + for _ in 0..2 { + // c = b · w + ndarray::linalg::general_mat_vec_mul(T::one(), &b, w, T::zero(), &mut c); + // w = w - bᵀ · c + ndarray::linalg::general_mat_vec_mul(-T::one(), &b.t(), &c, T::one(), w); + total += &c; + } + } + coeffs.slice_mut(s![..count]).assign(&total); +} + +/// Draw a unit vector orthogonal to the first `count` rows of `basis`. +/// +/// Returns `false` when the space is exhausted — no direction remains. +/// +/// The quality floor matters. A random draw that happens to land mostly inside the +/// existing span leaves a tiny residual, and normalising that residual scales the +/// Gram-Schmidt error up by its reciprocal. Accepting any non-zero residual let a +/// basis vector be orthogonal to only ~1e-8, which propagated into the returned +/// singular vectors as `||UᵀU - I|| = 2e-7`. Re-drawing costs nothing here and keeps +/// the amplification at O(1). +fn random_orthogonal( + out: &mut Array1, + basis: &ArrayView2, + count: usize, + coeffs: &mut Array1, + rng_state: &mut StdRng, +) -> bool { + let floor = T::from_f64_val(0.1); + for _ in 0..4 { + random_unit(out, rng_state); + // The coefficients here describe the *random* vector, not `A·v`, so unlike the + // main path they must not be recorded in `B`. + reorthogonalize(out, basis, count, coeffs); + let n = norm(out); + if n > floor && num_traits::Float::is_finite(n) { + *out /= n; + return true; + } + } + false +} + +fn norm(v: &Array1) -> T { + v.iter().map(|&x| x * x).sum::().sqrt() +} + +/// Fill `v` with a deterministic unit random vector. +fn random_unit(v: &mut Array1, rng_state: &mut StdRng) { + for x in v.iter_mut() { + *x = T::from_f64_val(rng_state.random_range(-1.0..1.0)); + } + let n = norm(v); + if n > T::zero() { + *v /= n; + } else { + v.fill(T::zero()); + v[0] = T::one(); + } +} + +/// Compute a decomposition with explicit configuration. +/// +/// `means`, when given, must have length `a.cols()` and is only consulted if +/// `cfg.mean_center` is set. [`svd_centered`] is the convenient entry point. +pub fn svd_with>( + a: &M, + cfg: &IrlbaConfig, + means: Option>, +) -> Result> { + let (rows, cols) = (a.rows(), a.cols()); + let min_dim = rows.min(cols); + + if cfg.rank == 0 { + return Err(SvdLibError::invalid("irlba: rank must be at least 1")); + } + if cfg.rank > min_dim { + return Err(SvdLibError::invalid(format!( + "irlba: rank {} exceeds min(rows, cols) = {min_dim}", + cfg.rank + ))); + } + if cfg.mean_center { + match &means { + Some(m) if m.len() == cols => {} + Some(m) => { + return Err(SvdLibError::shape(format!( + "irlba: means has length {} but the matrix has {cols} columns", + m.len() + ))) + } + None => { + return Err(SvdLibError::invalid( + "irlba: mean_center is set but no means were supplied; \ + use `svd_centered`", + )) + } + } + } + + let k = cfg.rank; + let seed = cfg.seed.unwrap_or_else(|| rng().next_u64()); + + // A matrix with a dimension of 1 has exactly one singular triplet and no Krylov + // subspace to build. Solve it in closed form rather than rejecting it: masking down + // to a single column is a perfectly ordinary thing to do. + if min_dim == 1 { + return trivial_rank_one(a, &means, cfg, seed); + } + + let work = cfg + .work + .unwrap_or(k + DEFAULT_EXTRA_WORK) + .clamp(k + 1, min_dim.max(k + 1)) + .min(min_dim); + if work <= k { + return Err(SvdLibError::invalid(format!( + "irlba: rank {k} needs a basis of at least {} vectors but the matrix only \ + admits {min_dim}; request at most {} triplets, or use \ + `single_svdlib::randomized`, which supports the full rank", + k + 1, + min_dim - 1 + ))); + } + + let means_slice = if cfg.mean_center { + means.as_ref().map(|m| m.as_slice().unwrap()) + } else { + None + }; + let op = Op { + a, + means: means_slice, + _p: std::marker::PhantomData, + }; + + let mut state = Solve::new(&op, work, k, cfg.tol, seed); + let outcome = state.run(cfg.max_restarts)?; + + // Assemble the requested triplets. + let Solve { v, u, .. } = state; + let SolveOutcome { + p, + q, + sigma, + restarts, + converged, + max_residual, + matvecs, + } = outcome; + + // u_out[r, i] = Σ_j P[j, i] · U[j, r] (rows × k) + // vt_out[i, c] = Σ_j Q[j, i] · V[j, c] (k × cols) + let pk = p.slice(s![.., ..k]); + let qk = q.slice(s![.., ..k]); + let mut u_out = pk + .t() + .dot(&u.slice(s![..work, ..])) + .reversed_axes() + .to_owned(); + let mut vt_out = qk.t().dot(&v.slice(s![..work, ..])).to_owned(); + let s_out = sigma.slice(s![..k]).to_owned(); + + if cfg.require_convergence && !converged { + return Err(SvdLibError::failed( + "irlba", + format!( + "did not converge in {restarts} restarts: largest residual is {:.3e} \ + against a threshold of {:.3e} (tol {:.1e} x sigma_max). Raise \ + `max_restarts` or `work`, loosen `tol`, or call `allow_unconverged` to \ + accept a best effort.", + max_residual.to_f64(), + cfg.tol * sigma[0].to_f64(), + cfg.tol, + ), + )); + } + + // Pin the per-triplet sign so repeat runs agree. + svd_flip(&mut u_out, &mut vt_out); + + Ok(SvdRec { + d: k, + u: u_out, + s: s_out, + vt: vt_out, + total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm( + a, + means.as_ref().map(|m| m.view()), + )), + diagnostics: Diagnostics { + algorithm: Algorithm::Irlba, + non_zero: a.nnz(), + dimensions: k, + significant_values: k, + transposed: false, + random_seed: seed, + matvecs, + detail: Detail::Irlba { + restarts, + converged, + tolerance: T::from_f64_val(cfg.tol), + max_residual, + }, + }, + }) +} + +/// The `min(rows, cols) == 1` case, in closed form. +/// +/// Such a matrix is a single row or column, so it has exactly one singular value — +/// the vector's norm — with the unit vector on the short side and the normalised +/// vector on the long side. +fn trivial_rank_one>( + a: &M, + means: &Option>, + cfg: &IrlbaConfig, + seed: u64, +) -> Result> { + let (rows, cols) = (a.rows(), a.cols()); + let op = Op { + a, + means: if cfg.mean_center { + means.as_ref().map(|m| m.as_slice().unwrap()) + } else { + None + }, + _p: std::marker::PhantomData, + }; + + // Materialise the single row (or column) by probing with a unit vector. + let (long, short, trans) = if rows == 1 { + (cols, rows, true) + } else { + (rows, cols, false) + }; + let mut probe = vec![T::zero(); short]; + probe[0] = T::one(); + let mut vec = vec![T::zero(); long]; + op.mul(&probe, &mut vec, trans); + + let sigma = vec.iter().map(|&x| x * x).sum::().sqrt(); + if !num_traits::Float::is_finite(sigma) { + return Err(SvdLibError::failed("irlba", "the operand is not finite")); + } + + let (u, vt) = if sigma > T::zero() { + let unit: Vec = vec.iter().map(|&x| x / sigma).collect(); + if rows == 1 { + // 1 x cols: u = [1], vt = row / sigma + ( + Array2::from_shape_vec((1, 1), vec![T::one()])?, + Array2::from_shape_vec((1, cols), unit)?, + ) + } else { + // rows x 1: u = col / sigma, vt = [1] + ( + Array2::from_shape_vec((rows, 1), unit)?, + Array2::from_shape_vec((1, 1), vec![T::one()])?, + ) + } + } else { + // The zero matrix: any unit vectors will do. + let mut u = Array2::::zeros((rows, 1)); + let mut vt = Array2::::zeros((1, cols)); + u[[0, 0]] = T::one(); + vt[[0, 0]] = T::one(); + (u, vt) + }; + + Ok(SvdRec { + d: 1, + u, + s: Array1::from_vec(vec![sigma]), + vt, + total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm( + a, + if cfg.mean_center { + means.as_ref().map(|m| m.view()) + } else { + None + }, + )), + diagnostics: Diagnostics { + algorithm: Algorithm::Irlba, + non_zero: a.nnz(), + dimensions: 1, + significant_values: 1, + transposed: false, + random_seed: seed, + matvecs: 1, + detail: Detail::Irlba { + restarts: 0, + converged: true, + tolerance: T::from_f64_val(cfg.tol), + max_residual: T::zero(), + }, + }, + }) +} + +struct SolveOutcome { + /// Left singular vectors of `B`, `work × work`. + p: Array2, + /// Right singular vectors of `B` (as columns), `work × work`. + q: Array2, + sigma: Array1, + restarts: usize, + converged: bool, + max_residual: T, + matvecs: usize, +} + +/// The bidiagonalization state. Vectors are stored as **rows** so each is contiguous +/// and can be handed to [`SparseMat::mul_vec`] without a copy. +struct Solve<'a, T, M> { + op: &'a Op<'a, T, M>, + work: usize, + k: usize, + tol: f64, + /// `(work + 1) × cols` + v: Array2, + /// `work × rows` + u: Array2, + /// `work × work`, bidiagonal plus the restart coupling column. + b: Array2, + rng: StdRng, + matvecs: usize, + /// Running estimate of `||A||`, taken as the largest recurrence coefficient seen. + /// The breakdown test has to be relative to this: a rank-deficient operand yields a + /// coefficient around `1e-17` rather than exactly zero, and dividing by it amplifies + /// rounding noise to O(1) garbage and then to NaN. + anorm: T, +} + +impl<'a, T: SvdFloat, M: SparseMat> Solve<'a, T, M> { + fn new(op: &'a Op<'a, T, M>, work: usize, k: usize, tol: f64, seed: u64) -> Self { + Self { + op, + work, + k, + tol, + v: Array2::zeros((work + 1, op.cols())), + u: Array2::zeros((work, op.rows())), + b: Array2::zeros((work, work)), + rng: StdRng::seed_from_u64(seed), + matvecs: 0, + anorm: T::zero(), + } + } + + /// Below this, a recurrence coefficient is treated as zero and the subspace as + /// invariant. Scaled by the operator norm so it means the same thing whatever the + /// matrix's magnitude; when nothing has been seen yet (`anorm == 0`, e.g. an + /// all-zero matrix) it degenerates to an exact-zero test, which is correct. + fn breakdown_threshold(&self) -> T { + let dim = T::from_f64_val((self.op.rows().max(self.op.cols()) as f64).sqrt()); + self.anorm * T::eps() * dim + } + + /// Extend the factorization from column `start` to `work`. + /// + /// `coupling` is the restart's `ρ` vector when `start > 0`: at the first extended + /// column the new left vector must be orthogonalised against all `k` retained + /// left Ritz vectors, not just its immediate predecessor. + /// + /// Returns `(β, v_next)` — the trailing residual norm and direction. + fn extend(&mut self, start: usize, coupling: Option<&Array1>) -> Result<(T, Array1)> { + let (rows, cols) = (self.op.rows(), self.op.cols()); + let mut w = Array1::::zeros(rows); + let mut z = Array1::::zeros(cols); + // Reused by every reorthogonalization in this sweep. + let mut coeffs = Array1::::zeros(self.work + 1); + + for j in start..self.work { + // w = A·v_j, minus the coupling to the already-built left vectors. + { + let vj = self.v.row(j).to_owned(); + self.op + .mul(vj.as_slice().unwrap(), w.as_slice_mut().unwrap(), false); + self.matvecs += 1; + } + if j == start && start > 0 { + let rho = coupling.expect("restart requires a coupling vector"); + // w -= Σ_{i 0 { + let beta_prev = self.b[[j - 1, j]]; + let uprev = self.u.row(j - 1); + w.scaled_add(-beta_prev, &uprev); + } + + { + let ub = self.u.view(); + reorthogonalize(&mut w, &ub, j, &mut coeffs); + } + // Record what reorthogonalization removed. `A·v_j = Σ_i B[i,j]·u_i` only + // holds if these land in B; see `reorthogonalize`. + for i in 0..j { + self.b[[i, j]] += coeffs[i]; + } + let alpha = norm(&w); + // A non-finite norm means the operand (or the iterate) is poisoned. Bail + // immediately: continuing would spend the whole restart budget producing + // NaN and then report a residual that means nothing. + if !num_traits::Float::is_finite(alpha) { + return Err(SvdLibError::failed( + "irlba", + "the left Krylov vector became non-finite; the matrix most likely \ + contains NaN or infinity", + )); + } + // `alpha` is the true recurrence coefficient even when the subspace has + // gone invariant, in which case it is (numerically) zero and a random + // direction carries the basis forward. Recording the *random* vector's norm + // here instead would invent a singular value out of nothing — on an + // all-zero matrix that reported 2.9. + let alpha_kept = if alpha <= self.breakdown_threshold() { + let ub = self.u.view(); + if !random_orthogonal(&mut w, &ub, j, &mut coeffs, &mut self.rng) { + // Same completion case as on the right, reached when `work` meets + // `rows`: no direction remains orthogonal to those already held. + return Ok((T::zero(), Array1::zeros(cols))); + } + T::zero() + } else { + self.anorm = Float::max(self.anorm, alpha); + w /= alpha; + alpha + }; + self.u.row_mut(j).assign(&w); + self.b[[j, j]] = alpha_kept; + + // z = Aᵀ·u_j − α·v_j + self.op + .mul(w.as_slice().unwrap(), z.as_slice_mut().unwrap(), true); + self.matvecs += 1; + { + let vj = self.v.row(j); + z.scaled_add(-alpha_kept, &vj); + } + { + let vb = self.v.view(); + reorthogonalize(&mut z, &vb, j + 1, &mut coeffs); + } + let beta = norm(&z); + if !num_traits::Float::is_finite(beta) { + return Err(SvdLibError::failed( + "irlba", + "the right Krylov vector became non-finite; the matrix most likely \ + contains NaN or infinity", + )); + } + let (beta_kept, zn) = if beta <= self.breakdown_threshold() { + let vb = self.v.view(); + if !random_orthogonal(&mut z, &vb, j + 1, &mut coeffs, &mut self.rng) { + // No direction left that is orthogonal to the `j + 1` already held: + // the basis spans the whole space. That is *completion*, not + // failure — `A` has been fully captured, the residual is exactly + // zero, and the untouched columns of `B` are correctly zero. It + // happens whenever `work` reaches `cols`, which 6% of unseeded runs + // on a 4x3 operand did. + return Ok((T::zero(), Array1::zeros(cols))); + } + (T::zero(), z.clone()) + } else { + self.anorm = Float::max(self.anorm, beta); + (beta, &z / beta) + }; + self.v.row_mut(j + 1).assign(&zn); + if j + 1 < self.work { + self.b[[j, j + 1]] = beta_kept; + } else { + return Ok((beta_kept, zn)); + } + } + unreachable!("extend always terminates at the final column") + } + + fn run(&mut self, max_restarts: usize) -> Result> { + // Starting vector, drawn from the *row space* rather than from all of R^cols. + // + // A random vector generally has a component in `null(A)`. The Krylov space then + // spends one of its `work` dimensions carrying that component, which is + // orthogonal to everything `A` can reach, so only `work - 1` row-space + // directions get explored. When `work` is close to `min(rows, cols)` that costs + // a real singular value: on an 11x13 operand with a 2-dimensional null space it + // returned ten genuine triplets while silently skipping the ninth-largest, and + // reported convergence, because every triplet it *did* return was accurate. + // + // `Aᵀ·r` lies in the row space by construction, so one extra product removes the + // whole failure mode. + { + let mut probe = Array1::::zeros(self.op.rows()); + random_unit(&mut probe, &mut self.rng); + let mut v0 = Array1::::zeros(self.op.cols()); + self.op + .mul(probe.as_slice().unwrap(), v0.as_slice_mut().unwrap(), true); + self.matvecs += 1; + + let n = norm(&v0); + if n > T::zero() && num_traits::Float::is_finite(n) { + v0 /= n; + } else { + // `A` is (numerically) zero, so the row space is empty and any unit + // vector will do. + random_unit(&mut v0, &mut self.rng); + } + self.v.row_mut(0).assign(&v0); + } + + let mut start = 0usize; + let mut coupling: Option> = None; + + for restart in 0..=max_restarts { + let (beta, v_next) = self.extend(start, coupling.as_ref())?; + + let svd = small_svd(self.b.view())?; + let sigma = svd.s; + let p = svd.u; // work × work + let q = svd.vt.reversed_axes().as_standard_layout().to_owned(); // work × work + + // Residual for triplet i is |β · P[work-1, i]|. + let smax = Float::max(sigma[0], T::eps()); + let thresh = T::from_f64_val(self.tol) * smax; + let mut max_resid = T::zero(); + for i in 0..self.k { + let r = Float::abs(beta * p[[self.work - 1, i]]); + if r > max_resid { + max_resid = r; + } + } + + if max_resid <= thresh || restart == max_restarts { + return Ok(SolveOutcome { + p, + q, + sigma, + restarts: restart, + converged: max_resid <= thresh, + max_residual: max_resid, + matvecs: self.matvecs, + }); + } + + // Thick restart: retain the k best Ritz pairs plus the residual direction. + // + // A·(V·qᵢ) = σᵢ·(U·pᵢ) and Aᵀ·(U·pᵢ) = σᵢ·(V·qᵢ) + ρᵢ·v_next, so the + // restarted B is diag(σ) with ρ as its final column — the subspace is + // carried over rather than discarded. + let vk = q + .slice(s![.., ..self.k]) + .t() + .dot(&self.v.slice(s![..self.work, ..])); + let uk = p + .slice(s![.., ..self.k]) + .t() + .dot(&self.u.slice(s![..self.work, ..])); + + let mut rho = Array1::::zeros(self.k); + for i in 0..self.k { + rho[i] = beta * p[[self.work - 1, i]]; + } + + self.v.slice_mut(s![..self.k, ..]).assign(&vk); + self.u.slice_mut(s![..self.k, ..]).assign(&uk); + self.v.row_mut(self.k).assign(&v_next); + + self.b.fill(T::zero()); + for i in 0..self.k { + self.b[[i, i]] = sigma[i]; + self.b[[i, self.k]] = rho[i]; + } + + start = self.k; + coupling = Some(rho); + } + unreachable!("the restart loop returns on its final iteration") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::matrix::SvdMat; + use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values, Lcg}; + use ndarray::Axis; + use sprs::TriMatI; + + fn diagonal(n: usize) -> SvdMat { + let mut t = TriMatI::::new((n, n)); + for i in 0..n { + t.add_triplet(i, i, (n - i) as f64); + } + t.to_csr::() + } + + fn dense_random(r: usize, c: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let mut t = TriMatI::::new((r, c)); + for i in 0..r { + for j in 0..c { + t.add_triplet(i, j, rng.signed()); + } + } + t.to_csr::() + } + + /// The acceptance criterion: agreement with a dense LAPACK reference. + fn assert_matches_lapack(name: &str, a: &SvdMat, rank: usize, tol: f64) -> SvdRec { + let want = reference_singular_values(&dense_of(a)); + let got = svd_seed(a, rank, 42).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(got.d, rank, "{name}: rank"); + for (i, &g) in got.s.iter().enumerate() { + let rel = (g - want[i]).abs() / want[i].abs().max(1e-30); + assert!( + rel < tol, + "{name}: singular value {i}: irlba {g:.12e} vs LAPACK {:.12e} (rel {rel:.3e})", + want[i] + ); + } + got + } + + #[test] + fn exact_on_diagonal_matrix() { + // Singular values are exactly 40, 39, 38, ... — the case LAS2 gets 20% wrong. + let a = diagonal(40); + let got = assert_matches_lapack("diagonal_40", &a, 10, 1e-10); + approx::assert_relative_eq!(got.s[0], 40.0, max_relative = 1e-10); + approx::assert_relative_eq!(got.s[9], 31.0, max_relative = 1e-10); + } + + #[test] + fn matches_lapack_on_dense_random() { + assert_matches_lapack("dense_random_60x40", &dense_random(60, 40, 3), 10, 1e-9); + } + + #[test] + fn matches_lapack_on_lowrank() { + assert_matches_lapack("lowrank_80x50_r8", &gen_lowrank(80, 50, 8, 21), 8, 1e-9); + assert_matches_lapack( + "lowrank_200x80_r10", + &gen_lowrank(200, 80, 10, 555), + 15, + 1e-8, + ); + } + + #[test] + fn matches_lapack_on_sparse() { + assert_matches_lapack("sparse_500x40", &gen_sparse(500, 40, 0.10, 7), 10, 1e-9); + assert_matches_lapack("sparse_200x120", &gen_sparse(200, 120, 0.05, 3), 20, 1e-9); + assert_matches_lapack( + "sparse_100x100", + &gen_sparse(100, 100, 0.0098, 42), + 20, + 1e-8, + ); + } + + /// Wide inputs must work as well as tall ones. + #[test] + fn matches_lapack_on_wide() { + assert_matches_lapack("wide_50x400", &gen_sparse(50, 400, 0.05, 1234), 10, 1e-9); + } + + #[test] + fn orientation_and_reconstruction() { + for (r, c) in [(200usize, 60usize), (60, 200)] { + let a = gen_sparse(r, c, 0.1, 11); + let rank = 10; + let svd = svd_seed(&a, rank, 42).unwrap(); + assert_eq!(svd.u.dim(), (r, rank), "u shape for {r}x{c}"); + assert_eq!(svd.vt.dim(), (rank, c), "vt shape for {r}x{c}"); + + // Rank-`rank` truncation error must match the reference tail exactly: + // ||A - A_k||_F = sqrt(Σ_{i>k} σ_i²). + let dense = dense_of(&a); + let refs = reference_singular_values(&dense); + let tail: f64 = refs[rank..].iter().map(|v| v * v).sum::().sqrt(); + let err: f64 = (&svd.recompose() - &dense) + .iter() + .map(|v| v * v) + .sum::() + .sqrt(); + approx::assert_relative_eq!(err, tail, max_relative = 1e-6); + } + } + + /// Singular vectors must be orthonormal and satisfy `A·vᵢ = σᵢ·uᵢ`. + #[test] + fn singular_vectors_are_orthonormal_and_consistent() { + let a = gen_sparse(300, 120, 0.06, 17); + let rank = 12; + let svd = svd_seed(&a, rank, 42).unwrap(); + + let orth_u = crate::dense::orthogonality_error(&svd.u.view()); + assert!(orth_u < 1e-9, "||UᵀU - I|| = {orth_u:.3e}"); + let vt_t = svd.vt.t().to_owned(); + let orth_v = crate::dense::orthogonality_error(&vt_t.view()); + assert!(orth_v < 1e-9, "||VᵀV - I|| = {orth_v:.3e}"); + + // A·vᵢ − σᵢ·uᵢ ≈ 0 + for i in 0..rank { + let vi: Vec = svd.vt.row(i).to_vec(); + let mut av = vec![0.0; a.rows()]; + SparseMat::mul_vec(&a, &vi, &mut av, false); + let resid: f64 = av + .iter() + .zip(svd.u.column(i).iter()) + .map(|(&x, &ui)| { + let d = x - svd.s[i] * ui; + d * d + }) + .sum::() + .sqrt(); + assert!( + resid / svd.s[0] < 1e-8, + "triplet {i}: ||A v - s u|| / s_max = {:.3e}", + resid / svd.s[0] + ); + } + } + + #[test] + fn csr_and_csc_agree() { + let a = gen_sparse(150, 90, 0.08, 5); + let csc = a.to_other_storage(); + let x = svd_seed(&a, 12, 42).unwrap(); + let y = svd_seed(&csc, 12, 42).unwrap(); + for (p, q) in x.s.iter().zip(y.s.iter()) { + approx::assert_relative_eq!(p, q, max_relative = 1e-10); + } + } + + /// Mean centering must match an explicitly centered dense reference — this is the + /// PCA path, and 1.x computed the correction wrongly. + #[test] + fn mean_centering_matches_dense_pca() { + let a = gen_lowrank(120, 40, 6, 31); + let dense = dense_of(&a); + let means = dense.mean_axis(Axis(0)).unwrap(); + let centered = &dense - &means.view().insert_axis(Axis(0)); + let want = reference_singular_values(¢ered); + + let got = svd_centered(&a, 6, Some(42)).unwrap(); + for (i, &g) in got.s.iter().enumerate() { + let rel = (g - want[i]).abs() / want[i].abs().max(1e-30); + assert!( + rel < 1e-8, + "centered singular value {i}: {g:.9e} vs {:.9e} (rel {rel:.3e})", + want[i] + ); + } + } + + #[test] + fn f32_matches_reference_at_f32_precision() { + let a64 = gen_lowrank(100, 50, 6, 77); + let want = reference_singular_values(&dense_of(&a64)); + // Same matrix at f32. + let mut t = TriMatI::::new((100, 50)); + for (v, (i, j)) in a64.iter() { + t.add_triplet(i as usize, j as usize, *v as f32); + } + let a32: SvdMat = t.to_csr::(); + let got = svd_seed(&a32, 6, 42).unwrap(); + for (i, &g) in got.s.iter().enumerate() { + let rel = ((g as f64) - want[i]).abs() / want[i].abs().max(1e-30); + assert!(rel < 1e-4, "f32 singular value {i}: rel {rel:.3e}"); + } + } + + #[test] + fn reports_convergence_and_bounded_restarts() { + let a = gen_sparse(200, 100, 0.05, 9); + let svd = svd_seed(&a, 10, 42).unwrap(); + assert_eq!(svd.diagnostics.algorithm, Algorithm::Irlba); + match svd.diagnostics.detail { + Detail::Irlba { + converged, + restarts, + max_residual, + .. + } => { + assert!(converged, "expected convergence"); + assert!(restarts < 50, "unexpectedly many restarts: {restarts}"); + assert!(max_residual >= 0.0); + } + ref other => panic!("wrong detail variant: {other:?}"), + } + assert!(svd.diagnostics.matvecs > 0); + } + + /// A tighter tolerance must not produce a worse answer. + #[test] + fn tolerance_is_monotone() { + let a = gen_lowrank(150, 60, 8, 44); + let want = reference_singular_values(&dense_of(&a)); + let mut prev = f64::INFINITY; + for tol in [1e-4, 1e-8, 1e-12] { + let cfg = IrlbaConfig::new(8).seed(42).tol(tol); + let got = svd_with(&a, &cfg, None).unwrap(); + let err = (0..8) + .map(|i| (got.s[i] - want[i]).abs() / want[i]) + .fold(0.0f64, f64::max); + assert!( + err <= prev * 10.0 + 1e-12, + "tol {tol:.0e} gave error {err:.3e}, worse than the looser tolerance's {prev:.3e}" + ); + prev = err.max(1e-16); + } + } + + #[test] + fn rejects_bad_configuration() { + let a = gen_sparse(50, 30, 0.2, 1); + assert!(matches!(svd(&a, 0), Err(SvdLibError::InvalidArgument(_)))); + assert!(matches!(svd(&a, 31), Err(SvdLibError::InvalidArgument(_)))); + // mean_center without means. + let cfg = IrlbaConfig::new(5).mean_center(true); + assert!(matches!( + svd_with(&a, &cfg, None), + Err(SvdLibError::InvalidArgument(_)) + )); + // means of the wrong length. + let cfg = IrlbaConfig::new(5).mean_center(true); + assert!(matches!( + svd_with(&a, &cfg, Some(Array1::zeros(7))), + Err(SvdLibError::ShapeMismatch(_)) + )); + } + + /// The same seed must reproduce bit-identical output, including vector signs. + #[test] + fn is_reproducible_given_a_seed() { + let a = gen_sparse(120, 70, 0.1, 23); + let x = svd_seed(&a, 8, 1234).unwrap(); + let y = svd_seed(&a, 8, 1234).unwrap(); + assert_eq!(x.s, y.s); + assert_eq!(x.u, y.u); + assert_eq!(x.vt, y.vt); + } + + /// Full rank on a small matrix: every singular value, exactly. + #[test] + fn full_rank_request() { + let a = gen_lowrank(30, 20, 20, 88); + assert_matches_lapack("full_rank_30x20", &a, 19, 1e-8); + } +} diff --git a/src/lanczos/masked.rs b/src/lanczos/masked.rs deleted file mode 100644 index 9b36c03..0000000 --- a/src/lanczos/masked.rs +++ /dev/null @@ -1,1001 +0,0 @@ -use crate::{determine_chunk_size, SMat, SvdFloat}; -use nalgebra_sparse::na::{DMatrix, DVector}; -use nalgebra_sparse::CsrMatrix; -use num_traits::Float; -use rayon::iter::IndexedParallelIterator; -use rayon::iter::ParallelIterator; -use rayon::prelude::{ - IntoParallelIterator, IntoParallelRefIterator, ParallelBridge, ParallelSliceMut, -}; -use std::fmt::Debug; -use std::ops::AddAssign; - -pub struct MaskedCSRMatrix<'a, T: Float> { - matrix: &'a CsrMatrix, - column_mask: Vec, - masked_to_original: Vec, - original_to_masked: Vec>, -} - -impl<'a, T: Float> MaskedCSRMatrix<'a, T> { - pub fn new(matrix: &'a CsrMatrix, column_mask: Vec) -> Self { - assert_eq!( - column_mask.len(), - matrix.ncols(), - "Column mask must have the same length as the number of columns in the matrix" - ); - - let mut masked_to_original = Vec::new(); - let mut original_to_masked = vec![None; column_mask.len()]; - let mut masked_index = 0; - - for (i, &is_included) in column_mask.iter().enumerate() { - if is_included { - masked_to_original.push(i); - original_to_masked[i] = Some(masked_index); - masked_index += 1; - } - } - - Self { - matrix, - column_mask, - masked_to_original, - original_to_masked, - } - } - - pub fn with_columns(matrix: &'a CsrMatrix, columns: &[usize]) -> Self { - let mut mask = vec![false; matrix.ncols()]; - for &col in columns { - assert!(col < matrix.ncols(), "Column index out of bounds"); - mask[col] = true; - } - Self::new(matrix, mask) - } - - pub fn uses_all_columns(&self) -> bool { - self.masked_to_original.len() == self.matrix.ncols() && self.column_mask.iter().all(|&x| x) - } - - pub fn ensure_identical_results_mode(&self) -> bool { - // For very small matrices where precision is critical - let is_small_matrix = self.matrix.nrows() <= 5 && self.matrix.ncols() <= 5; - is_small_matrix && self.uses_all_columns() - } -} - -impl< - T: Float - + AddAssign - + Sync - + Send - + std::ops::MulAssign - + Debug - + 'static - + std::iter::Sum - + std::ops::SubAssign - + num_traits::FromPrimitive, - > SMat for MaskedCSRMatrix<'_, T> -{ - fn nrows(&self) -> usize { - self.matrix.nrows() - } - - fn ncols(&self) -> usize { - self.masked_to_original.len() - } - - fn nnz(&self) -> usize { - let (major_offsets, minor_indices, _) = self.matrix.csr_data(); - let mut count = 0; - - for i in 0..self.matrix.nrows() { - for j in major_offsets[i]..major_offsets[i + 1] { - let col = minor_indices[j]; - if self.column_mask[col] { - count += 1; - } - } - } - count - } - - fn svd_opa(&self, x: &[T], y: &mut [T], transposed: bool) { - let nrows = if transposed { - self.ncols() - } else { - self.nrows() - }; - let ncols = if transposed { - self.nrows() - } else { - self.ncols() - }; - - assert_eq!( - x.len(), - ncols, - "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", - x.len(), - ncols - ); - assert_eq!( - y.len(), - nrows, - "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", - y.len(), - nrows - ); - - let (major_offsets, minor_indices, values) = self.matrix.csr_data(); - - if self.uses_all_columns() || (self.matrix.nrows() < 1000 && self.matrix.ncols() < 1000) { - // Fast path for unmasked matrices or small matrices - if !transposed { - // A * x calculation - self.matrix.svd_opa(x, y, false); - } else { - // A^T * x calculation - self.matrix.svd_opa(x, y, true); - } - return; - } - - y.fill(T::zero()); - - if !transposed { - // A * x calculation - let valid_indices: Vec> = (0..self.matrix.ncols()) - .map(|col| self.original_to_masked[col]) - .collect(); - - // Parallelization parameters - let rows = self.matrix.nrows(); - let chunk_size = std::cmp::max(16, rows / (rayon::current_num_threads() * 2)); - - // Process in parallel chunks - y.par_chunks_mut(chunk_size) - .enumerate() - .for_each(|(chunk_idx, y_chunk)| { - let start_row = chunk_idx * chunk_size; - let end_row = (start_row + y_chunk.len()).min(rows); - - for i in start_row..end_row { - let row_idx = i - start_row; - let mut sum = T::zero(); - - // Process row in blocks of 16 elements for better vectorization - let row_start = major_offsets[i]; - let row_end = major_offsets[i + 1]; - - // Unroll the loop by 4 for better instruction-level parallelism - let mut j = row_start; - while j + 4 <= row_end { - for offset in 0..4 { - let idx = j + offset; - let col = minor_indices[idx]; - if let Some(masked_col) = valid_indices[col] { - sum += values[idx] * x[masked_col]; - } - } - j += 4; - } - - // Handle remaining elements - while j < row_end { - let col = minor_indices[j]; - if let Some(masked_col) = valid_indices[col] { - sum += values[j] * x[masked_col]; - } - j += 1; - } - - y_chunk[row_idx] = sum; - } - }); - } else { - // A^T * x calculation - let nrows = self.matrix.nrows(); - let chunk_size = crate::utils::determine_chunk_size(nrows); - - // Create thread-local partial results and combine at the end - let results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = (start + chunk_size).min(nrows); - let mut local_y = vec![T::zero(); y.len()]; - - // Process a chunk of rows - for i in start..end { - let row_val = x[i]; - if row_val.is_zero() { - continue; // Skip zero values for performance - } - - for j in major_offsets[i]..major_offsets[i + 1] { - let col = minor_indices[j]; - if let Some(masked_col) = self.original_to_masked[col] { - local_y[masked_col] += values[j] * row_val; - } - } - } - local_y - }) - .collect(); - - // Combine results efficiently - for local_y in results { - // Only update non-zero elements to reduce memory traffic - for (idx, &val) in local_y.iter().enumerate() { - if !val.is_zero() { - y[idx] += val; - } - } - } - } - } - - fn compute_column_means(&self) -> Vec { - let rows = self.nrows(); - let masked_cols = self.ncols(); - let row_count_recip = T::one() / T::from(rows).unwrap(); - - let mut col_sums = vec![T::zero(); masked_cols]; - let (row_offsets, col_indices, values) = self.matrix.csr_data(); - - for i in 0..rows { - for j in row_offsets[i]..row_offsets[i + 1] { - let original_col = col_indices[j]; - if let Some(masked_col) = self.original_to_masked[original_col] { - col_sums[masked_col] += values[j]; - } - } - } - - // Convert to means - for j in 0..masked_cols { - col_sums[j] *= row_count_recip; - } - - col_sums - } - - fn multiply_with_dense( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - ) { - let m_rows = if transpose_self { - self.ncols() - } else { - self.nrows() - }; - let m_cols = if transpose_self { - self.nrows() - } else { - self.ncols() - }; - - assert_eq!( - dense.nrows(), - m_cols, - "Dense matrix has incompatible row count" - ); - assert_eq!( - result.nrows(), - m_rows, - "Result matrix has incompatible row count" - ); - assert_eq!( - result.ncols(), - dense.ncols(), - "Result matrix has incompatible column count" - ); - - let (major_offsets, minor_indices, values) = self.matrix.csr_data(); - - if !transpose_self { - let rows = self.matrix.nrows(); - let dense_cols = dense.ncols(); - - // Pre-filter valid column mappings to avoid repeated lookups - let valid_cols: Vec> = (0..self.matrix.ncols()) - .map(|col| self.original_to_masked.get(col).copied().flatten()) - .collect(); - - // Compute results in parallel, then apply to result matrix - let row_results: Vec<(usize, Vec)> = (0..rows) - .into_par_iter() - .map(|row| { - let mut row_result = vec![T::zero(); dense_cols]; - - // Process sparse row with blocked inner loop for better vectorization - let row_start = major_offsets[row]; - let row_end = major_offsets[row + 1]; - - // Unroll the sparse elements loop by 4 for better ILP - let mut j = row_start; - while j + 4 <= row_end { - // Process 4 sparse elements at once - for offset in 0..4 { - let idx = j + offset; - let col = minor_indices[idx]; - if let Some(masked_col) = valid_cols[col] { - let val = values[idx]; - - // Vectorized dense column update - for c in 0..dense_cols { - row_result[c] += val * dense[(masked_col, c)]; - } - } - } - j += 4; - } - - // Handle remaining elements - while j < row_end { - let col = minor_indices[j]; - if let Some(masked_col) = valid_cols[col] { - let val = values[j]; - - for c in 0..dense_cols { - row_result[c] += val * dense[(masked_col, c)]; - } - } - j += 1; - } - - (row, row_result) - }) - .collect(); - - // Apply results to output matrix - for (row, row_values) in row_results { - for c in 0..dense_cols { - result[(row, c)] = row_values[c]; - } - } - } else { - let nrows = self.matrix.nrows(); - let ncols = self.ncols(); - let dense_cols = dense.ncols(); - - // Clear result matrix once at the beginning - result.fill(T::zero()); - - // Pre-filter valid column mappings - let valid_cols: Vec> = (0..self.matrix.ncols()) - .map(|col| self.original_to_masked.get(col).copied().flatten()) - .collect(); - - let chunk_size = determine_chunk_size(nrows); - - // Use atomic-free approach with proper synchronization - let partial_results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = (start + chunk_size).min(nrows); - - // Use flat vector for better cache performance - let mut local_result = vec![T::zero(); ncols * dense_cols]; - - // Process chunk with better memory access patterns - for i in start..end { - let dense_row = unsafe { - std::slice::from_raw_parts( - dense.as_ptr().add(i * dense_cols), - dense_cols, - ) - }; - - // Block processing for better cache usage - let row_start = major_offsets[i]; - let row_end = major_offsets[i + 1]; - - // Process sparse elements in blocks of 8 for better vectorization - let mut j = row_start; - while j + 8 <= row_end { - for offset in 0..8 { - let idx = j + offset; - let col = minor_indices[idx]; - if let Some(masked_col) = valid_cols[col] { - let val = values[idx]; - let base_offset = masked_col * dense_cols; - - // Vectorized update with manual loop unrolling - let mut c = 0; - while c + 4 <= dense_cols { - local_result[base_offset + c] += val * dense_row[c]; - local_result[base_offset + c + 1] += val * dense_row[c + 1]; - local_result[base_offset + c + 2] += val * dense_row[c + 2]; - local_result[base_offset + c + 3] += val * dense_row[c + 3]; - c += 4; - } - - // Handle remaining columns - while c < dense_cols { - local_result[base_offset + c] += val * dense_row[c]; - c += 1; - } - } - } - j += 8; - } - - // Handle remaining sparse elements - while j < row_end { - let col = minor_indices[j]; - if let Some(masked_col) = valid_cols[col] { - let val = values[j]; - let base_offset = masked_col * dense_cols; - - for c in 0..dense_cols { - local_result[base_offset + c] += val * dense_row[c]; - } - } - j += 1; - } - } - - local_result - }) - .collect(); - - // Efficient reduction with blocked memory access - const BLOCK_SIZE: usize = 64; - for local_result in partial_results { - // Process in blocks for better cache performance - for r_block in (0..ncols).step_by(BLOCK_SIZE) { - let r_end = (r_block + BLOCK_SIZE).min(ncols); - - for c_block in (0..dense_cols).step_by(BLOCK_SIZE) { - let c_end = (c_block + BLOCK_SIZE).min(dense_cols); - - // Update result block - for r in r_block..r_end { - for c in c_block..c_end { - let val = local_result[r * dense_cols + c]; - if !val.is_zero() { - result[(r, c)] += val; - } - } - } - } - } - } - } - } - - fn multiply_with_dense_centered( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - means: &DVector, - ) { - let (major_offsets, minor_indices, values) = self.matrix.csr_data(); - - // Pre-compute column sums for the dense matrix - do this once - let dense_cols = dense.ncols(); - let dense_rows = dense.nrows(); - - // Pre-compute all column sums to avoid redundant calculations - let col_sums: Vec = (0..dense_cols) - .into_par_iter() - .map(|c| (0..dense_rows).map(|i| dense[(i, c)]).sum()) - .collect(); - - if !transpose_self { - let rows = self.matrix.nrows(); - - // Pre-compute mean adjustments for each column - let mean_adjustments: Vec = col_sums - .iter() - .map(|&col_sum| { - means - .iter() - .enumerate() - .filter_map(|(original_idx, &mean_val)| { - self.original_to_masked - .get(original_idx) - .map(|_| mean_val * col_sum) - }) - .sum() - }) - .collect(); - - let row_updates: Vec<(usize, Vec)> = (0..rows) - .into_par_iter() - .map(|row| { - let mut row_result = vec![T::zero(); dense_cols]; - - for j in major_offsets[row]..major_offsets[row + 1] { - let col = minor_indices[j]; - if let Some(masked_col) = self.original_to_masked[col] { - let val = values[j]; - - for c in 0..dense_cols { - row_result[c] += val * dense[(masked_col, c)]; - } - } - } - - for c in 0..dense_cols { - row_result[c] -= mean_adjustments[c]; - } - - (row, row_result) - }) - .collect(); - - for (row, row_values) in row_updates { - for c in 0..dense_cols { - result[(row, c)] = row_values[c]; - } - } - } else { - let nrows = self.matrix.nrows(); - let ncols = self.ncols(); - - // Clear the result matrix first - for i in 0..result.nrows() { - for j in 0..result.ncols() { - result[(i, j)] = T::zero(); - } - } - - // Choose optimal chunk size - let chunk_size = determine_chunk_size(nrows); - - // Compute partial results in parallel - let partial_results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = std::cmp::min(start + chunk_size, nrows); - - let mut local_result = DMatrix::::zeros(ncols, dense_cols); - - for i in start..end { - for j in major_offsets[i]..major_offsets[i + 1] { - let col = minor_indices[j]; - if let Some(masked_col) = self.original_to_masked[col] { - let sparse_val = values[j]; - - for c in 0..dense_cols { - local_result[(masked_col, c)] += sparse_val * dense[(i, c)]; - } - } - } - } - - // Apply mean adjustment for this chunk - let chunk_fraction = - T::from_f64((end - start) as f64 / dense_rows as f64).unwrap(); - - for masked_col in 0..ncols { - if masked_col < means.len() { - let mean = means[masked_col]; - for c in 0..dense_cols { - local_result[(masked_col, c)] -= - mean * col_sums[c] * chunk_fraction; - } - } - } - - local_result - }) - .collect(); - - for local_result in partial_results { - const BLOCK_SIZE: usize = 32; - - for r_block in 0..ncols.div_ceil(BLOCK_SIZE) { - let r_start = r_block * BLOCK_SIZE; - let r_end = std::cmp::min(r_start + BLOCK_SIZE, ncols); - - for c_block in 0..dense_cols.div_ceil(BLOCK_SIZE) { - let c_start = c_block * BLOCK_SIZE; - let c_end = std::cmp::min(c_start + BLOCK_SIZE, dense_cols); - - for r in r_start..r_end { - for c in c_start..c_end { - result[(r, c)] += local_result[(r, c)]; - } - } - } - } - } - } - } - - fn multiply_transposed_by_dense(&self, q: &DMatrix, result: &mut DMatrix) { - let q_rows = q.nrows(); - let q_cols = q.ncols(); - let masked_cols = self.ncols(); - - assert_eq!( - q_rows, - self.nrows(), - "Q matrix has incompatible row count: expected {}, got {}", - self.nrows(), - q_rows - ); - assert_eq!( - result.nrows(), - q_cols, - "Result matrix has incompatible row count: expected {}, got {}", - q_cols, - result.nrows() - ); - assert_eq!( - result.ncols(), - masked_cols, - "Result matrix has incompatible column count: expected {}, got {}", - masked_cols, - result.ncols() - ); - - // Clear result matrix - for i in 0..result.nrows() { - for j in 0..result.ncols() { - result[(i, j)] = T::zero(); - } - } - - let (major_offsets, minor_indices, values) = self.matrix.csr_data(); - let nrows = self.matrix.nrows(); - let chunk_size = determine_chunk_size(nrows); - - if self.uses_all_columns() && (nrows < 1000 && self.matrix.ncols() < 1000) { - // Fast path for small unmasked matrices - let partial_results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = (start + chunk_size).min(nrows); - let mut local_result = DMatrix::::zeros(q_cols, masked_cols); - - for row in start..end { - // Process all non-zeros in this row - for idx in major_offsets[row]..major_offsets[row + 1] { - let col = minor_indices[idx]; - let sparse_val = values[idx]; - - // Accumulate: local_result[q_col, col] += q[row, q_col] * sparse_val - for q_col in 0..q_cols { - local_result[(q_col, col)] += q[(row, q_col)] * sparse_val; - } - } - } - - local_result - }) - .collect(); - - // Combine partial results efficiently - for local_result in partial_results { - for r in 0..q_cols { - for c in 0..masked_cols { - let val = local_result[(r, c)]; - if !val.is_zero() { - result[(r, c)] += val; - } - } - } - } - } else { - // Optimized path for masked matrices - let partial_results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = (start + chunk_size).min(nrows); - let mut local_result = DMatrix::::zeros(q_cols, masked_cols); - - for row in start..end { - // Process all non-zeros in this row - for idx in major_offsets[row]..major_offsets[row + 1] { - let original_col = minor_indices[idx]; - - // Check if this column is in our mask - if let Some(masked_col) = self.original_to_masked[original_col] { - let sparse_val = values[idx]; - - // Accumulate: local_result[q_col, masked_col] += q[row, q_col] * sparse_val - for q_col in 0..q_cols { - local_result[(q_col, masked_col)] += q[(row, q_col)] * sparse_val; - } - } - } - } - - local_result - }) - .collect(); - - // Combine partial results efficiently - for local_result in partial_results { - for r in 0..q_cols { - for c in 0..masked_cols { - let val = local_result[(r, c)]; - if !val.is_zero() { - result[(r, c)] += val; - } - } - } - } - } - } - - fn multiply_transposed_by_dense_centered( - &self, - q: &DMatrix, - result: &mut DMatrix, - means: &DVector, - ) { - let q_rows = q.nrows(); - let q_cols = q.ncols(); - let masked_cols = self.ncols(); - - assert_eq!( - q_rows, - self.nrows(), - "Q matrix has incompatible row count: expected {}, got {}", - self.nrows(), - q_rows - ); - assert_eq!( - result.nrows(), - q_cols, - "Result matrix has incompatible row count: expected {}, got {}", - q_cols, - result.nrows() - ); - assert_eq!( - result.ncols(), - masked_cols, - "Result matrix has incompatible column count: expected {}, got {}", - masked_cols, - result.ncols() - ); - assert_eq!( - means.len(), - masked_cols, - "Means vector has incompatible length: expected {}, got {}", - masked_cols, - means.len() - ); - - // Clear result matrix - for i in 0..result.nrows() { - for j in 0..result.ncols() { - result[(i, j)] = T::zero(); - } - } - - let (major_offsets, minor_indices, values) = self.matrix.csr_data(); - - // Pre-compute column sums of Q - following the pattern from multiply_with_dense_centered - let q_col_sums: Vec = (0..q_cols) - .into_par_iter() - .map(|col| { - (0..q_rows).map(|row| q[(row, col)]).sum() - }) - .collect(); - - // Pre-compute mean adjustments for each masked column - // For Q^T * (A - means): result[q_col, masked_col] = Q^T * A - sum(Q[q_col]) * means[masked_col] - let mean_adjustments: Vec = q_col_sums - .iter() - .enumerate() - .map(|(q_col, &q_sum)| { - means - .iter() - .enumerate() - .map(|(masked_col_idx, &mean_val)| { - if masked_col_idx < masked_cols { - q_sum * mean_val - } else { - T::zero() - } - }) - .sum() - }) - .collect(); - - let nrows = self.matrix.nrows(); - let chunk_size = determine_chunk_size(nrows); - - // Process sparse matrix rows in chunks, similar to the transpose_self=true case - let partial_results: Vec> = (0..nrows.div_ceil(chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = std::cmp::min(start + chunk_size, nrows); - - let mut local_result = DMatrix::::zeros(q_cols, masked_cols); - - for row in start..end { - // Process all non-zeros in this row - for idx in major_offsets[row]..major_offsets[row + 1] { - let original_col = minor_indices[idx]; - - // Check if this column is in our mask - if let Some(masked_col) = self.original_to_masked[original_col] { - let sparse_val = values[idx]; - - // Accumulate: local_result[q_col, masked_col] += q[row, q_col] * sparse_val - for q_col in 0..q_cols { - local_result[(q_col, masked_col)] += q[(row, q_col)] * sparse_val; - } - } - } - } - - // Apply mean adjustment for this chunk, following the pattern from your function - let chunk_fraction = T::from_f64((end - start) as f64 / q_rows as f64).unwrap(); - - for q_col in 0..q_cols { - let q_sum = q_col_sums[q_col]; - for masked_col in 0..masked_cols { - local_result[(q_col, masked_col)] -= q_sum * means[masked_col] * chunk_fraction; - } - } - - local_result - }) - .collect(); - - // Combine partial results with block-wise writing for better cache locality - for local_result in partial_results { - const BLOCK_SIZE: usize = 64; - - for r_block in 0..q_cols.div_ceil(BLOCK_SIZE) { - let r_start = r_block * BLOCK_SIZE; - let r_end = std::cmp::min(r_start + BLOCK_SIZE, q_cols); - - for c_block in 0..masked_cols.div_ceil(BLOCK_SIZE) { - let c_start = c_block * BLOCK_SIZE; - let c_end = std::cmp::min(c_start + BLOCK_SIZE, masked_cols); - - for r in r_start..r_end { - for c in c_start..c_end { - result[(r, c)] += local_result[(r, c)]; - } - } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::SMat; - use nalgebra_sparse::{coo::CooMatrix, csr::CsrMatrix}; - use rand::rngs::StdRng; - use rand::{Rng, SeedableRng}; - - #[test] - fn test_masked_matrix() { - // Create a test matrix - let mut coo = CooMatrix::::new(3, 5); - coo.push(0, 0, 1.0); - coo.push(0, 2, 2.0); - coo.push(0, 4, 3.0); - coo.push(1, 1, 4.0); - coo.push(1, 3, 5.0); - coo.push(2, 0, 6.0); - coo.push(2, 2, 7.0); - coo.push(2, 4, 8.0); - - let csr = CsrMatrix::from(&coo); - - // Create a masked matrix with columns 0, 2, 4 - let columns = vec![0, 2, 4]; - let masked = MaskedCSRMatrix::with_columns(&csr, &columns); - - // Check dimensions - assert_eq!(masked.nrows(), 3); - assert_eq!(masked.ncols(), 3); - assert_eq!(masked.nnz(), 6); // Only entries in the selected columns - - // Test SVD on the masked matrix - let svd_result = crate::lanczos::svd(&masked); - assert!(svd_result.is_ok()); - } - - #[test] - fn test_masked_vs_physical_subset() { - // Create a fixed seed for reproducible tests - let mut rng = StdRng::seed_from_u64(42); - - // Generate a random matrix (5x8) - let nrows = 14; - let ncols = 10; - let nnz = 40; // Number of non-zero elements - - let mut coo = CooMatrix::::new(nrows, ncols); - - // Fill with random non-zero values - for _ in 0..nnz { - let row = rng.gen_range(0..nrows); - let col = rng.gen_range(0..ncols); - let val = rng.gen_range(0.1..10.0); - - // Note: CooMatrix will overwrite if the position already has a value - coo.push(row, col, val); - } - - // Convert to CSR which is what our masked implementation uses - let csr = CsrMatrix::from(&coo); - - // Select a subset of columns (e.g., columns 1, 3, 5, 7) - let selected_columns = vec![1, 3, 5, 7]; - - // Create the masked matrix view - let masked_matrix = MaskedCSRMatrix::with_columns(&csr, &selected_columns); - - // Create a physical copy with just those columns - let mut physical_subset = CooMatrix::::new(nrows, selected_columns.len()); - - // Map original column indices to new column indices - let col_map: std::collections::HashMap = selected_columns - .iter() - .enumerate() - .map(|(new_idx, &old_idx)| (old_idx, new_idx)) - .collect(); - - // Copy the values for the selected columns - for (row, col, val) in coo.triplet_iter() { - if let Some(&new_col) = col_map.get(&col) { - physical_subset.push(row, new_col, *val); - } - } - - // Convert to CSR for SVD - let physical_csr = CsrMatrix::from(&physical_subset); - - // Compare dimensions and nnz - assert_eq!(masked_matrix.nrows(), physical_csr.nrows()); - assert_eq!(masked_matrix.ncols(), physical_csr.ncols()); - assert_eq!(masked_matrix.nnz(), physical_csr.nnz()); - - // Perform SVD on both - let svd_masked = crate::lanczos::svd(&masked_matrix).unwrap(); - let svd_physical = crate::lanczos::svd(&physical_csr).unwrap(); - - // Compare SVD results - they should be very close but not exactly the same - // due to potential differences in numerical computation - - // Check dimension (rank) - assert_eq!(svd_masked.d, svd_physical.d); - - // Basic tolerance for floating point comparisons - let epsilon = 1e-10; - - // Check singular values (may be in different order, so we sort them) - let mut masked_s = svd_masked.s.to_vec(); - let mut physical_s = svd_physical.s.to_vec(); - masked_s.sort_by(|a, b| b.partial_cmp(a).unwrap()); // Sort in descending order - physical_s.sort_by(|a, b| b.partial_cmp(a).unwrap()); - - for (m, p) in masked_s.iter().zip(physical_s.iter()) { - assert!( - (m - p).abs() < epsilon, - "Singular values differ: {} vs {}", - m, - p - ); - } - - // Note: Comparing singular vectors is more complex due to potential sign flips - // and different ordering, so we'll skip that level of detailed comparison - } -} diff --git a/src/lanczos/mod.rs b/src/lanczos/mod.rs index 4e3a4ee..9591298 100644 --- a/src/lanczos/mod.rs +++ b/src/lanczos/mod.rs @@ -1,151 +1,167 @@ -pub mod masked; - -use crate::error::SvdLibError; -use crate::{Diagnostics, SMat, SvdFloat, SvdRec}; -use nalgebra_sparse::na::{DMatrix, DVector}; -use ndarray::{Array, Array2}; -use num_traits::real::Real; -use num_traits::{Float, FromPrimitive, One, Zero}; +//! Single-vector Lanczos with selective reorthogonalization — a port of LAS2 from +//! Doug Rohde's SVDLIBC. +//! +//! # ⚠ This module is deprecated and numerically unreliable +//! +//! LAS2 as implemented here does **not** agree with a dense LAPACK reference on any +//! matrix class tested. The largest singular value comes back with 18%–100% relative +//! error, including on `diag(n, n-1, ..., 1)` at full requested rank. The defect is +//! inherited from published 1.x, not introduced by the sprs port — running +//! `single-svdlib 1.0.9` on identical fixtures reproduces the same wrong values. +//! +//! Two causes are known: +//! +//! 1. **Fixed.** `imtqlb` hoisted its shift origin out of the iteration loop, so every +//! eigenvalue after the first used a stale shift. EISPACK `IMTQL1` assigns +//! `p = d(l)` inside the loop. This is what produced the "imtqlb had some +//! convergence issues" warnings 1.x printed on nearly every input before continuing +//! with corrupted Ritz values. +//! 2. **Open.** `ritvec` reads `s[k*js + i]` — row `k` — while `imtql2` stores +//! eigenvectors as columns. Transposing roughly halves the residual error but does +//! not eliminate it, so at least one further defect remains. +//! +//! Use [`crate::irlba`] instead: restarted Lanczos bidiagonalization, validated against +//! LAPACK, with a Krylov basis bounded by the requested rank rather than growing to +//! `min(rows, cols)`. +//! +//! The module is retained so 2.0 does not silently drop the API, and so the repair has +//! a home. The accuracy tests are present but `#[ignore]`d, and +//! `report_accuracy_vs_lapack` prints the current error profile. + +// Numeric kernels index several arrays in step from one loop variable, and +// offset arithmetic is load-bearing; iterator rewrites obscure which array an +// index belongs to. +#![allow(clippy::needless_range_loop)] +#![allow(clippy::manual_checked_ops)] + +use crate::error::{Result, SvdLibError}; +use crate::matrix::SparseMat; +use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec}; +use ndarray::{Array1, Array2}; +use num_traits::Float; use rand::rngs::StdRng; -use rand::{rng, Rng, RngCore, SeedableRng}; -use rayon::iter::IndexedParallelIterator; -use rayon::iter::ParallelIterator; -use rayon::prelude::{IntoParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator}; -use std::fmt::Debug; -use std::iter::Sum; +use rand::{rng, Rng, RngExt, SeedableRng}; +use rayon::prelude::*; +use std::cell::Cell; use std::mem; -use std::ops::{AddAssign, MulAssign, Neg, SubAssign}; -/// Trait for floating point types that can be used with the SVD algorithm - -/// SVD at full dimensionality, calls `svdLAS2` with the highlighted defaults -/// -/// svdLAS2(A, `0`, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, `0`) -/// -/// # Parameters -/// - A: Sparse matrix -pub fn svd(a: &M) -> Result, SvdLibError> -where - T: SvdFloat, - M: SMat, -{ - let eps_small = T::from_f64(-1.0e-30).unwrap(); - let eps_large = T::from_f64(1.0e-30).unwrap(); - let kappa = T::from_f64(1.0e-6).unwrap(); - svd_las2(a, 0, 0, &[eps_small, eps_large], kappa, 0) +const MAXLL: usize = 2; +const MAX_QL_ITERATIONS: usize = 100; + +/// Default end interval: eigenvalues inside it are considered unwanted. +pub const DEFAULT_END_INTERVAL: [f64; 2] = [-1.0e-30, 1.0e-30]; +/// Default relative accuracy for accepting a Ritz value as an eigenvalue. +pub const DEFAULT_KAPPA: f64 = 1.0e-6; + +/// SVD at full dimensionality with default tolerances. +#[deprecated( + since = "2.0.0", + note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs." +)] +pub fn svd>(a: &M) -> Result> { + #[allow(deprecated)] + svd_dim_seed(a, 0, 0) } -/// SVD at desired dimensionality, calls `svdLAS2` with the highlighted defaults +/// SVD at the requested dimensionality with default tolerances. /// -/// svdLAS2(A, dimensions, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, `0`) -/// -/// # Parameters -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions, bounded by the matrix shape -pub fn svd_dim(a: &M, dimensions: usize) -> Result, SvdLibError> -where - T: SvdFloat, - M: SMat, -{ - let eps_small = T::from_f64(-1.0e-30).unwrap(); - let eps_large = T::from_f64(1.0e-30).unwrap(); - let kappa = T::from_f64(1.0e-6).unwrap(); - - svd_las2(a, dimensions, 0, &[eps_small, eps_large], kappa, 0) +/// `dimensions == 0` means `min(rows, cols)`. +#[deprecated( + since = "2.0.0", + note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs." +)] +pub fn svd_dim>(a: &M, dimensions: usize) -> Result> { + #[allow(deprecated)] + svd_dim_seed(a, dimensions, 0) } -/// SVD at desired dimensionality with supplied seed, calls `svdLAS2` with the highlighted defaults -/// -/// svdLAS2(A, dimensions, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, random_seed) +/// SVD at the requested dimensionality with a fixed seed. /// -/// # Parameters -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions, bounded by the matrix shape -/// - random_seed: A supplied seed `if > 0`, otherwise an internal seed will be generated -pub fn svd_dim_seed( +/// `random_seed == 0` draws a seed from the OS. +#[deprecated( + since = "2.0.0", + note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs." +)] +pub fn svd_dim_seed>( a: &M, dimensions: usize, - random_seed: u32, -) -> Result, SvdLibError> -where - T: SvdFloat, - M: SMat, -{ - let eps_small = T::from_f64(-1.0e-30).unwrap(); - let eps_large = T::from_f64(1.0e-30).unwrap(); - let kappa = T::from_f64(1.0e-6).unwrap(); - + random_seed: u64, +) -> Result> { + #[allow(deprecated)] svd_las2( a, dimensions, 0, - &[eps_small, eps_large], - kappa, + &[ + T::from_f64_val(DEFAULT_END_INTERVAL[0]), + T::from_f64_val(DEFAULT_END_INTERVAL[1]), + ], + T::from_f64_val(DEFAULT_KAPPA), random_seed, ) } -/// Compute a singular value decomposition +/// Compute a singular value decomposition with full control. /// -/// # Parameters +/// - `dimensions`: upper limit on singular triplets, `0` for `min(rows, cols)` +/// - `iterations`: upper limit on Lanczos steps, `0` for `min(rows, cols)`; clamped +/// into `[dimensions, min(rows, cols)]` +/// - `end_interval`: interval bracketing unwanted (near-zero) eigenvalues +/// - `kappa`: relative accuracy for accepting Ritz values, floored at `eps^(3/4)` +/// - `random_seed`: `0` draws from the OS /// -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions (0 = max), -/// where "max" is a value bounded by the matrix shape, the smaller of -/// the matrix rows or columns. e.g. `A.nrows().min(A.ncols())` -/// - iterations: Upper limit of desired number of lanczos steps (0 = max), -/// where "max" is a value bounded by the matrix shape, the smaller of -/// the matrix rows or columns. e.g. `A.nrows().min(A.ncols())` -/// iterations must also be in range [`dimensions`, `A.nrows().min(A.ncols())`] -/// - end_interval: Left, right end of interval containing unwanted eigenvalues, -/// typically small values centered around zero, e.g. `[-1.0e-30, 1.0e-30]` -/// - kappa: Relative accuracy of ritz values acceptable as eigenvalues, e.g. `1.0e-6` -/// - random_seed: A supplied seed `if > 0`, otherwise an internal seed will be generated -pub fn svd_las2( +/// Singular values come back in descending order, with `u` as `m × d` and `vt` as +/// `d × n`. +#[deprecated( + since = "2.0.0", + note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs." +)] +pub fn svd_las2>( a: &M, dimensions: usize, iterations: usize, end_interval: &[T; 2], kappa: T, - random_seed: u32, -) -> Result, SvdLibError> -where - T: SvdFloat, - M: SMat, -{ - let random_seed = match random_seed > 0 { - true => random_seed, - false => rng().next_u32(), + random_seed: u64, +) -> Result> { + let random_seed = if random_seed > 0 { + random_seed + } else { + rng().next_u64() }; - let min_nrows_ncols = a.nrows().min(a.ncols()); + let min_dim = a.rows().min(a.cols()); + if min_dim < 2 { + return Err(SvdLibError::invalid(format!( + "svd_las2 needs both dimensions >= 2, got {}x{}", + a.rows(), + a.cols() + ))); + } let dimensions = match dimensions { - n if n == 0 || n > min_nrows_ncols => min_nrows_ncols, - _ => dimensions, + n if n == 0 || n > min_dim => min_dim, + n => n, }; - let iterations = match iterations { - n if n == 0 || n > min_nrows_ncols => min_nrows_ncols, + n if n == 0 || n > min_dim => min_dim, n if n < dimensions => dimensions, - _ => iterations, + n => n, }; - if dimensions < 2 { - return Err(SvdLibError::Las2Error(format!( + return Err(SvdLibError::invalid(format!( "svd_las2: insufficient dimensions: {dimensions}" ))); } - assert!(dimensions > 1 && dimensions <= min_nrows_ncols); - assert!(iterations >= dimensions && iterations <= min_nrows_ncols); + // Working on the transpose keeps the Lanczos vectors over the smaller dimension. + let transposed = (a.cols() as f64) >= (a.rows() as f64) * 1.2; + let nrows = if transposed { a.cols() } else { a.rows() }; + let ncols = if transposed { a.rows() } else { a.cols() }; - let transposed = (a.ncols() as f64) >= ((a.nrows() as f64) * 1.2); - let nrows = if transposed { a.ncols() } else { a.nrows() }; - let ncols = if transposed { a.nrows() } else { a.ncols() }; - - let mut wrk = WorkSpace::new(nrows, ncols, transposed, iterations)?; - let mut store = Store::new(ncols)?; + let mut wrk = WorkSpace::new(nrows, ncols, transposed, iterations); + let mut store = Store::new(ncols); + let tuning = Tuning::for_matrix(a.nnz(), a.rows(), a.cols()); let mut neig = 0; let steps = lanso( @@ -157,101 +173,183 @@ where &mut neig, &mut store, random_seed, + &tuning, )?; let kappa = Float::max(Float::abs(kappa), T::eps34()); - let mut r = ritvec(a, dimensions, kappa, &mut wrk, steps, neig, &mut store)?; + let mut raw = ritvec( + a, dimensions, kappa, &mut wrk, steps, neig, &mut store, &tuning, + )?; if transposed { - mem::swap(&mut r.Ut, &mut r.Vt); + mem::swap(&mut raw.ut, &mut raw.vt); } - Ok(SvdRec { - // Dimensionality (number of Ut,Vt rows & length of S) - d: r.d, - u: Array2::from_shape_vec((r.d, r.Ut.cols), r.Ut.value)?, - s: Array::from_shape_vec(r.d, r.S)?, - vt: Array2::from_shape_vec((r.d, r.Vt.cols), r.Vt.value)?, + let d = raw.d; + // `ut` is stored d x m row-major; the public contract is u as m x d. + let u = Array2::from_shape_vec((d, raw.ut.cols), raw.ut.value)? + .t() + .to_owned(); + let s = Array1::from_vec(raw.s); + let vt = Array2::from_shape_vec((d, raw.vt.cols), raw.vt.value)?; + + let mut rec = SvdRec { + d, + u, + s, + vt, + // LAS2 has no centering mode, so this is the plain Frobenius norm. + total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm(a, None)), diagnostics: Diagnostics { + algorithm: Algorithm::Las2, non_zero: a.nnz(), - dimensions: dimensions, - iterations: iterations, - transposed: transposed, - lanczos_steps: steps + 1, - ritz_values_stabilized: neig, - significant_values: r.d, - singular_values: r.nsig, - end_interval: *end_interval, - kappa: kappa, - random_seed: random_seed, + dimensions, + significant_values: raw.nsig, + transposed, + random_seed, + matvecs: wrk.matvecs.get(), + detail: Detail::Lanczos { + iterations, + lanczos_steps: steps + 1, + ritz_values_stabilized: neig, + end_interval: *end_interval, + kappa, + }, }, - }) + }; + sort_descending(&mut rec); + Ok(rec) } -const MAXLL: usize = 2; +/// Reorder a decomposition so singular values descend, permuting `u` and `vt` with +/// them. LAS2 produces them in ascending Ritz-value order internally. +fn sort_descending(rec: &mut SvdRec) { + let d = rec.d; + let mut order: Vec = (0..d).collect(); + order.sort_by(|&i, &j| { + rec.s[j] + .partial_cmp(&rec.s[i]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if order.iter().enumerate().all(|(i, &o)| i == o) { + return; + } + let s = Array1::from_iter(order.iter().map(|&i| rec.s[i])); + let u = rec.u.select(ndarray::Axis(1), &order); + let vt = rec.vt.select(ndarray::Axis(0), &order); + rec.s = s; + rec.u = u; + rec.vt = vt; +} + +/// Sparsity-derived tolerances and iteration caps. +/// +/// SVDLIBC used fixed values; very sparse operands need looser tolerances and more QL +/// sweeps to converge, so these scale with fill. +struct Tuning { + /// Tolerance floor used in place of raw machine epsilon. + eps: T, + /// Iteration cap for the tridiagonal QL kernels. + ql_iterations: usize, + /// Extra Lanczos steps granted per restart on very sparse inputs. + extra_steps: usize, + /// Multiplier applied to `kappa` when deciding significance. + kappa_scale: T, +} + +impl Tuning { + fn for_matrix(nnz: usize, rows: usize, cols: usize) -> Self { + let denom = (rows as f64) * (cols as f64); + let sparsity = if denom > 0.0 { + 1.0 - (nnz as f64 / denom) + } else { + 0.0 + }; + let eps = T::eps(); + let (eps_scale, ql_iterations, extra_steps, kappa_scale) = if sparsity > 0.999 { + (100.0, 500, 5, 10.0) + } else if sparsity > 0.99 { + (100.0, 300, 5, 10.0) + } else if sparsity > 0.9 { + (10.0, 200, 0, 1.0) + } else { + (1.0, MAX_QL_ITERATIONS, 0, 1.0) + }; + Self { + eps: eps * T::from_f64_val(eps_scale), + ql_iterations, + extra_steps, + kappa_scale: T::from_f64_val(kappa_scale), + } + } +} -#[derive(Debug, Clone, PartialEq)] -struct Store { +/// Retained Lanczos vectors. +/// +/// `storq` holds the Lanczos basis (offset by [`MAXLL`]); `storp` holds the first +/// [`MAXLL`] vectors used for the initial reorthogonalization. +struct Store { n: usize, vecs: Vec>, } -impl Store { - fn new(n: usize) -> Result { - Ok(Self { n, vecs: vec![] }) +impl Store { + fn new(n: usize) -> Self { + Self { n, vecs: vec![] } } - fn storq(&mut self, idx: usize, v: &[T]) { while idx + MAXLL >= self.vecs.len() { self.vecs.push(vec![T::zero(); self.n]); } self.vecs[idx + MAXLL].copy_from_slice(v); } - fn storp(&mut self, idx: usize, v: &[T]) { while idx >= self.vecs.len() { self.vecs.push(vec![T::zero(); self.n]); } self.vecs[idx].copy_from_slice(v); } - - fn retrq(&mut self, idx: usize) -> &[T] { + fn retrq(&self, idx: usize) -> &[T] { &self.vecs[idx + MAXLL] } - - fn retrp(&mut self, idx: usize) -> &[T] { + fn retrp(&self, idx: usize) -> &[T] { &self.vecs[idx] } } -#[derive(Debug, Clone, PartialEq)] -struct WorkSpace { +struct WorkSpace { nrows: usize, ncols: usize, transposed: bool, - w0: Vec, // workspace 0 - w1: Vec, // workspace 1 - w2: Vec, // workspace 2 - w3: Vec, // workspace 3 - w4: Vec, // workspace 4 - w5: Vec, // workspace 5 - alf: Vec, // array to hold diagonal of the tridiagonal matrix T - eta: Vec, // orthogonality estimate of Lanczos vectors at step j - oldeta: Vec, // orthogonality estimate of Lanczos vectors at step j-1 - bet: Vec, // array to hold off-diagonal of T - bnd: Vec, // array to hold the error bounds - ritz: Vec, // array to hold the ritz values - temp: Vec, // array to hold the temp values + w0: Vec, + w1: Vec, + w2: Vec, + w3: Vec, + w4: Vec, + w5: Vec, + /// Diagonal of the tridiagonal matrix T. + alf: Vec, + /// Orthogonality estimate at step j. + eta: Vec, + /// Orthogonality estimate at step j-1. + oldeta: Vec, + /// Off-diagonal of T. + bet: Vec, + /// Error bounds. + bnd: Vec, + /// Ritz values. + ritz: Vec, + temp: Vec, + /// Sparse products issued, for diagnostics. The Lanczos recurrence is serial, so a + /// `Cell` suffices — the parallelism lives inside each product. + matvecs: Cell, + /// Set when a QL sweep hit its iteration cap and fell back to best estimates. + ql_degraded: Cell, } -impl WorkSpace { - fn new( - nrows: usize, - ncols: usize, - transposed: bool, - iterations: usize, - ) -> Result { - Ok(Self { +impl WorkSpace { + fn new(nrows: usize, ncols: usize, transposed: bool, iterations: usize) -> Self { + Self { nrows, ncols, transposed, @@ -266,155 +364,106 @@ impl WorkSpace { oldeta: vec![T::zero(); iterations], bet: vec![T::zero(); 1 + iterations], ritz: vec![T::zero(); 1 + iterations], - bnd: vec![T::from_f64(f64::MAX).unwrap(); 1 + iterations], + bnd: vec![::max_value(); 1 + iterations], temp: vec![T::zero(); nrows], - }) + matvecs: Cell::new(0), + ql_degraded: Cell::new(false), + } } } -/* Row-major dense matrix. Rows are consecutive vectors. */ -#[derive(Debug, Clone, PartialEq)] -struct DMat { +/// Row-major dense matrix; rows are consecutive. +struct DMat { cols: usize, value: Vec, } -#[allow(non_snake_case)] -#[derive(Debug, Clone, PartialEq)] -struct SVDRawRec { +struct RawRec { d: usize, nsig: usize, - Ut: DMat, - S: Vec, - Vt: DMat, + ut: DMat, + s: Vec, + vt: DMat, } -fn compare(computed: T, expected: T) -> bool { - T::compare(computed, expected) +#[inline] +fn close(a: T, b: T) -> bool { + T::close(a, b) } -/* Function sorts array1 and array2 into increasing order for array1 */ -fn insert_sort(n: usize, array1: &mut [T], array2: &mut [T]) { - for i in 1..n { - for j in (1..i + 1).rev() { - if array1[j - 1] <= array1[j] { - break; - } - array1.swap(j - 1, j); - array2.swap(j - 1, j); - } - } +/// Sort `keys` ascending, applying the same permutation to `vals`. +/// +/// Replaces SVDLIBC's insertion sort, which was quadratic in the Lanczos step count. +/// A stable sort keeps the tie ordering the original relied on. +fn sort_pair(n: usize, keys: &mut [T], vals: &mut [T]) { + let mut order: Vec = (0..n).collect(); + order.sort_by(|&i, &j| { + keys[i] + .partial_cmp(&keys[j]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let sk: Vec = order.iter().map(|&i| keys[i]).collect(); + let sv: Vec = order.iter().map(|&i| vals[i]).collect(); + keys[..n].copy_from_slice(&sk); + vals[..n].copy_from_slice(&sv); } -#[allow(non_snake_case)] -#[rustfmt::skip] -fn svd_opb(A: &dyn SMat, x: &[T], y: &mut [T], temp: &mut [T], transposed: bool) { - let nrows = if transposed { A.ncols() } else { A.nrows() }; - let ncols = if transposed { A.nrows() } else { A.ncols() }; - assert_eq!(x.len(), ncols, "svd_opb: x must be A.ncols() in length, x = {}, A.ncols = {}", x.len(), ncols); - assert_eq!(y.len(), ncols, "svd_opb: y must be A.ncols() in length, y = {}, A.ncols = {}", y.len(), ncols); - assert_eq!(temp.len(), nrows, "svd_opa: temp must be A.nrows() in length, temp = {}, A.nrows = {}", temp.len(), nrows); - A.svd_opa(x, temp, transposed); // temp = (A * x) - A.svd_opa(temp, y, !transposed); // y = A' * (A * x) = A' * temp +/// `y = Aᵀ(Ax)`, using `temp` as the intermediate. +fn svd_opb>( + a: &M, + x: &[T], + y: &mut [T], + temp: &mut [T], + transposed: bool, + matvecs: &Cell, +) { + a.mul_vec(x, temp, transposed); + a.mul_vec(temp, y, !transposed); + matvecs.set(matvecs.get() + 2); } -// constant times a vector plus a vector -fn svd_daxpy(da: T, x: &[T], y: &mut [T]) { - if x.len() < 1000 { - for (xval, yval) in x.iter().zip(y.iter_mut()) { - *yval += da * *xval +fn daxpy(da: T, x: &[T], y: &mut [T]) { + if x.len() < 1024 { + for (yv, &xv) in y.iter_mut().zip(x.iter()) { + *yv += da * xv; } } else { y.par_iter_mut() .zip(x.par_iter()) - .for_each(|(yval, xval)| *yval += da * *xval); + .for_each(|(yv, &xv)| *yv += da * xv); } } -// finds the index of element having max absolute value -fn svd_idamax(n: usize, x: &[T]) -> usize { - assert!(n > 0, "svd_idamax: unexpected inputs!"); - - match n { - 1 => 0, - _ => { - let mut imax = 0; - for (i, xval) in x.iter().enumerate().take(n).skip(1) { - if xval.abs() > x[imax].abs() { - imax = i; - } - } - imax - } - } -} - -// returns |a| if b is positive; else fsign returns -|a| -fn svd_fsign(a: T, b: T) -> T { - match (a >= T::zero() && b >= T::zero()) || (a < T::zero() && b < T::zero()) { - true => a, - false => -a, - } -} - -// finds sqrt(a^2 + b^2) without overflow or destructive underflow -fn svd_pythag(a: T, b: T) -> T { - match Float::max(Float::abs(a), Float::abs(b)) { - n if n > T::zero() => { - let mut p = n; - let mut r = Float::powi(Float::min(Float::abs(a), Float::abs(b)) / p, 2); - let four = T::from_f64(4.0).unwrap(); - let two = T::from_f64(2.0).unwrap(); - let mut t = four + r; - while !compare(t, four) { - let s = r / t; - let u = T::one() + two * s; - p = p * u; - r = Float::powi((s / u), 2); - t = four + r; - } - p - } - _ => T::zero(), - } -} - -// dot product of two vectors -fn svd_ddot + Send + Sync>(x: &[T], y: &[T]) -> T { - if x.len() < 1000 { - x.iter().zip(y).map(|(a, b)| *a * *b).sum() +fn ddot(x: &[T], y: &[T]) -> T { + if x.len() < 1024 { + x.iter().zip(y).map(|(&a, &b)| a * b).sum() } else { - x.par_iter().zip(y.par_iter()).map(|(a, b)| *a * *b).sum() + x.par_iter().zip(y.par_iter()).map(|(&a, &b)| a * b).sum() } } -// norm (length) of a vector -fn svd_norm + Send + Sync>(x: &[T]) -> T { - svd_ddot(x, x).sqrt() +fn norm(x: &[T]) -> T { + ddot(x, x).sqrt() } -// scales an input vector 'x', by a constant, storing in 'y' -fn svd_datx>(d: T, x: &[T], y: &mut [T]) { - for (i, xval) in x.iter().enumerate() { - y[i] = d * *xval; +fn datx(d: T, x: &[T], y: &mut [T]) { + for (yv, &xv) in y.iter_mut().zip(x.iter()) { + *yv = d * xv; } } -// scales an input vector 'x' by a constant, modifying 'x' -fn svd_dscal(d: T, x: &mut [T]) { - if x.len() < 1000 { - for elem in x.iter_mut() { - *elem *= d; +fn dscal(d: T, x: &mut [T]) { + if x.len() < 1024 { + for v in x.iter_mut() { + *v *= d; } } else { - x.par_iter_mut().for_each(|elem| { - *elem *= d; - }); + x.par_iter_mut().for_each(|v| *v *= d); } } -// copies a vector x to a vector y (reversed direction) -fn svd_dcopy(n: usize, offset: usize, x: &[T], y: &mut [T]) { +/// Copy `n` elements of `x` into `y` starting at `offset`, reversing their order. +fn dcopy_rev(n: usize, offset: usize, x: &[T], y: &mut [T]) { if n > 0 { let start = n - 1; for i in 0..n { @@ -423,22 +472,70 @@ fn svd_dcopy(n: usize, offset: usize, x: &[T], y: &mut [T]) { } } -const MAX_IMTQLB_ITERATIONS: usize = 100; +/// Index of the element with the largest magnitude. +fn idamax(n: usize, x: &[T]) -> usize { + debug_assert!(n > 0); + let mut imax = 0; + for i in 1..n { + if Float::abs(x[i]) > Float::abs(x[imax]) { + imax = i; + } + } + imax +} +/// `|a|` if `b >= 0`, else `-|a|`. +fn fsign(a: T, b: T) -> T { + if (a >= T::zero()) == (b >= T::zero()) { + a + } else { + -a + } +} + +/// `sqrt(a² + b²)` without intermediate overflow. +fn pythag(a: T, b: T) -> T { + let n = Float::max(Float::abs(a), Float::abs(b)); + if n <= T::zero() { + return T::zero(); + } + let four = T::from_f64_val(4.0); + let two = T::from_f64_val(2.0); + let mut p = n; + let mut r = Float::powi(Float::min(Float::abs(a), Float::abs(b)) / p, 2); + let mut t = four + r; + // The convergence test is `t == 4`, which a NaN never satisfies — an unbounded loop + // here would hang the process. The iteration converges quadratically, so a handful + // of steps is ample and the cap only ever fires on a poisoned input. + let mut guard = 0usize; + while !close(t, four) && guard < 64 { + guard += 1; + let s = r / t; + let u = T::one() + two * s; + p *= u; + r = Float::powi(s / u, 2); + t = four + r; + } + p +} + +/// Implicit QL for the eigenvalues of a symmetric tridiagonal matrix, tracking the +/// first components of the eigenvectors in `bnd`. +/// +/// On hitting the iteration cap this widens the affected error bounds and continues +/// rather than failing, matching 1.x behaviour, and reports it through `degraded`. fn imtqlb( n: usize, d: &mut [T], e: &mut [T], bnd: &mut [T], - max_imtqlb: Option, -) -> Result<(), SvdLibError> { - let max_imtqlb = max_imtqlb.unwrap_or(MAX_IMTQLB_ITERATIONS); + max_iter: usize, + degraded: &Cell, +) { if n == 1 { - return Ok(()); + return; } - - let matrix_size_factor = T::from_f64((n as f64).sqrt()).unwrap(); - + let size_factor = T::from_f64_val((n as f64).sqrt()); bnd[0] = T::one(); let last = n - 1; for i in 1..=last { @@ -448,37 +545,35 @@ fn imtqlb( e[last] = T::zero(); let mut i = 0; - - let mut had_convergence_issues = false; - for l in 0..=last { let mut iteration = 0; - let mut p = d[l]; - let mut f = bnd[l]; - while iteration <= max_imtqlb { + while iteration <= max_iter { let mut m = l; while m < n { if m == last { break; } - - // More forgiving convergence test for large/sparse matrices let test = Float::abs(d[m]) + Float::abs(d[m + 1]); - // Scale tolerance with matrix size and magnitude - let tol = ::epsilon() - * T::from_f64(100.0).unwrap() - * Float::max(test, T::one()) - * matrix_size_factor; - + let tol = + T::eps() * T::from_f64_val(100.0) * Float::max(test, T::one()) * size_factor; if Float::abs(e[m]) <= tol { - break; // Convergence achieved for this element + break; } m += 1; } + // The shift origin and the tracked eigenvector component must be re-read + // from the *current* d and bnd on every sweep — EISPACK IMTQL1 assigns + // `p = d(l)` at label 120, inside the iteration loop. 1.x hoisted both out + // of the loop, so after the first sweep every subsequent eigenvalue was + // computed from a stale shift. That is what produced the "imtqlb had some + // convergence issues" warnings and the garbage Ritz values behind them. + let mut p = d[l]; + let mut f = bnd[l]; + if m == l { - // Order the eigenvalues + // Insert this eigenvalue into the already-ordered prefix. let mut exchange = true; if l > 0 { i = l; @@ -497,182 +592,289 @@ fn imtqlb( } d[i] = p; bnd[i] = f; - iteration = max_imtqlb + 1; // Exit the loop - } else { - // Check if we've reached max iterations without convergence - if iteration == max_imtqlb { - // CRITICAL CHANGE: Don't fail, just note the issue and continue - had_convergence_issues = true; - - // Set conservative error bounds for non-converged values - for idx in l..=m { - bnd[idx] = Float::max(bnd[idx], T::from_f64(0.1).unwrap()); - } + break; + } - // Force "convergence" by zeroing the problematic subdiagonal element - e[l] = T::zero(); + if iteration == max_iter { + degraded.set(true); + for b in bnd.iter_mut().take(m + 1).skip(l) { + *b = Float::max(*b, T::from_f64_val(0.1)); + } + e[l] = T::zero(); + break; + } + iteration += 1; - // Break out of the iteration loop and move to next eigenvalue + let two = T::from_f64_val(2.0); + let mut g = (d[l + 1] - p) / (two * e[l]); + let mut r = pythag(g, T::one()); + g = d[m] - p + e[l] / (g + fsign(r, g)); + let mut s = T::one(); + let mut c = T::one(); + p = T::zero(); + + debug_assert!(m > 0); + i = m - 1; + let mut underflow = false; + while !underflow && i >= l { + f = s * e[i]; + let b = c * e[i]; + r = pythag(f, g); + e[i + 1] = r; + + if r < T::eps() * T::from_f64_val(1000.0) * (Float::abs(f) + Float::abs(g)) { + underflow = true; break; } + if Float::abs(r) < T::eps() * T::from_f64_val(100.0) { + r = T::eps() * T::from_f64_val(100.0) * fsign(T::one(), r); + } - iteration += 1; - // ........ form shift ........ - let two = T::from_f64(2.0).unwrap(); - let mut g = (d[l + 1] - p) / (two * e[l]); - let mut r = svd_pythag(g, T::one()); - g = d[m] - p + e[l] / (g + svd_fsign(r, g)); - let mut s = T::one(); - let mut c = T::one(); - p = T::zero(); - - assert!(m > 0, "imtqlb: expected 'm' to be non-zero"); - i = m - 1; - let mut underflow = false; - while !underflow && i >= l { - f = s * e[i]; - let b = c * e[i]; - r = svd_pythag(f, g); - e[i + 1] = r; - - // More forgiving underflow detection for sparse matrices - if r < ::epsilon() - * T::from_f64(1000.0).unwrap() - * (Float::abs(f) + Float::abs(g)) - { - underflow = true; - break; - } + s = f / r; + c = g / r; + g = d[i + 1] - p; + r = (d[i] - g) * s + two * c * b; + p = s * r; + d[i + 1] = g + p; + g = c * r - b; + f = bnd[i + 1]; + bnd[i + 1] = s * bnd[i] + c * f; + bnd[i] = c * bnd[i] - s * f; + if i == 0 { + break; + } + i -= 1; + } + if underflow { + d[i + 1] -= p; + } else { + d[l] -= p; + e[l] = g; + } + e[m] = T::zero(); + } + } +} - // Safety check for division by very small numbers - if Float::abs(r) < ::epsilon() * T::from_f64(100.0).unwrap() { - r = ::epsilon() - * T::from_f64(100.0).unwrap() - * svd_fsign(T::one(), r); - } +/// Implicit QL for eigenvalues *and* eigenvectors of a symmetric tridiagonal matrix. +fn imtql2( + nm: usize, + n: usize, + d: &mut [T], + e: &mut [T], + z: &mut [T], + max_iter: usize, +) -> Result<()> { + if n == 1 { + return Ok(()); + } + let two = T::from_f64_val(2.0); + let last = n - 1; + for i in 1..n { + e[i - 1] = e[i]; + } + e[last] = T::zero(); + + let nnm = n * nm; + for l in 0..n { + let mut iteration = 0; + while iteration <= max_iter { + let mut m = l; + while m < n { + if m == last { + break; + } + let test = Float::abs(d[m]) + Float::abs(d[m + 1]); + if close(test, test + Float::abs(e[m])) { + break; + } + m += 1; + } + if m == l { + break; + } + if iteration == max_iter { + return Err(SvdLibError::NoConvergence { + stage: "imtql2", + iterations: max_iter, + }); + } + iteration += 1; + let mut g = (d[l + 1] - d[l]) / (two * e[l]); + let mut r = pythag(g, T::one()); + g = d[m] - d[l] + e[l] / (g + fsign(r, g)); + let mut s = T::one(); + let mut c = T::one(); + let mut p = T::zero(); + + debug_assert!(m > 0); + let mut i = m - 1; + let mut underflow = false; + while !underflow && i >= l { + let mut f = s * e[i]; + let b = c * e[i]; + r = pythag(f, g); + e[i + 1] = r; + if close(r, T::zero()) { + underflow = true; + } else { s = f / r; c = g / r; g = d[i + 1] - p; - r = (d[i] - g) * s + T::from_f64(2.0).unwrap() * c * b; + r = (d[i] - g) * s + two * c * b; p = s * r; d[i + 1] = g + p; g = c * r - b; - f = bnd[i + 1]; - bnd[i + 1] = s * bnd[i] + c * f; - bnd[i] = c * bnd[i] - s * f; + for k in (0..nnm).step_by(n) { + let index = k + i; + f = z[index + 1]; + z[index + 1] = s * z[index] + c * f; + z[index] = c * z[index] - s * f; + } if i == 0 { break; } i -= 1; } - // ........ recover from underflow ......... - if underflow { - d[i + 1] -= p; - } else { - d[l] -= p; - e[l] = g; - } - e[m] = T::zero(); } + if underflow { + d[i + 1] -= p; + } else { + d[l] -= p; + e[l] = g; + } + e[m] = T::zero(); } } - if had_convergence_issues { - eprintln!("Warning: imtqlb had some convergence issues but continued with best estimates. Results may have reduced accuracy."); + + // Order eigenvalues ascending, carrying the eigenvectors along. + for l in 1..n { + let i = l - 1; + let mut k = i; + let mut p = d[i]; + for (j, item) in d.iter().enumerate().take(n).skip(l) { + if *item < p { + k = j; + p = *item; + } + } + if k != i { + d[k] = d[i]; + d[i] = p; + for j in (0..nnm).step_by(n) { + z.swap(j + i, j + k); + } + } } Ok(()) } -#[allow(non_snake_case)] -fn startv( - A: &dyn SMat, +/// Produce a starting vector in the range of `AᵀA`, orthogonal to the basis so far. +fn startv>( + a: &M, wrk: &mut WorkSpace, step: usize, - store: &mut Store, - random_seed: u32, -) -> Result { - // get initial vector; default is random - let mut rnm2 = svd_ddot(&wrk.w0, &wrk.w0); + store: &Store, + random_seed: u64, +) -> Result { + let mut rnm2 = ddot(&wrk.w0, &wrk.w0); for id in 0..3 { - if id > 0 || step > 0 || compare(rnm2, T::zero()) { - let mut bytes = [0; 32]; + if id > 0 || step > 0 || close(rnm2, T::zero()) { + let mut bytes = [0u8; 32]; for (i, b) in random_seed.to_le_bytes().iter().enumerate() { bytes[i] = *b; } - let mut seeded_rng = StdRng::from_seed(bytes); + let mut seeded = StdRng::from_seed(bytes); for val in wrk.w0.iter_mut() { - *val = T::from_f64(seeded_rng.random_range(-1.0..1.0)).unwrap(); + *val = T::from_f64_val(seeded.random_range(-1.0..1.0)); } } wrk.w3.copy_from_slice(&wrk.w0); - - // apply operator to put r in range (essential if m singular) - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); + svd_opb( + a, + &wrk.w3, + &mut wrk.w0, + &mut wrk.temp, + wrk.transposed, + &wrk.matvecs, + ); wrk.w3.copy_from_slice(&wrk.w0); - rnm2 = svd_ddot(&wrk.w3, &wrk.w3); + rnm2 = ddot(&wrk.w3, &wrk.w3); if rnm2 > T::zero() { break; } } if rnm2 <= T::zero() { - return Err(SvdLibError::StartvError(format!( - "rnm2 <= 0.0, rnm2 = {rnm2:?}" - ))); + return Err(SvdLibError::failed( + "startv", + format!("could not find a starting vector in range (rnm2 = {rnm2:?})"), + )); } if step > 0 { for i in 0..step { let v = store.retrq(i); - svd_daxpy(-svd_ddot(&wrk.w3, v), v, &mut wrk.w0); + daxpy(-ddot(&wrk.w3, v), v, &mut wrk.w0); } - - // make sure q[step] is orthogonal to q[step-1] - svd_daxpy(-svd_ddot(&wrk.w4, &wrk.w0), &wrk.w2, &mut wrk.w0); + // Keep q[step] orthogonal to q[step-1]. + let t = -ddot(&wrk.w4, &wrk.w0); + let w2 = std::mem::take(&mut wrk.w2); + daxpy(t, &w2, &mut wrk.w0); + wrk.w2 = w2; wrk.w3.copy_from_slice(&wrk.w0); - - rnm2 = match svd_ddot(&wrk.w3, &wrk.w3) { + rnm2 = match ddot(&wrk.w3, &wrk.w3) { dot if dot <= T::eps() * rnm2 => T::zero(), dot => dot, - } + }; } Ok(rnm2.sqrt()) } -#[allow(non_snake_case)] -fn stpone( - A: &dyn SMat, +/// The first Lanczos step; returns `(rnm, tol)`. +fn stpone>( + a: &M, wrk: &mut WorkSpace, - store: &mut Store, - random_seed: u32, -) -> Result<(T, T), SvdLibError> { - // get initial vector; default is random - let mut rnm = startv(A, wrk, 0, store, random_seed)?; - if compare(rnm, T::zero()) { - return Err(SvdLibError::StponeError("rnm == 0.0".to_string())); + store: &Store, + random_seed: u64, +) -> Result<(T, T)> { + let mut rnm = startv(a, wrk, 0, store, random_seed)?; + if close(rnm, T::zero()) { + return Err(SvdLibError::failed( + "stpone", + "starting vector has zero norm", + )); } - // normalize starting vector - svd_datx(Float::recip(rnm), &wrk.w0, &mut wrk.w1); - svd_dscal(Float::recip(rnm), &mut wrk.w3); + datx(Float::recip(rnm), &wrk.w0, &mut wrk.w1); + dscal(Float::recip(rnm), &mut wrk.w3); - // take the first step - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); - wrk.alf[0] = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-wrk.alf[0], &wrk.w1, &mut wrk.w0); - let t = svd_ddot(&wrk.w0, &wrk.w3); + svd_opb( + a, + &wrk.w3, + &mut wrk.w0, + &mut wrk.temp, + wrk.transposed, + &wrk.matvecs, + ); + wrk.alf[0] = ddot(&wrk.w0, &wrk.w3); + let alf0 = wrk.alf[0]; + let w1 = std::mem::take(&mut wrk.w1); + daxpy(-alf0, &w1, &mut wrk.w0); + let t = ddot(&wrk.w0, &wrk.w3); wrk.alf[0] += t; - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); + daxpy(-t, &w1, &mut wrk.w0); + wrk.w1 = w1; wrk.w4.copy_from_slice(&wrk.w0); - rnm = svd_norm(&wrk.w4); + rnm = norm(&wrk.w4); let anorm = rnm + Float::abs(wrk.alf[0]); Ok((rnm, T::eps().sqrt() * anorm)) } -#[allow(non_snake_case)] #[allow(clippy::too_many_arguments)] -fn lanczos_step( - A: &dyn SMat, +fn lanczos_step>( + a: &M, wrk: &mut WorkSpace, first: usize, last: usize, @@ -681,10 +883,10 @@ fn lanczos_step( rnm: &mut T, tol: &mut T, store: &mut Store, -) -> Result { - let eps1 = T::eps() * T::from_f64(wrk.ncols as f64).unwrap().sqrt(); +) -> Result { + let eps1 = T::eps() * T::from_f64_val(wrk.ncols as f64).sqrt(); let mut j = first; - let four = T::from_f64(4.0).unwrap(); + let four = T::from_f64_val(4.0); while j < last { mem::swap(&mut wrk.w1, &mut wrk.w2); @@ -696,58 +898,68 @@ fn lanczos_step( } wrk.bet[j] = *rnm; - // restart if invariant subspace is found - if compare(*rnm, T::zero()) { - *rnm = startv(A, wrk, j, store, 0)?; - if compare(*rnm, T::zero()) { + // Restart if an invariant subspace turned up. + if close(*rnm, T::zero()) { + *rnm = startv(a, wrk, j, store, 0)?; + if close(*rnm, T::zero()) { *enough = true; } } - if *enough { mem::swap(&mut wrk.w1, &mut wrk.w2); break; } - // take a lanczos step - svd_datx(Float::recip(*rnm), &wrk.w0, &mut wrk.w1); - svd_dscal(Float::recip(*rnm), &mut wrk.w3); - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); - svd_daxpy(-*rnm, &wrk.w2, &mut wrk.w0); - wrk.alf[j] = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-wrk.alf[j], &wrk.w1, &mut wrk.w0); - - // orthogonalize against initial lanczos vectors + datx(Float::recip(*rnm), &wrk.w0, &mut wrk.w1); + dscal(Float::recip(*rnm), &mut wrk.w3); + svd_opb( + a, + &wrk.w3, + &mut wrk.w0, + &mut wrk.temp, + wrk.transposed, + &wrk.matvecs, + ); + let rnm_v = *rnm; + let w2 = std::mem::take(&mut wrk.w2); + daxpy(-rnm_v, &w2, &mut wrk.w0); + wrk.w2 = w2; + wrk.alf[j] = ddot(&wrk.w0, &wrk.w3); + let alfj = wrk.alf[j]; + let w1 = std::mem::take(&mut wrk.w1); + daxpy(-alfj, &w1, &mut wrk.w0); + wrk.w1 = w1; + + // Reorthogonalize against the first few Lanczos vectors. if j <= MAXLL && Float::abs(wrk.alf[j - 1]) > four * Float::abs(wrk.alf[j]) { *ll = j; } for i in 0..(j - 1).min(*ll) { - let v1 = store.retrp(i); - let t = svd_ddot(v1, &wrk.w0); - let v2 = store.retrq(i); - svd_daxpy(-t, v2, &mut wrk.w0); + let t = ddot(store.retrp(i), &wrk.w0); + daxpy(-t, store.retrq(i), &mut wrk.w0); wrk.eta[i] = eps1; wrk.oldeta[i] = eps1; } - // extended local reorthogonalization - let t = svd_ddot(&wrk.w0, &wrk.w4); - svd_daxpy(-t, &wrk.w2, &mut wrk.w0); + // Extended local reorthogonalization. + let t = ddot(&wrk.w0, &wrk.w4); + let w2 = std::mem::take(&mut wrk.w2); + daxpy(-t, &w2, &mut wrk.w0); + wrk.w2 = w2; if wrk.bet[j] > T::zero() { wrk.bet[j] += t; } - let t = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); + let t = ddot(&wrk.w0, &wrk.w3); + let w1 = std::mem::take(&mut wrk.w1); + daxpy(-t, &w1, &mut wrk.w0); + wrk.w1 = w1; wrk.alf[j] += t; wrk.w4.copy_from_slice(&wrk.w0); - *rnm = svd_norm(&wrk.w4); + *rnm = norm(&wrk.w4); let anorm = wrk.bet[j] + Float::abs(wrk.alf[j]) + *rnm; *tol = T::eps().sqrt() * anorm; - // update the orthogonality bounds ortbnd(wrk, j, *rnm, eps1); - - // restore the orthogonality state when needed purge(wrk.ncols, *ll, wrk, j, rnm, *tol, store); if *rnm <= *tol { *rnm = T::zero(); @@ -757,6 +969,7 @@ fn lanczos_step( Ok(j) } +/// Restore orthogonality once the estimates say it has been lost. fn purge( n: usize, ll: usize, @@ -764,41 +977,40 @@ fn purge( step: usize, rnm: &mut T, tol: T, - store: &mut Store, + store: &Store, ) { if step < ll + 2 { return; } - let reps = T::eps().sqrt(); - let eps1 = T::eps() * T::from_f64(n as f64).unwrap().sqrt(); - let two = T::from_f64(2.0).unwrap(); + let eps1 = T::eps() * T::from_f64_val(n as f64).sqrt(); - let k = svd_idamax(step - (ll + 1), &wrk.eta) + ll; + let k = idamax(step - (ll + 1), &wrk.eta) + ll; if Float::abs(wrk.eta[k]) > reps { let reps1 = eps1 / reps; let mut iteration = 0; let mut flag = true; while iteration < 2 && flag { if *rnm > tol { - // bring in a lanczos vector t and orthogonalize both r and q against it let mut tq = T::zero(); let mut tr = T::zero(); for i in ll..step { let v = store.retrq(i); - let t = svd_ddot(v, &wrk.w3); + let t = ddot(v, &wrk.w3); tq += Float::abs(t); - svd_daxpy(-t, v, &mut wrk.w1); - let t = svd_ddot(v, &wrk.w4); + daxpy(-t, v, &mut wrk.w1); + let t = ddot(v, &wrk.w4); tr += Float::abs(t); - svd_daxpy(-t, v, &mut wrk.w0); + daxpy(-t, v, &mut wrk.w0); } wrk.w3.copy_from_slice(&wrk.w1); - let t = svd_ddot(&wrk.w0, &wrk.w3); + let t = ddot(&wrk.w0, &wrk.w3); tr += Float::abs(t); - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); + let w1 = std::mem::take(&mut wrk.w1); + daxpy(-t, &w1, &mut wrk.w0); + wrk.w1 = w1; wrk.w4.copy_from_slice(&wrk.w0); - *rnm = svd_norm(&wrk.w4); + *rnm = norm(&wrk.w4); if tq <= reps1 && tr <= *rnm * reps1 { flag = false; } @@ -812,11 +1024,12 @@ fn purge( } } +/// Update the running estimates of basis orthogonality. fn ortbnd(wrk: &mut WorkSpace, step: usize, rnm: T, eps1: T) { if step < 1 { return; } - if !compare(rnm, T::zero()) && step > 1 { + if !close(rnm, T::zero()) && step > 1 { wrk.oldeta[0] = (wrk.bet[1] * wrk.eta[1] + (wrk.alf[0] - wrk.alf[step]) * wrk.eta[0] - wrk.bet[step] * wrk.oldeta[0]) / rnm @@ -837,6 +1050,7 @@ fn ortbnd(wrk: &mut WorkSpace, step: usize, rnm: T, eps1: T) { wrk.eta[step] = eps1; } +/// Tighten error bounds and count how many Ritz values have stabilized. fn error_bound( enough: &mut bool, endl: T, @@ -846,12 +1060,11 @@ fn error_bound( step: usize, tol: T, ) -> usize { - assert!(step > 0, "error_bound: expected 'step' to be non-zero"); - - // massage error bounds for very close ritz values - let mid = svd_idamax(step + 1, bnd); - let sixteen = T::from_f64(16.0).unwrap(); + debug_assert!(step > 0); + let mid = idamax(step + 1, bnd); + let sixteen = T::from_f64_val(16.0); + // Fold bounds together for Ritz values that are nearly coincident. let mut i = ((step + 1) + (step - 1)) / 2; while i > mid + 1 { if Float::abs(ritz[i - 1] - ritz[i]) < T::eps34() * Float::abs(ritz[i]) @@ -863,7 +1076,6 @@ fn error_bound( } i -= 1; } - let mut i = ((step + 1) - (step - 1)) / 2; while i + 1 < mid { if Float::abs(ritz[i + 1] - ritz[i]) < T::eps34() * Float::abs(ritz[i]) @@ -876,7 +1088,6 @@ fn error_bound( i += 1; } - // refine the error bounds let mut neig = 0; let mut gapl = ritz[step] - ritz[0]; for i in 0..=step { @@ -898,415 +1109,156 @@ fn error_bound( neig } -fn imtql2( - nm: usize, - n: usize, - d: &mut [T], - e: &mut [T], - z: &mut [T], - max_imtqlb: Option, -) -> Result<(), SvdLibError> { - let max_imtqlb = max_imtqlb.unwrap_or(MAX_IMTQLB_ITERATIONS); - if n == 1 { - return Ok(()); - } - assert!(n > 1, "imtql2: expected 'n' to be > 1"); - let two = T::from_f64(2.0).unwrap(); - - let last = n - 1; - - for i in 1..n { - e[i - 1] = e[i]; - } - e[last] = T::zero(); - - let nnm = n * nm; - for l in 0..n { - let mut iteration = 0; - - // look for small sub-diagonal element - while iteration <= max_imtqlb { - let mut m = l; - while m < n { - if m == last { - break; - } - let test = Float::abs(d[m]) + Float::abs(d[m + 1]); - if compare(test, test + Float::abs(e[m])) { - break; // convergence = true; - } - m += 1; - } - if m == l { - break; - } - - // error -- no convergence to an eigenvalue after 30 iterations. - if iteration == max_imtqlb { - return Err(SvdLibError::Imtql2Error(format!( - "imtql2 no convergence to an eigenvalue after {} iterations", - max_imtqlb - ))); - } - iteration += 1; - - // form shift - let mut g = (d[l + 1] - d[l]) / (two * e[l]); - let mut r = svd_pythag(g, T::one()); - g = d[m] - d[l] + e[l] / (g + svd_fsign(r, g)); - - let mut s = T::one(); - let mut c = T::one(); - let mut p = T::zero(); - - assert!(m > 0, "imtql2: expected 'm' to be non-zero"); - let mut i = m - 1; - let mut underflow = false; - while !underflow && i >= l { - let mut f = s * e[i]; - let b = c * e[i]; - r = svd_pythag(f, g); - e[i + 1] = r; - if compare(r, T::zero()) { - underflow = true; - } else { - s = f / r; - c = g / r; - g = d[i + 1] - p; - r = (d[i] - g) * s + two * c * b; - p = s * r; - d[i + 1] = g + p; - g = c * r - b; - - // form vector - for k in (0..nnm).step_by(n) { - let index = k + i; - f = z[index + 1]; - z[index + 1] = s * z[index] + c * f; - z[index] = c * z[index] - s * f; - } - if i == 0 { - break; - } - i -= 1; - } - } /* end while (underflow != FALSE && i >= l) */ - /*........ recover from underflow .........*/ - if underflow { - d[i + 1] -= p; - } else { - d[l] -= p; - e[l] = g; - } - e[m] = T::zero(); - } - } - - // order the eigenvalues - for l in 1..n { - let i = l - 1; - let mut k = i; - let mut p = d[i]; - for (j, item) in d.iter().enumerate().take(n).skip(l) { - if *item < p { - k = j; - p = *item; - } - } - - // ...and corresponding eigenvectors - if k != i { - d[k] = d[i]; - d[i] = p; - for j in (0..nnm).step_by(n) { - z.swap(j + i, j + k); - } - } - } - - Ok(()) -} - -fn rotate_array(a: &mut [T], x: usize) { - let n = a.len(); - let mut j = 0; - let mut start = 0; - let mut t1 = a[0]; - - for _ in 0..n { - j = match j >= x { - true => j - x, - false => j + n - x, - }; - - let t2 = a[j]; - a[j] = t1; - - if j == start { - j += 1; - start = j; - t1 = a[j]; - } else { - t1 = t2; - } - } -} - -#[allow(non_snake_case)] -fn ritvec( - A: &dyn SMat, +/// Recover singular triplets from the converged Lanczos basis. +#[allow(clippy::too_many_arguments)] +fn ritvec>( + a: &M, dimensions: usize, kappa: T, wrk: &mut WorkSpace, steps: usize, neig: usize, store: &mut Store, -) -> Result, SvdLibError> { + tuning: &Tuning, +) -> Result> { let js = steps + 1; let jsq = js * js; - - let sparsity = T::one() - - (T::from_usize(A.nnz()).unwrap() - / (T::from_usize(A.nrows()).unwrap() * T::from_usize(A.ncols()).unwrap())); - - let epsilon = ::epsilon(); - let adaptive_eps = if sparsity > T::from_f64(0.99).unwrap() { - // For very sparse matrices (>99%), use a more relaxed tolerance - epsilon * T::from_f64(100.0).unwrap() - } else if sparsity > T::from_f64(0.9).unwrap() { - // For moderately sparse matrices (>90%), use a somewhat relaxed tolerance - epsilon * T::from_f64(10.0).unwrap() - } else { - // For less sparse matrices, use standard epsilon - epsilon - }; - - let max_iterations_imtql2 = if sparsity > T::from_f64(0.999).unwrap() { - // Ultra sparse (>99.9%) - needs many more iterations - Some(500) - } else if sparsity > T::from_f64(0.99).unwrap() { - // Very sparse (>99%) - needs more iterations - Some(300) - } else if sparsity > T::from_f64(0.9).unwrap() { - // Moderately sparse (>90%) - needs somewhat more iterations - Some(200) - } else { - // Default iterations for less sparse matrices - Some(50) - }; + let adaptive_eps = tuning.eps; let mut s = vec![T::zero(); jsq]; - // initialize s to an identity matrix for i in (0..jsq).step_by(js + 1) { s[i] = T::one(); } - let mut Vt = DMat { - cols: wrk.ncols, - value: vec![T::zero(); wrk.ncols * dimensions], - }; - - svd_dcopy(js, 0, &wrk.alf, &mut Vt.value); - svd_dcopy(steps, 1, &wrk.bet, &mut wrk.w5); + let mut eigenvalues = vec![T::zero(); wrk.ncols.max(js)]; + dcopy_rev(js, 0, &wrk.alf, &mut eigenvalues); + dcopy_rev(steps, 1, &wrk.bet, &mut wrk.w5); - // on return from imtql2(), `R.Vt.value` contains eigenvalues in - // ascending order and `s` contains the corresponding eigenvectors + // On return `eigenvalues` is ascending and `s` holds the matching eigenvectors. imtql2( js, js, - &mut Vt.value, + &mut eigenvalues, &mut wrk.w5, &mut s, - max_iterations_imtql2, + tuning.ql_iterations, )?; - let max_eigenvalue = Vt - .value + let max_eigenvalue = eigenvalues .iter() - .fold(T::zero(), |max, &val| Float::max(max, Float::abs(val))); + .take(js) + .fold(T::zero(), |mx, &v| Float::max(mx, Float::abs(v))); + let adaptive_kappa = kappa * tuning.kappa_scale; - let adaptive_kappa = if sparsity > T::from_f64(0.99).unwrap() { - // More relaxed kappa for very sparse matrices - kappa * T::from_f64(10.0).unwrap() - } else { - kappa - }; + let store_vectors: Vec<&[T]> = (0..js).map(|i| store.retrq(i)).collect(); - let mut x = dimensions - 1; - - let store_vectors: Vec> = (0..js).map(|i| store.retrq(i).to_vec()).collect(); - - let significant_indices: Vec = (0..js) - .into_par_iter() + let significant: Vec = (0..js) .filter(|&k| { - let relative_bound = + let bound = adaptive_kappa * Float::max(Float::abs(wrk.ritz[k]), max_eigenvalue * adaptive_eps); - wrk.bnd[k] <= relative_bound && k + 1 > js - neig + wrk.bnd[k] <= bound && k + 1 > js - neig }) .collect(); + let nsig = significant.len(); - let nsig = significant_indices.len(); - - let mut vt_vectors: Vec<(usize, Vec)> = significant_indices + let d = dimensions.min(nsig); + if d == 0 { + return Err(SvdLibError::failed( + "ritvec", + "no singular values met the significance threshold; \ + try more iterations or a larger kappa", + )); + } + + // `imtql2` and `lanso` both order Ritz values ascending, so the *largest* `d` are + // the tail of `significant`. 1.x took the leading `d` instead, which silently + // returned the smallest converged triplets whenever more converged than were + // requested — on `diag(40..1)` that reported the 10th-largest singular value as + // the largest. Keep the tail, then restore ascending order within it. + let keep: Vec = significant[nsig - d..].to_vec(); + + let mut vt_vectors: Vec<(usize, Vec)> = keep .into_par_iter() .map(|k| { let mut vec = vec![T::zero(); wrk.ncols]; - - for i in 0..js { - let idx = k * js + i; - - if Float::abs(s[idx]) > adaptive_eps { - for (j, item) in store_vectors[i].iter().enumerate().take(wrk.ncols) { - vec[j] += s[idx] * *item; + for (i, sv) in store_vectors.iter().enumerate().take(js) { + let coeff = s[k * js + i]; + if Float::abs(coeff) > adaptive_eps { + for (dst, &src) in vec.iter_mut().zip(sv.iter()).take(wrk.ncols) { + *dst += coeff * src; } } } - (k, vec) }) .collect(); - - // Sort by k value to maintain original order vt_vectors.sort_by_key(|(k, _)| *k); - // final dimension size - let d = dimensions.min(nsig); - let mut S = vec![T::zero(); d]; - let mut Ut = DMat { - cols: wrk.nrows, - value: vec![T::zero(); wrk.nrows * d], - }; - - // Create new Vt with the correct size - let mut Vt = DMat { + let mut vt = DMat { cols: wrk.ncols, value: vec![T::zero(); wrk.ncols * d], }; - - // Fill Vt with the vectors we computed - for (i, (_, vec)) in vt_vectors.into_iter().take(d).enumerate() { - let vt_offset = i * Vt.cols; - Vt.value[vt_offset..vt_offset + Vt.cols].copy_from_slice(&vec); + for (i, (_, vec)) in vt_vectors.into_iter().enumerate() { + let off = i * vt.cols; + vt.value[off..off + vt.cols].copy_from_slice(&vec); } - // Prepare for parallel computation of S and Ut - let mut ab_products = Vec::with_capacity(d); - let mut a_products = Vec::with_capacity(d); + let mut ut = DMat { + cols: wrk.nrows, + value: vec![T::zero(); wrk.nrows * d], + }; + let mut sv = vec![T::zero(); d]; - // First compute all matrix-vector products sequentially + // Each triplet needs A·v and Aᵀ(A·v); the products are serial because they share + // `wrk.temp`, but each one is internally parallel. for i in 0..d { - let vt_offset = i * Vt.cols; - let vt_vec = &Vt.value[vt_offset..vt_offset + Vt.cols]; - - let mut tmp_vec = vec![T::zero(); Vt.cols]; - let mut ut_vec = vec![T::zero(); wrk.nrows]; - - // Matrix-vector products with A and A'A - svd_opb(A, vt_vec, &mut tmp_vec, &mut wrk.temp, wrk.transposed); - A.svd_opa(vt_vec, &mut ut_vec, wrk.transposed); - - ab_products.push(tmp_vec); - a_products.push(ut_vec); - } - - let results: Vec<(usize, T)> = (0..d) - .into_par_iter() - .map(|i| { - let vt_offset = i * Vt.cols; - let vt_vec = &Vt.value[vt_offset..vt_offset + Vt.cols]; - let tmp_vec = &ab_products[i]; - - // Compute singular value - let t = svd_ddot(vt_vec, tmp_vec); - let sval = Float::max(t, T::zero()).sqrt(); + let off = i * vt.cols; + let v = &vt.value[off..off + vt.cols]; + let mut abv = vec![T::zero(); vt.cols]; + let mut av = vec![T::zero(); wrk.nrows]; - (i, sval) - }) - .collect(); + svd_opb(a, v, &mut abv, &mut wrk.temp, wrk.transposed, &wrk.matvecs); + a.mul_vec(v, &mut av, wrk.transposed); + wrk.matvecs.set(wrk.matvecs.get() + 1); - // Process results and scale the vectors - for (i, sval) in results { - S[i] = sval; - let ut_offset = i * Ut.cols; - let mut ut_vec = a_products[i].clone(); - - if sval > adaptive_eps { - svd_dscal(T::one() / sval, &mut ut_vec); - } else { - let dls = Float::max(sval, adaptive_eps); - let safe_scale = T::one() / dls; - svd_dscal(safe_scale, &mut ut_vec); - } + let t = ddot(v, &abv); + let sval = Float::max(t, T::zero()).sqrt(); + sv[i] = sval; - // Copy to output - Ut.value[ut_offset..ut_offset + Ut.cols].copy_from_slice(&ut_vec); + let scale = T::one() / Float::max(sval, adaptive_eps); + dscal(scale, &mut av); + let uoff = i * ut.cols; + ut.value[uoff..uoff + ut.cols].copy_from_slice(&av); } - Ok(SVDRawRec { - // Dimensionality (rank) + Ok(RawRec { d, - // Significant values nsig, - // DMat Ut Transpose of left singular vectors. (d by m) - // The vectors are the rows of Ut. - Ut, - // Array of singular values. (length d) - S, - // DMat Vt Transpose of right singular vectors. (d by n) - // The vectors are the rows of Vt. - Vt, + ut, + s: sv, + vt, }) } -#[allow(non_snake_case)] +/// The outer restart loop: run Lanczos steps until enough Ritz values stabilize. #[allow(clippy::too_many_arguments)] -fn lanso( - A: &dyn SMat, +fn lanso>( + a: &M, dim: usize, iterations: usize, end_interval: &[T; 2], wrk: &mut WorkSpace, neig: &mut usize, store: &mut Store, - random_seed: u32, -) -> Result { - let sparsity = T::one() - - (T::from_usize(A.nnz()).unwrap() - / (T::from_usize(A.nrows()).unwrap() * T::from_usize(A.ncols()).unwrap())); - let max_iterations_imtqlb = if sparsity > T::from_f64(0.999).unwrap() { - // Ultra sparse (>99.9%) - needs many more iterations - Some(500) - } else if sparsity > T::from_f64(0.99).unwrap() { - // Very sparse (>99%) - needs more iterations - Some(300) - } else if sparsity > T::from_f64(0.9).unwrap() { - // Moderately sparse (>90%) - needs somewhat more iterations - Some(100) - } else { - // Default iterations for less sparse matrices - Some(50) - }; - - let epsilon = ::epsilon(); - let adaptive_eps = if sparsity > T::from_f64(0.99).unwrap() { - // For very sparse matrices (>99%), use a more relaxed tolerance - epsilon * T::from_f64(100.0).unwrap() - } else if sparsity > T::from_f64(0.9).unwrap() { - // For moderately sparse matrices (>90%), use a somewhat relaxed tolerance - epsilon * T::from_f64(10.0).unwrap() - } else { - // For less sparse matrices, use standard epsilon - epsilon - }; - + random_seed: u64, + tuning: &Tuning, +) -> Result { + let adaptive_eps = tuning.eps; let (endl, endr) = (end_interval[0], end_interval[1]); - /* take the first step */ - let rnm_tol = stpone(A, wrk, store, random_seed)?; - let mut rnm = rnm_tol.0; - let mut tol = rnm_tol.1; + let (mut rnm, mut tol) = stpone(a, wrk, store, random_seed)?; - let eps1 = adaptive_eps * T::from_f64(wrk.ncols as f64).unwrap().sqrt(); + let eps1 = adaptive_eps * T::from_f64_val(wrk.ncols as f64).sqrt(); wrk.eta[0] = eps1; wrk.oldeta[0] = eps1; let mut ll = 0; @@ -1321,9 +1273,8 @@ fn lanso( rnm = T::zero(); } - // the actual lanczos loop let steps = lanczos_step( - A, + a, wrk, first, last, @@ -1333,21 +1284,17 @@ fn lanso( &mut tol, store, )?; - j = match enough { - true => steps - 1, - false => last - 1, - }; + j = if enough { steps - 1 } else { last - 1 }; first = j + 1; wrk.bet[first] = rnm; - // analyze T + // Analyze T one unreduced block at a time. let mut l = 0; for _ in 0..j { if l > j { break; } - let mut i = l; while i <= j { if Float::abs(wrk.bet[i + 1]) <= adaptive_eps { @@ -1357,18 +1304,18 @@ fn lanso( } i = i.min(j); - // now i is at the end of an unreduced submatrix let sz = i - l; - svd_dcopy(sz + 1, l, &wrk.alf, &mut wrk.ritz); - svd_dcopy(sz, l + 1, &wrk.bet, &mut wrk.w5); + dcopy_rev(sz + 1, l, &wrk.alf, &mut wrk.ritz); + dcopy_rev(sz, l + 1, &wrk.bet, &mut wrk.w5); imtqlb( sz + 1, &mut wrk.ritz[l..], &mut wrk.w5[l..], &mut wrk.bnd[l..], - max_iterations_imtqlb, - )?; + tuning.ql_iterations, + &wrk.ql_degraded, + ); for m in l..=i { wrk.bnd[m] = rnm * Float::abs(wrk.bnd[m]); @@ -1376,28 +1323,20 @@ fn lanso( l = i + 1; } - // sort eigenvalues into increasing order - insert_sort(j + 1, &mut wrk.ritz, &mut wrk.bnd); - + sort_pair(j + 1, &mut wrk.ritz, &mut wrk.bnd); *neig = error_bound(&mut enough, endl, endr, &mut wrk.ritz, &mut wrk.bnd, j, tol); - // should we stop? if *neig < dim { if *neig == 0 { last = first + 9; intro = first; } else { - let extra_steps = if sparsity > T::from_f64(0.99).unwrap() { - 5 // For very sparse matrices, add extra steps - } else { - 0 - }; - - last = first + 3.max(1 + ((j - intro) * (dim - *neig)) / *neig) + extra_steps; + last = + first + 3.max(1 + ((j - intro) * (dim - *neig)) / *neig) + tuning.extra_steps; } last = last.min(iterations); } else { - enough = true + enough = true; } enough = enough || first >= iterations; } @@ -1405,336 +1344,217 @@ fn lanso( Ok(j) } -impl SvdRec { - pub fn recompose(&self) -> Array2 { - let sdiag = Array2::from_diag(&self.s); - self.u.dot(&sdiag).dot(&self.vt) - } -} - -impl SMat for nalgebra_sparse::csc::CscMatrix { - fn nrows(&self) -> usize { - self.nrows() - } - fn ncols(&self) -> usize { - self.ncols() - } - fn nnz(&self) -> usize { - self.nnz() - } - - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[T], y: &mut [T], transposed: bool) { - let nrows = if transposed { - self.ncols() - } else { - self.nrows() - }; - let ncols = if transposed { - self.nrows() - } else { - self.ncols() - }; - assert_eq!( - x.len(), - ncols, - "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", - x.len(), - ncols - ); - assert_eq!( - y.len(), - nrows, - "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", - y.len(), - nrows - ); - - let (major_offsets, minor_indices, values) = self.csc_data(); - - for y_val in y.iter_mut() { - *y_val = T::zero(); - } - - if transposed { - for (i, yval) in y.iter_mut().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - *yval += values[j] * x[minor_indices[j]]; - } - } - } else { - for (i, xval) in x.iter().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - y[minor_indices[j]] += values[j] * *xval; - } - } +#[cfg(test)] +#[allow(deprecated)] +mod tests { + use super::*; + use crate::matrix::SvdMat; + use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values}; + use sprs::TriMatI; + + /// `diag(n, n-1, ..., 1)` — singular values are known exactly, so this is the + /// least forgiving accuracy probe available. + fn diagonal(n: usize) -> SvdMat { + let mut t = TriMatI::::new((n, n)); + for i in 0..n { + t.add_triplet(i, i, (n - i) as f64); } + t.to_csr::() } - fn compute_column_means(&self) -> Vec { - todo!() - } + // --------------------------------------------------------------------------- + // Structural properties. These hold today and guard the port. + // --------------------------------------------------------------------------- - fn multiply_with_dense( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - ) { - todo!() + #[test] + fn singular_values_descend() { + let a = gen_sparse(200, 120, 0.05, 3); + let svd = svd_dim_seed(&a, 20, 42).unwrap(); + for w in svd.s.to_vec().windows(2) { + assert!(w[0] >= w[1], "not descending: {:?}", svd.s); + } } - fn multiply_with_dense_centered( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - means: &DVector, - ) { - todo!() - } - - fn multiply_transposed_by_dense(&self, q: &DMatrix, result: &mut DMatrix) { - todo!() - } - - fn multiply_transposed_by_dense_centered(&self, q: &DMatrix, result: &mut DMatrix, means: &DVector) { - todo!() + /// `u` must be `m x d` and `vt` `d x n` for every input shape, including the + /// internally-transposed case. 1.x returned `u` as `d x m` from this path while + /// the randomized path returned `m x d`, so `recompose` only worked when square. + #[test] + fn orientation_is_consistent_for_wide_and_tall() { + for (r, c) in [(200usize, 60usize), (60, 200)] { + let a = gen_sparse(r, c, 0.1, 11); + let svd = svd_dim_seed(&a, 10, 42).unwrap(); + assert_eq!(svd.u.nrows(), r, "u rows for {r}x{c}"); + assert_eq!(svd.u.ncols(), svd.d, "u cols for {r}x{c}"); + assert_eq!(svd.vt.nrows(), svd.d, "vt rows for {r}x{c}"); + assert_eq!(svd.vt.ncols(), c, "vt cols for {r}x{c}"); + } } -} -impl SMat - for nalgebra_sparse::csr::CsrMatrix -{ - fn nrows(&self) -> usize { - self.nrows() - } - fn ncols(&self) -> usize { - self.ncols() - } - fn nnz(&self) -> usize { - self.nnz() + #[test] + fn csc_input_matches_csr() { + let a = gen_sparse(150, 90, 0.08, 5); + let csc = a.to_other_storage(); + let from_csr = svd_dim_seed(&a, 12, 42).unwrap(); + let from_csc = svd_dim_seed(&csc, 12, 42).unwrap(); + for (x, y) in from_csr.s.iter().zip(from_csc.s.iter()) { + approx::assert_relative_eq!(x, y, max_relative = 1e-10); + } } - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[T], y: &mut [T], transposed: bool) { - //TODO parallelize me please - let nrows = if transposed { - self.ncols() - } else { - self.nrows() - }; - let ncols = if transposed { - self.nrows() - } else { - self.ncols() - }; - assert_eq!( - x.len(), - ncols, - "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", - x.len(), - ncols + #[test] + fn rejects_degenerate_shapes() { + let a = gen_sparse(1, 10, 1.0, 1); + assert!(matches!( + svd_dim_seed(&a, 0, 42), + Err(SvdLibError::InvalidArgument(_)) + )); + } + + #[test] + fn diagnostics_count_matvecs() { + let a = gen_sparse(100, 60, 0.1, 13); + let svd = svd_dim_seed(&a, 8, 42).unwrap(); + assert!(svd.diagnostics.matvecs > 0); + assert_eq!(svd.diagnostics.algorithm, Algorithm::Las2); + } + + /// The `imtqlb` shift-origin fix, pinned directly. + /// + /// `imtqlb` (eigenvalues only) and `imtql2` (eigenvalues and vectors) run the same + /// implicit-QL recurrence on the same tridiagonal matrix, so their eigenvalues must + /// agree. Before the fix they diverged wildly — on `diag(40..1)` `imtqlb` returned + /// `39.90, 7.11, 0.019, ...` against `imtql2`'s correct `39.89, 38.96, 37.86, ...`. + #[test] + fn imtqlb_agrees_with_imtql2_on_the_same_tridiagonal() { + // A tridiagonal with well-separated eigenvalues. + let n = 24; + let d0: Vec = (0..n).map(|i| 2.0 + i as f64).collect(); + let e0: Vec = (0..n).map(|i| 0.5 + 0.1 * (i as f64)).collect(); + + let mut d_b = d0.clone(); + let mut e_b = e0.clone(); + let mut bnd = vec![0.0f64; n]; + let degraded = Cell::new(false); + imtqlb( + n, + &mut d_b, + &mut e_b, + &mut bnd, + MAX_QL_ITERATIONS, + °raded, ); - assert_eq!( - y.len(), - nrows, - "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", - y.len(), - nrows - ); - - let (major_offsets, minor_indices, values) = self.csr_data(); + assert!(!degraded.get(), "imtqlb reported degraded convergence"); - y.fill(T::zero()); - - if !transposed { - let nrows = self.nrows(); - let chunk_size = crate::utils::determine_chunk_size(nrows); - - // Create thread-local vectors with results - let results: Vec<(usize, T)> = (0..nrows) - .into_par_iter() - .map(|i| { - let mut sum = T::zero(); - for j in major_offsets[i]..major_offsets[i + 1] { - sum += values[j] * x[minor_indices[j]]; - } - (i, sum) - }) - .collect(); - - // Apply the results to y - for (i, val) in results { - y[i] = val; - } - } else { - let nrows = self.nrows(); - let chunk_size = crate::utils::determine_chunk_size(nrows); - - // Process input in chunks and create partial results - let results: Vec> = (0..((nrows + chunk_size - 1) / chunk_size)) - .into_par_iter() - .map(|chunk_idx| { - let start = chunk_idx * chunk_size; - let end = (start + chunk_size).min(nrows); - - let mut local_y = vec![T::zero(); y.len()]; - for i in start..end { - let row_val = x[i]; - for j in major_offsets[i]..major_offsets[i + 1] { - let col = minor_indices[j]; - local_y[col] += values[j] * row_val; - } - } - local_y - }) - .collect(); - - // Combine partial results - for local_y in results { - for (idx, val) in local_y.iter().enumerate() { - if !val.is_zero() { - y[idx] += *val; - } - } - } + let mut d_2 = d0.clone(); + let mut e_2 = e0.clone(); + let mut z = vec![0.0f64; n * n]; + for i in (0..n * n).step_by(n + 1) { + z[i] = 1.0; } - } + imtql2(n, n, &mut d_2, &mut e_2, &mut z, MAX_QL_ITERATIONS).unwrap(); - fn compute_column_means(&self) -> Vec { - let rows = self.nrows(); - let cols = self.ncols(); - let row_count_recip = T::one() / T::from(rows).unwrap(); - - let mut col_sums = vec![T::zero(); cols]; - let (row_offsets, col_indices, values) = self.csr_data(); - - // Directly accumulate column sums from sparse representation - for i in 0..rows { - for j in row_offsets[i]..row_offsets[i + 1] { - let col = col_indices[j]; - col_sums[col] += values[j]; - } + // Not bit-identical: `imtqlb` deflates on a size-scaled tolerance while + // `imtql2` uses the tighter `test + |e| == test`, so it stops marginally + // earlier. A few ulps of spread is expected; the pre-fix divergence was + // orders of magnitude. + for i in 0..n { + approx::assert_relative_eq!(d_b[i], d_2[i], max_relative = 1e-5); } + } - // Convert to means - for j in 0..cols { - col_sums[j] *= row_count_recip; + // --------------------------------------------------------------------------- + // Accuracy against a dense LAPACK reference. + // + // These are `#[ignore]`d because LAS2 does not currently pass them — the failure + // is inherited from published 1.0.9, not introduced by the sprs port (verified by + // running 1.0.9 on identical fixtures). Two defects are identified so far: + // + // 1. `imtqlb` hoisted the shift origin out of its iteration loop — FIXED. + // 2. `ritvec` reads `s[k*js + i]` (row `k`) while `imtql2` stores eigenvectors + // as columns; transposing roughly halves the error but does not close it, + // so at least one further defect remains. + // + // Un-ignore these once LAS2 is repaired, or delete them with the module if LAS2 + // is retired in favour of `crate::irlba`. + // --------------------------------------------------------------------------- + + fn assert_matches_lapack(name: &str, a: &SvdMat, dims: usize, tol: f64) { + let want = reference_singular_values(&dense_of(a)); + let svd = svd_dim_seed(a, dims, 42).unwrap_or_else(|e| panic!("{name}: {e}")); + for (i, &g) in svd.s.iter().enumerate() { + let rel = (g - want[i]).abs() / want[i].abs().max(1e-30); + assert!( + rel < tol, + "{name}: singular value {i}: got {g:.9e}, LAPACK {:.9e} (rel {rel:.3e})", + want[i] + ); } - - col_sums } - fn multiply_with_dense( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - ) { - todo!() + #[test] + #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"] + fn exact_on_diagonal_matrix() { + assert_matches_lapack("diagonal_40", &diagonal(40), 10, 1e-8); } - fn multiply_with_dense_centered( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - means: &DVector, - ) { - todo!() - } - - fn multiply_transposed_by_dense(&self, q: &DMatrix, result: &mut DMatrix) { - todo!() + #[test] + #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"] + fn agrees_with_dense_reference_lowrank() { + assert_matches_lapack("lowrank_80x50_r8", &gen_lowrank(80, 50, 8, 21), 8, 1e-6); } - - fn multiply_transposed_by_dense_centered(&self, q: &DMatrix, result: &mut DMatrix, means: &DVector) { - todo!() - } -} -impl SMat for nalgebra_sparse::coo::CooMatrix { - fn nrows(&self) -> usize { - self.nrows() - } - fn ncols(&self) -> usize { - self.ncols() - } - fn nnz(&self) -> usize { - self.nnz() + #[test] + #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"] + fn agrees_with_dense_reference_sparse() { + assert_matches_lapack("sparse_500x40", &gen_sparse(500, 40, 0.10, 7), 10, 1e-6); } - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[T], y: &mut [T], transposed: bool) { - let nrows = if transposed { - self.ncols() - } else { - self.nrows() - }; - let ncols = if transposed { - self.nrows() - } else { - self.ncols() - }; - assert_eq!( - x.len(), - ncols, - "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", - x.len(), - ncols - ); - assert_eq!( - y.len(), - nrows, - "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", - y.len(), - nrows + #[test] + #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"] + fn recompose_round_trips() { + let a = gen_lowrank(40, 25, 25, 99); + let dense = dense_of(&a); + let svd = svd_dim_seed(&a, 25, 42).unwrap(); + let rec = svd.recompose(); + let err: f64 = (&rec - &dense).iter().map(|v| v * v).sum::().sqrt(); + let scale: f64 = dense.iter().map(|v| v * v).sum::().sqrt(); + assert!( + err / scale < 1e-8, + "relative reconstruction error {}", + err / scale ); + } - for y_val in y.iter_mut() { - *y_val = T::zero(); - } - - if transposed { - for (i, j, v) in self.triplet_iter() { - y[j] += *v * x[i]; - } - } else { - for (i, j, v) in self.triplet_iter() { - y[i] += *v * x[j]; + /// Scope report: prints LAS2's error against LAPACK across matrix classes. + /// Not an assertion — a diagnostic for whoever picks up the repair. + #[test] + #[ignore = "diagnostic, run explicitly"] + fn report_accuracy_vs_lapack() { + let cases: Vec<(&str, SvdMat, usize)> = vec![ + ("diagonal_40", diagonal(40), 10), + ("diagonal_40_full", diagonal(40), 40), + ("lowrank_80x50_r8", gen_lowrank(80, 50, 8, 21), 8), + ("lowrank_200x80_r10", gen_lowrank(200, 80, 10, 555), 15), + ("sparse_500x40_d10", gen_sparse(500, 40, 0.10, 7), 10), + ("sparse_200x120_d05", gen_sparse(200, 120, 0.05, 3), 20), + ]; + for (name, a, dims) in cases { + let want = reference_singular_values(&dense_of(&a)); + match svd_dim_seed(&a, dims, 42) { + Ok(svd) => { + let got = svd.s.to_vec(); + let n = got.len().min(want.len()); + let worst = (0..n) + .map(|i| (got[i] - want[i]).abs() / want[i].abs().max(1e-30)) + .fold(0.0f64, f64::max); + println!( + "{name:<24} dims={dims:<3} d={:<3} top_rel={:>9.2e} worst_rel={worst:>9.2e}", + svd.d, + (got[0] - want[0]).abs() / want[0].abs() + ); + } + Err(e) => println!("{name:<24} dims={dims:<3} ERROR {e}"), } } } - - fn compute_column_means(&self) -> Vec { - todo!() - } - - fn multiply_with_dense( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - ) { - todo!() - } - - fn multiply_with_dense_centered( - &self, - dense: &DMatrix, - result: &mut DMatrix, - transpose_self: bool, - means: &DVector, - ) { - todo!() - } - - fn multiply_transposed_by_dense(&self, q: &DMatrix, result: &mut DMatrix) { - todo!() - } - - fn multiply_transposed_by_dense_centered(&self, q: &DMatrix, result: &mut DMatrix, means: &DVector) { - todo!() - } } diff --git a/src/legacy.rs b/src/legacy.rs deleted file mode 100644 index a636dcd..0000000 --- a/src/legacy.rs +++ /dev/null @@ -1,2236 +0,0 @@ -//! # svdlibrs -//! -//! A Rust port of LAS2 from SVDLIBC -//! -//! A library that computes an svd on a sparse matrix, typically a large sparse matrix -//! -//! This is a functional port (mostly a translation) of the algorithm as implemented in Doug Rohde's SVDLIBC -//! -//! This library performs [singular value decomposition](https://en.wikipedia.org/wiki/Singular_value_decomposition) on a sparse input [Matrix](https://docs.rs/nalgebra-sparse/latest/nalgebra_sparse/) using the [Lanczos algorithm](https://en.wikipedia.org/wiki/Lanczos_algorithm) and returns the decomposition as [ndarray](https://docs.rs/ndarray/latest/ndarray/) components. -//! -//! # Usage -//! -//! Input: [Sparse Matrix (CSR, CSC, or COO)](https://docs.rs/nalgebra-sparse/latest/nalgebra_sparse/) -//! -//! Output: decomposition `U`,`S`,`V` where `U`,`V` are [`Array2`](https://docs.rs/ndarray/latest/ndarray/type.Array2.html) and `S` is [`Array1`](https://docs.rs/ndarray/latest/ndarray/type.Array1.html), packaged in a [Result](https://doc.rust-lang.org/stable/core/result/enum.Result.html)\<`SvdRec`, `SvdLibError`\> -//! -//! # Quick Start -//! -//! ## There are 3 convenience methods to handle common use cases -//! 1. `svd` -- simply computes an SVD -//! -//! 2. `svd_dim` -- computes an SVD supplying a desired numer of `dimensions` -//! -//! 3. `svd_dim_seed` -- computes an SVD supplying a desired numer of `dimensions` and a fixed `seed` to the LAS2 algorithm (the algorithm initializes with a random vector and will generate an internal seed if one isn't supplied) -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! use svdlibrs::svd; -//! # let mut coo = nalgebra_sparse::coo::CooMatrix::::new(2, 2); -//! # coo.push(0, 0, 1.0); -//! # coo.push(1, 0, 3.0); -//! # coo.push(1, 1, -5.0); -//! -//! # let csr = nalgebra_sparse::csr::CsrMatrix::from(&coo); -//! // SVD on a Compressed Sparse Row matrix -//! let svd = svd(&csr)?; -//! # Ok::<(), svdlibrs::error::SvdLibError>(()) -//! ``` -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! use svdlibrs::svd_dim; -//! # let mut coo = nalgebra_sparse::coo::CooMatrix::::new(3, 3); -//! # coo.push(0, 0, 1.0); coo.push(0, 1, 16.0); coo.push(0, 2, 49.0); -//! # coo.push(1, 0, 4.0); coo.push(1, 1, 25.0); coo.push(1, 2, 64.0); -//! # coo.push(2, 0, 9.0); coo.push(2, 1, 36.0); coo.push(2, 2, 81.0); -//! -//! # let csc = nalgebra_sparse::csc::CscMatrix::from(&coo); -//! // SVD on a Compressed Sparse Column matrix specifying the desired dimensions, 3 in this example -//! let svd = svd_dim(&csc, 3)?; -//! # Ok::<(), svdlibrs::error::SvdLibError>(()) -//! ``` -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! use svdlibrs::svd_dim_seed; -//! # let mut coo = nalgebra_sparse::coo::CooMatrix::::new(3, 3); -//! # coo.push(0, 0, 1.0); coo.push(0, 1, 16.0); coo.push(0, 2, 49.0); -//! # coo.push(1, 0, 4.0); coo.push(1, 1, 25.0); coo.push(1, 2, 64.0); -//! # coo.push(2, 0, 9.0); coo.push(2, 1, 36.0); coo.push(2, 2, 81.0); -//! # let dimensions = 3; -//! -//! // SVD on a Coordinate-form matrix requesting the -//! // dimensions and supplying a fixed seed to the LAS2 algorithm -//! let svd = svd_dim_seed(&coo, dimensions, 12345)?; -//! # Ok::<(), svdlibrs::error::SvdLibError>(()) -//! ``` -//! -//! # The SVD Decomposition and informational Diagnostics are returned in `SvdRec` -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! pub struct SvdRec { -//! pub d: usize, // Dimensionality (rank), the number of rows of both ut, vt and the length of s -//! pub ut: Array2, // Transpose of left singular vectors, the vectors are the rows of ut -//! pub s: Array1, // Singular values (length d) -//! pub vt: Array2, // Transpose of right singular vectors, the vectors are the rows of vt -//! pub diagnostics: Diagnostics, // Computational diagnostics -//! } -//! -//! pub struct Diagnostics { -//! pub non_zero: usize, // Number of non-zeros in the input matrix -//! pub dimensions: usize, // Number of dimensions attempted (bounded by matrix shape) -//! pub iterations: usize, // Number of iterations attempted (bounded by dimensions and matrix shape) -//! pub transposed: bool, // True if the matrix was transposed internally -//! pub lanczos_steps: usize, // Number of Lanczos steps performed -//! pub ritz_values_stabilized: usize, // Number of ritz values -//! pub significant_values: usize, // Number of significant values discovered -//! pub singular_values: usize, // Number of singular values returned -//! pub end_interval: [f64; 2], // Left, Right end of interval containing unwanted eigenvalues -//! pub kappa: f64, // Relative accuracy of ritz values acceptable as eigenvalues -//! pub random_seed: u32, // Random seed provided or the seed generated -//! } -//! ``` -//! -//! # The method `svdLAS2` provides the following parameter control -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! use svdlibrs::{svd, svd_dim, svd_dim_seed, svdLAS2, SvdRec}; -//! # let mut matrix = nalgebra_sparse::coo::CooMatrix::::new(3, 3); -//! # matrix.push(0, 0, 1.0); matrix.push(0, 1, 16.0); matrix.push(0, 2, 49.0); -//! # matrix.push(1, 0, 4.0); matrix.push(1, 1, 25.0); matrix.push(1, 2, 64.0); -//! # matrix.push(2, 0, 9.0); matrix.push(2, 1, 36.0); matrix.push(2, 2, 81.0); -//! # let dimensions = 3; -//! # let iterations = 0; -//! # let end_interval = &[-1.0e-30, 1.0e-30]; -//! # let kappa = 1.0e-6; -//! # let random_seed = 0; -//! -//! let svd: SvdRec = svdLAS2( -//! &matrix, // sparse matrix (nalgebra_sparse::{csr,csc,coo} -//! dimensions, // upper limit of desired number of dimensions -//! // supplying 0 will use the input matrix shape to determine dimensions -//! iterations, // number of algorithm iterations -//! // supplying 0 will use the input matrix shape to determine iterations -//! end_interval, // left, right end of interval containing unwanted eigenvalues, -//! // typically small values centered around zero -//! // set to [-1.0e-30, 1.0e-30] for convenience methods svd(), svd_dim(), svd_dim_seed() -//! kappa, // relative accuracy of ritz values acceptable as eigenvalues -//! // set to 1.0e-6 for convenience methods svd(), svd_dim(), svd_dim_seed() -//! random_seed, // a supplied seed if > 0, otherwise an internal seed will be generated -//! )?; -//! # Ok::<(), svdlibrs::error::SvdLibError>(()) -//! ``` -//! -//! # SVD Examples -//! -//! ### SVD using [R](https://www.r-project.org/) -//! -//! ```text -//! $ Rscript -e 'options(digits=12);m<-matrix(1:9,nrow=3)^2;print(m);r<-svd(m);print(r);r$u%*%diag(r$d)%*%t(r$v)' -//! -//! • The input matrix: M -//! [,1] [,2] [,3] -//! [1,] 1 16 49 -//! [2,] 4 25 64 -//! [3,] 9 36 81 -//! -//! • The diagonal matrix (singular values): S -//! $d -//! [1] 123.676578742544 6.084527896514 0.287038004183 -//! -//! • The left singular vectors: U -//! $u -//! [,1] [,2] [,3] -//! [1,] -0.415206840886 -0.753443585619 -0.509829424976 -//! [2,] -0.556377565194 -0.233080213641 0.797569820742 -//! [3,] -0.719755016815 0.614814099788 -0.322422608499 -//! -//! • The right singular vectors: V -//! $v -//! [,1] [,2] [,3] -//! [1,] -0.0737286909592 0.632351847728 -0.771164846712 -//! [2,] -0.3756889918995 0.698691000150 0.608842071210 -//! [3,] -0.9238083467338 -0.334607272761 -0.186054055373 -//! -//! • Recreating the original input matrix: r$u %*% diag(r$d) %*% t(r$v) -//! [,1] [,2] [,3] -//! [1,] 1 16 49 -//! [2,] 4 25 64 -//! [3,] 9 36 81 -//! ``` -//! -//! ### SVD using svdlibrs -//! -//! ```rust -//! # extern crate ndarray; -//! # use ndarray::prelude::*; -//! use nalgebra_sparse::{coo::CooMatrix, csc::CscMatrix}; -//! use svdlibrs::svd_dim_seed; -//! -//! // create a CscMatrix from a CooMatrix -//! // use the same matrix values as the R example above -//! // [,1] [,2] [,3] -//! // [1,] 1 16 49 -//! // [2,] 4 25 64 -//! // [3,] 9 36 81 -//! let mut coo = CooMatrix::::new(3, 3); -//! coo.push(0, 0, 1.0); coo.push(0, 1, 16.0); coo.push(0, 2, 49.0); -//! coo.push(1, 0, 4.0); coo.push(1, 1, 25.0); coo.push(1, 2, 64.0); -//! coo.push(2, 0, 9.0); coo.push(2, 1, 36.0); coo.push(2, 2, 81.0); -//! -//! // our input -//! let csc = CscMatrix::from(&coo); -//! -//! // compute the svd -//! // 1. supply 0 as the dimension (requesting max) -//! // 2. supply a fixed seed so outputs are repeatable between runs -//! let svd = svd_dim_seed(&csc, 0, 3141).unwrap(); -//! -//! // svd.d dimensions were found by the algorithm -//! // svd.ut is a 2-d array holding the left vectors -//! // svd.vt is a 2-d array holding the right vectors -//! // svd.s is a 1-d array holding the singular values -//! // assert the shape of all results in terms of svd.d -//! assert_eq!(svd.d, 3); -//! assert_eq!(svd.d, svd.ut.nrows()); -//! assert_eq!(svd.d, svd.s.dim()); -//! assert_eq!(svd.d, svd.vt.nrows()); -//! -//! // show transposed output -//! println!("svd.d = {}\n", svd.d); -//! println!("U =\n{:#?}\n", svd.ut.t()); -//! println!("S =\n{:#?}\n", svd.s); -//! println!("V =\n{:#?}\n", svd.vt.t()); -//! -//! // Note: svd.ut & svd.vt are returned in transposed form -//! // M = USV* -//! let m_approx = svd.ut.t().dot(&Array2::from_diag(&svd.s)).dot(&svd.vt); -//! assert_eq!(svd.recompose(), m_approx); -//! -//! // assert computed values are an acceptable approximation -//! let epsilon = 1.0e-12; -//! assert!((m_approx[[0, 0]] - 1.0).abs() < epsilon); -//! assert!((m_approx[[0, 1]] - 16.0).abs() < epsilon); -//! assert!((m_approx[[0, 2]] - 49.0).abs() < epsilon); -//! assert!((m_approx[[1, 0]] - 4.0).abs() < epsilon); -//! assert!((m_approx[[1, 1]] - 25.0).abs() < epsilon); -//! assert!((m_approx[[1, 2]] - 64.0).abs() < epsilon); -//! assert!((m_approx[[2, 0]] - 9.0).abs() < epsilon); -//! assert!((m_approx[[2, 1]] - 36.0).abs() < epsilon); -//! assert!((m_approx[[2, 2]] - 81.0).abs() < epsilon); -//! -//! assert!((svd.s[0] - 123.676578742544).abs() < epsilon); -//! assert!((svd.s[1] - 6.084527896514).abs() < epsilon); -//! assert!((svd.s[2] - 0.287038004183).abs() < epsilon); -//! ``` -//! -//! # Output -//! -//! ```text -//! svd.d = 3 -//! -//! U = -//! [[-0.4152068408862081, -0.7534435856189199, -0.5098294249756481], -//! [-0.556377565193878, -0.23308021364108839, 0.7975698207417085], -//! [-0.719755016814907, 0.6148140997884891, -0.3224226084985998]], shape=[3, 3], strides=[1, 3], layout=Ff (0xa), const ndim=2 -//! -//! S = -//! [123.67657874254405, 6.084527896513759, 0.2870380041828973], shape=[3], strides=[1], layout=CFcf (0xf), const ndim=1 -//! -//! V = -//! [[-0.07372869095916511, 0.6323518477280158, -0.7711648467120451], -//! [-0.3756889918994792, 0.6986910001499903, 0.6088420712097343], -//! [-0.9238083467337805, -0.33460727276072516, -0.18605405537270261]], shape=[3, 3], strides=[1, 3], layout=Ff (0xa), const ndim=2 -//! ``` -//! -//! # The full Result\ for above example looks like this: -//! ```text -//! svd = Ok( -//! SvdRec { -//! d: 3, -//! ut: [[-0.4152068408862081, -0.556377565193878, -0.719755016814907], -//! [-0.7534435856189199, -0.23308021364108839, 0.6148140997884891], -//! [-0.5098294249756481, 0.7975698207417085, -0.3224226084985998]], shape=[3, 3], strides=[3, 1], layout=Cc (0x5), const ndim=2, -//! s: [123.67657874254405, 6.084527896513759, 0.2870380041828973], shape=[3], strides=[1], layout=CFcf (0xf), const ndim=1, -//! vt: [[-0.07372869095916511, -0.3756889918994792, -0.9238083467337805], -//! [0.6323518477280158, 0.6986910001499903, -0.33460727276072516], -//! [-0.7711648467120451, 0.6088420712097343, -0.18605405537270261]], shape=[3, 3], strides=[3, 1], layout=Cc (0x5), const ndim=2, -//! diagnostics: Diagnostics { -//! non_zero: 9, -//! dimensions: 3, -//! iterations: 3, -//! transposed: false, -//! lanczos_steps: 3, -//! ritz_values_stabilized: 3, -//! significant_values: 3, -//! singular_values: 3, -//! end_interval: [ -//! -1e-30, -//! 1e-30, -//! ], -//! kappa: 1e-6, -//! random_seed: 3141, -//! }, -//! }, -//! ) -//! ``` - -// ================================================================================== -// This is a functional port (mostly a translation) of "svdLAS2()" from Doug Rohde's SVDLIBC -// It uses the same conceptual "workspace" storage as the C implementation. -// Most of the original function & variable names have been preserved. -// All C-style comments /* ... */ are from the original source, provided for context. -// -// dwf -- Wed May 5 16:48:01 MDT 2021 -// ================================================================================== - -/* -SVDLIBC License - -The following BSD License applies to all SVDLIBC source code and documentation: - -Copyright © 2002, University of Tennessee Research Foundation. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - - Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - Neither the name of the University of Tennessee nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -*/ - -/*********************************************************************** - * * - * main() * - * Sparse SVD(A) via Eigensystem of A'A symmetric Matrix * - * (double precision) * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - This sample program uses landr to compute singular triplets of A via - the equivalent symmetric eigenvalue problem - - B x = lambda x, where x' = (u',v'), lambda = sigma**2, - where sigma is a singular value of A, - - B = A'A , and A is m (nrow) by n (ncol) (nrow >> ncol), - - so that {u,sqrt(lambda),v} is a singular triplet of A. - (A' = transpose of A) - - User supplied routines: svd_opa, opb, store, timer - - svd_opa( x,y) takes an n-vector x and returns A*x in y. - svd_opb(ncol,x,y) takes an n-vector x and returns B*x in y. - - Based on operation flag isw, store(n,isw,j,s) stores/retrieves - to/from storage a vector of length n in s. - - User should edit timer() with an appropriate call to an intrinsic - timing routine that returns elapsed user time. - - - Local parameters - ---------------- - - (input) - endl left end of interval containing unwanted eigenvalues of B - endr right end of interval containing unwanted eigenvalues of B - kappa relative accuracy of ritz values acceptable as eigenvalues - of B - vectors is not equal to 1 - r work array - n dimension of the eigenproblem for matrix B (ncol) - dimensions upper limit of desired number of singular triplets of A - iterations upper limit of desired number of Lanczos steps - nnzero number of nonzeros in A - vectors 1 indicates both singular values and singular vectors are - wanted and they can be found in output file lav2; - 0 indicates only singular values are wanted - - (output) - ritz array of ritz values - bnd array of error bounds - d array of singular values - memory total memory allocated in bytes to solve the B-eigenproblem - - - Functions used - -------------- - - BLAS svd_daxpy, svd_dscal, svd_ddot - USER svd_opa, svd_opb, timer - MISC write_header, check_parameters - LAS2 landr - - - Precision - --------- - - All floating-point calculations are done in double precision; - variables are declared as long and double. - - - LAS2 development - ---------------- - - LAS2 is a C translation of the Fortran-77 LAS2 from the SVDPACK - library written by Michael W. Berry, University of Tennessee, - Dept. of Computer Science, 107 Ayres Hall, Knoxville, TN, 37996-1301 - - 31 Jan 1992: Date written - - Theresa H. Do - University of Tennessee - Dept. of Computer Science - 107 Ayres Hall - Knoxville, TN, 37996-1301 - internet: tdo@cs.utk.edu - -***********************************************************************/ - -use rand::{rngs::StdRng, thread_rng, Rng, SeedableRng}; -use std::mem; -extern crate ndarray; -use ndarray::prelude::*; -mod error; -use error::SvdLibError; - -// ==================== -// Public -// ==================== - -/// Sparse matrix -pub trait SMat { - fn nrows(&self) -> usize; - fn ncols(&self) -> usize; - fn nnz(&self) -> usize; - fn svd_opa(&self, x: &[f64], y: &mut [f64], transposed: bool); // y = A*x -} - -/// Singular Value Decomposition Components -/// -/// # Fields -/// - d: Dimensionality (rank), the number of rows of both `ut`, `vt` and the length of `s` -/// - ut: Transpose of left singular vectors, the vectors are the rows of `ut` -/// - s: Singular values (length `d`) -/// - vt: Transpose of right singular vectors, the vectors are the rows of `vt` -/// - diagnostics: Computational diagnostics -#[derive(Debug, Clone, PartialEq)] -pub struct SvdRec { - pub d: usize, - pub ut: Array2, - pub s: Array1, - pub vt: Array2, - pub diagnostics: Diagnostics, -} - -/// Computational Diagnostics -/// -/// # Fields -/// - non_zero: Number of non-zeros in the matrix -/// - dimensions: Number of dimensions attempted (bounded by matrix shape) -/// - iterations: Number of iterations attempted (bounded by dimensions and matrix shape) -/// - transposed: True if the matrix was transposed internally -/// - lanczos_steps: Number of Lanczos steps performed -/// - ritz_values_stabilized: Number of ritz values -/// - significant_values: Number of significant values discovered -/// - singular_values: Number of singular values returned -/// - end_interval: left, right end of interval containing unwanted eigenvalues -/// - kappa: relative accuracy of ritz values acceptable as eigenvalues -/// - random_seed: Random seed provided or the seed generated -#[derive(Debug, Clone, PartialEq)] -pub struct Diagnostics { - pub non_zero: usize, - pub dimensions: usize, - pub iterations: usize, - pub transposed: bool, - pub lanczos_steps: usize, - pub ritz_values_stabilized: usize, - pub significant_values: usize, - pub singular_values: usize, - pub end_interval: [f64; 2], - pub kappa: f64, - pub random_seed: u32, -} - -#[allow(non_snake_case)] -/// SVD at full dimensionality, calls `svdLAS2` with the highlighted defaults -/// -/// svdLAS2(A, `0`, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, `0`) -/// -/// # Parameters -/// - A: Sparse matrix -pub fn svd(A: &dyn SMat) -> Result { - svdLAS2(A, 0, 0, &[-1.0e-30, 1.0e-30], 1.0e-6, 0) -} - -#[allow(non_snake_case)] -/// SVD at desired dimensionality, calls `svdLAS2` with the highlighted defaults -/// -/// svdLAS2(A, dimensions, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, `0`) -/// -/// # Parameters -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions, bounded by the matrix shape -pub fn svd_dim(A: &dyn SMat, dimensions: usize) -> Result { - svdLAS2(A, dimensions, 0, &[-1.0e-30, 1.0e-30], 1.0e-6, 0) -} - -#[allow(non_snake_case)] -/// SVD at desired dimensionality with supplied seed, calls `svdLAS2` with the highlighted defaults -/// -/// svdLAS2(A, dimensions, `0`, `&[-1.0e-30, 1.0e-30]`, `1.0e-6`, random_seed) -/// -/// # Parameters -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions, bounded by the matrix shape -/// - random_seed: A supplied seed `if > 0`, otherwise an internal seed will be generated -pub fn svd_dim_seed(A: &dyn SMat, dimensions: usize, random_seed: u32) -> Result { - svdLAS2(A, dimensions, 0, &[-1.0e-30, 1.0e-30], 1.0e-6, random_seed) -} - -#[allow(clippy::redundant_field_names)] -#[allow(non_snake_case)] -/// Compute a singular value decomposition -/// -/// # Parameters -/// -/// - A: Sparse matrix -/// - dimensions: Upper limit of desired number of dimensions (0 = max), -/// where "max" is a value bounded by the matrix shape, the smaller of -/// the matrix rows or columns. e.g. `A.nrows().min(A.ncols())` -/// - iterations: Upper limit of desired number of lanczos steps (0 = max), -/// where "max" is a value bounded by the matrix shape, the smaller of -/// the matrix rows or columns. e.g. `A.nrows().min(A.ncols())` -/// iterations must also be in range [`dimensions`, `A.nrows().min(A.ncols())`] -/// - end_interval: Left, right end of interval containing unwanted eigenvalues, -/// typically small values centered around zero, e.g. `[-1.0e-30, 1.0e-30]` -/// - kappa: Relative accuracy of ritz values acceptable as eigenvalues, e.g. `1.0e-6` -/// - random_seed: A supplied seed `if > 0`, otherwise an internal seed will be generated -/// -/// # More on `dimensions`, `iterations` and `bounding` by the input matrix shape: -/// -/// let `min_nrows_ncols` = `A.nrows().min(A.ncols())`; // The smaller of `rows`, `columns` -/// -/// `dimensions` will be adjusted to `min_nrows_ncols` if `dimensions == 0` or `dimensions > min_nrows_ncols` -/// -/// The algorithm begins with the following assertion on `dimensions`: -/// -/// #### assert!(dimensions > 1 && dimensions <= min_nrows_ncols); -/// -/// --- -/// -/// `iterations` will be adjusted to `min_nrows_ncols` if `iterations == 0` or `iterations > min_nrows_ncols` -/// -/// `iterations` will be adjusted to `dimensions` if `iterations < dimensions` -/// -/// The algorithm begins with the following assertion on `iterations`: -/// -/// #### assert!(iterations >= dimensions && iterations <= min_nrows_ncols); -/// -/// # Returns -/// -/// Ok(`SvdRec`) on successful decomposition -pub fn svdLAS2( - A: &dyn SMat, - dimensions: usize, - iterations: usize, - end_interval: &[f64; 2], - kappa: f64, - random_seed: u32, -) -> Result { - let random_seed = match random_seed > 0 { - true => random_seed, - false => thread_rng().gen::<_>(), - }; - - let min_nrows_ncols = A.nrows().min(A.ncols()); - - let dimensions = match dimensions { - n if n == 0 || n > min_nrows_ncols => min_nrows_ncols, - _ => dimensions, - }; - - let iterations = match iterations { - n if n == 0 || n > min_nrows_ncols => min_nrows_ncols, - n if n < dimensions => dimensions, - _ => iterations, - }; - - if dimensions < 2 { - return Err(SvdLibError::Las2Error(format!( - "svdLAS2: insufficient dimensions: {dimensions}" - ))); - } - - assert!(dimensions > 1 && dimensions <= min_nrows_ncols); - assert!(iterations >= dimensions && iterations <= min_nrows_ncols); - - // If the matrix is wide, the SVD is computed on its transpose for speed - let transposed = A.ncols() as f64 >= (A.nrows() as f64 * 1.2); - let nrows = if transposed { A.ncols() } else { A.nrows() }; - let ncols = if transposed { A.nrows() } else { A.ncols() }; - - let mut wrk = WorkSpace::new(nrows, ncols, transposed, iterations)?; - let mut store = Store::new(ncols)?; - - // Actually run the lanczos thing - let mut neig = 0; - let steps = lanso( - A, - dimensions, - iterations, - end_interval, - &mut wrk, - &mut neig, - &mut store, - random_seed, - )?; - - // Compute the singular vectors of matrix A - let kappa = kappa.abs().max(eps34()); - let mut R = ritvec(A, dimensions, kappa, &mut wrk, steps, neig, &mut store)?; - - // This swaps and transposes the singular matrices if A was transposed. - if transposed { - mem::swap(&mut R.Ut, &mut R.Vt); - } - - Ok(SvdRec { - // Dimensionality (number of Ut,Vt rows & length of S) - d: R.d, - ut: Array::from_shape_vec((R.d, R.Ut.cols), R.Ut.value)?, - s: Array::from_shape_vec(R.d, R.S)?, - vt: Array::from_shape_vec((R.d, R.Vt.cols), R.Vt.value)?, - diagnostics: Diagnostics { - non_zero: A.nnz(), - dimensions: dimensions, - iterations: iterations, - transposed: transposed, - lanczos_steps: steps + 1, - ritz_values_stabilized: neig, - significant_values: R.d, - singular_values: R.nsig, - end_interval: *end_interval, - kappa: kappa, - random_seed: random_seed, - }, - }) -} - -//================================================================ -// Everything below is the private implementation -//================================================================ - -// ==================== -// Private -// ==================== - -const MAXLL: usize = 2; - -fn eps34() -> f64 { - f64::EPSILON.powf(0.75) // f64::EPSILON.sqrt() * f64::EPSILON.sqrt().sqrt(); -} - -/*********************************************************************** - * * - * store() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - store() is a user-supplied function which, based on the input - operation flag, stores to or retrieves from memory a vector. - - - Arguments - --------- - - (input) - n length of vector to be stored or retrieved - isw operation flag: - isw = 1 request to store j-th Lanczos vector q(j) - isw = 2 request to retrieve j-th Lanczos vector q(j) - isw = 3 request to store q(j) for j = 0 or 1 - isw = 4 request to retrieve q(j) for j = 0 or 1 - s contains the vector to be stored for a "store" request - - (output) - s contains the vector retrieved for a "retrieve" request - - Functions used - -------------- - - BLAS svd_dcopy - -***********************************************************************/ -#[derive(Debug, Clone, PartialEq)] -struct Store { - n: usize, - vecs: Vec>, -} -impl Store { - fn new(n: usize) -> Result { - Ok(Self { n, vecs: vec![] }) - } - fn storq(&mut self, idx: usize, v: &[f64]) { - while idx + MAXLL >= self.vecs.len() { - self.vecs.push(vec![0.0; self.n]); - } - //self.vecs[idx + MAXLL] = v.to_vec(); - //self.vecs[idx + MAXLL][..self.n].clone_from_slice(&v[..self.n]); - self.vecs[idx + MAXLL].copy_from_slice(v); - } - fn storp(&mut self, idx: usize, v: &[f64]) { - while idx >= self.vecs.len() { - self.vecs.push(vec![0.0; self.n]); - } - //self.vecs[idx] = v.to_vec(); - //self.vecs[idx][..self.n].clone_from_slice(&v[..self.n]); - self.vecs[idx].copy_from_slice(v); - } - fn retrq(&mut self, idx: usize) -> &[f64] { - &self.vecs[idx + MAXLL] - } - fn retrp(&mut self, idx: usize) -> &[f64] { - &self.vecs[idx] - } -} - -#[derive(Debug, Clone, PartialEq)] -struct WorkSpace { - nrows: usize, - ncols: usize, - transposed: bool, - w0: Vec, // workspace 0 - w1: Vec, // workspace 1 - w2: Vec, // workspace 2 - w3: Vec, // workspace 3 - w4: Vec, // workspace 4 - w5: Vec, // workspace 5 - alf: Vec, // array to hold diagonal of the tridiagonal matrix T - eta: Vec, // orthogonality estimate of Lanczos vectors at step j - oldeta: Vec, // orthogonality estimate of Lanczos vectors at step j-1 - bet: Vec, // array to hold off-diagonal of T - bnd: Vec, // array to hold the error bounds - ritz: Vec, // array to hold the ritz values - temp: Vec, // array to hold the temp values -} -impl WorkSpace { - fn new(nrows: usize, ncols: usize, transposed: bool, iterations: usize) -> Result { - Ok(Self { - nrows, - ncols, - transposed, - w0: vec![0.0; ncols], - w1: vec![0.0; ncols], - w2: vec![0.0; ncols], - w3: vec![0.0; ncols], - w4: vec![0.0; ncols], - w5: vec![0.0; ncols], - alf: vec![0.0; iterations], - eta: vec![0.0; iterations], - oldeta: vec![0.0; iterations], - bet: vec![0.0; 1 + iterations], - ritz: vec![0.0; 1 + iterations], - bnd: vec![f64::MAX; 1 + iterations], - temp: vec![0.0; nrows], - }) - } -} - -/* Row-major dense matrix. Rows are consecutive vectors. */ -#[derive(Debug, Clone, PartialEq)] -struct DMat { - //long rows; - //long cols; - //double **value; /* Accessed by [row][col]. Free value[0] and value to free.*/ - cols: usize, - value: Vec, -} - -#[allow(non_snake_case)] -#[derive(Debug, Clone, PartialEq)] -struct SVDRawRec { - //int d; /* Dimensionality (rank) */ - //DMat Ut; /* Transpose of left singular vectors. (d by m) - // The vectors are the rows of Ut. */ - //double *S; /* Array of singular values. (length d) */ - //DMat Vt; /* Transpose of right singular vectors. (d by n) - // The vectors are the rows of Vt. */ - d: usize, - nsig: usize, - Ut: DMat, - S: Vec, - Vt: DMat, -} - -// ================================================================= - -// compare two floats within epsilon -fn compare(computed: f64, expected: f64) -> bool { - (expected - computed).abs() < f64::EPSILON -} - -/* Function sorts array1 and array2 into increasing order for array1 */ -fn insert_sort(n: usize, array1: &mut [T], array2: &mut [T]) { - for i in 1..n { - for j in (1..i + 1).rev() { - if array1[j - 1] <= array1[j] { - break; - } - array1.swap(j - 1, j); - array2.swap(j - 1, j); - } - } -} - -#[allow(non_snake_case)] -#[rustfmt::skip] -fn svd_opb(A: &dyn SMat, x: &[f64], y: &mut [f64], temp: &mut [f64], transposed: bool) { - let nrows = if transposed { A.ncols() } else { A.nrows() }; - let ncols = if transposed { A.nrows() } else { A.ncols() }; - assert_eq!(x.len(), ncols, "svd_opb: x must be A.ncols() in length, x = {}, A.ncols = {}", x.len(), ncols); - assert_eq!(y.len(), ncols, "svd_opb: y must be A.ncols() in length, y = {}, A.ncols = {}", y.len(), ncols); - assert_eq!(temp.len(), nrows, "svd_opa: temp must be A.nrows() in length, temp = {}, A.nrows = {}", temp.len(), nrows); - A.svd_opa(x, temp, transposed); // temp = (A * x) - A.svd_opa(temp, y, !transposed); // y = A' * (A * x) = A' * temp -} - -// constant times a vector plus a vector -fn svd_daxpy(da: f64, x: &[f64], y: &mut [f64]) { - for (xval, yval) in x.iter().zip(y.iter_mut()) { - *yval += da * xval - } -} - -// finds the index of element having max absolute value -fn svd_idamax(n: usize, x: &[f64]) -> usize { - assert!(n > 0, "svd_idamax: unexpected inputs!"); - - match n { - 1 => 0, - _ => { - let mut imax = 0; - for (i, xval) in x.iter().enumerate().take(n).skip(1) { - if xval.abs() > x[imax].abs() { - imax = i; - } - } - imax - } - } -} - -// returns |a| if b is positive; else fsign returns -|a| -fn svd_fsign(a: f64, b: f64) -> f64 { - match a >= 0.0 && b >= 0.0 || a < 0.0 && b < 0.0 { - true => a, - false => -a, - } -} - -// finds sqrt(a^2 + b^2) without overflow or destructive underflow -fn svd_pythag(a: f64, b: f64) -> f64 { - match a.abs().max(b.abs()) { - n if n > 0.0 => { - let mut p = n; - let mut r = (a.abs().min(b.abs()) / p).powi(2); - let mut t = 4.0 + r; - while !compare(t, 4.0) { - let s = r / t; - let u = 1.0 + 2.0 * s; - p *= u; - r *= (s / u).powi(2); - t = 4.0 + r; - } - p - } - _ => 0.0, - } -} - -// dot product of two vectors -fn svd_ddot(x: &[f64], y: &[f64]) -> f64 { - x.iter().zip(y).map(|(a, b)| a * b).sum() -} - -// norm (length) of a vector -fn svd_norm(x: &[f64]) -> f64 { - svd_ddot(x, x).sqrt() -} - -// scales an input vector 'x', by a constant, storing in 'y' -fn svd_datx(d: f64, x: &[f64], y: &mut [f64]) { - for (i, xval) in x.iter().enumerate() { - y[i] = d * xval; - } -} - -// scales an input vector 'x' by a constant, modifying 'x' -fn svd_dscal(d: f64, x: &mut [f64]) { - for elem in x.iter_mut() { - *elem *= d; - } -} - -// copies a vector x to a vector y (reversed direction) -fn svd_dcopy(n: usize, offset: usize, x: &[f64], y: &mut [f64]) { - if n > 0 { - let start = n - 1; - for i in 0..n { - y[offset + start - i] = x[offset + i]; - } - } -} - -/*********************************************************************** - * * - * imtqlb() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - imtqlb() is a translation of a Fortran version of the Algol - procedure IMTQL1, Num. Math. 12, 377-383(1968) by Martin and - Wilkinson, as modified in Num. Math. 15, 450(1970) by Dubrulle. - Handbook for Auto. Comp., vol.II-Linear Algebra, 241-248(1971). - See also B. T. Smith et al, Eispack Guide, Lecture Notes in - Computer Science, Springer-Verlag, (1976). - - The function finds the eigenvalues of a symmetric tridiagonal - matrix by the implicit QL method. - - - Arguments - --------- - - (input) - n order of the symmetric tridiagonal matrix - d contains the diagonal elements of the input matrix - e contains the subdiagonal elements of the input matrix in its - last n-1 positions. e[0] is arbitrary - - (output) - d contains the eigenvalues in ascending order. if an error - exit is made, the eigenvalues are correct and ordered for - indices 0,1,...ierr, but may not be the smallest eigenvalues. - e has been destroyed. -***********************************************************************/ -fn imtqlb(n: usize, d: &mut [f64], e: &mut [f64], bnd: &mut [f64]) -> Result<(), SvdLibError> { - if n == 1 { - return Ok(()); - } - - bnd[0] = 1.0; - let last = n - 1; - for i in 1..=last { - bnd[i] = 0.0; - e[i - 1] = e[i]; - } - e[last] = 0.0; - - let mut i = 0; - - for l in 0..=last { - let mut iteration = 0; - while iteration <= 30 { - let mut m = l; - while m < n { - if m == last { - break; - } - let test = d[m].abs() + d[m + 1].abs(); - if compare(test, test + e[m].abs()) { - break; // convergence = true; - } - m += 1; - } - let mut p = d[l]; - let mut f = bnd[l]; - if m == l { - // order the eigenvalues - let mut exchange = true; - if l > 0 { - i = l; - while i >= 1 && exchange { - if p < d[i - 1] { - d[i] = d[i - 1]; - bnd[i] = bnd[i - 1]; - i -= 1; - } else { - exchange = false; - } - } - } - if exchange { - i = 0; - } - d[i] = p; - bnd[i] = f; - iteration = 31; - } else { - if iteration == 30 { - return Err(SvdLibError::ImtqlbError( - "imtqlb no convergence to an eigenvalue after 30 iterations".to_string(), - )); - } - iteration += 1; - // ........ form shift ........ - let mut g = (d[l + 1] - p) / (2.0 * e[l]); - let mut r = svd_pythag(g, 1.0); - g = d[m] - p + e[l] / (g + svd_fsign(r, g)); - let mut s = 1.0; - let mut c = 1.0; - p = 0.0; - - assert!(m > 0, "imtqlb: expected 'm' to be non-zero"); - i = m - 1; - let mut underflow = false; - while !underflow && i >= l { - f = s * e[i]; - let b = c * e[i]; - r = svd_pythag(f, g); - e[i + 1] = r; - if compare(r, 0.0) { - underflow = true; - break; - } - s = f / r; - c = g / r; - g = d[i + 1] - p; - r = (d[i] - g) * s + 2.0 * c * b; - p = s * r; - d[i + 1] = g + p; - g = c * r - b; - f = bnd[i + 1]; - bnd[i + 1] = s * bnd[i] + c * f; - bnd[i] = c * bnd[i] - s * f; - if i == 0 { - break; - } - i -= 1; - } - // ........ recover from underflow ......... - if underflow { - d[i + 1] -= p; - } else { - d[l] -= p; - e[l] = g; - } - e[m] = 0.0; - } - } - } - Ok(()) -} - -/*********************************************************************** - * * - * startv() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function delivers a starting vector in r and returns |r|; it returns - zero if the range is spanned, and ierr is non-zero if no starting - vector within range of operator can be found. - - Parameters - --------- - - (input) - n dimension of the eigenproblem matrix B - wptr array of pointers that point to work space - j starting index for a Lanczos run - eps machine epsilon (relative precision) - - (output) - wptr array of pointers that point to work space that contains - r[j], q[j], q[j-1], p[j], p[j-1] -***********************************************************************/ -#[allow(non_snake_case)] -fn startv( - A: &dyn SMat, - wrk: &mut WorkSpace, - step: usize, - store: &mut Store, - random_seed: u32, -) -> Result { - // get initial vector; default is random - let mut rnm2 = svd_ddot(&wrk.w0, &wrk.w0); - for id in 0..3 { - if id > 0 || step > 0 || compare(rnm2, 0.0) { - let mut bytes = [0; 32]; - for (i, b) in random_seed.to_le_bytes().iter().enumerate() { - bytes[i] = *b; - } - let mut seeded_rng = StdRng::from_seed(bytes); - wrk.w0.fill_with(|| seeded_rng.gen_range(-1.0..1.0)); - } - wrk.w3.copy_from_slice(&wrk.w0); - - // apply operator to put r in range (essential if m singular) - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); - wrk.w3.copy_from_slice(&wrk.w0); - rnm2 = svd_ddot(&wrk.w3, &wrk.w3); - if rnm2 > 0.0 { - break; - } - } - - if rnm2 <= 0.0 { - return Err(SvdLibError::StartvError(format!("rnm2 <= 0.0, rnm2 = {rnm2}"))); - } - - if step > 0 { - for i in 0..step { - let v = store.retrq(i); - svd_daxpy(-svd_ddot(&wrk.w3, v), v, &mut wrk.w0); - } - - // make sure q[step] is orthogonal to q[step-1] - svd_daxpy(-svd_ddot(&wrk.w4, &wrk.w0), &wrk.w2, &mut wrk.w0); - wrk.w3.copy_from_slice(&wrk.w0); - - rnm2 = match svd_ddot(&wrk.w3, &wrk.w3) { - dot if dot <= f64::EPSILON * rnm2 => 0.0, - dot => dot, - } - } - Ok(rnm2.sqrt()) -} - -/*********************************************************************** - * * - * stpone() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function performs the first step of the Lanczos algorithm. It also - does a step of extended local re-orthogonalization. - - Arguments - --------- - - (input) - n dimension of the eigenproblem for matrix B - - (output) - ierr error flag - wptr array of pointers that point to work space that contains - wptr[0] r[j] - wptr[1] q[j] - wptr[2] q[j-1] - wptr[3] p - wptr[4] p[j-1] - wptr[6] diagonal elements of matrix T -***********************************************************************/ -#[allow(non_snake_case)] -fn stpone(A: &dyn SMat, wrk: &mut WorkSpace, store: &mut Store, random_seed: u32) -> Result<(f64, f64), SvdLibError> { - // get initial vector; default is random - let mut rnm = startv(A, wrk, 0, store, random_seed)?; - if compare(rnm, 0.0) { - return Err(SvdLibError::StponeError("rnm == 0.0".to_string())); - } - - // normalize starting vector - svd_datx(rnm.recip(), &wrk.w0, &mut wrk.w1); - svd_dscal(rnm.recip(), &mut wrk.w3); - - // take the first step - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); - wrk.alf[0] = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-wrk.alf[0], &wrk.w1, &mut wrk.w0); - let t = svd_ddot(&wrk.w0, &wrk.w3); - wrk.alf[0] += t; - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); - wrk.w4.copy_from_slice(&wrk.w0); - rnm = svd_norm(&wrk.w4); - let anorm = rnm + wrk.alf[0].abs(); - Ok((rnm, f64::EPSILON.sqrt() * anorm)) -} - -/*********************************************************************** - * * - * lanczos_step() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function embodies a single Lanczos step - - Arguments - --------- - - (input) - n dimension of the eigenproblem for matrix B - first start of index through loop - last end of index through loop - wptr array of pointers pointing to work space - alf array to hold diagonal of the tridiagonal matrix T - eta orthogonality estimate of Lanczos vectors at step j - oldeta orthogonality estimate of Lanczos vectors at step j-1 - bet array to hold off-diagonal of T - ll number of intitial Lanczos vectors in local orthog. - (has value of 0, 1 or 2) - enough stop flag -***********************************************************************/ -#[allow(non_snake_case)] -#[allow(clippy::too_many_arguments)] -fn lanczos_step( - A: &dyn SMat, - wrk: &mut WorkSpace, - first: usize, - last: usize, - ll: &mut usize, - enough: &mut bool, - rnm: &mut f64, - tol: &mut f64, - store: &mut Store, -) -> Result { - let eps1 = f64::EPSILON * (wrk.ncols as f64).sqrt(); - let mut j = first; - - while j < last { - mem::swap(&mut wrk.w1, &mut wrk.w2); - mem::swap(&mut wrk.w3, &mut wrk.w4); - - store.storq(j - 1, &wrk.w2); - if j - 1 < MAXLL { - store.storp(j - 1, &wrk.w4); - } - wrk.bet[j] = *rnm; - - // restart if invariant subspace is found - if compare(*rnm, 0.0) { - *rnm = startv(A, wrk, j, store, 0)?; - if compare(*rnm, 0.0) { - *enough = true; - } - } - - if *enough { - // added by Doug... - // These lines fix a bug that occurs with low-rank matrices - mem::swap(&mut wrk.w1, &mut wrk.w2); - // ...added by Doug - break; - } - - // take a lanczos step - svd_datx(rnm.recip(), &wrk.w0, &mut wrk.w1); - svd_dscal(rnm.recip(), &mut wrk.w3); - svd_opb(A, &wrk.w3, &mut wrk.w0, &mut wrk.temp, wrk.transposed); - svd_daxpy(-*rnm, &wrk.w2, &mut wrk.w0); - wrk.alf[j] = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-wrk.alf[j], &wrk.w1, &mut wrk.w0); - - // orthogonalize against initial lanczos vectors - if j <= MAXLL && wrk.alf[j - 1].abs() > 4.0 * wrk.alf[j].abs() { - *ll = j; - } - for i in 0..(j - 1).min(*ll) { - let v1 = store.retrp(i); - let t = svd_ddot(v1, &wrk.w0); - let v2 = store.retrq(i); - svd_daxpy(-t, v2, &mut wrk.w0); - wrk.eta[i] = eps1; - wrk.oldeta[i] = eps1; - } - - // extended local reorthogonalization - let t = svd_ddot(&wrk.w0, &wrk.w4); - svd_daxpy(-t, &wrk.w2, &mut wrk.w0); - if wrk.bet[j] > 0.0 { - wrk.bet[j] += t; - } - let t = svd_ddot(&wrk.w0, &wrk.w3); - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); - wrk.alf[j] += t; - wrk.w4.copy_from_slice(&wrk.w0); - *rnm = svd_norm(&wrk.w4); - let anorm = wrk.bet[j] + wrk.alf[j].abs() + *rnm; - *tol = f64::EPSILON.sqrt() * anorm; - - // update the orthogonality bounds - ortbnd(wrk, j, *rnm, eps1); - - // restore the orthogonality state when needed - purge(wrk.ncols, *ll, wrk, j, rnm, *tol, store); - if *rnm <= *tol { - *rnm = 0.0; - } - j += 1; - } - Ok(j) -} - -/*********************************************************************** - * * - * purge() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function examines the state of orthogonality between the new Lanczos - vector and the previous ones to decide whether re-orthogonalization - should be performed - - - Arguments - --------- - - (input) - n dimension of the eigenproblem for matrix B - ll number of intitial Lanczos vectors in local orthog. - r residual vector to become next Lanczos vector - q current Lanczos vector - ra previous Lanczos vector - qa previous Lanczos vector - wrk temporary vector to hold the previous Lanczos vector - eta state of orthogonality between r and prev. Lanczos vectors - oldeta state of orthogonality between q and prev. Lanczos vectors - j current Lanczos step - - (output) - r residual vector orthogonalized against previous Lanczos - vectors - q current Lanczos vector orthogonalized against previous ones -***********************************************************************/ -fn purge(n: usize, ll: usize, wrk: &mut WorkSpace, step: usize, rnm: &mut f64, tol: f64, store: &mut Store) { - if step < ll + 2 { - return; - } - - let reps = f64::EPSILON.sqrt(); - let eps1 = f64::EPSILON * (n as f64).sqrt(); - - let k = svd_idamax(step - (ll + 1), &wrk.eta) + ll; - if wrk.eta[k].abs() > reps { - let reps1 = eps1 / reps; - let mut iteration = 0; - let mut flag = true; - while iteration < 2 && flag { - if *rnm > tol { - // bring in a lanczos vector t and orthogonalize both r and q against it - let mut tq = 0.0; - let mut tr = 0.0; - for i in ll..step { - let v = store.retrq(i); - let t = svd_ddot(v, &wrk.w3); - tq += t.abs(); - svd_daxpy(-t, v, &mut wrk.w1); - let t = svd_ddot(v, &wrk.w4); - tr += t.abs(); - svd_daxpy(-t, v, &mut wrk.w0); - } - wrk.w3.copy_from_slice(&wrk.w1); - let t = svd_ddot(&wrk.w0, &wrk.w3); - tr += t.abs(); - svd_daxpy(-t, &wrk.w1, &mut wrk.w0); - wrk.w4.copy_from_slice(&wrk.w0); - *rnm = svd_norm(&wrk.w4); - if tq <= reps1 && tr <= *rnm * reps1 { - flag = false; - } - } - iteration += 1; - } - for i in ll..=step { - wrk.eta[i] = eps1; - wrk.oldeta[i] = eps1; - } - } -} - -/*********************************************************************** - * * - * ortbnd() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function updates the eta recurrence - - Arguments - --------- - - (input) - alf array to hold diagonal of the tridiagonal matrix T - eta orthogonality estimate of Lanczos vectors at step j - oldeta orthogonality estimate of Lanczos vectors at step j-1 - bet array to hold off-diagonal of T - n dimension of the eigenproblem for matrix B - j dimension of T - rnm norm of the next residual vector - eps1 roundoff estimate for dot product of two unit vectors - - (output) - eta orthogonality estimate of Lanczos vectors at step j+1 - oldeta orthogonality estimate of Lanczos vectors at step j -***********************************************************************/ -fn ortbnd(wrk: &mut WorkSpace, step: usize, rnm: f64, eps1: f64) { - if step < 1 { - return; - } - if !compare(rnm, 0.0) && step > 1 { - wrk.oldeta[0] = - (wrk.bet[1] * wrk.eta[1] + (wrk.alf[0] - wrk.alf[step]) * wrk.eta[0] - wrk.bet[step] * wrk.oldeta[0]) / rnm - + eps1; - if step > 2 { - for i in 1..=step - 2 { - wrk.oldeta[i] = (wrk.bet[i + 1] * wrk.eta[i + 1] - + (wrk.alf[i] - wrk.alf[step]) * wrk.eta[i] - + wrk.bet[i] * wrk.eta[i - 1] - - wrk.bet[step] * wrk.oldeta[i]) - / rnm - + eps1; - } - } - } - wrk.oldeta[step - 1] = eps1; - mem::swap(&mut wrk.oldeta, &mut wrk.eta); - wrk.eta[step] = eps1; -} - -/*********************************************************************** - * * - * error_bound() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function massages error bounds for very close ritz values by placing - a gap between them. The error bounds are then refined to reflect - this. - - - Arguments - --------- - - (input) - endl left end of interval containing unwanted eigenvalues - endr right end of interval containing unwanted eigenvalues - ritz array to store the ritz values - bnd array to store the error bounds - enough stop flag -***********************************************************************/ -fn error_bound( - enough: &mut bool, - endl: f64, - endr: f64, - ritz: &mut [f64], - bnd: &mut [f64], - step: usize, - tol: f64, -) -> usize { - assert!(step > 0, "error_bound: expected 'step' to be non-zero"); - - // massage error bounds for very close ritz values - let mid = svd_idamax(step + 1, bnd); - - let mut i = ((step + 1) + (step - 1)) / 2; - while i > mid + 1 { - if (ritz[i - 1] - ritz[i]).abs() < eps34() * ritz[i].abs() && bnd[i] > tol && bnd[i - 1] > tol { - bnd[i - 1] = (bnd[i].powi(2) + bnd[i - 1].powi(2)).sqrt(); - bnd[i] = 0.0; - } - i -= 1; - } - - let mut i = ((step + 1) - (step - 1)) / 2; - while i + 1 < mid { - if (ritz[i + 1] - ritz[i]).abs() < eps34() * ritz[i].abs() && bnd[i] > tol && bnd[i + 1] > tol { - bnd[i + 1] = (bnd[i].powi(2) + bnd[i + 1].powi(2)).sqrt(); - bnd[i] = 0.0; - } - i += 1; - } - - // refine the error bounds - let mut neig = 0; - let mut gapl = ritz[step] - ritz[0]; - for i in 0..=step { - let mut gap = gapl; - if i < step { - gapl = ritz[i + 1] - ritz[i]; - } - gap = gap.min(gapl); - if gap > bnd[i] { - bnd[i] *= bnd[i] / gap; - } - if bnd[i] <= 16.0 * f64::EPSILON * ritz[i].abs() { - neig += 1; - if !*enough { - *enough = endl < ritz[i] && ritz[i] < endr; - } - } - } - neig -} - -/*********************************************************************** - * * - * imtql2() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - imtql2() is a translation of a Fortran version of the Algol - procedure IMTQL2, Num. Math. 12, 377-383(1968) by Martin and - Wilkinson, as modified in Num. Math. 15, 450(1970) by Dubrulle. - Handbook for Auto. Comp., vol.II-Linear Algebra, 241-248(1971). - See also B. T. Smith et al, Eispack Guide, Lecture Notes in - Computer Science, Springer-Verlag, (1976). - - This function finds the eigenvalues and eigenvectors of a symmetric - tridiagonal matrix by the implicit QL method. - - - Arguments - --------- - - (input) - nm row dimension of the symmetric tridiagonal matrix - n order of the matrix - d contains the diagonal elements of the input matrix - e contains the subdiagonal elements of the input matrix in its - last n-1 positions. e[0] is arbitrary - z contains the identity matrix - - (output) - d contains the eigenvalues in ascending order. if an error - exit is made, the eigenvalues are correct but unordered for - for indices 0,1,...,ierr. - e has been destroyed. - z contains orthonormal eigenvectors of the symmetric - tridiagonal (or full) matrix. if an error exit is made, - z contains the eigenvectors associated with the stored - eigenvalues. -***********************************************************************/ -fn imtql2(nm: usize, n: usize, d: &mut [f64], e: &mut [f64], z: &mut [f64]) -> Result<(), SvdLibError> { - if n == 1 { - return Ok(()); - } - assert!(n > 1, "imtql2: expected 'n' to be > 1"); - - let last = n - 1; - - for i in 1..n { - e[i - 1] = e[i]; - } - e[last] = 0.0; - - let nnm = n * nm; - for l in 0..n { - let mut iteration = 0; - - // look for small sub-diagonal element - while iteration <= 30 { - let mut m = l; - while m < n { - if m == last { - break; - } - let test = d[m].abs() + d[m + 1].abs(); - if compare(test, test + e[m].abs()) { - break; // convergence = true; - } - m += 1; - } - if m == l { - break; - } - - // error -- no convergence to an eigenvalue after 30 iterations. - if iteration == 30 { - return Err(SvdLibError::Imtql2Error( - "imtql2 no convergence to an eigenvalue after 30 iterations".to_string(), - )); - } - iteration += 1; - - // form shift - let mut g = (d[l + 1] - d[l]) / (2.0 * e[l]); - let mut r = svd_pythag(g, 1.0); - g = d[m] - d[l] + e[l] / (g + svd_fsign(r, g)); - - let mut s = 1.0; - let mut c = 1.0; - let mut p = 0.0; - - assert!(m > 0, "imtql2: expected 'm' to be non-zero"); - let mut i = m - 1; - let mut underflow = false; - while !underflow && i >= l { - let mut f = s * e[i]; - let b = c * e[i]; - r = svd_pythag(f, g); - e[i + 1] = r; - if compare(r, 0.0) { - underflow = true; - } else { - s = f / r; - c = g / r; - g = d[i + 1] - p; - r = (d[i] - g) * s + 2.0 * c * b; - p = s * r; - d[i + 1] = g + p; - g = c * r - b; - - // form vector - for k in (0..nnm).step_by(n) { - let index = k + i; - f = z[index + 1]; - z[index + 1] = s * z[index] + c * f; - z[index] = c * z[index] - s * f; - } - if i == 0 { - break; - } - i -= 1; - } - } /* end while (underflow != FALSE && i >= l) */ - /*........ recover from underflow .........*/ - if underflow { - d[i + 1] -= p; - } else { - d[l] -= p; - e[l] = g; - } - e[m] = 0.0; - } - } - - // order the eigenvalues - for l in 1..n { - let i = l - 1; - let mut k = i; - let mut p = d[i]; - for (j, item) in d.iter().enumerate().take(n).skip(l) { - if *item < p { - k = j; - p = *item; - } - } - - // ...and corresponding eigenvectors - if k != i { - d[k] = d[i]; - d[i] = p; - for j in (0..nnm).step_by(n) { - z.swap(j + i, j + k); - } - } - } - - Ok(()) -} - -fn rotate_array(a: &mut [f64], x: usize) { - let n = a.len(); - let mut j = 0; - let mut start = 0; - let mut t1 = a[0]; - - for _ in 0..n { - j = match j >= x { - true => j - x, - false => j + n - x, - }; - - let t2 = a[j]; - a[j] = t1; - - if j == start { - j += 1; - start = j; - t1 = a[j]; - } else { - t1 = t2; - } - } -} - -/*********************************************************************** - * * - * ritvec() * - * Function computes the singular vectors of matrix A * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - This function is invoked by landr() only if eigenvectors of the A'A - eigenproblem are desired. When called, ritvec() computes the - singular vectors of A and writes the result to an unformatted file. - - - Parameters - ---------- - - (input) - nrow number of rows of A - steps number of Lanczos iterations performed - fp_out2 pointer to unformatted output file - n dimension of matrix A - kappa relative accuracy of ritz values acceptable as - eigenvalues of A'A - ritz array of ritz values - bnd array of error bounds - alf array of diagonal elements of the tridiagonal matrix T - bet array of off-diagonal elements of T - w1, w2 work space - - (output) - xv1 array of eigenvectors of A'A (right singular vectors of A) - ierr error code - 0 for normal return from imtql2() - k if convergence did not occur for k-th eigenvalue in - imtql2() - nsig number of accepted ritz values based on kappa - - (local) - s work array which is initialized to the identity matrix - of order (j + 1) upon calling imtql2(). After the call, - s contains the orthonormal eigenvectors of the symmetric - tridiagonal matrix T -***********************************************************************/ -#[allow(non_snake_case)] -fn ritvec( - A: &dyn SMat, - dimensions: usize, - kappa: f64, - wrk: &mut WorkSpace, - steps: usize, - neig: usize, - store: &mut Store, -) -> Result { - let js = steps + 1; - let jsq = js * js; - let mut s = vec![0.0; jsq]; - - // initialize s to an identity matrix - for i in (0..jsq).step_by(js + 1) { - s[i] = 1.0; - } - - let mut Vt = DMat { - cols: wrk.ncols, - value: vec![0.0; wrk.ncols * dimensions], - }; - - svd_dcopy(js, 0, &wrk.alf, &mut Vt.value); - svd_dcopy(steps, 1, &wrk.bet, &mut wrk.w5); - - // on return from imtql2(), `R.Vt.value` contains eigenvalues in - // ascending order and `s` contains the corresponding eigenvectors - imtql2(js, js, &mut Vt.value, &mut wrk.w5, &mut s)?; - - let mut nsig = 0; - let mut x = 0; - let mut id2 = jsq - js; - for k in 0..js { - if wrk.bnd[k] <= kappa * wrk.ritz[k].abs() && k + 1 > js - neig { - x = match x { - 0 => dimensions - 1, - _ => x - 1, - }; - - let offset = x * Vt.cols; - Vt.value[offset..offset + Vt.cols].fill(0.0); - let mut idx = id2 + js; - for i in 0..js { - idx -= js; - if s[idx] != 0.0 { - for (j, item) in store.retrq(i).iter().enumerate().take(Vt.cols) { - Vt.value[j + offset] += s[idx] * item; - } - } - } - nsig += 1; - } - id2 += 1; - } - - // Rotate the singular vectors and values. - // `x` is now the location of the highest singular value. - if x > 0 { - rotate_array(&mut Vt.value, x * Vt.cols); - } - - // final dimension size - let d = dimensions.min(nsig); - let mut S = vec![0.0; d]; - let mut Ut = DMat { - cols: wrk.nrows, - value: vec![0.0; wrk.nrows * d], - }; - Vt.value.resize(Vt.cols * d, 0.0); - - let mut tmp_vec = vec![0.0; Vt.cols]; - for (i, sval) in S.iter_mut().enumerate() { - let vt_offset = i * Vt.cols; - let ut_offset = i * Ut.cols; - - let vt_vec = &Vt.value[vt_offset..vt_offset + Vt.cols]; - let ut_vec = &mut Ut.value[ut_offset..ut_offset + Ut.cols]; - - // multiply by matrix B first - svd_opb(A, vt_vec, &mut tmp_vec, &mut wrk.temp, wrk.transposed); - let t = svd_ddot(vt_vec, &tmp_vec); - - // store the Singular Value at S[i] - *sval = t.sqrt(); - - svd_daxpy(-t, vt_vec, &mut tmp_vec); - wrk.bnd[js] = svd_norm(&tmp_vec) * sval.recip(); - - // multiply by matrix A to get (scaled) left s-vector - A.svd_opa(vt_vec, ut_vec, wrk.transposed); - svd_dscal(sval.recip(), ut_vec); - } - - Ok(SVDRawRec { - // Dimensionality (rank) - d, - - // Significant values - nsig, - - // DMat Ut Transpose of left singular vectors. (d by m) - // The vectors are the rows of Ut. - Ut, - - // Array of singular values. (length d) - S, - - // DMat Vt Transpose of right singular vectors. (d by n) - // The vectors are the rows of Vt. - Vt, - }) -} - -/*********************************************************************** - * * - * lanso() * - * * - ***********************************************************************/ -/*********************************************************************** - - Description - ----------- - - Function determines when the restart of the Lanczos algorithm should - occur and when it should terminate. - - Arguments - --------- - - (input) - n dimension of the eigenproblem for matrix B - iterations upper limit of desired number of lanczos steps - dimensions upper limit of desired number of eigenpairs - endl left end of interval containing unwanted eigenvalues - endr right end of interval containing unwanted eigenvalues - ritz array to hold the ritz values - bnd array to hold the error bounds - wptr array of pointers that point to work space: - wptr[0]-wptr[5] six vectors of length n - wptr[6] array to hold diagonal of the tridiagonal matrix T - wptr[9] array to hold off-diagonal of T - wptr[7] orthogonality estimate of Lanczos vectors at - step j - wptr[8] orthogonality estimate of Lanczos vectors at - step j-1 - (output) - j number of Lanczos steps actually taken - neig number of ritz values stabilized - ritz array to hold the ritz values - bnd array to hold the error bounds - ierr (globally declared) error flag - ierr = 8192 if stpone() fails to find a starting vector - ierr = k if convergence did not occur for k-th eigenvalue - in imtqlb() -***********************************************************************/ -#[allow(non_snake_case)] -#[allow(clippy::too_many_arguments)] -fn lanso( - A: &dyn SMat, - dim: usize, - iterations: usize, - end_interval: &[f64; 2], - wrk: &mut WorkSpace, - neig: &mut usize, - store: &mut Store, - random_seed: u32, -) -> Result { - let (endl, endr) = (end_interval[0], end_interval[1]); - - /* take the first step */ - let rnm_tol = stpone(A, wrk, store, random_seed)?; - let mut rnm = rnm_tol.0; - let mut tol = rnm_tol.1; - - let eps1 = f64::EPSILON * (wrk.ncols as f64).sqrt(); - wrk.eta[0] = eps1; - wrk.oldeta[0] = eps1; - let mut ll = 0; - let mut first = 1; - let mut last = iterations.min(dim.max(8) + dim); - let mut enough = false; - let mut j = 0; - let mut intro = 0; - - while !enough { - if rnm <= tol { - rnm = 0.0; - } - - // the actual lanczos loop - let steps = lanczos_step(A, wrk, first, last, &mut ll, &mut enough, &mut rnm, &mut tol, store)?; - j = match enough { - true => steps - 1, - false => last - 1, - }; - - first = j + 1; - wrk.bet[first] = rnm; - - // analyze T - let mut l = 0; - for _ in 0..j { - if l > j { - break; - } - - let mut i = l; - while i <= j { - if compare(wrk.bet[i + 1], 0.0) { - break; - } - i += 1; - } - i = i.min(j); - - // now i is at the end of an unreduced submatrix - let sz = i - l; - svd_dcopy(sz + 1, l, &wrk.alf, &mut wrk.ritz); - svd_dcopy(sz, l + 1, &wrk.bet, &mut wrk.w5); - - imtqlb(sz + 1, &mut wrk.ritz[l..], &mut wrk.w5[l..], &mut wrk.bnd[l..])?; - - for m in l..=i { - wrk.bnd[m] = rnm * wrk.bnd[m].abs(); - } - l = i + 1; - } - - // sort eigenvalues into increasing order - insert_sort(j + 1, &mut wrk.ritz, &mut wrk.bnd); - - *neig = error_bound(&mut enough, endl, endr, &mut wrk.ritz, &mut wrk.bnd, j, tol); - - // should we stop? - if *neig < dim { - if *neig == 0 { - last = first + 9; - intro = first; - } else { - last = first + 3.max(1 + ((j - intro) * (dim - *neig)) / *neig); - } - last = last.min(iterations); - } else { - enough = true - } - enough = enough || first >= iterations; - } - store.storq(j, &wrk.w1); - Ok(j) -} - -////////////////////////////////////////// -// SvdRec implementation -////////////////////////////////////////// - -impl SvdRec { - pub fn recompose(&self) -> Array2 { - let sdiag = Array2::from_diag(&self.s); - self.ut.t().dot(&sdiag).dot(&self.vt) - } -} - -////////////////////////////////////////// -// SMat implementation for CscMatrix -////////////////////////////////////////// - -#[rustfmt::skip] -impl SMat for nalgebra_sparse::csc::CscMatrix { - fn nrows(&self) -> usize { self.nrows() } - fn ncols(&self) -> usize { self.ncols() } - fn nnz(&self) -> usize { self.nnz() } - - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[f64], y: &mut [f64], transposed: bool) { - let nrows = if transposed { self.ncols() } else { self.nrows() }; - let ncols = if transposed { self.nrows() } else { self.ncols() }; - assert_eq!(x.len(), ncols, "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", x.len(), ncols); - assert_eq!(y.len(), nrows, "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", y.len(), nrows); - - let (major_offsets, minor_indices, values) = self.csc_data(); - - y.fill(0.0); - if transposed { - for (i, yval) in y.iter_mut().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - *yval += values[j] * x[minor_indices[j]]; - } - } - } else { - for (i, xval) in x.iter().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - y[minor_indices[j]] += values[j] * xval; - } - } - } - } -} - -////////////////////////////////////////// -// SMat implementation for CsrMatrix -////////////////////////////////////////// - -#[rustfmt::skip] -impl SMat for nalgebra_sparse::csr::CsrMatrix { - fn nrows(&self) -> usize { self.nrows() } - fn ncols(&self) -> usize { self.ncols() } - fn nnz(&self) -> usize { self.nnz() } - - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[f64], y: &mut [f64], transposed: bool) { - let nrows = if transposed { self.ncols() } else { self.nrows() }; - let ncols = if transposed { self.nrows() } else { self.ncols() }; - assert_eq!(x.len(), ncols, "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", x.len(), ncols); - assert_eq!(y.len(), nrows, "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", y.len(), nrows); - - let (major_offsets, minor_indices, values) = self.csr_data(); - - y.fill(0.0); - if !transposed { - for (i, yval) in y.iter_mut().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - *yval += values[j] * x[minor_indices[j]]; - } - } - } else { - for (i, xval) in x.iter().enumerate() { - for j in major_offsets[i]..major_offsets[i + 1] { - y[minor_indices[j]] += values[j] * xval; - } - } - } - } -} - -////////////////////////////////////////// -// SMat implementation for CooMatrix -////////////////////////////////////////// - -#[rustfmt::skip] -impl SMat for nalgebra_sparse::coo::CooMatrix { - fn nrows(&self) -> usize { self.nrows() } - fn ncols(&self) -> usize { self.ncols() } - fn nnz(&self) -> usize { self.nnz() } - - /// takes an n-vector x and returns A*x in y - fn svd_opa(&self, x: &[f64], y: &mut [f64], transposed: bool) { - let nrows = if transposed { self.ncols() } else { self.nrows() }; - let ncols = if transposed { self.nrows() } else { self.ncols() }; - assert_eq!(x.len(), ncols, "svd_opa: x must be A.ncols() in length, x = {}, A.ncols = {}", x.len(), ncols); - assert_eq!(y.len(), nrows, "svd_opa: y must be A.nrows() in length, y = {}, A.nrows = {}", y.len(), nrows); - - y.fill(0.0); - if transposed { - for (i, j, v) in self.triplet_iter() { - y[j] += v * x[i]; - } - } else { - for (i, j, v) in self.triplet_iter() { - y[i] += v * x[j]; - } - } - } -} - -////////////////////////////////////////// -// Tests -////////////////////////////////////////// - -#[cfg(test)] -mod tests { - use super::*; - use nalgebra_sparse::{coo::CooMatrix, csc::CscMatrix, csr::CsrMatrix}; - - fn is_normal() {} - fn is_dynamic_trait() {} - - #[test] - fn normal_types() { - is_normal::(); - is_normal::(); - is_normal::(); - is_normal::(); - is_normal::(); - is_normal::(); - } - - #[test] - fn dynamic_types() { - is_dynamic_trait::(); - } - - #[test] - fn coo_csc_csr() { - let coo = CooMatrix::try_from_triplets(4, 4, vec![1, 2], vec![0, 1], vec![3.0, 4.0]).unwrap(); - let csc = CscMatrix::from(&coo); - let csr = CsrMatrix::from(&csc); - assert_eq!(svd_dim_seed(&coo, 3, 12345), svd_dim_seed(&csc, 3, 12345)); - assert_eq!(svd_dim_seed(&csc, 3, 12345), svd_dim_seed(&csr, 3, 12345)); - } - - #[test] - #[rustfmt::skip] - fn recomp() { - let mut coo = CooMatrix::::new(3, 3); - coo.push(0, 0, 1.0); coo.push(0, 1, 16.0); coo.push(0, 2, 49.0); - coo.push(1, 0, 4.0); coo.push(1, 1, 25.0); coo.push(1, 2, 64.0); - coo.push(2, 0, 9.0); coo.push(2, 1, 36.0); coo.push(2, 2, 81.0); - - // Note: svd.ut & svd.vt are returned in transposed form - // M = USV* - let svd = svd(&coo).unwrap(); - let matrix_approximation = svd.ut.t().dot(&Array2::from_diag(&svd.s)).dot(&svd.vt); - assert_eq!(svd.recompose(), matrix_approximation); - } - - #[test] - #[rustfmt::skip] - fn basic_2x2() { - // [ - // [ 4, 0 ], - // [ 3, -5 ] - // ] - let mut coo = CooMatrix::::new(2, 2); - coo.push(0, 0, 4.0); - coo.push(1, 0, 3.0); - coo.push(1, 1, -5.0); - - let svd = svd(&coo).unwrap(); - assert_eq!(svd.d, svd.ut.nrows()); - assert_eq!(svd.d, svd.s.dim()); - assert_eq!(svd.d, svd.vt.nrows()); - - // Note: svd.ut & svd.vt are returned in transposed form - // M = USV* - let matrix_approximation = svd.ut.t().dot(&Array2::from_diag(&svd.s)).dot(&svd.vt); - assert_eq!(svd.recompose(), matrix_approximation); - - let epsilon = 1.0e-12; - assert_eq!(svd.d, 2); - assert!((matrix_approximation[[0, 0]] - 4.0).abs() < epsilon); - assert!((matrix_approximation[[0, 1]] - 0.0).abs() < epsilon); - assert!((matrix_approximation[[1, 0]] - 3.0).abs() < epsilon); - assert!((matrix_approximation[[1, 1]] - -5.0).abs() < epsilon); - - assert!((svd.s[0] - 6.3245553203368).abs() < epsilon); - assert!((svd.s[1] - 3.1622776601684).abs() < epsilon); - } - - #[test] - #[rustfmt::skip] - fn identity_3x3() { - // [ [ 1, 0, 0 ], - // [ 0, 1, 0 ], - // [ 0, 0, 1 ] ] - let mut coo = CooMatrix::::new(3, 3); - coo.push(0, 0, 1.0); - coo.push(1, 1, 1.0); - coo.push(2, 2, 1.0); - - let csc = CscMatrix::from(&coo); - let svd = svd(&csc).unwrap(); - assert_eq!(svd.d, svd.ut.nrows()); - assert_eq!(svd.d, svd.s.dim()); - assert_eq!(svd.d, svd.vt.nrows()); - - let epsilon = 1.0e-12; - assert_eq!(svd.d, 1); - assert!((svd.s[0] - 1.0).abs() < epsilon); - } -} \ No newline at end of file diff --git a/src/legacy/error.rs b/src/legacy/error.rs deleted file mode 100644 index 8309930..0000000 --- a/src/legacy/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug, PartialEq)] -pub enum SvdLibError { - #[error("svdlibrs/imtqlb: {0}")] - ImtqlbError(String), - - #[error("svdlibrs/startv: {0}")] - StartvError(String), - - #[error("svdlibrs/stpone: {0}")] - StponeError(String), - - #[error("svdlibrs/imtql2: {0}")] - Imtql2Error(String), - - #[error("svdlibrs/svdLas2: {0}")] - Las2Error(String), - - #[error("svdlibrs/ndarray: {0}")] - NDArrayError(#[from] ndarray::ShapeError), -} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 1f4e146..4b10ff4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,281 +1,199 @@ -pub mod legacy; +//! Sparse singular value decomposition. +//! +//! Three solvers over [`sprs`] matrices, all returning the same [`SvdRec`]: +//! +//! | module | method | use when | +//! |---|---|---| +//! | [`irlba`] | thick-restarted Lanczos bidiagonalization | **default.** Accurate, memory bounded by the requested rank | +//! | [`randomized`] | randomized range finder, power iteration or block Krylov | very large inputs where an approximation is acceptable | +//! | `lanczos` | LAS2 from SVDLIBC | **deprecated, numerically unreliable** — behind the off-by-default `las2` feature | +//! +//! # Quick start +//! +//! ``` +//! use single_svdlib::{sprs::TriMatI, SvdMat}; +//! +//! // A 4x3 matrix in triplet form, converted to CSR with u32 indices. +//! let mut tri = TriMatI::::new((4, 3)); +//! tri.add_triplet(0, 0, 1.0); +//! tri.add_triplet(1, 1, 2.0); +//! tri.add_triplet(2, 2, 3.0); +//! tri.add_triplet(3, 0, 4.0); +//! let a: SvdMat = tri.to_csr::(); +//! +//! // Two largest singular triplets. +//! let svd = single_svdlib::svd(&a, 2)?; +//! +//! assert_eq!(svd.s.len(), 2); +//! assert_eq!(svd.u.dim(), (4, 2)); // left vectors are columns +//! assert_eq!(svd.vt.dim(), (2, 3)); // right vectors are rows +//! assert!(svd.s[0] >= svd.s[1]); +//! # Ok::<(), single_svdlib::SvdLibError>(()) +//! ``` +//! +//! # Index widths +//! +//! [`SvdMat`] defaults to `u32` column indices with `u64` row pointers, which is +//! 12 bytes per non-zero for `f64` data against the 16 that `usize`-everywhere costs +//! (8 against 16 for `f32`). Name the parameters to widen: `SvdMat`. +//! +//! # Orientation +//! +//! `A ≈ u · diag(s) · vt`, matching `numpy.linalg.svd`: `u` is `m × d` with left +//! vectors as columns, `s` is descending, `vt` is `d × n` with right vectors as rows. +//! 1.x was inconsistent between solvers on this point. + +// Numeric kernels index several arrays in step from one loop variable, and +// offset arithmetic is load-bearing; iterator rewrites obscure which array an +// index belongs to. +#![allow(clippy::needless_range_loop)] + +pub mod dense; pub mod error; -pub(crate) mod utils; - -pub mod randomized; - +pub mod irlba; +/// LAS2, from SVDLIBC. Deprecated and numerically unreliable — enable `las2` only to +/// keep a 1.x caller compiling while it moves to [`irlba`]. +#[cfg(feature = "las2")] pub mod lanczos; +pub mod matrix; +pub mod randomized; +pub mod types; -pub use utils::*; - +#[cfg(test)] +mod testing; + +pub use error::{Result, SvdLibError}; +pub use matrix::{ + MaskedCsMat, SparseMat, SparseMatDense, SvdMat, SvdMatView, DEFAULT_SCRATCH_BUDGET, +}; +pub use types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec}; + +/// Re-exported so callers construct matrices without pinning `sprs` themselves. +pub use sprs; + +/// Runs the README's Rust examples as doctests. `cfg(doctest)` keeps it out of the +/// rendered docs, so the README gets checked without being duplicated. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +pub struct ReadmeDoctests; + +/// The `rank` largest singular triplets. +/// +/// Dispatches to [`irlba`], which is accurate and holds a basis bounded by `rank`. +/// Reach past this for a fixed seed ([`irlba::svd_seed`]), PCA +/// ([`irlba::svd_centered`]), or an approximation on a very large input +/// ([`randomized`]). +pub fn svd>(a: &M, rank: usize) -> Result> { + irlba::svd(a, rank) +} + +/// The `rank` largest singular triplets, reproducibly. +pub fn svd_seed>(a: &M, rank: usize, seed: u64) -> Result> { + irlba::svd_seed(a, rank, seed) +} + +/// PCA: the `rank` largest singular triplets of the implicitly mean-centered matrix. +/// +/// The centering is applied as a rank-1 correction inside each product, so the matrix +/// is never densified. +pub fn svd_centered>( + a: &M, + rank: usize, + seed: Option, +) -> Result> { + irlba::svd_centered(a, rank, seed) +} #[cfg(test)] -mod simple_comparison_tests { +mod tests { use super::*; - use legacy; - use nalgebra_sparse::coo::CooMatrix; - use nalgebra_sparse::CsrMatrix; - use rand::{Rng, SeedableRng}; - use rand::rngs::StdRng; - use rayon::ThreadPoolBuilder; - - fn create_sparse_matrix(rows: usize, cols: usize, density: f64) -> nalgebra_sparse::coo::CooMatrix { - use rand::{rngs::StdRng, Rng, SeedableRng}; - use std::collections::HashSet; - - let mut coo = nalgebra_sparse::coo::CooMatrix::new(rows, cols); - - let mut rng = StdRng::seed_from_u64(42); - - let nnz = (rows as f64 * cols as f64 * density).round() as usize; - - let nnz = nnz.max(1); - - let mut positions = HashSet::new(); - - while positions.len() < nnz { - let i = rng.gen_range(0..rows); - let j = rng.gen_range(0..cols); - - if positions.insert((i, j)) { - let val = loop { - let v: f64 = rng.gen_range(-10.0..10.0); - if v.abs() > 1e-10 { // Ensure it's not too close to zero - break v; - } - }; - - coo.push(i, j, val); - } - } - - // Verify the density is as expected - let actual_density = coo.nnz() as f64 / (rows as f64 * cols as f64); - println!("Created sparse matrix: {} x {}", rows, cols); - println!(" - Requested density: {:.6}", density); - println!(" - Actual density: {:.6}", actual_density); - println!(" - Sparsity: {:.4}%", (1.0 - actual_density) * 100.0); - println!(" - Non-zeros: {}", coo.nnz()); - - coo - } - //#[test] - fn simple_matrix_comparison() { - // Create a small, predefined test matrix - let mut test_matrix = CooMatrix::::new(3, 3); - test_matrix.push(0, 0, 1.0); - test_matrix.push(0, 1, 16.0); - test_matrix.push(0, 2, 49.0); - test_matrix.push(1, 0, 4.0); - test_matrix.push(1, 1, 25.0); - test_matrix.push(1, 2, 64.0); - test_matrix.push(2, 0, 9.0); - test_matrix.push(2, 1, 36.0); - test_matrix.push(2, 2, 81.0); - - // Run both implementations with the same seed for deterministic behavior - let seed = 42; - let current_result = lanczos::svd_dim_seed(&test_matrix, 0, seed).unwrap(); - let legacy_result = legacy::svd_dim_seed(&test_matrix, 0, seed).unwrap(); - - // Compare dimensions - assert_eq!(current_result.d, legacy_result.d); - - // Compare singular values - let epsilon = 1.0e-12; - for i in 0..current_result.d { - let diff = (current_result.s[i] - legacy_result.s[i]).abs(); - assert!( - diff < epsilon, - "Singular value {} differs by {}: current = {}, legacy = {}", - i, diff, current_result.s[i], legacy_result.s[i] - ); - } + use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values}; - // Compare reconstructed matrices - let current_reconstructed = current_result.recompose(); - let legacy_reconstructed = legacy_result.recompose(); - - for i in 0..3 { - for j in 0..3 { - let diff = (current_reconstructed[[i, j]] - legacy_reconstructed[[i, j]]).abs(); + /// Every solver must agree with a dense LAPACK reference on the same matrix, to + /// each one's own accuracy class. This is the cross-algorithm contract. + #[test] + fn all_solvers_agree_with_lapack() { + let a = gen_lowrank(300, 100, 10, 101); + let want = reference_singular_values(&dense_of(&a)); + let rank = 10; + + let by_irlba = irlba::svd_seed(&a, rank, 42).unwrap(); + let by_random = randomized::svd_with( + &a, + &randomized::RandomizedConfig::new(rank) + .seed(42) + .power_iterations(4), + None, + ) + .unwrap(); + let by_default = svd_seed(&a, rank, 42).unwrap(); + + for i in 0..rank { + for (name, got, tol) in [ + ("irlba", by_irlba.s[i], 1e-9), + ("randomized", by_random.s[i], 1e-6), + ("top-level default", by_default.s[i], 1e-9), + ] { + let rel = (got - want[i]).abs() / want[i]; assert!( - diff < epsilon, - "Reconstructed matrix element [{},{}] differs by {}: current = {}, legacy = {}", - i, j, diff, current_reconstructed[[i, j]], legacy_reconstructed[[i, j]] + rel < tol, + "{name} triplet {i}: {got:.12e} vs LAPACK {:.12e} (rel {rel:.3e})", + want[i] ); } } } + /// The top-level entry point must be IRLBA, as documented. #[test] - fn random_matrix_comparison() { - let seed = 12345; - let (nrows, ncols) = (50, 30); - let mut rng = StdRng::seed_from_u64(seed); - - // Create random sparse matrix - let mut coo = CooMatrix::::new(nrows, ncols); - // Insert some random non-zero elements - for _ in 0..(nrows * ncols / 5) { // ~20% density - let i = rng.gen_range(0..nrows); - let j = rng.gen_range(0..ncols); - let value = rng.gen_range(-10.0..10.0); - coo.push(i, j, value); - } - - let csr = CsrMatrix::from(&coo); - - // Calculate SVD using original method - let legacy_svd = lanczos::svd_dim_seed(&csr, 0, seed as u32).unwrap(); - - // Calculate SVD using our masked method (using all columns) - let mask = vec![true; ncols]; - let masked_matrix = lanczos::masked::MaskedCSRMatrix::new(&csr, mask); - let current_svd = lanczos::svd_dim_seed(&masked_matrix, 0, seed as u32).unwrap(); - - // Compare with relative tolerance - let rel_tol = 1e-3; // 0.1% relative tolerance - - assert_eq!(legacy_svd.d, current_svd.d, "Ranks differ"); - - for i in 0..legacy_svd.d { - let legacy_val = legacy_svd.s[i]; - let current_val = current_svd.s[i]; - let abs_diff = (legacy_val - current_val).abs(); - let rel_diff = abs_diff / legacy_val.max(current_val); - - assert!( - rel_diff <= rel_tol, - "Singular value {} differs too much: relative diff = {}, current = {}, legacy = {}", - i, rel_diff, current_val, legacy_val - ); - } - } - - #[test] - fn test_real_sparse_matrix() { - // Create a matrix with similar sparsity to your real one (99.02%) - let test_matrix = create_sparse_matrix(100, 100, 0.0098); // 0.98% non-zeros - - // Should no longer fail with convergence error - let result = lanczos::svd_dim_seed(&test_matrix, 50, 42); - assert!(result.is_ok(), "{}", format!("SVD failed on 99.02% sparse matrix, {:?}", result.err().unwrap())); + fn top_level_dispatches_to_irlba() { + let a = gen_sparse(120, 60, 0.1, 7); + let got = svd(&a, 5).unwrap(); + assert_eq!(got.diagnostics.algorithm, Algorithm::Irlba); } + /// `u32`-indexed and `u64`-indexed matrices must give identical answers — the + /// memory win must not cost accuracy. #[test] - fn test_random_svd_computation() { - - let test_matrix = create_sparse_matrix(1000, 250, 0.01); // 1% non-zeros - - let csr = CsrMatrix::from(&test_matrix); - - let result = randomized::randomized_svd( - &csr, - 50, - 10, - 3, - randomized::PowerIterationNormalizer::QR, - false, - Some(42), - false - ); - - // Verify the computation succeeds on a highly sparse matrix - assert!( - result.is_ok(), - "Randomized SVD failed on 99% sparse matrix: {:?}", - result.err().unwrap() - ); - - // Additional checks on the result if successful - if let Ok(svd_result) = result { - // Verify dimensions match expectations - assert_eq!(svd_result.d, 50, "Expected rank of 50"); - - // Verify singular values are positive and in descending order - for i in 0..svd_result.s.len() { - assert!(svd_result.s[i] > 0.0, "Singular values should be positive"); - if i > 0 { - assert!( - svd_result.s[i-1] >= svd_result.s[i], - "Singular values should be in descending order" - ); - } - } - - // Verify basics of U and V dimensions - assert_eq!(svd_result.u.nrows(), 50, "U transpose should have 50 rows"); - assert_eq!(svd_result.u.ncols(), 1000, "U transpose should have 1000 columns"); - assert_eq!(svd_result.vt.nrows(), 50, "V transpose should have 50 rows"); - assert_eq!(svd_result.vt.ncols(), 250, "V transpose should have 250 columns"); + fn index_width_does_not_change_results() { + use sprs::TriMatI; + let a32 = gen_sparse(200, 80, 0.08, 13); + let mut t = TriMatI::::new((200, 80)); + for (v, (i, j)) in a32.iter() { + t.add_triplet(i as usize, j as usize, *v); } - } - - #[test] - fn test_randomized_svd_very_large_sparse_matrix() { - - // Create a very large matrix with high sparsity (99%) - let test_matrix = create_sparse_matrix(100000, 2500, 0.01); // 1% non-zeros - - // Convert to CSR for processing - let csr = CsrMatrix::from(&test_matrix); - - // Run randomized SVD with reasonable defaults for a sparse matrix - let threadpool = ThreadPoolBuilder::new().num_threads(10).build().unwrap(); - let result = threadpool.install(|| { - randomized::randomized_svd( - &csr, - 50, // target rank - 10, // oversampling parameter - 7, // power iterations - randomized::PowerIterationNormalizer::QR, // use QR normalization - false, - Some(42), - false// random seed - ) - }); - + let a64: SvdMat = t.to_csr::(); - // Simply verify that the computation succeeds on a highly sparse matrix - assert!( - result.is_ok(), - "Randomized SVD failed on 99% sparse matrix: {:?}", - result.err().unwrap() - ); + let x = svd_seed(&a32, 10, 42).unwrap(); + let y = svd_seed(&a64, 10, 42).unwrap(); + for (p, q) in x.s.iter().zip(y.s.iter()) { + approx::assert_relative_eq!(p, q, max_relative = 1e-12); + } } + /// The documented memory claim, checked against the buffers sprs actually holds. #[test] - fn test_randomized_svd_small_sparse_matrix() { - - // Create a very large matrix with high sparsity (99%) - let test_matrix = create_sparse_matrix(1000, 250, 0.01); // 1% non-zeros - - // Convert to CSR for processing - let csr = CsrMatrix::from(&test_matrix); - - // Run randomized SVD with reasonable defaults for a sparse matrix - let threadpool = ThreadPoolBuilder::new().num_threads(10).build().unwrap(); - let result = threadpool.install(|| { - randomized::randomized_svd( - &csr, - 50, // target rank - 10, // oversampling parameter - 2, // power iterations - randomized::PowerIterationNormalizer::QR, // use QR normalization - false, - Some(42), // random seed - false - ) - }); - - - // Simply verify that the computation succeeds on a highly sparse matrix + fn u32_indices_are_smaller_than_usize_indices() { + let a = gen_sparse(2000, 500, 0.02, 3); + let nnz = a.nnz(); + let rows = a.rows(); + + assert_eq!(a.indices().len(), nnz); + assert_eq!(a.data().len(), nnz); + + let ours = (rows + 1) * std::mem::size_of::() + + nnz * std::mem::size_of::() + + nnz * std::mem::size_of::(); + let usize_everywhere = (rows + 1) * std::mem::size_of::() + + nnz * std::mem::size_of::() + + nnz * std::mem::size_of::(); + + let saving = 1.0 - (ours as f64 / usize_everywhere as f64); assert!( - result.is_ok(), - "Randomized SVD failed on 99% sparse matrix: {:?}", - result.err().unwrap() + saving > 0.2, + "expected >20% smaller, got {:.1}%", + saving * 100.0 ); } -} \ No newline at end of file +} diff --git a/src/matrix/kernels.rs b/src/matrix/kernels.rs new file mode 100644 index 0000000..d4bd4f6 --- /dev/null +++ b/src/matrix/kernels.rs @@ -0,0 +1,515 @@ +//! Parallel sparse × dense kernels. +//! +//! # Why there are two kernels +//! +//! A compressed matrix can only be walked along its outer dimension. For a CSR matrix +//! that is rows, so: +//! +//! - `A · D` writes output row `i` from sparse row `i`. Threads own disjoint output +//! rows, so this needs **no scratch and no reduction** — [`gather_mul`]. +//! - `Aᵀ · D` reads sparse row `i` and scatters into output rows `j` for every column +//! `j` present in that row. Threads collide, so accumulation is needed — +//! [`scatter_mul`]. +//! +//! `transpose_view()` does not escape this: it relabels a CSR matrix as a CSC view of +//! the transpose, but the traversable dimension is unchanged. What it *does* buy is +//! that a CSC-stored matrix gets the disjoint kernel for `Aᵀ · D` for free, so callers +//! holding CSC pay nothing for the transposed direction. +//! +//! # Scratch budgeting +//! +//! The 1.x code allocated one full `n × k` buffer **per chunk**, with chunk count +//! driven by matrix size — 64 chunks on a 200k-row matrix, so ~922 MiB of scratch for +//! a single 30000 × 60 product. Here the accumulator count is the *thread* count, and +//! if `threads × n × k` still exceeds [`DEFAULT_SCRATCH_BUDGET`] the dense columns are +//! processed in blocks so the bound always holds. + +// Numeric kernels index several arrays in step from one loop variable, and +// offset arithmetic is load-bearing; iterator rewrites obscure which array an +// index belongs to. +#![allow(clippy::needless_range_loop)] + +use crate::types::SvdFloat; +use ndarray::{s, Array2, ArrayView2, ArrayViewMut2, Axis}; +use rayon::prelude::*; +use sprs::{CsMatViewI, SpIndex}; + +/// Upper bound on transient scratch for scatter-direction products, in bytes. +/// +/// Exceeding this trades an extra pass over the sparse indices for a smaller +/// footprint. 64 MiB keeps the accumulators comfortably inside last-level cache +/// pressure on typical machines while still amortising index reads over many columns. +pub const DEFAULT_SCRATCH_BUDGET: usize = 64 << 20; + +/// Below this many output elements, threading costs more than it saves. +const SERIAL_ELEMS: usize = 8 << 10; + +#[inline] +fn threads() -> usize { + rayon::current_num_threads().max(1) +} + +/// `y += alpha * x`, over contiguous slices so LLVM can vectorise it. +#[inline] +fn axpy(alpha: T, x: &[T], y: &mut [T]) { + debug_assert_eq!(x.len(), y.len()); + for (yi, &xi) in y.iter_mut().zip(x.iter()) { + *yi += alpha * xi; + } +} + +/// Split `[0, outer)` into `p` contiguous ranges holding roughly equal non-zeros. +/// +/// Row counts are a poor proxy for work when the non-zero distribution is skewed, +/// which it reliably is for count matrices (a few rows carry a large share of the +/// mass). Returns `p + 1` boundaries. +fn nnz_balanced_split( + m: &CsMatViewI, + p: usize, +) -> Vec { + let outer = m.outer_dims(); + let total = m.nnz(); + let mut bounds = Vec::with_capacity(p + 1); + bounds.push(0); + if p <= 1 || outer == 0 || total == 0 { + bounds.push(outer); + while bounds.len() < p + 1 { + bounds.push(outer); + } + return bounds; + } + let mut acc = 0usize; + let mut next = 1usize; + for i in 0..outer { + acc += m.outer_view(i).map_or(0, |v| v.nnz()); + // Advance past every boundary this row crosses, so a single heavy row cannot + // leave later partitions unassigned. + while next < p && acc * p >= total * next { + bounds.push(i + 1); + next += 1; + } + } + while bounds.len() < p + 1 { + bounds.push(outer); + } + bounds +} + +/// `out = lhs · rhs` where `lhs` is CSR. Write-disjoint: no scratch, no reduction. +/// +/// `out` is fully overwritten. +pub fn gather_mul( + lhs: CsMatViewI, + rhs: ArrayView2, + mut out: ArrayViewMut2, +) where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + assert!(lhs.is_csr(), "gather_mul requires CSR storage"); + assert_eq!(lhs.cols(), rhs.nrows(), "gather_mul: lhs.cols != rhs.rows"); + assert_eq!(lhs.rows(), out.nrows(), "gather_mul: lhs.rows != out.rows"); + assert_eq!(rhs.ncols(), out.ncols(), "gather_mul: rhs.cols != out.cols"); + + let k = rhs.ncols(); + let m = lhs.rows(); + if m == 0 || k == 0 { + out.fill(T::zero()); + return; + } + + let row_op = |i: usize, orow: &mut [T], rhs: &ArrayView2| { + orow.fill(T::zero()); + let Some(row) = lhs.outer_view(i) else { return }; + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let rrow = rhs.row(j.index()); + // rhs is built row-major by this crate; fall back if a caller passes a view. + match rrow.as_slice() { + Some(sl) => axpy(v, sl, orow), + None => { + for (o, &r) in orow.iter_mut().zip(rrow.iter()) { + *o += v * r; + } + } + } + } + }; + + if m * k <= SERIAL_ELEMS { + for i in 0..m { + let mut orow = out.row_mut(i); + match orow.as_slice_mut() { + Some(sl) => row_op(i, sl, &rhs), + None => { + let mut tmp = vec![T::zero(); k]; + row_op(i, &mut tmp, &rhs); + for (o, t) in orow.iter_mut().zip(tmp) { + *o = t; + } + } + } + } + return; + } + + // 4 chunks per thread lets rayon steal work when rows are unevenly filled. + let chunk = m.div_ceil(threads() * 4).max(1); + out.axis_chunks_iter_mut(Axis(0), chunk) + .into_par_iter() + .enumerate() + .for_each(|(ci, mut block)| { + let base = ci * chunk; + for (local, mut orow) in block.rows_mut().into_iter().enumerate() { + let i = base + local; + match orow.as_slice_mut() { + Some(sl) => row_op(i, sl, &rhs), + None => { + let mut tmp = vec![T::zero(); k]; + row_op(i, &mut tmp, &rhs); + for (o, t) in orow.iter_mut().zip(tmp) { + *o = t; + } + } + } + } + }); +} + +/// `out = lhsᵀ · rhs` where `lhs` is CSR. Scatter direction: uses one accumulator per +/// thread, blocking over the columns of `rhs` to keep scratch under `budget`. +/// +/// `out` is fully overwritten. +pub fn scatter_mul( + lhs: CsMatViewI, + rhs: ArrayView2, + mut out: ArrayViewMut2, + budget: usize, +) where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + assert!(lhs.is_csr(), "scatter_mul requires CSR storage"); + assert_eq!(lhs.rows(), rhs.nrows(), "scatter_mul: lhs.rows != rhs.rows"); + assert_eq!(lhs.cols(), out.nrows(), "scatter_mul: lhs.cols != out.rows"); + assert_eq!( + rhs.ncols(), + out.ncols(), + "scatter_mul: rhs.cols != out.cols" + ); + + let (m, n, k) = (lhs.rows(), lhs.cols(), rhs.ncols()); + out.fill(T::zero()); + if m == 0 || n == 0 || k == 0 { + return; + } + + // Serial path: accumulate straight into `out`, no scratch at all. + let p = threads(); + if p == 1 || n * k <= SERIAL_ELEMS { + for i in 0..m { + let Some(row) = lhs.outer_view(i) else { + continue; + }; + let rrow = rhs.row(i); + let rslice = rrow.as_slice(); + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let mut orow = out.row_mut(j.index()); + match (orow.as_slice_mut(), rslice) { + (Some(o), Some(r)) => axpy(v, r, o), + _ => { + for (o, &r) in orow.iter_mut().zip(rrow.iter()) { + *o += v * r; + } + } + } + } + } + return; + } + + // Widest column block whose p accumulators fit the budget. + let bytes_per_col = p.saturating_mul(n).saturating_mul(std::mem::size_of::()); + let kb = budget + .checked_div(bytes_per_col) + .map_or(k, |wide| wide.clamp(1, k)); + + let bounds = nnz_balanced_split(&lhs, p); + + for cstart in (0..k).step_by(kb) { + let cend = (cstart + kb).min(k); + let width = cend - cstart; + let rhs_blk = rhs.slice(s![.., cstart..cend]); + + let partials: Vec> = (0..p) + .into_par_iter() + .map(|t| { + let (lo, hi) = (bounds[t], bounds[t + 1]); + let mut acc = Array2::::zeros((n, width)); + for i in lo..hi { + let Some(row) = lhs.outer_view(i) else { + continue; + }; + let rrow = rhs_blk.row(i); + // rhs_blk is a column slice, so its rows stay contiguous only when + // the block spans every column; handle both. + let rslice = rrow.as_slice(); + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let jj = j.index(); + let mut arow = acc.row_mut(jj); + let aslice = arow.as_slice_mut().expect("owned array is contiguous"); + match rslice { + Some(r) => axpy(v, r, aslice), + None => { + for (a, &r) in aslice.iter_mut().zip(rrow.iter()) { + *a += v * r; + } + } + } + } + } + acc + }) + .collect(); + + // Reduce, parallel over disjoint output rows. + let mut out_blk = out.slice_mut(s![.., cstart..cend]); + let rchunk = n.div_ceil(p * 4).max(1); + out_blk + .axis_chunks_iter_mut(Axis(0), rchunk) + .into_par_iter() + .enumerate() + .for_each(|(ci, mut block)| { + let base = ci * rchunk; + for (local, mut orow) in block.rows_mut().into_iter().enumerate() { + let gi = base + local; + for acc in &partials { + let arow = acc.row(gi); + for (o, &a) in orow.iter_mut().zip(arow.iter()) { + *o += a; + } + } + } + }); + } +} + +/// `y = lhs · x` where `lhs` is CSR. Write-disjoint. +pub fn gather_mul_vec(lhs: CsMatViewI, x: &[T], y: &mut [T]) +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + assert!(lhs.is_csr(), "gather_mul_vec requires CSR storage"); + assert_eq!(lhs.cols(), x.len(), "gather_mul_vec: lhs.cols != x.len"); + assert_eq!(lhs.rows(), y.len(), "gather_mul_vec: lhs.rows != y.len"); + + let dot = |i: usize| -> T { + let Some(row) = lhs.outer_view(i) else { + return T::zero(); + }; + let mut sum = T::zero(); + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + sum += v * x[j.index()]; + } + sum + }; + + if y.len() <= SERIAL_ELEMS { + for (i, yi) in y.iter_mut().enumerate() { + *yi = dot(i); + } + return; + } + let chunk = y.len().div_ceil(threads() * 4).max(1); + y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| { + let base = ci * chunk; + for (local, yi) in blk.iter_mut().enumerate() { + *yi = dot(base + local); + } + }); +} + +/// `y = lhsᵀ · x` where `lhs` is CSR. Scatter direction, one accumulator per thread. +pub fn scatter_mul_vec(lhs: CsMatViewI, x: &[T], y: &mut [T]) +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + assert!(lhs.is_csr(), "scatter_mul_vec requires CSR storage"); + assert_eq!(lhs.rows(), x.len(), "scatter_mul_vec: lhs.rows != x.len"); + assert_eq!(lhs.cols(), y.len(), "scatter_mul_vec: lhs.cols != y.len"); + + let (m, n) = (lhs.rows(), lhs.cols()); + y.fill(T::zero()); + if m == 0 || n == 0 { + return; + } + + let p = threads(); + // A single vector's worth of accumulator is p * n scalars; only worth splitting + // when there is enough work to pay for the reduction. + if p == 1 || lhs.nnz() <= SERIAL_ELEMS { + for i in 0..m { + let Some(row) = lhs.outer_view(i) else { + continue; + }; + let xi = x[i]; + if xi.is_zero() { + continue; + } + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + y[j.index()] += v * xi; + } + } + return; + } + + let bounds = nnz_balanced_split(&lhs, p); + let partials: Vec> = (0..p) + .into_par_iter() + .map(|t| { + let (lo, hi) = (bounds[t], bounds[t + 1]); + let mut acc = vec![T::zero(); n]; + for i in lo..hi { + let Some(row) = lhs.outer_view(i) else { + continue; + }; + let xi = x[i]; + if xi.is_zero() { + continue; + } + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + acc[j.index()] += v * xi; + } + } + acc + }) + .collect(); + + let chunk = n.div_ceil(p * 4).max(1); + y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| { + let base = ci * chunk; + for (local, yi) in blk.iter_mut().enumerate() { + let gi = base + local; + for acc in &partials { + *yi += acc[gi]; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use ndarray::{arr2, Array2}; + use sprs::{CsMatI, TriMatI}; + + fn tiny() -> CsMatI { + // [ 1 0 2 ] + // [ 0 3 0 ] + // [ 4 0 5 ] + // [ 0 6 0 ] + let mut t = TriMatI::::new((4, 3)); + t.add_triplet(0, 0, 1.0); + t.add_triplet(0, 2, 2.0); + t.add_triplet(1, 1, 3.0); + t.add_triplet(2, 0, 4.0); + t.add_triplet(2, 2, 5.0); + t.add_triplet(3, 1, 6.0); + t.to_csr::() + } + + use crate::testing::dense_of; + + #[test] + fn gather_matches_dense() { + let a = tiny(); + let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]); + let mut out = Array2::zeros((4, 2)); + gather_mul(a.view(), rhs.view(), out.view_mut()); + let expect = dense_of(&a).dot(&rhs); + assert_eq!(out, expect); + } + + #[test] + fn scatter_matches_dense() { + let a = tiny(); + let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]); + let mut out = Array2::zeros((3, 2)); + scatter_mul(a.view(), rhs.view(), out.view_mut(), DEFAULT_SCRATCH_BUDGET); + let expect = dense_of(&a).t().dot(&rhs); + assert_eq!(out, expect); + } + + /// A budget of 0 forces `kb == 1`, exercising the multi-pass path. + #[test] + fn scatter_column_blocking_matches_single_pass() { + let a = tiny(); + let rhs = arr2(&[ + [1.0, 2.0, 9.0], + [3.0, 4.0, 8.0], + [5.0, 6.0, 7.0], + [7.0, 8.0, 6.0], + ]); + let mut wide = Array2::zeros((3, 3)); + let mut narrow = Array2::zeros((3, 3)); + scatter_mul(a.view(), rhs.view(), wide.view_mut(), usize::MAX); + scatter_mul(a.view(), rhs.view(), narrow.view_mut(), 0); + assert_eq!(wide, narrow); + assert_eq!(wide, dense_of(&a).t().dot(&rhs)); + } + + #[test] + fn matvecs_match_dense() { + let a = tiny(); + let d = dense_of(&a); + let x = vec![1.0, 2.0, 3.0]; + let mut y = vec![0.0; 4]; + gather_mul_vec(a.view(), &x, &mut y); + assert_eq!(y, d.dot(&ndarray::arr1(&x)).to_vec()); + + let xt = vec![1.0, 2.0, 3.0, 4.0]; + let mut yt = vec![0.0; 3]; + scatter_mul_vec(a.view(), &xt, &mut yt); + assert_eq!(yt, d.t().dot(&ndarray::arr1(&xt)).to_vec()); + } + + #[test] + fn nnz_split_covers_all_rows_and_is_monotone() { + let a = tiny(); + for p in 1..=8 { + let b = nnz_balanced_split(&a.view(), p); + assert_eq!(b.len(), p + 1); + assert_eq!(b[0], 0); + assert_eq!(*b.last().unwrap(), a.rows()); + assert!(b.windows(2).all(|w| w[0] <= w[1]), "not monotone: {b:?}"); + } + } + + /// A matrix whose non-zeros are concentrated in one row: row-count splitting + /// would put all the work on a single thread. + #[test] + fn nnz_split_handles_skew() { + let mut t = TriMatI::::new((100, 50)); + for j in 0..50 { + t.add_triplet(0, j, 1.0); + } + for i in 1..100 { + t.add_triplet(i, 0, 1.0); + } + let a: CsMatI = t.to_csr(); + let b = nnz_balanced_split(&a.view(), 4); + assert_eq!(b[0], 0); + assert_eq!(*b.last().unwrap(), 100); + assert!(b.windows(2).all(|w| w[0] <= w[1])); + // The heavy first row must be isolated into the first partition. + assert_eq!( + b[1], 1, + "expected the 50-nnz row to form its own partition: {b:?}" + ); + } +} diff --git a/src/matrix/masked.rs b/src/matrix/masked.rs new file mode 100644 index 0000000..ec4361b --- /dev/null +++ b/src/matrix/masked.rs @@ -0,0 +1,890 @@ +//! A submatrix view over a sparse matrix. +//! +//! Selects rows, columns, or both, and presents the result as a matrix in its own right +//! — the solvers see only the selected entries. Nothing is copied, nothing is +//! reindexed, and the underlying matrix is never modified. +//! +//! # Cost +//! +//! Row selection is free: the matrix is CSR, so a row is an outer index and skipping one +//! means not visiting it. Masking rows makes every product *cheaper* in proportion to +//! what was dropped. +//! +//! Column selection costs one lookup per non-zero visited, against a dense +//! `original -> masked` table of `cols()` entries. That table is the only allocation the +//! view makes beyond the index lists themselves. + +use super::{apply_centering, SparseMat, SparseMatDense}; +use crate::types::SvdFloat; +use ndarray::{Array1, ArrayView1, ArrayView2, ArrayViewMut2, Axis}; +use rayon::prelude::*; +use sprs::{CsMatI, SpIndex}; + +/// Sentinel for "this column is not in the mask". +/// +/// A sentinel rather than `Option` halves the lookup table and keeps the inner +/// loop branch-light. +const EXCLUDED: usize = usize::MAX; + +/// A view exposing a subset of the rows and/or columns of a CSR matrix. +/// +/// Both selections keep ascending original order, so masked index `i` is original index +/// `selected_rows()[i]` (resp. `selected_columns()[i]`). +/// +/// ``` +/// use single_svdlib::{sprs::TriMatI, MaskedCsMat, SparseMat, SvdMat}; +/// +/// let mut t = TriMatI::::new((6, 5)); +/// for i in 0..6 { for j in 0..5 { t.add_triplet(i, j, (i * 5 + j) as f64); } } +/// let a: SvdMat = t.to_csr::(); +/// +/// // Cells 0, 2, 4 by genes 1, 3 — a 3x2 matrix, without touching `a`. +/// let view = MaskedCsMat::submatrix(&a, Some(&[0, 2, 4]), Some(&[1, 3])); +/// assert_eq!((view.rows(), view.cols()), (3, 2)); +/// ``` +pub struct MaskedCsMat<'a, T, I = u32, Iptr = u64> +where + I: SpIndex, + Iptr: SpIndex, +{ + matrix: &'a CsMatI, + /// Selected original row indices, ascending. `None` means every row. + rows: Option>, + /// Selected original column indices, ascending. `None` means every column. + cols: Option>, + /// Original column -> masked column. Empty when no column mask is in force. + col_to_masked: Vec, + nnz: usize, +} + +impl<'a, T, I, Iptr> MaskedCsMat<'a, T, I, Iptr> +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + /// A view of the given rows and/or columns. `None` keeps that axis whole. + /// + /// Index lists may be in any order and may repeat; the view presents each selected + /// index once, ascending. + /// + /// # Panics + /// If any index is out of bounds, or the matrix is not CSR. + pub fn submatrix( + matrix: &'a CsMatI, + rows: Option<&[usize]>, + cols: Option<&[usize]>, + ) -> Self { + assert!(matrix.is_csr(), "MaskedCsMat requires a CSR matrix"); + + let rows = rows.map(|r| Self::normalise(r, matrix.rows(), "row")); + let cols = cols.map(|c| Self::normalise(c, matrix.cols(), "column")); + + let col_to_masked = match &cols { + Some(c) => { + let mut table = vec![EXCLUDED; matrix.cols()]; + for (masked, &orig) in c.iter().enumerate() { + table[orig] = masked; + } + table + } + None => Vec::new(), + }; + + let mut view = Self { + matrix, + rows, + cols, + col_to_masked, + nnz: 0, + }; + view.nnz = view.count_nnz(); + view + } + + /// A view of the given columns, every row. + pub fn with_columns(matrix: &'a CsMatI, columns: &[usize]) -> Self { + Self::submatrix(matrix, None, Some(columns)) + } + + /// A view of the given rows, every column. + pub fn with_rows(matrix: &'a CsMatI, rows: &[usize]) -> Self { + Self::submatrix(matrix, Some(rows), None) + } + + /// A view built from boolean masks, one entry per original row/column. + /// + /// # Panics + /// If a mask's length does not match the corresponding dimension. + pub fn from_masks( + matrix: &'a CsMatI, + row_mask: Option<&[bool]>, + col_mask: Option<&[bool]>, + ) -> Self { + let to_indices = |mask: &[bool], n: usize, what: &str| { + assert_eq!( + mask.len(), + n, + "{what} mask has length {} but the matrix has {n} {what}s", + mask.len() + ); + mask.iter() + .enumerate() + .filter_map(|(i, &keep)| keep.then_some(i)) + .collect::>() + }; + let r = row_mask.map(|m| to_indices(m, matrix.rows(), "row")); + let c = col_mask.map(|m| to_indices(m, matrix.cols(), "column")); + Self::submatrix(matrix, r.as_deref(), c.as_deref()) + } + + /// Sort, deduplicate and bounds-check a selection. + fn normalise(indices: &[usize], limit: usize, what: &str) -> Vec { + let mut v = indices.to_vec(); + v.sort_unstable(); + v.dedup(); + if let Some(&last) = v.last() { + assert!( + last < limit, + "{what} index {last} is out of bounds ({limit})" + ); + } + v + } + + fn count_nnz(&self) -> usize { + let masked = !self.col_to_masked.is_empty(); + (0..self.rows_len()) + .into_par_iter() + .map(|i| { + let orig = self.row_of(i); + self.matrix.outer_view(orig).map_or(0, |row| { + if masked { + row.indices() + .iter() + .filter(|j| self.col_to_masked[j.index()] != EXCLUDED) + .count() + } else { + row.nnz() + } + }) + }) + .sum() + } + + #[inline] + fn rows_len(&self) -> usize { + self.rows.as_ref().map_or(self.matrix.rows(), |r| r.len()) + } + + /// Original row index for masked row `i`. + #[inline] + fn row_of(&self, i: usize) -> usize { + match &self.rows { + Some(r) => r[i], + None => i, + } + } + + /// Masked column index for original column `j`, or [`EXCLUDED`]. + #[inline] + fn masked_col(&self, j: usize) -> usize { + if self.col_to_masked.is_empty() { + j + } else { + self.col_to_masked[j] + } + } + + /// Selected original row indices, ascending. Empty slice when every row is kept. + pub fn selected_rows(&self) -> Option<&[usize]> { + self.rows.as_deref() + } + + /// Selected original column indices, ascending. `None` when every column is kept. + pub fn selected_columns(&self) -> Option<&[usize]> { + self.cols.as_deref() + } + + /// Whether the view is the identity, in which case products delegate straight to + /// the underlying matrix. + pub fn is_identity(&self) -> bool { + self.rows.is_none() && self.cols.is_none() + } + + /// Whether every column is retained. + pub fn uses_all_columns(&self) -> bool { + self.cols.is_none() + } + + /// The matrix being viewed. + pub fn inner(&self) -> &'a CsMatI { + self.matrix + } + + /// Copy the view into an owned sparse matrix. Still sparse — a subset copy, not a + /// densification — and the source is untouched. + /// + /// A view doesn't make products cheaper: each one still walks every source non-zero + /// and checks it against the column table. Since a solver issues hundreds of + /// products, a restrictive mask usually repays the one-off `O(nnz)` copy almost + /// immediately. Stay with the view if the mask keeps most columns, if the copy + /// wouldn't fit alongside the source, or if you only need a few products. + /// + /// # Panics + /// If the extracted index range would overflow the index type `I`. + pub fn to_sparse(&self) -> CsMatI { + let (m, n) = (self.rows(), self.cols()); + let mut indptr: Vec = Vec::with_capacity(m + 1); + let mut indices: Vec = Vec::with_capacity(self.nnz); + let mut data: Vec = Vec::with_capacity(self.nnz); + + indptr.push(Iptr::from_usize(0)); + for i in 0..m { + if let Some(row) = self.matrix.outer_view(self.row_of(i)) { + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c != EXCLUDED { + // Source indices ascend and the column table is order-preserving, + // so the extracted indices ascend too — CSR's invariant holds + // without a sort. + indices.push(I::from_usize(c)); + data.push(v); + } + } + } + indptr.push(Iptr::from_usize(indices.len())); + } + + CsMatI::new((m, n), indptr, indices, data) + } +} + +impl SparseMat for MaskedCsMat<'_, T, I, Iptr> +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + fn rows(&self) -> usize { + self.rows_len() + } + fn cols(&self) -> usize { + self.cols.as_ref().map_or(self.matrix.cols(), |c| c.len()) + } + fn nnz(&self) -> usize { + self.nnz + } + + /// Only the selected entries, so the norm describes the view and not the matrix it + /// borrows from. + fn squared_frobenius(&self) -> f64 { + if self.is_identity() { + return SparseMat::squared_frobenius(self.matrix); + } + let masked = !self.col_to_masked.is_empty(); + (0..self.rows_len()) + .into_par_iter() + .map(|i| { + let orig = self.row_of(i); + self.matrix.outer_view(orig).map_or(0.0, |row| { + row.iter() + .filter(|(j, _)| !masked || self.col_to_masked[j.index()] != EXCLUDED) + .map(|(_, &v)| { + let x = v.to_f64(); + x * x + }) + .sum() + }) + }) + .sum() + } + + /// Selected entries only, summed per entry so a column offset can't cancel it away. + fn centered_squared_frobenius(&self, means: ArrayView1) -> f64 { + assert_eq!( + means.len(), + SparseMat::cols(self), + "centered_squared_frobenius: means must have length cols()" + ); + if self.is_identity() { + return SparseMat::centered_squared_frobenius(self.matrix, means); + } + let rows = self.rows_len(); + let ncols = SparseMat::cols(self); + let masked = !self.col_to_masked.is_empty(); + + // Sums and counts in one pass; splitting them would walk the view twice. + let (stored, col_nnz) = (0..rows) + .into_par_iter() + .fold( + || (0.0f64, vec![0usize; ncols]), + |(mut acc, mut cnt), i| { + if let Some(row) = self.matrix.outer_view(self.row_of(i)) { + for (j, &v) in row.iter() { + let c = if masked { + self.col_to_masked[j.index()] + } else { + j.index() + }; + if c == EXCLUDED { + continue; + } + let e = v.to_f64() - means[c].to_f64(); + acc += e * e; + cnt[c] += 1; + } + } + (acc, cnt) + }, + ) + .reduce( + || (0.0f64, vec![0usize; ncols]), + |(a1, mut c1), (a2, c2)| { + for (x, y) in c1.iter_mut().zip(c2) { + *x += y; + } + (a1 + a2, c1) + }, + ); + + super::centered_from_parts(stored, &col_nnz, means, rows) + } + + fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) { + // 1.x delegated to the *unmasked* matrix whenever the matrix was small, + // regardless of the mask, which fed a masked-width vector to a full-width + // product. Delegation is only ever valid when the view is the identity. + if self.is_identity() { + return SparseMat::mul_vec(self.matrix, x, y, trans); + } + + let (m, n) = (self.rows(), self.cols()); + if trans { + assert_eq!(x.len(), m, "mul_vec: x must have length rows()"); + assert_eq!(y.len(), n, "mul_vec: y must have length cols()"); + y.fill(T::zero()); + + let p = rayon::current_num_threads().max(1); + if p == 1 || self.nnz <= (8 << 10) { + for i in 0..m { + let xi = x[i]; + if xi.is_zero() { + continue; + } + let Some(row) = self.matrix.outer_view(self.row_of(i)) else { + continue; + }; + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c != EXCLUDED { + y[c] += v * xi; + } + } + } + return; + } + // Scatter direction: one accumulator per thread over the masked width. + let chunk = m.div_ceil(p).max(1); + let partials: Vec> = (0..p) + .into_par_iter() + .map(|t| { + let lo = (t * chunk).min(m); + let hi = ((t + 1) * chunk).min(m); + let mut acc = vec![T::zero(); n]; + for i in lo..hi { + let xi = x[i]; + if xi.is_zero() { + continue; + } + let Some(row) = self.matrix.outer_view(self.row_of(i)) else { + continue; + }; + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c != EXCLUDED { + acc[c] += v * xi; + } + } + } + acc + }) + .collect(); + for acc in &partials { + for (yi, &a) in y.iter_mut().zip(acc.iter()) { + *yi += a; + } + } + } else { + assert_eq!(x.len(), n, "mul_vec: x must have length cols()"); + assert_eq!(y.len(), m, "mul_vec: y must have length rows()"); + // Gather direction: each output entry is one row's dot product, so masked + // rows are simply never visited. + let dot = |i: usize| -> T { + let Some(row) = self.matrix.outer_view(self.row_of(i)) else { + return T::zero(); + }; + let mut sum = T::zero(); + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c != EXCLUDED { + sum += v * x[c]; + } + } + sum + }; + let chunk = m.div_ceil(rayon::current_num_threads().max(1) * 4).max(1); + y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| { + let base = ci * chunk; + for (local, yi) in blk.iter_mut().enumerate() { + *yi = dot(base + local); + } + }); + } + } +} + +impl SparseMatDense for MaskedCsMat<'_, T, I, Iptr> +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + fn mul_dense(&self, rhs: ArrayView2, mut out: ArrayViewMut2, trans: bool) { + if self.is_identity() { + return SparseMatDense::mul_dense(self.matrix, rhs, out, trans); + } + + let (m, n, k) = (self.rows(), self.cols(), rhs.ncols()); + if trans { + assert_eq!(rhs.nrows(), m, "mul_dense: rhs.rows != rows()"); + assert_eq!(out.nrows(), n, "mul_dense: out.rows != cols()"); + } else { + assert_eq!(rhs.nrows(), n, "mul_dense: rhs.rows != cols()"); + assert_eq!(out.nrows(), m, "mul_dense: out.rows != rows()"); + } + assert_eq!(out.ncols(), k, "mul_dense: out.cols != rhs.cols"); + + if !trans { + // Write-disjoint over output rows. + let chunk = m.div_ceil(rayon::current_num_threads().max(1) * 4).max(1); + out.axis_chunks_iter_mut(Axis(0), chunk) + .into_par_iter() + .enumerate() + .for_each(|(ci, mut block)| { + let base = ci * chunk; + for (local, mut orow) in block.rows_mut().into_iter().enumerate() { + orow.fill(T::zero()); + let Some(row) = self.matrix.outer_view(self.row_of(base + local)) else { + continue; + }; + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c == EXCLUDED { + continue; + } + let rrow = rhs.row(c); + for (o, &r) in orow.iter_mut().zip(rrow.iter()) { + *o += v * r; + } + } + } + }); + } else { + // Scatter direction, one accumulator per thread over the masked width. + out.fill(T::zero()); + let p = rayon::current_num_threads().max(1); + let chunk = m.div_ceil(p).max(1); + let partials: Vec> = (0..p) + .into_par_iter() + .map(|t| { + let lo = (t * chunk).min(m); + let hi = ((t + 1) * chunk).min(m); + let mut acc = ndarray::Array2::::zeros((n, k)); + for i in lo..hi { + let Some(row) = self.matrix.outer_view(self.row_of(i)) else { + continue; + }; + let rrow = rhs.row(i); + for (j, &v) in row.indices().iter().zip(row.data().iter()) { + let c = self.masked_col(j.index()); + if c == EXCLUDED { + continue; + } + let mut arow = acc.row_mut(c); + for (a, &r) in arow.iter_mut().zip(rrow.iter()) { + *a += v * r; + } + } + } + acc + }) + .collect(); + for acc in &partials { + for (mut orow, arow) in out.rows_mut().into_iter().zip(acc.rows()) { + for (o, &a) in orow.iter_mut().zip(arow.iter()) { + *o += a; + } + } + } + } + } + + /// Column means **of the view** — averaged over the selected rows only, so PCA on a + /// row subset centers on that subset's means rather than the whole matrix's. + fn col_means(&self) -> Array1 { + let m = self.rows(); + let ones = vec![T::one(); m]; + let mut sums = vec![T::zero(); self.cols()]; + self.mul_vec(&ones, &mut sums, true); + let scale = if m == 0 { + T::zero() + } else { + T::one() / T::from_f64_val(m as f64) + }; + Array1::from_vec(sums) * scale + } + + fn mul_dense_centered( + &self, + rhs: ArrayView2, + mut out: ArrayViewMut2, + trans: bool, + means: ArrayView1, + ) { + assert_eq!( + means.len(), + self.cols(), + "mul_dense_centered: means must have length cols() (the masked width)" + ); + self.mul_dense(rhs, out.view_mut(), trans); + apply_centering(rhs, out, trans, means); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{dense_of, gen_sparse}; + use ndarray::{Array2, Axis}; + use sprs::TriMatI; + + fn sample() -> CsMatI { + // 3 x 5 + // [1 0 2 0 3] + // [0 4 0 5 0] + // [6 0 7 0 8] + let mut t = TriMatI::::new((3, 5)); + for &(i, j, v) in &[ + (0, 0, 1.0), + (0, 2, 2.0), + (0, 4, 3.0), + (1, 1, 4.0), + (1, 3, 5.0), + (2, 0, 6.0), + (2, 2, 7.0), + (2, 4, 8.0), + ] { + t.add_triplet(i, j, v); + } + t.to_csr::() + } + + /// The dense submatrix a view is supposed to emulate. + fn physical(m: &CsMatI, rows: &[usize], cols: &[usize]) -> Array2 { + let full = dense_of(m); + let mut d = Array2::zeros((rows.len(), cols.len())); + for (ri, &r) in rows.iter().enumerate() { + for (ci, &c) in cols.iter().enumerate() { + d[[ri, ci]] = full[[r, c]]; + } + } + d + } + + fn check_against_physical(view: &MaskedCsMat, want: &Array2) { + assert_eq!((view.rows(), view.cols()), want.dim(), "shape"); + + let x: Vec = (0..view.cols()).map(|i| (i % 5) as f64 - 2.0).collect(); + let mut y = vec![0.0; view.rows()]; + view.mul_vec(&x, &mut y, false); + let expect = want.dot(&ndarray::Array1::from_vec(x.clone())); + for (g, w) in y.iter().zip(expect.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12); + } + + let xt: Vec = (0..view.rows()).map(|i| (i % 3) as f64 - 1.0).collect(); + let mut yt = vec![0.0; view.cols()]; + view.mul_vec(&xt, &mut yt, true); + let expect_t = want.t().dot(&ndarray::Array1::from_vec(xt.clone())); + for (g, w) in yt.iter().zip(expect_t.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12); + } + + // Blocked products, both directions. + let rhs = Array2::from_shape_fn((view.cols(), 2), |(i, j)| (i + 2 * j) as f64 - 1.0); + let mut out = Array2::zeros((view.rows(), 2)); + view.mul_dense(rhs.view(), out.view_mut(), false); + let want_out = want.dot(&rhs); + for (g, w) in out.iter().zip(want_out.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12); + } + + let rhs_t = Array2::from_shape_fn((view.rows(), 2), |(i, j)| (2 * i + j) as f64 - 2.0); + let mut out_t = Array2::zeros((view.cols(), 2)); + view.mul_dense(rhs_t.view(), out_t.view_mut(), true); + let want_t = want.t().dot(&rhs_t); + for (g, w) in out_t.iter().zip(want_t.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12); + } + } + + #[test] + fn column_selection_matches_physical_subset() { + let a = sample(); + let cols = [0usize, 2, 4]; + let all_rows: Vec = (0..3).collect(); + check_against_physical( + &MaskedCsMat::with_columns(&a, &cols), + &physical(&a, &all_rows, &cols), + ); + } + + #[test] + fn row_selection_matches_physical_subset() { + let a = sample(); + let rows = [0usize, 2]; + let all_cols: Vec = (0..5).collect(); + let view = MaskedCsMat::with_rows(&a, &rows); + assert_eq!(view.nnz(), 6, "only the two selected rows' non-zeros count"); + check_against_physical(&view, &physical(&a, &rows, &all_cols)); + } + + #[test] + fn combined_selection_matches_physical_subset() { + let a = sample(); + let rows = [0usize, 2]; + let cols = [1usize, 2, 4]; + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + assert_eq!((view.rows(), view.cols()), (2, 3)); + check_against_physical(&view, &physical(&a, &rows, &cols)); + } + + #[test] + fn selections_are_sorted_and_deduplicated() { + let a = sample(); + let view = MaskedCsMat::submatrix(&a, Some(&[2, 0, 2]), Some(&[4, 0, 4, 0])); + assert_eq!(view.selected_rows().unwrap(), &[0, 2]); + assert_eq!(view.selected_columns().unwrap(), &[0, 4]); + check_against_physical(&view, &physical(&a, &[0, 2], &[0, 4])); + } + + #[test] + fn boolean_masks_agree_with_index_lists() { + let a = sample(); + let by_mask = MaskedCsMat::from_masks( + &a, + Some(&[true, false, true]), + Some(&[false, true, false, true, false]), + ); + let by_index = MaskedCsMat::submatrix(&a, Some(&[0, 2]), Some(&[1, 3])); + assert_eq!(by_mask.selected_rows(), by_index.selected_rows()); + assert_eq!(by_mask.selected_columns(), by_index.selected_columns()); + assert_eq!(by_mask.nnz(), by_index.nnz()); + } + + /// Regression for the 1.x fast path: a *small* masked matrix used to delegate to the + /// unmasked product and panic on the length assert. + #[test] + fn small_masked_matrix_does_not_delegate() { + let a = sample(); + let cols = [0usize, 2, 4]; + let view = MaskedCsMat::with_columns(&a, &cols); + let want = physical(&a, &[0, 1, 2], &cols); + let x = [1.0, 2.0, 3.0]; + let mut y = vec![0.0; 3]; + view.mul_vec(&x, &mut y, false); + assert_eq!(y, want.dot(&ndarray::arr1(&x)).to_vec()); + } + + #[test] + fn identity_view_matches_unmasked() { + let a = sample(); + let view = MaskedCsMat::submatrix(&a, None, None); + assert!(view.is_identity()); + assert_eq!(view.nnz(), a.nnz()); + let x = [1.0, 2.0, 3.0, 4.0, 5.0]; + let mut ym = vec![0.0; 3]; + let mut yu = vec![0.0; 3]; + view.mul_vec(&x, &mut ym, false); + SparseMat::mul_vec(&a, &x, &mut yu, false); + assert_eq!(ym, yu); + } + + /// Means must be taken over the *selected* rows, so PCA on a row subset centers on + /// that subset. + #[test] + fn col_means_respect_the_row_selection() { + let a = sample(); + let rows = [0usize, 2]; + let cols = [0usize, 2, 4]; + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + let want = physical(&a, &rows, &cols); + + let got = view.col_means(); + let expect = want.mean_axis(Axis(0)).unwrap(); + for (g, w) in got.iter().zip(expect.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12); + } + + // And centering must match explicitly centering that submatrix. + let centered = &want - &expect.view().insert_axis(Axis(0)); + let rhs = ndarray::arr2(&[[1.0], [2.0], [3.0]]); + let mut out = Array2::zeros((2, 1)); + view.mul_dense_centered(rhs.view(), out.view_mut(), false, got.view()); + let want_out = centered.dot(&rhs); + for (g, w) in out.iter().zip(want_out.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12); + } + } + + /// The norm must describe the view, not its source — otherwise a masked PCA's + /// explained-variance ratios come out silently too small. + #[test] + fn squared_frobenius_counts_only_selected_entries() { + let a = gen_sparse(120, 40, 0.1, 91); + let rows: Vec = (0..120).filter(|r| r % 4 != 0).collect(); + let cols: Vec = (0..40).filter(|c| c % 3 == 0).collect(); + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + + let want: f64 = physical(&a, &rows, &cols).iter().map(|&v| v * v).sum(); + approx::assert_relative_eq!(view.squared_frobenius(), want, max_relative = 1e-12); + + // Strictly less than the source, and equal to it once nothing is masked out. + assert!(view.squared_frobenius() < SparseMat::squared_frobenius(&a)); + let identity = MaskedCsMat::submatrix(&a, None, None); + approx::assert_relative_eq!( + identity.squared_frobenius(), + SparseMat::squared_frobenius(&a), + max_relative = 1e-14 + ); + // The extraction is the same matrix, so it must report the same norm. + approx::assert_relative_eq!( + view.to_sparse().squared_frobenius(), + want, + max_relative = 1e-12 + ); + } + + /// Same for the centered norm, and summed per entry so an offset can't cancel it. + #[test] + fn centered_squared_frobenius_matches_the_selected_submatrix() { + let a = gen_sparse(120, 40, 0.2, 77); + let rows: Vec = (0..120).filter(|r| r % 4 != 0).collect(); + let cols: Vec = (0..40).filter(|c| c % 3 == 0).collect(); + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + + let sub = physical(&a, &rows, &cols); + let means = view.col_means(); + let centered = &sub - &means.view().insert_axis(Axis(0)); + let want: f64 = centered.iter().map(|&v| v * v).sum(); + + approx::assert_relative_eq!( + view.centered_squared_frobenius(means.view()), + want, + max_relative = 1e-10 + ); + // The extraction is the same matrix, so it must agree. + approx::assert_relative_eq!( + SparseMat::centered_squared_frobenius(&view.to_sparse(), means.view()), + want, + max_relative = 1e-10 + ); + // A view that selects everything must match the source exactly. + let identity = MaskedCsMat::submatrix(&a, None, None); + let full_means = identity.col_means(); + approx::assert_relative_eq!( + identity.centered_squared_frobenius(full_means.view()), + SparseMat::centered_squared_frobenius(&a, full_means.view()), + max_relative = 1e-12 + ); + } + + /// The extracted submatrix must be indistinguishable from the view. + #[test] + fn to_sparse_matches_the_view() { + let a = gen_sparse(120, 40, 0.1, 23); + let rows: Vec = (0..120).filter(|r| r % 4 != 0).collect(); + let cols: Vec = (0..40).filter(|c| c % 3 == 0).collect(); + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + let extracted = view.to_sparse(); + + assert_eq!(extracted.rows(), view.rows()); + assert_eq!(extracted.cols(), view.cols()); + assert_eq!(extracted.nnz(), view.nnz()); + assert!(extracted.is_csr()); + // Extraction must preserve CSR's ascending-index invariant. + for i in 0..extracted.rows() { + if let Some(row) = extracted.outer_view(i) { + let idx = row.indices(); + assert!( + idx.windows(2).all(|w| w[0] < w[1]), + "row {i} indices not sorted" + ); + } + } + + assert_eq!(dense_of(&extracted), physical(&a, &rows, &cols)); + + // And every product agrees. + let x: Vec = (0..view.cols()).map(|i| (i % 7) as f64 - 3.0).collect(); + let (mut yv, mut ye) = (vec![0.0; view.rows()], vec![0.0; view.rows()]); + view.mul_vec(&x, &mut yv, false); + SparseMat::mul_vec(&extracted, &x, &mut ye, false); + assert_eq!(yv, ye); + } + + /// A decomposition must not care which representation it was handed. + #[test] + fn pca_agrees_between_view_and_extraction() { + let a = gen_sparse(300, 50, 0.12, 29); + let cols: Vec = (0..50).filter(|c| c % 2 == 0).collect(); + let view = MaskedCsMat::with_columns(&a, &cols); + let extracted = view.to_sparse(); + + let by_view = crate::irlba::svd_centered(&view, 6, Some(42)).unwrap(); + let by_copy = crate::irlba::svd_centered(&extracted, 6, Some(42)).unwrap(); + for (x, y) in by_view.s.iter().zip(by_copy.s.iter()) { + approx::assert_relative_eq!(x, y, max_relative = 1e-9); + } + } + + #[test] + fn empty_selections() { + let a = sample(); + let no_cols = MaskedCsMat::submatrix(&a, None, Some(&[])); + assert_eq!(no_cols.cols(), 0); + assert_eq!(no_cols.nnz(), 0); + + let no_rows = MaskedCsMat::submatrix(&a, Some(&[]), None); + assert_eq!(no_rows.rows(), 0); + assert_eq!(no_rows.nnz(), 0); + } + + #[test] + #[should_panic(expected = "out of bounds")] + fn rejects_out_of_range_rows() { + let a = sample(); + let _ = MaskedCsMat::with_rows(&a, &[0, 99]); + } + + /// Masking rows must not change the answer relative to physically extracting them. + #[test] + fn larger_random_submatrix() { + let a = gen_sparse(200, 60, 0.08, 17); + let rows: Vec = (0..200).filter(|r| r % 3 == 0).collect(); + let cols: Vec = (0..60).filter(|c| c % 2 == 1).collect(); + let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols)); + check_against_physical(&view, &physical(&a, &rows, &cols)); + } +} diff --git a/src/matrix/mod.rs b/src/matrix/mod.rs new file mode 100644 index 0000000..f4701a8 --- /dev/null +++ b/src/matrix/mod.rs @@ -0,0 +1,545 @@ +//! Sparse operands and the traits the solvers consume. +//! +//! # Index widths +//! +//! [`SvdMat`] defaults to `u32` column indices with `u64` row pointers. Against +//! `usize`-everywhere that is 12 bytes per non-zero instead of 16 for `f64` data +//! (8 instead of 16 for `f32`), and the separate pointer width keeps matrices with +//! more than `u32::MAX` non-zeros representable. Callers who need wider indices can +//! name them: `SvdMat`. + +pub mod kernels; +pub mod masked; + +use crate::types::SvdFloat; +use ndarray::{Array1, ArrayView1, ArrayView2, ArrayViewMut2}; +use rayon::prelude::*; +use sprs::{CsMatI, CsMatViewI, SpIndex}; + +pub use kernels::DEFAULT_SCRATCH_BUDGET; +pub use masked::MaskedCsMat; + +/// An owned sparse matrix. Defaults to `u32` indices and `u64` row pointers. +pub type SvdMat = CsMatI; +/// A borrowed sparse matrix. Defaults to `u32` indices and `u64` row pointers. +pub type SvdMatView<'a, T, I = u32, Iptr = u64> = CsMatViewI<'a, T, I, Iptr>; + +/// The operand interface the Krylov solvers ([`crate::lanczos`], [`crate::irlba`]) +/// need: shape and a matrix-vector product. +/// +/// In 1.x this trait also carried the four blocked-product methods, which meant every +/// built-in implementation left them as `todo!()` and the randomized solvers panicked +/// on all three stock matrix types. Those methods now live on [`SparseMatDense`], +/// which has working defaults, so the gap cannot reappear. +pub trait SparseMat: Sync { + fn rows(&self) -> usize; + fn cols(&self) -> usize; + fn nnz(&self) -> usize; + + /// `y = A·x` when `trans` is false, `y = Aᵀ·x` when true. + /// + /// `y` is fully overwritten. `x` must have length `cols()` (`rows()` when + /// transposed) and `y` length `rows()` (`cols()` when transposed). + fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool); + + /// `‖A‖²_F`. Accumulated in `f64` even for `f32` data — this sums every non-zero, + /// and an `f32` accumulator would drop the tail or overflow. + fn squared_frobenius(&self) -> f64; + + /// `‖A − 1·meansᵀ‖²_F`. + /// + /// The default is the closed form `‖A‖²_F − rows·Σⱼ meanⱼ²`, which needs no pass over + /// the matrix but cancels badly when columns are large against their own spread: on + /// `f32` columns of `offset + O(1)` noise it is 3e-8 wrong at offset 0 and **5e-1** + /// wrong at offset 1000. Fine for counts data, where most entries are zero. The + /// built-in types override it with a per-entry sum that never cancels; do the same if + /// your data carries an offset. + fn centered_squared_frobenius(&self, means: ArrayView1) -> f64 { + let shift: f64 = means.iter().map(|&m| m.to_f64() * m.to_f64()).sum(); + (self.squared_frobenius() - self.rows() as f64 * shift).max(0.0) + } +} + +/// `‖A‖²_F`, or the centered version when `means` is supplied. What the solvers store in +/// [`SvdRec::total_squared_norm`](crate::SvdRec). +pub fn total_squared_norm + ?Sized>( + a: &M, + means: Option>, +) -> f64 { + match means { + Some(m) => a.centered_squared_frobenius(m), + None => a.squared_frobenius(), + } +} + +/// `stored + Σⱼ (rows − nnzⱼ)·meanⱼ²`, where `stored` is `Σ (aᵢⱼ − meanⱼ)²` over the +/// entries that exist. Every unstored entry contributes `meanⱼ²`. +#[inline] +fn centered_from_parts( + stored: f64, + col_nnz: &[usize], + means: ArrayView1, + rows: usize, +) -> f64 { + let missing: f64 = col_nnz + .iter() + .zip(means.iter()) + .map(|(&n, &m)| { + let m = m.to_f64(); + (rows - n) as f64 * m * m + }) + .sum(); + (stored + missing).max(0.0) +} + +/// Blocked products, needed by the randomized solvers. +/// +/// [`mul_dense`](Self::mul_dense) has no default — an implementor must supply it — but +/// [`col_means`](Self::col_means) and +/// [`mul_dense_centered`](Self::mul_dense_centered) do, so mean-centering comes for +/// free once the plain product works. +pub trait SparseMatDense: SparseMat { + /// `out = A·rhs` when `trans` is false, `out = Aᵀ·rhs` when true. + /// + /// `out` is fully overwritten. + fn mul_dense(&self, rhs: ArrayView2, out: ArrayViewMut2, trans: bool); + + /// Column means, length `cols()`. + /// + /// The default computes `Aᵀ·1 / rows()`, which routes through whichever + /// [`mul_vec`](SparseMat::mul_vec) direction is cheapest for the storage order. + fn col_means(&self) -> Array1 { + let m = self.rows(); + let ones = vec![T::one(); m]; + let mut sums = vec![T::zero(); self.cols()]; + self.mul_vec(&ones, &mut sums, true); + let scale = if m == 0 { + T::zero() + } else { + T::one() / T::from_f64_val(m as f64) + }; + Array1::from_vec(sums) * scale + } + + /// The product against `A - 1·meansᵀ`, without ever forming it — centering a sparse + /// matrix would destroy its sparsity, so it goes in as the rank-1 update it is: + /// + /// - `trans == false`: `(A - 1·mᵀ)·D = A·D - 1·(mᵀ·D)` + /// - `trans == true`: `(A - 1·mᵀ)ᵀ·D = Aᵀ·D - m·(1ᵀ·D)` + /// + /// The correction is one length-`k` vector either way, so it adds + /// `O(k·(rows + cols))` and allocates nothing else. + fn mul_dense_centered( + &self, + rhs: ArrayView2, + mut out: ArrayViewMut2, + trans: bool, + means: ArrayView1, + ) { + assert_eq!( + means.len(), + self.cols(), + "mul_dense_centered: means must have length cols()" + ); + self.mul_dense(rhs, out.view_mut(), trans); + apply_centering(rhs, out, trans, means); + } +} + +/// Subtract the rank-1 centering term from an uncentered product. Public so an +/// implementor writing a fused `mul_dense_centered` can reuse it. +pub fn apply_centering( + rhs: ArrayView2, + mut out: ArrayViewMut2, + trans: bool, + means: ArrayView1, +) { + let k = rhs.ncols(); + if k == 0 { + return; + } + if !trans { + // corr[c] = Σ_j means[j] · rhs[j, c]; subtract from every output row. + debug_assert_eq!(rhs.nrows(), means.len()); + let mut corr = vec![T::zero(); k]; + for (j, &mj) in means.iter().enumerate() { + if mj.is_zero() { + continue; + } + for (c, cv) in corr.iter_mut().enumerate() { + *cv += mj * rhs[[j, c]]; + } + } + for mut orow in out.rows_mut() { + for (o, &c) in orow.iter_mut().zip(corr.iter()) { + *o -= c; + } + } + } else { + // colsum[c] = Σ_i rhs[i, c]; subtract means[j] · colsum[c] from out[j, c]. + let mut colsum = vec![T::zero(); k]; + for row in rhs.rows() { + for (c, cv) in colsum.iter_mut().enumerate() { + *cv += row[c]; + } + } + debug_assert_eq!(out.nrows(), means.len()); + for (j, mut orow) in out.rows_mut().into_iter().enumerate() { + let mj = means[j]; + if mj.is_zero() { + continue; + } + for (o, &cs) in orow.iter_mut().zip(colsum.iter()) { + *o -= mj * cs; + } + } + } +} + +/// A CSR view of any compressed matrix, plus a flag for whether the view is the +/// transpose. CSC is bit-for-bit the CSR of its own transpose, so `transpose_view()` +/// reaches it for free — every kernel is written once against CSR and the caller's +/// `trans` is XORed with the flag. +#[inline] +fn csr_view( + m: &CsMatI, +) -> (CsMatViewI<'_, T, I, Iptr>, bool) { + if m.is_csr() { + (m.view(), false) + } else { + (m.transpose_view(), true) + } +} + +impl SparseMat for CsMatI +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + fn rows(&self) -> usize { + CsMatI::rows(self) + } + fn cols(&self) -> usize { + CsMatI::cols(self) + } + fn nnz(&self) -> usize { + CsMatI::nnz(self) + } + + fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) { + let (view, flipped) = csr_view(self); + if trans ^ flipped { + kernels::scatter_mul_vec(view, x, y); + } else { + kernels::gather_mul_vec(view, x, y); + } + } + + fn squared_frobenius(&self) -> f64 { + // Storage order doesn't matter — every value appears once. + self.data() + .par_iter() + .map(|&v| { + let x = v.to_f64(); + x * x + }) + .sum() + } + + fn centered_squared_frobenius(&self, means: ArrayView1) -> f64 { + assert_eq!( + means.len(), + SparseMat::cols(self), + "centered_squared_frobenius: means must have length cols()" + ); + let rows = SparseMat::rows(self); + let cols = SparseMat::cols(self); + + if self.is_csc() { + // Outer dimension is the column, so the count comes for free. + let total: f64 = (0..cols) + .into_par_iter() + .map(|j| { + let m = means[j].to_f64(); + let (sum, n) = self.outer_view(j).map_or((0.0, 0), |col| { + ( + col.iter() + .map(|(_, &v)| { + let e = v.to_f64() - m; + e * e + }) + .sum::(), + col.nnz(), + ) + }); + sum + (rows - n) as f64 * m * m + }) + .sum(); + return total.max(0.0); + } + + // CSR: count per column first, then sum the entries. + let mut col_nnz = vec![0usize; cols]; + for j in self.indices() { + col_nnz[j.index()] += 1; + } + let stored: f64 = (0..rows) + .into_par_iter() + .map(|i| { + self.outer_view(i).map_or(0.0, |row| { + row.iter() + .map(|(j, &v)| { + let e = v.to_f64() - means[j].to_f64(); + e * e + }) + .sum() + }) + }) + .sum(); + centered_from_parts(stored, &col_nnz, means, rows) + } +} + +impl SparseMatDense for CsMatI +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + fn mul_dense(&self, rhs: ArrayView2, out: ArrayViewMut2, trans: bool) { + let (view, flipped) = csr_view(self); + if trans ^ flipped { + kernels::scatter_mul(view, rhs, out, DEFAULT_SCRATCH_BUDGET); + } else { + kernels::gather_mul(view, rhs, out); + } + } +} + +// Blanket forwarding so `&M` and `Arc` work wherever `M` does. +impl + ?Sized> SparseMat for &M { + fn rows(&self) -> usize { + (**self).rows() + } + fn cols(&self) -> usize { + (**self).cols() + } + fn nnz(&self) -> usize { + (**self).nnz() + } + fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) { + (**self).mul_vec(x, y, trans) + } + fn squared_frobenius(&self) -> f64 { + (**self).squared_frobenius() + } + fn centered_squared_frobenius(&self, means: ArrayView1) -> f64 { + (**self).centered_squared_frobenius(means) + } +} + +impl + ?Sized> SparseMatDense for &M { + fn mul_dense(&self, rhs: ArrayView2, out: ArrayViewMut2, trans: bool) { + (**self).mul_dense(rhs, out, trans) + } + fn col_means(&self) -> Array1 { + (**self).col_means() + } + fn mul_dense_centered( + &self, + rhs: ArrayView2, + out: ArrayViewMut2, + trans: bool, + means: ArrayView1, + ) { + (**self).mul_dense_centered(rhs, out, trans, means) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ndarray::{arr2, Array2}; + use sprs::TriMatI; + + fn tiny_csr() -> SvdMat { + let mut t = TriMatI::::new((4, 3)); + t.add_triplet(0, 0, 1.0); + t.add_triplet(0, 2, 2.0); + t.add_triplet(1, 1, 3.0); + t.add_triplet(2, 0, 4.0); + t.add_triplet(2, 2, 5.0); + t.add_triplet(3, 1, 6.0); + t.to_csr::() + } + + use crate::testing::dense_of; + + /// CSR and CSC hold the same matrix, so every product must agree — this is what + /// makes the `transpose_view` dispatch safe. + #[test] + fn csr_and_csc_agree() { + let csr = tiny_csr(); + let csc = csr.to_other_storage(); + assert!(csr.is_csr() && csc.is_csc()); + let d = dense_of(&csr); + + let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]); + let mut a = Array2::zeros((4, 2)); + let mut b = Array2::zeros((4, 2)); + SparseMatDense::mul_dense(&csr, rhs.view(), a.view_mut(), false); + SparseMatDense::mul_dense(&csc, rhs.view(), b.view_mut(), false); + assert_eq!(a, d.dot(&rhs)); + assert_eq!(b, d.dot(&rhs)); + + let rhs_t = arr2(&[[1.0], [2.0], [3.0], [4.0]]); + let mut at = Array2::zeros((3, 1)); + let mut bt = Array2::zeros((3, 1)); + SparseMatDense::mul_dense(&csr, rhs_t.view(), at.view_mut(), true); + SparseMatDense::mul_dense(&csc, rhs_t.view(), bt.view_mut(), true); + assert_eq!(at, d.t().dot(&rhs_t)); + assert_eq!(bt, d.t().dot(&rhs_t)); + } + + #[test] + fn col_means_match_dense() { + let a = tiny_csr(); + let d = dense_of(&a); + let got = SparseMatDense::col_means(&a); + let want = d.mean_axis(ndarray::Axis(0)).unwrap(); + for (g, w) in got.iter().zip(want.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-14); + } + // The CSC path must agree. + let got_csc = SparseMatDense::col_means(&a.to_other_storage()); + for (g, w) in got_csc.iter().zip(want.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-14); + } + } + + /// The rank-1 correction must equal explicitly forming the dense centered matrix. + #[test] + fn centering_matches_explicit_dense_centering() { + let a = tiny_csr(); + let d = dense_of(&a); + let means = SparseMatDense::col_means(&a); + let centered = &d - &means.view().insert_axis(ndarray::Axis(0)); + + let rhs = arr2(&[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]); + let mut out = Array2::zeros((4, 2)); + SparseMatDense::mul_dense_centered(&a, rhs.view(), out.view_mut(), false, means.view()); + let want = centered.dot(&rhs); + for (g, w) in out.iter().zip(want.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12); + } + + let rhs_t = arr2(&[[1.0, 0.5], [2.0, 1.5], [3.0, 2.5], [4.0, 3.5]]); + let mut out_t = Array2::zeros((3, 2)); + SparseMatDense::mul_dense_centered(&a, rhs_t.view(), out_t.view_mut(), true, means.view()); + let want_t = centered.t().dot(&rhs_t); + for (g, w) in out_t.iter().zip(want_t.iter()) { + approx::assert_relative_eq!(g, w, max_relative = 1e-12); + } + } + + #[test] + fn squared_frobenius_matches_dense_and_is_storage_agnostic() { + let a = tiny_csr(); + let d = dense_of(&a); + let want: f64 = d.iter().map(|&v| v * v).sum(); + approx::assert_relative_eq!(SparseMat::squared_frobenius(&a), want, max_relative = 1e-14); + // Every stored value appears once whichever way the matrix is compressed. + approx::assert_relative_eq!( + SparseMat::squared_frobenius(&a.to_other_storage()), + want, + max_relative = 1e-14 + ); + } + + /// Must equal building the centered matrix and summing it, in both storage orders. + #[test] + fn centered_squared_frobenius_matches_explicit_centering() { + let a = tiny_csr(); + let d = dense_of(&a); + let means = SparseMatDense::col_means(&a); + let centered = &d - &means.view().insert_axis(ndarray::Axis(0)); + let want: f64 = centered.iter().map(|&v| v * v).sum(); + + approx::assert_relative_eq!( + SparseMat::centered_squared_frobenius(&a, means.view()), + want, + max_relative = 1e-12 + ); + // CSC reaches the same answer by iterating columns instead of rows. + approx::assert_relative_eq!( + SparseMat::centered_squared_frobenius(&a.to_other_storage(), means.view()), + want, + max_relative = 1e-12 + ); + } + + /// Centering can only remove norm, never add it, and never take it below zero. + #[test] + fn centering_never_increases_the_norm() { + let a = tiny_csr(); + let means = SparseMatDense::col_means(&a); + let raw = SparseMat::squared_frobenius(&a); + let centered = SparseMat::centered_squared_frobenius(&a, means.view()); + assert!(centered >= 0.0); + assert!(centered <= raw); + } + + /// The closed form was 51% wrong at an `f32` offset of 1000. The per-entry form must + /// stay at machine precision and not degrade as the offset grows. + #[test] + fn centered_norm_survives_a_large_column_offset() { + for offset in [0.0f32, 10.0, 100.0, 1000.0] { + let (rows, cols) = (200usize, 20usize); + let mut t = sprs::TriMatI::::new((rows, cols)); + let mut dense = Array2::::zeros((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let v = offset + (((i * 7 + j * 3) % 11) as f32 - 5.0) * 0.1; + t.add_triplet(i, j, v); + dense[[i, j]] = v as f64; + } + } + let a: SvdMat = t.to_csr::(); + let means = SparseMatDense::col_means(&a); + + // The reference uses the same f32 means the solver would, so this measures + // the summation and nothing else. + let mut want = 0.0f64; + for i in 0..rows { + for j in 0..cols { + let e = dense[[i, j]] - means[j] as f64; + want += e * e; + } + } + + let got = SparseMat::centered_squared_frobenius(&a, means.view()); + let rel = (got - want).abs() / want; + assert!( + rel < 1e-6, + "offset {offset}: centered norm {got:.6e} vs {want:.6e} (rel {rel:.3e})" + ); + } + } + + #[test] + fn mul_vec_matches_dense_both_directions() { + let a = tiny_csr(); + let d = dense_of(&a); + let mut y = vec![0.0; 4]; + SparseMat::mul_vec(&a, &[1.0, 2.0, 3.0], &mut y, false); + assert_eq!(y, d.dot(&ndarray::arr1(&[1.0, 2.0, 3.0])).to_vec()); + + let mut yt = vec![0.0; 3]; + SparseMat::mul_vec(&a, &[1.0, 2.0, 3.0, 4.0], &mut yt, true); + assert_eq!( + yt, + d.t().dot(&ndarray::arr1(&[1.0, 2.0, 3.0, 4.0])).to_vec() + ); + } +} diff --git a/src/randomized/mod.rs b/src/randomized/mod.rs index 39e45d2..c3198d2 100644 --- a/src/randomized/mod.rs +++ b/src/randomized/mod.rs @@ -1,778 +1,777 @@ -use crate::error::SvdLibError; -use crate::{Diagnostics, SMat, SvdFloat, SvdRec}; -use nalgebra_sparse::na::{ComplexField, DMatrix, DVector, RealField}; -use ndarray::Array1; -use rand::prelude::{Distribution, StdRng}; -use rand::SeedableRng; -use rand_distr::Normal; -use rayon::iter::ParallelIterator; -use rayon::prelude::IntoParallelIterator; -use std::ops::Mul; -use std::time::Instant; -use single_utilities::traits::IntoNdarray2; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum PowerIterationNormalizer { - QR, - LU, +//! Randomized SVD: range finding by random projection. +//! +//! Two sketching strategies, both built on the same reduction: +//! +//! - [`Sketch::PowerIteration`] — Halko, Martinsson & Tropp. Cheap and accurate when +//! the spectrum decays quickly. +//! - [`Sketch::BlockKrylov`] — Musco & Musco. Keeps every power-iteration block instead +//! of only the last, which is markedly more accurate on slowly-decaying spectra at +//! the cost of a wider basis. +//! +//! # The final factorization is `l × l`, not `l × cols` +//! +//! Once a range basis `Y` (`rows × l`) is in hand, the naive next step is to form +//! `B = Yᵀ·A` (`l × cols`) and take its dense SVD. `cols` can be large, so 1.x's +//! `b.svd(true, true)` was a dense factorization of a potentially huge matrix. +//! +//! Instead note that `Bᵀ = Aᵀ·Y` is itself tall and skinny (`cols × l`). Factor it with +//! [`tsqr`](crate::dense::tsqr()) as `Bᵀ = Q_c·R_c`, then the only dense SVD needed is of +//! `R_cᵀ`, which is `l × l`: +//! +//! ```text +//! A ≈ Y·Bᵀᵀ = Y·R_cᵀ·Q_cᵀ = (Y·Û)·Ŝ·(Q_c·V̂)ᵀ +//! ``` +//! +//! With rank 50 and 10 oversamples that is a 60 × 60 factorization regardless of how +//! wide the input is. + +use crate::dense::{small_svd, svd_flip, tsqr}; +use crate::error::{Result, SvdLibError}; +use crate::matrix::SparseMatDense; +use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec}; +use ndarray::{s, Array1, Array2, Axis}; +use rand::rngs::StdRng; +use rand::{rng, Rng, SeedableRng}; +use rand_distr::{Distribution, Normal}; + +/// Default oversampling beyond the requested rank. +pub const DEFAULT_OVERSAMPLES: usize = 10; +/// Default power iterations. +pub const DEFAULT_POWER_ITERATIONS: usize = 2; + +/// How the intermediate basis is re-orthogonalised between products. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Normalizer { + /// Tall-skinny QR. Numerically the right default. + #[default] + Tsqr, + /// Column normalisation only. Cheaper, and adequate for one or two iterations, but + /// it does not prevent the basis collapsing toward the dominant direction. + ColumnNorm, + /// No re-orthogonalisation. Only safe with zero power iterations. None, } -const PARALLEL_THRESHOLD_ROWS: usize = 5000; -const PARALLEL_THRESHOLD_COLS: usize = 1000; +/// The sketching strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Sketch { + /// `Y = (A·Aᵀ)^q·A·Ω`, keeping only the final block. + /// + /// Basis width is `rank + oversamples`. + PowerIteration { iterations: usize }, + /// `K = [A·Ω, (A·Aᵀ)A·Ω, …, (A·Aᵀ)^(b-1)·A·Ω]`, keeping every block. + /// + /// Basis width is `blocks · (rank + oversamples)`, so memory scales with `blocks`. + /// Two to four blocks is usually the sweet spot. + BlockKrylov { blocks: usize }, +} -pub fn randomized_svd( - m: &M, - target_rank: usize, - n_oversamples: usize, - n_power_iters: usize, - power_iteration_normalizer: PowerIterationNormalizer, - mean_center: bool, - seed: Option, - verbose: bool, -) -> anyhow::Result> -where - T: SvdFloat + RealField, - M: SMat + std::marker::Sync, - T: ComplexField, -{ - let start = Instant::now(); - let m_rows = m.nrows(); - let m_cols = m.ncols(); - - let rank = target_rank.min(m_rows.min(m_cols)); - let l = rank + n_oversamples; - - let column_means: Option> = if mean_center { - if verbose { - println!("SVD | Randomized | Computing column means...."); +impl Default for Sketch { + fn default() -> Self { + Sketch::PowerIteration { + iterations: DEFAULT_POWER_ITERATIONS, } - Some(DVector::from(m.compute_column_means())) - } else { - None - }; - if verbose && mean_center { - println!( - "SVD | Randomized | Computed column means, took: {:?} of total running time", - start.elapsed() - ); } +} - let omega = generate_random_matrix(m_cols, l, seed); +/// Configuration for [`svd_with`]. +/// +/// Replaces 1.x's eight positional arguments, two of which were bare `bool`s that the +/// crate's own call sites commented incorrectly. +#[derive(Debug, Clone)] +pub struct RandomizedConfig { + /// Number of singular triplets wanted. + pub rank: usize, + /// Extra sketch columns; improves accuracy at linear cost. + pub oversamples: usize, + pub sketch: Sketch, + pub normalizer: Normalizer, + /// Subtract column means without materialising the centered matrix. + pub mean_center: bool, + /// Fixed seed; `None` draws from the OS. + /// + /// 1.x accepted `Option` but substituted `0` for `None`, so "random" was in + /// fact a fixed sketch on every call. + pub seed: Option, +} - let mut y = DMatrix::::zeros(m_rows, l); - if verbose { - println!("SVD | Randomized | Multiplying m with omega matrix...."); +impl RandomizedConfig { + pub fn new(rank: usize) -> Self { + Self { + rank, + oversamples: DEFAULT_OVERSAMPLES, + sketch: Sketch::default(), + normalizer: Normalizer::default(), + mean_center: false, + seed: None, + } } - multiply_matrix_centered(m, &omega, &mut y, false, &column_means); - if verbose { - println!( - "SVD | Randomized | Multiplication done, took: {:?} of total running time", - start.elapsed() - ); + pub fn oversamples(mut self, n: usize) -> Self { + self.oversamples = n; + self } - if verbose { - println!("SVD | Randomized | Starting power iterations...."); + pub fn power_iterations(mut self, n: usize) -> Self { + self.sketch = Sketch::PowerIteration { iterations: n }; + self } - if n_power_iters > 0 { - let mut z = DMatrix::::zeros(m_cols, l); - - for i in 0..n_power_iters { - if verbose { - println!( - "SVD | Randomized | Running power-iteration: {:?}, current time: {:?}", - i, - start.elapsed() - ); - } - multiply_matrix_centered(m, &y, &mut z, true, &column_means); - if verbose { - println!( - "SVD | Randomized | Forward Multiplication {:?}", - start.elapsed() - ); - } - match power_iteration_normalizer { - PowerIterationNormalizer::QR => { - let qr = z.qr(); - z = qr.q(); - // After QR normalization, z has fewer columns, so we need to resize y - y = DMatrix::::zeros(m_rows, z.ncols()); - } - PowerIterationNormalizer::LU => { - normalize_columns(&mut z); - } - PowerIterationNormalizer::None => {} - } - if verbose { - println!( - "SVD | Randomized | Power Iteration Normalization Forward-Step {:?}", - start.elapsed() - ); - } - - multiply_matrix_centered(m, &z, &mut y, false, &column_means); - if verbose { - println!( - "SVD | Randomized | Backward Multiplication {:?}", - start.elapsed() - ); - } - match power_iteration_normalizer { - PowerIterationNormalizer::QR => { - let qr = y.qr(); - y = qr.q(); - } - PowerIterationNormalizer::LU => normalize_columns(&mut y), - PowerIterationNormalizer::None => {} - } - if verbose { - println!( - "SVD | Randomized | Power Iteration Normalization Backward-Step {:?}", - start.elapsed() - ); - } - } + pub fn block_krylov(mut self, blocks: usize) -> Self { + self.sketch = Sketch::BlockKrylov { blocks }; + self } - if verbose { - println!( - "SVD | Randomized | Running QR-Normalization after Power-Iterations {:?}", - start.elapsed() - ); + pub fn normalizer(mut self, n: Normalizer) -> Self { + self.normalizer = n; + self } - let qr = y.qr(); - let y = qr.q(); - if verbose { - println!( - "SVD | Randomized | Finished QR-Normalization after Power-Iterations {:?}", - start.elapsed() - ); + pub fn mean_center(mut self, yes: bool) -> Self { + self.mean_center = yes; + self } - - let mut b = DMatrix::::zeros(y.ncols(), m_cols); - multiply_transposed_by_matrix_centered(&y, m, &mut b, &column_means); - if verbose { - println!( - "SVD | Randomized | Transposed Matrix Multiplication {:?}", - start.elapsed() - ); - } - let svd = b.svd(true, true); - if verbose { - println!( - "SVD | Randomized | Running Singular Value Decomposition, took {:?}", - start.elapsed() - ); + pub fn seed(mut self, seed: u64) -> Self { + self.seed = Some(seed); + self } - let u_b = svd - .u - .ok_or_else(|| SvdLibError::Las2Error("SVD U computation failed".to_string()))?; - let singular_values = svd.singular_values; - let vt = svd - .v_t - .ok_or_else(|| SvdLibError::Las2Error("SVD V_t computation failed".to_string()))?; - - let u = y.mul(&u_b); - let actual_rank = target_rank.min(singular_values.len()); - - let u_subset = u.columns(0, actual_rank); - let s = convert_singular_values( - >::from(singular_values.rows(0, actual_rank)), - actual_rank, - ); - let vt_subset = vt.rows(0, actual_rank).into_owned(); - let u = u_subset.into_owned().into_ndarray2(); - let vt = vt_subset.into_ndarray2(); - Ok(SvdRec { - d: actual_rank, - u, - s, - vt, - diagnostics: create_diagnostics( - m, - actual_rank, - target_rank, - n_power_iters, - seed.unwrap_or(0) as u32, - ), - }) } -fn convert_singular_values( - values: DVector, - size: usize, -) -> Array1 { - let mut array = Array1::zeros(size); - - for i in 0..size { - array[i] = T::from_real(values[i].clone()); - } +/// A sink for progress messages, called once per major stage. +/// +/// 1.x printed stage timings to stdout behind a `verbose: bool`. A library shouldn't +/// write to the process's streams, so the caller supplies the sink. +pub type Progress<'a> = &'a (dyn Fn(&str) + Sync); - array +/// `rank` largest singular triplets with default settings. +pub fn svd>(a: &M, rank: usize) -> Result> { + svd_with(a, &RandomizedConfig::new(rank), None) } -fn create_diagnostics>( +/// `rank` largest singular triplets with a fixed seed. +pub fn svd_seed>( a: &M, - d: usize, - target_rank: usize, - power_iterations: usize, - seed: u32, -) -> Diagnostics -where - T: SvdFloat, -{ - Diagnostics { - non_zero: a.nnz(), - dimensions: target_rank, - iterations: power_iterations, - transposed: false, - lanczos_steps: 0, // we dont do that - ritz_values_stabilized: d, - significant_values: d, - singular_values: d, - end_interval: [T::from(-1e-30).unwrap(), T::from(1e-30).unwrap()], - kappa: T::from(1e-6).unwrap(), - random_seed: seed, - } -} - -fn normalize_columns(matrix: &mut DMatrix) { - let rows = matrix.nrows(); - let cols = matrix.ncols(); - - if rows < PARALLEL_THRESHOLD_ROWS && cols < PARALLEL_THRESHOLD_COLS { - for j in 0..cols { - let mut norm = T::zero(); - - // Calculate column norm - for i in 0..rows { - norm += ComplexField::powi(matrix[(i, j)], 2); - } - norm = ComplexField::sqrt(norm); - - if norm > T::from_f64(1e-10).unwrap() { - let scale = T::one() / norm; - for i in 0..rows { - matrix[(i, j)] *= scale; - } - } - } - return; - } - - let norms: Vec = (0..cols) - .into_par_iter() - .map(|j| { - let mut norm = T::zero(); - for i in 0..rows { - let val = unsafe { *matrix.get_unchecked((i, j)) }; - norm += ComplexField::powi(val, 2); - } - ComplexField::sqrt(norm) - }) - .collect(); - - let scales: Vec<(usize, T)> = norms - .into_iter() - .enumerate() - .filter_map(|(j, norm)| { - if norm > T::from_f64(1e-10).unwrap() { - Some((j, T::one() / norm)) - } else { - None // Skip columns with too small norms - } - }) - .collect(); - - scales.iter().for_each(|(j, scale)| { - for i in 0..rows { - let value = matrix.get_mut((i, *j)).unwrap(); - *value = value.clone() * scale.clone(); - } - }); + rank: usize, + seed: u64, +) -> Result> { + svd_with(a, &RandomizedConfig::new(rank).seed(seed), None) } -// ---------------------------------------- -// Utils Functions -// ---------------------------------------- - -fn generate_random_matrix( - rows: usize, - cols: usize, +/// Block-Krylov variant with `blocks` blocks. +pub fn svd_block_krylov>( + a: &M, + rank: usize, + blocks: usize, seed: Option, -) -> DMatrix { - let mut rng = match seed { - Some(s) => StdRng::seed_from_u64(s), - None => StdRng::seed_from_u64(0), - }; - - let normal = Normal::new(0.0, 1.0).unwrap(); - DMatrix::from_fn(rows, cols, |_, _| { - T::from_f64(normal.sample(&mut rng)).unwrap() - }) +) -> Result> { + let mut cfg = RandomizedConfig::new(rank).block_krylov(blocks); + cfg.seed = seed; + svd_with(a, &cfg, None) } -fn multiply_matrix>( - sparse: &M, - dense: &DMatrix, - result: &mut DMatrix, - transpose_sparse: bool, -) { - sparse.multiply_with_dense(dense, result, transpose_sparse) +/// PCA: `rank` largest triplets of the implicitly mean-centered matrix. +pub fn svd_centered>( + a: &M, + rank: usize, + seed: Option, +) -> Result> { + let mut cfg = RandomizedConfig::new(rank).mean_center(true); + cfg.seed = seed; + svd_with(a, &cfg, None) } -fn multiply_transposed_by_matrix + std::marker::Sync>( - q: &DMatrix, - sparse: &M, - result: &mut DMatrix, -) { - sparse.multiply_transposed_by_dense(q, result); -} +/// Compute a decomposition with explicit configuration. +pub fn svd_with>( + a: &M, + cfg: &RandomizedConfig, + progress: Option>, +) -> Result> { + let note = |msg: &str| { + if let Some(p) = progress { + p(msg); + } + }; -pub fn svd_flip( - u: Option<&mut DMatrix>, - v: Option<&mut DMatrix>, - u_based_decision: bool, -) -> Result<(), SvdLibError> { - if u.is_none() && v.is_none() { - return Err(SvdLibError::Las2Error( - "Both u and v cannot be None".to_string(), - )); + let (rows, cols) = (a.rows(), a.cols()); + let min_dim = rows.min(cols); + if cfg.rank == 0 { + return Err(SvdLibError::invalid("randomized: rank must be at least 1")); } - - if u_based_decision { - if u.is_none() { - return Err(SvdLibError::Las2Error( - "u cannot be None when u_based_decision is true".to_string(), + if cfg.rank > min_dim { + return Err(SvdLibError::invalid(format!( + "randomized: rank {} exceeds min(rows, cols) = {min_dim}", + cfg.rank + ))); + } + if let Sketch::BlockKrylov { blocks } = cfg.sketch { + if blocks == 0 { + return Err(SvdLibError::invalid( + "randomized: block_krylov needs at least one block", )); } + } - let u = u.unwrap(); - let ncols = u.ncols(); - let nrows = u.nrows(); - - let mut signs = DVector::from_element(ncols, T::one()); - - for j in 0..ncols { - let mut max_abs = T::zero(); - let mut max_idx = 0; + let rank = cfg.rank; + // Sketch width, capped so the basis cannot exceed the operand's rank. + let l = (rank + cfg.oversamples).min(min_dim); + let seed = cfg.seed.unwrap_or_else(|| rng().next_u64()); + let mut rng_state = StdRng::seed_from_u64(seed); - for i in 0..nrows { - let abs_val = num_traits::Float::abs(u[(i, j)]); - if abs_val > max_abs { - max_abs = abs_val; - max_idx = i; - } - } + let means: Option> = if cfg.mean_center { + note("computing column means"); + Some(a.col_means()) + } else { + None + }; + let mut matvecs = 0usize; - if u[(max_idx, j)] < T::zero() { - signs[j] = -T::one(); - } - } + // Product helpers that apply centering when configured. + let mul = |rhs: &Array2, out: &mut Array2, trans: bool| match &means { + Some(m) => a.mul_dense_centered(rhs.view(), out.view_mut(), trans, m.view()), + None => a.mul_dense(rhs.view(), out.view_mut(), trans), + }; - for j in 0..ncols { - for i in 0..nrows { - u[(i, j)] *= signs[j]; + note("drawing the random sketch"); + let omega = gaussian(cols, l, &mut rng_state); + + // ----- Stage 1: build a basis for the range of A ----- + let mut basis = match cfg.sketch { + Sketch::PowerIteration { iterations } => { + note("projecting"); + let mut y = Array2::::zeros((rows, l)); + mul(&omega, &mut y, false); + matvecs += l; + normalize(&mut y, cfg.normalizer)?; + + let mut z = Array2::::zeros((cols, l)); + for i in 0..iterations { + note(&format!("power iteration {}/{}", i + 1, iterations)); + mul(&y, &mut z, true); + matvecs += l; + normalize(&mut z, cfg.normalizer)?; + mul(&z, &mut y, false); + matvecs += l; + normalize(&mut y, cfg.normalizer)?; } + y } - - if let Some(v) = v { - let v_nrows = v.nrows(); - let v_ncols = v.ncols(); - - for i in 0..v_nrows.min(signs.len()) { - for j in 0..v_ncols { - v[(i, j)] *= signs[i]; + Sketch::BlockKrylov { blocks } => { + note("building the Krylov block basis"); + // The range of A has dimension at most min(rows, cols), so a basis wider + // than that is necessarily rank-deficient. Clamping to `rows` alone is not + // enough: on a 500x60 operand, 4 blocks of 22 would give an 88-column basis + // whose `Aᵀ·basis` is 60x88 — wider than tall, which no QR accepts. + let width = (blocks * l).min(min_dim); + let mut k = Array2::::zeros((rows, width)); + let mut y = Array2::::zeros((rows, l)); + let mut z = Array2::::zeros((cols, l)); + + mul(&omega, &mut y, false); + matvecs += l; + normalize(&mut y, cfg.normalizer)?; + + let mut filled = 0usize; + for b in 0..blocks { + if filled >= width { + break; } - } - } - } else { - if v.is_none() { - return Err(SvdLibError::Las2Error( - "v cannot be None when u_based_decision is false".to_string(), - )); - } - - let v = v.unwrap(); - let nrows = v.nrows(); - let ncols = v.ncols(); - - let mut signs = DVector::from_element(nrows, T::one()); - - for i in 0..nrows { - let mut max_abs = T::zero(); - let mut max_idx = 0; - - for j in 0..ncols { - let abs_val = num_traits::Float::abs(v[(i, j)]); - if abs_val > max_abs { - max_abs = abs_val; - max_idx = j; + let take = l.min(width - filled); + k.slice_mut(s![.., filled..filled + take]) + .assign(&y.slice(s![.., ..take])); + filled += take; + if b + 1 == blocks { + break; } + note(&format!("krylov block {}/{}", b + 2, blocks)); + mul(&y, &mut z, true); + matvecs += l; + normalize(&mut z, cfg.normalizer)?; + mul(&z, &mut y, false); + matvecs += l; + normalize(&mut y, cfg.normalizer)?; } - - if v[(i, max_idx)] < T::zero() { - signs[i] = -T::one(); - } - } - - for i in 0..nrows { - for j in 0..ncols { - v[(i, j)] *= signs[i]; + if filled < width { + k = k.slice(s![.., ..filled]).to_owned(); } + k } + }; - if let Some(u) = u { - let u_nrows = u.nrows(); - let u_ncols = u.ncols(); - - for j in 0..u_ncols.min(signs.len()) { - for i in 0..u_nrows { - u[(i, j)] *= signs[j]; - } - } - } - } + note("orthonormalising the basis"); + tsqr(&mut basis)?; + let width = basis.ncols(); + + // ----- Stage 2: project and factor ----- + // + // `bt = Aᵀ·basis` is tall-skinny, so TSQR it and take the SVD of the small `R` + // rather than factoring the wide `basis ᵀ·A` directly. + note("projecting onto the basis"); + let mut bt = Array2::::zeros((cols, width)); + mul(&basis, &mut bt, true); + matvecs += width; + + note("reducing"); + let r_c = tsqr(&mut bt)?; // bt is now Q_c (cols × width), r_c is width × width + let small = small_svd(r_c.t())?; // SVD of R_cᵀ + + // A ≈ (basis·Û)·Ŝ·(Q_c·V̂)ᵀ + let keep = rank.min(small.s.len()); + let u_hat = small.u.slice(s![.., ..keep]); + let v_hat = small.vt.slice(s![..keep, ..]).t().to_owned(); // width × keep + + let mut u = basis.dot(&u_hat); + let mut vt = bt + .dot(&v_hat) + .reversed_axes() + .as_standard_layout() + .to_owned(); + let s = small.s.slice(s![..keep]).to_owned(); + + svd_flip(&mut u, &mut vt); + + let (oversamples, power_iterations, block_size) = match cfg.sketch { + Sketch::PowerIteration { iterations } => (cfg.oversamples, iterations, l), + Sketch::BlockKrylov { blocks } => (cfg.oversamples, blocks, l), + }; - Ok(()) + Ok(SvdRec { + d: keep, + u, + s, + vt, + total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm( + a, + means.as_ref().map(|m| m.view()), + )), + diagnostics: Diagnostics { + algorithm: match cfg.sketch { + Sketch::PowerIteration { .. } => Algorithm::Randomized, + Sketch::BlockKrylov { .. } => Algorithm::BlockKrylov, + }, + non_zero: a.nnz(), + dimensions: rank, + significant_values: keep, + transposed: false, + random_seed: seed, + matvecs, + detail: Detail::Randomized { + oversamples, + power_iterations, + block_size, + }, + }, + }) } -fn multiply_matrix_centered + std::marker::Sync>( - sparse: &M, - dense: &DMatrix, - result: &mut DMatrix, - transpose_sparse: bool, - column_means: &Option>, -) { - if column_means.is_none() { - multiply_matrix(sparse, dense, result, transpose_sparse); - return; - } - - let means = column_means.as_ref().unwrap(); - sparse.multiply_with_dense_centered(dense, result, transpose_sparse, means) +/// A `rows × cols` matrix of standard normal draws. +fn gaussian(rows: usize, cols: usize, rng: &mut StdRng) -> Array2 { + let normal = Normal::new(0.0, 1.0).expect("N(0,1) is well-formed"); + Array2::from_shape_fn((rows, cols), |_| T::from_f64_val(normal.sample(rng))) } -fn multiply_transposed_by_matrix_centered + std::marker::Sync>( - q: &DMatrix, - sparse: &M, - result: &mut DMatrix, - column_means: &Option>, -) { - if column_means.is_none() { - multiply_transposed_by_matrix(q, sparse, result); - return; +fn normalize(m: &mut Array2, how: Normalizer) -> Result<()> { + match how { + Normalizer::Tsqr => { + tsqr(m)?; + Ok(()) + } + Normalizer::ColumnNorm => { + let floor = T::from_f64_val(1e-10); + for mut col in m.axis_iter_mut(Axis(1)) { + let n = col.iter().map(|&x| x * x).sum::().sqrt(); + if n > floor { + let inv = T::one() / n; + col.map_inplace(|x| *x *= inv); + } + } + Ok(()) + } + Normalizer::None => Ok(()), } - - let means = column_means.as_ref().unwrap(); - sparse.multiply_transposed_by_dense_centered(q, result, means); } #[cfg(test)] -mod randomized_svd_tests { +mod tests { use super::*; - use crate::randomized::{randomized_svd, PowerIterationNormalizer}; - use nalgebra_sparse::coo::CooMatrix; - use nalgebra_sparse::CsrMatrix; - use ndarray::Array2; - use rand::rngs::StdRng; - use rand::{Rng, SeedableRng}; - use rayon::ThreadPoolBuilder; - use std::sync::Once; - - static INIT: Once = Once::new(); - - fn setup_thread_pool() { - INIT.call_once(|| { - ThreadPoolBuilder::new() - .num_threads(16) - .build_global() - .expect("Failed to build global thread pool"); - - println!("Initialized thread pool with {} threads", 16); - }); - } - - fn create_sparse_matrix( - rows: usize, - cols: usize, - density: f64, - ) -> nalgebra_sparse::coo::CooMatrix { - use std::collections::HashSet; - - let mut coo = nalgebra_sparse::coo::CooMatrix::new(rows, cols); - - let mut rng = StdRng::seed_from_u64(42); - - let nnz = (rows as f64 * cols as f64 * density).round() as usize; - - let nnz = nnz.max(1); - - let mut positions = HashSet::new(); - - while positions.len() < nnz { - let i = rng.gen_range(0..rows); - let j = rng.gen_range(0..cols); - - if positions.insert((i, j)) { - let val = loop { - let v: f64 = rng.gen_range(-10.0..10.0); - if v.abs() > 1e-10 { - break v; - } - }; - - coo.push(i, j, val); - } + use crate::matrix::SvdMat; + use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values, Lcg}; + use sprs::TriMatI; + + fn diagonal(n: usize) -> SvdMat { + let mut t = TriMatI::::new((n, n)); + for i in 0..n { + t.add_triplet(i, i, (n - i) as f64); } + t.to_csr::() + } - let actual_density = coo.nnz() as f64 / (rows as f64 * cols as f64); - println!("Created sparse matrix: {} x {}", rows, cols); - println!(" - Requested density: {:.6}", density); - println!(" - Actual density: {:.6}", actual_density); - println!(" - Sparsity: {:.4}%", (1.0 - actual_density) * 100.0); - println!(" - Non-zeros: {}", coo.nnz()); - - coo + /// Worst relative error against a dense LAPACK reference. + fn max_rel_error(a: &SvdMat, got: &SvdRec) -> f64 { + let want = reference_singular_values(&dense_of(a)); + got.s + .iter() + .enumerate() + .map(|(i, &g)| (g - want[i]).abs() / want[i].abs().max(1e-30)) + .fold(0.0f64, f64::max) } + /// A rapidly-decaying spectrum is the regime randomized SVD is designed for, so + /// accuracy there should be high even with modest power iterations. #[test] - fn test_randomized_svd_accuracy() { - setup_thread_pool(); - - let coo = create_sparse_matrix(500, 40, 0.1); - - - let csr = CsrMatrix::from(&coo); - - let mut std_svd = crate::lanczos::svd_dim_seed(&csr, 10, 42).unwrap(); + fn accurate_on_decaying_spectrum() { + let a = gen_lowrank(400, 120, 10, 5); + let got = svd_seed(&a, 10, 42).unwrap(); + let err = max_rel_error(&a, &got); + assert!(err < 1e-6, "max relative error {err:.3e}"); + } - let rand_svd = randomized_svd( - &csr, - 10, - 5, - 4, - PowerIterationNormalizer::QR, - false, - Some(42), - true, + /// `diag(60..1)` decays only linearly, so `sigma_11/sigma_10 = 0.98` and the + /// randomized error bound `~(sigma_{k+1}/sigma_k)^(2q+1)` barely improves with `q`. + /// This is a limitation of the method, not a defect: assert the behaviour theory + /// predicts rather than an accuracy it cannot deliver. + #[test] + fn power_iteration_converges_slowly_on_linear_decay() { + let a = diagonal(60); + let loose = svd_with( + &a, + &RandomizedConfig::new(10).seed(42).power_iterations(0), + None, ) .unwrap(); + let tight = svd_with( + &a, + &RandomizedConfig::new(10).seed(42).power_iterations(7), + None, + ) + .unwrap(); + let e0 = max_rel_error(&a, &loose); + let e7 = max_rel_error(&a, &tight); + assert!( + e0 > 1e-2, + "q=0 should be visibly inaccurate here, got {e0:.3e}" + ); + assert!( + e7 < e0 / 50.0, + "7 power iterations should improve substantially: {e0:.3e} -> {e7:.3e}" + ); + } - assert_eq!(rand_svd.d, 10, "Expected rank of 10"); - - let rel_tol = 0.4; - let compare_count = std::cmp::min(std_svd.d, rand_svd.d); - println!("Standard SVD has {} dimensions", std_svd.d); - println!("Randomized SVD has {} dimensions", rand_svd.d); + /// Block Krylov is the answer for that same matrix: retaining every block spans the + /// dominant subspace essentially exactly, reaching machine precision where power + /// iteration is still at 1e-4. + #[test] + fn block_krylov_is_near_exact_on_linear_decay() { + let a = diagonal(60); + let got = svd_with( + &a, + &RandomizedConfig::new(10).seed(42).block_krylov(4), + None, + ) + .unwrap(); + let err = max_rel_error(&a, &got); + assert!(err < 1e-10, "block krylov max relative error {err:.3e}"); + } - for i in 0..compare_count { - let rel_diff = (std_svd.s[i] - rand_svd.s[i]).abs() / std_svd.s[i]; - println!( - "Singular value {}: standard={}, randomized={}, rel_diff={}", - i, std_svd.s[i], rand_svd.s[i], rel_diff - ); + /// More power iterations must not make the answer worse. + #[test] + fn power_iterations_improve_accuracy() { + let a = gen_sparse(600, 200, 0.05, 13); + let mut prev = f64::INFINITY; + for q in [0usize, 1, 2, 4, 7] { + let cfg = RandomizedConfig::new(15).seed(42).power_iterations(q); + let got = svd_with(&a, &cfg, None).unwrap(); + let err = max_rel_error(&a, &got); assert!( - rel_diff < rel_tol, - "Dominant singular value {} differs too much: rel diff = {}, standard = {}, randomized = {}", - i, rel_diff, std_svd.s[i], rand_svd.s[i] + err <= prev * 1.5 + 1e-9, + "q={q} error {err:.3e} is worse than q's predecessor {prev:.3e}" ); + prev = err; } - - + // A near-flat spectrum (ratio 0.999) cannot be driven to high accuracy by + // power iteration at any practical `q`; what must hold is a large improvement + // over the un-iterated sketch. + let plain = svd_with( + &a, + &RandomizedConfig::new(15).seed(42).power_iterations(0), + None, + ) + .unwrap(); + let e0 = max_rel_error(&a, &plain); + assert!( + prev < e0 / 10.0, + "7 power iterations ({prev:.3e}) should be well under the q=0 error ({e0:.3e})" + ); } - // Test with mean centering + /// Block Krylov should beat plain power iteration at equal matrix-product budget on + /// a slowly-decaying spectrum, which is exactly what it exists for. #[test] - fn test_randomized_svd_with_mean_centering() { - setup_thread_pool(); - - let mut coo = CooMatrix::::new(30, 10); - let mut rng = StdRng::seed_from_u64(123); - - let column_means: Vec = (0..10).map(|i| i as f64 * 2.0).collect(); - - let mut u = vec![vec![0.0; 3]; 30]; // 3 factors - let mut v = vec![vec![0.0; 3]; 10]; - - for i in 0..30 { - for j in 0..3 { - u[i][j] = rng.gen_range(-1.0..1.0); - } - } - - for i in 0..10 { - for j in 0..3 { - v[i][j] = rng.gen_range(-1.0..1.0); - } - } - - for i in 0..30 { - for j in 0..10 { - let mut val = 0.0; - for k in 0..3 { - val += u[i][k] * v[j][k]; - } - val = val + column_means[j] + rng.gen_range(-0.1..0.1); - coo.push(i, j, val); - } - } - - let csr = CsrMatrix::from(&coo); - - let svd_no_center = randomized_svd( - &csr, - 3, - 3, - 2, - PowerIterationNormalizer::QR, - false, - Some(42), - false, + fn block_krylov_beats_power_iteration_on_flat_spectrum() { + // A near-flat spectrum: random sparse, no low-rank structure. + let a = gen_sparse(800, 200, 0.04, 29); + let rank = 20; + + let power = svd_with( + &a, + &RandomizedConfig::new(rank).seed(42).power_iterations(3), + None, ) .unwrap(); - - let svd_with_center = randomized_svd( - &csr, - 3, - 3, - 2, - PowerIterationNormalizer::QR, - true, - Some(42), - false, + let krylov = svd_with( + &a, + &RandomizedConfig::new(rank).seed(42).block_krylov(4), + None, ) .unwrap(); - println!("Singular values without centering: {:?}", svd_no_center.s); - println!("Singular values with centering: {:?}", svd_with_center.s); + let e_power = max_rel_error(&a, &power); + let e_krylov = max_rel_error(&a, &krylov); + assert!( + e_krylov <= e_power, + "block krylov {e_krylov:.3e} did not improve on power iteration {e_power:.3e}" + ); + assert_eq!(krylov.diagnostics.algorithm, Algorithm::BlockKrylov); } #[test] - fn test_randomized_svd_large_sparse() { - setup_thread_pool(); - - let test_matrix = create_sparse_matrix(5000, 1000, 0.01); - - let csr = CsrMatrix::from(&test_matrix); - - let result = randomized_svd( - &csr, - 20, - 10, - 2, - PowerIterationNormalizer::QR, - false, - Some(42), - false, - ); + fn orientation_is_correct_for_wide_and_tall() { + for (r, c) in [(400usize, 80usize), (80, 400)] { + let a = gen_sparse(r, c, 0.08, 11); + let got = svd_seed(&a, 10, 42).unwrap(); + assert_eq!(got.u.dim(), (r, 10), "u shape for {r}x{c}"); + assert_eq!(got.vt.dim(), (10, c), "vt shape for {r}x{c}"); + } + } - assert!( - result.is_ok(), - "Randomized SVD failed on large sparse matrix: {:?}", - result.err().unwrap() - ); + #[test] + fn singular_vectors_are_orthonormal() { + let a = gen_lowrank(300, 100, 12, 71); + let got = svd_seed(&a, 12, 42).unwrap(); + let ou = crate::dense::orthogonality_error(&got.u.view()); + assert!(ou < 1e-8, "||UᵀU - I|| = {ou:.3e}"); + let vt_t = got.vt.t().to_owned(); + let ov = crate::dense::orthogonality_error(&vt_t.view()); + assert!(ov < 1e-8, "||VᵀV - I|| = {ov:.3e}"); + } - let svd = result.unwrap(); - assert_eq!(svd.d, 20, "Expected rank of 20"); - assert_eq!(svd.u.ncols(), 20, "Expected 20 left singular vectors"); - assert_eq!(svd.u.nrows(), 5000, "Expected 5000 columns in U transpose"); - assert_eq!(svd.vt.nrows(), 20, "Expected 20 right singular vectors"); - assert_eq!(svd.vt.ncols(), 1000, "Expected 1000 columns in V transpose"); + /// The whole point of the 2.0 rewrite: this used to panic with `todo!()` for every + /// stock matrix type. + #[test] + fn works_on_csr_and_csc_without_panicking() { + let a = gen_sparse(300, 120, 0.05, 3); + let csc = a.to_other_storage(); + let x = svd_seed(&a, 10, 42).unwrap(); + let y = svd_seed(&csc, 10, 42).unwrap(); + for (p, q) in x.s.iter().zip(y.s.iter()) { + approx::assert_relative_eq!(p, q, max_relative = 1e-9); + } + } + + #[test] + fn works_on_masked_matrices() { + let a = gen_sparse(300, 60, 0.1, 19); + let cols: Vec = (0..60).filter(|c| c % 2 == 0).collect(); + let masked = crate::matrix::MaskedCsMat::with_columns(&a, &cols); + let got = svd_seed(&masked, 8, 42).unwrap(); + assert_eq!(got.u.nrows(), 300); + assert_eq!(got.vt.ncols(), 30); + for w in got.s.to_vec().windows(2) { + assert!(w[0] >= w[1]); + } + } - for i in 1..svd.s.len() { - assert!(svd.s[i] > 0.0, "Singular values should be positive"); + /// Mean centering must agree with an explicitly centered dense reference. + #[test] + fn mean_centering_matches_dense_pca() { + let a = gen_lowrank(300, 60, 8, 37); + let dense = dense_of(&a); + let means = dense.mean_axis(Axis(0)).unwrap(); + let centered = &dense - &means.view().insert_axis(Axis(0)); + let want = reference_singular_values(¢ered); + + let cfg = RandomizedConfig::new(8) + .seed(42) + .mean_center(true) + .power_iterations(5); + let got = svd_with(&a, &cfg, None).unwrap(); + for (i, &g) in got.s.iter().enumerate() { + let rel = (g - want[i]).abs() / want[i].abs().max(1e-30); assert!( - svd.s[i - 1] >= svd.s[i], - "Singular values should be in descending order" + rel < 1e-5, + "centered singular value {i}: {g:.9e} vs {:.9e} (rel {rel:.3e})", + want[i] ); } } - // Test with different power iteration settings + /// `None` must actually vary the sketch. 1.x substituted seed 0 for `None`, so + /// successive calls were identical. #[test] - fn test_power_iteration_impact() { - setup_thread_pool(); - - let mut coo = CooMatrix::::new(100, 50); - let mut rng = StdRng::seed_from_u64(987); + fn unseeded_runs_differ() { + let a = gen_sparse(300, 100, 0.05, 47); + let cfg = RandomizedConfig::new(6).power_iterations(0); + let x = svd_with(&a, &cfg, None).unwrap(); + let y = svd_with(&a, &cfg, None).unwrap(); + assert_ne!( + x.diagnostics.random_seed, y.diagnostics.random_seed, + "an unseeded config produced the same seed twice" + ); + // Zero power iterations makes the sketch dependence visible in the output. + assert_ne!(x.u, y.u, "unseeded runs produced identical bases"); + } - let mut u = vec![vec![0.0; 10]; 100]; - let mut v = vec![vec![0.0; 10]; 50]; + #[test] + fn seeded_runs_are_reproducible() { + let a = gen_sparse(300, 100, 0.05, 51); + let x = svd_seed(&a, 8, 999).unwrap(); + let y = svd_seed(&a, 8, 999).unwrap(); + assert_eq!(x.s, y.s); + assert_eq!(x.u, y.u); + assert_eq!(x.vt, y.vt); + } - for i in 0..100 { - for j in 0..10 { - u[i][j] = rng.random_range(-1.0..1.0); - } + #[test] + fn agrees_with_irlba() { + let a = gen_lowrank(400, 150, 12, 61); + let rand = svd_with( + &a, + &RandomizedConfig::new(12).seed(42).power_iterations(6), + None, + ) + .unwrap(); + let exact = crate::irlba::svd_seed(&a, 12, 42).unwrap(); + for i in 0..12 { + let rel = (rand.s[i] - exact.s[i]).abs() / exact.s[i]; + assert!(rel < 1e-6, "triplet {i}: randomized vs irlba rel {rel:.3e}"); } + } - for i in 0..50 { - for j in 0..10 { - v[i][j] = rng.random_range(-1.0..1.0); - } + #[test] + fn normalizers_all_produce_usable_results() { + let a = gen_lowrank(400, 100, 10, 67); + for n in [Normalizer::Tsqr, Normalizer::ColumnNorm, Normalizer::None] { + let cfg = RandomizedConfig::new(10) + .seed(42) + .power_iterations(1) + .normalizer(n); + let got = svd_with(&a, &cfg, None).unwrap(); + let err = max_rel_error(&a, &got); + assert!(err < 1e-2, "{n:?} gave max relative error {err:.3e}"); } + } - for i in 0..100 { - for j in 0..50 { - let mut val = 0.0; - for k in 0..10 { - val += u[i][k] * v[j][k]; - } - val += rng.random_range(-0.01..0.01); - coo.push(i, j, val); - } - } + #[test] + fn progress_callback_is_invoked() { + let a = gen_sparse(200, 80, 0.1, 73); + let seen = std::sync::Mutex::new(Vec::::new()); + let sink = |msg: &str| seen.lock().unwrap().push(msg.to_string()); + let cfg = RandomizedConfig::new(6).seed(42).power_iterations(2); + svd_with(&a, &cfg, Some(&sink)).unwrap(); + let msgs = seen.into_inner().unwrap(); + assert!(!msgs.is_empty(), "no progress reported"); + assert!( + msgs.iter().any(|m| m.contains("power iteration")), + "power iterations were not reported: {msgs:?}" + ); + } - let csr = CsrMatrix::from(&coo); + /// Regression: a block count whose basis would exceed `min(rows, cols)` must clamp + /// rather than hand a wide matrix to the QR. + #[test] + fn block_krylov_clamps_basis_to_matrix_rank() { + // 500x60 with rank 12 + 10 oversamples = 22 per block; 4 blocks would be 88. + let a = gen_sparse(500, 60, 0.08, 7); + let got = svd_block_krylov(&a, 12, 4, Some(42)).expect("should clamp, not fail"); + assert_eq!(got.d, 12); + assert_eq!(got.u.dim(), (500, 12)); + assert_eq!(got.vt.dim(), (12, 60)); + + // Also the wide orientation. + let b = gen_sparse(60, 500, 0.08, 11); + let got = svd_block_krylov(&b, 12, 4, Some(42)).expect("should clamp, not fail"); + assert_eq!(got.u.dim(), (60, 12)); + assert_eq!(got.vt.dim(), (12, 500)); + } - let powers = [0, 1, 3, 5]; - let mut errors = Vec::new(); + #[test] + fn rejects_bad_configuration() { + let a = gen_sparse(50, 30, 0.2, 1); + assert!(matches!(svd(&a, 0), Err(SvdLibError::InvalidArgument(_)))); + assert!(matches!(svd(&a, 31), Err(SvdLibError::InvalidArgument(_)))); + let cfg = RandomizedConfig::new(5).block_krylov(0); + assert!(matches!( + svd_with(&a, &cfg, None), + Err(SvdLibError::InvalidArgument(_)) + )); + } - let mut dense_mat = Array2::::zeros((100, 50)); - for (i, j, val) in csr.triplet_iter() { - dense_mat[[i, j]] = *val; + #[test] + fn f32_works() { + let a64 = gen_lowrank(300, 80, 8, 79); + let want = reference_singular_values(&dense_of(&a64)); + let mut t = TriMatI::::new((300, 80)); + for (v, (i, j)) in a64.iter() { + t.add_triplet(i as usize, j as usize, *v as f32); } - let matrix_norm = dense_mat.iter().map(|x| x.powi(2)).sum::().sqrt(); - - for &power in &powers { - let svd = randomized_svd( - &csr, - 10, - 5, - power, - PowerIterationNormalizer::QR, - false, - Some(42), - false, - ) - .unwrap(); - - let recon = svd.recompose(); - let mut error = 0.0; - - for i in 0..100 { - for j in 0..50 { - error += (dense_mat[[i, j]] - recon[[i, j]]).powi(2); - } - } + let a32: SvdMat = t.to_csr::(); + let cfg = RandomizedConfig::new(8).seed(42).power_iterations(4); + let got = svd_with(&a32, &cfg, None).unwrap(); + for (i, &g) in got.s.iter().enumerate() { + let rel = ((g as f64) - want[i]).abs() / want[i].abs().max(1e-30); + assert!(rel < 1e-3, "f32 singular value {i}: rel {rel:.3e}"); + } + } - error = error.sqrt() / matrix_norm; - errors.push(error); + /// Oversampling beyond the operand's rank must clamp rather than overrun. + #[test] + fn oversampling_clamps_to_matrix_rank() { + let a = gen_sparse(40, 20, 0.3, 83); + let cfg = RandomizedConfig::new(5).seed(42).oversamples(1000); + let got = svd_with(&a, &cfg, None).unwrap(); + assert_eq!(got.d, 5); + let mut rng = Lcg::new(1); + let _ = rng.next_u64(); + } - println!("Power iterations: {}, Relative error: {}", power, error); + #[test] + fn diagnostics_report_matvecs_and_algorithm() { + let a = gen_sparse(200, 80, 0.1, 89); + let got = svd_seed(&a, 6, 42).unwrap(); + assert_eq!(got.diagnostics.algorithm, Algorithm::Randomized); + assert!(got.diagnostics.matvecs > 0); + match got.diagnostics.detail { + Detail::Randomized { + power_iterations, .. + } => { + assert_eq!(power_iterations, DEFAULT_POWER_ITERATIONS); + } + ref other => panic!("wrong detail variant: {other:?}"), } + } - let mut improved = false; - for i in 1..errors.len() { - if errors[i] < errors[0] * 0.9 { - improved = true; - break; + /// Characterises how each sketch converges as a function of spectral decay. Run + /// with `--ignored --nocapture` to see the table; it is the evidence behind the + /// guidance in the module docs about when to prefer block Krylov. + #[test] + #[ignore = "diagnostic, run explicitly"] + fn report_convergence_rates() { + let cases: Vec<(&str, SvdMat, usize)> = vec![ + ("diag_60_linear", diagonal(60), 10), + ("lowrank_400x120_r10", gen_lowrank(400, 120, 10, 5), 10), + ("sparse_600x200_flat", gen_sparse(600, 200, 0.05, 13), 15), + ]; + for (name, a, rank) in cases { + let want = reference_singular_values(&dense_of(&a)); + print!( + "{name:<22} sigma_ratio={:.3} ", + want[rank] / want[rank - 1] + ); + for q in [0usize, 1, 2, 4, 7] { + let cfg = RandomizedConfig::new(rank).seed(42).power_iterations(q); + let got = svd_with(&a, &cfg, None).unwrap(); + print!("q{q}={:.2e} ", max_rel_error(&a, &got)); + } + for b in [2usize, 4] { + let cfg = RandomizedConfig::new(rank).seed(42).block_krylov(b); + let got = svd_with(&a, &cfg, None).unwrap(); + print!("bk{b}={:.2e} ", max_rel_error(&a, &got)); } + println!(); } - - assert!( - improved, - "Power iterations did not improve accuracy as expected" - ); } } diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..dd942fe --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,112 @@ +//! Deterministic fixtures shared by the unit tests. +//! +//! The generators use a self-contained LCG so fixtures stay stable across `rand` +//! upgrades, and so the unit tests and the integration tests agree on what +//! `gen_sparse(200, 80, 0.1, 7)` means. + +#![cfg(test)] +// Numeric kernels index several arrays in step from one loop variable, and +// offset arithmetic is load-bearing; iterator rewrites obscure which array an +// index belongs to. +#![allow(clippy::needless_range_loop)] + +use crate::matrix::SvdMat; +use crate::types::SvdFloat; +use ndarray::Array2; +use sprs::{SpIndex, TriMatI}; + +/// Deterministic, dependency-free PRNG. +pub struct Lcg(u64); + +impl Lcg { + pub fn new(seed: u64) -> Self { + Lcg(seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + pub fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + /// Uniform in `[0, 1)`. + pub fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + /// Uniform in `[-1, 1)`. + pub fn signed(&mut self) -> f64 { + self.next_f64() * 2.0 - 1.0 + } + pub fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +/// Densify a sparse matrix, for comparing against a reference computation. +pub fn dense_of(m: &SvdMat) -> Array2 +where + T: SvdFloat, + I: SpIndex, + Iptr: SpIndex, +{ + let mut d = Array2::zeros((m.rows(), m.cols())); + for (v, (i, j)) in m.iter() { + d[[i.index(), j.index()]] = *v; + } + d +} + +/// A sparse matrix with an exact non-zero count at the requested density. +pub fn gen_sparse(rows: usize, cols: usize, density: f64, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let target = ((rows as f64 * cols as f64 * density).round() as usize).max(1); + let mut seen = std::collections::HashSet::new(); + let mut t = TriMatI::::new((rows, cols)); + let mut attempts = 0usize; + while seen.len() < target && attempts < target * 100 { + attempts += 1; + let i = rng.range(rows); + let j = rng.range(cols); + if seen.insert((i, j)) { + let v = rng.signed() * 10.0; + t.add_triplet(i, j, if v.abs() < 1e-6 { 1.0 } else { v }); + } + } + t.to_csr() +} + +/// A dense-ish matrix with a genuine low-rank structure and a decaying spectrum, +/// which is what exercises convergence behaviour. +pub fn gen_lowrank(rows: usize, cols: usize, rank: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let u: Vec> = (0..rows) + .map(|_| (0..rank).map(|_| rng.signed()).collect()) + .collect(); + let v: Vec> = (0..cols) + .map(|_| (0..rank).map(|_| rng.signed()).collect()) + .collect(); + let mut t = TriMatI::::new((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let mut val = 0.0; + for k in 0..rank { + // 1/(k+1) weighting gives the singular values a real gap structure. + val += u[i][k] * v[j][k] / (k as f64 + 1.0); + } + val += rng.signed() * 0.001; + t.add_triplet(i, j, val); + } + } + t.to_csr() +} + +/// Reference singular values via a dense Jacobi SVD of `AᵀA`, independent of any +/// code under test. +pub fn reference_singular_values(a: &Array2) -> Vec { + let m = nalgebra::DMatrix::from_fn(a.nrows(), a.ncols(), |i, j| a[[i, j]]); + let mut s: Vec = m.singular_values().iter().copied().collect(); + s.sort_by(|x, y| y.partial_cmp(x).unwrap()); + s +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..99ba9eb --- /dev/null +++ b/src/types.rs @@ -0,0 +1,295 @@ +//! Result and scalar types shared by every algorithm in the crate. + +use ndarray::{Array1, Array2}; +use single_utilities::traits::FloatOpsTS; + +/// Scalar types this crate can decompose. +/// +/// Implemented for `f32` and `f64`. The supertraits are what the sparse kernels and +/// the ndarray glue need; nothing here leaks a linear-algebra backend, because the +/// small dense factorizations are always performed in `f64` and cast back. +pub trait SvdFloat: + FloatOpsTS + ndarray::ScalarOperand + sprs::MulAcc + std::ops::DivAssign + 'static +{ + /// Machine epsilon. + fn eps() -> Self; + /// `eps^(3/4)`, the accuracy floor LAS2 clamps `kappa` to. + fn eps34() -> Self; + /// Widen to `f64` for the small dense factorizations. + fn to_f64(self) -> f64; + /// Narrow back from `f64`. + fn from_f64_val(v: f64) -> Self; + /// Equality within one ulp-ish epsilon. + fn close(a: Self, b: Self) -> bool { + num_traits::Float::abs(b - a) < Self::eps() + } +} + +impl SvdFloat for f32 { + #[inline] + fn eps() -> Self { + f32::EPSILON + } + #[inline] + fn eps34() -> Self { + // Constant-folded rather than powf'd on every call; `eps34_constants_match_computed` + // pins these to `EPSILON.powf(0.75)`. + const V: f32 = 6.4155306e-6; + V + } + #[inline] + fn to_f64(self) -> f64 { + self as f64 + } + #[inline] + fn from_f64_val(v: f64) -> Self { + v as f32 + } +} + +impl SvdFloat for f64 { + #[inline] + fn eps() -> Self { + f64::EPSILON + } + #[inline] + fn eps34() -> Self { + const V: f64 = 1.8189894035458565e-12; + V + } + #[inline] + fn to_f64(self) -> f64 { + self + } + #[inline] + fn from_f64_val(v: f64) -> Self { + v + } +} + +/// Which algorithm produced a result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Algorithm { + /// Single-vector Lanczos, the SVDLIBC LAS2 port. + Las2, + /// Restarted Lanczos bidiagonalization. + Irlba, + /// Randomized range finder with power iterations. + Randomized, + /// Randomized block Krylov. + BlockKrylov, +} + +/// Algorithm-specific counters. The fields common to every method live on +/// [`Diagnostics`] itself. +#[derive(Debug, Clone, PartialEq)] +pub enum Detail { + Lanczos { + iterations: usize, + lanczos_steps: usize, + ritz_values_stabilized: usize, + end_interval: [T; 2], + kappa: T, + }, + Irlba { + restarts: usize, + converged: bool, + tolerance: T, + /// Largest residual `||A v - s u||` over the returned triplets. + max_residual: T, + }, + Randomized { + oversamples: usize, + power_iterations: usize, + block_size: usize, + }, +} + +/// What the computation did, for logging and for deciding whether to trust a result. +#[derive(Debug, Clone, PartialEq)] +pub struct Diagnostics { + pub algorithm: Algorithm, + /// Non-zeros in the input. + pub non_zero: usize, + /// Dimensions requested by the caller. + pub dimensions: usize, + /// Dimensions actually returned and considered significant. + pub significant_values: usize, + /// Whether the algorithm worked on the transpose internally. + pub transposed: bool, + pub random_seed: u64, + /// Sparse matrix-vector products performed, counting a block product against `k` + /// dense columns as `k`. Comparable across algorithms, so it is the honest way to + /// price one method against another. + pub matvecs: usize, + pub detail: Detail, +} + +/// A singular value decomposition. +/// +/// # Orientation +/// +/// `A ≈ u · diag(s) · vt`, matching the `numpy.linalg.svd` / `scipy` convention: +/// +/// - `u` is `m × d` — left singular vectors are **columns** +/// - `s` is `d`, descending +/// - `vt` is `d × n` — right singular vectors are **rows** +/// +/// In 1.x this was inconsistent: the Lanczos path returned `u` transposed (`d × m`) +/// while the randomized path returned it as `m × d`, so [`SvdRec::recompose`] only +/// worked for square inputs. Both paths now follow the convention above. +#[derive(Debug, Clone, PartialEq)] +pub struct SvdRec { + /// Number of singular triplets returned. + pub d: usize, + /// Left singular vectors, `m × d`. + pub u: Array2, + /// Singular values, length `d`, descending. + pub s: Array1, + /// Transposed right singular vectors, `d × n`. + pub vt: Array2, + /// `‖A‖²_F` of what was decomposed — the centered matrix if the solver was centering. + /// + /// Recorded because a truncated result can't recover it: `Σᵢ sᵢ²` over the returned + /// `d` values is only the part that was kept, not the tail it's measured against. + pub total_squared_norm: T, + pub diagnostics: Diagnostics, +} + +impl SvdRec { + /// Rebuild the dense approximation `u · diag(s) · vt`. + /// + /// Allocates an `m × n` dense matrix — only reasonable for small inputs or for + /// checking reconstruction error in tests. + pub fn recompose(&self) -> Array2 { + let scaled = &self.u * &self.s.view().insert_axis(ndarray::Axis(0)); + scaled.dot(&self.vt) + } + + /// Whether an iterative method reached its tolerance. + /// + /// Always `true` for the randomized methods — they do a fixed amount of work and have + /// no convergence test. For IRLBA it's real: `false` means the restart budget ran out. + /// IRLBA refuses to return such a result by default, so this only matters if you + /// called `allow_unconverged`. + pub fn converged(&self) -> bool { + match self.diagnostics.detail { + Detail::Irlba { converged, .. } => converged, + Detail::Lanczos { .. } | Detail::Randomized { .. } => true, + } + } + + /// Largest `‖A·vᵢ − σᵢ·uᵢ‖` over the returned triplets, for algorithms that track it. + /// Judge it against `s[0]`: `1e-9 · σ_max` is excellent, `0.1 · σ_max` is unusable. + pub fn max_residual(&self) -> Option { + match self.diagnostics.detail { + Detail::Irlba { max_residual, .. } => Some(max_residual), + _ => None, + } + } + + /// PCA scores, `u · diag(s)`, shaped `m × d` — the embedding you'd cluster or plot. + pub fn scores(&self) -> Array2 { + &self.u * &self.s.view().insert_axis(ndarray::Axis(0)) + } + + /// `sᵢ² / (m − 1)`, matching sklearn's `explained_variance_`. Only a variance in the + /// statistical sense if the decomposition was centered. + pub fn explained_variance(&self) -> Array1 { + let denom = self.variance_denominator(); + self.s.mapv(|si| si * si / denom) + } + + /// Fraction of the total squared norm each component captures. Sums to at most 1, + /// and to zero (not `NaN`) for an all-zero input. + pub fn explained_variance_ratio(&self) -> Array1 { + if self.total_squared_norm <= T::zero() { + return Array1::zeros(self.s.len()); + } + self.s.mapv(|si| si * si / self.total_squared_norm) + } + + /// `‖A‖²_F / (m − 1)` — the same scale as + /// [`explained_variance`](Self::explained_variance). + pub fn total_variance(&self) -> T { + self.total_squared_norm / self.variance_denominator() + } + + /// `m − 1`, floored at 1 so a one-row input gives zeros rather than dividing by zero. + fn variance_denominator(&self) -> T { + T::from_f64_val(self.nrows().saturating_sub(1).max(1) as f64) + } + + /// Number of rows of the original matrix. + pub fn nrows(&self) -> usize { + self.u.nrows() + } + + /// Number of columns of the original matrix. + pub fn ncols(&self) -> usize { + self.vt.ncols() + } + + /// Truncate to the leading `k` triplets in place. + pub fn truncate(&mut self, k: usize) { + let k = k.min(self.d); + if k == self.d { + return; + } + self.u = self.u.slice(ndarray::s![.., ..k]).to_owned(); + self.s = self.s.slice(ndarray::s![..k]).to_owned(); + self.vt = self.vt.slice(ndarray::s![..k, ..]).to_owned(); + self.d = k; + self.diagnostics.significant_values = k; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eps34_constants_match_computed() { + // The hardcoded constants must equal the expression they replaced. + approx::assert_relative_eq!(f32::eps34(), f32::EPSILON.powf(0.75), max_relative = 1e-6); + approx::assert_relative_eq!(f64::eps34(), f64::EPSILON.powf(0.75), max_relative = 1e-12); + } + + #[test] + fn recompose_is_orientation_correct_for_non_square() { + // A 3x2 rank-1 matrix: u (3x1), s (1), vt (1x2). + let u = ndarray::arr2(&[[1.0f64], [2.0], [3.0]]); + let s = ndarray::arr1(&[2.0f64]); + let vt = ndarray::arr2(&[[1.0f64, 10.0]]); + let rec = SvdRec { + d: 1, + u, + s, + vt, + total_squared_norm: 0.0, + diagnostics: Diagnostics { + algorithm: Algorithm::Las2, + non_zero: 6, + dimensions: 1, + significant_values: 1, + transposed: false, + random_seed: 0, + matvecs: 0, + detail: Detail::Lanczos { + iterations: 0, + lanczos_steps: 0, + ritz_values_stabilized: 0, + end_interval: [0.0, 0.0], + kappa: 0.0, + }, + }, + }; + let a = rec.recompose(); + assert_eq!(a.dim(), (3, 2)); + // row i = u[i] * s * vt + assert_eq!(a[[0, 0]], 2.0); + assert_eq!(a[[0, 1]], 20.0); + assert_eq!(a[[2, 0]], 6.0); + assert_eq!(a[[2, 1]], 60.0); + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index e99179d..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,113 +0,0 @@ -use rayon::iter::ParallelIterator; -use nalgebra_sparse::na::{DMatrix, DVector}; -use ndarray::{Array1, Array2, ShapeBuilder}; -use num_traits::{Float, Zero}; -use rayon::prelude::{IntoParallelIterator, IndexedParallelIterator}; -use single_utilities::traits::FloatOpsTS; -use std::fmt::Debug; -use nalgebra::{Dim, Dyn, Scalar}; - -pub fn determine_chunk_size(nrows: usize) -> usize { - let num_threads = rayon::current_num_threads(); - - let min_rows_per_thread = 64; - let desired_chunks_per_thread = 4; - - let target_total_chunks = num_threads * desired_chunks_per_thread; - let chunk_size = nrows.div_ceil(target_total_chunks); - - chunk_size.max(min_rows_per_thread) -} - -pub trait SMat { - fn nrows(&self) -> usize; - fn ncols(&self) -> usize; - fn nnz(&self) -> usize; - fn svd_opa(&self, x: &[T], y: &mut [T], transposed: bool); // y = A*x - fn compute_column_means(&self) -> Vec; - fn multiply_with_dense(&self, dense: &DMatrix, result: &mut DMatrix, transpose_self: bool); - fn multiply_with_dense_centered(&self, dense: &DMatrix, result: &mut DMatrix, transpose_self: bool, means: &DVector); - - fn multiply_transposed_by_dense(&self, q: &DMatrix, result: &mut DMatrix); - fn multiply_transposed_by_dense_centered(&self, q: &DMatrix, result: &mut DMatrix, means: &DVector); -} - -/// Singular Value Decomposition Components -/// -/// # Fields -/// - d: Dimensionality (rank), the number of rows of both `ut`, `vt` and the length of `s` -/// - ut: Transpose of left singular vectors, the vectors are the rows of `ut` -/// - s: Singular values (length `d`) -/// - vt: Transpose of right singular vectors, the vectors are the rows of `vt` -/// - diagnostics: Computational diagnostics -#[derive(Debug, Clone, PartialEq)] -pub struct SvdRec { - pub d: usize, - pub u: Array2, - pub s: Array1, - pub vt: Array2, - pub diagnostics: Diagnostics, -} - -/// Computational Diagnostics -/// -/// # Fields -/// - non_zero: Number of non-zeros in the matrix -/// - dimensions: Number of dimensions attempted (bounded by matrix shape) -/// - iterations: Number of iterations attempted (bounded by dimensions and matrix shape) -/// - transposed: True if the matrix was transposed internally -/// - lanczos_steps: Number of Lanczos steps performed -/// - ritz_values_stabilized: Number of ritz values -/// - significant_values: Number of significant values discovered -/// - singular_values: Number of singular values returned -/// - end_interval: left, right end of interval containing unwanted eigenvalues -/// - kappa: relative accuracy of ritz values acceptable as eigenvalues -/// - random_seed: Random seed provided or the seed generated -#[derive(Debug, Clone, PartialEq)] -pub struct Diagnostics { - pub non_zero: usize, - pub dimensions: usize, - pub iterations: usize, - pub transposed: bool, - pub lanczos_steps: usize, - pub ritz_values_stabilized: usize, - pub significant_values: usize, - pub singular_values: usize, - pub end_interval: [T; 2], - pub kappa: T, - pub random_seed: u32, -} - -pub trait SvdFloat: FloatOpsTS { - fn eps() -> Self; - fn eps34() -> Self; - fn compare(a: Self, b: Self) -> bool; -} - -impl SvdFloat for f32 { - fn eps() -> Self { - f32::EPSILON - } - - fn eps34() -> Self { - f32::EPSILON.powf(0.75) - } - - fn compare(a: Self, b: Self) -> bool { - (b - a).abs() < f32::EPSILON - } -} - -impl SvdFloat for f64 { - fn eps() -> Self { - f64::EPSILON - } - - fn eps34() -> Self { - f64::EPSILON.powf(0.75) - } - - fn compare(a: Self, b: Self) -> bool { - (b - a).abs() < f64::EPSILON - } -} diff --git a/tests/cross_algorithm.rs b/tests/cross_algorithm.rs new file mode 100644 index 0000000..b439c31 --- /dev/null +++ b/tests/cross_algorithm.rs @@ -0,0 +1,444 @@ +//! Cross-algorithm agreement, exercised through the public API only. +//! +//! The unit tests inside each module check that module. These check the contract a +//! consumer actually depends on: that every solver agrees with a dense reference and +//! with each other, over a shared set of fixtures. + +#![allow(clippy::needless_range_loop)] + +use ndarray::{Array1, Array2, Axis}; +use single_svdlib::{irlba, randomized, MaskedCsMat, SparseMat, SparseMatDense, SvdMat, SvdRec}; +use sprs::{SpIndex, TriMatI}; + +// --------------------------------------------------------------------------- +// Fixtures. A self-contained LCG so they are stable across `rand` versions. +// --------------------------------------------------------------------------- + +struct Lcg(u64); + +impl Lcg { + fn new(s: u64) -> Self { + Lcg(s + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407)) + } + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 11 + } + fn next_f64(&mut self) -> f64 { + (self.next_u64() % (1 << 53)) as f64 / (1u64 << 53) as f64 + } + fn signed(&mut self) -> f64 { + self.next_f64() * 2.0 - 1.0 + } + fn range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +fn dense_of(m: &SvdMat) -> Array2 { + let mut d = Array2::zeros((m.rows(), m.cols())); + for (v, (i, j)) in m.iter() { + d[[i.index(), j.index()]] = *v; + } + d +} + +/// Reference singular values from a dense LAPACK-grade factorization. +fn reference(a: &Array2) -> Vec { + let m = nalgebra::DMatrix::from_fn(a.nrows(), a.ncols(), |i, j| a[[i, j]]); + let mut s: Vec = m.singular_values().iter().copied().collect(); + s.sort_by(|x, y| y.partial_cmp(x).unwrap()); + s +} + +fn sparse(rows: usize, cols: usize, density: f64, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let target = ((rows as f64 * cols as f64 * density).round() as usize).max(1); + let mut seen = std::collections::HashSet::new(); + let mut t = TriMatI::::new((rows, cols)); + let mut attempts = 0usize; + while seen.len() < target && attempts < target * 100 { + attempts += 1; + let (i, j) = (rng.range(rows), rng.range(cols)); + if seen.insert((i, j)) { + let v = rng.signed() * 10.0; + t.add_triplet(i, j, if v.abs() < 1e-6 { 1.0 } else { v }); + } + } + t.to_csr::() +} + +fn lowrank(rows: usize, cols: usize, rank: usize, seed: u64) -> SvdMat { + let mut rng = Lcg::new(seed); + let u: Vec> = (0..rows) + .map(|_| (0..rank).map(|_| rng.signed()).collect()) + .collect(); + let v: Vec> = (0..cols) + .map(|_| (0..rank).map(|_| rng.signed()).collect()) + .collect(); + let mut t = TriMatI::::new((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let mut val = 0.0; + for k in 0..rank { + val += u[i][k] * v[j][k] / (k as f64 + 1.0); + } + t.add_triplet(i, j, val + rng.signed() * 0.001); + } + } + t.to_csr::() +} + +fn diagonal(n: usize) -> SvdMat { + let mut t = TriMatI::::new((n, n)); + for i in 0..n { + t.add_triplet(i, i, (n - i) as f64); + } + t.to_csr::() +} + +/// Every fixture, with the rank to request from each. +fn fixtures() -> Vec<(&'static str, SvdMat, usize)> { + vec![ + ("diagonal_50", diagonal(50), 10), + ("sparse_tall_500x60", sparse(500, 60, 0.08, 7), 12), + ("sparse_wide_60x500", sparse(60, 500, 0.08, 11), 12), + ("sparse_square_200x200", sparse(200, 200, 0.03, 13), 15), + ("lowrank_300x90_r12", lowrank(300, 90, 12, 17), 12), + ("lowrank_90x300_r8", lowrank(90, 300, 8, 19), 8), + ] +} + +fn max_rel(got: &SvdRec, want: &[f64]) -> f64 { + got.s + .iter() + .enumerate() + .map(|(i, &g)| (g - want[i]).abs() / want[i].abs().max(1e-30)) + .fold(0.0f64, f64::max) +} + +// --------------------------------------------------------------------------- + +/// IRLBA — and therefore the top-level `svd` — must match LAPACK on every fixture. +#[test] +fn irlba_matches_lapack_everywhere() { + for (name, a, rank) in fixtures() { + let want = reference(&dense_of(&a)); + let got = irlba::svd_seed(&a, rank, 42).unwrap_or_else(|e| panic!("{name}: {e}")); + let err = max_rel(&got, &want); + assert!(err < 1e-9, "{name}: max relative error {err:.3e}"); + } +} + +#[test] +fn top_level_svd_matches_irlba() { + for (name, a, rank) in fixtures() { + let via_top = single_svdlib::svd_seed(&a, rank, 42).unwrap(); + let via_mod = irlba::svd_seed(&a, rank, 42).unwrap(); + assert_eq!(via_top.s, via_mod.s, "{name}"); + } +} + +/// Randomized SVD must land within its accuracy class on every fixture, and block +/// Krylov must never be worse than power iteration. +#[test] +fn randomized_lands_within_its_accuracy_class() { + for (name, a, rank) in fixtures() { + let want = reference(&dense_of(&a)); + + let power = randomized::svd_with( + &a, + &randomized::RandomizedConfig::new(rank) + .seed(42) + .power_iterations(7), + None, + ) + .unwrap_or_else(|e| panic!("{name} power: {e}")); + + let krylov = randomized::svd_block_krylov(&a, rank, 4, Some(42)) + .unwrap_or_else(|e| panic!("{name} krylov: {e}")); + + let e_power = max_rel(&power, &want); + let e_krylov = max_rel(&krylov, &want); + + // A loose absolute bar: randomized methods cannot be held to LAPACK precision + // on a flat spectrum, but they must be in the right ballpark. + assert!(e_power < 0.2, "{name}: power iteration error {e_power:.3e}"); + assert!(e_krylov < 0.2, "{name}: block krylov error {e_krylov:.3e}"); + // Block Krylov exists to dominate power iteration; allow a little slack for + // cases where both are already at machine precision. + assert!( + e_krylov <= e_power * 1.5 + 1e-12, + "{name}: block krylov {e_krylov:.3e} worse than power iteration {e_power:.3e}" + ); + } +} + +/// Storage order must not change the answer. +#[test] +fn csr_and_csc_agree_across_solvers() { + for (name, a, rank) in fixtures() { + let csc = a.to_other_storage(); + assert!(a.is_csr() && csc.is_csc(), "{name}: storage setup"); + + let i_csr = irlba::svd_seed(&a, rank, 42).unwrap(); + let i_csc = irlba::svd_seed(&csc, rank, 42).unwrap(); + for (x, y) in i_csr.s.iter().zip(i_csc.s.iter()) { + approx::assert_relative_eq!(x, y, max_relative = 1e-10); + } + + let r_csr = randomized::svd_seed(&a, rank, 42).unwrap(); + let r_csc = randomized::svd_seed(&csc, rank, 42).unwrap(); + for (x, y) in r_csr.s.iter().zip(r_csc.s.iter()) { + approx::assert_relative_eq!(x, y, max_relative = 1e-9); + } + } +} + +/// Index width is a memory choice, not a numerical one. +#[test] +fn index_widths_agree() { + let a32 = sparse(300, 100, 0.05, 23); + + let mut t64 = TriMatI::::new((300, 100)); + for (v, (i, j)) in a32.iter() { + t64.add_triplet(i as usize, j as usize, *v); + } + let a64: SvdMat = t64.to_csr::(); + + let x = irlba::svd_seed(&a32, 10, 42).unwrap(); + let y = irlba::svd_seed(&a64, 10, 42).unwrap(); + for (p, q) in x.s.iter().zip(y.s.iter()) { + approx::assert_relative_eq!(p, q, max_relative = 1e-12); + } +} + +/// Rank-`k` truncation error must equal the reference spectral tail exactly: +/// `||A - A_k||_F = sqrt(sum_{i>k} sigma_i^2)`. This is a much stronger check than +/// comparing singular values, because it tests the vectors too. +#[test] +fn truncation_error_matches_spectral_tail() { + for (name, a, rank) in fixtures() { + let dense = dense_of(&a); + let refs = reference(&dense); + let tail: f64 = refs[rank..].iter().map(|v| v * v).sum::().sqrt(); + + let got = irlba::svd_seed(&a, rank, 42).unwrap(); + let err: f64 = (&got.recompose() - &dense) + .iter() + .map(|v| v * v) + .sum::() + .sqrt(); + approx::assert_relative_eq!(err, tail, max_relative = 1e-6); + assert!(err.is_finite(), "{name}: non-finite reconstruction error"); + } +} + +/// `A·vᵢ = σᵢ·uᵢ` for every returned triplet, which is the definition. +#[test] +fn triplets_satisfy_the_defining_relation() { + for (name, a, rank) in fixtures() { + let got = irlba::svd_seed(&a, rank, 42).unwrap(); + for i in 0..got.d { + let vi: Vec = got.vt.row(i).to_vec(); + let mut av = vec![0.0; a.rows()]; + SparseMat::mul_vec(&a, &vi, &mut av, false); + let resid: f64 = av + .iter() + .zip(got.u.column(i).iter()) + .map(|(&x, &ui)| { + let d = x - got.s[i] * ui; + d * d + }) + .sum::() + .sqrt(); + assert!( + resid / got.s[0] < 1e-8, + "{name} triplet {i}: ||A v - s u|| / s_max = {:.3e}", + resid / got.s[0] + ); + } + } +} + +/// PCA on a masked matrix, checked against explicitly building the submatrix and +/// centering it densely — the composition 1.x got wrong in three separate places. +#[test] +fn masked_pca_matches_dense_reference() { + let a = sparse(400, 40, 0.15, 29); + let cols: Vec = (0..40).filter(|c| c % 3 == 0).collect(); + let masked = MaskedCsMat::with_columns(&a, &cols); + + // The submatrix, densely. + let full = dense_of(&a); + let mut sub = Array2::::zeros((400, cols.len())); + for (new, &old) in cols.iter().enumerate() { + sub.column_mut(new).assign(&full.column(old)); + } + let means = sub.mean_axis(Axis(0)).unwrap(); + let centered = &sub - &means.view().insert_axis(Axis(0)); + let want = reference(¢ered); + + let rank = 6; + let got = irlba::svd_centered(&masked, rank, Some(42)).unwrap(); + assert_eq!(got.u.nrows(), 400); + assert_eq!(got.vt.ncols(), cols.len()); + for (i, &g) in got.s.iter().enumerate() { + let rel = (g - want[i]).abs() / want[i].abs().max(1e-30); + assert!( + rel < 1e-8, + "masked PCA singular value {i}: {g:.9e} vs {:.9e} (rel {rel:.3e})", + want[i] + ); + } + + // The denominator must be the centered submatrix. Using the full 40-column matrix + // would make every ratio proportionally too small but still look well-formed. + let want_total: f64 = centered.iter().map(|v| v * v).sum(); + let rel_norm = (got.total_squared_norm - want_total).abs() / want_total; + assert!( + rel_norm < 1e-12, + "masked centered norm {:.9e} vs dense {want_total:.9e} (rel {rel_norm:.3e})", + got.total_squared_norm + ); + + let ratio = got.explained_variance_ratio(); + for i in 0..rank { + let expect = want[i] * want[i] / want_total; + assert!( + (ratio[i] - expect).abs() < 1e-9, + "masked explained_variance_ratio[{i}] = {:.9e} vs {expect:.9e}", + ratio[i] + ); + } + // sklearn's convention: the ratio is explained_variance / total_variance. + let ev = got.explained_variance(); + for i in 0..rank { + approx::assert_relative_eq!(ev[i] / got.total_variance(), ratio[i], max_relative = 1e-12); + } +} + +/// f32 PCA on columns with a large offset: centering subtracts near-equal quantities +/// and f32 has only ~7 digits to spend on the difference. +#[test] +fn f32_centered_with_column_offset() { + let (rows, cols) = (300usize, 24usize); + let mut t = TriMatI::::new((rows, cols)); + let mut dense = Array2::::zeros((rows, cols)); + for i in 0..rows { + for j in 0..cols { + // A rank-structured signal of size ~1, sitting on an offset of 500. + let signal = + ((i % 5) as f32) * ((j % 3) as f32) * 0.25 + ((i * 3 + j) % 7) as f32 * 0.1; + let v = 500.0 + signal; + t.add_triplet(i, j, v); + dense[[i, j]] = v as f64; + } + } + let a: SvdMat = t.to_csr::(); + + // Center in f64 using the same f32 means the solver uses, so this measures the + // solver and not the representation. + let means = SparseMatDense::col_means(&a); + let means64 = Array1::from_iter(means.iter().map(|&m| m as f64)); + let centered = &dense - &means64.view().insert_axis(Axis(0)); + let want = reference(¢ered); + let want_norm: f64 = centered.iter().map(|&v| v * v).sum(); + + let got = irlba::svd_centered(&a, 5, Some(42)).unwrap(); + assert!(got.converged()); + + let scale = want[0].max(1e-30); + for i in 0..got.d { + let err = (got.s[i] as f64 - want[i]).abs(); + assert!( + err < 1e-3 * scale, + "f32 offset PCA value {i}: {:.6e} vs {:.6e} (abs err {err:.3e}, scale {scale:.3e})", + got.s[i], + want[i] + ); + } + + // The denominator is what cancels — the closed form was 51% wrong here. + let rel = (got.total_squared_norm as f64 - want_norm).abs() / want_norm; + assert!( + rel < 1e-5, + "f32 offset centered norm {:.6e} vs {want_norm:.6e} (rel {rel:.3e})", + got.total_squared_norm + ); + let total: f32 = got.explained_variance_ratio().iter().sum(); + assert!( + total <= 1.0 + 1e-5, + "f32 offset ratios sum to {total:.9e}, above 1" + ); +} + +/// f32 must work end to end and land at f32 precision. +#[test] +fn f32_end_to_end() { + let a64 = lowrank(200, 60, 8, 31); + let want = reference(&dense_of(&a64)); + + let mut t = TriMatI::::new((200, 60)); + for (v, (i, j)) in a64.iter() { + t.add_triplet(i as usize, j as usize, *v as f32); + } + let a32: SvdMat = t.to_csr::(); + + let got = irlba::svd_seed(&a32, 8, 42).unwrap(); + for (i, &g) in got.s.iter().enumerate() { + let rel = ((g as f64) - want[i]).abs() / want[i].abs().max(1e-30); + assert!(rel < 1e-4, "f32 singular value {i}: rel {rel:.3e}"); + } +} + +/// Seeded runs must be bit-reproducible; unseeded ones must actually differ. +#[test] +fn seeding_behaves() { + let a = sparse(200, 80, 0.08, 37); + + let x = irlba::svd_seed(&a, 8, 7).unwrap(); + let y = irlba::svd_seed(&a, 8, 7).unwrap(); + assert_eq!(x.s, y.s); + assert_eq!(x.u, y.u); + assert_eq!(x.vt, y.vt); + + let cfg = randomized::RandomizedConfig::new(6).power_iterations(0); + let p = randomized::svd_with(&a, &cfg, None).unwrap(); + let q = randomized::svd_with(&a, &cfg, None).unwrap(); + assert_ne!( + p.diagnostics.random_seed, q.diagnostics.random_seed, + "unseeded runs reused a seed" + ); +} + +/// Requesting more triplets than the matrix can supply must be an error, not a panic +/// or silent truncation. +#[test] +fn out_of_range_rank_is_an_error() { + let a = sparse(30, 12, 0.3, 41); + assert!(irlba::svd(&a, 13).is_err()); + assert!(randomized::svd(&a, 13).is_err()); + assert!(single_svdlib::svd(&a, 0).is_err()); +} + +/// The deprecated module must still compile and run when its feature is enabled — 2.0 +/// gates the API off by default but does not remove it. +#[cfg(feature = "las2")] +#[test] +#[allow(deprecated)] +fn deprecated_lanczos_still_callable() { + let a = sparse(120, 50, 0.1, 43); + // Only that it runs; its accuracy is documented as unreliable. + let got = single_svdlib::lanczos::svd_dim_seed(&a, 8, 42); + assert!( + got.is_ok(), + "deprecated path failed to run: {:?}", + got.err() + ); +} diff --git a/tests/properties.proptest-regressions b/tests/properties.proptest-regressions new file mode 100644 index 0000000..fd66d25 --- /dev/null +++ b/tests/properties.proptest-regressions @@ -0,0 +1,11 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 444fc1c28f0624222f330a43337bd3f6b0742850e8d8e90e0767a476dd37e7b6 # shrinks to (a, d) = (CsMatBase { storage: CSR, nrows: 15, ncols: 4, indptr: IndPtrBase { storage: [0, 2, 5, 7, 10, 13, 17, 19, 22, 24, 28, 31, 34, 36, 39, 41] }, indices: [0, 2, 1, 2, 3, 0, 1, 0, 2, 3, 1, 2, 3, 0, 1, 2, 3, 0, 2, 0, 1, 3, 2, 3, 0, 1, 2, 3, 0, 1, 3, 0, 1, 2, 0, 3, 0, 1, 2, 1, 3], data: [-6.2913543894782675, -25699141.941067617, 0.5588126569647607, -5.859926538473093, -1.9480286157577804e-5, 675.368369914793, 3.051232191043276, 9.517312019557389, 1.3519592427519336, 3.0, -2.0, 0.8157956183940058, -6.0, 5.1359201790060265, 398260.9609002883, 1.708665909372323, 5.110933052579247e-8, -0.03323578776493455, -0.001, 3.7963147865936313, -6.638242872105427, 4.589403380652678, 0.21741197007938923, 6.496900488492738, -5.0, -4.0, 1.0, 3.213639516272398, -5.232777655785047, -4.1765939134870615, -5.808563243607353, -1.9949720813817835, -7.0, 6.834168876818538, -8.237949875026752, -8.978730620660105, -6.696835417731905, -5.6631715325025, 3.078995263920262, 5.0, 9.534697922897974] }, [[-6.2913543894782675, 0.0, -25699141.941067617, 0.0], [0.0, 0.5588126569647607, -5.859926538473093, -1.9480286157577804e-5], [675.368369914793, 3.051232191043276, 0.0, 0.0], [9.517312019557389, 0.0, 1.3519592427519336, 3.0], [0.0, -2.0, 0.8157956183940058, -6.0], [5.1359201790060265, 398260.9609002883, 1.708665909372323, 5.110933052579247e-8], [-0.03323578776493455, 0.0, -0.001, 0.0], [3.7963147865936313, -6.638242872105427, 0.0, 4.589403380652678], [0.0, 0.0, 0.21741197007938923, 6.496900488492738], [-5.0, -4.0, 1.0, 3.213639516272398], [-5.232777655785047, -4.1765939134870615, 0.0, -5.808563243607353], [-1.9949720813817835, -7.0, 6.834168876818538, 0.0], [-8.237949875026752, 0.0, 0.0, -8.978730620660105], [-6.696835417731905, -5.6631715325025, 3.078995263920262, 0.0], [0.0, 5.0, 0.0, 9.534697922897974]], shape=[15, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2) +cc e26ab148443740c3bb9326eccb78197323da04fb24417509dbcb2dbbb2b05b0b # shrinks to a = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.8977478193857099, -1.0], [0.0, 7255691.862956913]], shape=[5, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2 +cc 725de8dea69f598291a27c0bd5af2affedfc026b92258278a63271ff6ee7dd18 # shrinks to (a, d) = (CsMatBase { storage: CSR, nrows: 12, ncols: 4, indptr: IndPtrBase { storage: [0, 4, 7, 8, 11, 15, 16, 19, 22, 25, 28, 29, 31] }, indices: [0, 1, 2, 3, 0, 1, 3, 1, 1, 2, 3, 0, 1, 2, 3, 3, 1, 2, 3, 0, 1, 2, 0, 1, 2, 0, 2, 3, 1, 0, 2], data: [-0.9078633730099904, -2.093894927027082, -7.294716936340985, 2238.0469644871578, 2.55518183120856, 2.387120201553134, -5.0, -1.0, 4.041625132113301, 0.25266579182188564, 9.5535313584502, 5.374055222440424, -0.2822660925873829, -2.0, -5.892422261027978, -3.0, -8.315033206207554, 5.0, 1.2044439965056533, -7.940287247756354, 4.566756667882264, 0.23530487127617883, 1.0, 234272.5654957103, -6.0, -3.254551218061495, 2.3728880209870944, -6.152810873612488, 7.917112962755349, 3.4661145830068345, 8.441815004461471] }, [[-0.9078633730099904, -2.093894927027082, -7.294716936340985, 2238.0469644871578], [2.55518183120856, 2.387120201553134, 0.0, -5.0], [0.0, -1.0, 0.0, 0.0], [0.0, 4.041625132113301, 0.25266579182188564, 9.5535313584502], [5.374055222440424, -0.2822660925873829, -2.0, -5.892422261027978], [0.0, 0.0, 0.0, -3.0], [0.0, -8.315033206207554, 5.0, 1.2044439965056533], [-7.940287247756354, 4.566756667882264, 0.23530487127617883, 0.0], [1.0, 234272.5654957103, -6.0, 0.0], [-3.254551218061495, 0.0, 2.3728880209870944, -6.152810873612488], [0.0, 7.917112962755349, 0.0, 0.0], [3.4661145830068345, 0.0, 8.441815004461471, 0.0]], shape=[12, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2) +cc ca791972ef96f2f03006fef714ee12c2014aefff6e33af872aee2f7a223c07db # shrinks to (a, d) = (CsMatBase { storage: CSR, nrows: 11, ncols: 13, indptr: IndPtrBase { storage: [0, 1, 3, 12, 19, 26, 33, 40, 46, 47, 48, 56] }, indices: [10, 7, 11, 0, 1, 2, 3, 5, 6, 7, 9, 11, 0, 2, 5, 6, 7, 9, 11, 0, 3, 5, 6, 7, 11, 12, 0, 3, 5, 6, 7, 9, 11, 0, 2, 3, 5, 6, 11, 12, 2, 3, 5, 6, 7, 9, 8, 4, 0, 1, 2, 3, 5, 7, 9, 12], data: [595.1637423683952, -374582.63707232947, -36141582.76347948, -1.0, -5.632628226167156, 4.827094230528077, -0.03709090810370358, -1.0, -8.373690814856918, 4.0, 7.0, 5.0, 1.3451867059878135, -8.389419457096713, 2.0, 5.471276828590632, 5.581321301337688, 0.001, -11.275674356042453, -4.0, 0.0001, -0.07886288033218106, -6.109533905108408, 2.4955157759482494, 0.01, 994597.235652603, -9.758015633852636, 0.854453497926349, 2.0, 5.0, 0.001, 3.0539425877419446, 0.01, -0.75151056626167, -7.235304691177424, 5.366085961705634, -1.4064732516987013, 3.544716677020206, 0.1, -693048.5531239766, 32.10124084423937, 9.591998530857255, 3.7914374857722377, 0.01, -8.812731497402883, -7.7437288231465295, -9.133120344512113, 100000.0, -1.168743249836656, 8.315300964152964, 0.01, 5.0, 5.0, 7.0, 0.31696302919625496, -45910953.31239337] }, [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 595.1637423683952, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -374582.63707232947, 0.0, 0.0, 0.0, -36141582.76347948, 0.0], [-1.0, -5.632628226167156, 4.827094230528077, -0.03709090810370358, 0.0, -1.0, -8.373690814856918, 4.0, 0.0, 7.0, 0.0, 5.0, 0.0], [1.3451867059878135, 0.0, -8.389419457096713, 0.0, 0.0, 2.0, 5.471276828590632, 5.581321301337688, 0.0, 0.001, 0.0, -11.275674356042453, 0.0], [-4.0, 0.0, 0.0, 0.0001, 0.0, -0.07886288033218106, -6.109533905108408, 2.4955157759482494, 0.0, 0.0, 0.0, 0.01, 994597.235652603], [-9.758015633852636, 0.0, 0.0, 0.854453497926349, 0.0, 2.0, 5.0, 0.001, 0.0, 3.0539425877419446, 0.0, 0.01, 0.0], [-0.75151056626167, 0.0, -7.235304691177424, 5.366085961705634, 0.0, -1.4064732516987013, 3.544716677020206, 0.0, 0.0, 0.0, 0.0, 0.1, -693048.5531239766], [0.0, 0.0, 32.10124084423937, 9.591998530857255, 0.0, 3.7914374857722377, 0.01, -8.812731497402883, 0.0, -7.7437288231465295, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -9.133120344512113, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 100000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [-1.168743249836656, 8.315300964152964, 0.01, 5.0, 0.0, 5.0, 0.0, 7.0, 0.0, 0.31696302919625496, 0.0, 0.0, -45910953.31239337]], shape=[11, 13], strides=[13, 1], layout=Cc (0x5), const ndim=2) +cc 596f7299fb8e32561c056d48adc6d23cb8501006356fb4010f9eb2de7ba695cb # shrinks to (a, _d) = (CsMatBase { storage: CSR, nrows: 19, ncols: 19, indptr: IndPtrBase { storage: [0, 1, 5, 6, 10, 11, 18, 19, 21, 22, 28, 29, 30, 31, 36, 38, 39, 39, 39, 39] }, indices: [6, 5, 7, 9, 12, 16, 3, 9, 12, 15, 2, 3, 4, 5, 7, 9, 12, 15, 0, 1, 6, 11, 3, 7, 10, 12, 13, 14, 0, 10, 17, 4, 9, 10, 12, 14, 1, 18, 15], data: [43031126.122012034, 7.801470258557793, 1.0, -1.0, 1.0, 6236664.837416687, -8.224209275254934, -1.0, -8.900945416369911, -7.11348238407316, 33872789.91170271, -3.0, -326.8230131900567, 4.191668263349865, 2.889959055645891, 1.0, 0.1, 1.6128653409081206, 4563.152043682442, 5184.788585836254, -91005586.91660911, 2569607.8878492117, 3.9814313340467495, -2.8660624794707448, -1.7816185982240527, -7.219402834071076, 0.1, 0.01, -64441689.28445951, -3.449148340102838, 1000000.0, 1546.8167727637094, -3.0, -9.027501386579845, 66.41337166486389, 1.0, 1000000.0, 7.489912538714374, -0.06762731845007887] }, [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 43031126.122012034, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 7.801470258557793, 0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 6236664.837416687, 0.0, 0.0], [0.0, 0.0, 0.0, -8.224209275254934, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, -8.900945416369911, 0.0, 0.0, -7.11348238407316, 0.0, 0.0, 0.0], [0.0, 0.0, 33872789.91170271, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, -3.0, -326.8230131900567, 4.191668263349865, 0.0, 2.889959055645891, 0.0, 1.0, 0.0, 0.0, 0.1, 0.0, 0.0, 1.6128653409081206, 0.0, 0.0, 0.0], [4563.152043682442, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 5184.788585836254, 0.0, 0.0, 0.0, 0.0, -91005586.91660911, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2569607.8878492117, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 3.9814313340467495, 0.0, 0.0, 0.0, -2.8660624794707448, 0.0, 0.0, -1.7816185982240527, 0.0, -7.219402834071076, 0.1, 0.01, 0.0, 0.0, 0.0, 0.0], [-64441689.28445951, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -3.449148340102838, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1000000.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1546.8167727637094, 0.0, 0.0, 0.0, 0.0, -3.0, -9.027501386579845, 0.0, 66.41337166486389, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], [0.0, 1000000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 7.489912538714374], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.06762731845007887, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], shape=[19, 19], strides=[19, 1], layout=Cc (0x5), const ndim=2) diff --git a/tests/properties.rs b/tests/properties.rs new file mode 100644 index 0000000..d04cbc4 --- /dev/null +++ b/tests/properties.rs @@ -0,0 +1,825 @@ +//! Property-based tests. +//! +//! The hand-written suites check cases someone thought of. These check invariants that +//! must hold for *any* operand, over shapes, densities and value distributions that +//! proptest chooses — including ones nobody would think to write down. +//! +//! # Tolerance policy +//! +//! Every comparison is relative to `σ_max`, never to the individual singular value. +//! A trailing singular value can legitimately be `1e-18` on a rank-deficient operand, +//! where its own relative error is meaningless but its absolute error against the +//! matrix scale is exactly what matters. This is the same convention LAPACK's own test +//! suite uses. + +#![allow(clippy::needless_range_loop)] + +use ndarray::{Array2, Axis}; +use proptest::prelude::*; +use single_svdlib::{ + dense, irlba, matrix::kernels, randomized, MaskedCsMat, SparseMat, SparseMatDense, SvdMat, +}; +use sprs::TriMatI; + +// --------------------------------------------------------------------------- +// Strategies +// --------------------------------------------------------------------------- + +/// Value distributions that stress different failure modes. +fn value() -> impl Strategy { + prop_oneof![ + // Ordinary magnitudes. + 4 => -10.0f64..10.0, + // Wide dynamic range: exercises cancellation and scaling. + 2 => (-8i32..8, 1.0f64..10.0, any::()) + .prop_map(|(e, m, neg)| { + let v = m * 10f64.powi(e); + if neg { -v } else { v } + }), + // Small integers are exact in f64, so they exercise the exact-arithmetic paths + // and produce genuine rank deficiency far more often than continuous values. + 2 => (-8i64..8).prop_map(|v| v as f64), + // Exact zeros, to produce structural sparsity and empty rows/columns. + 1 => Just(0.0), + ] +} + +/// A sparse matrix and the dense array holding exactly the same content. +/// +/// Both are built from one buffer, so any disagreement is a bug in the code under +/// test rather than in the fixture. +fn matrix(max_dim: usize) -> impl Strategy, Array2)> { + (2usize..=max_dim, 2usize..=max_dim).prop_flat_map(|(rows, cols)| { + proptest::collection::vec(value(), rows * cols).prop_map(move |vals| { + let mut tri = TriMatI::::new((rows, cols)); + let mut dense = Array2::::zeros((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let v = vals[i * cols + j]; + dense[[i, j]] = v; + if v != 0.0 { + tri.add_triplet(i, j, v); + } + } + } + (tri.to_csr::(), dense) + }) + }) +} + +/// A dense tall-skinny matrix, for the QR properties. +fn tall(max_rows: usize, max_cols: usize) -> impl Strategy> { + (1usize..=max_cols) + .prop_flat_map(move |cols| (Just(cols), cols..=max_rows.max(cols))) + .prop_flat_map(|(cols, rows)| { + proptest::collection::vec(value(), rows * cols) + .prop_map(move |v| Array2::from_shape_vec((rows, cols), v).unwrap()) + }) +} + +/// A matrix paired with a rank that is always valid for it, so the strategy never has +/// to reject — filtering `rank` after the fact exhausts proptest's global reject budget. +fn matrix_and_rank(max_dim: usize) -> impl Strategy, Array2, usize)> { + matrix(max_dim).prop_flat_map(|(a, d)| { + let min_dim = a.rows().min(a.cols()); + (Just(a), Just(d), 1usize..=min_dim) + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn reference(a: &Array2) -> Vec { + let m = nalgebra::DMatrix::from_fn(a.nrows(), a.ncols(), |i, j| a[[i, j]]); + let mut s: Vec = m.singular_values().iter().copied().collect(); + s.sort_by(|x, y| y.partial_cmp(x).unwrap()); + s +} + +fn frob(a: &Array2) -> f64 { + a.iter().map(|v| v * v).sum::().sqrt() +} + +/// The matrix scale every tolerance is measured against. +fn scale(want: &[f64]) -> f64 { + want.first().copied().unwrap_or(0.0).max(f64::MIN_POSITIVE) +} + +// --------------------------------------------------------------------------- +// Core solver invariants +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 200, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// IRLBA's singular values must match a dense reference, for any operand. + #[test] + fn irlba_matches_dense_reference((a, d) in matrix(22)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let s = scale(&want); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + let (converged, max_residual, restarts) = match got.diagnostics.detail { + single_svdlib::Detail::Irlba { converged, max_residual, restarts, .. } => (converged, max_residual, restarts), + _ => unreachable!(), + }; + for i in 0..got.d { + let err = (got.s[i] - want[i]).abs(); + prop_assert!( + err <= 1e-8 * s, + "triplet {i}: got {:.12e}, want {:.12e}, abs err {:.3e} vs scale {:.3e} \ + [converged={converged} restarts={restarts} max_residual={max_residual:.3e} rank={} d={} shape={:?}]\n\ + got : {:?}\n want: {:?}", + got.s[i], want[i], err, s, rank, got.d, (a.rows(), a.cols()), + got.s.iter().map(|v| format!("{v:.6e}")).collect::>(), + want.iter().map(|v| format!("{v:.6e}")).collect::>() + ); + } + } + + /// Shapes, ordering and finiteness — the contract every caller relies on. + #[test] + fn irlba_output_is_well_formed((a, _d) in matrix(22)) { + let (rows, cols) = (a.rows(), a.cols()); + let min_dim = rows.min(cols); + let rank = (min_dim - 1).max(1); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + prop_assert_eq!(got.u.dim(), (rows, got.d), "u shape"); + prop_assert_eq!(got.vt.dim(), (got.d, cols), "vt shape"); + prop_assert_eq!(got.s.len(), got.d, "s length"); + + prop_assert!(got.s.iter().all(|v| v.is_finite() && *v >= 0.0), "s: {:?}", got.s); + prop_assert!(got.u.iter().all(|v| v.is_finite()), "u has non-finite entries"); + prop_assert!(got.vt.iter().all(|v| v.is_finite()), "vt has non-finite entries"); + + for w in got.s.to_vec().windows(2) { + prop_assert!(w[0] >= w[1], "not descending: {:?}", got.s); + } + } + + /// Singular vectors must be orthonormal, including on rank-deficient operands + /// where the trailing directions are arbitrary but still must form a basis. + #[test] + fn irlba_vectors_are_orthonormal((a, _d) in matrix(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + let ou = dense::orthogonality_error(&got.u.view()); + prop_assert!(ou < 1e-8, "||U^T U - I||_F = {ou:.3e}"); + + let vt_t = got.vt.t().to_owned(); + let ov = dense::orthogonality_error(&vt_t.view()); + prop_assert!(ov < 1e-8, "||V^T V - I||_F = {ov:.3e}"); + } + + /// The defining relation `A·vᵢ = σᵢ·uᵢ`, measured against the matrix scale. + #[test] + fn irlba_triplets_satisfy_definition((a, d) in matrix(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let s = scale(&want); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + for i in 0..got.d { + let vi: Vec = got.vt.row(i).to_vec(); + let mut av = vec![0.0; a.rows()]; + SparseMat::mul_vec(&a, &vi, &mut av, false); + let resid: f64 = av + .iter() + .zip(got.u.column(i).iter()) + .map(|(&x, &ui)| { let e = x - got.s[i] * ui; e * e }) + .sum::() + .sqrt(); + prop_assert!( + resid <= 1e-8 * s, + "triplet {i}: ||A v - s u|| = {resid:.3e} vs scale {s:.3e}" + ); + } + } + + /// Rank-`k` truncation error must equal the reference spectral tail. This tests the + /// singular *vectors*, not just the values — a wrong subspace shows up here even + /// when the values happen to be right. + #[test] + fn irlba_truncation_matches_spectral_tail((a, d) in matrix(18)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let tail: f64 = want[rank..].iter().map(|v| v * v).sum::().sqrt(); + let s = scale(&want); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + let err = frob(&(&got.recompose() - &d)); + prop_assert!( + (err - tail).abs() <= 1e-7 * s * (d.nrows() as f64).sqrt(), + "truncation error {err:.6e} vs spectral tail {tail:.6e} (scale {s:.3e})" + ); + } + + /// Storage order is an implementation detail and must not change the answer. + #[test] + fn storage_order_is_irrelevant((a, d) in matrix(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let s = scale(&want); + let csc = a.to_other_storage(); + + let x = irlba::svd_seed(&a, rank, 42); + let y = irlba::svd_seed(&csc, rank, 42); + prop_assume!(x.is_ok() && y.is_ok()); + let (x, y) = (x.unwrap(), y.unwrap()); + + for i in 0..x.d { + prop_assert!( + (x.s[i] - y.s[i]).abs() <= 1e-9 * s, + "CSR vs CSC differ at {i}: {:.12e} vs {:.12e}", x.s[i], y.s[i] + ); + } + } + + /// A fixed seed must reproduce byte-identical output. + #[test] + fn seeded_runs_are_reproducible((a, _d) in matrix(18)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let x = irlba::svd_seed(&a, rank, 7); + let y = irlba::svd_seed(&a, rank, 7); + prop_assume!(x.is_ok() && y.is_ok()); + let (x, y) = (x.unwrap(), y.unwrap()); + prop_assert_eq!(x.s, y.s); + prop_assert_eq!(x.u, y.u); + prop_assert_eq!(x.vt, y.vt); + } + + /// Whatever the operand, the solver returns — no panic, no hang, no non-finite + /// output smuggled out as success. Ranks beyond what the method supports must be + /// typed errors. + #[test] + fn solvers_always_terminate_cleanly((a, _d, rank) in matrix_and_rank(16)) { + // A typed error is always acceptable; silently returning garbage is not. + if let Ok(rec) = irlba::svd_seed(&a, rank, 42) { + prop_assert!(rec.s.iter().all(|v| v.is_finite()), "irlba leaked non-finite values"); + prop_assert_eq!(rec.d, rank); + } + if let Ok(rec) = randomized::svd_seed(&a, rank, 42) { + prop_assert!( + rec.s.iter().all(|v| v.is_finite()), + "randomized leaked non-finite values" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Randomized solvers +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 120, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// Randomized SVD can never *overestimate* a singular value by more than rounding: + /// it computes the SVD of a projection of A onto a subspace, and a projection + /// cannot have larger singular values than the original. Underestimation is + /// expected and is the method's approximation error. + #[test] + fn randomized_never_overestimates((a, d) in matrix(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let s = scale(&want); + + let cfg = randomized::RandomizedConfig::new(rank).seed(42).power_iterations(2); + let got = randomized::svd_with(&a, &cfg, None); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + for i in 0..got.d { + prop_assert!( + got.s[i] <= want[i] + 1e-9 * s, + "triplet {i}: randomized {:.12e} exceeds true {:.12e}", + got.s[i], want[i] + ); + } + } + + /// With enough power iterations the dominant singular value must be close, whatever + /// the operand. Trailing values are not held to this — that is the method's + /// documented weakness on flat spectra, not a defect. + #[test] + fn randomized_captures_the_dominant_value((a, d) in matrix(20)) { + let want = reference(&d); + let s = scale(&want); + prop_assume!(s > 1e-12); + + let cfg = randomized::RandomizedConfig::new(1).seed(42).power_iterations(12); + let got = randomized::svd_with(&a, &cfg, None); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + prop_assert!( + (got.s[0] - want[0]).abs() <= 1e-3 * s, + "dominant value {:.9e} vs true {:.9e}", got.s[0], want[0] + ); + } + + /// Block Krylov spans a superset of the power-iteration subspace at equal block + /// count, so it must never do worse on the dominant value. + #[test] + fn block_krylov_is_no_worse_than_power_iteration((a, d) in matrix(18)) { + let want = reference(&d); + let s = scale(&want); + prop_assume!(s > 1e-12); + + let power = randomized::svd_with( + &a, &randomized::RandomizedConfig::new(1).seed(42).power_iterations(3), None); + let krylov = randomized::svd_block_krylov(&a, 1, 4, Some(42)); + prop_assume!(power.is_ok() && krylov.is_ok()); + + let e_power = (power.unwrap().s[0] - want[0]).abs() / s; + let e_krylov = (krylov.unwrap().s[0] - want[0]).abs() / s; + prop_assert!( + e_krylov <= e_power + 1e-9, + "block krylov {e_krylov:.3e} worse than power iteration {e_power:.3e}" + ); + } +} + +// --------------------------------------------------------------------------- +// Kernels and dense helpers +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 300, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// The parallel sparse x dense kernels must equal the dense products exactly enough + /// that only summation order distinguishes them. + #[test] + fn kernels_match_dense_products( + (a, d) in matrix(24), + k in 1usize..6, + budget in prop_oneof![Just(0usize), Just(1024), Just(usize::MAX)], + ) { + let (rows, cols) = (a.rows(), a.cols()); + let scale_a = frob(&d).max(1.0); + + // A · D + let rhs = Array2::from_shape_fn((cols, k), |(i, j)| ((i * 7 + j * 3) % 11) as f64 - 5.0); + let mut out = Array2::zeros((rows, k)); + kernels::gather_mul(a.view(), rhs.view(), out.view_mut()); + let want = d.dot(&rhs); + let err = frob(&(&out - &want)); + prop_assert!(err <= 1e-9 * scale_a * (k as f64), "gather_mul err {err:.3e}"); + + // Aᵀ · D, at every scratch budget + let rhs_t = Array2::from_shape_fn((rows, k), |(i, j)| ((i * 5 + j) % 9) as f64 - 4.0); + let mut out_t = Array2::zeros((cols, k)); + kernels::scatter_mul(a.view(), rhs_t.view(), out_t.view_mut(), budget); + let want_t = d.t().dot(&rhs_t); + let err_t = frob(&(&out_t - &want_t)); + prop_assert!(err_t <= 1e-9 * scale_a * (k as f64), "scatter_mul err {err_t:.3e} at budget {budget}"); + } + + /// Mean centering as a rank-1 correction must equal explicitly building the + /// centered dense matrix. + #[test] + fn centering_matches_explicit_dense((a, d) in matrix(20), k in 1usize..4) { + let (rows, cols) = (a.rows(), a.cols()); + let means = SparseMatDense::col_means(&a); + let want_means = d.mean_axis(Axis(0)).unwrap(); + for (g, w) in means.iter().zip(want_means.iter()) { + prop_assert!((g - w).abs() <= 1e-9 * (w.abs() + 1.0), "col mean {g} vs {w}"); + } + + let centered = &d - &want_means.view().insert_axis(Axis(0)); + let scale_c = frob(¢ered).max(1.0); + + let rhs = Array2::from_shape_fn((cols, k), |(i, j)| ((i * 3 + j) % 7) as f64 - 3.0); + let mut out = Array2::zeros((rows, k)); + SparseMatDense::mul_dense_centered(&a, rhs.view(), out.view_mut(), false, means.view()); + let err = frob(&(&out - ¢ered.dot(&rhs))); + prop_assert!(err <= 1e-8 * scale_c * (k as f64), "centered A·D err {err:.3e}"); + + let rhs_t = Array2::from_shape_fn((rows, k), |(i, j)| ((i + j * 2) % 5) as f64 - 2.0); + let mut out_t = Array2::zeros((cols, k)); + SparseMatDense::mul_dense_centered(&a, rhs_t.view(), out_t.view_mut(), true, means.view()); + let err_t = frob(&(&out_t - ¢ered.t().dot(&rhs_t))); + prop_assert!(err_t <= 1e-8 * scale_c * (k as f64), "centered Aᵀ·D err {err_t:.3e}"); + } + + /// A masked view must behave exactly like the physically extracted submatrix. + #[test] + fn masked_view_matches_physical_subset( + (a, d) in matrix(20), + picks in proptest::collection::vec(any::(), 1..21), + anchor in any::(), + ) { + let cols = a.cols(); + let mut selected: Vec = + (0..cols).filter(|c| *picks.get(*c % picks.len()).unwrap_or(&true)).collect(); + // Guarantee a non-empty mask instead of rejecting the empty draw — rejection + // here burns proptest's global reject budget and aborts the run. + if selected.is_empty() { + selected.push(anchor.index(cols)); + } + + let masked = MaskedCsMat::with_columns(&a, &selected); + prop_assert_eq!(masked.cols(), selected.len()); + + // The physical submatrix. + let mut sub = Array2::::zeros((a.rows(), selected.len())); + for (new, &old) in selected.iter().enumerate() { + sub.column_mut(new).assign(&d.column(old)); + } + let scale_s = frob(&sub).max(1.0); + + // Matvec, both directions. + let x: Vec = (0..selected.len()).map(|i| (i % 5) as f64 - 2.0).collect(); + let mut y = vec![0.0; a.rows()]; + masked.mul_vec(&x, &mut y, false); + let want = sub.dot(&ndarray::Array1::from_vec(x)); + for (g, w) in y.iter().zip(want.iter()) { + prop_assert!((g - w).abs() <= 1e-9 * scale_s, "masked A·x {g} vs {w}"); + } + + let xt: Vec = (0..a.rows()).map(|i| (i % 3) as f64 - 1.0).collect(); + let mut yt = vec![0.0; selected.len()]; + masked.mul_vec(&xt, &mut yt, true); + let want_t = sub.t().dot(&ndarray::Array1::from_vec(xt)); + for (g, w) in yt.iter().zip(want_t.iter()) { + prop_assert!((g - w).abs() <= 1e-9 * scale_s, "masked Aᵀ·x {g} vs {w}"); + } + } +} + +// --------------------------------------------------------------------------- +// TSQR +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 200, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// `A == Q·R` with `Q` orthonormal and `R` upper triangular, for any tall operand. + #[test] + fn tsqr_factorizes_correctly(a in tall(40, 8)) { + let original = a.clone(); + let mut q = a; + let r = dense::tsqr(&mut q); + prop_assume!(r.is_ok()); + let r = r.unwrap(); + let (m, n) = original.dim(); + + prop_assert_eq!(q.dim(), (m, n)); + prop_assert_eq!(r.dim(), (n, n)); + prop_assert!(q.iter().all(|v| v.is_finite()), "Q has non-finite entries"); + prop_assert!(r.iter().all(|v| v.is_finite()), "R has non-finite entries"); + + // R upper triangular. + for i in 1..n { + for j in 0..i { + prop_assert!(r[[i, j]].abs() < 1e-10 * frob(&original).max(1.0), + "R not upper triangular at ({i},{j}): {}", r[[i, j]]); + } + } + + // Q orthonormal. + let orth = dense::orthogonality_error(&q.view()); + prop_assert!(orth < 1e-9, "||Q^T Q - I||_F = {orth:.3e} for {m}x{n}"); + + // A == Q R. + let scale_a = frob(&original).max(f64::MIN_POSITIVE); + let err = frob(&(&q.dot(&r) - &original)) / scale_a; + prop_assert!(err < 1e-9, "||A - QR||/||A|| = {err:.3e} for {m}x{n}"); + } + + /// The small dense SVD must reconstruct its operand and come back ordered. + #[test] + fn small_svd_reconstructs(a in tall(16, 10)) { + let svd = dense::small_svd(a.view()); + prop_assume!(svd.is_ok()); + let svd = svd.unwrap(); + + for w in svd.s.to_vec().windows(2) { + prop_assert!(w[0] >= w[1], "singular values not descending"); + } + prop_assert!(svd.s.iter().all(|v| v.is_finite() && *v >= 0.0)); + + let scaled = &svd.u * &svd.s.view().insert_axis(Axis(0)); + let err = frob(&(&scaled.dot(&svd.vt) - &a)) / frob(&a).max(f64::MIN_POSITIVE); + prop_assert!(err < 1e-9, "relative reconstruction {err:.3e}"); + } +} + +// --------------------------------------------------------------------------- +// Explained variance +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 200, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// Ratios must be in `[0, 1]`, descending, and sum to at most 1 — anything above 1 + /// would claim more energy than the matrix has. + #[test] + fn explained_variance_ratios_are_a_partition((a, d) in matrix(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + // Uncentered, so the denominator is the plain Frobenius norm. + let want_norm = d.iter().map(|v| v * v).sum::(); + prop_assert!( + (got.total_squared_norm - want_norm).abs() <= 1e-9 * want_norm.max(f64::MIN_POSITIVE), + "total_squared_norm {:.6e} vs dense {:.6e}", got.total_squared_norm, want_norm + ); + + let ratio = got.explained_variance_ratio(); + prop_assert_eq!(ratio.len(), got.d); + for &r in ratio.iter() { + prop_assert!(r.is_finite() && (-1e-12..=1.0 + 1e-9).contains(&r), "ratio {r:.6e}"); + } + for w in ratio.to_vec().windows(2) { + prop_assert!(w[0] >= w[1] - 1e-12, "ratios not descending"); + } + let total: f64 = ratio.iter().sum(); + prop_assert!(total <= 1.0 + 1e-9, "ratios sum to {total:.12e}, above 1"); + + // explained_variance is the same quantity before normalising. + let denom = (got.nrows().saturating_sub(1).max(1)) as f64; + let ev = got.explained_variance(); + for i in 0..got.d { + let want = got.s[i] * got.s[i] / denom; + prop_assert!( + (ev[i] - want).abs() <= 1e-12 * want.max(f64::MIN_POSITIVE), + "explained_variance[{i}] = {:.6e}, want {want:.6e}", ev[i] + ); + } + // ...and total_variance is the denominator on that same scale. + prop_assert!( + (got.total_variance() * denom - got.total_squared_norm).abs() + <= 1e-9 * got.total_squared_norm.max(f64::MIN_POSITIVE) + ); + } + + /// Each ratio must match the dense spectrum's `sᵢ² / Σⱼ sⱼ²`. This is what pins the + /// denominator: ratios that only have to sum to under 1 can still be uniformly + /// scaled by a wrong total. IRLBA can't be asked for the full `min_dim`, so the sum + /// over `d` is always partial. + #[test] + fn ratios_match_the_dense_spectrum((a, d) in matrix(16)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let want_total: f64 = want.iter().map(|v| v * v).sum(); + // A matrix that is entirely zero has no energy to apportion. + prop_assume!(want_total > 1e-12); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + let ratio = got.explained_variance_ratio(); + for i in 0..got.d { + let expect = want[i] * want[i] / want_total; + prop_assert!( + (ratio[i] - expect).abs() < 1e-8, + "ratio[{i}] = {:.12e}, dense says {expect:.12e} (shape {:?})", + ratio[i], (a.rows(), a.cols()) + ); + } + } + + /// After centering, the denominator must describe the centered matrix, not the input. + #[test] + fn centered_norm_matches_explicitly_centered_matrix((a, d) in matrix(16)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_centered(&a, rank, Some(42)); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + let means = d.mean_axis(Axis(0)).unwrap(); + let centered = &d - &means.view().insert_axis(Axis(0)); + let want = centered.iter().map(|v| v * v).sum::(); + + prop_assert!( + (got.total_squared_norm - want).abs() <= 1e-8 * want.max(f64::MIN_POSITIVE) + 1e-12, + "centered norm {:.6e} vs dense {:.6e}", got.total_squared_norm, want + ); + let total: f64 = got.explained_variance_ratio().iter().sum(); + prop_assert!(total <= 1.0 + 1e-8, "centered ratios sum to {total:.12e}"); + } + + /// Scores must equal `u · diag(s)`. + #[test] + fn scores_equal_u_times_sigma((a, _d) in matrix(18)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + let scores = got.scores(); + prop_assert_eq!(scores.dim(), (got.nrows(), got.d)); + for i in 0..got.nrows() { + for j in 0..got.d { + let want = got.u[[i, j]] * got.s[j]; + prop_assert!((scores[[i, j]] - want).abs() <= 1e-12 * want.abs().max(1.0)); + } + } + } +} + +// --------------------------------------------------------------------------- +// f32 +// --------------------------------------------------------------------------- + +/// `SvdFloat` is public and generic over `f32`, but everything above runs in `f64`. +/// These repeat the load-bearing invariants at single precision. Tolerances are +/// `f32`-relative against the same operand widened to `f64`, so a failure means the +/// `f32` path lost more than the precision itself explains. +mod f32_paths { + use super::*; + + /// Modest dynamic range — a `1e8` spread would wipe out the small entries in `f32`, + /// which tests the float type rather than the algorithm. + fn value_f32() -> impl Strategy { + prop_oneof![ + 4 => -10.0f32..10.0, + 2 => (-3i32..3, 1.0f32..10.0, any::()) + .prop_map(|(e, m, neg)| { let v = m * 10f32.powi(e); if neg { -v } else { v } }), + 2 => (-8i32..8).prop_map(|v| v as f32), + 1 => Just(0.0f32), + ] + } + + /// An `f32` matrix plus the same content in `f64`, so the reference is exact. + fn matrix32(max_dim: usize) -> impl Strategy, Array2)> { + (2usize..=max_dim, 2usize..=max_dim).prop_flat_map(|(rows, cols)| { + proptest::collection::vec(value_f32(), rows * cols).prop_map(move |vals| { + let mut tri = TriMatI::::new((rows, cols)); + let mut dense = Array2::::zeros((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let v = vals[i * cols + j]; + dense[[i, j]] = v as f64; + if v != 0.0 { + tri.add_triplet(i, j, v); + } + } + } + (tri.to_csr::(), dense) + }) + }) + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 200, max_shrink_iters: 2000, ..ProptestConfig::default() })] + + /// Values must land at `f32` precision against an `f64` reference. + #[test] + fn irlba_f32_matches_dense_reference((a, d) in matrix32(20)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let want = reference(&d); + let s = scale(&want); + + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + for i in 0..got.d { + let err = (got.s[i] as f64 - want[i]).abs(); + prop_assert!( + err <= 1e-4 * s, + "f32 triplet {i}: got {:.9e}, want {:.9e}, abs err {:.3e} vs scale {:.3e}", + got.s[i], want[i], err, s + ); + } + } + + /// Shapes, ordering, finiteness and orthonormality in `f32`. + #[test] + fn irlba_f32_output_is_well_formed((a, _d) in matrix32(18)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + prop_assert_eq!(got.u.dim(), (a.rows(), got.d)); + prop_assert_eq!(got.vt.dim(), (got.d, a.cols())); + prop_assert!(got.s.iter().all(|v| v.is_finite() && *v >= 0.0)); + for w in got.s.to_vec().windows(2) { + prop_assert!(w[0] >= w[1], "f32 singular values not descending"); + } + + // ||U^T U - I||, computed in f64 so the check itself is not the limit. + for p in 0..got.d { + for q in 0..got.d { + let dot: f64 = (0..got.u.nrows()) + .map(|i| got.u[[i, p]] as f64 * got.u[[i, q]] as f64) + .sum(); + let want = if p == q { 1.0 } else { 0.0 }; + prop_assert!( + (dot - want).abs() < 1e-4, + "f32 U^T U [{p},{q}] = {dot:.6e}, want {want}" + ); + } + } + } + + /// `A·vᵢ = σᵢ·uᵢ` at `f32` — catches a wrong vector, not just an inaccurate value. + #[test] + fn irlba_f32_triplets_satisfy_definition((a, d) in matrix32(16)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + let smax = got.s.iter().fold(0.0f32, |m, &v| m.max(v)) as f64; + prop_assume!(smax > 1e-6); + + for i in 0..got.d { + let v: Vec = got.vt.row(i).iter().map(|&x| x as f64).collect(); + let av = d.dot(&ndarray::Array1::from_vec(v)); + let resid: f64 = av + .iter() + .zip(got.u.column(i).iter()) + .map(|(&x, &ui)| { let e = x - got.s[i] as f64 * ui as f64; e * e }) + .sum::() + .sqrt(); + prop_assert!( + resid / smax < 1e-4, + "f32 residual for triplet {i}: {:.3e} against sigma_max {smax:.3e}", + resid / smax + ); + } + } + + /// Centering subtracts near-equal quantities, so it's where `f32` fails first. + #[test] + fn irlba_f32_centered_matches_dense((a, d) in matrix32(16)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + + let means = d.mean_axis(Axis(0)).unwrap(); + let centered = &d - &means.view().insert_axis(Axis(0)); + let want = reference(¢ered); + let s = scale(&want); + prop_assume!(s > 1e-4); + + let got = irlba::svd_centered(&a, rank, Some(42)); + prop_assume!(got.is_ok(), "solver declined: {:?}", got.err()); + let got = got.unwrap(); + + for i in 0..got.d { + let err = (got.s[i] as f64 - want[i]).abs(); + prop_assert!( + err <= 1e-3 * s, + "f32 centered triplet {i}: got {:.9e}, want {:.9e}, abs err {:.3e} vs scale {:.3e}", + got.s[i], want[i], err, s + ); + } + } + + /// The variance denominator must survive `f32` too. + #[test] + fn f32_explained_variance_is_well_formed((a, d) in matrix32(16)) { + let min_dim = a.rows().min(a.cols()); + let rank = (min_dim - 1).max(1); + let got = irlba::svd_seed(&a, rank, 42); + prop_assume!(got.is_ok()); + let got = got.unwrap(); + + let want_norm: f64 = d.iter().map(|v| v * v).sum(); + prop_assume!(want_norm > 1e-6); + let rel = (got.total_squared_norm as f64 - want_norm).abs() / want_norm; + prop_assert!(rel < 1e-5, "f32 total_squared_norm rel err {rel:.3e}"); + + let total: f32 = got.explained_variance_ratio().iter().sum(); + prop_assert!(total <= 1.0 + 1e-5, "f32 ratios sum to {total:.9e}"); + } + } +} diff --git a/tests/robustness.rs b/tests/robustness.rs new file mode 100644 index 0000000..15663e8 --- /dev/null +++ b/tests/robustness.rs @@ -0,0 +1,393 @@ +//! Adversarial and degenerate inputs. +//! +//! Every test here asserts that a hostile input produces either a correct answer or a +//! typed error — never a panic, a hang, or a silently wrong result. + +#![allow(clippy::needless_range_loop)] + +use single_svdlib::{irlba, randomized, MaskedCsMat, SparseMat, SvdLibError, SvdMat}; +use sprs::TriMatI; + +fn from_triplets(rows: usize, cols: usize, t: &[(usize, usize, f64)]) -> SvdMat { + let mut tri = TriMatI::::new((rows, cols)); + for &(i, j, v) in t { + tri.add_triplet(i, j, v); + } + tri.to_csr::() +} + +fn dense_like(rows: usize, cols: usize, f: impl Fn(usize, usize) -> f64) -> SvdMat { + let mut tri = TriMatI::::new((rows, cols)); + for i in 0..rows { + for j in 0..cols { + let v = f(i, j); + if v != 0.0 { + tri.add_triplet(i, j, v); + } + } + } + tri.to_csr::() +} + +// --------------------------------------------------------------------------- +// Structurally degenerate operands +// --------------------------------------------------------------------------- + +/// An all-zero matrix has every singular value zero. It must not panic or hang. +#[test] +fn all_zero_matrix() { + let a = from_triplets(20, 10, &[]); + assert_eq!(a.nnz(), 0); + + match irlba::svd_seed(&a, 3, 42) { + Ok(rec) => { + for &s in rec.s.iter() { + assert!(s.abs() < 1e-10, "expected zero singular values, got {s}"); + } + } + // A typed error is also acceptable: there is no meaningful Krylov subspace. + Err(SvdLibError::Failed { .. }) => {} + Err(e) => panic!("unexpected error kind: {e}"), + } + + match randomized::svd_seed(&a, 3, 42) { + Ok(rec) => { + for &s in rec.s.iter() { + assert!(s.abs() < 1e-10, "expected zero singular values, got {s}"); + } + } + Err(SvdLibError::Failed { .. }) | Err(SvdLibError::DenseFactorization { .. }) => {} + Err(e) => panic!("unexpected error kind: {e}"), + } +} + +/// A matrix of exact rank 1 asked for more triplets than it has. +#[test] +fn rank_deficient_beyond_actual_rank() { + // Every row is a multiple of [1, 2, 3, 4]. + let a = dense_like(30, 4, |i, j| (i as f64 + 1.0) * (j as f64 + 1.0)); + + let rec = irlba::svd_seed(&a, 3, 42).expect("rank-1 matrix, 3 requested"); + assert!(rec.s[0] > 1.0, "dominant value should be substantial"); + for &s in rec.s.iter().skip(1) { + assert!(s < 1e-8 * rec.s[0], "trailing values should be ~0, got {s}"); + } + assert!(rec.s.iter().all(|s| s.is_finite())); +} + +/// Entirely empty rows and columns. +#[test] +fn zero_rows_and_columns() { + // Only rows 3 and 7, columns 1 and 5 carry anything. + let a = from_triplets( + 10, + 8, + &[(3, 1, 2.0), (3, 5, 3.0), (7, 1, 1.0), (7, 5, -4.0)], + ); + let rec = irlba::svd_seed(&a, 2, 42).expect("should handle empty rows/cols"); + assert!(rec.s.iter().all(|s| s.is_finite() && *s >= 0.0)); + assert!(rec.s[0] >= rec.s[1]); + assert!(rec.u.iter().all(|v| v.is_finite())); + assert!(rec.vt.iter().all(|v| v.is_finite())); +} + +/// Duplicate rows make the operand exactly singular in a way that stresses +/// reorthogonalization. +#[test] +fn duplicated_rows() { + let a = dense_like(40, 12, |i, j| { + let base = i % 4; // only 4 distinct rows + ((base * 7 + j * 3) % 11) as f64 + }); + let rec = irlba::svd_seed(&a, 6, 42).expect("duplicated rows"); + assert!(rec.s.iter().all(|s| s.is_finite())); + // Rank is at most 4, so trailing values must collapse. + for &s in rec.s.iter().skip(4) { + assert!(s < 1e-8 * rec.s[0], "value beyond true rank was {s}"); + } +} + +/// The smallest operand the API accepts. +#[test] +fn minimal_shapes() { + let a = from_triplets(2, 2, &[(0, 0, 1.0), (1, 1, 2.0)]); + let rec = irlba::svd_seed(&a, 1, 42).expect("2x2 rank 1"); + approx::assert_relative_eq!(rec.s[0], 2.0, max_relative = 1e-10); + + // A single row or column is a valid matrix, if a degenerate one. + let row = from_triplets(1, 5, &[(0, 0, 3.0), (0, 3, 4.0)]); + match irlba::svd_seed(&row, 1, 42) { + Ok(rec) => approx::assert_relative_eq!(rec.s[0], 5.0, max_relative = 1e-8), + Err(e) => panic!("1xN should work or error cleanly, got {e}"), + } +} + +// --------------------------------------------------------------------------- +// Numerically hostile values +// --------------------------------------------------------------------------- + +/// Extreme dynamic range within one matrix. +#[test] +fn wide_dynamic_range() { + let a = dense_like(50, 20, |i, j| { + if i == 0 { + 1e12 + } else if i == 1 { + 1e-12 + } else { + ((i * 3 + j) % 7) as f64 + } + }); + let rec = irlba::svd_seed(&a, 5, 42).expect("wide dynamic range"); + assert!(rec.s.iter().all(|s| s.is_finite()), "{:?}", rec.s); + assert!( + rec.s[0] > 1e11, + "dominant scale should survive: {}", + rec.s[0] + ); + for w in rec.s.to_vec().windows(2) { + assert!(w[0] >= w[1], "not descending under wide range"); + } +} + +/// A NaN anywhere in the operand must not hang or be silently reported as converged. +#[test] +fn nan_input_does_not_hang_or_claim_convergence() { + let a = from_triplets(20, 10, &[(0, 0, 1.0), (1, 1, f64::NAN), (2, 2, 3.0)]); + let cfg = irlba::IrlbaConfig::new(2).seed(42).max_restarts(20); + match irlba::svd_with(&a, &cfg, None) { + Err(_) => {} // a typed error is the ideal outcome + Ok(rec) => { + let poisoned = rec.s.iter().any(|s| s.is_nan()) + || rec.u.iter().any(|v| v.is_nan()) + || rec.vt.iter().any(|v| v.is_nan()); + if poisoned { + match rec.diagnostics.detail { + single_svdlib::Detail::Irlba { converged, .. } => assert!( + !converged, + "NaN propagated into the result but convergence was reported" + ), + _ => unreachable!(), + } + } + } + } +} + +/// The randomized path reaches the dense factorization by a different route than +/// IRLBA, so it needs its own non-finite check. +#[test] +fn randomized_survives_non_finite_input() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let a = from_triplets(30, 12, &[(0, 0, 1.0), (1, 1, bad), (2, 2, 3.0)]); + let cfg = randomized::RandomizedConfig::new(3) + .seed(42) + .power_iterations(2); + match randomized::svd_with(&a, &cfg, None) { + Err(_) => {} + Ok(rec) => assert!( + rec.s.iter().all(|s| s.is_finite()), + "returned non-finite singular values for input {bad}" + ), + } + // Block Krylov too. + let _ = randomized::svd_block_krylov(&a, 3, 2, Some(42)); + } +} + +/// An infinity in the operand, same contract. +#[test] +fn infinity_input_does_not_hang() { + let a = from_triplets(20, 10, &[(0, 0, f64::INFINITY), (1, 1, 2.0)]); + let cfg = irlba::IrlbaConfig::new(2).seed(42).max_restarts(20); + let _ = irlba::svd_with(&a, &cfg, None); // must return, either way +} + +// --------------------------------------------------------------------------- +// Configuration boundaries +// --------------------------------------------------------------------------- + +/// The largest rank each solver will actually accept, and what happens at the boundary. +#[test] +fn maximum_supported_rank() { + let a = dense_like(30, 20, |i, j| ((i * 5 + j * 3) % 13) as f64 + 0.5); + let min_dim = 20; + + // One below the dimension must work. + assert!( + irlba::svd_seed(&a, min_dim - 1, 42).is_ok(), + "rank = min_dim - 1 should be supported" + ); + + // Exactly min_dim: whatever the behaviour, it must be a typed error and not a + // panic, and the message must explain itself. + match irlba::svd_seed(&a, min_dim, 42) { + Ok(rec) => assert_eq!(rec.d, min_dim), + Err(SvdLibError::InvalidArgument(msg)) => { + assert!( + msg.contains("work") || msg.contains("rank"), + "unhelpful message at the rank boundary: {msg}" + ); + } + Err(e) => panic!("unexpected error kind at rank = min_dim: {e}"), + } + + // Randomized has no such restriction and should reach full rank. + assert!( + randomized::svd_seed(&a, min_dim, 42).is_ok(), + "randomized should support rank = min_dim" + ); +} + +/// A restart budget too small to converge must be an error, not a quietly degraded +/// result — a pipeline that forgets to inspect the diagnostics would otherwise feed a +/// best-effort decomposition into whatever comes next. +#[test] +fn exhausted_restart_budget_fails_loudly() { + // A near-flat spectrum needs many restarts. + let a = dense_like(200, 100, |i, j| (((i * 31 + j * 17) % 101) as f64) - 50.0); + let cfg = irlba::IrlbaConfig::new(30) + .seed(42) + .tol(1e-14) + .max_restarts(1); + + match irlba::svd_with(&a, &cfg, None) { + Err(SvdLibError::Failed { stage, message }) => { + assert_eq!(stage, "irlba"); + assert!( + message.contains("did not converge") && message.contains("residual"), + "error should say what happened and what to do: {message}" + ); + } + Err(e) => panic!("wrong error kind: {e}"), + // Converging in one restart is acceptable; silently returning garbage is not. + Ok(rec) => assert!(rec.converged(), "returned an unconverged result as Ok"), + } + + // Opting in to a best effort must still work, and must say so. + let lax = cfg.clone().allow_unconverged(); + let rec = irlba::svd_with(&a, &lax, None).expect("best effort should be available"); + if !rec.converged() { + let resid = rec.max_residual().expect("irlba tracks a residual"); + assert!( + resid > 0.0, + "unconverged result must carry a positive residual" + ); + } +} + +/// `converged()` must be reachable without matching on the diagnostics enum. +#[test] +fn convergence_is_visible_without_pattern_matching() { + let a = dense_like(120, 40, |i, j| ((i * 3 + j) % 17) as f64); + let rec = irlba::svd_seed(&a, 8, 42).unwrap(); + assert!(rec.converged()); + assert!(rec.max_residual().is_some()); + + // The randomized methods complete by construction. + let r = randomized::svd_seed(&a, 8, 42).unwrap(); + assert!(r.converged()); + assert!(r.max_residual().is_none()); +} + +/// `work` smaller than `rank` is incoherent and must be rejected, not silently fixed +/// into something that returns the wrong number of triplets. +#[test] +fn incoherent_work_size() { + let a = dense_like(60, 30, |i, j| ((i + j) % 5) as f64); + let cfg = irlba::IrlbaConfig::new(10).seed(42).work(3); + match irlba::svd_with(&a, &cfg, None) { + Ok(rec) => assert_eq!(rec.d, 10, "returned a different rank than requested"), + Err(SvdLibError::InvalidArgument(_)) => {} + Err(e) => panic!("unexpected error kind: {e}"), + } +} + +/// Zero oversampling is legal but marginal. +#[test] +fn zero_oversampling() { + let a = dense_like(100, 40, |i, j| ((i * 7 + j) % 11) as f64); + let cfg = randomized::RandomizedConfig::new(5).seed(42).oversamples(0); + let rec = randomized::svd_with(&a, &cfg, None).expect("zero oversampling"); + assert_eq!(rec.d, 5); + assert!(rec.s.iter().all(|s| s.is_finite())); +} + +/// An empty mask leaves a zero-column operand. +#[test] +fn empty_column_mask() { + let a = dense_like(30, 10, |i, j| ((i + j) % 3) as f64); + let masked = MaskedCsMat::with_columns(&a, &[]); + assert_eq!(masked.cols(), 0); + // Any rank is out of range for a zero-column matrix. + assert!(irlba::svd(&masked, 1).is_err()); +} + +/// A mask selecting a single column. +#[test] +fn single_column_mask() { + let a = dense_like(30, 10, |i, j| ((i * 3 + j) % 7) as f64 + 1.0); + let masked = MaskedCsMat::with_columns(&a, &[4]); + assert_eq!(masked.cols(), 1); + match irlba::svd(&masked, 1) { + Ok(rec) => { + assert_eq!(rec.d, 1); + assert!(rec.s[0].is_finite() && rec.s[0] > 0.0); + } + Err(e) => panic!("single-column mask should work, got {e}"), + } +} + +// --------------------------------------------------------------------------- +// Execution environment +// --------------------------------------------------------------------------- + +/// Results must not depend on the thread count — the parallel kernels reduce in a +/// different order per thread count, so this pins that the difference stays at +/// rounding level. +#[test] +fn results_are_stable_across_thread_counts() { + let a = dense_like(400, 80, |i, j| (((i * 13 + j * 7) % 23) as f64) - 11.0); + + let mut spectra = Vec::new(); + for threads in [1usize, 2, 4] { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .unwrap(); + let rec = pool.install(|| irlba::svd_seed(&a, 10, 42).unwrap()); + spectra.push(rec.s.to_vec()); + } + for k in 1..spectra.len() { + for i in 0..spectra[0].len() { + let rel = (spectra[k][i] - spectra[0][i]).abs() / spectra[0][i].abs().max(1e-30); + assert!( + rel < 1e-8, + "thread count changed singular value {i}: {:.12e} vs {:.12e}", + spectra[k][i], + spectra[0][i] + ); + } + } +} + +/// The scatter kernel's column blocking must not change the answer, at any budget. +#[test] +fn scratch_budget_does_not_change_results() { + use ndarray::Array2; + use single_svdlib::matrix::kernels::{scatter_mul, DEFAULT_SCRATCH_BUDGET}; + + let a = dense_like(500, 120, |i, j| (((i * 11 + j * 5) % 17) as f64) - 8.0); + let rhs = Array2::from_shape_fn((500, 24), |(i, j)| ((i * 3 + j) % 9) as f64 - 4.0); + + let mut reference = Array2::zeros((120, 24)); + scatter_mul(a.view(), rhs.view(), reference.view_mut(), usize::MAX); + + for budget in [0usize, 1, 1024, DEFAULT_SCRATCH_BUDGET] { + let mut got = Array2::zeros((120, 24)); + scatter_mul(a.view(), rhs.view(), got.view_mut(), budget); + for (x, y) in got.iter().zip(reference.iter()) { + approx::assert_relative_eq!(x, y, max_relative = 1e-12, epsilon = 1e-12); + } + } +}