fix(reports): show the measured metrics, including the one that beats us

The Reports page carried a BENCHMARKS block of invented figures. It now reads
GET /admin/model/metrics and the block is deleted, with a comment recording
what it held and why it is gone. No fabricated number remains anywhere in the
product.

The comparison chart sorts by value rather than by kind, which puts the
obligation_ratio_only baseline at 0.768 above the LightGBM model at 0.760.
Grouping models above baselines would have hidden that, which is the original
problem wearing better clothes. Baselines are drawn grey, models blue.

The synthetic-label share travels with every figure, so accuracy cannot be
read here without the caveat. fmt() prints an em dash for an absent metric,
never 0.00 — a missing measurement and a measured zero are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:46:02 +05:30
co-authored by Claude Opus 5
parent 1ffc1cbd4c
commit 9d307c2d20
4 changed files with 176 additions and 60 deletions
+50 -10
View File
@@ -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.
*
* <p>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.
*
* <p>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<string, MetricArm>
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 } } },
},
}
+1 -1
View File
@@ -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),
+7 -7
View File
@@ -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',
}
+103 -27
View File
@@ -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.
* <p>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 <Unavailable what="Reports" onRetry={retry} />
return (
<div className="space-y-4">
{/*
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 && (
<div className="ul-card p-5">
<p className="text-[13px] font-semibold text-ink">No backtest has been run yet</p>
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[62ch] leading-snug">
{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.'}
</p>
</div>
)}
{metrics?.available && (
<>
<div className="flex flex-wrap gap-3">
<KpiTile label="AUC-ROC · 90-day stress" value={BENCHMARKS.aucRoc.toFixed(2)} delta="full signal set" tone="good" />
<KpiTile label="Median lead time" value={`${BENCHMARKS.medianLeadTimeMonths} mo`} delta="before first missed instalment" />
<KpiTile label="Alert precision · Red band" value={BENCHMARKS.alertPrecisionRed.toFixed(2)} delta="cost of a false positive is one visit" tone="watch" />
<KpiTile label="On-device scoring" value={`< ${BENCHMARKS.onDeviceLatencyMs} ms`} delta="zero connectivity" />
<KpiTile
label="AUC-ROC · 90-day stress"
value={fmt(best?.auc_roc)}
delta={`${bestLabel} · expanding-window folds`}
tone="good"
/>
<KpiTile
label="PR-AUC"
value={fmt(best?.pr_auc)}
delta={`base rate ${basePct}% — read this before AUC`}
tone="watch"
/>
<KpiTile
label="Alert precision · Red band"
value={fmt(best?.precision_red)}
delta="cost of a false positive is one visit"
tone="watch"
/>
<KpiTile
label="Training rows"
value={(metrics.datasetRows ?? 0).toLocaleString('en-IN')}
delta={`${metrics.datasetPositives} positives · ${syntheticPct}% synthetic labels`}
tone="red"
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Model comparison — 90-day stress detection (AUC-ROC)">
<Card
title="Model comparison — 90-day stress detection"
badge={metrics.modelVersion}
actions={<span className="text-[10.5px] text-ink-3">AUC-ROC, identical folds</span>}
>
<div className="p-4">
<ModelComparisonBar />
<p className="text-[10.5px] text-slate-400 mt-2 leading-snug">
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.
<ModelComparisonBar arms={metrics.arms ?? {}} />
<p className="text-[10.5px] text-ink-3 mt-2 leading-snug">
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.{' '}
<b className="text-ink-2">
{syntheticPct}% of labels are generated DPD history
</b>
, so these figures measure the generator as much as the borrowers. Production calibration needs a
partner bank's repayment record.
</p>
</div>
</Card>
<Card title="Validation method">
<ul className="p-4 space-y-2.5 text-[12px] text-slate-600">
<Card title="What this does and does not show" badge="read with the numbers">
<ul className="p-4 space-y-2.5 text-[12px] text-ink-2">
{[
'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.',
`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) => (
<li key={t} className="flex gap-2.5">
<span className="w-1.5 h-1.5 rounded-full bg-green mt-1.5 shrink-0" />
<span className="w-1.5 h-1.5 rounded-full bg-lp-blue mt-1.5 shrink-0" />
{t}
</li>
))}
</ul>
</Card>
</div>
</>
)}
<Card title="MIS exports" badge={`${block === 'ALL' ? 'all blocks' : block} · ${month}`}>
<div className="overflow-x-auto">
@@ -133,7 +209,7 @@ export function ReportsPage() {
<td className="font-semibold text-slate-700">{r.name}</td>
<td className="whitespace-normal text-slate-600">{r.detail}</td>
<td className="text-right text-slate-500">
{[enterprises.length, alerts.length, visits.length, sectorRollup(block).length][i]}
{[enterprises.length, alerts.length, visits.length, sectorRollup(block, enterprises).length][i]}
</td>
<td className="text-right">
<GhostBtn onClick={r.run}>Download CSV</GhostBtn>