kumo-tabular-webgpu / predict.js
mrfakename's picture
Initial Space demo
68a88b7 verified
Raw History Blame Contribute Delete
6.5 kB
// Runtime-agnostic prediction pipeline: `ort` is onnxruntime-web (browser) or
// onnxruntime-node (tests); `getSession(task)` returns an InferenceSession.
import {
prepareFeatures, buildMembers, prepareClassification, prepareRegression,
trimmedMean, softmax, makeRng, isMissing, parseNumber, isNumericColumn,
} from "./kumo.js";
export const LIMITS = { context: 5000, query: 20000, classes: 10, chunk: 256 };
export async function predict({ ort, getSession, ctx, qry, target, task, numEstimators, seed = 0, onProgress = () => {} }) {
if (!ctx.columns.includes(target)) throw new Error(`Target "${target}" not found`);
const features = ctx.columns.filter((c) => c !== target);
if (!features.length) throw new Error("Need at least one feature column besides the target");
const labeled = ctx.rows.filter((r) => !isMissing(r[target]));
const unlabeled = ctx.rows.filter((r) => isMissing(r[target]));
const queryRows = (qry ? qry.rows : []).concat(unlabeled);
if (!queryRows.length) throw new Error("Nothing to predict: add a query CSV or leave the target blank on some rows");
if (labeled.length < 2) throw new Error("Need at least 2 labeled context rows");
if (labeled.length > LIMITS.context) throw new Error(`Browser demo limit: ${LIMITS.context} labeled context rows`);
if (queryRows.length > LIMITS.query) throw new Error(`Browser demo limit: ${LIMITS.query} query rows`);
const yVals = labeled.map((r) => r[target]);
if (task === "auto") {
const numeric = isNumericColumn(yVals);
task = numeric && new Set(yVals.map(Number)).size > 10 ? "regression" : "classification";
}
if (task === "regression" && yVals.some((v) => !Number.isFinite(parseNumber(v)))) throw new Error("Regression needs a numeric target");
const E = numEstimators;
const rng = makeRng(seed + 1);
// RNG order mirrors sdm: target first, then feature flips, then column shuffle.
const tgt = task === "classification" ? prepareClassification(yVals, rng, E) : prepareRegression(yVals, rng, E);
if (task === "classification") {
if (tgt.K < 2) throw new Error("Classification needs at least 2 distinct target values");
if (tgt.K > LIMITS.classes) throw new Error(`Browser demo supports up to ${LIMITS.classes} classes (got ${tgt.K})`);
}
onProgress("Preprocessing…", 0);
const prep = prepareFeatures(features, labeled, queryRows);
if (!prep.columns.length) throw new Error("All feature columns are constant in the context rows");
const members = buildMembers(prep, E, rng);
const session = await getSession(task);
const n = prep.n, nq = queryRows.length, chunk = LIMITS.chunk;
const nChunks = Math.ceil(nq / chunk);
const outW = task === "classification" ? tgt.K : 999;
const perMember = members.map(() => new Float64Array(nq * outW));
let step = 0;
for (let m = 0; m < E; m++) {
const { x, F, mask } = members[m];
const yT = task === "classification"
? new ort.Tensor("int64", tgt.yFor(m), [n])
: new ort.Tensor("float32", tgt.yFor(m), [n]);
const maskT = new ort.Tensor("bool", mask, [F]);
for (let c = 0; c < nChunks; c++) {
const q0 = c * chunk, q1 = Math.min(nq, q0 + chunk), R = n + (q1 - q0);
const xb = new Float32Array(R * F);
xb.set(x.subarray(0, n * F), 0);
xb.set(x.subarray((n + q0) * F, (n + q1) * F), n * F);
const out = await session.run({ x: new ort.Tensor("float32", xb, [R, F]), y: yT, categorical_mask: maskT });
const o = out.logits.data, W = out.logits.dims[1];
for (let i = 0; i < q1 - q0; i++) {
const row = o.subarray(i * W, (i + 1) * W);
const v = task === "classification" ? tgt.unshift(m, row) : tgt.invert(m, row);
perMember[m].set(v.subarray(0, outW), (q0 + i) * outW);
}
step++;
onProgress(`Estimator ${m + 1}/${E} · chunk ${c + 1}/${nChunks}`, step / (E * nChunks));
}
}
// Post-process
const columns = [], rows = [];
const truthPresent = queryRows.some((r) => target in r && !isMissing(r[target]));
let metrics = "";
if (task === "classification") {
const names = tgt.classes;
columns.push(`predicted_${target}`, ...(truthPresent ? [`true_${target}`] : []), "confidence", ...features, ...names.map((c) => `p(${c})`));
let correct = 0, scored = 0;
for (let i = 0; i < nq; i++) {
const avg = Array.from({ length: tgt.K }, (_, k) => members.reduce((s, _m, m) => s + perMember[m][i * tgt.K + k], 0) / E);
const p = softmax(avg);
let best = 0; for (let k = 1; k < p.length; k++) if (p[k] > p[best]) best = k;
const row = { [`predicted_${target}`]: names[best], confidence: +p[best].toFixed(4) };
if (truthPresent) {
const t = queryRows[i][target] ?? "";
row[`true_${target}`] = t;
if (!isMissing(t)) { scored++; if (String(t) === names[best] || (Number(t) === Number(names[best]) && t !== "")) correct++; }
}
for (const f of features) row[f] = queryRows[i][f] ?? "";
names.forEach((c, k) => (row[`p(${c})`] = +p[k].toFixed(4)));
rows.push(row);
}
if (scored) metrics = `accuracy ${(correct / scored).toFixed(3)} on ${scored} labeled query rows`;
} else {
columns.push(`predicted_${target}`, ...(truthPresent ? [`true_${target}`] : []), "p10", "p90", ...features);
const pick = (i, qi) => trimmedMean(members.map((_m, m) => perMember[m][i * 999 + qi]));
let se = 0, ae = 0, cov = 0, scored = 0; const ts = [];
for (let i = 0; i < nq; i++) {
const med = pick(i, 499), p10 = pick(i, 99), p90 = pick(i, 899);
const row = { [`predicted_${target}`]: +med.toPrecision(6), p10: +p10.toPrecision(6), p90: +p90.toPrecision(6) };
if (truthPresent) {
const t = queryRows[i][target] ?? "";
row[`true_${target}`] = t;
const tv = parseNumber(t);
if (Number.isFinite(tv)) { scored++; se += (med - tv) ** 2; ae += Math.abs(med - tv); cov += tv >= p10 && tv <= p90; ts.push([tv, med]); }
}
for (const f of features) row[f] = queryRows[i][f] ?? "";
rows.push(row);
}
if (scored) {
const mt = ts.reduce((a, [t]) => a + t, 0) / scored, sst = ts.reduce((a, [t]) => a + (t - mt) ** 2, 0);
metrics = `RMSE ${Math.sqrt(se / scored).toFixed(3)} · MAE ${(ae / scored).toFixed(3)} · R² ${(1 - se / sst).toFixed(3)} · 80% interval coverage ${(cov / scored).toFixed(2)} on ${scored} rows`;
}
}
return { task, columns, rows, metrics, nContext: n, nQuery: nq, nFeatures: prep.columns.length };
}