feat: drive Chart 3's ridgelines from real posterior draws, with fallback

Wire useDrawDistribution into RateDensityRidgeline so each race×sex ridge
uses a Gaussian KDE over that group's 500 real posterior draws when the
selected model's shard has loaded, falling back per-group to the analytic
fitSkewedInterval approximation otherwise. ApproxNote now only shows when
status !== 'ready'. Pass leaid/state through from ChartPanel.
This commit is contained in:
2026-08-11 09:30:46 -04:00
parent ea6d554e3d
commit b5941a78d1
2 changed files with 32 additions and 13 deletions
+31 -12
View File
@@ -2,23 +2,25 @@ import { useState } from 'react'
import { MODEL_QUADRANTS } from '../hooks/useApi.js' import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js' import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js' import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
import { kdeCurve } from '../utils/kde.js'
import { niceTicks } from '../utils/niceTicks.js' import { niceTicks } from '../utils/niceTicks.js'
import { useDrawDistribution } from '../hooks/useDrawDistribution.js'
import ChartLegend from '../components/ChartLegend.jsx' import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx' import ApproxNote from '../components/ApproxNote.jsx'
/** /**
* Modeled posterior density per race×sex group, for one selected model * Modeled posterior density per race×sex group, for one selected model
* (dropdown, default three-year + referral rate), split into Female/Male * (dropdown, default three-year + referral rate), split into Female/Male
* columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain * columns. Each ridge is drawn from that group's 500 real posterior draws
* SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve, * (Gaussian KDE) when available, falling back per-group to the analytic
* already smooth without a binning/curveBasis smoothing pass. * fitSkewedInterval/densityCurve approximation otherwise.
*/ */
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate
function buildGroupRow(row) { function buildGroupRow(row, draws) {
const enroll = row.stu_enroll || 0 const enroll = row.stu_enroll || 0
const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0 const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0
const rateMedian = (row.rate_median || 0) * 1000 const rateMedian = (row.rate_median || 0) * 1000
@@ -28,13 +30,16 @@ function buildGroupRow(row) {
race: row.race, race: row.race,
sex: row.sex, sex: row.sex,
observedRate, observedRate,
draws,
fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }), fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }),
} }
} }
export default function RateDensityRidgeline({ quadData }) { export default function RateDensityRidgeline({ quadData, leaid, state }) {
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL) const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
const rows = (quadData && quadData[selectedModel]) || [] const rows = (quadData && quadData[selectedModel]) || []
const groups = rows.map((r) => ({ race: r.race, sex: r.sex, stuEnroll: r.stu_enroll || 0 }))
const { status, drawsByGroup } = useDrawDistribution({ leaid, state, model: selectedModel, year: '21-22', groups })
const modelSelect = ( const modelSelect = (
<select <select
@@ -55,7 +60,7 @@ export default function RateDensityRidgeline({ quadData }) {
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}> <h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Predicted arrest rates by student group Predicted arrest rates by student group
</h3> </h3>
<ApproxNote /> {status !== 'ready' && <ApproxNote />}
</div> </div>
{modelSelect} {modelSelect}
</div> </div>
@@ -64,7 +69,7 @@ export default function RateDensityRidgeline({ quadData }) {
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p> <p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
) : ( ) : (
<> <>
<RidgeColumns rows={rows} /> <RidgeColumns rows={rows} drawsByGroup={drawsByGroup} />
<ChartLegend items={[ <ChartLegend items={[
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), ...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
@@ -75,7 +80,7 @@ export default function RateDensityRidgeline({ quadData }) {
) )
} }
function RidgeColumns({ rows }) { function RidgeColumns({ rows, drawsByGroup }) {
// Shared x-domain across both columns, so Female/Male are directly comparable. // Shared x-domain across both columns, so Female/Male are directly comparable.
const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000) const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000)
const allObserved = rows const allObserved = rows
@@ -87,14 +92,24 @@ function RidgeColumns({ rows }) {
return ( return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_COLUMNS.map(({ sex, label }) => ( {SEX_COLUMNS.map(({ sex, label }) => (
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} ticks={ticks} maxRate={niceMax} /> <SexRidgeColumn
key={sex}
label={label}
rows={rows.filter((r) => r.sex === sex)}
drawsByGroup={drawsByGroup}
ticks={ticks}
maxRate={niceMax}
/>
))} ))}
</div> </div>
) )
} }
function SexRidgeColumn({ label, rows, ticks, maxRate }) { function SexRidgeColumn({ label, rows, drawsByGroup, ticks, maxRate }) {
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow) const groups = RACE_ORDER
.map((race) => rows.find((r) => r.race === race))
.filter(Boolean)
.map((row) => buildGroupRow(row, drawsByGroup?.[`${row.race}_${row.sex}`]))
const width = 300 const width = 300
const rowHeight = 58 const rowHeight = 58
@@ -104,7 +119,11 @@ function SexRidgeColumn({ label, rows, ticks, maxRate }) {
const xScale = (val) => margin.left + (val / maxRate) * innerWidth const xScale = (val) => margin.left + (val / maxRate) * innerWidth
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 })) const curves = groups.map((g) =>
g.draws && g.draws.length > 0
? kdeCurve(g.draws, { min: 0, max: maxRate, n: 60 })
: densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }),
)
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9) const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
const peakHeight = rowHeight * 0.82 const peakHeight = rowHeight * 0.82
+1 -1
View File
@@ -97,7 +97,7 @@ export default function ChartPanel({ district, state }) {
<RateByGroupBar data={rateByGroup} leaid={district.leaid} state={state} /> <RateByGroupBar data={rateByGroup} leaid={district.leaid} state={state} />
<RateDensityRidgeline quadData={data.quadData} /> <RateDensityRidgeline quadData={data.quadData} leaid={district.leaid} state={state} />
</div> </div>
{/* Methodology footer */} {/* Methodology footer */}