Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions PLAN.md

Large diffs are not rendered by default.

29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,20 @@ The project follows three non-negotiable principles:

### ML Lab — `/ml`

| Area | What it does |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Data in | Drag & drop CSV/Excel (parsed in a worker), demo datasets, per-column profiling, automatic task detection, smart exclusions and **target-leakage detection**; **free-text columns** join the pipeline as hand-written TF-IDF (FR/EN tokenization, capped vocabulary, fitted on the training split only) |
| Models | 8 classifiers / 7 regressors trained live: naive baseline, linear/logistic regression, k-NN, Gaussian Naive Bayes, decision tree, random forest, **hand-written histogram gradient boosting** (LightGBM-style) and **MLP** (seeded He init, Adam) |
| Leaderboard | Accuracy/F1/ROC-AUC/log-loss or RMSE/MAE/R², delta vs baseline, train time, inference latency p50/p95, **95% bootstrap intervals** with a paired winner-vs-baseline verdict |
| Understanding | Confusion matrix, ROC, permutation importance, partial dependence, live what-if with **exact Shapley explanations**, **signed word effects** for text columns (which words push the answer up or down), plain-language read (FR/EN, rule-generated) |
| Where it fails | **Per-segment analysis**: the test set sliced by every categorical column — including excluded ones, where proxy effects hide — worst gaps first |
| Imbalance | Precision-recall curve (AP), calibration curve (Brier), **cost-priced decision threshold** with the optimal cut computed by exhaustive sweep |
| Tuning | Seeded random search scored by stratified 3-fold cross-validation, pipeline refitted inside each fold — the test set is scored exactly once |
| More data? | **Learning curve** on demand: one model retrained on growing seeded nested fractions, 95% bootstrap band, plain verdict — still climbing (collect more rows) or flattened (work on features) — including whether an announced training cap costs accuracy |
| No target? | Seeded k-means (k chosen by silhouette) + power-iteration PCA projection, groups described in plain language; date column? **Holt-Winters forecasting** validated by rolling-origin backtest |
| MLOps loop | Score a **new batch** with honest test-vs-batch metrics; **compare two runs** side by side with cross-run uncertainty verdicts; **export a model as JSON and re-import it later** — the exact predictor is rebuilt (byte-identical predictions) and scores any CSV without retraining |
| Persistence | Local run history with attached artifacts, opted-in dataset storage (compressed, explicit 50 MB budget), self-contained HTML reports, data-free share links |
| Area | What it does |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Data in | Drag & drop CSV/Excel (parsed in a worker), demo datasets, per-column profiling, automatic task detection, smart exclusions and **target-leakage detection**; **free-text columns** join the pipeline as hand-written TF-IDF (FR/EN tokenization, capped vocabulary, fitted on the training split only) |
| Models | 8 classifiers / 7 regressors trained live: naive baseline, linear/logistic regression, k-NN, Gaussian Naive Bayes, decision tree, random forest, **hand-written histogram gradient boosting** (LightGBM-style) and **MLP** (seeded He init, Adam) |
| Leaderboard | Accuracy/F1/ROC-AUC/log-loss or RMSE/MAE/R², delta vs baseline, train time, inference latency p50/p95, **95% bootstrap intervals** with a paired winner-vs-baseline verdict |
| Understanding | Confusion matrix, ROC, permutation importance, partial dependence, live what-if with **exact Shapley explanations**, **signed word effects** for text columns (which words push the answer up or down), plain-language read (FR/EN, rule-generated) |
| Where it fails | **Per-segment analysis**: the test set sliced by every categorical column — including excluded ones, where proxy effects hide — worst gaps first |
| Imbalance | Precision-recall curve (AP), calibration curve (Brier), **cost-priced decision threshold** with the optimal cut computed by exhaustive sweep |
| Tuning | Seeded random search scored by stratified 3-fold cross-validation, pipeline refitted inside each fold — the test set is scored exactly once |
| More data? | **Learning curve** on demand: one model retrained on growing seeded nested fractions, 95% bootstrap band, plain verdict — still climbing (collect more rows) or flattened (work on features) — including whether an announced training cap costs accuracy |
| No target? | Seeded k-means (k chosen by silhouette) + power-iteration PCA projection, groups described in plain language; date column? **Holt-Winters forecasting** validated by rolling-origin backtest |
| MLOps loop | Score a **new batch** with honest test-vs-batch metrics; **compare two runs** side by side with cross-run uncertainty verdicts, or **up to six at once** read against the oldest of the selection; **export a model as JSON and re-import it later** — the exact predictor is rebuilt (byte-identical predictions) and scores any CSV without retraining |
| Speed | Heavy families train on **helper cores** (announced on the leaderboard, split by measured cost, never silent), and a model crosses back as JSON so it is rebuilt through the same path an imported model uses. Measured on a 60 000-row run: **74 s → 9.5 s**, with every leaderboard number identical |
| Persistence | Local run history with attached artifacts, opted-in dataset storage (compressed, explicit 50 MB budget), self-contained HTML reports, data-free share links |

