@qe-libs/rena-wasm 0.1.5 → 0.1.6
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/README.md +20 -0
- package/package.json +1 -1
- package/src/index.js +140 -97
- package/src/pipeline.js +5 -2
package/README.md
CHANGED
|
@@ -91,6 +91,26 @@ const model = ena.fit(rows, { codes, units, conversations, window: 4, weightMode
|
|
|
91
91
|
|
|
92
92
|
---
|
|
93
93
|
|
|
94
|
+
## Reduced-Code Search (PRIA)
|
|
95
|
+
|
|
96
|
+
`pria()` finds the largest set of codes (up to `removeNum`, never leaving
|
|
97
|
+
fewer than 3) whose removal keeps the model within `threshold` of the full
|
|
98
|
+
model — the same search, gates and tie-break as R's `PRIA::pria()`. It takes
|
|
99
|
+
the same options as `fit()` (rotation, `weightModel`, `tensor`, `codeMask`,
|
|
100
|
+
…), so it scores the model you display.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const { removed, k, variance } = ena.pria(rows, {
|
|
104
|
+
codes, units, conversations, window: 4,
|
|
105
|
+
rotation: 'mean', groupA, groupB,
|
|
106
|
+
removeNum: 3, threshold: 0.95,
|
|
107
|
+
});
|
|
108
|
+
// removed → code names to drop (most codes first, then highest dim-1 variance)
|
|
109
|
+
// variance → dim-1 share of variance in the chosen reduced model
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
94
114
|
## Accumulation Only
|
|
95
115
|
|
|
96
116
|
Returns raw (un-normalised) network vectors without running the full pipeline.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -210,11 +210,47 @@ function weightModelName(name) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// ── code masking ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Zero, in place, every connection column the code mask excludes.
|
|
217
|
+
* codeMask is an nCodes × nCodes 0/1 matrix; a 0 at [a][b] drops the
|
|
218
|
+
* connection between codes a and b (unordered: either orientation drops the
|
|
219
|
+
* pair; ordered: codeMask[j][i] === 0 drops the directed column i*n + j).
|
|
220
|
+
*
|
|
221
|
+
* @param {Float64Array} networks nUnits × nConnections, row-major
|
|
222
|
+
* @param {number[][]} codeMask
|
|
223
|
+
* @param {number} nCodes
|
|
224
|
+
* @param {number} nUnits
|
|
225
|
+
* @param {number} nConnections
|
|
226
|
+
* @param {boolean} ordered
|
|
227
|
+
*/
|
|
228
|
+
function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered) {
|
|
229
|
+
if (!codeMask || codeMask.length !== nCodes) return;
|
|
230
|
+
const zeroColumn = (k) => {
|
|
231
|
+
for (let u = 0; u < nUnits; u++) networks[u * nConnections + k] = 0;
|
|
232
|
+
};
|
|
233
|
+
if (ordered) {
|
|
234
|
+
for (let j = 0; j < nCodes; j++)
|
|
235
|
+
for (let i = 0; i < nCodes; i++)
|
|
236
|
+
if (codeMask[j] && codeMask[j][i] === 0) zeroColumn(i * nCodes + j);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
let k = 0;
|
|
240
|
+
for (let col = 1; col < nCodes; col++) {
|
|
241
|
+
for (let row = 0; row < col; row++) {
|
|
242
|
+
if ((codeMask[row] && codeMask[row][col] === 0) ||
|
|
243
|
+
(codeMask[col] && codeMask[col][row] === 0)) zeroColumn(k);
|
|
244
|
+
k++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
213
249
|
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
214
250
|
|
|
215
251
|
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
216
252
|
metaData, rotMethod, groupA, groupB, dims, gParams,
|
|
217
|
-
rowConnectionCounts = null) {
|
|
253
|
+
rowConnectionCounts = null, directedNodes = false) {
|
|
218
254
|
const connectionNames = qe.connection_names(codes);
|
|
219
255
|
|
|
220
256
|
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
@@ -241,6 +277,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
241
277
|
rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
|
|
242
278
|
}
|
|
243
279
|
|
|
280
|
+
// Never ask for more dimensions than the rotation has columns.
|
|
281
|
+
dims = Math.min(dims, rot.rotCols);
|
|
282
|
+
|
|
244
283
|
// Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
|
|
245
284
|
const rotationMatrix = new Float64Array(rot.rotRows * dims);
|
|
246
285
|
for (let r = 0; r < rot.rotRows; r++)
|
|
@@ -259,8 +298,10 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
259
298
|
// Matches rENA's lws.positions.sq, which regresses the projected points onto
|
|
260
299
|
// the SPHERE-normed line weights (enaset$line.weights) — not the centered
|
|
261
300
|
// networks. Verified node-for-node against R rENA on rs.data.new.csv.
|
|
301
|
+
// `directedNodes` (ordered networks, used by pria()) solves with libqe's
|
|
302
|
+
// directed_node_positions, as rENA's optimize() does for ordered sets.
|
|
262
303
|
const { nodes, centroids } = nodePositions(
|
|
263
|
-
qe, lineWeights, nUnits, nConnections, points, dims
|
|
304
|
+
qe, lineWeights, nUnits, nConnections, points, dims, directedNodes
|
|
264
305
|
);
|
|
265
306
|
|
|
266
307
|
// Variance explained (= R's model$variance)
|
|
@@ -371,34 +412,7 @@ export default async function loadENA() {
|
|
|
371
412
|
);
|
|
372
413
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
373
414
|
|
|
374
|
-
|
|
375
|
-
if (codeMask && codeMask.length === codes.length) {
|
|
376
|
-
console.log('[rena-wasm] Applying code mask to raw networks...');
|
|
377
|
-
if (ordered) {
|
|
378
|
-
for (let j = 0; j < codes.length; j++) {
|
|
379
|
-
for (let i = 0; i < codes.length; i++) {
|
|
380
|
-
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
381
|
-
const k = i * codes.length + j;
|
|
382
|
-
for (let u = 0; u < nUnits; u++) {
|
|
383
|
-
rawNetworks[u * nConnections + k] = 0;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
} else {
|
|
389
|
-
let k = 0;
|
|
390
|
-
for (let col = 1; col < codes.length; col++) {
|
|
391
|
-
for (let row = 0; row < col; row++) {
|
|
392
|
-
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
393
|
-
for (let u = 0; u < nUnits; u++) {
|
|
394
|
-
rawNetworks[u * nConnections + k] = 0;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
k++;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
415
|
+
applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
402
416
|
|
|
403
417
|
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
404
418
|
unitLabels, metaData, rotMethod, groupA, groupB,
|
|
@@ -444,34 +458,7 @@ export default async function loadENA() {
|
|
|
444
458
|
);
|
|
445
459
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
446
460
|
|
|
447
|
-
|
|
448
|
-
if (codeMask && codeMask.length === codes.length) {
|
|
449
|
-
console.log('[rena-wasm] Applying code mask to accumulated networks...');
|
|
450
|
-
if (ordered) {
|
|
451
|
-
for (let j = 0; j < codes.length; j++) {
|
|
452
|
-
for (let i = 0; i < codes.length; i++) {
|
|
453
|
-
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
454
|
-
const k = i * codes.length + j;
|
|
455
|
-
for (let u = 0; u < nUnits; u++) {
|
|
456
|
-
networks[u * nConnections + k] = 0;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
} else {
|
|
462
|
-
let k = 0;
|
|
463
|
-
for (let col = 1; col < codes.length; col++) {
|
|
464
|
-
for (let row = 0; row < col; row++) {
|
|
465
|
-
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
466
|
-
for (let u = 0; u < nUnits; u++) {
|
|
467
|
-
networks[u * nConnections + k] = 0;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
k++;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
461
|
+
applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
475
462
|
|
|
476
463
|
const connectionNames = qe.connection_names(codes);
|
|
477
464
|
return {
|
|
@@ -676,12 +663,27 @@ export default async function loadENA() {
|
|
|
676
663
|
* removed while keeping the model's goodness-of-fit within `threshold`
|
|
677
664
|
* of the full model. Brute-force subset search matching R
|
|
678
665
|
* PRIA::pria(): for k = 1..removeNum, over every k-subset of codes,
|
|
679
|
-
* build the reduced model (
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
* with the MOST codes removed, then the highest reduced dim-1
|
|
666
|
+
* build the reduced model (drop every connection touching a removed
|
|
667
|
+
* code, as R's remove.codes.from.accum does), gate on min_d(gof_reduced[d] / gof_full[d]) >= threshold where
|
|
668
|
+
* gof is the per-dimension ena_correlation(points, centroids) on dims
|
|
669
|
+
* 1:2, then on point and retained-node correlations, and keep the
|
|
670
|
+
* subset with the MOST codes removed, then the highest reduced dim-1
|
|
671
|
+
* variance (= R's reduced.set$model$variance[1]).
|
|
672
|
+
*
|
|
673
|
+
* The full and reduced models are built from the same accumulation as
|
|
674
|
+
* fit() with the same options — weightModel, tensor and codeMask
|
|
675
|
+
* included — so PRIA scores the model the caller actually displays.
|
|
676
|
+
* The data are accumulated once; each candidate only drops the removed
|
|
677
|
+
* codes' connection columns and re-runs normalize → center → rotate →
|
|
678
|
+
* project.
|
|
684
679
|
*
|
|
680
|
+
* @param {Object[]} rows
|
|
681
|
+
* @param {object} opts - fit() options (codes, units, conversations,
|
|
682
|
+
* window, binary, ordered, tensor, weightModel,
|
|
683
|
+
* codeMask, rotation, groupA, groupB, gParams)
|
|
684
|
+
* plus removeNum (default 3) and threshold
|
|
685
|
+
* (default 0.95). `dims` is ignored: scoring
|
|
686
|
+
* uses dims 1:2 and the variance uses all dims.
|
|
685
687
|
* @returns {{ removed: string[], removedIndices: number[], k: number,
|
|
686
688
|
* variance: (number|null) }}
|
|
687
689
|
*/
|
|
@@ -689,7 +691,8 @@ export default async function loadENA() {
|
|
|
689
691
|
const {
|
|
690
692
|
codes, units, conversations,
|
|
691
693
|
window: windowSize = 4, binary = true, ordered = false,
|
|
692
|
-
|
|
694
|
+
tensor: tensorDef, weightModel, codeMask,
|
|
695
|
+
rotation = 'svd', gParams, groupA, groupB,
|
|
693
696
|
removeNum = 3, threshold = 0.95,
|
|
694
697
|
} = opts;
|
|
695
698
|
if (!codes?.length) throw new Error('opts.codes is required');
|
|
@@ -701,32 +704,79 @@ export default async function loadENA() {
|
|
|
701
704
|
const empty = { removed: [], removedIndices: [], k: 0, variance: null };
|
|
702
705
|
if (rn < 1) return empty;
|
|
703
706
|
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
+
// Accumulate once, exactly as fit() would for these options.
|
|
708
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
709
|
+
unitOf, convoGroups, metaData } =
|
|
710
|
+
parseData(rows, codes, units, conversations);
|
|
711
|
+
const { networks: raw } = accumulateTensor(
|
|
712
|
+
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
713
|
+
unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
|
|
714
|
+
ordered, binary, weightModelName(weightModel)
|
|
715
|
+
);
|
|
716
|
+
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
717
|
+
applyCodeMask(raw, codeMask, nCodes, nUnits, nConnections, ordered);
|
|
718
|
+
|
|
719
|
+
// Build a model from the accumulation with `removedSet`'s codes
|
|
720
|
+
// DROPPED -- their connection columns removed and the code list
|
|
721
|
+
// shortened -- as R PRIA's remove.codes.from.accum() does. Zeroing
|
|
722
|
+
// the columns instead is not equivalent: libqe's GMR rotation is
|
|
723
|
+
// sensitive to all-zero columns. All dimensions are kept so
|
|
724
|
+
// model.variance is the full-spectrum ratio R uses; the gates read
|
|
725
|
+
// only dims 1:2.
|
|
726
|
+
const fitWithout = (removedSet) => {
|
|
727
|
+
let networks = raw, keptCodes = codes, nConn = nConnections;
|
|
728
|
+
if (removedSet) {
|
|
729
|
+
const keep = (c) => !removedSet.has(c);
|
|
730
|
+
const cols = [];
|
|
731
|
+
if (ordered) {
|
|
732
|
+
for (let i = 0; i < nCodes; i++)
|
|
733
|
+
for (let j = 0; j < nCodes; j++)
|
|
734
|
+
if (keep(i) && keep(j)) cols.push(i * nCodes + j);
|
|
735
|
+
} else {
|
|
736
|
+
let k = 0;
|
|
737
|
+
for (let col = 1; col < nCodes; col++)
|
|
738
|
+
for (let row = 0; row < col; row++, k++)
|
|
739
|
+
if (keep(row) && keep(col)) cols.push(k);
|
|
740
|
+
}
|
|
741
|
+
nConn = cols.length;
|
|
742
|
+
networks = new Float64Array(nUnits * nConn);
|
|
743
|
+
for (let u = 0; u < nUnits; u++)
|
|
744
|
+
for (let c = 0; c < nConn; c++)
|
|
745
|
+
networks[u * nConn + c] = raw[u * nConnections + cols[c]];
|
|
746
|
+
keptCodes = codes.filter((_, i) => keep(i));
|
|
747
|
+
}
|
|
748
|
+
// Ordered models are scored with directed node positions (R's
|
|
749
|
+
// ordered pipeline), independent of how fit() places nodes.
|
|
750
|
+
return runPipeline(qe, networks, nUnits, nConn, keptCodes,
|
|
751
|
+
unitLabels, metaData, rotation, groupA, groupB,
|
|
752
|
+
nConn, gParams, null, ordered);
|
|
753
|
+
};
|
|
707
754
|
|
|
708
|
-
const D =
|
|
709
|
-
//
|
|
710
|
-
// row-major (nRows × dims) array.
|
|
711
|
-
const sub = (flat, rowIdxs) => {
|
|
755
|
+
const D = 2; // score on the first 2 dims (R's get_pria_scores_2Ds)
|
|
756
|
+
// D-column submatrix (given row indices) of a model's flat
|
|
757
|
+
// row-major (nRows × model.dims) array.
|
|
758
|
+
const sub = (mdl, flat, rowIdxs) => {
|
|
712
759
|
const out = new Float64Array(rowIdxs.length * D);
|
|
713
|
-
rowIdxs.forEach((r, t) => {
|
|
760
|
+
rowIdxs.forEach((r, t) => {
|
|
761
|
+
for (let d = 0; d < D; d++) out[t * D + d] = flat[r * mdl.dims + d];
|
|
762
|
+
});
|
|
714
763
|
return out;
|
|
715
764
|
};
|
|
716
765
|
// ena_correlation(A, B) → [r_dim0, r_dim1] (col 0 of the dims×3 result).
|
|
717
|
-
const corr2 = (Aflat, Bflat,
|
|
718
|
-
const r = qe.ena_correlation(Array.from(Aflat),
|
|
719
|
-
Array.from(Bflat),
|
|
766
|
+
const corr2 = (Aflat, Bflat, n) => {
|
|
767
|
+
const r = qe.ena_correlation(Array.from(Aflat), n, D,
|
|
768
|
+
Array.from(Bflat), n, D, 0.95);
|
|
720
769
|
const out = [];
|
|
721
770
|
for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
|
|
722
771
|
return out;
|
|
723
772
|
};
|
|
724
|
-
|
|
725
|
-
const
|
|
773
|
+
|
|
774
|
+
const full = fitWithout(null);
|
|
726
775
|
const unitRows = Array.from({ length: nUnits }, (_, i) => i);
|
|
727
|
-
const gofOf = (mdl) => corr2(sub(mdl
|
|
776
|
+
const gofOf = (mdl) => corr2(sub(mdl, mdl.points, unitRows),
|
|
777
|
+
sub(mdl, mdl.model.centroids, unitRows), nUnits);
|
|
728
778
|
const gFull = gofOf(full);
|
|
729
|
-
const fullPts2 = sub(full.points, unitRows);
|
|
779
|
+
const fullPts2 = sub(full, full.points, unitRows);
|
|
730
780
|
|
|
731
781
|
// All k-subsets of [0..m) as index arrays.
|
|
732
782
|
const combos = (n, k) => {
|
|
@@ -743,15 +793,7 @@ export default async function loadENA() {
|
|
|
743
793
|
for (let k = 1; k <= rn; k++) {
|
|
744
794
|
for (const idxs of combos(m, k)) {
|
|
745
795
|
const removedSet = new Set(idxs);
|
|
746
|
-
const
|
|
747
|
-
for (let i = 0; i < m; i++) {
|
|
748
|
-
const row = [];
|
|
749
|
-
for (let j = 0; j < m; j++) {
|
|
750
|
-
row.push((removedSet.has(i) || removedSet.has(j)) ? 0 : 1);
|
|
751
|
-
}
|
|
752
|
-
mask.push(row);
|
|
753
|
-
}
|
|
754
|
-
const red = api.fit(rows, { ...baseOpts, codeMask: mask });
|
|
796
|
+
const red = fitWithout(removedSet);
|
|
755
797
|
|
|
756
798
|
// Gate 1 — goodness-of-fit ratio vs full, per dim.
|
|
757
799
|
const gRed = gofOf(red);
|
|
@@ -764,21 +806,22 @@ export default async function loadENA() {
|
|
|
764
806
|
// Gate 2 — reduced points AND retained-code nodes must each
|
|
765
807
|
// correlate >= threshold with the full model (per dim, with a
|
|
766
808
|
// per-dim sign flip that negates the node corr alongside it).
|
|
809
|
+
// The reduced model's nodes are the retained codes only, in order.
|
|
767
810
|
const retained = [];
|
|
768
811
|
for (let i = 0; i < m; i++) if (!removedSet.has(i)) retained.push(i);
|
|
769
|
-
|
|
770
|
-
let [
|
|
771
|
-
|
|
812
|
+
const reducedRows = retained.map((_, t) => t);
|
|
813
|
+
let [pc1, pc2] = corr2(fullPts2, sub(red, red.points, unitRows), nUnits);
|
|
814
|
+
let [nc1, nc2] = corr2(sub(full, full.rotation.nodes, retained),
|
|
815
|
+
sub(red, red.rotation.nodes, reducedRows), retained.length);
|
|
772
816
|
if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
|
|
773
817
|
if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
|
|
774
818
|
if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
|
|
775
819
|
|
|
776
|
-
// Dim-1 variance
|
|
777
|
-
// (= R's set$model$variance[1]
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const vr1 = evSum > 0 ? ev[0] / evSum : 0;
|
|
820
|
+
// Dim-1 share of the variance of the projected points across
|
|
821
|
+
// ALL dimensions (= R's reduced.set$model$variance[1]). Not the
|
|
822
|
+
// eigenvalue ratio: means and GMR rotations report a zero
|
|
823
|
+
// eigenvalue for their first axis.
|
|
824
|
+
const vr1 = red.model.variance[0];
|
|
782
825
|
// Prefer more codes removed; tie-break on higher dim-1 variance.
|
|
783
826
|
if (k > bestK || (k === bestK && vr1 > bestVar)) {
|
|
784
827
|
bestK = k; bestVar = vr1; bestRemoved = idxs.slice();
|
package/src/pipeline.js
CHANGED
|
@@ -301,14 +301,17 @@ export function project(centered, nUnits, nConnections, rotation, rotRows, rotCo
|
|
|
301
301
|
/**
|
|
302
302
|
* Compute code node positions via least-squares (LWS).
|
|
303
303
|
*
|
|
304
|
+
* @param {boolean} [directed=false] Use libqe's directed_node_positions (for
|
|
305
|
+
* ordered n² networks), as rENA's optimize() does for ordered sets.
|
|
304
306
|
* @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
|
|
305
307
|
* centroids: Float64Array|null }}
|
|
306
308
|
* nodes — code positions in ENA space (= R's rotation$nodes)
|
|
307
309
|
* centroids — LWS unit centroid positions (= R's model$centroids), or null
|
|
308
310
|
* if libqe does not expose them
|
|
309
311
|
*/
|
|
310
|
-
export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
|
|
311
|
-
const
|
|
312
|
+
export function nodePositions(qe, networks, nUnits, nConnections, points, nDims, directed = false) {
|
|
313
|
+
const solve = directed ? qe.directed_node_positions : qe.node_positions;
|
|
314
|
+
const r = solve(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
|
|
312
315
|
return {
|
|
313
316
|
nodes: new Float64Array(r.nodes.data),
|
|
314
317
|
nodeRows: r.nodes.rows,
|