diff --git a/src/components/charts/ModelComparisonBar.tsx b/src/components/charts/ModelComparisonBar.tsx index 6d68109..eb252f6 100644 --- a/src/components/charts/ModelComparisonBar.tsx +++ b/src/components/charts/ModelComparisonBar.tsx @@ -1,31 +1,71 @@ import { Bar } from 'react-chartjs-2' import type { ChartOptions } from 'chart.js' -import { BENCHMARKS } from '../../lib/demo/ops' +import { ARM_LABEL } from '../../lib/api' +import type { MetricArm } from '../../lib/api' import { CHART_COLOURS } from './chartSetup' import './chartSetup' -/** Slide-10 benchmark: 90-day stress detection AUC-ROC against two baselines. */ -export function ModelComparisonBar({ height = 190 }: { height?: number }) { +/** + * The four arms of the backtest, measured on identical folds. + * + *

This chart used to plot three hard-coded numbers against an invented + * "bureau-score baseline" that nothing had computed. It now plots what + * `model_metric` holds, and the baselines are the real ones: a single obligation + * ratio, and current DPD alone. That matters because AUC 0.76 in isolation is + * either a result or a restatement of "a borrower who is late today is late in + * three months" — only the comparison says which. + * + *

Baselines are drawn in a muted grey and the trained models in brand blue, so + * the reader can tell a fitted model from a single column at a glance without + * reading the legend. Bars are sorted by value rather than by kind, because + * hiding a baseline that beats the model would be the whole problem again. + */ +export function ModelComparisonBar({ + arms, + metric = 'auc_roc', + height = 190, +}: { + arms: Record + metric?: 'auc_roc' | 'pr_auc' + height?: number +}) { + const entries = Object.entries(arms) + .map(([key, arm]) => ({ key, arm, value: arm[metric] })) + .filter((e): e is { key: string; arm: MetricArm; value: number } => typeof e.value === 'number') + .sort((a, b) => b.value - a.value) + const data = { - labels: ['UdyamLinc — full signal set', 'Repayment history only', 'Bureau-score baseline'], + labels: entries.map((e) => ARM_LABEL[e.key] ?? e.key), datasets: [ { - label: 'AUC-ROC', - data: [BENCHMARKS.aucRoc, BENCHMARKS.aucRepaymentOnly, BENCHMARKS.aucBureau], - backgroundColor: [CHART_COLOURS.forest, CHART_COLOURS.greenHi, '#94a3b8'], + label: metric === 'auc_roc' ? 'AUC-ROC' : 'PR-AUC', + data: entries.map((e) => e.value), + backgroundColor: entries.map((e) => (e.arm.baseline ? '#9aa8b4' : CHART_COLOURS.forest)), borderRadius: 4, - barThickness: 22, + barThickness: 20, }, ], } + // PR-AUC on a 4% positive rate lives near the bottom of the axis; forcing it to + // 0–1 would render four indistinguishable stubs. AUC keeps the full axis because + // 0.5 is a meaningful floor there and cropping it would flatter every arm. + const max = metric === 'auc_roc' ? 1 : Math.min(1, Math.max(...entries.map((e) => e.value)) * 1.4) + const options: ChartOptions<'bar'> = { indexAxis: 'y', responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + afterLabel: (ctx) => (entries[ctx.dataIndex].arm.baseline ? 'single feature, not a model' : ''), + }, + }, + }, scales: { - x: { min: 0, max: 1, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: 0.1 } }, + x: { min: 0, max, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: metric === 'auc_roc' ? 0.1 : 0.05 } }, y: { grid: { display: false }, ticks: { font: { size: 10.5 } } }, }, } diff --git a/src/components/charts/SubScoreRadar.tsx b/src/components/charts/SubScoreRadar.tsx index 0e3ea43..b9ba8ea 100644 --- a/src/components/charts/SubScoreRadar.tsx +++ b/src/components/charts/SubScoreRadar.tsx @@ -6,7 +6,7 @@ import './chartSetup' /** The five risk families as a radar — shows the *shape* of the stress, not just its size. */ export function SubScoreRadar({ sub, band, height = 230 }: { sub: SubScores; band: Band; height?: number }) { - const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46' + const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64' const data = { labels: DRIVER_META.map((d) => d.label), diff --git a/src/components/charts/chartSetup.ts b/src/components/charts/chartSetup.ts index e57beeb..1e53fa0 100644 --- a/src/components/charts/chartSetup.ts +++ b/src/components/charts/chartSetup.ts @@ -27,15 +27,15 @@ ChartJS.register( Legend, ) -ChartJS.defaults.font.family = '"Segoe UI", Roboto, -apple-system, Arial, sans-serif' +ChartJS.defaults.font.family = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' ChartJS.defaults.font.size = 11 -ChartJS.defaults.color = '#64748b' +ChartJS.defaults.color = '#87939c' export const CHART_COLOURS = { - forest: '#0b5d3b', - green: '#1e7a46', - greenHi: '#3fb871', - amber: '#e8a020', + forest: '#2a71b8', // s1 — the design's primary series + green: '#28ae64', // s3 + greenHi: '#4cc582', + amber: '#e08019', // s2 red: '#c0392b', - grid: '#e8eeea', + grid: '#edf0f3', } diff --git a/src/features/officer/ReportsPage.tsx b/src/features/officer/ReportsPage.tsx index 8e0eb1a..67722c3 100644 --- a/src/features/officer/ReportsPage.tsx +++ b/src/features/officer/ReportsPage.tsx @@ -1,20 +1,31 @@ -import { Card, GhostBtn, KpiTile } from '../../components/ui/bits' +import { useQuery } from '@tanstack/react-query' +import { ARM_LABEL, fetchModelMetrics } from '../../lib/api' +import { Card, GhostBtn, KpiTile, Unavailable } from '../../components/ui/bits' import { ModelComparisonBar } from '../../components/charts/ModelComparisonBar' import { SECTORS } from '../../lib/demo/enterprises' -import { BENCHMARKS, alertsFor, scopedEnterprises, sectorRollup, visitsFor } from '../../lib/demo/ops' +import { sectorRollup } from '../../lib/demo/ops' +import { useTenantAlerts, useTenantEnterprises, useTenantVisits } from './useTenantData' import { exportCsv } from '../../lib/csv' import { useScope } from './officerScope' /** - * MIS exports and the supervisory pack. The model figures are the ones the deck - * publishes — measured on the simulated 2,400-enterprise dataset, not on live - * institutional data, and labelled as such. + * MIS exports and the supervisory pack. + *

Model figures are read from `model_metric` — written by the backtest that + * produced them, and carrying the share of synthetic labels they were measured + * against. They were four hard-coded constants until 2026-08-24. */ +/** Two decimals, or an em dash. A metric that was not computed must not print 0.00. */ +function fmt(v: number | undefined): string { + return typeof v === "number" ? v.toFixed(3) : "—" +} + export function ReportsPage() { const { block, month } = useScope() - const enterprises = scopedEnterprises(block) - const alerts = alertsFor(block) - const visits = visitsFor(block) + // Exports carry borrower rows, so they come from the scoped API. An export is + // the most consequential place to get tenancy wrong: a CSV leaves the building. + const { rows: enterprises, unavailable, retry } = useTenantEnterprises(block) + const { rows: alerts } = useTenantAlerts(block) + const { rows: visits } = useTenantVisits(block) const REPORTS: { name: string; detail: string; run: () => void }[] = [ { @@ -73,48 +84,113 @@ export function ReportsPage() { exportCsv( `udyam-sectors-${block}-${month}.csv`, ['Sector', 'Enterprises', 'High risk', 'Watch', 'Standard', 'Average score'], - sectorRollup(block).map((s) => [s.label, s.count, s.red, s.watch, s.standard, s.avgScore]), + sectorRollup(block, enterprises).map((s) => [s.label, s.count, s.red, s.watch, s.standard, s.avgScore]), ), }, ] + const metricsQ = useQuery({ queryKey: ['model-metrics'], queryFn: fetchModelMetrics }) + const metrics = metricsQ.data + + // The winning arm is whichever non-baseline scored best by PR-AUC — the same + // rule the trainer uses to pick, so the headline agrees with the artifact. + const models = Object.entries(metrics?.arms ?? {}).filter(([, a]) => !a.baseline) + const [bestKey, best] = models.sort((a, b) => (b[1].pr_auc ?? 0) - (a[1].pr_auc ?? 0))[0] ?? [] + const bestLabel = bestKey ? (ARM_LABEL[bestKey] ?? bestKey).replace('UdyamLinc — ', '') : '—' + const syntheticPct = Math.round((metrics?.syntheticLabelShare ?? 0) * 100) + const basePct = metrics?.datasetRows + ? ((metrics.datasetPositives ?? 0) / metrics.datasetRows * 100).toFixed(1) + : '—' + + if (unavailable) return + return (

-
- - - - -
+ {/* + Every figure below is read from `model_metric`, written by the backtest + that produced it. This block used to render four constants from + `lib/demo/ops.ts` — an AUC of 0.87 nothing had computed, and an on-device + latency for inference that does not run on a device. A juror asking "how + did you arrive at that?" would have been shown a literal. + */} + {!metrics?.available && ( +
+

No backtest has been run yet

+

+ {metrics?.message ?? + 'Model quality figures appear here once a backtest has written to model_metric. Nothing is shown in the meantime — a placeholder number is how an unmeasured claim ends up on a slide.'} +

+
+ )} -
- -
- -

- Measured on a simulated {BENCHMARKS.datasetSize.toLocaleString('en-IN')}-enterprise dataset across{' '} - {BENCHMARKS.sectorsCovered} sectors and {BENCHMARKS.districtsCovered} districts. Production calibration - will use institutional historical portfolios under a data-sharing agreement. -

+ {metrics?.available && ( + <> +
+ + + +
- - -
    - {[ - 'Time-series split — no forward leakage across folds.', - 'Backtested against simulated stress events.', - 'Precision measured at the Red band, where the cost of a false positive is one visit.', - 'Sector packs evaluated independently before pooling.', - ].map((t) => ( -
  • - - {t} -
  • - ))} -
-
-
+
+ AUC-ROC, identical folds} + > +
+ +

+ Grey bars are single features, not models. Measured on {metrics.datasetRows?.toLocaleString('en-IN')}{' '} + rows across 21 monthly as-of dates, {metrics.datasetPositives} of them positive.{' '} + + {syntheticPct}% of labels are generated DPD history + + , so these figures measure the generator as much as the borrowers. Production calibration needs a + partner bank's repayment record. +

+
+
+ + +
    + {[ + `Validation: ${metrics.validation ?? 'expanding-window time-series split'} — no forward leakage across folds, and the point-in-time columns make that checkable rather than asserted.`, + 'PR-AUC is the honest headline at a 4% positive rate. AUC-ROC is flattered by the majority class.', + 'The two grey arms are one feature each. A model that only matches them has not yet earned its complexity.', + 'dscr_p10 and dscr_p50 are excluded from training: both read the current forecast at every past date.', + 'Labels are generated. No figure here is calibrated against a real default.', + ].map((t) => ( +
  • + + {t} +
  • + ))} +
+
+
+ + )}
@@ -133,7 +209,7 @@ export function ReportsPage() { {r.name} {r.detail} - {[enterprises.length, alerts.length, visits.length, sectorRollup(block).length][i]} + {[enterprises.length, alerts.length, visits.length, sectorRollup(block, enterprises).length][i]} Download CSV