### Data Studio — `/data`

Expand Down Expand Up @@ -118,7 +119,7 @@ The project follows three non-negotiable principles:
- **Performance.** Every section serves a prerendered static shell (hero paints before
JavaScript); Lighthouse mobile ≈ 0.99 on `/ml` under real throttling. Heavy
dependencies (Dexie, SheetJS, ONNX Runtime) load lazily.
- **Quality bar.** 387 unit tests, 69 Playwright end-to-end tests (including offline PWA,
- **Quality bar.** 422 unit tests, 71 Playwright end-to-end tests (including offline PWA,
fake-webcam and axe-core WCAG A/AA accessibility checks), strict TypeScript, ESLint,
Prettier, and Lighthouse budgets — all enforced in CI.

Expand Down
83 changes: 83 additions & 0 deletions e2e/parallel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from '@playwright/test';

test.use({ locale: 'en-US' });
test.setTimeout(180_000);

/**
* V37 — speed, and the comfort of a long session.
*
* The two things worth pinning in a real browser: helper cores are ANNOUNCED
* like every other decision the lab makes, and a run trained across several
* cores is the same run — nothing about the leaderboard changes because of
* where a family happened to be fitted.
*/
test('the helper cores are announced by name, and the run is unchanged', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /titanic\.csv/ }).click();
await expect(page.getByText('891 rows · 15 columns')).toBeVisible();
await page.selectOption('#target-select', 'survived');
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 120_000 });

const leaderboard = page.getByTestId('leaderboard');
// The announcement names how many cores helped and which families they took.
await expect(leaderboard).toContainText(/\d helper cores?:/);
await expect(leaderboard).toContainText('trained in parallel');
// Parallelism is an optimisation: no family may go missing because of it.
await expect(leaderboard).not.toContainText('failed');
await expect(leaderboard.locator('tbody tr')).toHaveCount(9);

// A model fitted in a helper crosses back as JSON and is rebuilt here. If
// that rebuild were broken (it was, through structured clone) the first
// prediction would throw — the inference column is where that shows.
const rows = await leaderboard.locator('tbody tr').allInnerTexts();
expect(rows.every((row) => row.includes('ms'))).toBe(true);
});

/**
* V37 — three or more runs read against the oldest, which is where the
* session started. Three separate pairwise diffs would make the reader do the
* joining; this table does it for them.
*/
test('iris: three runs compare in one table, against the oldest', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /iris\.csv/ }).click();
await expect(page.getByText('150 rows · 5 columns')).toBeVisible();
await page.selectOption('#target-select', 'species');

// Run A: everything. Run B: one feature dropped. Run C: a second one too.
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60_000 });
await page
.getByTestId('column-card-petal_width')
.getByRole('button', { name: 'Exclude' })
.click();
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60_000 });
await page
.getByTestId('column-card-sepal_width')
.getByRole('button', { name: 'Exclude' })
.click();
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60_000 });

const history = page.getByTestId('runs-history');
const checkboxes = history.getByRole('checkbox');
await expect(checkboxes).toHaveCount(3);
await checkboxes.nth(2).check(); // oldest — the reference
await checkboxes.nth(1).check();
await checkboxes.nth(0).check();
await page.getByTestId('compare-many-open').click();

