Methodology

Targets, features, models, metrics, baselines, train/val/test split

Targets

Three weekly targets are forecast independently:

  1. target_count — Poisson / negative-binomial regression of weekly incident counts. Range 0–3 in TEST.
  2. target_any (binary, ≥1 incident in week) — logistic regression. Base rate in the TEST window: 22% (23 of 104 weeks).
  3. target_high (binary, ≥2 incidents in week) — logistic regression. Base rate in the TEST window: 1% (1 of 104 weeks).

All targets share the same weekly panel and the same feature set.

Features

Built weekly from signals observable before the outcome week. No-leak invariant: each feature f for week t uses only data from weeks ≤ t−1.

  • Autoregressive (incidents_prev_{1,2,4,8,13,26,52}wk): rolling counts of incidents in the prior 1, 2, 4, 8, 13, 26, 52 weeks.
  • incidents_prev_year_matching_woy: lag-52w count for the same week-of-year.
  • Public-coverage proxy (incidents_count_lag{k} for k ∈ {1,2,4,8,13,26,52}): monthly mass-shooting incident count from gtrends_mass_shooting.csv, linearly interpolated to week-grain and lagged. Monthly values only become available after month end, so a future monthly total never leaks into the same month.
  • NICS firearm background checks (nics_{handgun,long_gun,multiple,other,total}_lag{1,4,13,26,52}): monthly national sums, interpolated to weekly via a piecewise-linear triangular window whose anchors are the month start, midpoint, and end (weights sum to the monthly total; never interpolate to an adjacent month's value). Lags of 1, 4, 13, 26, and 52 weeks. NICS data ends Sep 2023; the last observed weekly level is carried forward through Dec 2023 rather than fabricated.
  • Calendar (sin/cos pairs): month, day of year, ISO week of year.
  • Holiday & school-session flags: ISO week containing Jan 1 / Jul 4 / Thanksgiving / Christmas-New Year. School-session indicator is the fraction of Monday-Friday dates in a week that fall between Aug 15 and May 31.
  • Population (slow-moving scale control).

Models

Each model file exposes fit_predict(train, test, target_col, feature_cols):

#ModelFamilyNotes
1naive_seasonalbaseliney_t = mean of historical same-week-of-year over prior 5y (Poisson quantiles)
2nb_lagstatNegative-binomial GLM, lags-only features
3nb_fullstatNegative-binomial GLM, full feature set, light ridge regularization
4logistic_anystatLogit on the ≥1 target, slopes penalized (intercept unpenalized)
5logistic_highstatLogit on the ≥2 target
6rf_any (conditional)mlsklearn RandomForestClassifier on ≥1

Random forest was intentionally dropped. The build rule allowed the RF commit only if import sklearn completed in under 1 second; the measured cold-import was 1.54 seconds, past the cutoff. The omission was made before results were examined and is recorded in results/model_metadata.json under rf_skip_reason. The two required logistic models are the binding deliverable.

Metrics & validation

  • Train — 2014-W01 → 2021-W52 (418 weeks).
  • Val (last chronological 10% of train) — 2021-W01 → 2021-W52.
  • TEST — 2022-W01 → 2023-W52 (104 weeks, never fit).

Backtest via rolling-origin inner cross-validation on train (3 folds in 2019, 2020, 2021) plus fixed held-out test. The metrics that ship in results/metrics_*.csv are TEST-only; results/cv_metrics.csv carries the inner-CV scores.

TaskCount metricsBinary metrics
countRMSE, MAE, stabilized MAPE, pinball @ {0.5, 0.8, 0.9}
anyAUC-PR, AUC-ROC, Brier
highAUC-PR, AUC-ROC, Brier

Every model is reported alongside the seasonal baseline for the same task.

Stabilized MAPE uses max(|y|, 1) as denominator (ordinary MAPE is undefined when most true counts are zero). Labeled explicitly in code.

Pipeline

make all
  data     →  Load + validate CSVs
  features →  Build 522-week panel, compute 40 features
  train    →  Fit 5 models on train (with rolling-origin inner-CV)
  eval     →  Evaluate on held-out test (104 weeks, 2022-W01 → 2023-W52)
  figures  →  Observed-vs-predicted, feature importance, residuals, ROC, calibration
  paper    →  (rebuilt in pipeline repo at paper/manuscript.md)
  test     →  pytest -q (loaders, features, metrics)

Each target is make-stage independent — make eval regenerates metrics without re-training. Total wall-clock on a single CPU: ~2 minutes.