@qe-libs/rena-wasm 0.1.0 → 0.1.1
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/package.json +7 -2
- package/src/detect.js +325 -0
- package/src/index.js +167 -9
- package/src/pipeline.js +164 -0
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.1",
|
|
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
|
@@ -42,11 +42,15 @@ import loadLibQE from '@qe-libs/libqe-wasm';
|
|
|
42
42
|
import { parseData } from './data.js';
|
|
43
43
|
import {
|
|
44
44
|
accumulate, sphereNorm, center,
|
|
45
|
-
rotateSVD, rotateMeans,
|
|
46
|
-
project, nodePositions,
|
|
45
|
+
rotateSVD, rotateMeans, rotateGeneralized,
|
|
46
|
+
project, nodePositions, spaceDistCorr,
|
|
47
47
|
} from './pipeline.js';
|
|
48
48
|
import { accumulateTensor, defaultTensor } from './tensor.js';
|
|
49
49
|
|
|
50
|
+
// Heuristic model-parameter detection (units / conversations / codes).
|
|
51
|
+
// Pure JS — re-exported synchronously so callers don't need to load WASM.
|
|
52
|
+
export { detectParams, isBinary, scoreUnit, scoreConvo } from './detect.js';
|
|
53
|
+
|
|
50
54
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -182,7 +186,7 @@ class ENAModel {
|
|
|
182
186
|
// ── shared pipeline (post-accumulation) ──────────────────────────────────────
|
|
183
187
|
|
|
184
188
|
function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
185
|
-
metaData, rotMethod, groupA, groupB, dims) {
|
|
189
|
+
metaData, rotMethod, groupA, groupB, dims, gParams) {
|
|
186
190
|
const connectionNames = qe.connection_names(codes);
|
|
187
191
|
|
|
188
192
|
// Sphere norm → lineWeights (= R's set$line.weights)
|
|
@@ -195,7 +199,12 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
195
199
|
|
|
196
200
|
// Rotate
|
|
197
201
|
let rot;
|
|
198
|
-
if (rotMethod === '
|
|
202
|
+
if (rotMethod === 'generalized') {
|
|
203
|
+
if (!gParams) throw new Error(
|
|
204
|
+
'opts.gParams is required for generalized (GMR) rotation'
|
|
205
|
+
);
|
|
206
|
+
rot = rotateGeneralized(qe, pointsForProjection, nUnits, nConnections, gParams);
|
|
207
|
+
} else if (rotMethod === 'mean') {
|
|
199
208
|
if (!groupA || !groupB) throw new Error(
|
|
200
209
|
'opts.groupA and opts.groupB are required for means rotation'
|
|
201
210
|
);
|
|
@@ -267,7 +276,7 @@ function runPipeline(qe, rawNetworks, nUnits, nConnections, codes, unitLabels,
|
|
|
267
276
|
export default async function loadENA() {
|
|
268
277
|
const qe = await loadLibQE();
|
|
269
278
|
|
|
270
|
-
|
|
279
|
+
const api = {
|
|
271
280
|
/**
|
|
272
281
|
* Run the full ENA pipeline.
|
|
273
282
|
*
|
|
@@ -280,9 +289,10 @@ export default async function loadENA() {
|
|
|
280
289
|
* @param {boolean} [opts.binary=true] - Binarise co-occurrences (simple path only)
|
|
281
290
|
* @param {boolean} [opts.ordered=false] - Directed networks (tensor path only)
|
|
282
291
|
* @param {object} [opts.tensor] - Context tensor definition
|
|
283
|
-
* @param {string} [opts.rotation='svd'] - 'svd' or '
|
|
292
|
+
* @param {string} [opts.rotation='svd'] - 'svd', 'mean', or 'generalized'
|
|
284
293
|
* @param {number[]} [opts.groupA] - Unit indices for means rotation group A
|
|
285
294
|
* @param {number[]} [opts.groupB] - Unit indices for means rotation group B
|
|
295
|
+
* @param {object} [opts.gParams] - Pre-built GMR parameters for 'generalized' rotation
|
|
286
296
|
* @param {number} [opts.dims=2] - Number of dimensions to return
|
|
287
297
|
*
|
|
288
298
|
* @returns {ENAModel}
|
|
@@ -299,7 +309,9 @@ export default async function loadENA() {
|
|
|
299
309
|
rotation: rotMethod = 'svd',
|
|
300
310
|
groupA,
|
|
301
311
|
groupB,
|
|
312
|
+
gParams,
|
|
302
313
|
dims = 2,
|
|
314
|
+
codeMask,
|
|
303
315
|
} = opts;
|
|
304
316
|
|
|
305
317
|
if (!codes?.length) throw new Error('opts.codes is required');
|
|
@@ -326,8 +338,37 @@ export default async function loadENA() {
|
|
|
326
338
|
nConnections = qe.choose_two(nCodes);
|
|
327
339
|
}
|
|
328
340
|
|
|
341
|
+
// Apply code masking by zeroing out the masked connection columns across all units
|
|
342
|
+
if (codeMask && codeMask.length === codes.length) {
|
|
343
|
+
console.log('[rena-wasm] Applying code mask to raw networks...');
|
|
344
|
+
if (ordered) {
|
|
345
|
+
for (let j = 0; j < codes.length; j++) {
|
|
346
|
+
for (let i = 0; i < codes.length; i++) {
|
|
347
|
+
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
348
|
+
const k = i * codes.length + j;
|
|
349
|
+
for (let u = 0; u < nUnits; u++) {
|
|
350
|
+
rawNetworks[u * nConnections + k] = 0;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
let k = 0;
|
|
357
|
+
for (let col = 1; col < codes.length; col++) {
|
|
358
|
+
for (let row = 0; row < col; row++) {
|
|
359
|
+
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
360
|
+
for (let u = 0; u < nUnits; u++) {
|
|
361
|
+
rawNetworks[u * nConnections + k] = 0;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
k++;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
329
370
|
return runPipeline(qe, rawNetworks, nUnits, nConnections, codes,
|
|
330
|
-
unitLabels, metaData, rotMethod, groupA, groupB, dims);
|
|
371
|
+
unitLabels, metaData, rotMethod, groupA, groupB, dims, gParams);
|
|
331
372
|
},
|
|
332
373
|
|
|
333
374
|
/**
|
|
@@ -343,6 +384,7 @@ export default async function loadENA() {
|
|
|
343
384
|
* metaData: Object[],
|
|
344
385
|
* nUnits: number,
|
|
345
386
|
* nConnections: number,
|
|
387
|
+
* _call: object, // args used to build this accumulation (for tuneWindowSize)
|
|
346
388
|
* }}
|
|
347
389
|
*/
|
|
348
390
|
accumulate(rows, opts = {}) {
|
|
@@ -352,6 +394,7 @@ export default async function loadENA() {
|
|
|
352
394
|
binary = true,
|
|
353
395
|
ordered = false,
|
|
354
396
|
tensor: tensorDef,
|
|
397
|
+
codeMask,
|
|
355
398
|
} = opts;
|
|
356
399
|
|
|
357
400
|
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
@@ -374,9 +417,122 @@ export default async function loadENA() {
|
|
|
374
417
|
nConnections = qe.choose_two(nCodes);
|
|
375
418
|
}
|
|
376
419
|
|
|
420
|
+
// Apply code masking by zeroing out the masked connection columns across all units
|
|
421
|
+
if (codeMask && codeMask.length === codes.length) {
|
|
422
|
+
console.log('[rena-wasm] Applying code mask to accumulated networks...');
|
|
423
|
+
if (ordered) {
|
|
424
|
+
for (let j = 0; j < codes.length; j++) {
|
|
425
|
+
for (let i = 0; i < codes.length; i++) {
|
|
426
|
+
if (codeMask[j] && codeMask[j][i] === 0) {
|
|
427
|
+
const k = i * codes.length + j;
|
|
428
|
+
for (let u = 0; u < nUnits; u++) {
|
|
429
|
+
networks[u * nConnections + k] = 0;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
let k = 0;
|
|
436
|
+
for (let col = 1; col < codes.length; col++) {
|
|
437
|
+
for (let row = 0; row < col; row++) {
|
|
438
|
+
if ((codeMask[row] && codeMask[row][col] === 0) || (codeMask[col] && codeMask[col][row] === 0)) {
|
|
439
|
+
for (let u = 0; u < nUnits; u++) {
|
|
440
|
+
networks[u * nConnections + k] = 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
k++;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
377
449
|
const connectionNames = qe.connection_names(codes);
|
|
378
|
-
return {
|
|
379
|
-
|
|
450
|
+
return {
|
|
451
|
+
connectionCounts: networks, unitLabels, connectionNames,
|
|
452
|
+
metaData, nUnits, nConnections,
|
|
453
|
+
// Retained so tuneWindowSize() can rebuild at other window sizes
|
|
454
|
+
// (= R's ENAAccumulation$`_function.call`).
|
|
455
|
+
_call: { rows, codes, units, conversations,
|
|
456
|
+
window: windowSize, binary, ordered, tensor: tensorDef },
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Tune the stanza window size (= R's ena.tune.window.size).
|
|
462
|
+
*
|
|
463
|
+
* Rebuilds the accumulation and fits a default SVD model for every
|
|
464
|
+
* window from `minSize` to `maxSize`, correlates the unit-distance
|
|
465
|
+
* geometry of adjacent window sizes via spaceDistCorr, and selects the
|
|
466
|
+
* smallest window whose adjacent correlation reaches
|
|
467
|
+
* `cutoff * max(correlation)`. Mirrors R by returning a fresh
|
|
468
|
+
* accumulation rebuilt at the selected window size.
|
|
469
|
+
*
|
|
470
|
+
* @param {object} accum - an accumulation returned by accumulate()
|
|
471
|
+
* @param {object} [opts]
|
|
472
|
+
* @param {number} [opts.minSize=1]
|
|
473
|
+
* @param {number} [opts.maxSize=20]
|
|
474
|
+
* @param {number} [opts.cutoff=0.95]
|
|
475
|
+
* @returns {object} accumulation rebuilt at the selected window
|
|
476
|
+
* (selected size available as result._call.window)
|
|
477
|
+
*/
|
|
478
|
+
tuneWindowSize(accum, opts = {}) {
|
|
479
|
+
const { minSize = 1, maxSize = 20, cutoff = 0.95 } = opts;
|
|
480
|
+
const call = accum && accum._call;
|
|
481
|
+
if (!call) throw new Error(
|
|
482
|
+
'accum has no stored _call; build it with accumulate() to enable tuning.'
|
|
483
|
+
);
|
|
484
|
+
if (call.tensor) throw new Error(
|
|
485
|
+
'window-size tuning is only supported for the simple (window) accumulation path.'
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const windowRange = [];
|
|
489
|
+
for (let w = minSize; w <= maxSize; w++) windowRange.push(w);
|
|
490
|
+
if (windowRange.length < 2) throw new Error(
|
|
491
|
+
'maxSize must be greater than minSize to compare windows.'
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const { rows, codes, units, conversations, binary } = call;
|
|
495
|
+
|
|
496
|
+
// Parse once; only the window size changes between iterations.
|
|
497
|
+
const { codeMatrix, nRows, nCodes, nUnits, unitLabels,
|
|
498
|
+
unitOf, convoGroups, metaData } =
|
|
499
|
+
parseData(rows, codes, units, conversations);
|
|
500
|
+
const nConnections = qe.choose_two(nCodes);
|
|
501
|
+
|
|
502
|
+
// 1. Rebuild + fit at each window, collecting unit points.
|
|
503
|
+
const dims = 2;
|
|
504
|
+
const allPoints = [];
|
|
505
|
+
for (const w of windowRange) {
|
|
506
|
+
const raw = accumulate(
|
|
507
|
+
qe, codeMatrix, nRows, nCodes, nUnits,
|
|
508
|
+
unitOf, convoGroups, w, binary
|
|
509
|
+
);
|
|
510
|
+
const model = runPipeline(
|
|
511
|
+
qe, raw, nUnits, nConnections, codes,
|
|
512
|
+
unitLabels, metaData, 'svd', undefined, undefined, dims
|
|
513
|
+
);
|
|
514
|
+
allPoints.push(model.points);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 2. Adjacent-window distance-space correlations.
|
|
518
|
+
const nSteps = windowRange.length - 1;
|
|
519
|
+
const corr = new Array(nSteps);
|
|
520
|
+
for (let i = 0; i < nSteps; i++) {
|
|
521
|
+
corr[i] = spaceDistCorr(allPoints[i], allPoints[i + 1], nUnits, dims);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 3. Smallest window crossing cutoff * max correlation.
|
|
525
|
+
const finite = corr.filter(v => !Number.isNaN(v));
|
|
526
|
+
const maxCorr = finite.length ? Math.max(...finite) : NaN;
|
|
527
|
+
const threshold = cutoff * maxCorr;
|
|
528
|
+
let bestIdx = corr.findIndex(v => v >= threshold);
|
|
529
|
+
if (bestIdx < 0) bestIdx = 0;
|
|
530
|
+
const bestWindow = windowRange[bestIdx];
|
|
531
|
+
|
|
532
|
+
// 4. Rebuild the accumulation at the selected window size.
|
|
533
|
+
return api.accumulate(rows, {
|
|
534
|
+
codes, units, conversations, window: bestWindow, binary,
|
|
535
|
+
});
|
|
380
536
|
},
|
|
381
537
|
|
|
382
538
|
/**
|
|
@@ -430,4 +586,6 @@ export default async function loadENA() {
|
|
|
430
586
|
*/
|
|
431
587
|
defaultTensor,
|
|
432
588
|
};
|
|
589
|
+
|
|
590
|
+
return api;
|
|
433
591
|
}
|
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
|
/**
|
|
@@ -181,6 +262,89 @@ export function rotateMeans(qe, centered, nUnits, nConnections, groupA, groupB)
|
|
|
181
262
|
};
|
|
182
263
|
}
|
|
183
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Generalized Means Rotation (GMR).
|
|
267
|
+
*
|
|
268
|
+
* Mirrors R's ena.rotate.by.generalized() / libqe::generalized_means_rotation().
|
|
269
|
+
* Handles Lasso-adjusted OLS covariate control, between-group scatter for
|
|
270
|
+
* categorical targets, optional Y axis, and SVD completion.
|
|
271
|
+
*
|
|
272
|
+
* All matrix inputs are row-major Float64Arrays; all index arrays are Int32Arrays.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} qe
|
|
275
|
+
* @param {Float64Array} centered nUnits × nConnections, row-major
|
|
276
|
+
* @param {number} nUnits
|
|
277
|
+
* @param {number} nConnections
|
|
278
|
+
* @param {object} p Pre-built GMR parameters
|
|
279
|
+
* @param {Float64Array} p.xModelMatrix nUnits × xmCols row-major model matrix
|
|
280
|
+
* (treatment coding, reference = level[0])
|
|
281
|
+
* @param {number} p.xmRows must equal nUnits
|
|
282
|
+
* @param {number} p.xmCols number of dummy columns (nGroups - 1)
|
|
283
|
+
* @param {Float64Array} p.xTarget nUnits — 0-based integer codes for target
|
|
284
|
+
* @param {Int32Array} p.x1Cols 0-based column indices in xModelMatrix for the target
|
|
285
|
+
* @param {boolean} p.xCategorical
|
|
286
|
+
* @param {number} p.xNGroups number of distinct levels
|
|
287
|
+
* @param {Int32Array} p.xSubset 0-based unit-row indices used for GMR fit
|
|
288
|
+
* @param {boolean} [p.hasY=false] whether a Y-axis GMR target is provided
|
|
289
|
+
* @param {Float64Array} [p.yModelMatrix] (ignored when hasY=false)
|
|
290
|
+
* @param {number} [p.ymRows]
|
|
291
|
+
* @param {number} [p.ymCols]
|
|
292
|
+
* @param {Float64Array} [p.yTarget]
|
|
293
|
+
* @param {Int32Array} [p.y1Cols]
|
|
294
|
+
* @param {boolean} [p.yCategorical=false]
|
|
295
|
+
* @param {number} [p.yNGroups=0]
|
|
296
|
+
* @param {number} [p.nLambda=50]
|
|
297
|
+
* @param {number} [p.kFolds=5]
|
|
298
|
+
* @param {number} [p.lassoEps=0.01]
|
|
299
|
+
* @returns {{ rotation, rotRows, rotCols, eigenvalues, columnNames }}
|
|
300
|
+
*/
|
|
301
|
+
export function rotateGeneralized(qe, centered, nUnits, nConnections, p) {
|
|
302
|
+
const hasY = !!p.hasY;
|
|
303
|
+
const nLambda = (p.nLambda ?? 50) | 0;
|
|
304
|
+
const kFolds = (p.kFolds ?? 5) | 0;
|
|
305
|
+
const lassoEps = p.lassoEps ?? 0.01;
|
|
306
|
+
|
|
307
|
+
// Stub Y params when hasY=false — C++ ignores them but still needs valid arrays.
|
|
308
|
+
const yMM = hasY ? p.yModelMatrix : new Float64Array(nUnits);
|
|
309
|
+
const ymR = hasY ? (p.ymRows | 0) : nUnits;
|
|
310
|
+
const ymC = hasY ? (p.ymCols | 0) : 1;
|
|
311
|
+
const yTgt = hasY ? p.yTarget : new Float64Array(nUnits);
|
|
312
|
+
const y1C = hasY ? p.y1Cols : new Int32Array([0]);
|
|
313
|
+
const yCat = hasY ? !!p.yCategorical : false;
|
|
314
|
+
const yNGrp = hasY ? ((p.yNGroups || 0) | 0) : 0;
|
|
315
|
+
|
|
316
|
+
let r;
|
|
317
|
+
try {
|
|
318
|
+
r = qe.generalized_means_rotation(
|
|
319
|
+
centered, nUnits, nConnections,
|
|
320
|
+
p.xModelMatrix, p.xmRows | 0, p.xmCols | 0,
|
|
321
|
+
p.xTarget,
|
|
322
|
+
p.x1Cols,
|
|
323
|
+
!!p.xCategorical, (p.xNGroups | 0),
|
|
324
|
+
p.xSubset,
|
|
325
|
+
hasY,
|
|
326
|
+
yMM, ymR, ymC,
|
|
327
|
+
yTgt,
|
|
328
|
+
y1C,
|
|
329
|
+
yCat, yNGrp,
|
|
330
|
+
nLambda, kFolds, lassoEps
|
|
331
|
+
);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
'Rotation by Regression failed: the target variable or group selection ' +
|
|
335
|
+
'has zero variance or insufficient rank across units.'
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
rotation: r.rotation.data,
|
|
341
|
+
rotRows: r.rotation.rows,
|
|
342
|
+
rotCols: r.rotation.cols,
|
|
343
|
+
eigenvalues: r.eigenvalues,
|
|
344
|
+
columnNames: r.column_names,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
184
348
|
// ── projection & node positions ───────────────────────────────────────────────
|
|
185
349
|
|
|
186
350
|
/**
|