pca-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/base.d.ts +79 -0
  4. package/dist/base.d.ts.map +1 -0
  5. package/dist/base.js +358 -0
  6. package/dist/base.js.map +1 -0
  7. package/dist/incremental-pca.d.ts +86 -0
  8. package/dist/incremental-pca.d.ts.map +1 -0
  9. package/dist/incremental-pca.js +397 -0
  10. package/dist/incremental-pca.js.map +1 -0
  11. package/dist/index.d.ts +21 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +14 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/matrix.d.ts +44 -0
  16. package/dist/matrix.d.ts.map +1 -0
  17. package/dist/matrix.js +99 -0
  18. package/dist/matrix.js.map +1 -0
  19. package/dist/model.d.ts +86 -0
  20. package/dist/model.d.ts.map +1 -0
  21. package/dist/model.js +132 -0
  22. package/dist/model.js.map +1 -0
  23. package/dist/numeric/blas.d.ts +33 -0
  24. package/dist/numeric/blas.d.ts.map +1 -0
  25. package/dist/numeric/blas.js +130 -0
  26. package/dist/numeric/blas.js.map +1 -0
  27. package/dist/numeric/eigh.d.ts +21 -0
  28. package/dist/numeric/eigh.d.ts.map +1 -0
  29. package/dist/numeric/eigh.js +233 -0
  30. package/dist/numeric/eigh.js.map +1 -0
  31. package/dist/numeric/lanczos.d.ts +31 -0
  32. package/dist/numeric/lanczos.d.ts.map +1 -0
  33. package/dist/numeric/lanczos.js +287 -0
  34. package/dist/numeric/lanczos.js.map +1 -0
  35. package/dist/numeric/lu.d.ts +27 -0
  36. package/dist/numeric/lu.d.ts.map +1 -0
  37. package/dist/numeric/lu.js +149 -0
  38. package/dist/numeric/lu.js.map +1 -0
  39. package/dist/numeric/mle.d.ts +5 -0
  40. package/dist/numeric/mle.d.ts.map +1 -0
  41. package/dist/numeric/mle.js +67 -0
  42. package/dist/numeric/mle.js.map +1 -0
  43. package/dist/numeric/qr.d.ts +15 -0
  44. package/dist/numeric/qr.d.ts.map +1 -0
  45. package/dist/numeric/qr.js +88 -0
  46. package/dist/numeric/qr.js.map +1 -0
  47. package/dist/numeric/randomized.d.ts +54 -0
  48. package/dist/numeric/randomized.d.ts.map +1 -0
  49. package/dist/numeric/randomized.js +130 -0
  50. package/dist/numeric/randomized.js.map +1 -0
  51. package/dist/numeric/rng.d.ts +40 -0
  52. package/dist/numeric/rng.d.ts.map +1 -0
  53. package/dist/numeric/rng.js +127 -0
  54. package/dist/numeric/rng.js.map +1 -0
  55. package/dist/numeric/special.d.ts +2 -0
  56. package/dist/numeric/special.d.ts.map +1 -0
  57. package/dist/numeric/special.js +25 -0
  58. package/dist/numeric/special.js.map +1 -0
  59. package/dist/numeric/stats.d.ts +27 -0
  60. package/dist/numeric/stats.d.ts.map +1 -0
  61. package/dist/numeric/stats.js +110 -0
  62. package/dist/numeric/stats.js.map +1 -0
  63. package/dist/numeric/svd.d.ts +27 -0
  64. package/dist/numeric/svd.d.ts.map +1 -0
  65. package/dist/numeric/svd.js +374 -0
  66. package/dist/numeric/svd.js.map +1 -0
  67. package/dist/numeric/svdflip.d.ts +9 -0
  68. package/dist/numeric/svdflip.d.ts.map +1 -0
  69. package/dist/numeric/svdflip.js +32 -0
  70. package/dist/numeric/svdflip.js.map +1 -0
  71. package/dist/pca.d.ts +170 -0
  72. package/dist/pca.d.ts.map +1 -0
  73. package/dist/pca.js +649 -0
  74. package/dist/pca.js.map +1 -0
  75. package/dist/progress.d.ts +155 -0
  76. package/dist/progress.d.ts.map +1 -0
  77. package/dist/progress.js +173 -0
  78. package/dist/progress.js.map +1 -0
  79. package/dist/scheduling.d.ts +66 -0
  80. package/dist/scheduling.d.ts.map +1 -0
  81. package/dist/scheduling.js +160 -0
  82. package/dist/scheduling.js.map +1 -0
  83. package/dist/types.d.ts +10 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +11 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/validation.d.ts +17 -0
  88. package/dist/validation.d.ts.map +1 -0
  89. package/dist/validation.js +36 -0
  90. package/dist/validation.js.map +1 -0
  91. package/dist/webgpu/df64.d.ts +18 -0
  92. package/dist/webgpu/df64.d.ts.map +1 -0
  93. package/dist/webgpu/df64.js +42 -0
  94. package/dist/webgpu/df64.js.map +1 -0
  95. package/dist/webgpu/engine.d.ts +74 -0
  96. package/dist/webgpu/engine.d.ts.map +1 -0
  97. package/dist/webgpu/engine.js +326 -0
  98. package/dist/webgpu/engine.js.map +1 -0
  99. package/dist/webgpu/gpu-pca.d.ts +90 -0
  100. package/dist/webgpu/gpu-pca.d.ts.map +1 -0
  101. package/dist/webgpu/gpu-pca.js +407 -0
  102. package/dist/webgpu/gpu-pca.js.map +1 -0
  103. package/dist/webgpu/index.d.ts +19 -0
  104. package/dist/webgpu/index.d.ts.map +1 -0
  105. package/dist/webgpu/index.js +16 -0
  106. package/dist/webgpu/index.js.map +1 -0
  107. package/dist/webgpu/kernels.d.ts +47 -0
  108. package/dist/webgpu/kernels.d.ts.map +1 -0
  109. package/dist/webgpu/kernels.js +147 -0
  110. package/dist/webgpu/kernels.js.map +1 -0
  111. package/dist/worker/client.d.ts +174 -0
  112. package/dist/worker/client.d.ts.map +1 -0
  113. package/dist/worker/client.js +482 -0
  114. package/dist/worker/client.js.map +1 -0
  115. package/dist/worker/handler.d.ts +17 -0
  116. package/dist/worker/handler.d.ts.map +1 -0
  117. package/dist/worker/handler.js +404 -0
  118. package/dist/worker/handler.js.map +1 -0
  119. package/dist/worker/protocol.d.ts +192 -0
  120. package/dist/worker/protocol.d.ts.map +1 -0
  121. package/dist/worker/protocol.js +70 -0
  122. package/dist/worker/protocol.js.map +1 -0
  123. package/dist/worker/worker.d.ts +14 -0
  124. package/dist/worker/worker.d.ts.map +1 -0
  125. package/dist/worker/worker.js +20 -0
  126. package/dist/worker/worker.js.map +1 -0
  127. package/package.json +94 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeya Balaji Balasubramanian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # pca-web
