@qe-libs/rena-wasm 0.1.0 → 0.1.2
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 +8 -6
- package/package.json +7 -2
- package/src/detect.js +325 -0
- package/src/index.js +184 -14
- package/src/pipeline.js +209 -0
package/README.md
CHANGED
|
@@ -32,12 +32,14 @@ const model = ena.fit(rows, {
|
|
|
32
32
|
dims: 2,
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
model.centroids
|
|
36
|
-
model.
|
|
37
|
-
model.
|
|
35
|
+
model.model.centroids // Float64Array nUnits × dims
|
|
36
|
+
model.lineWeights // Float64Array nUnits × nConnections (normed)
|
|
37
|
+
model.connectionCounts // Float64Array nUnits × nConnections (raw unit counts)
|
|
38
|
+
model.rowConnectionCounts // Float64Array nRows × nConnections (raw row counts)
|
|
39
|
+
model.rotation.nodes // Float64Array nCodes × dims
|
|
38
40
|
model.connectionNames // ['Data & Technical.Constraints', ...]
|
|
39
|
-
model.unitLabels
|
|
40
|
-
model.columnNames
|
|
41
|
+
model.model.unitLabels // ['UserName1_ConditionA', ...]
|
|
42
|
+
model.rotation.columnNames // ['SVD1', 'SVD2']
|
|
41
43
|
|
|
42
44
|
// Per-unit helpers
|
|
43
45
|
model.centroid('Alice_A') // number[] length = dims
|
|
@@ -70,7 +72,7 @@ const model = ena.fit(rows, {
|
|
|
70
72
|
Returns raw (un-normalised) network vectors without running the full pipeline.
|
|
71
73
|
|
|
72
74
|
```js
|
|
73
|
-
const {
|
|
75
|
+
const { connectionCounts, rowConnectionCounts, unitLabels, connectionNames, nUnits, nConnections } =
|
|
74
76
|
ena.accumulate(rows, { codes, units, conversations, window: 4 });
|
|
75
77
|
```
|
|
76
78
|
|
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.2",
|
|
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",
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
"publishConfig": {
|
|
14
14
|
"registry": "https://gitlab.com/api/v4/projects/22522458/packages/npm/"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"wasm",
|
|
18
|
+
"ena",
|
|
19
|
+
"epistemic-network-analysis",
|
|
20
|
+
"quantitative-ethnography"
|
|
21
|
+
],
|
|
17
22
|
"license": "GPL-3.0-only",
|
|
18
23
|
"repository": {
|
|
19
24
|
"type": "git",
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect.js — heuristic ENA model-parameter detection.
|
|
3
|
+
*
|
|
4
|
+
* Given parsed tabular rows (array of plain objects, one per row, keyed by
|
|
5
|
+
* column name), suggest which columns are ENA codes, units, and conversations
|
|
6
|
+
* (a.k.a. horizons), plus a likely group/comparison column.
|
|
7
|
+
*
|
|
8
|
+
* Pure JS heuristics — no WASM required, so this is exported synchronously
|
|
9
|
+
* alongside the async `loadENA()` factory. Parse data however you like
|
|
10
|
+
* (PapaParse, d.ply, etc.), then pass the rows:
|
|
11
|
+
*
|
|
12
|
+
* import { detectParams } from '@qe-libs/rena-wasm';
|
|
13
|
+
* const r = detectParams(rows);
|
|
14
|
+
* // r.codes / r.units / r.conversations / r.groups / r.columns
|
|
15
|
+
* // r.recommended / r.confidence / r.reason
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Helper to split a column name into lowercase alphanumeric tokens.
|
|
19
|
+
// Handles camelCase, snake_case, dot.case, hyphen-case, etc.
|
|
20
|
+
function getTokens(str) {
|
|
21
|
+
const camelSplit = str.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
22
|
+
return camelSplit.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── name-hint vocabularies ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const UNIT_HINTS = new Set(['user', 'student', 'participant', 'speaker', 'person',
|
|
28
|
+
'author', 'subject', 'respondent', 'id', 'name', 'who',
|
|
29
|
+
'group', 'condition', 'class', 'team',
|
|
30
|
+
'player', 'character', 'actor', 'agent']);
|
|
31
|
+
const CONVO_HINTS = new Set(['group', 'condition', 'session', 'conversation', 'convo',
|
|
32
|
+
'episode', 'class', 'team', 'day', 'week', 'period',
|
|
33
|
+
'context', 'case', 'trial', 'horizon', 'activity', 'task',
|
|
34
|
+
'problem', 'scenario', 'phase', 'stage', 'run', 'round',
|
|
35
|
+
'turn', 'half',
|
|
36
|
+
'play', 'act', 'scene', 'chapter', 'section']);
|
|
37
|
+
// Names implying free text, scores, or metadata — never codes/units/conversations.
|
|
38
|
+
const EXCLUDE = new Set([
|
|
39
|
+
'timestamp', 'timestamps', 'time', 'times', 'date', 'dates', 'text', 'texts',
|
|
40
|
+
'utterance', 'utterances', 'message', 'messages', 'content', 'contents',
|
|
41
|
+
'line', 'lines', 'row', 'rows', 'index', 'indices', 'notes', 'note',
|
|
42
|
+
'score', 'scores', 'grade', 'grades', 'level', 'levels', 'change', 'changes',
|
|
43
|
+
'pre', 'post', 'confidence', 'values', 'value', 'vals', 'val', 'rates', 'rate',
|
|
44
|
+
'rating', 'ratings'
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// ── column classifiers ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** A code column: every non-empty value is 0 or 1. */
|
|
50
|
+
export function isBinary(rows, col) {
|
|
51
|
+
const vals = new Set(rows.map(r => r[col]).filter(v => v !== '' && v != null));
|
|
52
|
+
return vals.size > 0 && [...vals].every(v => v === '0' || v === '1' || v === 0 || v === 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Score a column as a UNIT-of-analysis candidate (0–1). 0 = not a candidate. */
|
|
56
|
+
export function scoreUnit(col, nUnique, nRows) {
|
|
57
|
+
const tokens = getTokens(col);
|
|
58
|
+
if (nUnique < 2 || nUnique > Math.min(nRows * 0.8, 1000)) return 0;
|
|
59
|
+
let s = 0.3;
|
|
60
|
+
if (tokens.some(t => UNIT_HINTS.has(t))) s += 0.5;
|
|
61
|
+
if (nUnique >= 2 && nUnique <= 200) s += 0.2;
|
|
62
|
+
return Math.min(s, 1.0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Score a column as a CONVERSATION / horizon (segmentation) candidate (0–1). */
|
|
66
|
+
export function scoreConvo(col, nUnique, nRows) {
|
|
67
|
+
const tokens = getTokens(col);
|
|
68
|
+
if (nUnique < 2 || nUnique > Math.min(nRows * 0.5, 500)) return 0;
|
|
69
|
+
let s = 0.2;
|
|
70
|
+
if (tokens.some(t => CONVO_HINTS.has(t))) s += 0.6;
|
|
71
|
+
if (nUnique >= 2 && nUnique <= 100) s += 0.2;
|
|
72
|
+
return Math.min(s, 1.0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const round2 = v => Math.round(v * 100) / 100;
|
|
76
|
+
|
|
77
|
+
// ── main entry point ────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detect ENA model parameters from parsed rows.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object[]} rows Array of row objects keyed by column name.
|
|
83
|
+
* @returns {{
|
|
84
|
+
* recommended: boolean,
|
|
85
|
+
* confidence: 'high'|'medium'|'low',
|
|
86
|
+
* reason: string,
|
|
87
|
+
* nRows: number,
|
|
88
|
+
* nCols: number,
|
|
89
|
+
* codes: string[],
|
|
90
|
+
* units: {column:string,nUnique:number,score:number}[],
|
|
91
|
+
* conversations: {column:string,nUnique:number,score:number}[],
|
|
92
|
+
* groups: Record<string,string[]>,
|
|
93
|
+
* columns: {name:string,nUnique:number,role:string}[],
|
|
94
|
+
* }}
|
|
95
|
+
*/
|
|
96
|
+
export function detectParams(rows) {
|
|
97
|
+
const nRows = Array.isArray(rows) ? rows.length : 0;
|
|
98
|
+
const headers = nRows > 0 ? Object.keys(rows[0]) : [];
|
|
99
|
+
|
|
100
|
+
const codeCols = [];
|
|
101
|
+
const columnsData = {};
|
|
102
|
+
|
|
103
|
+
// First pass: identify roles, compute nUnique, basic unit/convo scores
|
|
104
|
+
for (const col of headers) {
|
|
105
|
+
const tokens = getTokens(col);
|
|
106
|
+
const vals = rows.map(r => r[col]).filter(v => v !== '' && v != null);
|
|
107
|
+
const nUnique = new Set(vals).size;
|
|
108
|
+
|
|
109
|
+
// Calculate variance of string lengths to identify free-text/dialogue columns
|
|
110
|
+
let lenVar = 0;
|
|
111
|
+
if (vals.length > 1) {
|
|
112
|
+
const lengths = vals.map(v => String(v).length);
|
|
113
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
114
|
+
lenVar = lengths.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / lengths.length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (tokens.some(t => EXCLUDE.has(t)) || lenVar > 100) {
|
|
118
|
+
columnsData[col] = { role: 'metadata', nUnique, uScore: 0, cScore: 0 };
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isBinary(rows, col)) {
|
|
123
|
+
codeCols.push(col);
|
|
124
|
+
columnsData[col] = { role: 'code', nUnique, uScore: 0, cScore: 0 };
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const uScore = scoreUnit(col, nUnique, nRows);
|
|
129
|
+
const cScore = scoreConvo(col, nUnique, nRows);
|
|
130
|
+
columnsData[col] = { role: 'candidate', nUnique, uScore, cScore };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Find the primary unit column (highest uScore, tie-break by nUnique)
|
|
134
|
+
let primaryUnitCol = null;
|
|
135
|
+
let maxUScore = -1;
|
|
136
|
+
let maxUnique = -1;
|
|
137
|
+
|
|
138
|
+
for (const col of headers) {
|
|
139
|
+
const data = columnsData[col];
|
|
140
|
+
if (data.role !== 'candidate') continue;
|
|
141
|
+
if (data.uScore > maxUScore || (data.uScore === maxUScore && data.nUnique > maxUnique)) {
|
|
142
|
+
maxUScore = data.uScore;
|
|
143
|
+
maxUnique = data.nUnique;
|
|
144
|
+
primaryUnitCol = col;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Second pass: apply split penalty for units, and nesting penalty for conversations
|
|
149
|
+
const unitCands = []; // [score, col, nUnique]
|
|
150
|
+
const convoCands = [];
|
|
151
|
+
const columns = [];
|
|
152
|
+
|
|
153
|
+
// Identify which columns are valid conversation candidates to check nesting
|
|
154
|
+
const convoCandidateCols = headers.filter(col => {
|
|
155
|
+
const data = columnsData[col];
|
|
156
|
+
return data && data.role === 'candidate' && data.cScore > 0;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const col of headers) {
|
|
160
|
+
const data = columnsData[col];
|
|
161
|
+
if (data.role === 'metadata') {
|
|
162
|
+
columns.push({ name: col, nUnique: data.nUnique, role: 'metadata' });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (data.role === 'code') {
|
|
166
|
+
columns.push({ name: col, nUnique: data.nUnique, role: 'code' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let uScore = data.uScore;
|
|
171
|
+
let cScore = data.cScore;
|
|
172
|
+
|
|
173
|
+
if (col === primaryUnitCol) {
|
|
174
|
+
uScore = data.uScore;
|
|
175
|
+
} else if (primaryUnitCol) {
|
|
176
|
+
// Check if combining col with primaryUnitCol increases the number of unique units
|
|
177
|
+
const combinedVals = rows.map(r => `${r[primaryUnitCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
|
|
178
|
+
const nCombinedUnique = new Set(combinedVals).size;
|
|
179
|
+
|
|
180
|
+
if (nCombinedUnique > maxUnique) {
|
|
181
|
+
// Splits the primary unit into multiple smaller units. Penalize the score.
|
|
182
|
+
uScore = Math.max(0, uScore - 0.25);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Conversation nesting penalty: B is nested under A if B has more unique values,
|
|
187
|
+
// and combining A + B doesn't increase unique combinations beyond soft-nesting limit.
|
|
188
|
+
if (cScore > 0) {
|
|
189
|
+
let nParents = 0;
|
|
190
|
+
for (const parentCol of convoCandidateCols) {
|
|
191
|
+
if (parentCol === col) continue;
|
|
192
|
+
const parentData = columnsData[parentCol];
|
|
193
|
+
|
|
194
|
+
if (parentData.nUnique < data.nUnique) {
|
|
195
|
+
const combinedVals = rows.map(r => `${r[parentCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
|
|
196
|
+
const nCombinedUnique = new Set(combinedVals).size;
|
|
197
|
+
const ratio = nCombinedUnique / data.nUnique;
|
|
198
|
+
|
|
199
|
+
if (ratio <= 2.5) {
|
|
200
|
+
nParents++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (nParents > 0) {
|
|
205
|
+
cScore = Math.max(0.1, cScore - nParents * 0.15);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (uScore > 0 || cScore > 0) {
|
|
210
|
+
const role = uScore >= cScore ? 'unit_candidate' : 'conversation_candidate';
|
|
211
|
+
if (uScore > 0) unitCands.push([uScore, col, data.nUnique]);
|
|
212
|
+
if (cScore > 0) convoCands.push([cScore, col, data.nUnique]);
|
|
213
|
+
columns.push({ name: col, nUnique: data.nUnique, role });
|
|
214
|
+
} else {
|
|
215
|
+
columns.push({ name: col, nUnique: data.nUnique, role: 'other' });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
unitCands.sort((a, b) => b[0] - a[0] || b[2] - a[2]);
|
|
220
|
+
convoCands.sort((a, b) => b[0] - a[0] || a[2] - b[2]);
|
|
221
|
+
|
|
222
|
+
const units = unitCands.slice(0, 5).map(([s, c, u]) =>
|
|
223
|
+
({ column: c, nUnique: u, score: round2(s) }));
|
|
224
|
+
const conversations = convoCands.slice(0, 5).map(([s, c, u]) =>
|
|
225
|
+
({ column: c, nUnique: u, score: round2(s) }));
|
|
226
|
+
|
|
227
|
+
// Compute suggested combined selections in the correct nesting order
|
|
228
|
+
const suggestedUnits = [];
|
|
229
|
+
if (primaryUnitCol) {
|
|
230
|
+
// Find other unit columns that don't split the primary unit
|
|
231
|
+
const nestingUnitCols = [];
|
|
232
|
+
for (const [score, col, nUnique] of unitCands) {
|
|
233
|
+
if (col === primaryUnitCol) continue;
|
|
234
|
+
if (score >= 0.5) {
|
|
235
|
+
const combinedVals = rows.map(r => `${r[primaryUnitCol]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
|
|
236
|
+
const nCombinedUnique = new Set(combinedVals).size;
|
|
237
|
+
if (nCombinedUnique === maxUnique) {
|
|
238
|
+
nestingUnitCols.push({ col, nUnique });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Sort nesting columns by nUnique ascending (broadest first)
|
|
243
|
+
nestingUnitCols.sort((a, b) => a.nUnique - b.nUnique);
|
|
244
|
+
nestingUnitCols.forEach(x => suggestedUnits.push(x.col));
|
|
245
|
+
suggestedUnits.push(primaryUnitCol);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const suggestedConversations = [];
|
|
249
|
+
if (convoCands.length > 0) {
|
|
250
|
+
const convo1 = convoCands[0][1];
|
|
251
|
+
const nUnique1 = convoCands[0][2];
|
|
252
|
+
let convo2 = null;
|
|
253
|
+
for (let i = 1; i < convoCands.length; i++) {
|
|
254
|
+
const score = convoCands[i][0];
|
|
255
|
+
const col = convoCands[i][1];
|
|
256
|
+
const nUniqueCol = convoCands[i][2];
|
|
257
|
+
if (score >= 0.5 && nUniqueCol > nUnique1) {
|
|
258
|
+
const combinedVals = rows.map(r => `${r[convo1]}|||${r[col]}`).filter(v => !v.includes('null') && !v.includes('undefined'));
|
|
259
|
+
const nCombinedUnique = new Set(combinedVals).size;
|
|
260
|
+
const ratio = nCombinedUnique / nUniqueCol;
|
|
261
|
+
if (ratio > 1.01) { // Orthogonal/crosses
|
|
262
|
+
convo2 = col;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
suggestedConversations.push(convo1);
|
|
268
|
+
if (convo2) suggestedConversations.push(convo2);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// First convo candidate with a small, discrete value set → a comparison variable.
|
|
272
|
+
const groups = {};
|
|
273
|
+
for (const [, col, nUnique] of convoCands.slice(0, 3)) {
|
|
274
|
+
if (nUnique >= 2 && nUnique <= 8) {
|
|
275
|
+
groups[col] = [...new Set(rows.map(r => String(r[col])).filter(Boolean))].sort();
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const nCodes = codeCols.length;
|
|
281
|
+
const hasUnits = unitCands.length > 0 && unitCands[0][0] >= 0.3;
|
|
282
|
+
const hasConvo = convoCands.length > 0 && convoCands[0][0] >= 0.2;
|
|
283
|
+
|
|
284
|
+
let recommended, confidence, reason;
|
|
285
|
+
if (nCodes >= 3 && hasUnits && hasConvo) {
|
|
286
|
+
recommended = true;
|
|
287
|
+
confidence = nCodes >= 5 && unitCands[0][0] >= 0.7 ? 'high' : 'medium';
|
|
288
|
+
reason = `Found ${nCodes} binary code columns, a likely unit column ` +
|
|
289
|
+
`('${unitCands[0][1]}', ${unitCands[0][2]} unique values), and a likely ` +
|
|
290
|
+
`conversation column ('${convoCands[0][1]}', ${convoCands[0][2]} unique values).`;
|
|
291
|
+
} else if (nCodes >= 2 && (hasUnits || hasConvo)) {
|
|
292
|
+
recommended = true;
|
|
293
|
+
confidence = 'low';
|
|
294
|
+
reason = `Found ${nCodes} binary code column(s) and partial structure. ENA may be applicable.`;
|
|
295
|
+
} else if (nCodes >= 2) {
|
|
296
|
+
recommended = true;
|
|
297
|
+
confidence = 'low';
|
|
298
|
+
reason = `Found ${nCodes} binary code columns but no clear unit/conversation columns by name.`;
|
|
299
|
+
} else if (nCodes === 1) {
|
|
300
|
+
recommended = false;
|
|
301
|
+
confidence = 'low';
|
|
302
|
+
reason = `Only 1 binary code column was detected. ENA needs at least 2 (ideally 4+).`;
|
|
303
|
+
} else {
|
|
304
|
+
recommended = false;
|
|
305
|
+
confidence = 'low';
|
|
306
|
+
reason = `No binary (0/1) code columns were detected. ENA requires presence/absence code columns.`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
recommended,
|
|
311
|
+
confidence,
|
|
312
|
+
reason,
|
|
313
|
+
nRows,
|
|
314
|
+
nCols: headers.length,
|
|
315
|
+
codes: codeCols,
|
|
316
|
+
units,
|
|
317
|
+
conversations,
|
|
318
|
+
groups,
|
|
319
|
+
columns,
|
|
320
|
+
suggestedUnits,
|
|
321
|
+
suggestedConversations,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default detectParams;
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Top-level fields (= R's set$...):
|
|
14
14
|
* connectionCounts Float64Array (nUnits × nConnections) — raw accumulation
|
|
15
|
+
* rowConnectionCounts Float64Array (nRows × nConnections) — per-row raw accumulation
|
|
15
16
|
* lineWeights Float64Array (nUnits × nConnections) — sphere-normed
|
|
16
17
|
* points Float64Array (nUnits × dims) — projected positions
|
|
17
18
|
* rotationMatrix Float64Array (nConnections × dims) — rotation vectors
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
* dims number
|
|
24
25
|
*
|
|
25
26
|
* model sub-object (= R's set$model$...):
|
|
27
|
+
* model.rowConnectionCounts Float64Array (nRows × nConnections) — per-row raw accumulation
|
|
26
28
|
* model.centroids Float64Array (nUnits × dims) — LWS centroids
|
|
27
29
|
* model.variance number[] variance explained per dim
|
|
28
30
|
* model.unitLabels string[]
|
|
@@ -41,12 +43,16 @@
|
|
|
41
43
|
import loadLibQE from '@qe-libs/libqe-wasm';
|
|
42
44
|
import { parseData } from './data.js';
|
|
43
45
|
import {
|
|
44
|
-
accumulate, sphereNorm, center,
|
|
45
|
-
rotateSVD, rotateMeans,
|
|
46
|
-
project, nodePositions,
|
|
46
|
+
accumulate, accumulateWithRows, sphereNorm, center,
|
|
47
|
+
rotateSVD, rotateMeans, rotateGeneralized,
|
|
48
|
+
project, nodePositions, spaceDistCorr,
|
|
47
49
|
} from './pipeline.js';
|
|
48
50
|
import { accumulateTensor, defaultTensor } from './tensor.js';
|
|
49
51
|
|
|
52
|
+
// Heuristic model-parameter detection (units / conversations / codes).
|
|
53
|
+
// Pure JS — re-exported synchronously so callers don't need to load WASM.
|
|
54
|
+
export { detectParams, isBinary, scoreUnit, scoreConvo } from './detect.js';
|
|
55
|
+
|
|
50
56
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
51
57
|
|
|
52
58
|
/**
|
|
@@ -103,6 +109,7 @@ class ENAModel {
|
|
|
103
109
|
constructor(opts) {
|
|
104
110
|
// ── top-level fields (= R's set$...) ────────────────────────────────
|
|
105
111
|
this.connectionCounts = opts.connectionCounts; // raw networks
|
|
112
|
+
this.rowConnectionCounts = opts.rowConnectionCounts;
|
|
106
113
|
this.lineWeights = opts.lineWeights; // sphere-normed networks
|
|
107
114
|
this.points = opts.points; // projected unit positions
|
|
108
115
|
this.rotationMatrix = opts.rotationMatrix; // n_connections × dims
|
|
@@ -114,6 +121,7 @@ class ENAModel {
|
|
|
114
121
|
|
|
115
122
|
// ── model sub-object (= R's set$model$...) ───────────────────────────
|
|
116
123
|
this.model = {
|
|
124
|
+
rowConnectionCounts: opts.rowConnectionCounts,
|
|
117
125
|
centroids: opts.centroids, // LWS positions
|
|
118
126
|
variance: opts.variance, // variance explained
|
|
119
127
|
unitLabels: opts.unitLabels,
|
|
@@ -182,7 +190,8 @@ class ENAModel {
|
|
|
182
190
|
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
183
191
|
|
|
184
192
|
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
185
|
-
metaData, rotMethod, groupA, groupB, dims
|
|
193
|
+
metaData, rotMethod, groupA, groupB, dims, gParams,
|
|
194
|
+
rowConnectionCounts = null) {
|
|
186
195
|
const connectionNames = qe.connection_names(codes);
|
|
187
196
|
|
|
188
197
|
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
@@ -195,7 +204,12 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
195
204
|
|
|
196
205
|
// Rotate
|
|
197
206
|
let rot;
|
|
198
|
-
if (rotMethod === '
|
|
207
|
+
if (rotMethod === 'generalized') {
|
|
208
|
+
if (!gParams) throw new Error(
|
|
209
|
+
'opts.gParams is required for generalized (GMR) rotation'
|
|
210
|
+
);
|
|
211
|
+
rot = rotateGeneralized(qe, pointsForProjection, nUnits, nConnections, gParams);
|
|
212
|
+
} else if (rotMethod === 'mean') {
|
|
199
213
|
if (!groupA || !groupB) throw new Error(
|
|
200
214
|
'opts.groupA and opts.groupB are required for means rotation'
|
|
201
215
|
);
|
|
@@ -234,6 +248,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
234
248
|
return new ENAModel({
|
|
235
249
|
// top-level
|
|
236
250
|
connectionCounts: rawNetworks,
|
|
251
|
+
rowConnectionCounts,
|
|
237
252
|
lineWeights,
|
|
238
253
|
points,
|
|
239
254
|
rotationMatrix,
|
|
@@ -267,7 +282,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
267
282
|
export default async function loadENA() {
|
|
268
283
|
const qe = await loadLibQE();
|
|
269
284
|
|
|
270
|
-
|
|
285
|
+
const api = {
|
|
271
286
|
/**
|
|
272
287
|
* Run the full ENA pipeline.
|
|
273
288
|
*
|
|
@@ -280,9 +295,10 @@ export default async function loadENA() {
|
|
|
280
295
|
* @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
|
|
281
296
|
* @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
|
|
282
297
|
* @param {object} [opts.tensor] - Context tensor definition
|
|
283
|
-
* @param {string} [opts.rotation='svd'] - 'svd' or '
|
|
298
|
+
* @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
|
|
284
299
|
* @param {number[]} [opts.groupA] - Unit indices for means rotation group A
|
|
285
300
|
* @param {number[]} [opts.groupB] - Unit indices for means rotation group B
|
|
301
|
+
* @param {object} [opts.gParams] - Pre-built GMR parameters for 'generalized' rotation
|
|
286
302
|
* @param {number} [opts.dims=2] - Number of dimensions to return
|
|
287
303
|
*
|
|
288
304
|
* @returns {ENAModel}
|
|
@@ -299,7 +315,9 @@ export default async function loadENA() {
|
|
|
299
315
|
rotation: rotMethod = 'svd',
|
|
300
316
|
groupA,
|
|
301
317
|
groupB,
|
|
318
|
+
gParams,
|
|
302
319
|
dims = 2,
|
|
320
|
+
codeMask,
|
|
303
321
|
} = opts;
|
|
304
322
|
|
|
305
323
|
if (!codes?.length) throw new Error('opts.codes is required');
|
|
@@ -310,7 +328,7 @@ export default async function loadENA() {
|
|
|
310
328
|
unitOf, convoGroups, metaData } =
|
|
311
329
|
parseData(rows, codes, units, conversations);
|
|
312
330
|
|
|
313
|
-
let rawNetworks, nConnections;
|
|
331
|
+
let rawNetworks, rowConnectionCounts = null, nConnections;
|
|
314
332
|
|
|
315
333
|
if (tensorDef) {
|
|
316
334
|
rawNetworks = accumulateTensor(
|
|
@@ -319,15 +337,47 @@ export default async function loadENA() {
|
|
|
319
337
|
);
|
|
320
338
|
nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
321
339
|
} else {
|
|
322
|
-
|
|
340
|
+
const accumulated = accumulateWithRows(
|
|
323
341
|
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
324
342
|
unitOf, convoGroups, windowSize, binary
|
|
325
343
|
);
|
|
344
|
+
rawNetworks = accumulated.networks;
|
|
345
|
+
rowConnectionCounts = accumulated.rowConnectionCounts;
|
|
326
346
|
nConnections = qe.choose_two(nCodes);
|
|
327
347
|
}
|
|
328
348
|
|
|
349
|
+
// Apply code masking by zeroing out the masked connection columns across all units
|
|
350
|
+
if (codeMask && codeMask.length === codes.length) {
|
|
351
|
+
console.log('[rena-wasm] Applying code mask to raw networks...');
|
|
352
|
+
if (ordered) {
|
|
353
|
+
for (let j = 0; j < codes.length; j++) {
|
|
354
|
+
for (let i = 0; i < codes.length; i++) {
|
|
355
|
+
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
356
|
+
const k = i * codes.length + j;
|
|
357
|
+
for (let u = 0; u < nUnits; u++) {
|
|
358
|
+
rawNetworks[u * nConnections + k] = 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
let k = 0;
|
|
365
|
+
for (let col = 1; col < codes.length; col++) {
|
|
366
|
+
for (let row = 0; row < col; row++) {
|
|
367
|
+
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
368
|
+
for (let u = 0; u < nUnits; u++) {
|
|
369
|
+
rawNetworks[u * nConnections + k] = 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
k++;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
329
378
|
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
330
|
-
unitLabels, metaData, rotMethod, groupA, groupB,
|
|
379
|
+
unitLabels, metaData, rotMethod, groupA, groupB,
|
|
380
|
+
dims, gParams, rowConnectionCounts);
|
|
331
381
|
},
|
|
332
382
|
|
|
333
383
|
/**
|
|
@@ -338,11 +388,13 @@ export default async function loadENA() {
|
|
|
338
388
|
* @param {object} opts - codes, units, conversations, window, binary, ordered, tensor
|
|
339
389
|
* @returns {{
|
|
340
390
|
* connectionCounts: Float64Array,
|
|
391
|
+
* rowConnectionCounts: Float64Array | null,
|
|
341
392
|
* unitLabels: string[],
|
|
342
393
|
* connectionNames: string[],
|
|
343
394
|
* metaData: Object[],
|
|
344
395
|
* nUnits: number,
|
|
345
396
|
* nConnections: number,
|
|
397
|
+
* _call: object, // args used to build this accumulation (for tuneWindowSize)
|
|
346
398
|
* }}
|
|
347
399
|
*/
|
|
348
400
|
accumulate(rows, opts = {}) {
|
|
@@ -352,13 +404,14 @@ export default async function loadENA() {
|
|
|
352
404
|
binary = true,
|
|
353
405
|
ordered = false,
|
|
354
406
|
tensor: tensorDef,
|
|
407
|
+
codeMask,
|
|
355
408
|
} = opts;
|
|
356
409
|
|
|
357
410
|
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
358
411
|
unitOf, convoGroups, metaData } =
|
|
359
412
|
parseData(rows, codes, units, conversations);
|
|
360
413
|
|
|
361
|
-
let networks, nConnections;
|
|
414
|
+
let networks, rowConnectionCounts = null, nConnections;
|
|
362
415
|
|
|
363
416
|
if (tensorDef) {
|
|
364
417
|
networks = accumulateTensor(
|
|
@@ -367,16 +420,131 @@ export default async function loadENA() {
|
|
|
367
420
|
);
|
|
368
421
|
nConnections = ordered ? nCodes * nCodes : qe.choose_two(nCodes);
|
|
369
422
|
} else {
|
|
370
|
-
|
|
423
|
+
const accumulated = accumulateWithRows(
|
|
371
424
|
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
372
425
|
unitOf, convoGroups, windowSize, binary
|
|
373
426
|
);
|
|
427
|
+
networks = accumulated.networks;
|
|
428
|
+
rowConnectionCounts = accumulated.rowConnectionCounts;
|
|
374
429
|
nConnections = qe.choose_two(nCodes);
|
|
375
430
|
}
|
|
376
431
|
|
|
432
|
+
// Apply code masking by zeroing out the masked connection columns across all units
|
|
433
|
+
if (codeMask && codeMask.length === codes.length) {
|
|
434
|
+
console.log('[rena-wasm] Applying code mask to accumulated networks...');
|
|
435
|
+
if (ordered) {
|
|
436
|
+
for (let j = 0; j < codes.length; j++) {
|
|
437
|
+
for (let i = 0; i < codes.length; i++) {
|
|
438
|
+
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
439
|
+
const k = i * codes.length + j;
|
|
440
|
+
for (let u = 0; u < nUnits; u++) {
|
|
441
|
+
networks[u * nConnections + k] = 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
let k = 0;
|
|
448
|
+
for (let col = 1; col < codes.length; col++) {
|
|
449
|
+
for (let row = 0; row < col; row++) {
|
|
450
|
+
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
451
|
+
for (let u = 0; u < nUnits; u++) {
|
|
452
|
+
networks[u * nConnections + k] = 0;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
k++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
377
461
|
const connectionNames = qe.connection_names(codes);
|
|
378
|
-
return {
|
|
379
|
-
|
|
462
|
+
return {
|
|
463
|
+
connectionCounts: networks, rowConnectionCounts, unitLabels, connectionNames,
|
|
464
|
+
metaData, nUnits, nConnections,
|
|
465
|
+
// Retained so tuneWindowSize() can rebuild at other window sizes
|
|
466
|
+
// (= R's ENAAccumulation$`_function.call`).
|
|
467
|
+
_call: { rows, codes, units, conversations,
|
|
468
|
+
window: windowSize, binary, ordered, tensor: tensorDef },
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Tune the stanza window size (= R's ena.tune.window.size).
|
|
474
|
+
*
|
|
475
|
+
* Rebuilds the accumulation and fits a default SVD model for every
|
|
476
|
+
* window from `minSize` to `maxSize`, correlates the unit-distance
|
|
477
|
+
* geometry of adjacent window sizes via spaceDistCorr, and selects the
|
|
478
|
+
* smallest window whose adjacent correlation reaches
|
|
479
|
+
* `cutoff * max(correlation)`. Mirrors R by returning a fresh
|
|
480
|
+
* accumulation rebuilt at the selected window size.
|
|
481
|
+
*
|
|
482
|
+
* @param {object} accum - an accumulation returned by accumulate()
|
|
483
|
+
* @param {object} [opts]
|
|
484
|
+
* @param {number} [opts.minSize=1]
|
|
485
|
+
* @param {number} [opts.maxSize=20]
|
|
486
|
+
* @param {number} [opts.cutoff=0.95]
|
|
487
|
+
* @returns {object} accumulation rebuilt at the selected window
|
|
488
|
+
* (selected size available as result._call.window)
|
|
489
|
+
*/
|
|
490
|
+
tuneWindowSize(accum, opts = {}) {
|
|
491
|
+
const { minSize = 1, maxSize = 20, cutoff = 0.95 } = opts;
|
|
492
|
+
const call = accum && accum._call;
|
|
493
|
+
if (!call) throw new Error(
|
|
494
|
+
'accum has no stored _call; build it with accumulate() to enable tuning.'
|
|
495
|
+
);
|
|
496
|
+
if (call.tensor) throw new Error(
|
|
497
|
+
'window-size tuning is only supported for the simple (window) accumulation path.'
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const windowRange = [];
|
|
501
|
+
for (let w = minSize; w <= maxSize; w++) windowRange.push(w);
|
|
502
|
+
if (windowRange.length < 2) throw new Error(
|
|
503
|
+
'maxSize must be greater than minSize to compare windows.'
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const { rows, codes, units, conversations, binary } = call;
|
|
507
|
+
|
|
508
|
+
// Parse once; only the window size changes between iterations.
|
|
509
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
510
|
+
unitOf, convoGroups, metaData } =
|
|
511
|
+
parseData(rows, codes, units, conversations);
|
|
512
|
+
const nConnections = qe.choose_two(nCodes);
|
|
513
|
+
|
|
514
|
+
// 1. Rebuild + fit at each window, collecting unit points.
|
|
515
|
+
const dims = 2;
|
|
516
|
+
const allPoints = [];
|
|
517
|
+
for (const w of windowRange) {
|
|
518
|
+
const raw = accumulate(
|
|
519
|
+
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
520
|
+
unitOf, convoGroups, w, binary
|
|
521
|
+
);
|
|
522
|
+
const model = runPipeline(
|
|
523
|
+
qe, raw, nUnits, nConnections, codes,
|
|
524
|
+
unitLabels, metaData, 'svd', undefined, undefined, dims
|
|
525
|
+
);
|
|
526
|
+
allPoints.push(model.points);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 2. Adjacent-window distance-space correlations.
|
|
530
|
+
const nSteps = windowRange.length - 1;
|
|
531
|
+
const corr = new Array(nSteps);
|
|
532
|
+
for (let i = 0; i < nSteps; i++) {
|
|
533
|
+
corr[i] = spaceDistCorr(allPoints[i], allPoints[i + 1], nUnits, dims);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 3. Smallest window crossing cutoff * max correlation.
|
|
537
|
+
const finite = corr.filter(v => !Number.isNaN(v));
|
|
538
|
+
const maxCorr = finite.length ? Math.max(...finite) : NaN;
|
|
539
|
+
const threshold = cutoff * maxCorr;
|
|
540
|
+
let bestIdx = corr.findIndex(v => v >= threshold);
|
|
541
|
+
if (bestIdx < 0) bestIdx = 0;
|
|
542
|
+
const bestWindow = windowRange[bestIdx];
|
|
543
|
+
|
|
544
|
+
// 4. Rebuild the accumulation at the selected window size.
|
|
545
|
+
return api.accumulate(rows, {
|
|
546
|
+
codes, units, conversations, window: bestWindow, binary,
|
|
547
|
+
});
|
|
380
548
|
},
|
|
381
549
|
|
|
382
550
|
/**
|
|
@@ -430,4 +598,6 @@ export default async function loadENA() {
|
|
|
430
598
|
*/
|
|
431
599
|
defaultTensor,
|
|
432
600
|
};
|
|
601
|
+
|
|
602
|
+
return api;
|
|
433
603
|
}
|
package/src/pipeline.js
CHANGED
|
@@ -35,6 +35,87 @@ export function sliceCols(data, nRows, nCols, nDims) {
|
|
|
35
35
|
return out;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Pearson correlation of two paired numeric vectors.
|
|
40
|
+
* Returns NaN if either vector has zero variance.
|
|
41
|
+
*/
|
|
42
|
+
function pearson(x, y) {
|
|
43
|
+
const n = x.length;
|
|
44
|
+
if (n === 0) return NaN;
|
|
45
|
+
let mx = 0, my = 0;
|
|
46
|
+
for (let i = 0; i < n; i++) { mx += x[i]; my += y[i]; }
|
|
47
|
+
mx /= n; my /= n;
|
|
48
|
+
let sxy = 0, sxx = 0, syy = 0;
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const dx = x[i] - mx, dy = y[i] - my;
|
|
51
|
+
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
|
|
52
|
+
}
|
|
53
|
+
const denom = Math.sqrt(sxx * syy);
|
|
54
|
+
return denom === 0 ? NaN : sxy / denom;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pearson correlation between the pairwise distances of two ENA spaces.
|
|
59
|
+
*
|
|
60
|
+
* Computes the Euclidean distance between every pair of points within `A` and
|
|
61
|
+
* within `B` (same pairing for both), then correlates the two distance vectors.
|
|
62
|
+
* Because pairwise distances are invariant to rotation/reflection of a space,
|
|
63
|
+
* this measures configuration similarity up to an orthogonal transform — the
|
|
64
|
+
* right tool for comparing ENA solutions across window sizes whose SVD axes may
|
|
65
|
+
* flip sign. Mirrors R's ena_space_dist_corr: exact for small spaces, sampled
|
|
66
|
+
* (with replacement, self-pairs dropped) once unique pairs exceed the limit.
|
|
67
|
+
*
|
|
68
|
+
* @param {Float64Array} A m × d, row-major
|
|
69
|
+
* @param {Float64Array} B m × d, row-major
|
|
70
|
+
* @param {number} m number of points (rows)
|
|
71
|
+
* @param {number} d dimensions (cols)
|
|
72
|
+
* @param {number} [maxSampleSize=100000]
|
|
73
|
+
* @param {() => number} [rand=Math.random] RNG for the sampled path
|
|
74
|
+
* @returns {number} Pearson correlation of the paired distance vectors
|
|
75
|
+
*/
|
|
76
|
+
export function spaceDistCorr(A, B, m, d, maxSampleSize = 100000, rand = Math.random) {
|
|
77
|
+
if (!m || m === 0) throw new Error('The spaces must have a non-zero number of rows.');
|
|
78
|
+
|
|
79
|
+
const dist = (M, p, q) => {
|
|
80
|
+
let s = 0;
|
|
81
|
+
for (let c = 0; c < d; c++) {
|
|
82
|
+
const diff = M[p * d + c] - M[q * d + c];
|
|
83
|
+
s += diff * diff;
|
|
84
|
+
}
|
|
85
|
+
return Math.sqrt(s);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const totalPairs = (m * (m - 1)) / 2;
|
|
89
|
+
|
|
90
|
+
let distA, distB;
|
|
91
|
+
if (totalPairs <= maxSampleSize) {
|
|
92
|
+
// Exact: all unique i<j pairs.
|
|
93
|
+
distA = new Float64Array(totalPairs);
|
|
94
|
+
distB = new Float64Array(totalPairs);
|
|
95
|
+
let k = 0;
|
|
96
|
+
for (let i = 0; i < m; i++)
|
|
97
|
+
for (let j = i + 1; j < m; j++) {
|
|
98
|
+
distA[k] = dist(A, i, j);
|
|
99
|
+
distB[k] = dist(B, i, j);
|
|
100
|
+
k++;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Sample pairs with replacement, drop self-pairs.
|
|
104
|
+
const a = [], b = [];
|
|
105
|
+
for (let s = 0; s < maxSampleSize; s++) {
|
|
106
|
+
const i = Math.floor(rand() * m);
|
|
107
|
+
const j = Math.floor(rand() * m);
|
|
108
|
+
if (i === j) continue;
|
|
109
|
+
a.push(dist(A, i, j));
|
|
110
|
+
b.push(dist(B, i, j));
|
|
111
|
+
}
|
|
112
|
+
distA = Float64Array.from(a);
|
|
113
|
+
distB = Float64Array.from(b);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return pearson(distA, distB);
|
|
117
|
+
}
|
|
118
|
+
|
|
38
119
|
// ── accumulation ─────────────────────────────────────────────────────────────
|
|
39
120
|
|
|
40
121
|
/**
|
|
@@ -93,6 +174,51 @@ export function accumulate(qe, codeMatrix, nRows, nCodes, nUnits,
|
|
|
93
174
|
return networks;
|
|
94
175
|
}
|
|
95
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Accumulate windowed co-occurrences and retain both per-unit and per-row
|
|
179
|
+
* connection vectors.
|
|
180
|
+
*
|
|
181
|
+
* @returns {{ networks: Float64Array, rowConnectionCounts: Float64Array }}
|
|
182
|
+
*/
|
|
183
|
+
export function accumulateWithRows(qe, codeMatrix, nRows, nCodes, nUnits,
|
|
184
|
+
unitOf, convoGroups, windowSize = 4, binary = true) {
|
|
185
|
+
const nConnections = qe.choose_two(nCodes);
|
|
186
|
+
const networks = new Float64Array(nUnits * nConnections);
|
|
187
|
+
const rowConnectionCounts = new Float64Array(nRows * nConnections);
|
|
188
|
+
|
|
189
|
+
for (const [, rowIndices] of convoGroups) {
|
|
190
|
+
const nConvo = rowIndices.length;
|
|
191
|
+
|
|
192
|
+
const convoCodes = new Float64Array(nConvo * nCodes);
|
|
193
|
+
for (let r = 0; r < nConvo; r++) {
|
|
194
|
+
const src = rowIndices[r];
|
|
195
|
+
convoCodes.set(
|
|
196
|
+
codeMatrix.subarray(src * nCodes, src * nCodes + nCodes),
|
|
197
|
+
r * nCodes
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const stanza = qe.accumulate_stanza(
|
|
202
|
+
convoCodes, nConvo, nCodes, windowSize, 0, binary
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
for (let r = 0; r < nConvo; r++) {
|
|
206
|
+
const src = rowIndices[r];
|
|
207
|
+
const unit = unitOf[src];
|
|
208
|
+
const offset = r * nConnections;
|
|
209
|
+
const rowOut = src * nConnections;
|
|
210
|
+
|
|
211
|
+
for (let c = 0; c < nConnections; c++) {
|
|
212
|
+
const value = stanza.data[offset + c];
|
|
213
|
+
rowConnectionCounts[rowOut + c] = value;
|
|
214
|
+
networks[unit * nConnections + c] += value;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { networks, rowConnectionCounts };
|
|
220
|
+
}
|
|
221
|
+
|
|
96
222
|
// ── normalization (sphere norm) ───────────────────────────────────────────────
|
|
97
223
|
|
|
98
224
|
/**
|
|
@@ -181,6 +307,89 @@ export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB)
|
|
|
181
307
|
};
|
|
182
308
|
}
|
|
183
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Generalized Means Rotation (GMR).
|
|
312
|
+
*
|
|
313
|
+
* Mirrors R's ena.rotate.by.generalized() / libqe::generalized_means_rotation().
|
|
314
|
+
* Handles Lasso-adjusted OLS covariate control, between-group scatter for
|
|
315
|
+
* categorical targets, optional Y axis, and SVD completion.
|
|
316
|
+
*
|
|
317
|
+
* All matrix inputs are row-major Float64Arrays; all index arrays are Int32Arrays.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} qe
|
|
320
|
+
* @param {Float64Array} centered nUnits × nConnections, row-major
|
|
321
|
+
* @param {number} nUnits
|
|
322
|
+
* @param {number} nConnections
|
|
323
|
+
* @param {object} p Pre-built GMR parameters
|
|
324
|
+
* @param {Float64Array} p.xModelMatrix nUnits × xmCols row-major model matrix
|
|
325
|
+
* (treatment coding, reference = level[0])
|
|
326
|
+
* @param {number} p.xmRows must equal nUnits
|
|
327
|
+
* @param {number} p.xmCols number of dummy columns (nGroups - 1)
|
|
328
|
+
* @param {Float64Array} p.xTarget nUnits — 0-based integer codes for target
|
|
329
|
+
* @param {Int32Array} p.x1Cols 0-based column indices in xModelMatrix for the target
|
|
330
|
+
* @param {boolean} p.xCategorical
|
|
331
|
+
* @param {number} p.xNGroups number of distinct levels
|
|
332
|
+
* @param {Int32Array} p.xSubset 0-based unit-row indices used for GMR fit
|
|
333
|
+
* @param {boolean} [p.hasY=false] whether a Y-axis GMR target is provided
|
|
334
|
+
* @param {Float64Array} [p.yModelMatrix] (ignored when hasY=false)
|
|
335
|
+
* @param {number} [p.ymRows]
|
|
336
|
+
* @param {number} [p.ymCols]
|
|
337
|
+
* @param {Float64Array} [p.yTarget]
|
|
338
|
+
* @param {Int32Array} [p.y1Cols]
|
|
339
|
+
* @param {boolean} [p.yCategorical=false]
|
|
340
|
+
* @param {number} [p.yNGroups=0]
|
|
341
|
+
* @param {number} [p.nLambda=50]
|
|
342
|
+
* @param {number} [p.kFolds=5]
|
|
343
|
+
* @param {number} [p.lassoEps=0.01]
|
|
344
|
+
* @returns {{ rotation, rotRows, rotCols, eigenvalues, columnNames }}
|
|
345
|
+
*/
|
|
346
|
+
export function rotateGeneralized(qe, centered, nUnits, nConnections, p) {
|
|
347
|
+
const hasY = !!p.hasY;
|
|
348
|
+
const nLambda = (p.nLambda ?? 50) | 0;
|
|
349
|
+
const kFolds = (p.kFolds ?? 5) | 0;
|
|
350
|
+
const lassoEps = p.lassoEps ?? 0.01;
|
|
351
|
+
|
|
352
|
+
// Stub Y params when hasY=false — C++ ignores them but still needs valid arrays.
|
|
353
|
+
const yMM = hasY ? p.yModelMatrix : new Float64Array(nUnits);
|
|
354
|
+
const ymR = hasY ? (p.ymRows | 0) : nUnits;
|
|
355
|
+
const ymC = hasY ? (p.ymCols | 0) : 1;
|
|
356
|
+
const yTgt = hasY ? p.yTarget : new Float64Array(nUnits);
|
|
357
|
+
const y1C = hasY ? p.y1Cols : new Int32Array([0]);
|
|
358
|
+
const yCat = hasY ? !!p.yCategorical : false;
|
|
359
|
+
const yNGrp = hasY ? ((p.yNGroups || 0) | 0) : 0;
|
|
360
|
+
|
|
361
|
+
let r;
|
|
362
|
+
try {
|
|
363
|
+
r = qe.generalized_means_rotation(
|
|
364
|
+
centered, nUnits, nConnections,
|
|
365
|
+
p.xModelMatrix, p.xmRows | 0, p.xmCols | 0,
|
|
366
|
+
p.xTarget,
|
|
367
|
+
p.x1Cols,
|
|
368
|
+
!!p.xCategorical, (p.xNGroups | 0),
|
|
369
|
+
p.xSubset,
|
|
370
|
+
hasY,
|
|
371
|
+
yMM, ymR, ymC,
|
|
372
|
+
yTgt,
|
|
373
|
+
y1C,
|
|
374
|
+
yCat, yNGrp,
|
|
375
|
+
nLambda, kFolds, lassoEps
|
|
376
|
+
);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
'Rotation by Regression failed: the target variable or group selection ' +
|
|
380
|
+
'has zero variance or insufficient rank across units.'
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
rotation: r.rotation.data,
|
|
386
|
+
rotRows: r.rotation.rows,
|
|
387
|
+
rotCols: r.rotation.cols,
|
|
388
|
+
eigenvalues: r.eigenvalues,
|
|
389
|
+
columnNames: r.column_names,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
184
393
|
// ── projection & node positions ───────────────────────────────────────────────
|
|
185
394
|
|
|
186
395
|
/**
|