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 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 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.
-
+ 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.
+
- {[
- '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) => (
-
-
+ {[
+ `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) => (
+
+ {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]}