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.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/base.d.ts +79 -0
- package/dist/base.d.ts.map +1 -0
- package/dist/base.js +358 -0
- package/dist/base.js.map +1 -0
- package/dist/incremental-pca.d.ts +86 -0
- package/dist/incremental-pca.d.ts.map +1 -0
- package/dist/incremental-pca.js +397 -0
- package/dist/incremental-pca.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/matrix.d.ts +44 -0
- package/dist/matrix.d.ts.map +1 -0
- package/dist/matrix.js +99 -0
- package/dist/matrix.js.map +1 -0
- package/dist/model.d.ts +86 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +132 -0
- package/dist/model.js.map +1 -0
- package/dist/numeric/blas.d.ts +33 -0
- package/dist/numeric/blas.d.ts.map +1 -0
- package/dist/numeric/blas.js +130 -0
- package/dist/numeric/blas.js.map +1 -0
- package/dist/numeric/eigh.d.ts +21 -0
- package/dist/numeric/eigh.d.ts.map +1 -0
- package/dist/numeric/eigh.js +233 -0
- package/dist/numeric/eigh.js.map +1 -0
- package/dist/numeric/lanczos.d.ts +31 -0
- package/dist/numeric/lanczos.d.ts.map +1 -0
- package/dist/numeric/lanczos.js +287 -0
- package/dist/numeric/lanczos.js.map +1 -0
- package/dist/numeric/lu.d.ts +27 -0
- package/dist/numeric/lu.d.ts.map +1 -0
- package/dist/numeric/lu.js +149 -0
- package/dist/numeric/lu.js.map +1 -0
- package/dist/numeric/mle.d.ts +5 -0
- package/dist/numeric/mle.d.ts.map +1 -0
- package/dist/numeric/mle.js +67 -0
- package/dist/numeric/mle.js.map +1 -0
- package/dist/numeric/qr.d.ts +15 -0
- package/dist/numeric/qr.d.ts.map +1 -0
- package/dist/numeric/qr.js +88 -0
- package/dist/numeric/qr.js.map +1 -0
- package/dist/numeric/randomized.d.ts +54 -0
- package/dist/numeric/randomized.d.ts.map +1 -0
- package/dist/numeric/randomized.js +130 -0
- package/dist/numeric/randomized.js.map +1 -0
- package/dist/numeric/rng.d.ts +40 -0
- package/dist/numeric/rng.d.ts.map +1 -0
- package/dist/numeric/rng.js +127 -0
- package/dist/numeric/rng.js.map +1 -0
- package/dist/numeric/special.d.ts +2 -0
- package/dist/numeric/special.d.ts.map +1 -0
- package/dist/numeric/special.js +25 -0
- package/dist/numeric/special.js.map +1 -0
- package/dist/numeric/stats.d.ts +27 -0
- package/dist/numeric/stats.d.ts.map +1 -0
- package/dist/numeric/stats.js +110 -0
- package/dist/numeric/stats.js.map +1 -0
- package/dist/numeric/svd.d.ts +27 -0
- package/dist/numeric/svd.d.ts.map +1 -0
- package/dist/numeric/svd.js +374 -0
- package/dist/numeric/svd.js.map +1 -0
- package/dist/numeric/svdflip.d.ts +9 -0
- package/dist/numeric/svdflip.d.ts.map +1 -0
- package/dist/numeric/svdflip.js +32 -0
- package/dist/numeric/svdflip.js.map +1 -0
- package/dist/pca.d.ts +170 -0
- package/dist/pca.d.ts.map +1 -0
- package/dist/pca.js +649 -0
- package/dist/pca.js.map +1 -0
- package/dist/progress.d.ts +155 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +173 -0
- package/dist/progress.js.map +1 -0
- package/dist/scheduling.d.ts +66 -0
- package/dist/scheduling.d.ts.map +1 -0
- package/dist/scheduling.js +160 -0
- package/dist/scheduling.js.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +17 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +36 -0
- package/dist/validation.js.map +1 -0
- package/dist/webgpu/df64.d.ts +18 -0
- package/dist/webgpu/df64.d.ts.map +1 -0
- package/dist/webgpu/df64.js +42 -0
- package/dist/webgpu/df64.js.map +1 -0
- package/dist/webgpu/engine.d.ts +74 -0
- package/dist/webgpu/engine.d.ts.map +1 -0
- package/dist/webgpu/engine.js +326 -0
- package/dist/webgpu/engine.js.map +1 -0
- package/dist/webgpu/gpu-pca.d.ts +90 -0
- package/dist/webgpu/gpu-pca.d.ts.map +1 -0
- package/dist/webgpu/gpu-pca.js +407 -0
- package/dist/webgpu/gpu-pca.js.map +1 -0
- package/dist/webgpu/index.d.ts +19 -0
- package/dist/webgpu/index.d.ts.map +1 -0
- package/dist/webgpu/index.js +16 -0
- package/dist/webgpu/index.js.map +1 -0
- package/dist/webgpu/kernels.d.ts +47 -0
- package/dist/webgpu/kernels.d.ts.map +1 -0
- package/dist/webgpu/kernels.js +147 -0
- package/dist/webgpu/kernels.js.map +1 -0
- package/dist/worker/client.d.ts +174 -0
- package/dist/worker/client.d.ts.map +1 -0
- package/dist/worker/client.js +482 -0
- package/dist/worker/client.js.map +1 -0
- package/dist/worker/handler.d.ts +17 -0
- package/dist/worker/handler.d.ts.map +1 -0
- package/dist/worker/handler.js +404 -0
- package/dist/worker/handler.js.map +1 -0
- package/dist/worker/protocol.d.ts +192 -0
- package/dist/worker/protocol.d.ts.map +1 -0
- package/dist/worker/protocol.js +70 -0
- package/dist/worker/protocol.js.map +1 -0
- package/dist/worker/worker.d.ts +14 -0
- package/dist/worker/worker.d.ts.map +1 -0
- package/dist/worker/worker.js +20 -0
- package/dist/worker/worker.js.map +1 -0
- 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
|