/** * Chooses an honest visual profile for one group's posterior predictive draws. * * In a sparse district the posterior predictive is a *discrete count* * distribution, not a smooth one. Carson City NV (3200390) has a 53-student * AI/AN female cell where a single arrest is 18.9 per 1,000: the draws take * four distinct values, and a Gaussian KDE renders those four spikes as a lumpy * smear that reads as a rendering bug rather than as a finding about the data. * * So: few distinct values → draw the actual probability mass at each achievable * rate. Many → the smooth KDE, delegated to `kde.js` unchanged (its bandwidth * clamp was tuned for exactly these zero-inflated posteriors — see * BANDWIDTH_FLOOR_DIVISOR there; do not re-derive it here). */ import { kdeCurve } from './kde.js' import { toRates } from './pooling.js' /** * At or below this many distinct predicted counts, the draws are shown as * discrete mass rather than smoothed. * * 12 is comfortably above the 3–5 distinct values a genuinely sparse cell * produces and comfortably below the ~40+ a district with real arrest volume * produces, so the switch happens well away from either regime rather than * flickering at the boundary. */ export const MASS_MAX_DISTINCT = 12 /** * @param {number[] | null | undefined} counts - predicted counts, indexed by draw * @param {number | null | undefined} enroll - students in the group * @param {{min?: number, max?: number, n?: number}} [domain] - the x-range the * profile will be drawn over, in rate per 1,000. Passed straight to * `kdeCurve`, whose bandwidth clamp is relative to this width. * @returns {{kind: 'kde'|'mass', points: Array<{x: number, y: number}>, * maxY: number, step: number} | null} * `null` when there is nothing to draw (no draws, or no denominator). * For `kind: 'mass'`, `y` is a probability and `step` is the spacing between * achievable rates — the bar width for a filled staircase. For `kind: 'kde'`, * `y` is a density and `step` is 0. * `maxY` is the profile's own peak: because the two kinds carry different y * units, a chart overlaying several groups must normalize each profile by its * own `maxY` rather than by a shared maximum. */ export function densityProfile(counts, enroll, domain = {}) { const rates = toRates(counts, enroll) if (!rates.length) return null // Counts are integers, so consecutive achievable rates are exactly one // student-rate apart — pass that explicitly rather than inferring it from the // gaps actually observed, which overstates the bar width when (say) only // counts 0 and 3 appear. return rateProfile(rates, domain, 1000 / enroll) } /** * The same choice made directly on a set of rates, for quantities that are * already differences rather than count/denominator pairs (see * `utils/groupDifference.js`). A difference of two discrete posteriors is * itself discrete, and deserves the same honesty. * * @param {number[] | null | undefined} rates - values on the plotted scale * @param {{min?: number, max?: number, n?: number}} [domain] * @param {number} [explicitStep] - known spacing between achievable values; * inferred from the smallest observed gap when omitted. * @returns {{kind: 'kde'|'mass', points: Array<{x: number, y: number}>, * maxY: number, step: number} | null} */ export function rateProfile(rates, domain = {}, explicitStep = 0) { if (!rates?.length) return null const { min = 0, max, n = 60 } = domain // Round before tallying so two draws that differ only in floating-point noise // count as one achievable value rather than two. const tally = new Map() for (const r of rates) { const key = Math.round(r * 1e9) / 1e9 tally.set(key, (tally.get(key) || 0) + 1) } if (tally.size <= MASS_MAX_DISTINCT) { const total = rates.length const points = [...tally.entries()] .map(([x, freq]) => ({ x, y: freq / total })) .sort((a, b) => a.x - b.x) return { kind: 'mass', points, maxY: Math.max(...points.map((p) => p.y)), step: explicitStep > 0 ? explicitStep : inferStep(points, max, min), } } const points = kdeCurve(rates, { min, max, n }) return { kind: 'kde', points, maxY: Math.max(...points.map((p) => p.y)), step: 0, } } /** Smallest gap between achievable values; a visible default for a single point. */ function inferStep(points, max, min) { let smallest = Infinity for (let i = 1; i < points.length; i++) { const gap = points[i].x - points[i - 1].x if (gap > 0 && gap < smallest) smallest = gap } if (Number.isFinite(smallest)) return smallest const width = Number.isFinite(max) && max > min ? max - min : 1 return width / 40 }