fix: report the API's 95% intervals as 95%, not 90%
The API returns 95% intervals: validate_interval() in crdc-arrests/api/R/validate.R defaults to 95L and this app never passes `interval=`. Two places claimed 90% anyway. fitSkewedInterval's `intervalMass` defaulted to 0.90, so the analytic fallback fitted 95% bounds as if they covered 90% of the mass. That divides each half-interval by 1.645 instead of 1.960 and understates sigma by ~16% — the fallback drew a distribution visibly narrower than the model's own, in the one code path where we have no draws to check it against. ArrestsOverTime's legend read "Modeled (median + 90% interval)" while plotting count_lower/count_upper, which are the same 95% bounds. Also exports probit() from distributionApprox.js so the Agresti-Coull port can reuse it rather than carrying a second qnorm implementation.
This commit is contained in:
@@ -129,7 +129,9 @@ export default function ArrestsOverTime({ data, modelId }) {
|
||||
|
||||
<ChartLegend items={[
|
||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||||
// 95%, not 90%: these bars are the API's count_lower/count_upper, and
|
||||
// validate_interval() defaults to 95 (the app never passes interval=).
|
||||
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 95% interval)' },
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
|
||||
@@ -42,9 +42,18 @@ function standardNormalCdf(z) {
|
||||
return 0.5 * (1 + erf(z / Math.SQRT2))
|
||||
}
|
||||
|
||||
// Peter Acklam's rational approximation of the inverse standard normal CDF
|
||||
// (probit), relative error < 1.15e-9. Supports arbitrary intervalMass.
|
||||
function probit(p) {
|
||||
/**
|
||||
* Peter Acklam's rational approximation of the inverse standard normal CDF
|
||||
* (probit / R's `qnorm`), relative error < 1.15e-9. Supports arbitrary
|
||||
* intervalMass.
|
||||
*
|
||||
* Exported so `agrestiCoull.js` can reuse it — the app should carry exactly one
|
||||
* probit implementation.
|
||||
*
|
||||
* @param {number} p - probability in (0, 1)
|
||||
* @returns {number}
|
||||
*/
|
||||
export function probit(p) {
|
||||
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
|
||||
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01]
|
||||
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
|
||||
@@ -70,11 +79,15 @@ function probit(p) {
|
||||
|
||||
/**
|
||||
* @param {{median:number, lower:number, upper:number, intervalMass?:number, floorAtZero?:boolean}} p
|
||||
* intervalMass: fraction of probability covered by [lower, upper] — the
|
||||
* API's rate/count interval bounds are a 90% interval, so default 0.90.
|
||||
* intervalMass: fraction of probability covered by [lower, upper]. The API's
|
||||
* rate/count bounds are a **95%** interval — `validate_interval()` in
|
||||
* crdc-arrests/api/R/validate.R defaults to 95 and the app never passes
|
||||
* `interval=` — so the default is 0.95. Fitting 95% bounds as if they were
|
||||
* 90% understates sigma by ~16% and draws a distribution narrower than the
|
||||
* model's own.
|
||||
* @returns {{ median:number, sigmaLeft:number, sigmaRight:number, pdf:(x:number)=>number, cdf:(x:number)=>number }}
|
||||
*/
|
||||
export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.90, floorAtZero = true }) {
|
||||
export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.95, floorAtZero = true }) {
|
||||
const z = probit((1 + intervalMass) / 2)
|
||||
|
||||
let sigmaLeft = (median - lower) / z
|
||||
|
||||
Reference in New Issue
Block a user