const table = page.getByTestId('compare-many-page');
await expect(table).toBeVisible();
await expect(table).toContainText('Session comparison');
// The oldest run is labelled as the reference, exactly once.
await expect(table.getByText('reference', { exact: true })).toHaveCount(1);
// The champion row and the per-model matrix both read across all three runs.
await expect(table).toContainText('Best model');
await expect(table).toContainText('Naive baseline');
// The features card names what each later run dropped relative to the first.
await expect(table).toContainText('petal_width');
await expect(table).toContainText('sepal_width');
});
14 changes: 12 additions & 2 deletions e2e/text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ test('reviews: the text column trains, and the words explain the model', async (
await expect(words).toContainText('Pick another model in the leaderboard');

// And on a model that gives graded probabilities, the words do speak.
await page.getByTestId('leaderboard').getByText('Gradient boosting').click();
// The row, not any mention of the name: V37's footer announces which
// families trained on helper cores, so the name also appears there.
await page
.getByTestId('leaderboard')
.getByRole('row', { name: /Gradient boosting/ })
.click();
await expect(words).toContainText('Words that move the answer', { timeout: 30_000 });
// Effects are signed — at least one word pushes each way on this dataset.
await expect(words).toContainText('+');
Expand All @@ -56,6 +61,11 @@ test('reviews in French: the words card speaks French too', async ({ page }) =>
await expect(words).toBeVisible({ timeout: 30_000 });
// Le refus est traduit lui aussi — une carte qui se tait n'apprend rien.
await expect(words).toContainText('ses probabilités sont saturées');
await page.getByTestId('leaderboard').getByText('Gradient boosting').click();
// The row, not any mention of the name: V37's footer announces which
// families trained on helper cores, so the name also appears there.
await page
.getByTestId('leaderboard')
.getByRole('row', { name: /Gradient boosting/ })
.click();
await expect(words).toContainText('Les mots qui font bouger la réponse', { timeout: 30_000 });
});
6 changes: 6 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const router = createBrowserRouter([
Component: (await import('@/features/ml/pages/MlComparePage')).default,
}),
},
{
path: 'ml/compare-many/:ids',
lazy: async () => ({
Component: (await import('@/features/ml/pages/MlCompareManyPage')).default,
}),
},
{
path: 'ml/share',
lazy: async () => ({
Expand Down
12 changes: 12 additions & 0 deletions src/features/ml/components/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,18 @@ export function LeaderboardTable({
)
</>
)}
{summary.parallel !== undefined && summary.parallel.families.length > 0 && (
<>
{' '}
·{' '}
{t('ml.lab.leaderboard.parallel', {
count: summary.parallel.helpers,
families: summary.parallel.families
.map((key) => t(`ml.lab.models.${key}`))
.join(', '),
})}
</>
)}
{summary.skippedColumns.length > 0 && (
<>
{' '}
Expand Down
19 changes: 18 additions & 1 deletion src/features/ml/components/RunsHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useLabStore } from '@/features/ml/lab-store';
import { cn } from '@/lib/utils';
import type { RunRecord } from '@/features/ml/projects/types';
import { bestResult } from '@/features/ml/train/ranking';
import { MAX_RUNS } from '@/features/ml/projects/compare-many';

/** Datasets kept in the browser (v19) — reopen or forget, all local. */
function SavedDatasets() {
Expand Down Expand Up @@ -113,9 +114,13 @@ export function RunsHistory() {
.map((id) => runs.find((r) => r.id === id))
.filter((r): r is RunRecord => Boolean(r));

// V37: up to MAX_RUNS selections, not two. Three or four runs answer a
// different question — « which of the things I tried actually worked? »
function toggleCompare(id: number) {
setCompare((current) =>
current.includes(id) ? current.filter((v) => v !== id) : [...current.slice(-1), id],
current.includes(id)
? current.filter((v) => v !== id)
: [...current.slice(-(MAX_RUNS - 1)), id],
);
}

Expand Down Expand Up @@ -252,6 +257,18 @@ export function RunsHistory() {
</Link>
</div>
)}
{compared.length > 2 && (
<div className="mt-4">
<Link
to={`/ml/compare-many/${compared.map((r) => r.id).join('-')}`}
data-testid="compare-many-open"
className={cn(buttonVariants({ size: 'sm' }))}
>
<GitCompareArrows className="h-4 w-4" aria-hidden="true" />
{t('ml.lab.compare.openMany', { count: compared.length })}
</Link>
</div>
)}
</Card>

<SavedDatasets />
Expand Down
Loading
Loading