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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user