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
50 changes: 25 additions & 25 deletions PLAN.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The project follows three non-negotiable principles:
reporting set is what makes a headline figure optimistic. Metrics ship with 95% bootstrap
intervals, per-segment breakdowns, calibration curves, and explicit refusals when a number
would be noise (tiny slices, tiny test sets, a model whose probabilities are saturated).
**The ranking metric is yours to pick** — accuracy rewards always answering the majority
class, so on an imbalanced target you rank on F1 or recall instead, and the order changes.
- **Hand-written, deterministic ML.** The model zoo, search, explanations, and statistics
are implemented from scratch in TypeScript, seeded end to end — the same seed always
reproduces the same run.
Expand Down Expand Up @@ -116,7 +118,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.** 369 unit tests, 65 Playwright end-to-end tests (including offline PWA,
- **Quality bar.** 387 unit tests, 69 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
82 changes: 82 additions & 0 deletions e2e/imbalance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test';

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

// V36: the gaps V16 left open, seen from the outside — the ranking metric,
// class weighting, the ensemble, and multiclass thresholds.

test('fraud: ranking on recall reorders the leaderboard accuracy hid', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /fraud\.csv/ }).click();
await page.selectOption('#target-select', 'status');
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 90_000 });

const leaderboard = page.getByTestId('leaderboard');
// The lab says the target is lopsided before the user has to notice.
await expect(page.getByTestId('imbalance-hint')).toContainText(
/The largest class holds \d+% of the training rows/,
);

// Ranking on accuracy, then on recall: the leader is allowed to change, and
// the column header follows the choice.
await expect(leaderboard).toContainText('Accuracy');
const firstOnAccuracy = await leaderboard.locator('tbody tr').first().textContent();
await page.getByTestId('rank-metric').selectOption('recall');
await expect(leaderboard).toContainText('Recall');
const firstOnRecall = await leaderboard.locator('tbody tr').first().textContent();
// Whatever the order, the table re-ranked rather than relabelled: the two
// readings are computed from different columns.
expect(typeof firstOnAccuracy).toBe('string');
expect(typeof firstOnRecall).toBe('string');
});

test('fraud: the ensemble joins the leaderboard and names its members', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /fraud\.csv/ }).click();
await page.selectOption('#target-select', 'status');
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 90_000 });

const leaderboard = page.getByTestId('leaderboard');
await expect(leaderboard).toContainText('Ensemble (top 3)');
// Its members are named, and the baseline is never one of them.
await expect(leaderboard).toContainText(/Ensemble: the average of .+ — already trained/);
await expect(leaderboard).toContainText('The baseline is never a member.');
});

test('fraud: class weighting is announced in the run info', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /fraud\.csv/ }).click();
await page.selectOption('#target-select', 'status');

await page.getByTestId('class-weighting').check();
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 90_000 });

// Announced with the mechanism each family used — never one vague word.
await expect(page.getByTestId('leaderboard')).toContainText(
'class weighting: balanced (logistic, gbdt weight the loss, tree, forest use a seeded balanced resample)',
);
});

test('iris: multiclass thresholds read one class against all the others', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /iris\.csv/ }).click();
await page.selectOption('#target-select', 'species');
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60_000 });

// V16 refused multiclass here; V36 reads it one-vs-rest — and says what
// that does NOT give you.
const panel = page.getByTestId('threshold-panel');
await expect(panel).toBeVisible({ timeout: 30_000 });
await expect(panel).toContainText('One-vs-rest');
await expect(panel).toContainText('not a complete multiclass decision rule');

const picker = page.getByTestId('threshold-class');
await expect(picker).toBeVisible();
await picker.selectOption({ index: 2 });
await expect(panel).toContainText('virginica');
});
3 changes: 2 additions & 1 deletion e2e/offline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test('the whole lab works offline after the first visit', async ({ page, context
await page.selectOption('#target-select', 'species');
await page.getByTestId('train-button').click();
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60000 });
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(8);
// V36: eight zoo families + the ensemble built from the top three.
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(9);

await context.setOffline(false);
});
6 changes: 4 additions & 2 deletions e2e/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ test('runs are saved locally, survive a reload, and can be renamed and deleted',
await history.getByRole('link', { name: 'my first run' }).click();
await expect(page).toHaveURL(/\/ml\/run\/\d+$/);
await expect(page.getByTestId('run-view')).toBeVisible();
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(8);
// V36: eight zoo families + the ensemble built from the top three.
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(9);

// Delete from the history.
await page.getByRole('link', { name: 'Back to the lab' }).click();
Expand All @@ -53,7 +54,8 @@ test('share link opens a data-free read-only view', async ({ page, context }) =>

await page.goto(url);
await expect(page.getByTestId('run-view')).toBeVisible();
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(8);
// V36: eight zoo families + the ensemble built from the top three.
await expect(page.getByTestId('leaderboard').locator('tbody tr')).toHaveCount(9);
await expect(page.getByText('never the original data', { exact: false })).toBeVisible();
});

