feat: drive Chart 2's box plot from real posterior draws, with fallback
This commit is contained in:
@@ -2,33 +2,40 @@ import ChartLegend from '../components/ChartLegend.jsx'
|
|||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
import ApproxNote from '../components/ApproxNote.jsx'
|
||||||
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
|
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
|
||||||
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
||||||
|
import { quantile } from '../utils/kde.js'
|
||||||
import { niceTicks } from '../utils/niceTicks.js'
|
import { niceTicks } from '../utils/niceTicks.js'
|
||||||
|
import { useDrawDistribution } from '../hooks/useDrawDistribution.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arrest rate by student group, most recent year, disaggregated into two
|
* Arrest rate by student group, most recent year, disaggregated into two
|
||||||
* panels (Female / Male), each a horizontal box-and-whisker across the 4
|
* panels (Female / Male), each a horizontal box-and-whisker across the 4
|
||||||
* race categories. Whisker = the model's reported 90% interval, box = the
|
* race categories. Whisker = the model's reported 90% interval; box = the
|
||||||
* fitted approximation's 25th-75th percentile, white tick = median, dark
|
* 25th-75th percentile of the group's 500 real posterior draws (or, if draws
|
||||||
* diamond = observed rate.
|
* are unavailable, the fitSkewedInterval approximation); white tick =
|
||||||
|
* median; dark diamond = observed rate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||||
const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
||||||
const ROW_HEIGHT = 34
|
const ROW_HEIGHT = 34
|
||||||
|
const MODEL = 'unified_m3_mod' // matches ChartPanel's WAVE_MODEL, which built `data`
|
||||||
|
|
||||||
function buildBox(d) {
|
function buildBox(d, draws) {
|
||||||
const median = Math.max(d.modeledMedian || 0, 0)
|
const median = Math.max(d.modeledMedian || 0, 0)
|
||||||
const lower = Math.max(Math.min(d.rateLower ?? median, median), 0)
|
const lower = Math.max(Math.min(d.rateLower ?? median, median), 0)
|
||||||
const upper = Math.max(d.rateUpper ?? median, median)
|
const upper = Math.max(d.rateUpper ?? median, median)
|
||||||
const fit = fitSkewedInterval({ median, lower, upper })
|
|
||||||
return {
|
if (draws && draws.length > 0) {
|
||||||
lower, upper, median,
|
return { lower, upper, median, q1: Math.max(quantile(draws, 0.25), 0), q3: Math.max(quantile(draws, 0.75), median) }
|
||||||
q1: Math.max(fit.quantile(0.25), 0),
|
|
||||||
q3: Math.max(fit.quantile(0.75), median),
|
|
||||||
}
|
}
|
||||||
|
const fit = fitSkewedInterval({ median, lower, upper })
|
||||||
|
return { lower, upper, median, q1: Math.max(fit.quantile(0.25), 0), q3: Math.max(fit.quantile(0.75), median) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RateByGroupBar({ data }) {
|
export default function RateByGroupBar({ data, leaid, state }) {
|
||||||
|
const groups = data.map((d) => ({ race: d.race, sex: d.sex, stuEnroll: d.enrollment }))
|
||||||
|
const { status, drawsByGroup } = useDrawDistribution({ leaid, state, model: MODEL, year: '21-22', groups })
|
||||||
|
|
||||||
const maxRate = Math.max(
|
const maxRate = Math.max(
|
||||||
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
||||||
0.5
|
0.5
|
||||||
@@ -40,11 +47,18 @@ export default function RateByGroupBar({ data }) {
|
|||||||
<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)' }}>
|
||||||
Arrest rate by student group — 2021–22 (per 1,000)
|
Arrest rate by student group — 2021–22 (per 1,000)
|
||||||
</h3>
|
</h3>
|
||||||
<ApproxNote />
|
{status !== 'ready' && <ApproxNote />}
|
||||||
|
|
||||||
<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_PANELS.map(({ sex, label }) => (
|
{SEX_PANELS.map(({ sex, label }) => (
|
||||||
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} ticks={ticks} niceMax={niceMax} />
|
<SexPanel
|
||||||
|
key={sex}
|
||||||
|
label={label}
|
||||||
|
rows={data.filter((d) => d.sex === sex)}
|
||||||
|
drawsByGroup={drawsByGroup}
|
||||||
|
ticks={ticks}
|
||||||
|
niceMax={niceMax}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,14 +70,16 @@ export default function RateByGroupBar({ data }) {
|
|||||||
]} />
|
]} />
|
||||||
|
|
||||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||||
Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported
|
{status === 'ready'
|
||||||
90% interval; white tick = median. The dark diamond is the observed rate.
|
? "Box = 25th–75th percentile of 500 real posterior draws; whisker = the model's reported 90% interval; white tick = median."
|
||||||
|
: "Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported 90% interval; white tick = median."}
|
||||||
|
{' '}The dark diamond is the observed rate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SexPanel({ label, rows, ticks, niceMax }) {
|
function SexPanel({ label, rows, drawsByGroup, ticks, niceMax }) {
|
||||||
const width = 300
|
const width = 300
|
||||||
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
|
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
|
||||||
const innerWidth = width - margin.left - margin.right
|
const innerWidth = width - margin.left - margin.right
|
||||||
@@ -99,7 +115,8 @@ function SexPanel({ label, rows, ticks, niceMax }) {
|
|||||||
const boxTop = midY - ROW_HEIGHT * 0.26
|
const boxTop = midY - ROW_HEIGHT * 0.26
|
||||||
const boxBottom = midY + ROW_HEIGHT * 0.26
|
const boxBottom = midY + ROW_HEIGHT * 0.26
|
||||||
const color = raceColor(d.race)
|
const color = raceColor(d.race)
|
||||||
const box = buildBox(d)
|
const draws = drawsByGroup?.[`${d.race}_${d.sex}`]
|
||||||
|
const box = buildBox(d, draws)
|
||||||
const observedX = xScale(d.observedRate)
|
const observedX = xScale(d.observedRate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default function ChartPanel({ district, state }) {
|
|||||||
}}>
|
}}>
|
||||||
<ArrestsOverTime data={timeSeriesData} modelId={WAVE_MODEL} />
|
<ArrestsOverTime data={timeSeriesData} modelId={WAVE_MODEL} />
|
||||||
|
|
||||||
<RateByGroupBar data={rateByGroup} />
|
<RateByGroupBar data={rateByGroup} leaid={district.leaid} state={state} />
|
||||||
|
|
||||||
<RateDensityRidgeline quadData={data.quadData} />
|
<RateDensityRidgeline quadData={data.quadData} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getDb } from '../utils/duckdbClient.js'
|
||||||
|
|
||||||
|
const HF_BASE = 'https://huggingface.co/datasets/civilytics/crdc-school-arrest-rates/resolve/main/parquet'
|
||||||
|
|
||||||
|
// Module-level cache: one registered duckdb-wasm file buffer per
|
||||||
|
// (model, year, state) shard, shared across every component instance and
|
||||||
|
// district navigated to in this browser session. See Global Constraints —
|
||||||
|
// in-memory only, no persistence across page loads.
|
||||||
|
const shardCache = new Map()
|
||||||
|
|
||||||
|
function shardKey(model, year, state) {
|
||||||
|
return `${model}__${year}__${state}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureShardRegistered(db, model, year, state) {
|
||||||
|
const key = shardKey(model, year, state)
|
||||||
|
if (!shardCache.has(key)) {
|
||||||
|
shardCache.set(
|
||||||
|
key,
|
||||||
|
(async () => {
|
||||||
|
const url = `${HF_BASE}/model_id=${model}/YEAR=${year}/LEA_STATE=${state}/data_0.parquet`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch draw shard: HTTP ${res.status}`)
|
||||||
|
const buffer = new Uint8Array(await res.arrayBuffer())
|
||||||
|
const fileName = `${key}.parquet`
|
||||||
|
await db.registerFileBuffer(fileName, buffer)
|
||||||
|
return fileName
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return shardCache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches real posterior draws for one district/model/year from the Hugging
|
||||||
|
* Face parquet dataset via duckdb-wasm, converts predicted counts to
|
||||||
|
* rate-per-1,000 using each group's stu_enroll (not present in the draws
|
||||||
|
* table itself — joined here from data this app already has), and returns
|
||||||
|
* them keyed by "RACE_SEX".
|
||||||
|
*
|
||||||
|
* @param {{leaid: string, state: string, model: string, year: string,
|
||||||
|
* groups: Array<{race: string, sex: string, stuEnroll: number}>}} params
|
||||||
|
* @returns {{status: 'loading'|'ready'|'error', drawsByGroup: Record<string, number[]> | null}}
|
||||||
|
*/
|
||||||
|
export function useDrawDistribution({ leaid, state, model, year, groups }) {
|
||||||
|
const [status, setStatus] = useState('loading')
|
||||||
|
const [drawsByGroup, setDrawsByGroup] = useState(null)
|
||||||
|
|
||||||
|
// groups is typically a fresh array literal every render; derive a stable
|
||||||
|
// primitive so the effect only re-runs when its actual content changes.
|
||||||
|
const groupsSignature = (groups || []).map((g) => `${g.race}:${g.sex}:${g.stuEnroll}`).join(',')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leaid || !state || !model || !year || !groups?.length) return
|
||||||
|
let cancelled = false
|
||||||
|
setStatus('loading')
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
let conn
|
||||||
|
try {
|
||||||
|
const db = await getDb()
|
||||||
|
const fileName = await ensureShardRegistered(db, model, year, state)
|
||||||
|
conn = await db.connect()
|
||||||
|
const stmt = await conn.prepare(`SELECT RACE, SEX, pred FROM read_parquet('${fileName}') WHERE LEAID = ?`)
|
||||||
|
const table = await stmt.query(leaid)
|
||||||
|
await stmt.close()
|
||||||
|
const rows = table.toArray().map((r) => r.toJSON())
|
||||||
|
|
||||||
|
const enrollByGroup = {}
|
||||||
|
for (const g of groups) enrollByGroup[`${g.race}_${g.sex}`] = g.stuEnroll || 0
|
||||||
|
|
||||||
|
const byGroup = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.RACE}_${row.SEX}`
|
||||||
|
const enroll = enrollByGroup[key]
|
||||||
|
if (!enroll) continue
|
||||||
|
const rate = (Number(row.pred) / enroll) * 1000
|
||||||
|
;(byGroup[key] ??= []).push(rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setDrawsByGroup(byGroup)
|
||||||
|
setStatus('ready')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('useDrawDistribution failed:', err)
|
||||||
|
if (!cancelled) setStatus('error')
|
||||||
|
} finally {
|
||||||
|
if (conn) await conn.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [leaid, state, model, year, groupsSignature])
|
||||||
|
|
||||||
|
return { status, drawsByGroup }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user