@myvillage/cli 2.3.0 → 2.5.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/package.json +1 -1
- package/src/agent-runtime/builtin-tools-registry.js +6 -3
- package/src/agent-runtime/builtin-tools.js +15 -6
- package/src/commands/dataset.js +54 -13
- package/src/index.js +9 -4
- package/src/utils/dataset-workspace.js +121 -22
- package/src/utils/model-trainer.js +33 -24
- package/src/utils/table-columns.js +162 -0
package/package.json
CHANGED
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
export const DATASET_APPEND_DESCRIPTION =
|
|
15
15
|
'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
|
|
16
16
|
'The dataset must already exist (created via the Studio Lab or ' +
|
|
17
|
-
'`myvillage dataset create`). `features` is text
|
|
18
|
-
'
|
|
17
|
+
'`myvillage dataset create`). `features` is text (words datasets); for a ' +
|
|
18
|
+
'table dataset, its values in column order or an object keyed by column ' +
|
|
19
|
+
'name (numbers, category text, true/false, short notes); or a local image ' +
|
|
20
|
+
'file path (image datasets — the file is ' +
|
|
19
21
|
'copied into the dataset). For classification provide `label`; for ' +
|
|
20
22
|
'regression provide a numeric `target`. Data stays local; provenance ' +
|
|
21
23
|
'is recorded automatically.';
|
|
22
24
|
|
|
23
25
|
export const MODEL_PREDICT_DESCRIPTION =
|
|
24
26
|
'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
|
|
25
|
-
'text,
|
|
27
|
+
'text (words models), the values of one table row as a list in column ' +
|
|
28
|
+
'order or an object keyed by column name (table models), or a local image file path (image ' +
|
|
26
29
|
'models). Returns the predicted class with per-class confidence scores ' +
|
|
27
30
|
'(classification) or a numeric value (regression). The model must already ' +
|
|
28
31
|
'exist (trained in the Studio Lab or via `myvillage model train`).';
|
|
@@ -65,10 +65,15 @@ export function getBuiltinTools(agentName, config) {
|
|
|
65
65
|
.array(
|
|
66
66
|
z.object({
|
|
67
67
|
features: z
|
|
68
|
-
.union([
|
|
68
|
+
.union([
|
|
69
|
+
z.string(),
|
|
70
|
+
z.array(z.union([z.number(), z.string(), z.boolean()])),
|
|
71
|
+
z.record(z.union([z.number(), z.string(), z.boolean()])),
|
|
72
|
+
])
|
|
69
73
|
.describe(
|
|
70
|
-
'Text content
|
|
71
|
-
'
|
|
74
|
+
'Text content for a words dataset; for a table dataset, its values in column ' +
|
|
75
|
+
'order or an object keyed by column name (numbers, category text, true/false, ' +
|
|
76
|
+
'short notes); or, for image datasets, an absolute path to a local image file (.jpg/.png).',
|
|
72
77
|
),
|
|
73
78
|
label: z.string().optional().describe('Class label (classification datasets)'),
|
|
74
79
|
target: z.number().optional().describe('Numeric target (regression datasets)'),
|
|
@@ -122,10 +127,14 @@ export function getBuiltinTools(agentName, config) {
|
|
|
122
127
|
inputSchema: z.object({
|
|
123
128
|
modelId: z.string().describe('Slug of the local trained model'),
|
|
124
129
|
input: z
|
|
125
|
-
.union([
|
|
130
|
+
.union([
|
|
131
|
+
z.string(),
|
|
132
|
+
z.array(z.union([z.number(), z.string(), z.boolean()])),
|
|
133
|
+
z.record(z.union([z.number(), z.string(), z.boolean()])),
|
|
134
|
+
])
|
|
126
135
|
.describe(
|
|
127
|
-
'Text
|
|
128
|
-
'an absolute path to a local image file (.jpg/.png).',
|
|
136
|
+
'Text for a words model; for a table model, its values in column order or an ' +
|
|
137
|
+
'object keyed by column name; or, for image models, an absolute path to a local image file (.jpg/.png).',
|
|
129
138
|
),
|
|
130
139
|
}),
|
|
131
140
|
execute: async ({ modelId, input }) => {
|
package/src/commands/dataset.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function datasetAppendCommand(options) {
|
|
|
103
103
|
rows = readRowsFromFile(options.file);
|
|
104
104
|
} else if (options.text !== undefined || options.features !== undefined) {
|
|
105
105
|
const row = { split: options.split || 'train' };
|
|
106
|
-
row.features = options.features !== undefined ? parseFeatures(options.features) : options.text;
|
|
106
|
+
row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality, manifest) : options.text;
|
|
107
107
|
if (options.label !== undefined) row.label = options.label;
|
|
108
108
|
if (options.target !== undefined) row.target = Number(options.target);
|
|
109
109
|
rows = [row];
|
|
@@ -111,15 +111,29 @@ export async function datasetAppendCommand(options) {
|
|
|
111
111
|
throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
if (options.replace && !options.file) {
|
|
115
|
+
throw new Error('--replace only works with --file (it swaps in the whole working set).');
|
|
116
|
+
}
|
|
117
|
+
const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
|
|
118
|
+
const featureTypes = options.featureTypes !== undefined ? parseNameList(options.featureTypes) : undefined;
|
|
119
|
+
|
|
114
120
|
const spinner = options.json ? null : villageSpinner('Appending rows...').start();
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
let result;
|
|
122
|
+
try {
|
|
123
|
+
result = appendRows(slug, rows, { source: options.source || 'cli' }, {
|
|
124
|
+
replace: Boolean(options.replace),
|
|
125
|
+
featureNames,
|
|
126
|
+
featureTypes,
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
if (spinner) spinner.stop();
|
|
130
|
+
}
|
|
117
131
|
|
|
118
132
|
if (options.json) {
|
|
119
133
|
console.log(JSON.stringify(result, null, 2));
|
|
120
134
|
return;
|
|
121
135
|
}
|
|
122
|
-
success(
|
|
136
|
+
success(`${result.replaced ? 'Replaced the working set with' : 'Appended'} ${result.appended} row(s) ${result.replaced ? 'in' : 'to'} ${slug}`);
|
|
123
137
|
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
124
138
|
} catch (e) {
|
|
125
139
|
error(e.message);
|
|
@@ -133,8 +147,13 @@ export async function datasetShowCommand(slug, options) {
|
|
|
133
147
|
try {
|
|
134
148
|
const manifest = readManifest(slug);
|
|
135
149
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
136
|
-
|
|
137
|
-
const
|
|
150
|
+
// --limit 0 returns every row (used by Studio's export).
|
|
151
|
+
const limit = options.limit !== undefined ? parseInt(options.limit, 10) : 5;
|
|
152
|
+
const version = options.frozen !== undefined ? parseInt(options.frozen, 10) : undefined;
|
|
153
|
+
if (version !== undefined && !manifest.versions.some((v) => v.version === version)) {
|
|
154
|
+
throw new Error(`Dataset "${slug}" has no frozen v${options.frozen}.`);
|
|
155
|
+
}
|
|
156
|
+
const sample = readRows(slug, { limit, version });
|
|
138
157
|
if (options.json) {
|
|
139
158
|
console.log(JSON.stringify({ manifest, sample }, null, 2));
|
|
140
159
|
return;
|
|
@@ -206,7 +225,10 @@ export async function datasetRowUpdateCommand(options) {
|
|
|
206
225
|
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
207
226
|
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
208
227
|
const updates = {};
|
|
209
|
-
if (options.features !== undefined)
|
|
228
|
+
if (options.features !== undefined) {
|
|
229
|
+
const m = readManifest(slug);
|
|
230
|
+
updates.features = parseFeatures(options.features, m?.modality, m);
|
|
231
|
+
}
|
|
210
232
|
else if (options.text !== undefined) updates.features = options.text;
|
|
211
233
|
if (options.label !== undefined) updates.label = options.label;
|
|
212
234
|
if (options.target !== undefined) updates.target = Number(options.target);
|
|
@@ -244,9 +266,31 @@ function readRowsFromFile(path) {
|
|
|
244
266
|
return rows;
|
|
245
267
|
}
|
|
246
268
|
|
|
247
|
-
|
|
248
|
-
|
|
269
|
+
/** A JSON array of names, or a comma-separated list. */
|
|
270
|
+
function parseNameList(raw) {
|
|
271
|
+
const trimmed = String(raw).trim();
|
|
272
|
+
if (trimmed.startsWith('[')) {
|
|
273
|
+
const parsed = JSON.parse(trimmed);
|
|
274
|
+
if (!Array.isArray(parsed)) throw new Error('--feature-names must be a list.');
|
|
275
|
+
return parsed.map(String);
|
|
276
|
+
}
|
|
277
|
+
return trimmed.split(',').map((s) => s.trim());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseFeatures(raw, modality, manifest) {
|
|
281
|
+
// Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
|
|
282
|
+
// comma list ("12, 45" or a single "12"). Anything that isn't a number stays
|
|
283
|
+
// NaN so row validation rejects it rather than storing it as text.
|
|
284
|
+
if (modality !== 'tabular') return raw;
|
|
249
285
|
const trimmed = raw.trim();
|
|
286
|
+
// Typed tables take JSON: a list in column order or an object by column name.
|
|
287
|
+
if (manifest?.featureTypes?.some((t) => t !== 'number')) {
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(trimmed);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new Error('This table has typed columns; pass --features as JSON, e.g. \'{"Role": "student", "Hours": 3}\'.');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
250
294
|
if (trimmed.startsWith('[')) {
|
|
251
295
|
try {
|
|
252
296
|
return JSON.parse(trimmed);
|
|
@@ -254,10 +298,7 @@ function parseFeatures(raw) {
|
|
|
254
298
|
/* fall through */
|
|
255
299
|
}
|
|
256
300
|
}
|
|
257
|
-
|
|
258
|
-
return trimmed.split(',').map((n) => Number(n.trim()));
|
|
259
|
-
}
|
|
260
|
-
return raw;
|
|
301
|
+
return trimmed.split(',').map((n) => (n.trim() === '' ? NaN : Number(n.trim())));
|
|
261
302
|
}
|
|
262
303
|
|
|
263
304
|
function truncate(s, n) {
|
package/src/index.js
CHANGED
|
@@ -1006,17 +1006,22 @@ export function run() {
|
|
|
1006
1006
|
.option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
|
|
1007
1007
|
.option('--image <path>', 'Image file to add (image datasets); copied into the dataset')
|
|
1008
1008
|
.option('--text <text>', 'Single-row text features')
|
|
1009
|
-
.option('--features <value>', 'Single-row features (string, or
|
|
1009
|
+
.option('--features <value>', 'Single-row features (string; numbers for a table, or JSON for a typed table)')
|
|
1010
1010
|
.option('--label <label>', 'Class label (classification)')
|
|
1011
1011
|
.option('--target <number>', 'Numeric target (regression)')
|
|
1012
1012
|
.option('--split <split>', 'train | val', 'train')
|
|
1013
|
+
.option('--replace', 'With --file: replace the working set instead of adding to it')
|
|
1014
|
+
.option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
|
|
1015
|
+
.option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
|
|
1016
|
+
.option('--feature-types <list>', 'Tabular column types, same order: number | category | boolean | text')
|
|
1013
1017
|
.option('--json', 'Output raw JSON')
|
|
1014
1018
|
.action(datasetAppendCommand);
|
|
1015
1019
|
|
|
1016
1020
|
datasetCmd
|
|
1017
1021
|
.command('show <slug>')
|
|
1018
1022
|
.description('Show a dataset manifest and a sample of rows')
|
|
1019
|
-
.option('--limit <n>', 'Number of sample rows to show', '5')
|
|
1023
|
+
.option('--limit <n>', 'Number of sample rows to show (0 = all)', '5')
|
|
1024
|
+
.option('--frozen <n>', 'Read frozen version n instead of the working set')
|
|
1020
1025
|
.option('--json', 'Output raw JSON')
|
|
1021
1026
|
.action(datasetShowCommand);
|
|
1022
1027
|
|
|
@@ -1041,7 +1046,7 @@ export function run() {
|
|
|
1041
1046
|
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
1042
1047
|
.requiredOption('--id <rowId>', 'Row id to update')
|
|
1043
1048
|
.option('--text <text>', 'New text features')
|
|
1044
|
-
.option('--features <value>', 'New features (string, or
|
|
1049
|
+
.option('--features <value>', 'New features (string; numbers for a table, or JSON for a typed table)')
|
|
1045
1050
|
.option('--label <label>', 'New class label (classification)')
|
|
1046
1051
|
.option('--target <number>', 'New numeric target (regression)')
|
|
1047
1052
|
.option('--split <split>', 'train | val')
|
|
@@ -1077,7 +1082,7 @@ export function run() {
|
|
|
1077
1082
|
modelCmd
|
|
1078
1083
|
.command('predict <slug>')
|
|
1079
1084
|
.description('Run a prediction with a trained model')
|
|
1080
|
-
.option('--input <value>', 'Input: text
|
|
1085
|
+
.option('--input <value>', 'Input: text; comma-separated numbers for a table; or JSON for a typed table')
|
|
1081
1086
|
.option('--image <path>', 'Image file to classify (image models)')
|
|
1082
1087
|
.option('--json', 'Output raw JSON')
|
|
1083
1088
|
.action(modelPredictCommand);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { join, extname, basename } from 'path';
|
|
31
31
|
import { randomUUID } from 'crypto';
|
|
32
32
|
import { getConfigDir } from './config.js';
|
|
33
|
+
import { columnTypesOf, normalizeTypedFeatures, validateColumnTypes } from './table-columns.js';
|
|
33
34
|
|
|
34
35
|
const DATASETS_DIR_NAME = 'datasets';
|
|
35
36
|
export const DATASET_SCHEMA_VERSION = '1.0.0';
|
|
@@ -202,23 +203,58 @@ export function createDataset({ name, modality, taskType = 'classification', lab
|
|
|
202
203
|
* Append rows to a dataset's working set, stamping provenance, validating
|
|
203
204
|
* against the dataset's modality/task, and updating manifest stats.
|
|
204
205
|
*
|
|
206
|
+
* Every row is validated before anything is written, so a bad row leaves the
|
|
207
|
+
* dataset untouched. With `replace`, the working set is overwritten instead of
|
|
208
|
+
* extended (frozen versions are never touched) and the label list is rebuilt
|
|
209
|
+
* from the new rows. `featureNames` names the columns of a table (tabular)
|
|
210
|
+
* dataset and `featureTypes` optionally types them (number / category /
|
|
211
|
+
* boolean / text, see table-columns.js); without types every column is a
|
|
212
|
+
* number, as before. Rows may then give their values as a list in column
|
|
213
|
+
* order or as an object keyed by column name. Once set, every row must fit
|
|
214
|
+
* those columns.
|
|
215
|
+
*
|
|
205
216
|
* @param {string} slug
|
|
206
217
|
* @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
|
|
207
218
|
* @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
|
|
208
|
-
* @
|
|
219
|
+
* @param {{replace?:boolean, featureNames?:string[], featureTypes?:string[]}} opts
|
|
220
|
+
* @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
|
|
209
221
|
*/
|
|
210
|
-
export function appendRows(slug, rows, provenance = {}) {
|
|
222
|
+
export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames, featureTypes } = {}) {
|
|
211
223
|
const manifest = readManifest(slug);
|
|
212
224
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
213
225
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
214
226
|
throw new Error('No rows to append.');
|
|
215
227
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
if (featureTypes !== undefined && featureNames === undefined) {
|
|
229
|
+
throw new Error('Column types need column names too.');
|
|
230
|
+
}
|
|
231
|
+
if (featureNames !== undefined) {
|
|
232
|
+
applyFeatureNames(manifest, featureNames, replace);
|
|
233
|
+
applyFeatureTypes(manifest, featureTypes, replace);
|
|
234
|
+
} else if (replace) {
|
|
235
|
+
delete manifest.featureNames;
|
|
236
|
+
delete manifest.featureTypes;
|
|
237
|
+
}
|
|
219
238
|
|
|
220
|
-
|
|
221
|
-
|
|
239
|
+
const existing = replace ? [] : readRows(slug);
|
|
240
|
+
// Without named columns, hold tabular rows to the width already on disk
|
|
241
|
+
// (the trainer needs every vector the same length).
|
|
242
|
+
let width = manifest.featureNames?.length;
|
|
243
|
+
if (width == null && manifest.modality === 'tabular') {
|
|
244
|
+
const first = existing.find((r) => Array.isArray(r.features));
|
|
245
|
+
if (first) width = first.features.length;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const source = provenance.source || 'cli';
|
|
249
|
+
const newRows = [];
|
|
250
|
+
rows.forEach((raw, i) => {
|
|
251
|
+
let row;
|
|
252
|
+
try {
|
|
253
|
+
row = normalizeRow(raw, manifest, width);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
throw new Error(rows.length > 1 ? `Row ${i + 1}: ${e.message}` : e.message);
|
|
256
|
+
}
|
|
257
|
+
if (width == null && Array.isArray(row.features)) width = row.features.length;
|
|
222
258
|
row.id = randomUUID();
|
|
223
259
|
row.provenance = {
|
|
224
260
|
source,
|
|
@@ -227,24 +263,65 @@ export function appendRows(slug, rows, provenance = {}) {
|
|
|
227
263
|
...(provenance.villageId ? { villageId: provenance.villageId } : {}),
|
|
228
264
|
ts: nowISO(),
|
|
229
265
|
};
|
|
230
|
-
|
|
266
|
+
newRows.push(row);
|
|
267
|
+
});
|
|
231
268
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
manifest.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
269
|
+
if (replace) manifest.labels = [];
|
|
270
|
+
for (const row of newRows) {
|
|
271
|
+
if (row.label != null && !manifest.labels.includes(row.label)) manifest.labels.push(row.label);
|
|
272
|
+
}
|
|
273
|
+
if (replace) {
|
|
274
|
+
writeWorkingSet(slug, manifest, newRows);
|
|
275
|
+
} else {
|
|
276
|
+
appendFileSync(getCurrentDataFile(slug), newRows.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
277
|
+
manifest.stats = recomputeStats([...existing, ...newRows]);
|
|
278
|
+
writeManifest(slug, manifest);
|
|
279
|
+
}
|
|
280
|
+
return { datasetId: slug, appended: newRows.length, replaced: replace, stats: manifest.stats };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Swap the whole working set for `rows` (e.g. a spreadsheet import). */
|
|
284
|
+
export function replaceRows(slug, rows, provenance = {}, { featureNames, featureTypes } = {}) {
|
|
285
|
+
return appendRows(slug, rows, provenance, { replace: true, featureNames, featureTypes });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Set a tabular dataset's column names, refusing a mismatch on append. */
|
|
289
|
+
function applyFeatureNames(manifest, featureNames, replace) {
|
|
290
|
+
if (manifest.modality !== 'tabular') {
|
|
291
|
+
throw new Error('Feature names only apply to tabular datasets.');
|
|
240
292
|
}
|
|
293
|
+
const names = featureNames.map((n) => String(n).trim());
|
|
294
|
+
if (names.length === 0 || names.some((n) => !n)) {
|
|
295
|
+
throw new Error('Feature names must be non-empty.');
|
|
296
|
+
}
|
|
297
|
+
const current = manifest.featureNames;
|
|
298
|
+
if (!replace && manifest.stats.rowCount > 0 && current?.length && current.join('\u0000') !== names.join('\u0000')) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`These columns don't match the dataset. It expects: ${current.join(', ')}.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
manifest.featureNames = names;
|
|
304
|
+
}
|
|
241
305
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
306
|
+
/** Set (replace) or check (append) a table's column types, alongside its names. */
|
|
307
|
+
function applyFeatureTypes(manifest, featureTypes, replace) {
|
|
308
|
+
const incoming = featureTypes === undefined ? undefined : featureTypes.map((t) => String(t).trim());
|
|
309
|
+
if (incoming) validateColumnTypes(incoming, manifest.featureNames);
|
|
310
|
+
// An empty dataset takes whatever columns the first rows bring.
|
|
311
|
+
if (replace || manifest.stats.rowCount === 0) {
|
|
312
|
+
if (incoming && incoming.some((t) => t !== 'number')) manifest.featureTypes = incoming;
|
|
313
|
+
else delete manifest.featureTypes;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const current = columnTypesOf(manifest) ?? manifest.featureNames.map(() => 'number');
|
|
317
|
+
const want = incoming ?? manifest.featureNames.map(() => 'number');
|
|
318
|
+
if (current.join('\u0000') !== want.join('\u0000')) {
|
|
319
|
+
const describe = manifest.featureNames.map((n, k) => `${n} (${current[k]})`).join(', ');
|
|
320
|
+
throw new Error(`These columns don't match the dataset. It expects: ${describe}.`);
|
|
321
|
+
}
|
|
245
322
|
}
|
|
246
323
|
|
|
247
|
-
function normalizeRow(raw, manifest) {
|
|
324
|
+
function normalizeRow(raw, manifest, width) {
|
|
248
325
|
if (raw == null || typeof raw !== 'object') {
|
|
249
326
|
throw new Error('Each row must be an object.');
|
|
250
327
|
}
|
|
@@ -266,14 +343,34 @@ function normalizeRow(raw, manifest) {
|
|
|
266
343
|
);
|
|
267
344
|
}
|
|
268
345
|
}
|
|
269
|
-
|
|
346
|
+
let features = raw.features;
|
|
347
|
+
const types = manifest.modality === 'tabular' ? columnTypesOf(manifest) : null;
|
|
348
|
+
if (types) {
|
|
349
|
+
features = normalizeTypedFeatures(raw.features, manifest.featureNames, types);
|
|
350
|
+
} else if (manifest.modality === 'tabular') {
|
|
351
|
+
// An object keyed by column name is fine for a named all-numbers table too.
|
|
352
|
+
if (features && typeof features === 'object' && !Array.isArray(features) && manifest.featureNames?.length) {
|
|
353
|
+
features = normalizeTypedFeatures(features, manifest.featureNames, manifest.featureNames.map(() => 'number'));
|
|
354
|
+
}
|
|
355
|
+
const f = features;
|
|
356
|
+
if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
|
|
357
|
+
throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
|
|
358
|
+
}
|
|
359
|
+
if (width != null && f.length !== width) {
|
|
360
|
+
throw new Error(`Expected ${width} number(s) in each row, got ${f.length}.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
|
|
364
|
+
throw new Error('Text rows need some text.');
|
|
365
|
+
}
|
|
366
|
+
const row = { features, split };
|
|
270
367
|
if (manifest.taskType === 'classification') {
|
|
271
368
|
if (raw.label == null || String(raw.label).length === 0) {
|
|
272
369
|
throw new Error('Classification rows require a "label".');
|
|
273
370
|
}
|
|
274
371
|
row.label = String(raw.label);
|
|
275
372
|
} else {
|
|
276
|
-
if (raw.target == null || Number.
|
|
373
|
+
if (raw.target == null || raw.target === '' || !Number.isFinite(Number(raw.target))) {
|
|
277
374
|
throw new Error('Regression rows require a numeric "target".');
|
|
278
375
|
}
|
|
279
376
|
row.target = Number(raw.target);
|
|
@@ -376,6 +473,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
376
473
|
if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
|
|
377
474
|
throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
|
|
378
475
|
}
|
|
476
|
+
const sibling = rows.find((r, i) => i !== idx && Array.isArray(r.features));
|
|
379
477
|
const merged = normalizeRow(
|
|
380
478
|
{
|
|
381
479
|
features: updates.features !== undefined ? updates.features : existing.features,
|
|
@@ -384,6 +482,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
384
482
|
target: updates.target !== undefined ? updates.target : existing.target,
|
|
385
483
|
},
|
|
386
484
|
manifest,
|
|
485
|
+
manifest.featureNames?.length ?? sibling?.features.length,
|
|
387
486
|
);
|
|
388
487
|
merged.id = existing.id;
|
|
389
488
|
merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* the Studio is just a front-door that streams the progress it emits.
|
|
8
8
|
*
|
|
9
9
|
* v1 modalities:
|
|
10
|
-
* - tabular: numeric feature vectors → standardized → dense head
|
|
10
|
+
* - tabular: numeric feature vectors → standardized → dense head; typed
|
|
11
|
+
* tables (numbers, categories, yes/no, notes) → see table-columns.js
|
|
11
12
|
* - text: hashing vectorizer (pure JS) → dense head
|
|
12
13
|
* - image: bundled MobileNetV3-Small backbone → 1024-d embedding → dense head
|
|
13
14
|
* audio needs a bundled audio backbone and lands in a later slice.
|
|
@@ -20,34 +21,21 @@
|
|
|
20
21
|
import * as tf from '@tensorflow/tfjs';
|
|
21
22
|
import { readRows, getAssetPath } from './dataset-workspace.js';
|
|
22
23
|
import { embedImage, imageFeaturization } from './image-backbone.js';
|
|
24
|
+
import {
|
|
25
|
+
columnTypesOf,
|
|
26
|
+
fitTableFeaturization,
|
|
27
|
+
featurizeTableRow,
|
|
28
|
+
normalizeTypedFeatures,
|
|
29
|
+
vectorizeText as vectorizeWords,
|
|
30
|
+
} from './table-columns.js';
|
|
23
31
|
|
|
24
32
|
const TEXT_DIM = 1024;
|
|
25
33
|
export const TRAINABLE_MODALITIES = ['tabular', 'text', 'image'];
|
|
26
34
|
|
|
27
35
|
// ── Featurization ──────────────────────────────────────
|
|
28
36
|
|
|
29
|
-
function tokenize(s) {
|
|
30
|
-
return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// FNV-1a → bucket. Stable across runs/machines (no Math.random).
|
|
34
|
-
function hashToken(t) {
|
|
35
|
-
let h = 2166136261 >>> 0;
|
|
36
|
-
for (let i = 0; i < t.length; i++) {
|
|
37
|
-
h ^= t.charCodeAt(i);
|
|
38
|
-
h = Math.imul(h, 16777619) >>> 0;
|
|
39
|
-
}
|
|
40
|
-
return h >>> 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
function vectorizeText(s) {
|
|
44
|
-
|
|
45
|
-
for (const tok of tokenize(s)) v[hashToken(tok) % TEXT_DIM] += 1;
|
|
46
|
-
let norm = 0;
|
|
47
|
-
for (let i = 0; i < TEXT_DIM; i++) norm += v[i] * v[i];
|
|
48
|
-
norm = Math.sqrt(norm);
|
|
49
|
-
if (norm > 0) for (let i = 0; i < TEXT_DIM; i++) v[i] /= norm;
|
|
50
|
-
return v;
|
|
38
|
+
return vectorizeWords(s, TEXT_DIM);
|
|
51
39
|
}
|
|
52
40
|
|
|
53
41
|
function toNumericVector(features) {
|
|
@@ -68,7 +56,7 @@ function imagePathOf(features, slug) {
|
|
|
68
56
|
|
|
69
57
|
/** Build the feature matrix + the featurization params to persist. Async to
|
|
70
58
|
* allow the image backbone to embed each example off disk. */
|
|
71
|
-
async function featurize(rows, modality, slug) {
|
|
59
|
+
async function featurize(rows, modality, slug, manifest) {
|
|
72
60
|
if (modality === 'image') {
|
|
73
61
|
const X = [];
|
|
74
62
|
for (const r of rows) X.push(await embedImage(imagePathOf(r.features, slug)));
|
|
@@ -77,6 +65,14 @@ async function featurize(rows, modality, slug) {
|
|
|
77
65
|
if (modality === 'text') {
|
|
78
66
|
return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
|
|
79
67
|
}
|
|
68
|
+
// typed table: each column featurized by its type (featurization.columns)
|
|
69
|
+
const types = columnTypesOf(manifest);
|
|
70
|
+
if (types) {
|
|
71
|
+
const values = rows.map((r) => normalizeTypedFeatures(r.features, manifest.featureNames, types));
|
|
72
|
+
const featurization = fitTableFeaturization(values, manifest.featureNames, types);
|
|
73
|
+
if (featurization.dim === 0) throw new Error('None of the columns give the model anything to learn from.');
|
|
74
|
+
return { X: values.map((v) => featurizeTableRow(v, featurization)), featurization };
|
|
75
|
+
}
|
|
80
76
|
// tabular: standardize each column (store mean/std for predict)
|
|
81
77
|
const raw = rows.map((r) => toNumericVector(r.features));
|
|
82
78
|
const dim = raw[0].length;
|
|
@@ -103,6 +99,19 @@ export async function featurizeOne(input, featurization) {
|
|
|
103
99
|
return embedImage(path);
|
|
104
100
|
}
|
|
105
101
|
if (featurization.modality === 'text') return vectorizeText(input);
|
|
102
|
+
if (featurization.columns) {
|
|
103
|
+
const names = featurization.columns.map((c) => c.name);
|
|
104
|
+
const types = featurization.columns.map((c) => c.type);
|
|
105
|
+
let raw = input;
|
|
106
|
+
if (typeof raw === 'string') {
|
|
107
|
+
try {
|
|
108
|
+
raw = JSON.parse(raw); // CLI --input '{"Role":"student", ...}' or '[3, "student"]'
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error(`Send the values as JSON, for example {${names.map((n) => JSON.stringify(n) + ': …').join(', ')}}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return featurizeTableRow(normalizeTypedFeatures(raw, names, types), featurization);
|
|
114
|
+
}
|
|
106
115
|
const v = toNumericVector(input);
|
|
107
116
|
return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
|
|
108
117
|
}
|
|
@@ -144,7 +153,7 @@ export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {
|
|
|
144
153
|
if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
|
|
145
154
|
|
|
146
155
|
const task = manifest.taskType;
|
|
147
|
-
const { X, featurization } = await featurize(rows, manifest.modality, slug);
|
|
156
|
+
const { X, featurization } = await featurize(rows, manifest.modality, slug, manifest);
|
|
148
157
|
const inputDim = X[0].length;
|
|
149
158
|
|
|
150
159
|
// Build targets: one-hot rows (classification) or standardized scalars (regression)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed columns for "table" (tabular) datasets.
|
|
3
|
+
*
|
|
4
|
+
* A table dataset names its columns (manifest.featureNames) and, optionally,
|
|
5
|
+
* gives each a type (manifest.featureTypes, same order):
|
|
6
|
+
*
|
|
7
|
+
* number 3, 4.5 scaled to mean 0 / std 1 for training
|
|
8
|
+
* category "student" one slot per value seen in training (one-hot)
|
|
9
|
+
* boolean true / false 1 or 0
|
|
10
|
+
* text "I worked on…" hashed words, like a words dataset (short notes)
|
|
11
|
+
*
|
|
12
|
+
* A row's features are its values in column order: [3, "student", true, "…"].
|
|
13
|
+
* Datasets with no featureTypes are all numbers, exactly as before, so every
|
|
14
|
+
* existing dataset and model keeps working unchanged.
|
|
15
|
+
*
|
|
16
|
+
* The featurization here must match myvillage-studio packages/lab-runtime
|
|
17
|
+
* (myvillage-model.js), which runs these models outside the CLI; its parity
|
|
18
|
+
* test trains with this code and compares answers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const COLUMN_TYPES = ['number', 'category', 'boolean', 'text'];
|
|
22
|
+
|
|
23
|
+
/** Hash-bucket width for a writing column inside a table (a words dataset uses 1024). */
|
|
24
|
+
export const TABLE_TEXT_DIM = 256;
|
|
25
|
+
/** Most distinct values a category column one-hot encodes; the rest count as "other" (all zeros). */
|
|
26
|
+
export const MAX_CATEGORY_VALUES = 50;
|
|
27
|
+
|
|
28
|
+
const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 't']);
|
|
29
|
+
const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'f']);
|
|
30
|
+
|
|
31
|
+
/** The dataset's column types, or null for a plain all-numbers table. */
|
|
32
|
+
export function columnTypesOf(manifest) {
|
|
33
|
+
const t = manifest?.featureTypes;
|
|
34
|
+
return Array.isArray(t) && t.length && t.some((x) => x !== 'number') ? t : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Check a featureTypes list against its names. */
|
|
38
|
+
export function validateColumnTypes(types, names) {
|
|
39
|
+
if (!Array.isArray(types) || types.length === 0) throw new Error('Column types must be a list.');
|
|
40
|
+
const bad = types.find((t) => !COLUMN_TYPES.includes(t));
|
|
41
|
+
if (bad !== undefined) throw new Error(`Unknown column type "${bad}". Use: ${COLUMN_TYPES.join(', ')}.`);
|
|
42
|
+
if (names && names.length !== types.length) {
|
|
43
|
+
throw new Error(`There are ${names.length} column names but ${types.length} column types.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coerce(value, type, name) {
|
|
48
|
+
const label = name ? `"${name}"` : 'A value';
|
|
49
|
+
if (type === 'number') {
|
|
50
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value.trim()) : NaN;
|
|
51
|
+
if (!Number.isFinite(n)) throw new Error(`${label} needs a number.`);
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
if (type === 'boolean') {
|
|
55
|
+
if (typeof value === 'boolean') return value;
|
|
56
|
+
const w = String(value ?? '').trim().toLowerCase();
|
|
57
|
+
if (TRUE_WORDS.has(w)) return true;
|
|
58
|
+
if (FALSE_WORDS.has(w)) return false;
|
|
59
|
+
throw new Error(`${label} needs yes or no (true/false).`);
|
|
60
|
+
}
|
|
61
|
+
// category / text: any scalar, kept as trimmed text; blank is allowed.
|
|
62
|
+
if (value !== null && value !== undefined && typeof value === 'object') {
|
|
63
|
+
throw new Error(`${label} needs a single value, not a list or object.`);
|
|
64
|
+
}
|
|
65
|
+
return value === null || value === undefined ? '' : String(value).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Turn a raw typed-table row (a list in column order, or an object keyed by
|
|
70
|
+
* column name) into the stored list of values. Throws a message a person can act on.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeTypedFeatures(raw, names, types) {
|
|
73
|
+
let values;
|
|
74
|
+
if (Array.isArray(raw)) {
|
|
75
|
+
values = raw;
|
|
76
|
+
} else if (raw && typeof raw === 'object') {
|
|
77
|
+
values = names.map((n) => {
|
|
78
|
+
if (!(n in raw)) throw new Error(`Missing "${n}".`);
|
|
79
|
+
return raw[n];
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error(`Table rows need a value for each column: ${names.join(', ')}.`);
|
|
83
|
+
}
|
|
84
|
+
if (values.length !== types.length) {
|
|
85
|
+
throw new Error(`Expected ${types.length} values (${names.join(', ')}), got ${values.length}.`);
|
|
86
|
+
}
|
|
87
|
+
return values.map((v, i) => coerce(v, types[i], names[i]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Featurization ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function tokenize(s) {
|
|
93
|
+
return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// FNV-1a, the same stable hash words datasets use.
|
|
97
|
+
export function hashToken(t) {
|
|
98
|
+
let h = 2166136261 >>> 0;
|
|
99
|
+
for (let i = 0; i < t.length; i++) {
|
|
100
|
+
h ^= t.charCodeAt(i);
|
|
101
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
102
|
+
}
|
|
103
|
+
return h >>> 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function vectorizeText(s, dim) {
|
|
107
|
+
const v = new Array(dim).fill(0);
|
|
108
|
+
for (const tok of tokenize(s)) v[hashToken(tok) % dim] += 1;
|
|
109
|
+
let norm = 0;
|
|
110
|
+
for (let i = 0; i < dim; i++) norm += v[i] * v[i];
|
|
111
|
+
norm = Math.sqrt(norm);
|
|
112
|
+
if (norm > 0) for (let i = 0; i < dim; i++) v[i] /= norm;
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Learn how to turn each column into numbers from the training rows (already
|
|
118
|
+
* normalized). Returns the featurization record saved with the model.
|
|
119
|
+
*/
|
|
120
|
+
export function fitTableFeaturization(rows, names, types) {
|
|
121
|
+
const columns = types.map((type, j) => {
|
|
122
|
+
const col = rows.map((r) => r[j]);
|
|
123
|
+
if (type === 'number') {
|
|
124
|
+
const mean = col.reduce((a, b) => a + b, 0) / col.length;
|
|
125
|
+
const std = Math.sqrt(col.reduce((a, b) => a + (b - mean) ** 2, 0) / col.length) || 1;
|
|
126
|
+
return { name: names[j], type, mean, std };
|
|
127
|
+
}
|
|
128
|
+
if (type === 'category') {
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
for (const v of col) if (v !== '') counts.set(v, (counts.get(v) || 0) + 1);
|
|
131
|
+
// Most common first, ties alphabetically, so the order is stable.
|
|
132
|
+
const values = [...counts.entries()]
|
|
133
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
134
|
+
.slice(0, MAX_CATEGORY_VALUES)
|
|
135
|
+
.map(([v]) => v);
|
|
136
|
+
return { name: names[j], type, values };
|
|
137
|
+
}
|
|
138
|
+
if (type === 'text') return { name: names[j], type, dim: TABLE_TEXT_DIM };
|
|
139
|
+
return { name: names[j], type };
|
|
140
|
+
});
|
|
141
|
+
const dim = columns.reduce((n, c) => n + columnWidth(c), 0);
|
|
142
|
+
return { modality: 'tabular', dim, columns };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function columnWidth(c) {
|
|
146
|
+
if (c.type === 'category') return c.values.length;
|
|
147
|
+
if (c.type === 'text') return c.dim;
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One normalized row → the model's input vector. */
|
|
152
|
+
export function featurizeTableRow(values, featurization) {
|
|
153
|
+
const out = [];
|
|
154
|
+
featurization.columns.forEach((c, j) => {
|
|
155
|
+
const v = values[j];
|
|
156
|
+
if (c.type === 'number') out.push((v - c.mean) / (c.std || 1));
|
|
157
|
+
else if (c.type === 'boolean') out.push(v ? 1 : 0);
|
|
158
|
+
else if (c.type === 'category') for (const value of c.values) out.push(v === value ? 1 : 0);
|
|
159
|
+
else for (const x of vectorizeText(v, c.dim)) out.push(x);
|
|
160
|
+
});
|
|
161
|
+
return out;
|
|
162
|
+
}
|