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/dist/pca.js ADDED
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Principal component analysis matching `sklearn.decomposition.PCA` (1.9).
3
+ *
4
+ * Naming is idiomatic TypeScript camelCase; every option and fitted attribute
5
+ * maps 1:1 to its sklearn counterpart (`nComponents` ↔ `n_components`,
6
+ * `explainedVarianceRatio` ↔ `explained_variance_ratio_`, …). Numerics follow
7
+ * sklearn's exact computation order, including its deterministic
8
+ * `svd_flip(u_based_decision=False)` sign convention.
9
+ */
10
+ import { BasePCA, castTo, promoteDtype } from './base.js';
11
+ import { asMatrix, Matrix } from './matrix.js';
12
+ import { assertValidModel } from './model.js';
13
+ import { syrkTChunk, syrkTMirror } from './numeric/blas.js';
14
+ import { eigh } from './numeric/eigh.js';
15
+ import { lanczosSvdSteps } from './numeric/lanczos.js';
16
+ import { slogdet } from './numeric/lu.js';
17
+ import { inferDimension } from './numeric/mle.js';
18
+ import { computeBigGemm, randomizedSvdSteps } from './numeric/randomized.js';
19
+ import { checkRandomState, RandomState } from './numeric/rng.js';
20
+ import { centerInPlace, colMeans, cumsum, searchsortedRight } from './numeric/stats.js';
21
+ import { svd } from './numeric/svd.js';
22
+ import { svdFlipVBased } from './numeric/svdflip.js';
23
+ import { makeReporter, projectForSnapshot, toFloat64Copy, } from './progress.js';
24
+ import { driveAsync, driveSync } from './scheduling.js';
25
+ import { dtypeOf, epsFor } from './types.js';
26
+ import { assertAllFinite, assertMinSamplesForFit, checkFeatureCount } from './validation.js';
27
+ const SOLVERS = ['auto', 'full', 'covariance_eigh', 'arpack', 'randomized'];
28
+ const NORMALIZERS = ['auto', 'QR', 'LU', 'none'];
29
+ /**
30
+ * sklearn 1.9's auto-solver heuristic (`PCA._fit`), as a pure function so the
31
+ * WebGPU frontend dispatches identically. `nComponents` is the raw option
32
+ * (null → min(shape) for the heuristic, like sklearn's unset default).
33
+ */
34
+ export function resolveSvdSolver(rows, cols, nComponents, requested) {
35
+ if (requested !== 'auto') {
36
+ return requested;
37
+ }
38
+ const minDim = Math.min(rows, cols);
39
+ const nc = nComponents === null ? minDim : nComponents;
40
+ // Tall-and-skinny problems are best handled by precomputing the covariance.
41
+ if (cols <= 1000 && rows >= 10 * cols) {
42
+ return 'covariance_eigh';
43
+ }
44
+ if (Math.max(rows, cols) <= 500 || nc === 'mle') {
45
+ return 'full';
46
+ }
47
+ if (typeof nc === 'number' && nc >= 1 && nc < 0.8 * minDim) {
48
+ return 'randomized';
49
+ }
50
+ // Also the case of nComponents in (0, 1).
51
+ return 'full';
52
+ }
53
+ /**
54
+ * Fit-time nComponents/solver compatibility checks, shared by the CPU and
55
+ * WebGPU frontends. Message text mirrors the sklearn errors it was ported
56
+ * from (camelCased).
57
+ */
58
+ export function validateNcForSolver(nc, n, p, solver) {
59
+ const minDim = Math.min(n, p);
60
+ if (solver === 'full' || solver === 'covariance_eigh') {
61
+ if (nc === 'mle') {
62
+ if (n < p) {
63
+ throw new Error("nComponents='mle' is only supported if nSamples >= nFeatures");
64
+ }
65
+ }
66
+ else if (!(nc >= 0 && nc <= minDim)) {
67
+ throw new Error(`nComponents=${nc} must be between 0 and min(nSamples, nFeatures)=${minDim} with svdSolver='${solver}'`);
68
+ }
69
+ return;
70
+ }
71
+ if (typeof nc !== 'number') {
72
+ throw new Error(`nComponents='${nc}' cannot be a string with svdSolver='${solver}'`);
73
+ }
74
+ if (!(Number.isInteger(nc) && nc >= 1 && nc <= minDim)) {
75
+ throw new Error(`nComponents=${nc} must be an integer between 1 and min(nSamples, nFeatures)=${minDim} with svdSolver='${solver}'`);
76
+ }
77
+ if (solver === 'arpack' && nc === minDim) {
78
+ throw new Error(`nComponents=${nc} must be strictly less than min(nSamples, nFeatures)=${minDim} with svdSolver='arpack'`);
79
+ }
80
+ }
81
+ function validateOptions(o) {
82
+ const nComponents = o.nComponents === undefined ? null : o.nComponents;
83
+ if (nComponents !== null && nComponents !== 'mle') {
84
+ if (typeof nComponents !== 'number' || Number.isNaN(nComponents)) {
85
+ throw new Error(`nComponents must be a number, 'mle', or null; got ${nComponents}`);
86
+ }
87
+ const isInt = Number.isInteger(nComponents);
88
+ if (isInt ? nComponents < 0 : !(nComponents > 0 && nComponents < 1)) {
89
+ throw new Error(`nComponents must be an integer >= 0, a float in (0, 1), 'mle', or null; got ${nComponents}`);
90
+ }
91
+ }
92
+ const svdSolver = o.svdSolver ?? 'auto';
93
+ if (!SOLVERS.includes(svdSolver)) {
94
+ throw new Error(`svdSolver must be one of ${SOLVERS.join(', ')}; got '${svdSolver}'`);
95
+ }
96
+ const tol = o.tol ?? 0.0;
97
+ if (!(typeof tol === 'number' && tol >= 0)) {
98
+ throw new Error(`tol must be a number >= 0; got ${tol}`);
99
+ }
100
+ const iteratedPower = o.iteratedPower ?? 'auto';
101
+ if (iteratedPower !== 'auto' && !(Number.isInteger(iteratedPower) && iteratedPower >= 0)) {
102
+ throw new Error(`iteratedPower must be 'auto' or an integer >= 0; got ${iteratedPower}`);
103
+ }
104
+ const nOversamples = o.nOversamples ?? 10;
105
+ if (!(Number.isInteger(nOversamples) && nOversamples >= 1)) {
106
+ throw new Error(`nOversamples must be an integer >= 1; got ${nOversamples}`);
107
+ }
108
+ const powerIterationNormalizer = o.powerIterationNormalizer ?? 'auto';
109
+ if (!NORMALIZERS.includes(powerIterationNormalizer)) {
110
+ throw new Error(`powerIterationNormalizer must be one of ${NORMALIZERS.join(', ')}; got '${powerIterationNormalizer}'`);
111
+ }
112
+ return {
113
+ nComponents,
114
+ copy: o.copy ?? true,
115
+ whiten: o.whiten ?? false,
116
+ svdSolver,
117
+ tol,
118
+ iteratedPower,
119
+ nOversamples,
120
+ powerIterationNormalizer,
121
+ randomState: o.randomState ?? null,
122
+ };
123
+ }
124
+ export class PCA extends BasePCA {
125
+ opts;
126
+ nSamples_ = 0;
127
+ fitSvdSolver_ = 'full';
128
+ constructor(options = {}) {
129
+ super();
130
+ this.opts = validateOptions(options);
131
+ this.whitenOpt = this.opts.whiten;
132
+ }
133
+ /** sklearn's `n_samples_`. */
134
+ get nSamples() {
135
+ this.assertFitted();
136
+ return this.nSamples_;
137
+ }
138
+ /** The solver actually used by fit (sklearn's `_fit_svd_solver`). */
139
+ get resolvedSvdSolver() {
140
+ this.assertFitted();
141
+ return this.fitSvdSolver_;
142
+ }
143
+ // ------------------------------------------------------------------
144
+ // Fitting
145
+ // ------------------------------------------------------------------
146
+ /** Fit the model on X — sklearn's `fit`. X must have at least 2 samples. */
147
+ fit(X, observer) {
148
+ driveSync(this._fitSteps(asMatrix(X), observer), observer?.signal);
149
+ return this;
150
+ }
151
+ /** Fit and return the embedding of X — sklearn's `fit_transform` fast path. */
152
+ fitTransform(X, observer) {
153
+ const xm = asMatrix(X);
154
+ const r = driveSync(this._fitSteps(xm, observer), observer?.signal);
155
+ return this.fitTransformFromResult(r);
156
+ }
157
+ /**
158
+ * Non-blocking fit: runs the exact same steps as `fit`, time-sliced on
159
+ * the event loop (`budgetMs` of work per slice), so the UI stays
160
+ * responsive. Results are bit-identical to the synchronous fit.
161
+ */
162
+ async fitAsync(X, options = {}) {
163
+ await driveAsync(this._fitSteps(asMatrix(X), options), {
164
+ budgetMs: options.budgetMs,
165
+ signal: options.signal,
166
+ });
167
+ return this;
168
+ }
169
+ /** Non-blocking fitTransform — the same U·S fast path as the sync version. */
170
+ async fitTransformAsync(X, options = {}) {
171
+ const xm = asMatrix(X);
172
+ const r = await driveAsync(this._fitSteps(xm, options), {
173
+ budgetMs: options.budgetMs,
174
+ signal: options.signal,
175
+ });
176
+ return this.fitTransformFromResult(r);
177
+ }
178
+ /** The U·S fast path shared by fitTransform and fitTransformAsync. */
179
+ fitTransformFromResult(r) {
180
+ const n = r.x.rows;
181
+ const k = this.nComponents_;
182
+ if (r.u !== null) {
183
+ // X_new = U * S (or U * sqrt(n−1) when whitening).
184
+ const out = new Float64Array(n * k);
185
+ if (this.opts.whiten) {
186
+ const f = Math.sqrt(n - 1);
187
+ for (let i = 0; i < n; i++) {
188
+ for (let c = 0; c < k; c++) {
189
+ out[i * k + c] = r.u[i * r.uCols + c] * f;
190
+ }
191
+ }
192
+ }
193
+ else {
194
+ for (let i = 0; i < n; i++) {
195
+ for (let c = 0; c < k; c++) {
196
+ out[i * k + c] = r.u[i * r.uCols + c] * r.s[c];
197
+ }
198
+ }
199
+ }
200
+ return new Matrix(castTo(out, this.dtype), n, k);
201
+ }
202
+ // covariance_eigh computes no U at fit time.
203
+ return this.transformCore(r.x, false);
204
+ }
205
+ /**
206
+ * @internal The fit as a step generator — every `yield` is a suspension
207
+ * and abort checkpoint for the drivers (sync drain, time-sliced async,
208
+ * worker). Runs the exact statement sequence of the classic fit. Progress
209
+ * events (including the identical sequence for sync and async drives) are
210
+ * emitted synchronously between steps; on any exit before completion —
211
+ * abort, input error, callback throw — the estimator ends up unfitted.
212
+ */
213
+ *_fitSteps(X, observer) {
214
+ if (this.fitting) {
215
+ throw new Error('This PCA instance is already fitting; concurrent fits on one instance are not supported');
216
+ }
217
+ // Pre-fit validation: failures here leave any previous model intact.
218
+ if (X.rows < 1 || X.cols < 1) {
219
+ throw new Error(`Found array with shape (${X.rows}, ${X.cols}); PCA requires at least 1 sample and 1 feature`);
220
+ }
221
+ assertMinSamplesForFit(X, 'PCA');
222
+ assertAllFinite(X, 'PCA.fit');
223
+ this.fitting = true;
224
+ let completed = false;
225
+ try {
226
+ this.dtype = X.dtype;
227
+ const minDim = Math.min(X.rows, X.cols);
228
+ const ncOpt = this.opts.nComponents;
229
+ const solver = resolveSvdSolver(X.rows, X.cols, ncOpt, this.opts.svdSolver);
230
+ const nc = ncOpt === null ? (solver !== 'arpack' ? minDim : minDim - 1) : ncOpt;
231
+ this.fitSvdSolver_ = solver;
232
+ const reporter = makeReporter(observer, {
233
+ estimator: 'PCA',
234
+ solver,
235
+ nRows: X.rows,
236
+ whiten: this.opts.whiten,
237
+ });
238
+ let r;
239
+ if (solver === 'full' || solver === 'covariance_eigh') {
240
+ r = yield* this.fitFullSteps(X, nc, reporter);
241
+ }
242
+ else {
243
+ r = yield* this.fitTruncatedSteps(X, nc, solver, reporter);
244
+ }
245
+ if (reporter) {
246
+ this._emitFinalize(reporter, r.x, r.u, r.uCols, r.s);
247
+ }
248
+ completed = true;
249
+ return r;
250
+ }
251
+ finally {
252
+ this.fitting = false;
253
+ if (!completed) {
254
+ // A failed or aborted fit leaves no (possibly inconsistent) model.
255
+ this.fitted = false;
256
+ }
257
+ }
258
+ }
259
+ *fitFullSteps(X, nc, reporter, rawGram) {
260
+ const n = X.rows;
261
+ const p = X.cols;
262
+ const minDim = Math.min(n, p);
263
+ const solver = this.fitSvdSolver_;
264
+ validateNcForSolver(nc, n, p, solver);
265
+ const meanF64 = colMeans(X.data, n, p);
266
+ let u;
267
+ let uCols = 0;
268
+ let s;
269
+ let vt;
270
+ let explainedVariance;
271
+ if (solver === 'full') {
272
+ const xc = this.opts.copy ? X.data.slice() : X.data;
273
+ centerInPlace(xc, n, p, meanF64);
274
+ reporter?.emit({ phase: 'decompose', step: 0, totalSteps: null });
275
+ yield;
276
+ const dec = svd(xc, n, p);
277
+ u = dec.u;
278
+ uCols = minDim;
279
+ s = dec.s;
280
+ vt = dec.vt;
281
+ explainedVariance = new Float64Array(minDim);
282
+ for (let i = 0; i < minDim; i++) {
283
+ explainedVariance[i] = (s[i] * s[i]) / (n - 1);
284
+ }
285
+ }
286
+ else {
287
+ // covariance_eigh: form the Gram matrix and center it afterwards,
288
+ // avoiding any copy or mutation of X. The WebGPU frontend passes the
289
+ // Gram in precomputed; everything downstream is shared.
290
+ const c = rawGram ?? (yield* this.gramSteps(X, reporter));
291
+ for (let i = 0; i < p; i++) {
292
+ for (let j = 0; j < p; j++) {
293
+ c[i * p + j] = (c[i * p + j] - n * meanF64[i] * meanF64[j]) / (n - 1);
294
+ }
295
+ }
296
+ reporter?.emit({ phase: 'decompose', step: 0, totalSteps: null });
297
+ yield;
298
+ const dec = eigh(c, p);
299
+ // Ascending → descending; clip tiny negatives (PSD by construction).
300
+ const evals = new Float64Array(p);
301
+ for (let i = 0; i < p; i++) {
302
+ const v = dec.values[p - 1 - i];
303
+ evals[i] = v < 0 ? 0 : v;
304
+ }
305
+ explainedVariance = evals;
306
+ s = new Float64Array(p);
307
+ vt = new Float64Array(p * p);
308
+ for (let i = 0; i < p; i++) {
309
+ s[i] = Math.sqrt(evals[i] * (n - 1));
310
+ for (let j = 0; j < p; j++) {
311
+ vt[i * p + j] = dec.vectors[j * p + (p - 1 - i)];
312
+ }
313
+ }
314
+ u = null;
315
+ }
316
+ // Deterministic sign convention (sklearn 1.9: v-based for all solvers).
317
+ svdFlipVBased(u, n, vt, u === null ? p : minDim, p);
318
+ let totalVar = 0;
319
+ for (let i = 0; i < explainedVariance.length; i++) {
320
+ totalVar += explainedVariance[i];
321
+ }
322
+ const ratio = new Float64Array(explainedVariance.length);
323
+ for (let i = 0; i < explainedVariance.length; i++) {
324
+ ratio[i] = explainedVariance[i] / totalVar;
325
+ }
326
+ let k;
327
+ if (nc === 'mle') {
328
+ k = inferDimension(explainedVariance, n);
329
+ }
330
+ else if (nc > 0 && nc < 1) {
331
+ k = searchsortedRight(cumsum(ratio), nc) + 1;
332
+ }
333
+ else {
334
+ k = nc;
335
+ }
336
+ this.noiseVariance_ = 0;
337
+ if (k < minDim) {
338
+ let acc = 0;
339
+ for (let i = k; i < explainedVariance.length; i++) {
340
+ acc += explainedVariance[i];
341
+ }
342
+ this.noiseVariance_ = acc / (explainedVariance.length - k);
343
+ }
344
+ this.storeFitted(X, meanF64, k, vt, explainedVariance, ratio, s);
345
+ return { u, uCols, s, x: X };
346
+ }
347
+ /**
348
+ * The XᵀX Gram product in row chunks, yielding between chunks. Chunked
349
+ * accumulation is strictly row-sequential, so the result is bitwise
350
+ * identical to one monolithic syrkT pass.
351
+ */
352
+ *gramSteps(X, reporter) {
353
+ const n = X.rows;
354
+ const p = X.cols;
355
+ const c = new Float64Array(p * p);
356
+ const chunkRows = Math.max(64, Math.ceil(2 ** 22 / (p * p)));
357
+ const totalSteps = Math.ceil(n / chunkRows);
358
+ let step = 0;
359
+ for (let start = 0; start < n; start += chunkRows) {
360
+ const end = Math.min(n, start + chunkRows);
361
+ syrkTChunk(X.data, p, start, end, c);
362
+ step++;
363
+ reporter?.emit({ phase: 'gram', step, totalSteps });
364
+ yield;
365
+ }
366
+ syrkTMirror(c, p);
367
+ return c;
368
+ }
369
+ *fitTruncatedSteps(X, nc, solver, reporter) {
370
+ const n = X.rows;
371
+ const p = X.cols;
372
+ const minDim = Math.min(n, p);
373
+ validateNcForSolver(nc, n, p, solver);
374
+ const k = nc;
375
+ const rng = checkRandomState(this.opts.randomState);
376
+ const meanF64 = colMeans(X.data, n, p);
377
+ const xc = this.opts.copy ? X.data.slice() : X.data;
378
+ centerInPlace(xc, n, p, meanF64);
379
+ let dec;
380
+ if (solver === 'arpack') {
381
+ const v0 = new Float64Array(minDim);
382
+ rng.uniform(-1, 1, v0);
383
+ // scipy's svds returns ascending order and sklearn reverses it; our
384
+ // Lanczos yields the same converged triplets already descending.
385
+ dec = yield* lanczosSvdSteps(xc, n, p, k, v0, rng, reporter);
386
+ }
387
+ else {
388
+ const gen = randomizedSvdSteps(n, p, k, {
389
+ nOversamples: this.opts.nOversamples,
390
+ nIter: this.opts.iteratedPower,
391
+ powerIterationNormalizer: this.opts.powerIterationNormalizer,
392
+ rng,
393
+ float32Stream: this.dtype === 'float32',
394
+ hooks: reporter,
395
+ });
396
+ let step = gen.next();
397
+ while (!step.done) {
398
+ yield;
399
+ step = gen.next(computeBigGemm(xc, n, p, step.value));
400
+ }
401
+ dec = step.value;
402
+ }
403
+ return this.finishTruncated(X, xc, meanF64, k, dec);
404
+ }
405
+ /** Post-decomposition tail of the truncated solvers (flip, variances, store). */
406
+ finishTruncated(X, xc, meanF64, nc, dec) {
407
+ const n = X.rows;
408
+ const p = X.cols;
409
+ const minDim = Math.min(n, p);
410
+ const { u, s, vt } = dec;
411
+ svdFlipVBased(u, n, vt, nc, p);
412
+ const explainedVariance = new Float64Array(nc);
413
+ for (let i = 0; i < nc; i++) {
414
+ explainedVariance[i] = (s[i] * s[i]) / (n - 1);
415
+ }
416
+ // Total variance of the centered data. sklearn squares X_centered in
417
+ // place — destroying the caller's data when copy=false — replicated here.
418
+ let totalVar = 0;
419
+ if (xc !== X.data) {
420
+ for (let i = 0; i < xc.length; i++) {
421
+ totalVar += xc[i] * xc[i];
422
+ }
423
+ }
424
+ else {
425
+ for (let i = 0; i < xc.length; i++) {
426
+ xc[i] *= xc[i];
427
+ totalVar += xc[i];
428
+ }
429
+ }
430
+ totalVar /= n - 1;
431
+ const ratio = new Float64Array(nc);
432
+ for (let i = 0; i < nc; i++) {
433
+ ratio[i] = explainedVariance[i] / totalVar;
434
+ }
435
+ this.noiseVariance_ = 0;
436
+ if (nc < minDim) {
437
+ let acc = totalVar;
438
+ for (let i = 0; i < nc; i++) {
439
+ acc -= explainedVariance[i];
440
+ }
441
+ this.noiseVariance_ = acc / (minDim - nc);
442
+ }
443
+ this.storeFitted(X, meanF64, nc, vt, explainedVariance, ratio, s);
444
+ return { u, uCols: nc, s, x: X };
445
+ }
446
+ // ------------------------------------------------------------------
447
+ // WebGPU frontend bridges (internal API — not part of the public surface)
448
+ // ------------------------------------------------------------------
449
+ /**
450
+ * @internal Completes a covariance_eigh fit from an externally computed
451
+ * raw Gram matrix XᵀX (uncentered, float64, p×p). Used by the WebGPU
452
+ * frontend; all semantics downstream of the Gram product are shared with
453
+ * the CPU path. `rawGram` is consumed (mutated in place).
454
+ */
455
+ _fitGram(X, rawGram) {
456
+ this.prepareFit(X, 'covariance_eigh');
457
+ const minDim = Math.min(X.rows, X.cols);
458
+ const ncOpt = this.opts.nComponents;
459
+ driveSync(this.fitFullSteps(X, ncOpt === null ? minDim : ncOpt, null, rawGram));
460
+ }
461
+ /**
462
+ * @internal Completes a randomized fit from an externally computed
463
+ * decomposition of the centered data. `xc` must be the centered training
464
+ * buffer (X.data itself when copy=false, matching sklearn's destructive
465
+ * semantics) and `meanF64` the original column means.
466
+ */
467
+ _fitDecomposed(X, xc, meanF64, dec) {
468
+ this.prepareFit(X, 'randomized');
469
+ const ncOpt = this.opts.nComponents;
470
+ const nc = ncOpt === null ? Math.min(X.rows, X.cols) : ncOpt;
471
+ validateNcForSolver(nc, X.rows, X.cols, 'randomized');
472
+ this.finishTruncated(X, xc, meanF64, nc, dec);
473
+ }
474
+ /** @internal Option access for the WebGPU frontend (read-only). */
475
+ _resolvedOptions() {
476
+ return this.opts;
477
+ }
478
+ /** The fitCore preamble shared by the internal fit bridges. */
479
+ prepareFit(X, solver) {
480
+ if (X.rows < 1 || X.cols < 1) {
481
+ throw new Error(`Found array with shape (${X.rows}, ${X.cols}); PCA requires at least 1 sample and 1 feature`);
482
+ }
483
+ assertMinSamplesForFit(X, 'PCA');
484
+ assertAllFinite(X, 'PCA.fit');
485
+ this.dtype = X.dtype;
486
+ this.fitSvdSolver_ = solver;
487
+ }
488
+ /**
489
+ * @internal Emits the finalize event (fraction 1, snapshot of the final
490
+ * model) after storeFitted — shared by _fitSteps and the WebGPU frontend.
491
+ * `u`/`s` are the fit's left vectors and singular values when the solver
492
+ * produced them (already sign-flipped), used for U·S snapshot scores.
493
+ */
494
+ _emitFinalize(reporter, x, u, uCols, s) {
495
+ reporter.emit({
496
+ phase: 'finalize',
497
+ step: 1,
498
+ totalSteps: 1,
499
+ snapshot: this.finalizeSnapshot(reporter, x, u, uCols, s),
500
+ });
501
+ }
502
+ /**
503
+ * The final model as a snapshot (fresh float64 copies of the stored
504
+ * attributes — float32 fits produce float32-rounded values, faithfully
505
+ * reflecting the model). Scores use U·S when the solver produced a U
506
+ * (`fitTransform`'s math) and one projection GEMM for covariance_eigh,
507
+ * whose fit never mutates X.
508
+ */
509
+ finalizeSnapshot(reporter, x, u, uCols, s) {
510
+ if (!reporter.snapshotsEnabled) {
511
+ return undefined;
512
+ }
513
+ const comp = this.components_;
514
+ const k = this.nComponents_;
515
+ const p = this.nFeaturesIn_;
516
+ const snapshot = {
517
+ components: new Matrix(toFloat64Copy(comp.data), k, p),
518
+ singularValues: toFloat64Copy(this.singularValues_),
519
+ explainedVariance: toFloat64Copy(this.explainedVariance_),
520
+ };
521
+ if (reporter.scoresRequested) {
522
+ const n = x.rows;
523
+ if (u !== null && s !== null) {
524
+ const scores = new Float64Array(n * k);
525
+ const f = Math.sqrt(n - 1);
526
+ for (let i = 0; i < n; i++) {
527
+ for (let c = 0; c < k; c++) {
528
+ const uv = u[i * uCols + c];
529
+ scores[i * k + c] = this.opts.whiten ? uv * f : uv * s[c];
530
+ }
531
+ }
532
+ snapshot.scores = new Matrix(scores, n, k);
533
+ }
534
+ else {
535
+ const ev = this.explainedVariance_;
536
+ snapshot.scores = projectForSnapshot(x.data, n, p, comp.data, k, this.mean_, ev, this.opts.whiten, epsFor(dtypeOf(ev)));
537
+ }
538
+ }
539
+ return snapshot;
540
+ }
541
+ storeFitted(X, meanF64, k, vt, explainedVariance, ratio, s) {
542
+ const p = X.cols;
543
+ const dt = this.dtype;
544
+ this.nSamples_ = X.rows;
545
+ this.nFeaturesIn_ = p;
546
+ this.nComponents_ = k;
547
+ this.components_ = new Matrix(castTo(vt.slice(0, k * p), dt), k, p);
548
+ this.explainedVariance_ = castTo(explainedVariance.slice(0, k), dt);
549
+ this.explainedVarianceRatio_ = castTo(ratio.slice(0, k), dt);
550
+ this.singularValues_ = castTo(s.slice(0, k), dt);
551
+ this.mean_ = castTo(meanF64, dt);
552
+ this.fitted = true;
553
+ }
554
+ // ------------------------------------------------------------------
555
+ // Model serialization
556
+ // ------------------------------------------------------------------
557
+ /**
558
+ * The fitted model as a plain structured-clone-friendly object (tight
559
+ * typed-array copies) — for `postMessage`, IndexedDB, or `modelToJSON`.
560
+ * A live `RandomState` in the options serializes as null.
561
+ */
562
+ toModel() {
563
+ const base = this.exportBaseModel();
564
+ const o = this.opts;
565
+ return {
566
+ ...base,
567
+ estimator: 'pca',
568
+ nSamples: this.nSamples_,
569
+ svdSolver: this.fitSvdSolver_,
570
+ options: {
571
+ nComponents: o.nComponents,
572
+ copy: o.copy,
573
+ whiten: o.whiten,
574
+ svdSolver: o.svdSolver,
575
+ tol: o.tol,
576
+ iteratedPower: o.iteratedPower,
577
+ nOversamples: o.nOversamples,
578
+ powerIterationNormalizer: o.powerIterationNormalizer,
579
+ randomState: typeof o.randomState === 'number' ? o.randomState : null,
580
+ },
581
+ };
582
+ }
583
+ /**
584
+ * Rehydrates a fitted PCA from `toModel()` output (validated first).
585
+ * The model's arrays are adopted without copying — pass a copy if the
586
+ * caller keeps mutating the model object.
587
+ */
588
+ static fromModel(model) {
589
+ assertValidModel(model, 'pca');
590
+ const pca = new PCA(model.options);
591
+ pca.importBaseModel(model);
592
+ pca.nSamples_ = model.nSamples;
593
+ pca.fitSvdSolver_ = model.svdSolver;
594
+ return pca;
595
+ }
596
+ // ------------------------------------------------------------------
597
+ // Probabilistic PCA log-likelihood (PCA-only in sklearn, too)
598
+ // ------------------------------------------------------------------
599
+ /** Per-sample log-likelihood under the Tipping–Bishop probabilistic PCA model. */
600
+ scoreSamples(X) {
601
+ this.assertFitted();
602
+ const xm = asMatrix(X);
603
+ assertAllFinite(xm, 'PCA.scoreSamples');
604
+ checkFeatureCount(xm, this.nFeaturesIn_, this.estimatorName);
605
+ const meanArr = this.mean_;
606
+ const n = xm.rows;
607
+ const p = xm.cols;
608
+ const prec = this.precisionF64();
609
+ const [sign, logdet] = slogdet(prec, p);
610
+ const fastLogdet = sign > 0 ? logdet : Number.NEGATIVE_INFINITY;
611
+ const constTerm = 0.5 * (p * Math.log(2 * Math.PI) - fastLogdet);
612
+ const out = new Float64Array(n);
613
+ const xr = new Float64Array(p);
614
+ const proj = new Float64Array(p);
615
+ for (let i = 0; i < n; i++) {
616
+ const off = i * p;
617
+ for (let j = 0; j < p; j++) {
618
+ xr[j] = xm.data[off + j] - meanArr[j];
619
+ }
620
+ proj.fill(0);
621
+ for (let a = 0; a < p; a++) {
622
+ const v = xr[a];
623
+ if (v !== 0) {
624
+ const poff = a * p;
625
+ for (let j = 0; j < p; j++) {
626
+ proj[j] += v * prec[poff + j];
627
+ }
628
+ }
629
+ }
630
+ let acc = 0;
631
+ for (let j = 0; j < p; j++) {
632
+ acc += xr[j] * proj[j];
633
+ }
634
+ out[i] = -0.5 * acc - constTerm;
635
+ }
636
+ return castTo(out, promoteDtype(xm.dtype, this.dtype));
637
+ }
638
+ /** Mean log-likelihood of all samples — sklearn's `score`. */
639
+ score(X) {
640
+ const ll = this.scoreSamples(X);
641
+ let acc = 0;
642
+ for (let i = 0; i < ll.length; i++) {
643
+ acc += ll[i];
644
+ }
645
+ return acc / ll.length;
646
+ }
647
+ }
648
+ export { RandomState };
649
+ //# sourceMappingURL=pca.js.map