@qe-libs/rena-wasm 0.1.5 → 0.1.7
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 +27 -1
- package/package.json +1 -1
- package/src/index.js +188 -105
- package/src/pipeline.js +12 -5
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.
|
|
@@ -116,7 +136,13 @@ columns in the data.
|
|
|
116
136
|
// dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
|
|
117
137
|
const model = ena.fit(rows, {
|
|
118
138
|
codes, units, conversations,
|
|
119
|
-
ordered: true, // directed (n² connections); also works without a tensor
|
|
139
|
+
ordered: true, // directed (n² connections); also works without a tensor.
|
|
140
|
+
// Ordered models follow ona::model(): zero-network units are
|
|
141
|
+
// left out of the centring mean but shifted by it, nodes use
|
|
142
|
+
// directed positions, and points/nodes are centred on the origin.
|
|
143
|
+
// connectionNames / rotation.adjacencyKey list all n² directed
|
|
144
|
+
// connections as R names them: column j*n + i is
|
|
145
|
+
// "codes[i] & codes[j]", ground codes[i] → response codes[j].
|
|
120
146
|
tensor: {
|
|
121
147
|
dims: [2, 2], // [nRoleValues=2, weight/window=2]
|
|
122
148
|
dimsSender: [0], // axis 0 is a sender factor
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -90,19 +90,45 @@ function computeVariance(points, nUnits, dims) {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Build the adjacency key — list of [codeA, codeB] pairs for each connection.
|
|
93
|
-
* Matches R's
|
|
93
|
+
* Matches R's rotation$adjacency.key.
|
|
94
|
+
*
|
|
95
|
+
* Unordered: the upper triangle in column-major order (choose_two(n) pairs).
|
|
96
|
+
* Ordered: all n² directed connections in column-major order — column
|
|
97
|
+
* j*n + i is [codes[i], codes[j]], ground codes[i] → response codes[j]
|
|
98
|
+
* (as R names it "codes[i] & codes[j]").
|
|
94
99
|
*
|
|
95
100
|
* @param {string[]} codes
|
|
101
|
+
* @param {boolean} [ordered=false]
|
|
96
102
|
* @returns {string[][]} length nConnections, each entry is [codeI, codeJ]
|
|
97
103
|
*/
|
|
98
|
-
function buildAdjacencyKey(codes) {
|
|
104
|
+
function buildAdjacencyKey(codes, ordered = false) {
|
|
99
105
|
const key = [];
|
|
106
|
+
if (ordered) {
|
|
107
|
+
for (let j = 0; j < codes.length; j++)
|
|
108
|
+
for (let i = 0; i < codes.length; i++)
|
|
109
|
+
key.push([codes[i], codes[j]]);
|
|
110
|
+
return key;
|
|
111
|
+
}
|
|
100
112
|
for (let j = 1; j < codes.length; j++)
|
|
101
113
|
for (let i = 0; i < j; i++)
|
|
102
114
|
key.push([codes[i], codes[j]]);
|
|
103
115
|
return key;
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Connection names, "codeA & codeB" per connection, in network column order
|
|
120
|
+
* (= R's colnames(set$connection.counts)).
|
|
121
|
+
*
|
|
122
|
+
* @param {object} qe
|
|
123
|
+
* @param {string[]} codes
|
|
124
|
+
* @param {boolean} [ordered=false]
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
function connectionNamesFor(qe, codes, ordered = false) {
|
|
128
|
+
if (!ordered) return qe.connection_names(codes);
|
|
129
|
+
return buildAdjacencyKey(codes, true).map(([a, b]) => `${a} & ${b}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
// ── ENA result object ─────────────────────────────────────────────────────────
|
|
107
133
|
|
|
108
134
|
class ENAModel {
|
|
@@ -210,20 +236,58 @@ function weightModelName(name) {
|
|
|
210
236
|
}
|
|
211
237
|
}
|
|
212
238
|
|
|
239
|
+
// ── code masking ─────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Zero, in place, every connection column the code mask excludes.
|
|
243
|
+
* codeMask is an nCodes × nCodes 0/1 matrix; a 0 at [a][b] drops the
|
|
244
|
+
* connection between codes a and b (unordered: either orientation drops the
|
|
245
|
+
* pair; ordered: codeMask[j][i] === 0 drops the directed column i*n + j).
|
|
246
|
+
*
|
|
247
|
+
* @param {Float64Array} networks nUnits × nConnections, row-major
|
|
248
|
+
* @param {number[][]} codeMask
|
|
249
|
+
* @param {number} nCodes
|
|
250
|
+
* @param {number} nUnits
|
|
251
|
+
* @param {number} nConnections
|
|
252
|
+
* @param {boolean} ordered
|
|
253
|
+
*/
|
|
254
|
+
function applyCodeMask(networks, codeMask, nCodes, nUnits, nConnections, ordered) {
|
|
255
|
+
if (!codeMask || codeMask.length !== nCodes) return;
|
|
256
|
+
const zeroColumn = (k) => {
|
|
257
|
+
for (let u = 0; u < nUnits; u++) networks[u * nConnections + k] = 0;
|
|
258
|
+
};
|
|
259
|
+
if (ordered) {
|
|
260
|
+
for (let j = 0; j < nCodes; j++)
|
|
261
|
+
for (let i = 0; i < nCodes; i++)
|
|
262
|
+
if (codeMask[j] && codeMask[j][i] === 0) zeroColumn(i * nCodes + j);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let k = 0;
|
|
266
|
+
for (let col = 1; col < nCodes; col++) {
|
|
267
|
+
for (let row = 0; row < col; row++) {
|
|
268
|
+
if ((codeMask[row] && codeMask[row][col] === 0) ||
|
|
269
|
+
(codeMask[col] && codeMask[col][row] === 0)) zeroColumn(k);
|
|
270
|
+
k++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
213
275
|
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
214
276
|
|
|
215
277
|
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
216
278
|
metaData, rotMethod, groupA, groupB, dims, gParams,
|
|
217
|
-
rowConnectionCounts = null) {
|
|
218
|
-
const connectionNames = qe
|
|
279
|
+
rowConnectionCounts = null, ordered = false) {
|
|
280
|
+
const connectionNames = connectionNamesFor(qe, codes, ordered);
|
|
219
281
|
|
|
220
282
|
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
221
283
|
const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
|
|
222
284
|
|
|
223
285
|
// Center → pointsForProjection (= R's model$points.for.projection)
|
|
224
286
|
// centerVec (= R's rotation$center.vec)
|
|
287
|
+
// Ordered (ONA) models follow ona::model(): zero-network units are left
|
|
288
|
+
// out of the mean but still shifted by it.
|
|
225
289
|
const { centered: pointsForProjection, centerVec } =
|
|
226
|
-
center(qe, lineWeights, nUnits, nConnections);
|
|
290
|
+
center(qe, lineWeights, nUnits, nConnections, ordered);
|
|
227
291
|
|
|
228
292
|
// Rotate
|
|
229
293
|
let rot;
|
|
@@ -241,6 +305,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
241
305
|
rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
|
|
242
306
|
}
|
|
243
307
|
|
|
308
|
+
// Never ask for more dimensions than the rotation has columns.
|
|
309
|
+
dims = Math.min(dims, rot.rotCols);
|
|
310
|
+
|
|
244
311
|
// Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
|
|
245
312
|
const rotationMatrix = new Float64Array(rot.rotRows * dims);
|
|
246
313
|
for (let r = 0; r < rot.rotRows; r++)
|
|
@@ -259,15 +326,30 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
259
326
|
// Matches rENA's lws.positions.sq, which regresses the projected points onto
|
|
260
327
|
// the SPHERE-normed line weights (enaset$line.weights) — not the centered
|
|
261
328
|
// networks. Verified node-for-node against R rENA on rs.data.new.csv.
|
|
329
|
+
// Ordered (ONA) models use libqe's directed_node_positions, as
|
|
330
|
+
// ona::model() does (via rENA's optimize()).
|
|
262
331
|
const { nodes, centroids } = nodePositions(
|
|
263
|
-
qe, lineWeights, nUnits, nConnections, points, dims
|
|
332
|
+
qe, lineWeights, nUnits, nConnections, points, dims, ordered
|
|
264
333
|
);
|
|
265
334
|
|
|
335
|
+
// ONA: translate points and nodes so the points' mean is at the origin
|
|
336
|
+
// (ona::model's center_to_origin; centroids are left as computed, as in R).
|
|
337
|
+
if (ordered) {
|
|
338
|
+
const nCodes = nodes.length / dims;
|
|
339
|
+
for (let d = 0; d < dims; d++) {
|
|
340
|
+
let mean = 0;
|
|
341
|
+
for (let u = 0; u < nUnits; u++) mean += points[u * dims + d];
|
|
342
|
+
mean /= nUnits;
|
|
343
|
+
for (let u = 0; u < nUnits; u++) points[u * dims + d] -= mean;
|
|
344
|
+
for (let c = 0; c < nCodes; c++) nodes[c * dims + d] -= mean;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
266
348
|
// Variance explained (= R's model$variance)
|
|
267
349
|
const variance = computeVariance(points, nUnits, dims);
|
|
268
350
|
|
|
269
351
|
// Adjacency key (= R's rotation$adjacency.key)
|
|
270
|
-
const adjacencyKey = buildAdjacencyKey(codes);
|
|
352
|
+
const adjacencyKey = buildAdjacencyKey(codes, ordered);
|
|
271
353
|
|
|
272
354
|
return new ENAModel({
|
|
273
355
|
// top-level
|
|
@@ -371,38 +453,11 @@ export default async function loadENA() {
|
|
|
371
453
|
);
|
|
372
454
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
373
455
|
|
|
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
|
-
}
|
|
456
|
+
applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
402
457
|
|
|
403
458
|
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
404
459
|
unitLabels, metaData, rotMethod, groupA, groupB,
|
|
405
|
-
dims, gParams, rowConnectionCounts);
|
|
460
|
+
dims, gParams, rowConnectionCounts, ordered);
|
|
406
461
|
},
|
|
407
462
|
|
|
408
463
|
/**
|
|
@@ -444,36 +499,9 @@ export default async function loadENA() {
|
|
|
444
499
|
);
|
|
445
500
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
446
501
|
|
|
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
|
-
}
|
|
502
|
+
applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
475
503
|
|
|
476
|
-
const connectionNames = qe
|
|
504
|
+
const connectionNames = connectionNamesFor(qe, codes, ordered);
|
|
477
505
|
return {
|
|
478
506
|
connectionCounts: networks, rowConnectionCounts, unitLabels, connectionNames,
|
|
479
507
|
metaData, nUnits, nConnections,
|
|
@@ -538,7 +566,8 @@ export default async function loadENA() {
|
|
|
538
566
|
);
|
|
539
567
|
const model = runPipeline(
|
|
540
568
|
qe, raw, nUnits, nConnections, codes,
|
|
541
|
-
unitLabels, metaData, 'svd', undefined, undefined, dims
|
|
569
|
+
unitLabels, metaData, 'svd', undefined, undefined, dims,
|
|
570
|
+
undefined, null, ordered
|
|
542
571
|
);
|
|
543
572
|
allPoints.push(model.points);
|
|
544
573
|
}
|
|
@@ -676,12 +705,27 @@ export default async function loadENA() {
|
|
|
676
705
|
* removed while keeping the model's goodness-of-fit within `threshold`
|
|
677
706
|
* of the full model. Brute-force subset search matching R
|
|
678
707
|
* 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
|
|
708
|
+
* build the reduced model (drop every connection touching a removed
|
|
709
|
+
* code, as R's remove.codes.from.accum does), gate on min_d(gof_reduced[d] / gof_full[d]) >= threshold where
|
|
710
|
+
* gof is the per-dimension ena_correlation(points, centroids) on dims
|
|
711
|
+
* 1:2, then on point and retained-node correlations, and keep the
|
|
712
|
+
* subset with the MOST codes removed, then the highest reduced dim-1
|
|
713
|
+
* variance (= R's reduced.set$model$variance[1]).
|
|
714
|
+
*
|
|
715
|
+
* The full and reduced models are built from the same accumulation as
|
|
716
|
+
* fit() with the same options — weightModel, tensor and codeMask
|
|
717
|
+
* included — so PRIA scores the model the caller actually displays.
|
|
718
|
+
* The data are accumulated once; each candidate only drops the removed
|
|
719
|
+
* codes' connection columns and re-runs normalize → center → rotate →
|
|
720
|
+
* project.
|
|
684
721
|
*
|
|
722
|
+
* @param {Object[]} rows
|
|
723
|
+
* @param {object} opts - fit() options (codes, units, conversations,
|
|
724
|
+
* window, binary, ordered, tensor, weightModel,
|
|
725
|
+
* codeMask, rotation, groupA, groupB, gParams)
|
|
726
|
+
* plus removeNum (default 3) and threshold
|
|
727
|
+
* (default 0.95). `dims` is ignored: scoring
|
|
728
|
+
* uses dims 1:2 and the variance uses all dims.
|
|
685
729
|
* @returns {{ removed: string[], removedIndices: number[], k: number,
|
|
686
730
|
* variance: (number|null) }}
|
|
687
731
|
*/
|
|
@@ -689,7 +733,8 @@ export default async function loadENA() {
|
|
|
689
733
|
const {
|
|
690
734
|
codes, units, conversations,
|
|
691
735
|
window: windowSize = 4, binary = true, ordered = false,
|
|
692
|
-
|
|
736
|
+
tensor: tensorDef, weightModel, codeMask,
|
|
737
|
+
rotation = 'svd', gParams, groupA, groupB,
|
|
693
738
|
removeNum = 3, threshold = 0.95,
|
|
694
739
|
} = opts;
|
|
695
740
|
if (!codes?.length) throw new Error('opts.codes is required');
|
|
@@ -701,32 +746,77 @@ export default async function loadENA() {
|
|
|
701
746
|
const empty = { removed: [], removedIndices: [], k: 0, variance: null };
|
|
702
747
|
if (rn < 1) return empty;
|
|
703
748
|
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
|
|
749
|
+
// Accumulate once, exactly as fit() would for these options.
|
|
750
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
751
|
+
unitOf, convoGroups, metaData } =
|
|
752
|
+
parseData(rows, codes, units, conversations);
|
|
753
|
+
const { networks: raw } = accumulateTensor(
|
|
754
|
+
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
755
|
+
unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
|
|
756
|
+
ordered, binary, weightModelName(weightModel)
|
|
757
|
+
);
|
|
758
|
+
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
759
|
+
applyCodeMask(raw, codeMask, nCodes, nUnits, nConnections, ordered);
|
|
760
|
+
|
|
761
|
+
// Build a model from the accumulation with `removedSet`'s codes
|
|
762
|
+
// DROPPED -- their connection columns removed and the code list
|
|
763
|
+
// shortened -- as R PRIA's remove.codes.from.accum() does. Zeroing
|
|
764
|
+
// the columns instead is not equivalent: libqe's GMR rotation is
|
|
765
|
+
// sensitive to all-zero columns. All dimensions are kept so
|
|
766
|
+
// model.variance is the full-spectrum ratio R uses; the gates read
|
|
767
|
+
// only dims 1:2.
|
|
768
|
+
const fitWithout = (removedSet) => {
|
|
769
|
+
let networks = raw, keptCodes = codes, nConn = nConnections;
|
|
770
|
+
if (removedSet) {
|
|
771
|
+
const keep = (c) => !removedSet.has(c);
|
|
772
|
+
const cols = [];
|
|
773
|
+
if (ordered) {
|
|
774
|
+
for (let i = 0; i < nCodes; i++)
|
|
775
|
+
for (let j = 0; j < nCodes; j++)
|
|
776
|
+
if (keep(i) && keep(j)) cols.push(i * nCodes + j);
|
|
777
|
+
} else {
|
|
778
|
+
let k = 0;
|
|
779
|
+
for (let col = 1; col < nCodes; col++)
|
|
780
|
+
for (let row = 0; row < col; row++, k++)
|
|
781
|
+
if (keep(row) && keep(col)) cols.push(k);
|
|
782
|
+
}
|
|
783
|
+
nConn = cols.length;
|
|
784
|
+
networks = new Float64Array(nUnits * nConn);
|
|
785
|
+
for (let u = 0; u < nUnits; u++)
|
|
786
|
+
for (let c = 0; c < nConn; c++)
|
|
787
|
+
networks[u * nConn + c] = raw[u * nConnections + cols[c]];
|
|
788
|
+
keptCodes = codes.filter((_, i) => keep(i));
|
|
789
|
+
}
|
|
790
|
+
return runPipeline(qe, networks, nUnits, nConn, keptCodes,
|
|
791
|
+
unitLabels, metaData, rotation, groupA, groupB,
|
|
792
|
+
nConn, gParams, null, ordered);
|
|
793
|
+
};
|
|
707
794
|
|
|
708
|
-
const D =
|
|
709
|
-
//
|
|
710
|
-
// row-major (nRows × dims) array.
|
|
711
|
-
const sub = (flat, rowIdxs) => {
|
|
795
|
+
const D = 2; // score on the first 2 dims (R's get_pria_scores_2Ds)
|
|
796
|
+
// D-column submatrix (given row indices) of a model's flat
|
|
797
|
+
// row-major (nRows × model.dims) array.
|
|
798
|
+
const sub = (mdl, flat, rowIdxs) => {
|
|
712
799
|
const out = new Float64Array(rowIdxs.length * D);
|
|
713
|
-
rowIdxs.forEach((r, t) => {
|
|
800
|
+
rowIdxs.forEach((r, t) => {
|
|
801
|
+
for (let d = 0; d < D; d++) out[t * D + d] = flat[r * mdl.dims + d];
|
|
802
|
+
});
|
|
714
803
|
return out;
|
|
715
804
|
};
|
|
716
805
|
// 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),
|
|
806
|
+
const corr2 = (Aflat, Bflat, n) => {
|
|
807
|
+
const r = qe.ena_correlation(Array.from(Aflat), n, D,
|
|
808
|
+
Array.from(Bflat), n, D, 0.95);
|
|
720
809
|
const out = [];
|
|
721
810
|
for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
|
|
722
811
|
return out;
|
|
723
812
|
};
|
|
724
|
-
|
|
725
|
-
const
|
|
813
|
+
|
|
814
|
+
const full = fitWithout(null);
|
|
726
815
|
const unitRows = Array.from({ length: nUnits }, (_, i) => i);
|
|
727
|
-
const gofOf = (mdl) => corr2(sub(mdl
|
|
816
|
+
const gofOf = (mdl) => corr2(sub(mdl, mdl.points, unitRows),
|
|
817
|
+
sub(mdl, mdl.model.centroids, unitRows), nUnits);
|
|
728
818
|
const gFull = gofOf(full);
|
|
729
|
-
const fullPts2 = sub(full.points, unitRows);
|
|
819
|
+
const fullPts2 = sub(full, full.points, unitRows);
|
|
730
820
|
|
|
731
821
|
// All k-subsets of [0..m) as index arrays.
|
|
732
822
|
const combos = (n, k) => {
|
|
@@ -743,15 +833,7 @@ export default async function loadENA() {
|
|
|
743
833
|
for (let k = 1; k <= rn; k++) {
|
|
744
834
|
for (const idxs of combos(m, k)) {
|
|
745
835
|
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 });
|
|
836
|
+
const red = fitWithout(removedSet);
|
|
755
837
|
|
|
756
838
|
// Gate 1 — goodness-of-fit ratio vs full, per dim.
|
|
757
839
|
const gRed = gofOf(red);
|
|
@@ -764,21 +846,22 @@ export default async function loadENA() {
|
|
|
764
846
|
// Gate 2 — reduced points AND retained-code nodes must each
|
|
765
847
|
// correlate >= threshold with the full model (per dim, with a
|
|
766
848
|
// per-dim sign flip that negates the node corr alongside it).
|
|
849
|
+
// The reduced model's nodes are the retained codes only, in order.
|
|
767
850
|
const retained = [];
|
|
768
851
|
for (let i = 0; i < m; i++) if (!removedSet.has(i)) retained.push(i);
|
|
769
|
-
|
|
770
|
-
let [
|
|
771
|
-
|
|
852
|
+
const reducedRows = retained.map((_, t) => t);
|
|
853
|
+
let [pc1, pc2] = corr2(fullPts2, sub(red, red.points, unitRows), nUnits);
|
|
854
|
+
let [nc1, nc2] = corr2(sub(full, full.rotation.nodes, retained),
|
|
855
|
+
sub(red, red.rotation.nodes, reducedRows), retained.length);
|
|
772
856
|
if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
|
|
773
857
|
if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
|
|
774
858
|
if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
|
|
775
859
|
|
|
776
|
-
// Dim-1 variance
|
|
777
|
-
// (= R's set$model$variance[1]
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const vr1 = evSum > 0 ? ev[0] / evSum : 0;
|
|
860
|
+
// Dim-1 share of the variance of the projected points across
|
|
861
|
+
// ALL dimensions (= R's reduced.set$model$variance[1]). Not the
|
|
862
|
+
// eigenvalue ratio: means and GMR rotations report a zero
|
|
863
|
+
// eigenvalue for their first axis.
|
|
864
|
+
const vr1 = red.model.variance[0];
|
|
782
865
|
// Prefer more codes removed; tie-break on higher dim-1 variance.
|
|
783
866
|
if (k > bestK || (k === bestK && vr1 > bestVar)) {
|
|
784
867
|
bestK = k; bestVar = vr1; bestRemoved = idxs.slice();
|
package/src/pipeline.js
CHANGED
|
@@ -133,11 +133,15 @@ export function sphereNorm(qe, networks, nUnits, nConnections) {
|
|
|
133
133
|
* Subtract column means (center the network space).
|
|
134
134
|
* Excludes all-zero rows from the mean calculation (zero-network exclusion).
|
|
135
135
|
*
|
|
136
|
+
* @param {boolean} [shiftZeroRows=false] Also subtract the mean from all-zero
|
|
137
|
+
* rows, as rENA's center(exclude_zero_networks = TRUE) does -- the ONA
|
|
138
|
+
* convention (ona::model). By default zero-network rows stay at zero, as in
|
|
139
|
+
* rENA's ena.make.set().
|
|
136
140
|
* @returns {{ centered: Float64Array, centerVec: Float64Array }}
|
|
137
|
-
* centered — mean-subtracted networks
|
|
141
|
+
* centered — mean-subtracted networks
|
|
138
142
|
* centerVec — the column means used for centering (= R's rotation$center.vec)
|
|
139
143
|
*/
|
|
140
|
-
export function center(qe, networks, nUnits, nConnections) {
|
|
144
|
+
export function center(qe, networks, nUnits, nConnections, shiftZeroRows = false) {
|
|
141
145
|
// Identify non-zero rows
|
|
142
146
|
const active = [];
|
|
143
147
|
for (let u = 0; u < nUnits; u++) {
|
|
@@ -160,7 +164,7 @@ export function center(qe, networks, nUnits, nConnections) {
|
|
|
160
164
|
const activeSet = new Set(active);
|
|
161
165
|
const centered = new Float64Array(networks.length);
|
|
162
166
|
for (let u = 0; u < nUnits; u++) {
|
|
163
|
-
if (!activeSet.has(u)) continue;
|
|
167
|
+
if (!shiftZeroRows && !activeSet.has(u)) continue; // leave zero-network row as zero
|
|
164
168
|
for (let c = 0; c < nConnections; c++) {
|
|
165
169
|
centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
|
|
166
170
|
}
|
|
@@ -301,14 +305,17 @@ export function project(centered, nUnits, nConnections, rotation, rotRows, rotCo
|
|
|
301
305
|
/**
|
|
302
306
|
* Compute code node positions via least-squares (LWS).
|
|
303
307
|
*
|
|
308
|
+
* @param {boolean} [directed=false] Use libqe's directed_node_positions (for
|
|
309
|
+
* ordered n² networks), as rENA's optimize() does for ordered sets.
|
|
304
310
|
* @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
|
|
305
311
|
* centroids: Float64Array|null }}
|
|
306
312
|
* nodes — code positions in ENA space (= R's rotation$nodes)
|
|
307
313
|
* centroids — LWS unit centroid positions (= R's model$centroids), or null
|
|
308
314
|
* if libqe does not expose them
|
|
309
315
|
*/
|
|
310
|
-
export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
|
|
311
|
-
const
|
|
316
|
+
export function nodePositions(qe, networks, nUnits, nConnections, points, nDims, directed = false) {
|
|
317
|
+
const solve = directed ? qe.directed_node_positions : qe.node_positions;
|
|
318
|
+
const r = solve(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
|
|
312
319
|
return {
|
|
313
320
|
nodes: new Float64Array(r.nodes.data),
|
|
314
321
|
nodeRows: r.nodes.rows,
|