@qe-libs/rena-wasm 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/README.md +164 -0
- package/package.json +34 -0
- package/src/data.js +96 -0
- package/src/index.js +433 -0
- package/src/pipeline.js +212 -0
- package/src/tensor.js +232 -0
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# @qe-libs/rena-wasm
|
|
2
|
+
|
|
3
|
+
JavaScript/WebAssembly ENA pipeline — thin orchestration layer over
|
|
4
|
+
[`@qe-libs/libqe-wasm`](../../libqe/wasm/README.md).
|
|
5
|
+
|
|
6
|
+
Handles data parsing, unit/conversation grouping, and the full
|
|
7
|
+
accumulate → normalize → center → rotate → project → node-positions pipeline.
|
|
8
|
+
All math is delegated to the libqe WASM module; no C++ compilation required here.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @qe-libs/rena-wasm
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import loadENA from '@qe-libs/rena-wasm';
|
|
24
|
+
|
|
25
|
+
const ena = await loadENA();
|
|
26
|
+
|
|
27
|
+
const model = ena.fit(rows, {
|
|
28
|
+
codes: ['Data', 'Technical.Constraints', 'Performance.Parameters'],
|
|
29
|
+
units: ['UserName', 'Condition'],
|
|
30
|
+
conversations: ['Condition', 'GroupName'],
|
|
31
|
+
window: 4,
|
|
32
|
+
dims: 2,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
model.centroids // Float64Array nUnits × dims
|
|
36
|
+
model.networks // Float64Array nUnits × nConnections (normed)
|
|
37
|
+
model.positions // Float64Array nCodes × dims
|
|
38
|
+
model.connectionNames // ['Data & Technical.Constraints', ...]
|
|
39
|
+
model.unitLabels // ['UserName1_ConditionA', ...]
|
|
40
|
+
model.columnNames // ['SVD1', 'SVD2']
|
|
41
|
+
|
|
42
|
+
// Per-unit helpers
|
|
43
|
+
model.centroid('Alice_A') // number[] length = dims
|
|
44
|
+
model.network('Alice_A') // number[] length = nConnections
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Rotation Methods
|
|
50
|
+
|
|
51
|
+
| Method | `rotation` option | Extra options |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| SVD (default) | `'svd'` | — |
|
|
54
|
+
| Means | `'mean'` | `groupA: [unitIdx, ...]`, `groupB: [unitIdx, ...]` |
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// Means rotation — groupA/groupB are unit indices (position in unitLabels)
|
|
58
|
+
const model = ena.fit(rows, {
|
|
59
|
+
codes, units, conversations,
|
|
60
|
+
rotation: 'mean',
|
|
61
|
+
groupA: [0, 1, 2],
|
|
62
|
+
groupB: [3, 4, 5],
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Accumulation Only
|
|
69
|
+
|
|
70
|
+
Returns raw (un-normalised) network vectors without running the full pipeline.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const { networks, unitLabels, connectionNames, nUnits, nConnections } =
|
|
74
|
+
ena.accumulate(rows, { codes, units, conversations, window: 4 });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Advanced: Context Tensor Accumulation
|
|
80
|
+
|
|
81
|
+
Context tensors give per-factor-combination control over window sizes and
|
|
82
|
+
weights — the JS equivalent of `tma::accumulate_contexts()` with HOO rules.
|
|
83
|
+
|
|
84
|
+
The tensor is a multi-dimensional array whose last axis is always size 2
|
|
85
|
+
(index 0 = weight, index 1 = window). Earlier axes correspond to factor
|
|
86
|
+
columns in the data.
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
// Example: sender role (T=Teacher, S=Student) controls the window size.
|
|
90
|
+
// dims = [nRoleValues, 2] → Teacher uses window=4, Student uses window=2.
|
|
91
|
+
const model = ena.fit(rows, {
|
|
92
|
+
codes, units, conversations,
|
|
93
|
+
ordered: true, // directed (n² connections) when using tensors
|
|
94
|
+
tensor: {
|
|
95
|
+
dims: [2, 2], // [nRoleValues=2, weight/window=2]
|
|
96
|
+
dimsSender: [0], // axis 0 is a sender factor
|
|
97
|
+
dimsReceiver: [],
|
|
98
|
+
dimsMode: [],
|
|
99
|
+
factors: ['Role'], // column in the data
|
|
100
|
+
// Optional: explicit value → index mapping. Inferred automatically if omitted.
|
|
101
|
+
factorLevels: { Role: { 'Teacher': 0, 'Student': 1 } },
|
|
102
|
+
// Flat column-major: [weight_T, weight_S, window_T, window_S]
|
|
103
|
+
data: Float64Array.of(1, 1, 4, 2),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Tensor layout
|
|
109
|
+
|
|
110
|
+
The `data` array is **column-major** with shape `dims`. The last axis
|
|
111
|
+
selects weight (`0`) or window (`1`). For `dims = [nA, nB, 2]`:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
data[a + nA*b + nA*nB*0] → weight for factor combination (a, b)
|
|
115
|
+
data[a + nA*b + nA*nB*1] → window for factor combination (a, b)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Factor axis roles
|
|
119
|
+
|
|
120
|
+
| Option | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `dimsSender` | These axes use the *ground* row's factor values when looking up the window |
|
|
123
|
+
| `dimsReceiver` | These axes use the *response* row's factor values (overrides ground) |
|
|
124
|
+
| `dimsMode` | These axes use a shared mode value |
|
|
125
|
+
|
|
126
|
+
### `defaultTensor` helper
|
|
127
|
+
|
|
128
|
+
Express simple windowed accumulation as a tensor (IS_DEFAULT path):
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { defaultTensor } from '@qe-libs/rena-wasm/src/tensor.js';
|
|
132
|
+
|
|
133
|
+
const tensor = defaultTensor(4); // window=4, weight=1
|
|
134
|
+
const tensor = defaultTensor(4, 0.5); // window=4, weight=0.5
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Input Format
|
|
140
|
+
|
|
141
|
+
`rows` is an array of plain objects — one per utterance/event.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const rows = [
|
|
145
|
+
{ UserName: 'Alice', Condition: 'A', GroupName: 'G1', Role: 'Teacher', Data: 1, Reasoning: 0 },
|
|
146
|
+
...
|
|
147
|
+
];
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Code column values should be numeric (0/1 for binary codes). Factor column
|
|
151
|
+
values can be any string or number — they are mapped to 0-based indices
|
|
152
|
+
automatically unless `factorLevels` is provided explicitly.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Testing
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm install
|
|
160
|
+
npm test
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Tests cover the simple windowed pipeline (`test/ena.test.js`) and the
|
|
164
|
+
context-tensor path (`test/tensor.test.js`).
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qe-libs/rena-wasm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JavaScript/WebAssembly ENA pipeline — thin orchestration layer over @qe-libs/libqe-wasm",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"registry": "https://gitlab.com/api/v4/projects/22522458/packages/npm/"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["wasm", "ena", "epistemic-network-analysis", "quantitative-ethnography"],
|
|
17
|
+
"license": "GPL-3.0-only",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://gitlab.com/epistemic-analytics/qe-packages/rENA",
|
|
21
|
+
"directory": "wasm"
|
|
22
|
+
},
|
|
23
|
+
"jest": {
|
|
24
|
+
"testEnvironment": "node",
|
|
25
|
+
"transform": {},
|
|
26
|
+
"resolver": "<rootDir>/jest-resolver.cjs"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@qe-libs/libqe-wasm": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"jest": "^29.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/data.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data.js — Data parsing and unit/conversation grouping for ENA.
|
|
3
|
+
*
|
|
4
|
+
* Converts an array of row objects (e.g. parsed CSV rows) into the flat
|
|
5
|
+
* Float64Array code matrix and unit/conversation index structures that the
|
|
6
|
+
* pipeline needs. Also extracts per-unit metadata (non-code, non-unit,
|
|
7
|
+
* non-conversation columns), mirroring R's enadata$metadata / set$meta.data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a composite key from multiple column values.
|
|
12
|
+
* @param {Object} row
|
|
13
|
+
* @param {string[]} cols
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function rowKey(row, cols) {
|
|
17
|
+
return cols.map(c => row[c]).join('__');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse tabular data into ENA-ready structures.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object[]} rows - Array of row objects (one per utterance/event)
|
|
24
|
+
* @param {string[]} codes - Code column names
|
|
25
|
+
* @param {string[]} unitCols - Column(s) that identify a unit (e.g. ['UserName', 'Condition'])
|
|
26
|
+
* @param {string[]} convoCols - Column(s) that identify a conversation (e.g. ['Condition', 'GroupName'])
|
|
27
|
+
*
|
|
28
|
+
* @returns {{
|
|
29
|
+
* codeMatrix: Float64Array, // n_rows × n_codes, row-major
|
|
30
|
+
* nRows: number,
|
|
31
|
+
* nCodes: number,
|
|
32
|
+
* nUnits: number,
|
|
33
|
+
* unitLabels: string[], // unit key for each unit index
|
|
34
|
+
* unitOf: Int32Array, // unit index for each row
|
|
35
|
+
* convoOf: Int32Array, // conversation index for each row
|
|
36
|
+
* convoGroups: Map<number, number[]>, // convoIdx → [rowIdx, ...]
|
|
37
|
+
* metaData: Object[], // one metadata object per unit (first-row representative)
|
|
38
|
+
* metaCols: string[], // names of metadata columns
|
|
39
|
+
* }}
|
|
40
|
+
*/
|
|
41
|
+
export function parseData(rows, codes, unitCols, convoCols) {
|
|
42
|
+
const nRows = rows.length;
|
|
43
|
+
const nCodes = codes.length;
|
|
44
|
+
|
|
45
|
+
const codeMatrix = new Float64Array(nRows * nCodes);
|
|
46
|
+
const unitOf = new Int32Array(nRows);
|
|
47
|
+
const convoOf = new Int32Array(nRows);
|
|
48
|
+
|
|
49
|
+
const unitIndex = new Map(); // key → index
|
|
50
|
+
const convoIndex = new Map(); // key → index
|
|
51
|
+
const convoGroups = new Map(); // convoIdx → [rowIdx, ...]
|
|
52
|
+
|
|
53
|
+
// Metadata columns: every column that is NOT a code, unit, or convo column.
|
|
54
|
+
const excludedCols = new Set([...codes, ...unitCols, ...convoCols]);
|
|
55
|
+
const allCols = nRows > 0 ? Object.keys(rows[0]) : [];
|
|
56
|
+
const metaCols = allCols.filter(c => !excludedCols.has(c));
|
|
57
|
+
|
|
58
|
+
// Per-unit metadata rows — populated on first occurrence of each unit.
|
|
59
|
+
const metaData = [];
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < nRows; i++) {
|
|
62
|
+
const row = rows[i];
|
|
63
|
+
|
|
64
|
+
// Code values
|
|
65
|
+
for (let c = 0; c < nCodes; c++) {
|
|
66
|
+
codeMatrix[i * nCodes + c] = Number(row[codes[c]]) || 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Unit index
|
|
70
|
+
const uKey = rowKey(row, unitCols);
|
|
71
|
+
if (!unitIndex.has(uKey)) {
|
|
72
|
+
const uIdx = unitIndex.size;
|
|
73
|
+
unitIndex.set(uKey, uIdx);
|
|
74
|
+
// First row for this unit → representative metadata
|
|
75
|
+
const meta = {};
|
|
76
|
+
for (const col of metaCols) meta[col] = row[col];
|
|
77
|
+
metaData[uIdx] = meta;
|
|
78
|
+
}
|
|
79
|
+
unitOf[i] = unitIndex.get(uKey);
|
|
80
|
+
|
|
81
|
+
// Conversation index
|
|
82
|
+
const cKey = rowKey(row, convoCols);
|
|
83
|
+
if (!convoIndex.has(cKey)) convoIndex.set(cKey, convoIndex.size);
|
|
84
|
+
const cIdx = convoIndex.get(cKey);
|
|
85
|
+
convoOf[i] = cIdx;
|
|
86
|
+
|
|
87
|
+
if (!convoGroups.has(cIdx)) convoGroups.set(cIdx, []);
|
|
88
|
+
convoGroups.get(cIdx).push(i);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const unitLabels = Array.from(unitIndex.keys());
|
|
92
|
+
const nUnits = unitLabels.length;
|
|
93
|
+
|
|
94
|
+
return { codeMatrix, nRows, nCodes, nUnits, unitLabels, unitOf, convoOf, convoGroups,
|
|
95
|
+
metaData, metaCols };
|
|
96
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @qe-libs/rena-wasm
|
|
3
|
+
*
|
|
4
|
+
* JavaScript/WebAssembly ENA pipeline.
|
|
5
|
+
* Thin orchestration layer over @qe-libs/libqe-wasm — handles data parsing,
|
|
6
|
+
* unit/conversation grouping, and the full accumulate→normalize→center→
|
|
7
|
+
* rotate→project→node-positions pipeline.
|
|
8
|
+
*
|
|
9
|
+
* Output structure mirrors R's ena.set object (without R-specific S3 class
|
|
10
|
+
* attributes and without metadata columns prepended to every matrix).
|
|
11
|
+
* Metadata lives in metaData / model.metaData instead.
|
|
12
|
+
*
|
|
13
|
+
* Top-level fields (= R's set$...):
|
|
14
|
+
* connectionCounts Float64Array (nUnits × nConnections) — raw accumulation
|
|
15
|
+
* lineWeights Float64Array (nUnits × nConnections) — sphere-normed
|
|
16
|
+
* points Float64Array (nUnits × dims) — projected positions
|
|
17
|
+
* rotationMatrix Float64Array (nConnections × dims) — rotation vectors
|
|
18
|
+
* metaData Object[] one object per unit, non-code/unit/convo cols
|
|
19
|
+
* connectionNames string[]
|
|
20
|
+
* columnClasses Object matrix name → R column class string
|
|
21
|
+
* nUnits number
|
|
22
|
+
* nConnections number
|
|
23
|
+
* dims number
|
|
24
|
+
*
|
|
25
|
+
* model sub-object (= R's set$model$...):
|
|
26
|
+
* model.centroids Float64Array (nUnits × dims) — LWS centroids
|
|
27
|
+
* model.variance number[] variance explained per dim
|
|
28
|
+
* model.unitLabels string[]
|
|
29
|
+
* model.pointsForProjection Float64Array (nUnits × nConnections) — centered normed
|
|
30
|
+
*
|
|
31
|
+
* rotation sub-object (= R's set$rotation$...):
|
|
32
|
+
* rotation.rotationMatrix Float64Array same reference as top-level rotationMatrix
|
|
33
|
+
* rotation.nodes Float64Array (nCodes × dims) — code positions
|
|
34
|
+
* rotation.columnNames string[] axis labels e.g. ['SVD1','SVD2']
|
|
35
|
+
* rotation.eigenvalues number[]
|
|
36
|
+
* rotation.centerVec Float64Array (nConnections) — centering vector
|
|
37
|
+
* rotation.codes string[]
|
|
38
|
+
* rotation.adjacencyKey string[][] [[codeA,codeB], ...] per connection
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import loadLibQE from '@qe-libs/libqe-wasm';
|
|
42
|
+
import { parseData } from './data.js';
|
|
43
|
+
import {
|
|
44
|
+
accumulate, sphereNorm, center,
|
|
45
|
+
rotateSVD, rotateMeans,
|
|
46
|
+
project, nodePositions,
|
|
47
|
+
} from './pipeline.js';
|
|
48
|
+
import { accumulateTensor, defaultTensor } from './tensor.js';
|
|
49
|
+
|
|
50
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute per-dimension variance explained from projected unit positions.
|
|
54
|
+
* Matches R: diagonal(var(points)) / sum(diagonal(var(points)))
|
|
55
|
+
*
|
|
56
|
+
* @param {Float64Array} points nUnits × dims, row-major
|
|
57
|
+
* @param {number} nUnits
|
|
58
|
+
* @param {number} dims
|
|
59
|
+
* @returns {number[]} length dims, sums to 1
|
|
60
|
+
*/
|
|
61
|
+
function computeVariance(points, nUnits, dims) {
|
|
62
|
+
if (nUnits < 2) return Array.from({ length: dims }, () => 1 / dims);
|
|
63
|
+
|
|
64
|
+
// Column means
|
|
65
|
+
const means = new Float64Array(dims);
|
|
66
|
+
for (let u = 0; u < nUnits; u++)
|
|
67
|
+
for (let d = 0; d < dims; d++)
|
|
68
|
+
means[d] += points[u * dims + d];
|
|
69
|
+
for (let d = 0; d < dims; d++) means[d] /= nUnits;
|
|
70
|
+
|
|
71
|
+
// Sample variance (n-1 denominator, matching R's var())
|
|
72
|
+
const variances = new Float64Array(dims);
|
|
73
|
+
for (let u = 0; u < nUnits; u++)
|
|
74
|
+
for (let d = 0; d < dims; d++) {
|
|
75
|
+
const diff = points[u * dims + d] - means[d];
|
|
76
|
+
variances[d] += diff * diff;
|
|
77
|
+
}
|
|
78
|
+
for (let d = 0; d < dims; d++) variances[d] /= (nUnits - 1);
|
|
79
|
+
|
|
80
|
+
const total = Array.from(variances).reduce((a, b) => a + b, 0);
|
|
81
|
+
if (total === 0) return Array.from({ length: dims }, () => 1 / dims);
|
|
82
|
+
return Array.from(variances).map(v => v / total);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the adjacency key — list of [codeA, codeB] pairs for each connection.
|
|
87
|
+
* Matches R's enadata$adjacency.matrix (column-major upper triangle order).
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} codes
|
|
90
|
+
* @returns {string[][]} length nConnections, each entry is [codeI, codeJ]
|
|
91
|
+
*/
|
|
92
|
+
function buildAdjacencyKey(codes) {
|
|
93
|
+
const key = [];
|
|
94
|
+
for (let j = 1; j < codes.length; j++)
|
|
95
|
+
for (let i = 0; i < j; i++)
|
|
96
|
+
key.push([codes[i], codes[j]]);
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── ENA result object ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
class ENAModel {
|
|
103
|
+
constructor(opts) {
|
|
104
|
+
// ── top-level fields (= R's set$...) ────────────────────────────────
|
|
105
|
+
this.connectionCounts = opts.connectionCounts; // raw networks
|
|
106
|
+
this.lineWeights = opts.lineWeights; // sphere-normed networks
|
|
107
|
+
this.points = opts.points; // projected unit positions
|
|
108
|
+
this.rotationMatrix = opts.rotationMatrix; // n_connections × dims
|
|
109
|
+
this.metaData = opts.metaData; // array of unit metadata objects
|
|
110
|
+
this.connectionNames = opts.connectionNames;
|
|
111
|
+
this.nUnits = opts.nUnits;
|
|
112
|
+
this.nConnections = opts.nConnections;
|
|
113
|
+
this.dims = opts.dims;
|
|
114
|
+
|
|
115
|
+
// ── model sub-object (= R's set$model$...) ───────────────────────────
|
|
116
|
+
this.model = {
|
|
117
|
+
centroids: opts.centroids, // LWS positions
|
|
118
|
+
variance: opts.variance, // variance explained
|
|
119
|
+
unitLabels: opts.unitLabels,
|
|
120
|
+
pointsForProjection: opts.pointsForProjection, // centered normed networks
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ── rotation sub-object (= R's set$rotation$...) ────────────────────
|
|
124
|
+
this.rotation = {
|
|
125
|
+
rotationMatrix: opts.rotationMatrix, // same reference as top-level
|
|
126
|
+
nodes: opts.nodes, // code positions
|
|
127
|
+
columnNames: opts.columnNames, // axis labels ['SVD1','SVD2']
|
|
128
|
+
eigenvalues: opts.eigenvalues,
|
|
129
|
+
centerVec: opts.centerVec,
|
|
130
|
+
codes: opts.codes,
|
|
131
|
+
adjacencyKey: opts.adjacencyKey,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── column class annotations (mirrors R's S3 class tags) ─────────────
|
|
135
|
+
this.columnClasses = {
|
|
136
|
+
connectionCounts: 'ena.co.occurrence',
|
|
137
|
+
lineWeights: 'ena.co.occurrence',
|
|
138
|
+
points: 'ena.dimension',
|
|
139
|
+
pointsForProjection: 'ena.co.occurrence',
|
|
140
|
+
rotationMatrix: 'ena.dimension',
|
|
141
|
+
nodes: 'ena.dimension',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Projected position for a single unit (= R's set$points[unit,]).
|
|
147
|
+
* @param {string} unitLabel
|
|
148
|
+
* @returns {number[]}
|
|
149
|
+
*/
|
|
150
|
+
point(unitLabel) {
|
|
151
|
+
const idx = this.model.unitLabels.indexOf(unitLabel);
|
|
152
|
+
if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
|
|
153
|
+
return Array.from(this.points.subarray(idx * this.dims, (idx + 1) * this.dims));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* LWS centroid for a single unit (= R's set$model$centroids[unit,]).
|
|
158
|
+
* @param {string} unitLabel
|
|
159
|
+
* @returns {number[]}
|
|
160
|
+
*/
|
|
161
|
+
centroid(unitLabel) {
|
|
162
|
+
const idx = this.model.unitLabels.indexOf(unitLabel);
|
|
163
|
+
if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
|
|
164
|
+
if (!this.model.centroids) throw new Error('LWS centroids not available from libqe');
|
|
165
|
+
return Array.from(this.model.centroids.subarray(idx * this.dims, (idx + 1) * this.dims));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Normed network vector for a single unit (= R's set$line.weights[unit,]).
|
|
170
|
+
* @param {string} unitLabel
|
|
171
|
+
* @returns {number[]}
|
|
172
|
+
*/
|
|
173
|
+
network(unitLabel) {
|
|
174
|
+
const idx = this.model.unitLabels.indexOf(unitLabel);
|
|
175
|
+
if (idx < 0) throw new Error(`Unknown unit: ${unitLabel}`);
|
|
176
|
+
return Array.from(this.lineWeights.subarray(
|
|
177
|
+
idx * this.nConnections, (idx + 1) * this.nConnections
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
185
|
+
metaData, rotMethod, groupA, groupB, dims) {
|
|
186
|
+
const connectionNames = qe.connection_names(codes);
|
|
187
|
+
|
|
188
|
+
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
189
|
+
const lineWeights = sphereNorm(qe, rawNetworks, nUnits, nConnections);
|
|
190
|
+
|
|
191
|
+
// Center → pointsForProjection (= R's model$points.for.projection)
|
|
192
|
+
// centerVec (= R's rotation$center.vec)
|
|
193
|
+
const { centered: pointsForProjection, centerVec } =
|
|
194
|
+
center(qe, lineWeights, nUnits, nConnections);
|
|
195
|
+
|
|
196
|
+
// Rotate
|
|
197
|
+
let rot;
|
|
198
|
+
if (rotMethod === 'mean') {
|
|
199
|
+
if (!groupA || !groupB) throw new Error(
|
|
200
|
+
'opts.groupA and opts.groupB are required for means rotation'
|
|
201
|
+
);
|
|
202
|
+
rot = rotateMeans(qe, pointsForProjection, nUnits, nConnections, groupA, groupB);
|
|
203
|
+
} else {
|
|
204
|
+
rot = rotateSVD(qe, pointsForProjection, nUnits, nConnections);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Truncate rotation matrix to dims columns (= R's set$rotation.matrix)
|
|
208
|
+
const rotationMatrix = new Float64Array(rot.rotRows * dims);
|
|
209
|
+
for (let r = 0; r < rot.rotRows; r++)
|
|
210
|
+
for (let d = 0; d < dims; d++)
|
|
211
|
+
rotationMatrix[r * dims + d] = rot.rotation[r * rot.rotCols + d];
|
|
212
|
+
|
|
213
|
+
const columnNames = rot.columnNames.slice(0, dims);
|
|
214
|
+
|
|
215
|
+
// Project → points (= R's set$points)
|
|
216
|
+
const points = project(
|
|
217
|
+
pointsForProjection, nUnits, nConnections,
|
|
218
|
+
rot.rotation, rot.rotRows, rot.rotCols, dims
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Node positions (LWS) → rotation.nodes + model.centroids
|
|
222
|
+
// libqe.node_positions expects centered-normed networks (pointsForProjection),
|
|
223
|
+
// not sphere-normed (lineWeights). Using lineWeights places code nodes in wrong locations.
|
|
224
|
+
const { nodes, centroids } = nodePositions(
|
|
225
|
+
qe, pointsForProjection, nUnits, nConnections, points, dims
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Variance explained (= R's model$variance)
|
|
229
|
+
const variance = computeVariance(points, nUnits, dims);
|
|
230
|
+
|
|
231
|
+
// Adjacency key (= R's rotation$adjacency.key)
|
|
232
|
+
const adjacencyKey = buildAdjacencyKey(codes);
|
|
233
|
+
|
|
234
|
+
return new ENAModel({
|
|
235
|
+
// top-level
|
|
236
|
+
connectionCounts: rawNetworks,
|
|
237
|
+
lineWeights,
|
|
238
|
+
points,
|
|
239
|
+
rotationMatrix,
|
|
240
|
+
metaData,
|
|
241
|
+
connectionNames,
|
|
242
|
+
nUnits,
|
|
243
|
+
nConnections,
|
|
244
|
+
dims,
|
|
245
|
+
// model sub
|
|
246
|
+
centroids,
|
|
247
|
+
variance,
|
|
248
|
+
unitLabels,
|
|
249
|
+
pointsForProjection,
|
|
250
|
+
// rotation sub
|
|
251
|
+
nodes,
|
|
252
|
+
columnNames,
|
|
253
|
+
eigenvalues: rot.eigenvalues,
|
|
254
|
+
centerVec,
|
|
255
|
+
codes,
|
|
256
|
+
adjacencyKey,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── main factory ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Load the ENA module. Returns an object with `fit()` and `accumulate()`.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<{fit: Function, accumulate: Function}>}
|
|
266
|
+
*/
|
|
267
|
+
export default async function loadENA() {
|
|
268
|
+
const qe = await loadLibQE();
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
/**
|
|
272
|
+
* Run the full ENA pipeline.
|
|
273
|
+
*
|
|
274
|
+
* @param {Object[]} rows - Tabular data (array of row objects)
|
|
275
|
+
* @param {object} opts
|
|
276
|
+
* @param {string[]} opts.codes - Code column names
|
|
277
|
+
* @param {string[]} opts.units - Unit identifier column(s)
|
|
278
|
+
* @param {string[]} opts.conversations - Conversation identifier column(s)
|
|
279
|
+
* @param {number} [opts.window=4] - Backward window (simple path; ignored when tensor provided)
|
|
280
|
+
* @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
|
|
281
|
+
* @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
|
|
282
|
+
* @param {object} [opts.tensor] - Context tensor definition
|
|
283
|
+
* @param {string} [opts.rotation='svd'] - 'svd' or 'mean'
|
|
284
|
+
* @param {number[]} [opts.groupA] - Unit indices for means rotation group A
|
|
285
|
+
* @param {number[]} [opts.groupB] - Unit indices for means rotation group B
|
|
286
|
+
* @param {number} [opts.dims=2] - Number of dimensions to return
|
|
287
|
+
*
|
|
288
|
+
* @returns {ENAModel}
|
|
289
|
+
*/
|
|
290
|
+
fit(rows, opts = {}) {
|
|
291
|
+
const {
|
|
292
|
+
codes,
|
|
293
|
+
units,
|
|
294
|
+
conversations,
|
|
295
|
+
window: windowSize = 4,
|
|
296
|
+
binary = true,
|
|
297
|
+
ordered = false,
|
|
298
|
+
tensor: tensorDef,
|
|
299
|
+
rotation: rotMethod = 'svd',
|
|
300
|
+
groupA,
|
|
301
|
+
groupB,
|
|
302
|
+
dims = 2,
|
|
303
|
+
} = opts;
|
|
304
|
+
|
|
305
|
+
if (!codes?.length) throw new Error('opts.codes is required');
|
|
306
|
+
if (!units?.length) throw new Error('opts.units is required');
|
|
307
|
+
if (!conversations?.length) throw new Error('opts.conversations is required');
|
|
308
|
+
|
|
309
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
310
|
+
unitOf, convoGroups, metaData } =
|
|
311
|
+
parseData(rows, codes, units, conversations);
|
|
312
|
+
|
|
313
|
+
let rawNetworks, nConnections;
|
|
314
|
+
|
|
315
|
+
if (tensorDef) {
|
|
316
|
+
rawNetworks = accumulateTensor(
|
|
317
|
+
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
318
|
+
unitOf, convoGroups, tensorDef, ordered
|
|
319
|
+
);
|
|
320
|
+
nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
321
|
+
} else {
|
|
322
|
+
rawNetworks = accumulate(
|
|
323
|
+
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
324
|
+
unitOf, convoGroups, windowSize, binary
|
|
325
|
+
);
|
|
326
|
+
nConnections = qe.choose_two(nCodes);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
330
|
+
unitLabels, metaData, rotMethod, groupA, groupB, dims);
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Run only the accumulation step (= R's ena.accumulate.data()).
|
|
335
|
+
* Returns raw (un-normalised) network vectors.
|
|
336
|
+
*
|
|
337
|
+
* @param {Object[]} rows
|
|
338
|
+
* @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
|
|
339
|
+
* @returns {{
|
|
340
|
+
* connectionCounts: Float64Array,
|
|
341
|
+
* unitLabels: string[],
|
|
342
|
+
* connectionNames: string[],
|
|
343
|
+
* metaData: Object[],
|
|
344
|
+
* nUnits: number,
|
|
345
|
+
* nConnections: number,
|
|
346
|
+
* }}
|
|
347
|
+
*/
|
|
348
|
+
accumulate(rows, opts = {}) {
|
|
349
|
+
const {
|
|
350
|
+
codes, units, conversations,
|
|
351
|
+
window: windowSize = 4,
|
|
352
|
+
binary = true,
|
|
353
|
+
ordered = false,
|
|
354
|
+
tensor: tensorDef,
|
|
355
|
+
} = opts;
|
|
356
|
+
|
|
357
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
358
|
+
unitOf, convoGroups, metaData } =
|
|
359
|
+
parseData(rows, codes, units, conversations);
|
|
360
|
+
|
|
361
|
+
let networks, nConnections;
|
|
362
|
+
|
|
363
|
+
if (tensorDef) {
|
|
364
|
+
networks = accumulateTensor(
|
|
365
|
+
qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
366
|
+
unitOf, convoGroups, tensorDef, ordered
|
|
367
|
+
);
|
|
368
|
+
nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
369
|
+
} else {
|
|
370
|
+
networks = accumulate(
|
|
371
|
+
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
372
|
+
unitOf, convoGroups, windowSize, binary
|
|
373
|
+
);
|
|
374
|
+
nConnections = qe.choose_two(nCodes);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const connectionNames = qe.connection_names(codes);
|
|
378
|
+
return { connectionCounts: networks, unitLabels, connectionNames,
|
|
379
|
+
metaData, nUnits, nConnections };
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Per-dimension t-based confidence intervals around the column means.
|
|
384
|
+
* Matches R's conf.ints / libqe::mean_ci.
|
|
385
|
+
*
|
|
386
|
+
* @param {Float64Array} points nUnits × dims, row-major
|
|
387
|
+
* @param {number} nUnits
|
|
388
|
+
* @param {number} dims
|
|
389
|
+
* @param {number} [confLevel=0.95]
|
|
390
|
+
* @returns {{ data: Float64Array, rows: number, cols: number }}
|
|
391
|
+
* rows=dims, cols=3 — columns: mean, lower CI, upper CI
|
|
392
|
+
*/
|
|
393
|
+
confInts(points, nUnits, dims, confLevel = 0.95) {
|
|
394
|
+
return qe.mean_ci(points, nUnits, dims, confLevel);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Per-dimension Tukey-fence outlier intervals (Q1-k*IQR, Q3+k*IQR).
|
|
399
|
+
* Matches R's outlier.ints / libqe::outlier_ci.
|
|
400
|
+
*
|
|
401
|
+
* @param {Float64Array} points nUnits × dims, row-major
|
|
402
|
+
* @param {number} nUnits
|
|
403
|
+
* @param {number} dims
|
|
404
|
+
* @param {number} [iqrFactor=1.5]
|
|
405
|
+
* @returns {{ data: Float64Array, rows: number, cols: number }}
|
|
406
|
+
* rows=dims, cols=2 — columns: lower fence, upper fence
|
|
407
|
+
*/
|
|
408
|
+
outlierInts(points, nUnits, dims, iqrFactor = 1.5) {
|
|
409
|
+
return qe.outlier_ci(points, nUnits, dims, iqrFactor);
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Per-dimension parametric and non-parametric two-group statistics.
|
|
414
|
+
* Matches R's set$tests / libqe::group_stats.
|
|
415
|
+
*
|
|
416
|
+
* @param {Float64Array} g1Points group 1 points, nG1 × dims, row-major
|
|
417
|
+
* @param {number} nG1
|
|
418
|
+
* @param {Float64Array} g2Points group 2 points, nG2 × dims, row-major
|
|
419
|
+
* @param {number} nG2
|
|
420
|
+
* @param {number} dims
|
|
421
|
+
* @returns {{ n1, n2, t, df, pvalue_t, cohens_d, means, sds,
|
|
422
|
+
* U, pvalue_u, effect_r, medians }}
|
|
423
|
+
*/
|
|
424
|
+
compareGroups(g1Points, nG1, g2Points, nG2, dims) {
|
|
425
|
+
return qe.group_stats(g1Points, nG1, dims, g2Points, nG2, dims);
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Helpers re-exported for consumers who want to build their own pipeline.
|
|
430
|
+
*/
|
|
431
|
+
defaultTensor,
|
|
432
|
+
};
|
|
433
|
+
}
|
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline.js — ENA pipeline steps backed by @qe-libs/libqe-wasm.
|
|
3
|
+
*
|
|
4
|
+
* All functions take a libqe module instance (`qe`) as first argument so
|
|
5
|
+
* they work in both Node and browser contexts without global state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Multiply two row-major matrices: A (m×k) @ B (k×n) → C (m×n).
|
|
12
|
+
* Used for projection (networks @ rotation_matrix).
|
|
13
|
+
*/
|
|
14
|
+
export function matmul(A, m, k, B, n) {
|
|
15
|
+
const C = new Float64Array(m * n);
|
|
16
|
+
for (let r = 0; r < m; r++) {
|
|
17
|
+
for (let c = 0; c < n; c++) {
|
|
18
|
+
let sum = 0;
|
|
19
|
+
for (let i = 0; i < k; i++) sum += A[r * k + i] * B[i * n + c];
|
|
20
|
+
C[r * n + c] = sum;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return C;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract the first `nDims` columns of a row-major matrix.
|
|
28
|
+
*/
|
|
29
|
+
export function sliceCols(data, nRows, nCols, nDims) {
|
|
30
|
+
if (nDims >= nCols) return data;
|
|
31
|
+
const out = new Float64Array(nRows * nDims);
|
|
32
|
+
for (let r = 0; r < nRows; r++)
|
|
33
|
+
for (let c = 0; c < nDims; c++)
|
|
34
|
+
out[r * nDims + c] = data[r * nCols + c];
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── accumulation ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Accumulate windowed co-occurrences for all units across all conversations.
|
|
42
|
+
*
|
|
43
|
+
* For each conversation group, runs qe.accumulate_stanza() on the conversation's
|
|
44
|
+
* rows to get per-row connection vectors, then folds each row's vector into its
|
|
45
|
+
* owning unit's running sum.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} qe libqe WASM module
|
|
48
|
+
* @param {Float64Array} codeMatrix n_rows × n_codes, row-major
|
|
49
|
+
* @param {number} nRows
|
|
50
|
+
* @param {number} nCodes
|
|
51
|
+
* @param {number} nUnits
|
|
52
|
+
* @param {Int32Array} unitOf unit index per row
|
|
53
|
+
* @param {Map} convoGroups convoIdx → [rowIdx, ...]
|
|
54
|
+
* @param {number} windowSize backward window (rows)
|
|
55
|
+
* @param {boolean} binary binarise co-occurrences
|
|
56
|
+
*
|
|
57
|
+
* @returns {Float64Array} nUnits × nConnections, row-major
|
|
58
|
+
*/
|
|
59
|
+
export function accumulate(qe, codeMatrix, nRows, nCodes, nUnits,
|
|
60
|
+
unitOf, convoGroups, windowSize = 4, binary = true) {
|
|
61
|
+
const nConnections = qe.choose_two(nCodes);
|
|
62
|
+
const networks = new Float64Array(nUnits * nConnections);
|
|
63
|
+
|
|
64
|
+
for (const [, rowIndices] of convoGroups) {
|
|
65
|
+
const nConvo = rowIndices.length;
|
|
66
|
+
|
|
67
|
+
// Extract code rows for this conversation (contiguous sub-matrix)
|
|
68
|
+
const convoCodes = new Float64Array(nConvo * nCodes);
|
|
69
|
+
for (let r = 0; r < nConvo; r++) {
|
|
70
|
+
const src = rowIndices[r];
|
|
71
|
+
convoCodes.set(
|
|
72
|
+
codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
|
|
73
|
+
r * nCodes
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Windowed accumulation — returns per-row connection vectors
|
|
78
|
+
const stanza = qe.accumulate_stanza(
|
|
79
|
+
convoCodes, nConvo, nCodes, windowSize, 0, binary
|
|
80
|
+
);
|
|
81
|
+
// stanza.data: nConvo × nConnections, row-major
|
|
82
|
+
|
|
83
|
+
// Fold each row into its unit's accumulator
|
|
84
|
+
for (let r = 0; r < nConvo; r++) {
|
|
85
|
+
const unit = unitOf[rowIndices[r]];
|
|
86
|
+
const offset = r * nConnections;
|
|
87
|
+
for (let c = 0; c < nConnections; c++) {
|
|
88
|
+
networks[unit * nConnections + c] += stanza.data[offset + c];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return networks;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── normalization (sphere norm) ───────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* L2-normalize each row (sphere normalization).
|
|
100
|
+
* Rows with zero norm are left as zero.
|
|
101
|
+
*/
|
|
102
|
+
export function sphereNorm(qe, networks, nUnits, nConnections) {
|
|
103
|
+
const result = qe.normalize_networks(networks, nUnits, nConnections);
|
|
104
|
+
return new Float64Array(result.data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── centering ─────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Subtract column means (center the network space).
|
|
111
|
+
* Excludes all-zero rows from the mean calculation (zero-network exclusion).
|
|
112
|
+
*
|
|
113
|
+
* @returns {{ centered: Float64Array, centerVec: Float64Array }}
|
|
114
|
+
* centered — mean-subtracted networks (zero-network rows left at zero)
|
|
115
|
+
* centerVec — the column means used for centering (= R's rotation$center.vec)
|
|
116
|
+
*/
|
|
117
|
+
export function center(qe, networks, nUnits, nConnections) {
|
|
118
|
+
// Identify non-zero rows
|
|
119
|
+
const active = [];
|
|
120
|
+
for (let u = 0; u < nUnits; u++) {
|
|
121
|
+
let rowSum = 0;
|
|
122
|
+
for (let c = 0; c < nConnections; c++) rowSum += Math.abs(networks[u * nConnections + c]);
|
|
123
|
+
if (rowSum > 0) active.push(u);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Compute column means over active rows only (= R's rotation$center.vec)
|
|
127
|
+
const means = new Float64Array(nConnections);
|
|
128
|
+
for (const u of active) {
|
|
129
|
+
for (let c = 0; c < nConnections; c++) means[c] += networks[u * nConnections + c];
|
|
130
|
+
}
|
|
131
|
+
if (active.length > 0) {
|
|
132
|
+
for (let c = 0; c < nConnections; c++) means[c] /= active.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Subtract means from non-zero rows only.
|
|
136
|
+
// Zero-network rows remain at zero (R: center.align.to.origin = TRUE default).
|
|
137
|
+
const activeSet = new Set(active);
|
|
138
|
+
const centered = new Float64Array(networks.length);
|
|
139
|
+
for (let u = 0; u < nUnits; u++) {
|
|
140
|
+
if (!activeSet.has(u)) continue; // leave zero-network row as zero
|
|
141
|
+
for (let c = 0; c < nConnections; c++) {
|
|
142
|
+
centered[u * nConnections + c] = networks[u * nConnections + c] - means[c];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { centered, centerVec: means };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── rotation ──────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* SVD rotation (default ENA rotation).
|
|
153
|
+
* @returns {{ rotation: Float64Array, rotRows: number, rotCols: number,
|
|
154
|
+
* eigenvalues: number[], columnNames: string[] }}
|
|
155
|
+
*/
|
|
156
|
+
export function rotateSVD(qe, centered, nUnits, nConnections) {
|
|
157
|
+
const r = qe.ena_svd(centered, nUnits, nConnections);
|
|
158
|
+
return {
|
|
159
|
+
rotation: r.rotation.data,
|
|
160
|
+
rotRows: r.rotation.rows,
|
|
161
|
+
rotCols: r.rotation.cols,
|
|
162
|
+
eigenvalues: r.eigenvalues,
|
|
163
|
+
columnNames: r.column_names,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Means rotation.
|
|
169
|
+
* @param {Int32Array[]} groupA Row indices of group A
|
|
170
|
+
* @param {Int32Array[]} groupB Row indices of group B
|
|
171
|
+
*/
|
|
172
|
+
export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB) {
|
|
173
|
+
const groupPairs = [{ a: new Int32Array(groupA), b: new Int32Array(groupB) }];
|
|
174
|
+
const r = qe.means_rotation(centered, nUnits, nConnections, groupPairs);
|
|
175
|
+
return {
|
|
176
|
+
rotation: r.rotation.data,
|
|
177
|
+
rotRows: r.rotation.rows,
|
|
178
|
+
rotCols: r.rotation.cols,
|
|
179
|
+
eigenvalues: r.eigenvalues,
|
|
180
|
+
columnNames: r.column_names,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── projection & node positions ───────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Project centered networks into the rotated ENA space.
|
|
188
|
+
* @returns {Float64Array} nUnits × nDims, row-major
|
|
189
|
+
*/
|
|
190
|
+
export function project(centered, nUnits, nConnections, rotation, rotRows, rotCols, nDims) {
|
|
191
|
+
const fullPoints = matmul(centered, nUnits, nConnections, rotation, rotCols);
|
|
192
|
+
return sliceCols(fullPoints, nUnits, rotCols, nDims);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compute code node positions via least-squares (LWS).
|
|
197
|
+
*
|
|
198
|
+
* @returns {{ nodes: Float64Array, nodeRows: number, nodeCols: number,
|
|
199
|
+
* centroids: Float64Array|null }}
|
|
200
|
+
* nodes — code positions in ENA space (= R's rotation$nodes)
|
|
201
|
+
* centroids — LWS unit centroid positions (= R's model$centroids), or null
|
|
202
|
+
* if libqe does not expose them
|
|
203
|
+
*/
|
|
204
|
+
export function nodePositions(qe, networks, nUnits, nConnections, points, nDims) {
|
|
205
|
+
const r = qe.node_positions(networks, nUnits, nConnections, points, nUnits, nDims, nDims);
|
|
206
|
+
return {
|
|
207
|
+
nodes: new Float64Array(r.nodes.data),
|
|
208
|
+
nodeRows: r.nodes.rows,
|
|
209
|
+
nodeCols: r.nodes.cols,
|
|
210
|
+
centroids: r.centroids ? new Float64Array(r.centroids.data) : null,
|
|
211
|
+
};
|
|
212
|
+
}
|
package/src/tensor.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tensor.js — Context-tensor accumulation for rENA WASM.
|
|
3
|
+
*
|
|
4
|
+
* Maps tma's apply_tensor() semantics to the JS/WASM layer. Each unit's
|
|
5
|
+
* network is accumulated by calling qe.accumulate_tensor_unit() with a
|
|
6
|
+
* multi-dimensional tensor that encodes per-factor-combination window sizes
|
|
7
|
+
* and weights.
|
|
8
|
+
*
|
|
9
|
+
* Tensor definition (passed as `opts.tensor` to fit/accumulate):
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* // Tensor shape. Last entry is always 2 (index 0 = weight, index 1 = window).
|
|
13
|
+
* // Earlier entries correspond to the factor columns in order.
|
|
14
|
+
* dims: [nValsFactor0, nValsFactor1, ..., 2],
|
|
15
|
+
*
|
|
16
|
+
* // Which factor axes (0-based, NOT including the last weight/window axis)
|
|
17
|
+
* // act as sender / receiver / mode dimensions.
|
|
18
|
+
* dimsSender: [0], // e.g. axis 0 is a sender factor
|
|
19
|
+
* dimsReceiver: [1], // e.g. axis 1 is a receiver factor
|
|
20
|
+
* dimsMode: [],
|
|
21
|
+
*
|
|
22
|
+
* // Column names in the data — one per factor axis, matching dims[0..n-2].
|
|
23
|
+
* factors: ['SenderType', 'ReceiverType'],
|
|
24
|
+
*
|
|
25
|
+
* // Maps each factor column's string values to 0-based integer indices.
|
|
26
|
+
* // If omitted, values are encoded in order of first appearance.
|
|
27
|
+
* factorLevels: {
|
|
28
|
+
* SenderType: { 'A': 0, 'B': 1, 'C': 2 },
|
|
29
|
+
* ReceiverType: { 'X': 0, 'Y': 1, 'Z': 2 },
|
|
30
|
+
* },
|
|
31
|
+
*
|
|
32
|
+
* // Flat column-major tensor data: weight and window for every factor
|
|
33
|
+
* // combination. Length must equal product(dims).
|
|
34
|
+
* // Index 0 along last axis = weight; index 1 along last axis = window.
|
|
35
|
+
* data: new Float64Array([...]),
|
|
36
|
+
*
|
|
37
|
+
* // Optional column name for per-row timestamps.
|
|
38
|
+
* // Defaults to row index (0, 1, 2, ...) — equivalent to unit-step time.
|
|
39
|
+
* timesCol: 'timestamp',
|
|
40
|
+
* }
|
|
41
|
+
*
|
|
42
|
+
* Simple windowed accumulation (IS_DEFAULT path):
|
|
43
|
+
* Pass `{ dims: [2], dimsSender: [], dimsReceiver: [], dimsMode: [],
|
|
44
|
+
* factors: [], data: Float64Array.of(weight, window) }`
|
|
45
|
+
* or just use the `window` shorthand on the outer opts instead.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the context_lookup matrix and times vector from row data.
|
|
50
|
+
*
|
|
51
|
+
* @param {Object[]} rows All data rows (full dataset)
|
|
52
|
+
* @param {number[]} rowIndices Indices into rows for this conversation
|
|
53
|
+
* @param {string[]} factors Factor column names
|
|
54
|
+
* @param {Object} factorLevels { colName: { value: index } }
|
|
55
|
+
* @returns {{ contextLookup: Int32Array, clRows: number, clCols: number,
|
|
56
|
+
* times: Float64Array }}
|
|
57
|
+
*/
|
|
58
|
+
export function buildContextLookup(rows, rowIndices, factors, factorLevels) {
|
|
59
|
+
const nRows = rowIndices.length;
|
|
60
|
+
const nFactor = factors.length;
|
|
61
|
+
|
|
62
|
+
const contextLookup = new Int32Array(nRows * nFactor);
|
|
63
|
+
const times = new Float64Array(nRows);
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < nRows; i++) {
|
|
66
|
+
const row = rows[rowIndices[i]];
|
|
67
|
+
times[i] = i; // default: row index as time
|
|
68
|
+
for (let f = 0; f < nFactor; f++) {
|
|
69
|
+
const col = factors[f];
|
|
70
|
+
contextLookup[i * nFactor + f] = factorLevels[col][row[col]] ?? 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { contextLookup, clRows: nRows, clCols: nFactor, times };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the context_lookup with explicit timestamp column.
|
|
79
|
+
*/
|
|
80
|
+
export function buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol) {
|
|
81
|
+
const nRows = rowIndices.length;
|
|
82
|
+
const nFactor = factors.length;
|
|
83
|
+
|
|
84
|
+
const contextLookup = new Int32Array(nRows * nFactor);
|
|
85
|
+
const times = new Float64Array(nRows);
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < nRows; i++) {
|
|
88
|
+
const row = rows[rowIndices[i]];
|
|
89
|
+
times[i] = Number(row[timesCol]) || i;
|
|
90
|
+
for (let f = 0; f < nFactor; f++) {
|
|
91
|
+
const col = factors[f];
|
|
92
|
+
contextLookup[i * nFactor + f] = factorLevels[col][row[col]] ?? 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { contextLookup, clRows: nRows, clCols: nFactor, times };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Auto-build factorLevels from data when not explicitly provided.
|
|
101
|
+
* Scans all rows and assigns integer indices in order of first appearance.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object[]} rows
|
|
104
|
+
* @param {string[]} factors
|
|
105
|
+
* @returns {Object} { colName: { value: index, ... }, ... }
|
|
106
|
+
*/
|
|
107
|
+
export function inferFactorLevels(rows, factors) {
|
|
108
|
+
const levels = {};
|
|
109
|
+
for (const col of factors) {
|
|
110
|
+
levels[col] = {};
|
|
111
|
+
let idx = 0;
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const v = row[col];
|
|
114
|
+
if (v !== undefined && !(v in levels[col])) {
|
|
115
|
+
levels[col][v] = idx++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return levels;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Accumulate tensor networks for all units across all conversations.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} qe libqe WASM module
|
|
126
|
+
* @param {Object[]} rows Full dataset
|
|
127
|
+
* @param {Float64Array} codeMatrix n_rows × n_codes, row-major
|
|
128
|
+
* @param {number} nRows
|
|
129
|
+
* @param {number} nCodes
|
|
130
|
+
* @param {number} nUnits
|
|
131
|
+
* @param {Int32Array} unitOf unit index per row
|
|
132
|
+
* @param {Map} convoGroups convoIdx → [rowIdx, ...]
|
|
133
|
+
* @param {object} tensorDef Tensor definition (see module docstring)
|
|
134
|
+
* @param {boolean} ordered true → directed (n²); false → undirected
|
|
135
|
+
*
|
|
136
|
+
* @returns {Float64Array} nUnits × nConnections, row-major
|
|
137
|
+
*/
|
|
138
|
+
export function accumulateTensor(qe, rows, codeMatrix, nRows, nCodes, nUnits,
|
|
139
|
+
unitOf, convoGroups, tensorDef, ordered = false) {
|
|
140
|
+
const {
|
|
141
|
+
dims,
|
|
142
|
+
dimsSender = [],
|
|
143
|
+
dimsReceiver = [],
|
|
144
|
+
dimsMode = [],
|
|
145
|
+
factors = [],
|
|
146
|
+
data: tensorData,
|
|
147
|
+
timesCol,
|
|
148
|
+
} = tensorDef;
|
|
149
|
+
|
|
150
|
+
// Resolve factor levels (infer if not provided)
|
|
151
|
+
const factorLevels = tensorDef.factorLevels ?? inferFactorLevels(rows, factors);
|
|
152
|
+
|
|
153
|
+
const nConnections = ordered
|
|
154
|
+
? nCodes * nCodes
|
|
155
|
+
: qe.choose_two(nCodes);
|
|
156
|
+
|
|
157
|
+
const networks = new Float64Array(nUnits * nConnections);
|
|
158
|
+
|
|
159
|
+
const dimsArr = new Int32Array(dims);
|
|
160
|
+
const senderArr = new Int32Array(dimsSender);
|
|
161
|
+
const receiverArr = new Int32Array(dimsReceiver);
|
|
162
|
+
const modeArr = new Int32Array(dimsMode);
|
|
163
|
+
|
|
164
|
+
for (const [, rowIndices] of convoGroups) {
|
|
165
|
+
const nConvo = rowIndices.length;
|
|
166
|
+
|
|
167
|
+
// Extract code rows for this conversation
|
|
168
|
+
const convoCodes = new Float64Array(nConvo * nCodes);
|
|
169
|
+
for (let r = 0; r < nConvo; r++) {
|
|
170
|
+
const src = rowIndices[r];
|
|
171
|
+
convoCodes.set(
|
|
172
|
+
codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
|
|
173
|
+
r * nCodes
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Build context_lookup and times for this conversation
|
|
178
|
+
const { contextLookup, clRows, clCols, times } = timesCol
|
|
179
|
+
? buildContextLookupWithTimes(rows, rowIndices, factors, factorLevels, timesCol)
|
|
180
|
+
: buildContextLookup(rows, rowIndices, factors, factorLevels);
|
|
181
|
+
|
|
182
|
+
// Group response rows by unit (within this conversation)
|
|
183
|
+
const unitConvoRows = new Map();
|
|
184
|
+
for (let r = 0; r < nConvo; r++) {
|
|
185
|
+
const unit = unitOf[rowIndices[r]];
|
|
186
|
+
if (!unitConvoRows.has(unit)) unitConvoRows.set(unit, []);
|
|
187
|
+
unitConvoRows.get(unit).push(r); // local (conversation-relative) index
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Accumulate per unit
|
|
191
|
+
for (const [unit, localRows] of unitConvoRows) {
|
|
192
|
+
const unitRowsArr = new Int32Array(localRows);
|
|
193
|
+
|
|
194
|
+
const result = qe.accumulate_tensor_unit(
|
|
195
|
+
tensorData, dimsArr,
|
|
196
|
+
senderArr, receiverArr, modeArr,
|
|
197
|
+
contextLookup, clRows, clCols,
|
|
198
|
+
unitRowsArr,
|
|
199
|
+
convoCodes, nConvo, nCodes,
|
|
200
|
+
times,
|
|
201
|
+
ordered
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Add connection counts to this unit's accumulator
|
|
205
|
+
for (let c = 0; c < nConnections; c++) {
|
|
206
|
+
networks[unit * nConnections + c] += result.connection_counts[c];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return networks;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build an IS_DEFAULT tensor definition from a simple window + weight.
|
|
216
|
+
* Use this to express simple windowed accumulation through the tensor path.
|
|
217
|
+
*
|
|
218
|
+
* @param {number} windowSize
|
|
219
|
+
* @param {number} [weight=1]
|
|
220
|
+
* @returns {object} tensorDef
|
|
221
|
+
*/
|
|
222
|
+
export function defaultTensor(windowSize, weight = 1) {
|
|
223
|
+
return {
|
|
224
|
+
dims: [2],
|
|
225
|
+
dimsSender: [],
|
|
226
|
+
dimsReceiver: [],
|
|
227
|
+
dimsMode: [],
|
|
228
|
+
factors: [],
|
|
229
|
+
factorLevels: {},
|
|
230
|
+
data: Float64Array.of(weight, windowSize),
|
|
231
|
+
};
|
|
232
|
+
}
|