2
+
3
+ Client-side **PCA** and **IncrementalPCA** for the browser and Node.js — a zero-runtime-dependency
4
+ TypeScript reimplementation of scikit-learn 1.9's `sklearn.decomposition.PCA` and
5
+ `IncrementalPCA`, with optional **WebGPU acceleration**.
6
+
7
+ - **scikit-learn is the specification.** Every solver (`full`, `covariance_eigh`, `arpack`,
8
+ `randomized`), every `nComponents` mode (int, fraction, `'mle'`, `null`), whitening, `copy`
9
+ semantics, the auto-solver heuristic, and the deterministic sign convention
10
+ (`svd_flip(u_based_decision=False)`) are ported from the sklearn 1.9.0 source and verified
11
+ against fixtures generated by the real scikit-learn (see [Numerical parity](#numerical-parity-and-tolerances)).
12
+ - Ships as ESM with TypeScript declarations. Node ≥ 20 and evergreen browsers.
13
+ - Zero runtime dependencies. The WebGPU path lives behind the `pca-web/webgpu` subpath so the
14
+ core stays GPU-free for bundlers.
15
+ - Even the random number generator is a bit-exact numpy `RandomState` (MT19937) replica: with
16
+ the same seed, `svdSolver: 'randomized'` reproduces sklearn's output to floating-point
17
+ accuracy — not just statistically.
18
+ - Fits can run [off the main thread](#web-workers) (`pca-web/client` + `pca-web/worker`), report
19
+ [live progress with intermediate models](#progress-non-blocking-fits-and-cancellation), be
20
+ aborted mid-fit, and be [serialized](#model-serialization) for IndexedDB/`postMessage`/JSON —
21
+ see the [live demo](https://jeyabbalas.github.io/pca-web/) (source in `examples/demo/`).
22
+
23
+ ```ts
24
+ import { PCA } from 'pca-web';
25
+
26
+ const pca = new PCA({ nComponents: 2, svdSolver: 'randomized', randomState: 42 });
27
+ const embedding = pca.fitTransform(X); // X: number[][] or a Matrix
28
+
29
+ pca.components; // principal axes (Matrix, k×p)
30
+ pca.explainedVarianceRatio; // Float64Array
31
+ pca.singularValues;
32
+ pca.transform(Xnew);
33
+ pca.inverseTransform(embedding);
34
+ ```
35
+
36
+ ## Installation
37
+
38
+ ```sh
39
+ npm install pca-web
40
+ ```
41
+
42
+ ```ts
43
+ import { PCA, IncrementalPCA, Matrix } from 'pca-web'; // CPU core
44
+ import { WebGPUPCA, isWebGPUSupported } from 'pca-web/webgpu'; // GPU frontend
45
+ ```
46
+
47
+ ## Inputs, outputs and dtypes
48
+
49
+ `fit`/`transform` accept either `number[][]` or a `Matrix` — a thin row-major wrapper around a
50
+ typed array:
51
+
52
+ ```ts
53
+ import { Matrix } from 'pca-web';
54
+ const X = new Matrix(new Float64Array(n * p), n, p); // zero-copy
55
+ const X32 = new Matrix(new Float32Array(n * p), n, p);
56
+ ```
57
+
58
+ The result dtype follows the input, like sklearn: `Float32Array` in → float32 attributes and
59
+ transforms out; `Float64Array` or `number[][]` in → float64 out. One deliberate divergence:
60
+ float32 inputs are *computed* in float64 internally and rounded to float32 on output, whereas
61
+ sklearn runs a native-f32 LAPACK pipeline — this port is therefore slightly **more** accurate
62
+ for f32 data, and agrees with sklearn within the documented f32 tolerance class (~2e-3)
63
+ rather than bit-for-bit. Transform results are `Matrix` (use `.toArray()` for `number[][]`,
64
+ or `.data`/`.rows`/`.cols` directly).
65
+
66
+ Like sklearn, `copy: false` lets `fit` overwrite the training data: the `full` solver centers
67
+ it in place, and the truncated solvers (`arpack`, `randomized`) center **and square** it in
68
+ place — the input is destroyed. The default `copy: true` never mutates inputs.
69
+
70
+ Fitting requires **at least 2 samples** (`n − 1` is the variance denominator). This is a
71
+ deliberate, documented deviation from sklearn, which accepts a single sample and returns an
72
+ all-NaN model alongside a Python `RuntimeWarning` — a channel that does not exist in JS. The
73
+ same applies to `IncrementalPCA`'s first batch; later `partialFit` batches may be any size,
74
+ including single rows.
75
+
76
+ ## API ↔ scikit-learn mapping
77
+
78
+ Names are idiomatic TypeScript camelCase; each maps 1:1 to its sklearn counterpart.
79
+
80
+ | pca-web | scikit-learn |
81
+ | --- | --- |
82
+ | `new PCA({ nComponents, copy, whiten, svdSolver, tol, iteratedPower, nOversamples, powerIterationNormalizer, randomState })` | `PCA(n_components, copy, whiten, svd_solver, tol, iterated_power, n_oversamples, power_iteration_normalizer, random_state)` |
83
+ | `fit(X)` / `fitTransform(X)` / `transform(X)` / `inverseTransform(Y)` | `fit` / `fit_transform` / `transform` / `inverse_transform` |
84
+ | `getCovariance()` / `getPrecision()` | `get_covariance()` / `get_precision()` |
85
+ | `scoreSamples(X)` / `score(X)` | `score_samples` / `score` |
86
+ | `getFeatureNamesOut()` | `get_feature_names_out()` |
87
+ | `fitAsync(X, opts?)` / `fitTransformAsync(X, opts?)` | — (non-blocking fit, [see below](#progress-non-blocking-fits-and-cancellation)) |
88
+ | `toModel()` / `PCA.fromModel(m)` / `modelToJSON` / `modelFromJSON` | — (serialization, [see below](#model-serialization)) |
89
+ | `components` | `components_` |
90
+ | `explainedVariance` / `explainedVarianceRatio` | `explained_variance_` / `explained_variance_ratio_` |
91
+ | `singularValues` / `mean` / `noiseVariance` | `singular_values_` / `mean_` / `noise_variance_` |
92
+ | `nComponents` / `nSamples` / `nFeaturesIn` | `n_components_` / `n_samples_` / `n_features_in_` |
93
+ | `resolvedSvdSolver` | `_fit_svd_solver` (the solver `'auto'` dispatched to) |
94
+ | `new IncrementalPCA({ nComponents, whiten, copy, batchSize })` | `IncrementalPCA(n_components, whiten, copy, batch_size)` |
95
+ | `partialFit(X)` / `batchSize` / `nSamplesSeen` / `variance` | `partial_fit` / `batch_size_` / `n_samples_seen_` / `var_` |
96
+
97
+ `nComponents` semantics match sklearn exactly: an integer ≥ 0; a fraction in (0, 1) selecting
98
+ the smallest k whose cumulative explained-variance ratio exceeds it (full/covariance_eigh); the
99
+ string `'mle'` for Minka's MLE (full/covariance_eigh, requires n ≥ p); or `null` (default) for
100
+ `min(nSamples, nFeatures)` (arpack: min − 1). Validation errors mirror sklearn's messages and
101
+ check order.
102
+
103
+ `tol` is accepted for API parity; the built-in Lanczos solver always converges the requested
104
+ triplets to machine precision (equivalent to sklearn's default `tol=0`).
105
+
106
+ ## Solver selection
107
+
108
+ | solver | use when | notes |
109
+ | --- | --- | --- |
110
+ | `'auto'` (default) | in doubt | sklearn 1.9's exact heuristic: `covariance_eigh` when p ≤ 1000 and n ≥ 10·p; else `full` when max(n, p) ≤ 500 or `nComponents` is `'mle'`; else `randomized` when 1 ≤ k < 0.8·min(n, p); else `full` |
111
+ | `'full'` | small/medium data, all components | Golub–Reinsch SVD of the centered data |
112
+ | `'covariance_eigh'` | many samples, few features (n ≫ p) | eigendecomposition of the p×p covariance; fastest for tall data, GPU-accelerated |
113
+ | `'randomized'` | large data, few components | Halko et al. randomized SVD, seed-compatible with sklearn; GPU-accelerated |
114
+ | `'arpack'` | k strictly < min(n, p), sparse-ish spectra | Golub–Kahan–Lanczos with full reorthogonalization (scipy `svds` equivalent) |
115
+
116
+ `RandomState` (exported) is the numpy-compatible RNG; pass an integer seed (< 2³²) or an
117
+ instance as `randomState`.
118
+
119
+ ## IncrementalPCA
120
+
121
+ ```ts
122
+ import { IncrementalPCA } from 'pca-web';
123
+
124
+ const ipca = new IncrementalPCA({ nComponents: 8, batchSize: 200 });
125
+ ipca.fit(X); // internally batched over gen_batches(n, batchSize)
126
+ // or stream without holding the dataset in memory:
127
+ for (const chunk of chunks) {
128
+ ipca.partialFit(chunk);
129
+ }
130
+ ipca.transform(Xnew);
131
+ ```
132
+
133
+ Semantics ported from sklearn: `batchSize` defaults to `5 * nFeatures` in `fit`; small tail
134
+ batches are merged like `gen_batches`; the incremental mean/variance uses float64 accumulators
135
+ (so `mean`/`variance` are always float64, while float32 components stay float32 after the
136
+ first `partialFit` and become float64 once batches are stacked — numpy's `vstack` promotion,
137
+ faithfully replicated); `noiseVariance` follows sklearn's `k ∉ {batch n, p}` rule. The parity
138
+ suite verifies the complete fitted state after **every** `partial_fit` step against sklearn.
139
+
140
+ ## WebGPU acceleration
141
+
142
+ ```ts
143
+ import { WebGPUPCA, isWebGPUSupported } from 'pca-web/webgpu';
144
+
145
+ const pca = new WebGPUPCA({ nComponents: 16, svdSolver: 'randomized', randomState: 42 });
146
+ await pca.fit(X); // async: GPU when available and worthwhile, CPU otherwise
147
+ pca.backend; // 'webgpu' | 'cpu' — what the last fit actually used
148
+ pca.gpuAdapterInfo; // e.g. "apple / metal-3"
149
+ await pca.transform(Xnew); // GPU projection for large inputs
150
+ pca.dispose(); // release GPU resources
151
+ ```
152
+
153
+ - **What runs on the GPU:** the large-GEMM hotspots — the Gram product of `covariance_eigh`,
154
+ the power-iteration panel products of `randomized` (the data matrix stays resident on the
155
+ device across iterations), and large `transform` projections. Everything else (eigh, panel
156
+ QR/LU, small SVDs, sign flips, validation, bookkeeping) runs through the same code as the
157
+ CPU class, so semantics cannot drift.
158
+ - **Fallback:** without WebGPU (or for `full`/`arpack`, or inputs below `minGpuElements`,
159
+ default 2¹⁸ elements), `WebGPUPCA` delegates to the CPU `PCA` — results are **bit-identical**
160
+ to `new PCA(...)` by construction (verified in the Node test suite).
161
+ - **Device injection:** pass your own `device: GPUDevice` (it is never destroyed by
162
+ `dispose()`); `powerPreference` is forwarded to `requestAdapter`.
163
+ - **TypeScript:** the `pca-web/webgpu` declarations use the standard WebGPU globals
164
+ (`GPUDevice`, …). With `lib: ["dom"]` (TypeScript ≥ 5.9 ships WebGPU in the DOM lib) they
165
+ resolve with no extra packages. Node-only lib targets instead add the types-only package
166
+ `@webgpu/types` (declared here as an optional peer dependency) with
167
+ `"types": ["@webgpu/types"]` and `skipLibCheck` in tsconfig. The runtime has zero
168
+ dependencies either way, and the core `pca-web` entry needs nothing.
169
+ - **Precision:** WGSL has no f64, and (measured on Chrome/Metal) the shader compiler's
170
+ fast-math destroys classic compensated-arithmetic tricks. The GEMM kernels therefore use
171
+ fma-exact products of double-single operands with **exact integer-binned accumulation**
172
+ (5×13-bit i32 bins per element; integer adds cannot be fast-mathed), recombined in float64.
173
+ The engine self-checks precision on creation and **refuses the GPU** (falling back to CPU)
174
+ if the driver cannot reach near-f64 accuracy — measured 5.3e-13 worst-case GEMM relative
175
+ error on Apple Metal, vs ~1e-5 for a plain f32 kernel.
176
+ - **Measured CPU↔GPU equivalence** (browser harness, Apple M-series, `apple / metal-3`):
177
+ components/singular values/variances agree to ~1e-10 absolute / ~1e-8 relative worst-case
178
+ on float64 fits (dominated by eigenvector gap sensitivity, not GEMM error); float32 fits
179
+ round to **identical** f32 outputs.
180
+
181
+ Benchmarks from this machine (`npm run bench:browser`, median of 3 fits, GPU time includes
182
+ upload + readback):
183
+
184
+ | case | size | solver | CPU (ms) | GPU (ms) | speedup |
185
+ | --- | --- | --- | ---: | ---: | ---: |
186
+ | cov_50000x200 | 50000×200 | covariance_eigh | 730 | 237 | 3.1× |
187
+ | cov_100000x100 | 100000×100 | covariance_eigh | 396 | 149 | 2.7× |
188
+ | rand_20000x500_nc16 | 20000×500 | randomized | 3332 | 637 | 5.2× |
189
+ | rand_5000x2000_nc32 | 5000×2000 | randomized | 4843 | 631 | 7.7× |
190
+ | cov_f32_50000x200 (f32) | 50000×200 | covariance_eigh | 1250 | 235 | 5.3× |
191
+
192
+ ## Progress, non-blocking fits and cancellation
193
+
194
+ Every estimator has four fit entry points with identical numerics — `fit` and `fitTransform`
195
+ run the solver to completion synchronously; `fitAsync` and `fitTransformAsync` run the **same
196
+ solver steps** time-sliced through the event loop (default budget 12 ms per slice), so the UI
197
+ keeps painting. Observer-on vs observer-off, sync vs async: the fitted models are
198
+ **bit-identical** (enforced by tests, including that snapshots draw nothing from the RNG
199
+ stream).
200
+
201
+ ```ts
202
+ const pca = new PCA({ nComponents: 16, svdSolver: 'randomized', randomState: 42 });
203
+ const controller = new AbortController();
204
+
205
+ await pca.fitAsync(X, {
206
+ budgetMs: 12, // solver time per event-loop slice
207
+ signal: controller.signal, // abort mid-fit → rejects with name === 'AbortError'
208
+ snapshot: { scores: true, every: 1 },
209
+ onProgress(e) {
210
+ render(e.fraction); // [0,1] monotone, or null while indeterminate
211
+ if (e.snapshot) plot(e.snapshot.scores); // evolving embedding of the training rows
212
+ },
213
+ });
214
+ ```
215
+
216
+ `onProgress` fires synchronously from inside the fit with `{estimator, solver, phase, step,
217
+ totalSteps, fraction, snapshot?, detail?}`. Cadence and snapshot availability per solver:
218
+
219
+ | solver | phases | one event per… | `fraction` | intermediate snapshots |
220
+ | --- | --- | --- | --- | --- |
221
+ | `full` | `decompose` → `finalize` | decomposition (indeterminate) | null → 1 | finalize only |
222
+ | `covariance_eigh` | `gram` → `decompose` → `finalize` | Gram chunk (~2²² elements) | 0–0.85 → pinned → 1 | finalize only |
223
+ | `randomized` | `power-iteration` → `finalize` | power iteration | 0–0.9 → 1 | every iteration (opt-in) |
224
+ | `arpack` | `lanczos-step` → `finalize` | convergence check; `detail: {basisSize, jmax, maxResidual}` | null → 1 | at checkpoints |
225
+ | IncrementalPCA | `batch` → `finalize` | `partial_fit` batch | linear → 1 | every batch |
226
+
227
+ Snapshots are opt-in (`snapshot: { components: true }` or `{ scores: true }`): fresh float64
228
+ copies in sklearn's sign convention, safe to keep or transfer. On the randomized solver a
229
+ snapshot costs roughly one extra solver step per iteration — `every: 2` halves that.
230
+
231
+ Semantics: an `onProgress` exception propagates and fails the fit. An aborted or failed `PCA`
232
+ fit leaves the estimator **unfitted** (an abort before the first step leaves a previous model
233
+ intact); `IncrementalPCA` keeps the completed-batch model, matching `partial_fit` semantics.
234
+ Abort accepts any `{aborted, reason?}` object — a real `AbortSignal` works, none is required.
235
+ Concurrent fits on one instance throw. `WebGPUPCA.fit(X, options)` takes the same options;
236
+ abort during a GPU fit releases device buffers and does **not** fall back to a CPU refit.
237
+
238
+ ## Web Workers
239
+
240
+ `pca-web/client` exports `WorkerPCA` and `WorkerIncrementalPCA` — async proxies that run the
241
+ estimator in a worker and stream progress back. The client bundle contains **no solver code**
242
+ (enforced by an import-graph test): apps that fit only in the worker ship the numerics once,
243
+ inside the worker bundle.
244
+
245
+ ```ts
246
+ import { WorkerPCA } from 'pca-web/client';
247
+
248
+ const pca = new WorkerPCA({
249
+ nComponents: 16,
250
+ svdSolver: 'randomized',
251
+ randomState: 42, // numeric seeds only — a live RandomState can't cross threads
252
+ backend: 'webgpu', // optional: WebGPU inside the worker, CPU fallback
253
+ });
254
+ await pca.fit(X, {
255
+ signal: controller.signal, // aborts land mid-fit (the worker fit is time-sliced)
256
+ transfer: true, // optional: move X's buffer instead of copying it
257
+ onProgress: (e) => render(e.fraction, e.snapshot),
258
+ progress: { minIntervalMs: 33, snapshot: { scores: true } }, // worker-side throttle
259
+ });
260
+
261
+ pca.components; // sync — every fit piggybacks the model back to a client mirror
262
+ pca.explainedVarianceRatio; // sync
263
+ await pca.transform(Xnew); // compute methods stay in the worker
264
+ const model = pca.exportModel(); // sync snapshot of the mirror (see Model serialization)
265
+ await pca.dispose(); // frees the estimator; terminates the worker it owns
266
+ ```
267
+
268
+ Progress events are throttled worker-side (`minIntervalMs`, default 33 ms, latest-wins within
269
+ a phase; phase boundaries and `finalize` always delivered, `seq` monotone). `transfer: true`
270
+ detaches the input's buffer from the caller; views are tight-sliced first so a subarray's
271
+ parent buffer is never touched. With `backend: 'webgpu'`, `await pca.info()` reports which
272
+ backend actually executed plus the adapter (`{backend, gpuAdapterInfo, webgpuAvailable}`).
273
+
274
+ **Where the worker comes from.** By default `WorkerPCA` spawns the packaged entry via
275
+ `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })` — webpack 5, Vite
276
+ build, and native ESM all resolve that statically. If your toolchain can't, pass `{ worker }`:
277
+
278
+ | toolchain | recipe |
279
+ | --- | --- |
280
+ | webpack 5, native ESM | default works — or `{ worker: new Worker(new URL('pca-web/worker', import.meta.url), { type: 'module' }) }` |
281
+ | Vite | make a one-line entry `worker-entry.ts` containing `import 'pca-web/worker'`, pass `{ worker: () => new Worker(new URL('./worker-entry.ts', import.meta.url), { type: 'module' }) }`, and set `worker: { format: 'es' }` in `vite.config.ts` (what `examples/demo` does); or `import PcaWorker from 'pca-web/worker?worker'` → `{ worker: () => new PcaWorker() }`. In dev also add `'pca-web'` to `optimizeDeps.exclude`. |
282
+ | esbuild | bundle `pca-web/worker` as its own entry point and pass its URL: `{ worker: () => new Worker('/assets/pca-worker.js', { type: 'module' }) }` |
283
+ | Node ≥ 20 | `worker_threads` — bridge a `MessagePort` (below) |
284
+
285
+ Node (`worker_threads` `Worker` is an EventEmitter, not an EventTarget, so hand the client a
286
+ `MessagePort`, which is one):
287
+
288
+ ```ts
289
+ // pca.worker.mjs — runs in the thread
290
+ import { workerData } from 'node:worker_threads';
291
+ import { attachPCAWorker } from 'pca-web/worker';
292
+ attachPCAWorker(workerData.port);
293
+
294
+ // main thread
295
+ import { MessageChannel, Worker } from 'node:worker_threads';
296
+ import { WorkerPCA } from 'pca-web/client';
297
+ const { port1, port2 } = new MessageChannel();
298
+ new Worker(new URL('./pca.worker.mjs', import.meta.url), {
299
+ workerData: { port: port2 },
300
+ transferList: [port2],
301
+ });
302
+ const pca = new WorkerPCA({ nComponents: 3, worker: port1 });
303
+ ```
304
+
305
+ `attachPCAWorker(port)` (from `pca-web/worker`) attaches the request handler to any
306
+ port-shaped endpoint; the packaged worker entry just calls it on the worker's global scope.
307
+ Requests on one worker execute strictly in order; aborts are handled out-of-band. Errors are
308
+ marshalled back `instanceof`-correct where it matters (`NotFittedError`, `AbortError`), and
309
+ `terminate()` rejects in-flight calls with `WorkerTerminatedError`.
310
+
311
+ ## Model serialization
312
+
313
+ `toModel()` captures the complete fitted state as a plain object of typed arrays — cheap,
314
+ structured-clone-friendly (IndexedDB, `postMessage`, `structuredClone`), and validated on
315
+ rehydration. `PCA.fromModel(model)` / `IncrementalPCA.fromModel(model)` restore an estimator
316
+ whose every method output is **bit-identical** to the original's; a rehydrated
317
+ `IncrementalPCA` continues `partialFit` streams exactly as if never interrupted.
318
+
319
+ ```ts
320
+ // IndexedDB (structured clone — no JSON, typed arrays stored directly)
321
+ store.put(pca.toModel(), 'my-model');
322
+ const pca2 = PCA.fromModel(await get(store, 'my-model'));
323
+
324
+ // JSON when you need text (bit-exact round-trip for f64 and f32)
325
+ localStorage.setItem('m', modelToJSON(pca.toModel()));
326
+ const pca3 = PCA.fromModel(modelFromJSON(localStorage.getItem('m')!) as PCAModel);
327
+ ```
328
+
329
+ The worker proxies speak the same format: `exportModel()` (synchronous, from the client-side
330
+ mirror) and `WorkerPCA.fromModel(model, options)`, which hydrates the worker without a refit.
331
+ Models carry a `formatVersion`; `assertValidModel` rejects malformed or future-versioned input.
332
+
333
+ ## Numerical parity and tolerances
334
+
335
+ Parity is defined by tests, not intent. `python/generate_fixtures.py` runs real
336
+ scikit-learn 1.9.0 (numpy 2.5.1) over 96 PCA/IncrementalPCA cases — every solver, every
337
+ `nComponents` mode, whiten on/off, float32/float64, rank-deficient and badly scaled data,
338
+ per-step `partial_fit` state — plus RNG-stream and LAPACK-kernel fixtures, and commits the
339
+ outputs (`fixtures/`, ~11 MB). The TypeScript suite fits the same inputs and compares **every**
340
+ fitted attribute and method output elementwise — including signs; comparisons are never
341
+ up-to-sign.
342
+
343
+ Assertion bounds by class (all cases pass; see `tests/pca-parity.test.ts` for the full table):
344
+
345
+ | class | components | explained variance (rel) | transforms | notes |
346
+ | --- | --- | --- | --- | --- |
347
+ | float64 `full`/`arpack` | 1e-9 abs / 1e-7 rel | 1e-9 | 1e-8 abs / 1e-7 rel | observed component diffs ≤ ~1e-12 on unit-scale data |
348
+ | float64 `covariance_eigh` | 1e-9 abs / 1e-7 rel | 1e-9 | 1e-7 abs / 1e-6 rel | Gram route squares the condition number; tail singular values carry a ~√eps noise floor |
349
+ | float64 `randomized` (LU/QR/auto) | 1e-7 abs / 1e-5 rel | 1e-7 | 1e-6 abs / 1e-5 rel | same-seed comparison; observed ≤ ~1e-10 |
350
+ | float32 (all solvers) | 2e-3 | 2e-3 | 5e-3 | f32 rounding dominates |
351
+ | IncrementalPCA float64 | 1e-8 abs / 1e-6 rel | 1e-8 | 1e-7 | observed ≤ ~6e-13; mean/var bit-exact |
352
+
353
+ Documented caveats, discovered and verified during porting:
354
+
355
+ - **`noiseVariance`** is the cancellation `(totalVar − Σev)/(min − k)`; its relative error is
356
+ the explained-variance error amplified by ~totalVar/noiseVar. Bounds: 1e-11 abs (roundoff-
357
+ scale zeros) / 1e-9 rel deterministic, 1e-4 rel randomized.
358
+ - **`powerIterationNormalizer: 'none'`** (explicitly requested, ≥3 iterations) is documented
359
+ by sklearn itself as numerically unstable: trailing directions carry (σᵢ/σ₁)^(2·nIter+1)
360
+ ≈ 1e-13 relative weight, and sklearn's own components move ~2e-4 under a **1-ulp** input
361
+ perturbation (measured). The suite compares that configuration in a dedicated loose class
362
+ (components 5e-3 abs); no implementation can do better than the algorithm's conditioning.
363
+ - **Rank deficiency:** components beyond the numerical rank span an arbitrary orthonormal
364
+ basis of the null space — LAPACK's choice and this library's legitimately differ. The suite
365
+ masks comparisons beyond the fixture rank (their variances are still compared, near zero),
366
+ and skips `inverseTransform` comparisons of out-of-span test data when rank < k.
367
+ - **Score/log-likelihood** values are large negatives; they agree to ~1e-10 relative
368
+ (deterministic f64).
369
+ - **Deliberate deviations:** fits with fewer than 2 samples throw a clear error (sklearn
370
+ returns an all-NaN model with a `RuntimeWarning`). All-constant (zero-variance) data keeps
371
+ sklearn's exact behavior: a well-formed model whose `explainedVarianceRatio` is NaN (0/0).
372
+
373
+ ## Demo app
374
+
375
+ `examples/demo/` is a Vite app exercising the whole feature surface — worker vs main-thread
376
+ execution, live progress with snapshot embeddings, abort, WebGPU-in-worker with adapter
377
+ readout, IncrementalPCA `partialFit` streaming, eigen-digit/reconstruction/outlier panels, and
378
+ model persistence (JSON + IndexedDB restore-without-refit). It consumes the library strictly
379
+ through its public package exports, doubling as a packaging test.
380
+
381
+ ```sh
382
+ npm run demo:dev # build the library, then vite dev
383
+ npm run demo:build # static build (examples/demo/dist)
384
+ ```
385
+
386
+ The deploy workflow (`.github/workflows/deploy-pages.yml`) publishes it to GitHub Pages on
387
+ every push to `main`; one-time repo setup: **Settings → Pages → Source: "GitHub Actions"**.
388
+ The bundled digits dataset is regenerated by `npm run demo:data` (same venv as fixtures).
389
+
390
+ ## Testing and development
391
+
392
+ ```sh
393
+ npm test # Node suite: parity vs sklearn fixtures + API behavior,
394
+ # async/observer bit-equivalence, abort, model round-trips,
395
+ # worker protocol/parity over MessageChannel (275 tests)
396
+ npm run test:coverage # the same suite with a V8 coverage report (report-only)
397
+ npm run test:browser # real-GPU harness (Playwright Chromium); reports exactly which
398
+ # cases executed on which adapter, and fails if the GPU didn't run
399
+ npm run test:browser:worker # real Worker in Chromium: bit-equal results, progress on the
400
+ # main thread, mid-fit abort, transfer semantics, WebGPU-in-worker
401
+ npm run typecheck && npm run lint && npm run build
402
+ npm run bench # CPU timings (Node)
403
+ npm run bench:browser # CPU vs GPU timings (browser)
404
+ ```
405
+
406
+ Regenerating fixtures requires Python with the pinned reference stack (scikit-learn 1.9.0,
407
+ numpy 2.5.1, scipy 1.18.0 — `python/requirements.txt`):
408
+
409
+ ```sh
410
+ python -m venv .venv && .venv/bin/pip install -r python/requirements.txt
411
+ npm run fixtures # rewrites fixtures/ deterministically
412
+ ```
413
+
414
+ The numerics core (`src/numeric/`) — Golub–Reinsch SVD, symmetric tridiagonal eigensolver,
415
+ Householder QR with LAPACK `dlarfg` semantics, partial-pivot LU (`scipy permute_l`
416
+ compatible), Golub–Kahan–Lanczos, Minka MLE, and the MT19937 `RandomState` replica — is
417
+ dependency-free TypeScript, unit-tested against numpy/scipy fixtures.
418
+
419
+ ## License
420
+
421
+ MIT
package/dist/base.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shared machinery for PCA and IncrementalPCA — the counterpart of sklearn's
3
+ * `_BasePCA`: transform / inverseTransform / getCovariance / getPrecision /
4
+ * getFeatureNamesOut operating on the fitted state.
5
+ */
6
+ import { Matrix, type MatrixInput } from './matrix.js';
7
+ import { type PCAModelBaseFields } from './model.js';
8
+ import { type Dtype, type FloatArray } from './types.js';
9
+ export declare function castTo(a: Float64Array, dtype: Dtype): FloatArray;
10
+ export declare function promoteDtype(a: Dtype, b: Dtype): Dtype;
11
+ export declare abstract class BasePCA {
12
+ protected readonly estimatorName: string;
13
+ protected fitted: boolean;
14
+ /** True while a fit generator is live — re-entrant fits on one instance throw. */
15
+ protected fitting: boolean;
16
+ protected dtype: Dtype;
17
+ protected whitenOpt: boolean;
18
+ protected components_: Matrix | null;
19
+ protected explainedVariance_: FloatArray | null;
20
+ protected explainedVarianceRatio_: FloatArray | null;
21
+ protected singularValues_: FloatArray | null;
22
+ protected mean_: FloatArray | null;
23
+ protected nComponents_: number;
24
+ protected nFeaturesIn_: number;
25
+ protected noiseVariance_: number;
26
+ protected assertFitted(): void;
27
+ /** Principal axes (nComponents × nFeatures) — sklearn's `components_`. */
28
+ get components(): Matrix;
29
+ /** sklearn's `explained_variance_`. */
30
+ get explainedVariance(): FloatArray;
31
+ /**
32
+ * sklearn's `explained_variance_ratio_`. For zero-variance (all-constant)
33
+ * training data the total variance is 0, so every entry is NaN — the same
34
+ * 0/0 sklearn produces (alongside a RuntimeWarning) in that case.
35
+ */
36
+ get explainedVarianceRatio(): FloatArray;
37
+ /** sklearn's `singular_values_`. */
38
+ get singularValues(): FloatArray;
39
+ /** Per-feature training mean — sklearn's `mean_`. */
40
+ get mean(): FloatArray;
41
+ /** Estimated number of components — sklearn's `n_components_`. */
42
+ get nComponents(): number;
43
+ /** sklearn's `n_features_in_`. */
44
+ get nFeaturesIn(): number;
45
+ /** Tipping–Bishop noise variance — sklearn's `noise_variance_`. */
46
+ get noiseVariance(): number;
47
+ /** Whether whitening is enabled (constructor option). */
48
+ get whiten(): boolean;
49
+ /** Project X onto the principal components — sklearn's `transform`. */
50
+ transform(X: MatrixInput): Matrix;
51
+ protected transformCore(X: Matrix, xIsCentered: boolean): Matrix;
52
+ /**
53
+ * @internal Everything in transform downstream of the X·componentsᵀ
54
+ * product (mean-projection removal, whitening, dtype cast). The WebGPU
55
+ * frontend calls this with a device-computed projection; `xt` is consumed.
56
+ */
57
+ _transformFromProjection(xt: Float64Array, n: number, xDtype: Dtype, xIsCentered: boolean): Matrix;
58
+ /** Map component-space data back to feature space — sklearn's `inverse_transform`. */
59
+ inverseTransform(X: MatrixInput): Matrix;
60
+ /** Model covariance `componentsᵀ diag(ev − nv) components + nv·I` — sklearn's `get_covariance`. */
61
+ getCovariance(): Matrix;
62
+ protected covarianceF64(): Float64Array;
63
+ /** Model precision via the matrix inversion lemma — sklearn's `get_precision`. */
64
+ getPrecision(): Matrix;
65
+ protected precisionF64(): Float64Array;
66
+ /** Output feature names, e.g. ['pca0', 'pca1', …] — sklearn's `get_feature_names_out`. */
67
+ getFeatureNamesOut(): string[];
68
+ /**
69
+ * The shared fitted attributes as tight copies — a snapshot fully
70
+ * decoupled from live estimator state, safe to transfer or persist.
71
+ */
72
+ protected exportBaseModel(): PCAModelBaseFields;
73
+ /**
74
+ * Adopts a (validated) model's fields zero-copy and marks the estimator
75
+ * fitted. Callers own any defensive copying.
76
+ */
77
+ protected importBaseModel(m: PCAModelBaseFields): void;
78
+ }
79
+ //# sourceMappingURL=base.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../src/base.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAY,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAG/E,OAAO,EAAE,KAAK,KAAK,EAAmB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAG1E,wBAAgB,MAAM,CAAC,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,GAAG,UAAU,CAEhE;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,GAAG,KAAK,CAEtD;AAED,8BAAsB,OAAO;IAC3B,SAAS,CAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAS;IAEjD,SAAS,CAAC,MAAM,UAAS;IACzB,kFAAkF;IAClF,SAAS,CAAC,OAAO,UAAS;IAC1B,SAAS,CAAC,KAAK,EAAE,KAAK,CAAa;IACnC,SAAS,CAAC,SAAS,UAAS;IAE5B,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC5C,SAAS,CAAC,kBAAkB,EAAE,UAAU,GAAG,IAAI,CAAQ;IACvD,SAAS,CAAC,uBAAuB,EAAE,UAAU,GAAG,IAAI,CAAQ;IAC5D,SAAS,CAAC,eAAe,EAAE,UAAU,GAAG,IAAI,CAAQ;IACpD,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAQ;IAC1C,SAAS,CAAC,YAAY,SAAK;IAC3B,SAAS,CAAC,YAAY,SAAK;IAC3B,SAAS,CAAC,cAAc,SAAK;IAE7B,SAAS,CAAC,YAAY,IAAI,IAAI;IAU9B,0EAA0E;IAC1E,IAAI,UAAU,IAAI,MAAM,CAGvB;IAED,uCAAuC;IACvC,IAAI,iBAAiB,IAAI,UAAU,CAGlC;IAED;;;;OAIG;IACH,IAAI,sBAAsB,IAAI,UAAU,CAGvC;IAED,oCAAoC;IACpC,IAAI,cAAc,IAAI,UAAU,CAG/B;IAED,qDAAqD;IACrD,IAAI,IAAI,IAAI,UAAU,CAGrB;IAED,kEAAkE;IAClE,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED,kCAAkC;IAClC,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED,mEAAmE;IACnE,IAAI,aAAa,IAAI,MAAM,CAG1B;IAED,yDAAyD;IACzD,IAAI,MAAM,IAAI,OAAO,CAEpB;IAMD,uEAAuE;IACvE,SAAS,CAAC,CAAC,EAAE,WAAW,GAAG,MAAM;IAQjC,SAAS,CAAC,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM;IAQhE;;;;OAIG;IACH,wBAAwB,CACtB,EAAE,EAAE,YAAY,EAChB,CAAC,EAAE,MAAM,EACT,MAAM,EAAE,KAAK,EACb,WAAW,EAAE,OAAO,GACnB,MAAM;IAuCT,sFAAsF;IACtF,gBAAgB,CAAC,CAAC,EAAE,WAAW,GAAG,MAAM;IA4CxC,mGAAmG;IACnG,aAAa,IAAI,MAAM;IAMvB,SAAS,CAAC,aAAa,IAAI,YAAY;IAuCvC,kFAAkF;IAClF,YAAY,IAAI,MAAM;IAMtB,SAAS,CAAC,YAAY,IAAI,YAAY;IAmEtC,0FAA0F;IAC1F,kBAAkB,IAAI,MAAM,EAAE;IAc9B;;;OAGG;IACH,SAAS,CAAC,eAAe,IAAI,kBAAkB;IAiB/C;;;OAGG;IACH,SAAS,CAAC,eAAe,CAAC,CAAC,EAAE,kBAAkB,GAAG,IAAI;CAavD"}