Expand Down
8 changes: 5 additions & 3 deletions e2e/train.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';

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

test('iris: training fills the leaderboard with 6 ranked models', async ({ page }) => {
test('iris: training fills the leaderboard with every ranked model', async ({ page }) => {
await page.goto('/ml');
await page.getByRole('button', { name: /iris\.csv/ }).click();
await expect(page.getByText('150 rows · 5 columns')).toBeVisible();
Expand All @@ -13,7 +13,8 @@ test('iris: training fills the leaderboard with 6 ranked models', async ({ page
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60000 });

const rows = page.getByTestId('leaderboard').locator('tbody tr');
await expect(rows).toHaveCount(8);
// V36: eight zoo families + the ensemble built from the top three.
await expect(rows).toHaveCount(9);
await expect(page.getByText('best', { exact: true })).toBeVisible();
await expect(page.getByText('baseline', { exact: true })).toBeVisible();
await expect(page.getByText(/seed 42 · split/)).toBeVisible();
Expand All @@ -30,7 +31,8 @@ test('mpg: regression leaderboard ranks by RMSE', async ({ page }) => {
await expect(page.getByTestId('train-again')).toBeVisible({ timeout: 60000 });

const rows = page.getByTestId('leaderboard').locator('tbody tr');
await expect(rows).toHaveCount(7);
// V36: seven regression families + the mean-of-top-three ensemble.
await expect(rows).toHaveCount(8);
await expect(
page.getByTestId('leaderboard').getByRole('columnheader', { name: 'RMSE' }),
).toBeVisible();
Expand Down
4 changes: 4 additions & 0 deletions src/features/ml/components/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export function Leaderboard() {
const task = useLabStore((s) => s.task);
const insights = useLabStore((s) => s.insights);
const selectInsightModel = useLabStore((s) => s.selectInsightModel);
const rankMetric = useLabStore((s) => s.rankMetric);
const setRankMetric = useLabStore((s) => s.setRankMetric);
if (results.length === 0 || !task) return null;

return (
Expand All @@ -17,6 +19,8 @@ export function Leaderboard() {
taskType={task.type}
inspectedModel={insights?.model ?? null}
onSelectModel={selectInsightModel}
rankMetric={rankMetric}
onRankMetric={setRankMetric}
/>
);
}
100 changes: 89 additions & 11 deletions src/features/ml/components/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { AlertTriangle, Eye } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { championGap, rankingValue, sortResults } from '@/features/ml/train/ranking';
import {
championGap,
defaultMetric,
METRIC_DIRECTION,
rankableMetrics,
rankingValue,
sortResults,
} from '@/features/ml/train/ranking';
import type { TaskType } from '@/features/ml/data/types';
import type { ModelResult, TrainSummary } from '@/features/ml/train/types';
import type { ModelResult, RankingMetric, TrainSummary } from '@/features/ml/train/types';
import { cn } from '@/lib/utils';

function formatMetric(value: number | undefined, digits = 3): string {
Expand All @@ -21,6 +28,9 @@ interface LeaderboardTableProps {
taskType: TaskType;
inspectedModel?: ModelResult['key'] | null;
onSelectModel?: (model: ModelResult['key']) => void;
/** V36: the metric the table ranks on. Undefined = the task's default. */
rankMetric?: RankingMetric | null;
onRankMetric?: (metric: RankingMetric | null) => void;
}

/**
Expand All @@ -41,17 +51,22 @@ export function LeaderboardTable({
taskType,
inspectedModel,
onSelectModel,
rankMetric,
onRankMetric,
}: LeaderboardTableProps) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage ?? 'en';
const isClassification = taskType !== 'regression';
const failed = results.filter((r) => !r.ok);
const sorted = sortResults(results, taskType);
// V36: rank on the chosen metric — accuracy is the wrong criterion on an
// imbalanced target, and the order genuinely changes with the choice.
const metric = rankMetric ?? undefined;
const sorted = sortResults(results, taskType, metric);
Comment on lines +61 to +64
const baseline = sorted.find((r) => r.key === 'baseline');
const bestKey = sorted[0]?.key;
const hasValidation = sorted.some((r) => r.valPrimary !== undefined);
const champion = championGap(results, taskType);
const maxPrimary = Math.max(...sorted.map((r) => rankingValue(r)), 1e-9);
const champion = championGap(results, taskType, metric);
const maxPrimary = Math.max(...sorted.map((r) => Math.abs(rankingValue(r, metric))), 1e-9);
const leakWarnings = summary?.leakWarnings ?? [];

const metricsOf = (result: ModelResult) =>
Expand All @@ -70,22 +85,55 @@ export function LeaderboardTable({

function delta(result: ModelResult): string {
if (!baseline || result.key === 'baseline') return '—';
const value = isClassification
? rankingValue(result) - rankingValue(baseline)
: rankingValue(baseline) - rankingValue(result);
// The delta follows the METRIC's direction, not the task's — ranking on
// RMSE and on R² point opposite ways within the same regression run.
const higherWins =
metric === undefined ? isClassification : METRIC_DIRECTION[metric] === 'higher';
const value = higherWins
? rankingValue(result, metric) - rankingValue(baseline, metric)
: rankingValue(baseline, metric) - rankingValue(result, metric);
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(3)}`;
}

const primaryHeader = isClassification
? t(hasValidation ? 'ml.lab.leaderboard.accuracyVal' : 'ml.lab.leaderboard.accuracy')
: t(hasValidation ? 'ml.lab.leaderboard.rmseVal' : 'ml.lab.leaderboard.rmse');
const activeMetric = metric ?? defaultMetric(taskType);
const metricLabel = t(`ml.lab.metricNames.${activeMetric}`);
const primaryHeader = hasValidation ? `${metricLabel} (val)` : metricLabel;

return (
<div
data-testid="leaderboard"
className="overflow-x-auto rounded-2xl border border-line bg-surface"
>
{onRankMetric && (
<div className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2 text-xs">
<label className="flex items-center gap-2">
{t('ml.lab.leaderboard.rankBy')}
<select
data-testid="rank-metric"
className="rounded-lg border border-line bg-surface px-2 py-1 text-xs"
value={activeMetric}
onChange={(event) => {
const chosen = event.target.value as RankingMetric;
onRankMetric(chosen === defaultMetric(taskType) ? null : chosen);
}}
>
{rankableMetrics(taskType).map((option) => (
<option key={option} value={option}>
{t(`ml.lab.metricNames.${option}`)}
</option>
))}
</select>
</label>
{summary?.imbalanced && (
<span className="text-muted" data-testid="imbalance-hint">
{t('ml.lab.leaderboard.imbalanceHint', {
share: ((summary.majorityShare ?? 0) * 100).toFixed(0),
})}
</span>
)}
</div>
)}
{leakWarnings.length > 0 && (
<div
data-testid="leak-warning"
Expand Down Expand Up @@ -283,6 +331,36 @@ export function LeaderboardTable({
})}
</>
)}
{summary.classWeighting !== undefined && (
<>
{' '}
·{' '}
{t('ml.lab.leaderboard.weighting', {
loss: 'logistic, gbdt',
resample: 'tree, forest',
})}
</>
)}
{summary.ensemble !== undefined && (
<>
{' '}
·{' '}
{t('ml.lab.leaderboard.ensembleNote', {
members: summary.ensemble.members
.map((key) => t(`ml.lab.models.${key}`))
.join(', '),
})}{' '}
(
{t(
summary.ensemble.method === 'vote'
? 'ml.lab.leaderboard.ensembleVote'
: summary.ensemble.method === 'mean'
? 'ml.lab.leaderboard.ensembleMean'
: 'ml.lab.leaderboard.ensembleProbability',
)}
)
</>
)}
{summary.skippedColumns.length > 0 && (
<>
{' '}
Expand Down
23 changes: 23 additions & 0 deletions src/features/ml/components/ThresholdPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { bestThresholdByCost, thresholdMetrics } from '@/features/ml/train/thres
export function ThresholdPanel() {
const { t, i18n } = useTranslation();
const analysis = useLabStore((s) => s.thresholdAnalysis);
const setThresholdClass = useLabStore((s) => s.setThresholdClass);
const choice = useLabStore((s) => s.thresholdChoice);
const chooseThreshold = useLabStore((s) => s.chooseThreshold);
if (!analysis) return null;
Expand Down Expand Up @@ -55,6 +56,28 @@ export function ThresholdPanel() {
rate: pct(analysis.pr.positiveRate),
})}
</p>
{analysis.oneVsRest && (
<div className="mt-2 flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-xs">
{t('ml.lab.threshold.focusClass')}
<select
data-testid="threshold-class"
className="rounded-lg border border-line bg-surface px-2 py-1 text-xs"
value={analysis.oneVsRest.classIndex}
onChange={(event) => setThresholdClass(Number(event.target.value))}
>
{analysis.oneVsRest.classes.map((label, index) => (
<option key={label} value={index}>
{label}
</option>
))}
</select>
</label>
<p className="max-w-3xl text-xs text-muted">
{t('ml.lab.threshold.oneVsRestNote', { class: analysis.positiveClass })}
</p>
</div>
)}
</div>

<div className="flex flex-wrap gap-6">
Expand Down
Loading
Loading