@qe-libs/rena-wasm 0.1.4 → 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 +47 -3
- package/package.json +2 -2
- package/src/index.js +167 -122
- package/src/pipeline.js +5 -2
- package/src/tensor.js +24 -67
package/README.md
CHANGED
|
@@ -34,8 +34,8 @@ const model = ena.fit(rows, {
|
|
|
34
34
|
|
|
35
35
|
model.model.centroids // Float64Array nUnits × dims
|
|
36
36
|
model.lineWeights // Float64Array nUnits × nConnections (normed)
|
|
37
|
-
model.connectionCounts // Float64Array nUnits × nConnections (
|
|
38
|
-
model.rowConnectionCounts // Float64Array nRows × nConnections (
|
|
37
|
+
model.connectionCounts // Float64Array nUnits × nConnections (unit counts, before normalisation)
|
|
38
|
+
model.rowConnectionCounts // Float64Array nRows × nConnections (each row's counts after binarize / weight; rows sum to connectionCounts)
|
|
39
39
|
model.rotation.nodes // Float64Array nCodes × dims
|
|
40
40
|
model.connectionNames // ['Data & Technical.Constraints', ...]
|
|
41
41
|
model.model.unitLabels // ['UserName1_ConditionA', ...]
|
|
@@ -67,6 +67,50 @@ const model = ena.fit(rows, {
|
|
|
67
67
|
|
|
68
68
|
---
|
|
69
69
|
|
|
70
|
+
## Weight Models
|
|
71
|
+
|
|
72
|
+
`weightModel` (= R's `weight.by` / tma's `weight_by`) is applied to each line's
|
|
73
|
+
co-occurrence counts **before** they are summed into the unit network, by
|
|
74
|
+
libqe's shared `finalize_row_connections` kernel — the same stage and results
|
|
75
|
+
as rENA's `ena.accumulate.data()` and `tma::accumulate()`.
|
|
76
|
+
|
|
77
|
+
| `weightModel` | Per line |
|
|
78
|
+
|---|---|
|
|
79
|
+
| — (default) | binary: each positive count becomes 1 (`binary: false` keeps raw counts) |
|
|
80
|
+
| `'product'` | the raw, non-binarized counts |
|
|
81
|
+
| `'sqrt'` | square root of each line's count |
|
|
82
|
+
| `'log'` (alias `'log1p'`) | `log(1 + x)` of each line's count |
|
|
83
|
+
|
|
84
|
+
For ordered (directed) networks the weight is applied to each directed cell,
|
|
85
|
+
and the default keeps the raw directed counts. `weightModel` works in `fit()`,
|
|
86
|
+
`accumulate()` and `tuneWindowSize()`.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const model = ena.fit(rows, { codes, units, conversations, window: 4, weightModel: 'sqrt' });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
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
|
+
|
|
70
114
|
## Accumulation Only
|
|
71
115
|
|
|
72
116
|
Returns raw (un-normalised) network vectors without running the full pipeline.
|
|
@@ -92,7 +136,7 @@ columns in the data.
|
|
|
92
136
|
// dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
|
|
93
137
|
const model = ena.fit(rows, {
|
|
94
138
|
codes, units, conversations,
|
|
95
|
-
ordered: true, // directed (n² connections)
|
|
139
|
+
ordered: true, // directed (n² connections); also works without a tensor
|
|
96
140
|
tensor: {
|
|
97
141
|
dims: [2, 2], // [nRoleValues=2, weight/window=2]
|
|
98
142
|
dimsSender: [0], // axis 0 is a sender factor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qe-libs/rena-wasm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "JavaScript/WebAssembly ENA pipeline — thin orchestration layer over @qe-libs/libqe-wasm",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"resolver": "<rootDir>/jest-resolver.cjs"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@qe-libs/libqe-wasm": "^0.1.
|
|
34
|
+
"@qe-libs/libqe-wasm": "^0.1.5"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"jest": "^29.0.0"
|
package/src/index.js
CHANGED
|
@@ -190,30 +190,67 @@ class ENAModel {
|
|
|
190
190
|
// ── weight models (= R's `weight.by`) ────────────────────────────────────────
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* Map a weight-model name to
|
|
194
|
-
*
|
|
195
|
-
* 'sqrt'
|
|
196
|
-
* 'log'
|
|
197
|
-
* 'product'
|
|
198
|
-
*
|
|
199
|
-
*
|
|
193
|
+
* Map a rena-wasm weight-model name to the libqe weight model applied per line
|
|
194
|
+
* (before the per-unit sum) by libqe's finalize_row_connections:
|
|
195
|
+
* 'sqrt' → 'sqrt' (R weight.by = sqrt)
|
|
196
|
+
* 'log' | 'log1p' → 'log1p' (log(x+1); guards log(0))
|
|
197
|
+
* 'product' → 'product' (the raw, non-binarized line counts)
|
|
198
|
+
* A falsy value, 'binary' or an unknown name returns null (binary / `binary`
|
|
199
|
+
* flag accumulation, no weight model).
|
|
200
200
|
* @param {string|false|undefined} name
|
|
201
|
-
* @returns {
|
|
201
|
+
* @returns {'sqrt'|'log1p'|'product'|null}
|
|
202
202
|
*/
|
|
203
|
-
function
|
|
203
|
+
function weightModelName(name) {
|
|
204
204
|
switch (name) {
|
|
205
|
-
case 'sqrt': return
|
|
206
|
-
case 'log':
|
|
207
|
-
case '
|
|
205
|
+
case 'sqrt': return 'sqrt';
|
|
206
|
+
case 'log':
|
|
207
|
+
case 'log1p': return 'log1p';
|
|
208
|
+
case 'product': return 'product';
|
|
208
209
|
default: return null; // binary / off / unknown
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
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
|
+
|
|
212
249
|
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
213
250
|
|
|
214
251
|
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
215
252
|
metaData, rotMethod, groupA, groupB, dims, gParams,
|
|
216
|
-
rowConnectionCounts = null) {
|
|
253
|
+
rowConnectionCounts = null, directedNodes = false) {
|
|
217
254
|
const connectionNames = qe.connection_names(codes);
|
|
218
255
|
|
|
219
256
|
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
@@ -240,6 +277,9 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
240
277
|
rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
|
|
241
278
|
}
|
|
242
279
|
|
|
280
|
+
// Never ask for more dimensions than the rotation has columns.
|
|
281
|
+
dims = Math.min(dims, rot.rotCols);
|
|
282
|
+
|
|
243
283
|
// Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
|
|
244
284
|
const rotationMatrix = new Float64Array(rot.rotRows * dims);
|
|
245
285
|
for (let r = 0; r < rot.rotRows; r++)
|
|
@@ -258,8 +298,10 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
258
298
|
// Matches rENA's lws.positions.sq, which regresses the projected points onto
|
|
259
299
|
// the SPHERE-normed line weights (enaset$line.weights) — not the centered
|
|
260
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.
|
|
261
303
|
const { nodes, centroids } = nodePositions(
|
|
262
|
-
qe, lineWeights, nUnits, nConnections, points, dims
|
|
304
|
+
qe, lineWeights, nUnits, nConnections, points, dims, directedNodes
|
|
263
305
|
);
|
|
264
306
|
|
|
265
307
|
// Variance explained (= R's model$variance)
|
|
@@ -318,7 +360,7 @@ export default async function loadENA() {
|
|
|
318
360
|
* @param {boolean} [opts.binary=true] - Binarise each line's co-occurrences (unordered; ignored when weightModel is set)
|
|
319
361
|
* @param {boolean} [opts.ordered=false] - Directed networks
|
|
320
362
|
* @param {object} [opts.tensor] - Context tensor definition (overrides window)
|
|
321
|
-
* @param {string} [opts.weightModel] - 'product' | 'sqrt' | 'log' (per
|
|
363
|
+
* @param {string} [opts.weightModel] - 'product' | 'sqrt' | 'log' (alias 'log1p'); per line, before the unit sum
|
|
322
364
|
* @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
|
|
323
365
|
* @param {number[]} [opts.groupA] - Unit indices for means rotation group A
|
|
324
366
|
* @param {number[]} [opts.groupB] - Unit indices for means rotation group B
|
|
@@ -353,12 +395,9 @@ export default async function loadENA() {
|
|
|
353
395
|
// "product" co-occurrence counts) BEFORE summing per unit, mirroring
|
|
354
396
|
// accumulate.data.R (lapply(.SD, weight.by) over the per-line
|
|
355
397
|
// co-occurrence table, then per-unit aggregation). Because
|
|
356
|
-
// sqrt(Σ) ≠ Σsqrt,
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
// any weight model therefore forces non-binary accumulation.
|
|
360
|
-
const weightFn = weightModelTransform(weightModel);
|
|
361
|
-
const effBinary = weightFn ? false : binary;
|
|
398
|
+
// sqrt(Σ) ≠ Σsqrt, libqe applies it to each row's counts inside
|
|
399
|
+
// accumulateTensor, not to the unit-summed network.
|
|
400
|
+
const weight = weightModelName(weightModel);
|
|
362
401
|
|
|
363
402
|
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
364
403
|
unitOf, convoGroups, metaData } =
|
|
@@ -369,38 +408,11 @@ export default async function loadENA() {
|
|
|
369
408
|
const { networks: rawNetworks, rowConnectionCounts } = accumulateTensor(
|
|
370
409
|
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
371
410
|
unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
|
|
372
|
-
ordered,
|
|
411
|
+
ordered, binary, weight
|
|
373
412
|
);
|
|
374
413
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
375
414
|
|
|
376
|
-
|
|
377
|
-
if (codeMask && codeMask.length === codes.length) {
|
|
378
|
-
console.log('[rena-wasm] Applying code mask to raw networks...');
|
|
379
|
-
if (ordered) {
|
|
380
|
-
for (let j = 0; j < codes.length; j++) {
|
|
381
|
-
for (let i = 0; i < codes.length; i++) {
|
|
382
|
-
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
383
|
-
const k = i * codes.length + j;
|
|
384
|
-
for (let u = 0; u < nUnits; u++) {
|
|
385
|
-
rawNetworks[u * nConnections + k] = 0;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
} else {
|
|
391
|
-
let k = 0;
|
|
392
|
-
for (let col = 1; col < codes.length; col++) {
|
|
393
|
-
for (let row = 0; row < col; row++) {
|
|
394
|
-
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
395
|
-
for (let u = 0; u < nUnits; u++) {
|
|
396
|
-
rawNetworks[u * nConnections + k] = 0;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
k++;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
415
|
+
applyCodeMask(rawNetworks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
404
416
|
|
|
405
417
|
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
406
418
|
unitLabels, metaData, rotMethod, groupA, groupB,
|
|
@@ -412,7 +424,7 @@ export default async function loadENA() {
|
|
|
412
424
|
* Returns raw (un-normalised) network vectors.
|
|
413
425
|
*
|
|
414
426
|
* @param {Object[]} rows
|
|
415
|
-
* @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
|
|
427
|
+
* @param {object} opts - codes, units, conversations, window, binary, ordered, tensor, weightModel
|
|
416
428
|
* @returns {{
|
|
417
429
|
* connectionCounts: Float64Array,
|
|
418
430
|
* rowConnectionCounts: Float64Array | null,
|
|
@@ -432,6 +444,7 @@ export default async function loadENA() {
|
|
|
432
444
|
ordered = false,
|
|
433
445
|
tensor: tensorDef,
|
|
434
446
|
codeMask,
|
|
447
|
+
weightModel,
|
|
435
448
|
} = opts;
|
|
436
449
|
|
|
437
450
|
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
@@ -441,38 +454,11 @@ export default async function loadENA() {
|
|
|
441
454
|
const { networks, rowConnectionCounts } = accumulateTensor(
|
|
442
455
|
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
443
456
|
unitOf, convoGroups, tensorDef ?? defaultTensor(windowSize),
|
|
444
|
-
ordered, binary
|
|
457
|
+
ordered, binary, weightModelName(weightModel)
|
|
445
458
|
);
|
|
446
459
|
const nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
447
460
|
|
|
448
|
-
|
|
449
|
-
if (codeMask && codeMask.length === codes.length) {
|
|
450
|
-
console.log('[rena-wasm] Applying code mask to accumulated networks...');
|
|
451
|
-
if (ordered) {
|
|
452
|
-
for (let j = 0; j < codes.length; j++) {
|
|
453
|
-
for (let i = 0; i < codes.length; i++) {
|
|
454
|
-
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
455
|
-
const k = i * codes.length + j;
|
|
456
|
-
for (let u = 0; u < nUnits; u++) {
|
|
457
|
-
networks[u * nConnections + k] = 0;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
} else {
|
|
463
|
-
let k = 0;
|
|
464
|
-
for (let col = 1; col < codes.length; col++) {
|
|
465
|
-
for (let row = 0; row < col; row++) {
|
|
466
|
-
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
467
|
-
for (let u = 0; u < nUnits; u++) {
|
|
468
|
-
networks[u * nConnections + k] = 0;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
k++;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
461
|
+
applyCodeMask(networks, codeMask, codes.length, nUnits, nConnections, ordered);
|
|
476
462
|
|
|
477
463
|
const connectionNames = qe.connection_names(codes);
|
|
478
464
|
return {
|
|
@@ -481,7 +467,8 @@ export default async function loadENA() {
|
|
|
481
467
|
// Retained so tuneWindowSize() can rebuild at other window sizes
|
|
482
468
|
// (= R's ENAAccumulation$`_function.call`).
|
|
483
469
|
_call: { rows, codes, units, conversations,
|
|
484
|
-
window: windowSize, binary, ordered, tensor: tensorDef
|
|
470
|
+
window: windowSize, binary, ordered, tensor: tensorDef,
|
|
471
|
+
weightModel },
|
|
485
472
|
};
|
|
486
473
|
},
|
|
487
474
|
|
|
@@ -519,7 +506,7 @@ export default async function loadENA() {
|
|
|
519
506
|
'maxSize must be greater than minSize to compare windows.'
|
|
520
507
|
);
|
|
521
508
|
|
|
522
|
-
const { rows, codes, units, conversations, binary, ordered } = call;
|
|
509
|
+
const { rows, codes, units, conversations, binary, ordered, weightModel } = call;
|
|
523
510
|
|
|
524
511
|
// Parse once; only the window size changes between iterations.
|
|
525
512
|
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
@@ -533,7 +520,8 @@ export default async function loadENA() {
|
|
|
533
520
|
for (const w of windowRange) {
|
|
534
521
|
const { networks: raw } = accumulateTensor(
|
|
535
522
|
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
536
|
-
unitOf, convoGroups, defaultTensor(w), ordered, binary
|
|
523
|
+
unitOf, convoGroups, defaultTensor(w), ordered, binary,
|
|
524
|
+
weightModelName(weightModel)
|
|
537
525
|
);
|
|
538
526
|
const model = runPipeline(
|
|
539
527
|
qe, raw, nUnits, nConnections, codes,
|
|
@@ -560,6 +548,7 @@ export default async function loadENA() {
|
|
|
560
548
|
// 4. Rebuild the accumulation at the selected window size.
|
|
561
549
|
return api.accumulate(rows, {
|
|
562
550
|
codes, units, conversations, window: bestWindow, binary, ordered,
|
|
551
|
+
weightModel,
|
|
563
552
|
});
|
|
564
553
|
},
|
|
565
554
|
|
|
@@ -674,12 +663,27 @@ export default async function loadENA() {
|
|
|
674
663
|
* removed while keeping the model's goodness-of-fit within `threshold`
|
|
675
664
|
* of the full model. Brute-force subset search matching R
|
|
676
665
|
* PRIA::pria(): for k = 1..removeNum, over every k-subset of codes,
|
|
677
|
-
* build the reduced model (
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* 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.
|
|
682
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.
|
|
683
687
|
* @returns {{ removed: string[], removedIndices: number[], k: number,
|
|
684
688
|
* variance: (number|null) }}
|
|
685
689
|
*/
|
|
@@ -687,7 +691,8 @@ export default async function loadENA() {
|
|
|
687
691
|
const {
|
|
688
692
|
codes, units, conversations,
|
|
689
693
|
window: windowSize = 4, binary = true, ordered = false,
|
|
690
|
-
|
|
694
|
+
tensor: tensorDef, weightModel, codeMask,
|
|
695
|
+
rotation = 'svd', gParams, groupA, groupB,
|
|
691
696
|
removeNum = 3, threshold = 0.95,
|
|
692
697
|
} = opts;
|
|
693
698
|
if (!codes?.length) throw new Error('opts.codes is required');
|
|
@@ -699,32 +704,79 @@ export default async function loadENA() {
|
|
|
699
704
|
const empty = { removed: [], removedIndices: [], k: 0, variance: null };
|
|
700
705
|
if (rn < 1) return empty;
|
|
701
706
|
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
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
|
+
};
|
|
705
754
|
|
|
706
|
-
const D =
|
|
707
|
-
//
|
|
708
|
-
// row-major (nRows × dims) array.
|
|
709
|
-
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) => {
|
|
710
759
|
const out = new Float64Array(rowIdxs.length * D);
|
|
711
|
-
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
|
+
});
|
|
712
763
|
return out;
|
|
713
764
|
};
|
|
714
765
|
// ena_correlation(A, B) → [r_dim0, r_dim1] (col 0 of the dims×3 result).
|
|
715
|
-
const corr2 = (Aflat, Bflat,
|
|
716
|
-
const r = qe.ena_correlation(Array.from(Aflat),
|
|
717
|
-
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);
|
|
718
769
|
const out = [];
|
|
719
770
|
for (let d = 0; d < D; d++) out.push(r.data[d * 3]);
|
|
720
771
|
return out;
|
|
721
772
|
};
|
|
722
|
-
|
|
723
|
-
const
|
|
773
|
+
|
|
774
|
+
const full = fitWithout(null);
|
|
724
775
|
const unitRows = Array.from({ length: nUnits }, (_, i) => i);
|
|
725
|
-
const gofOf = (mdl) => corr2(sub(mdl
|
|
776
|
+
const gofOf = (mdl) => corr2(sub(mdl, mdl.points, unitRows),
|
|
777
|
+
sub(mdl, mdl.model.centroids, unitRows), nUnits);
|
|
726
778
|
const gFull = gofOf(full);
|
|
727
|
-
const fullPts2 = sub(full.points, unitRows);
|
|
779
|
+
const fullPts2 = sub(full, full.points, unitRows);
|
|
728
780
|
|
|
729
781
|
// All k-subsets of [0..m) as index arrays.
|
|
730
782
|
const combos = (n, k) => {
|
|
@@ -741,15 +793,7 @@ export default async function loadENA() {
|
|
|
741
793
|
for (let k = 1; k <= rn; k++) {
|
|
742
794
|
for (const idxs of combos(m, k)) {
|
|
743
795
|
const removedSet = new Set(idxs);
|
|
744
|
-
const
|
|
745
|
-
for (let i = 0; i < m; i++) {
|
|
746
|
-
const row = [];
|
|
747
|
-
for (let j = 0; j < m; j++) {
|
|
748
|
-
row.push((removedSet.has(i) || removedSet.has(j)) ? 0 : 1);
|
|
749
|
-
}
|
|
750
|
-
mask.push(row);
|
|
751
|
-
}
|
|
752
|
-
const red = api.fit(rows, { ...baseOpts, codeMask: mask });
|
|
796
|
+
const red = fitWithout(removedSet);
|
|
753
797
|
|
|
754
798
|
// Gate 1 — goodness-of-fit ratio vs full, per dim.
|
|
755
799
|
const gRed = gofOf(red);
|
|
@@ -762,21 +806,22 @@ export default async function loadENA() {
|
|
|
762
806
|
// Gate 2 — reduced points AND retained-code nodes must each
|
|
763
807
|
// correlate >= threshold with the full model (per dim, with a
|
|
764
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.
|
|
765
810
|
const retained = [];
|
|
766
811
|
for (let i = 0; i < m; i++) if (!removedSet.has(i)) retained.push(i);
|
|
767
|
-
|
|
768
|
-
let [
|
|
769
|
-
|
|
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);
|
|
770
816
|
if (pc1 < 0) { pc1 = -pc1; nc1 = -nc1; }
|
|
771
817
|
if (pc2 < 0) { pc2 = -pc2; nc2 = -nc2; }
|
|
772
818
|
if (Math.min(pc1, pc2, nc1, nc2) < threshold) continue;
|
|
773
819
|
|
|
774
|
-
// Dim-1 variance
|
|
775
|
-
// (= R's set$model$variance[1]
|
|
776
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
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];
|
|
780
825
|
// Prefer more codes removed; tie-break on higher dim-1 variance.
|
|
781
826
|
if (k > bestK || (k === bestK && vr1 > bestVar)) {
|
|
782
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,
|
package/src/tensor.js
CHANGED
|
@@ -119,54 +119,6 @@ export function inferFactorLevels(rows, factors) {
|
|
|
119
119
|
return levels;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* Weighted aggregation of a unit's per-response-row directed connection counts.
|
|
124
|
-
*
|
|
125
|
-
* Mirrors the kernel's aggregate_row_connections but applies a weight-model
|
|
126
|
-
* transform (R's weight.by) per LINE before the per-unit sum, so weight models
|
|
127
|
-
* behave identically on the tensor path and the windowed path. Non-binary by
|
|
128
|
-
* construction (the weight replaces the binarize step).
|
|
129
|
-
*
|
|
130
|
-
* unordered: fold each directed row (m + mᵀ, upper triangle, column-major —
|
|
131
|
-
* the same order as choose_two/connection_names), weight each
|
|
132
|
-
* folded cell, then sum across rows.
|
|
133
|
-
* ordered: weight each directed cell, then sum across rows.
|
|
134
|
-
*
|
|
135
|
-
* @param {Float64Array|number[]} data Row-major (nRccRows × nCodes²) counts.
|
|
136
|
-
* @param {number} nRccRows Number of response rows for this unit.
|
|
137
|
-
* @param {number} nCodes
|
|
138
|
-
* @param {boolean} ordered
|
|
139
|
-
* @param {(x:number)=>number} weightFn
|
|
140
|
-
* @returns {Float64Array} length nCodes² (ordered) or choose_two(nCodes).
|
|
141
|
-
*/
|
|
142
|
-
function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weightFn) {
|
|
143
|
-
const nSq = nCodes * nCodes;
|
|
144
|
-
if (ordered) {
|
|
145
|
-
const out = new Float64Array(nSq);
|
|
146
|
-
for (let r = 0; r < nRccRows; r++) {
|
|
147
|
-
const base = r * nSq;
|
|
148
|
-
for (let c = 0; c < nSq; c++) out[c] += weightFn(data[base + c]);
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
151
|
-
}
|
|
152
|
-
const nTri = (nCodes * (nCodes - 1)) / 2;
|
|
153
|
-
const out = new Float64Array(nTri);
|
|
154
|
-
for (let r = 0; r < nRccRows; r++) {
|
|
155
|
-
const base = r * nSq;
|
|
156
|
-
let k = 0;
|
|
157
|
-
for (let col = 1; col < nCodes; col++) {
|
|
158
|
-
for (let row = 0; row < col; row++) {
|
|
159
|
-
// fold: m(row,col) + m(col,row), column-major reshape of the row
|
|
160
|
-
const folded = data[base + col * nCodes + row] +
|
|
161
|
-
data[base + row * nCodes + col];
|
|
162
|
-
out[k] += weightFn(folded);
|
|
163
|
-
k++;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
122
|
/**
|
|
171
123
|
* Accumulate tensor networks for all units across all conversations.
|
|
172
124
|
*
|
|
@@ -184,8 +136,11 @@ function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weight
|
|
|
184
136
|
* @param {Map} convoGroups convoIdx → [rowIdx, ...]
|
|
185
137
|
* @param {object} tensorDef Tensor definition (see module docstring)
|
|
186
138
|
* @param {boolean} ordered true → directed (n²); false → undirected
|
|
187
|
-
* @param {boolean} [binary=true] binarize each folded row (unordered only)
|
|
188
|
-
*
|
|
139
|
+
* @param {boolean} [binary=true] binarize each folded row (unordered only);
|
|
140
|
+
* used when no weight model is given
|
|
141
|
+
* @param {string|null} [weight=null] libqe weight model applied per line before
|
|
142
|
+
* the unit sum: 'binary' | 'product' |
|
|
143
|
+
* 'sqrt' | 'log1p'
|
|
189
144
|
*
|
|
190
145
|
* @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
|
|
191
146
|
* networks nUnits × nConnections, row-major
|
|
@@ -195,7 +150,7 @@ function aggregateRowConnectionsWeighted(data, nRccRows, nCodes, ordered, weight
|
|
|
195
150
|
*/
|
|
196
151
|
export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
197
152
|
unitOf, convoGroups, tensorDef, ordered = false,
|
|
198
|
-
binary = true,
|
|
153
|
+
binary = true, weight = null) {
|
|
199
154
|
const {
|
|
200
155
|
dims,
|
|
201
156
|
dimsSender = [],
|
|
@@ -216,6 +171,9 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
|
216
171
|
const networks = new Float64Array(nUnits * nConnections);
|
|
217
172
|
const rowConnectionCounts = new Float64Array(nRows * nConnections);
|
|
218
173
|
|
|
174
|
+
// libqe weight argument: a weight-model name, or the legacy binary flag.
|
|
175
|
+
const kernelWeight = weight ?? binary;
|
|
176
|
+
|
|
219
177
|
const dimsArr = new Int32Array(dims);
|
|
220
178
|
const senderArr = new Int32Array(dimsSender);
|
|
221
179
|
const receiverArr = new Int32Array(dimsReceiver);
|
|
@@ -267,24 +225,23 @@ export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
|
267
225
|
true
|
|
268
226
|
);
|
|
269
227
|
|
|
270
|
-
// Finalise each raw per-response-row connection vector
|
|
271
|
-
// tma does in R: fold to the upper
|
|
272
|
-
// (unordered)
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// through the libqe kernel so fold/binarize semantics are shared
|
|
276
|
-
// across every binding. Row i of rcc is local row localRows[i].
|
|
228
|
+
// Finalise each raw per-response-row connection vector with libqe's
|
|
229
|
+
// shared kernel, exactly as tma does in R: fold to the upper
|
|
230
|
+
// triangle (unordered) or keep the directed row (ordered), then
|
|
231
|
+
// apply the weight model per line (binary clamp, product, sqrt,
|
|
232
|
+
// log1p) before the per-unit sum. Row i is local row localRows[i].
|
|
277
233
|
const rcc = result.row_connection_counts;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const rowOff
|
|
284
|
-
const
|
|
234
|
+
const fin = qe.finalize_row_connections(
|
|
235
|
+
rcc.data, rcc.rows, rcc.cols, nCodes, ordered, kernelWeight
|
|
236
|
+
);
|
|
237
|
+
const unitOff = unit * nConnections;
|
|
238
|
+
for (let i = 0; i < fin.rows; i++) {
|
|
239
|
+
const rowOff = rowIndices[localRows[i]] * nConnections;
|
|
240
|
+
const finOff = i * fin.cols;
|
|
285
241
|
for (let c = 0; c < nConnections; c++) {
|
|
286
|
-
|
|
287
|
-
|
|
242
|
+
const v = fin.data[finOff + c];
|
|
243
|
+
rowConnectionCounts[rowOff + c] = v;
|
|
244
|
+
networks[unitOff + c] += v;
|
|
288
245
|
}
|
|
289
246
|
}
|
|
290
247
|
}
|