thermostrife.inference¶
Case-crossover null + conditional logit + stratified permutation, plus
the descriptive H1 tests ported from the original analysis.py.
.. py:module:: thermostrife.inference
Case-crossover null model + H1 descriptive tests + 1σ rescaling.
The H1 tests (Wilcoxon signed-rank, sign, bootstrap CI) are ports of
the original analysis.py scaffold kept under _legacy/; the H2
case-crossover engine is the headline contribution of this module.
All tests run unconditionally on every event regardless of
significance, following the project rule that decision logic belongs
in interpretation, not in the pipeline.
.. py:function:: wilcoxon_signed_rank(anomaly: numpy.ndarray) -> dict :module: thermostrife.inference
One-sided Wilcoxon signed-rank that median(anomaly) > 0.
.. py:function:: sign_test(anomaly: numpy.ndarray) -> dict :module: thermostrife.inference
Binomial sign test on P(anomaly > 0) > 0.5.
.. py:function:: bootstrap_mean_ci(anomaly: np.ndarray, n_boot: int = 10000, alpha: float = 0.05, rng: np.random.Generator | None = None) -> dict :module: thermostrife.inference
Bootstrap percentile CI for the mean anomaly.
.. py:function:: daylight_hours(lat_deg: float, when: ~datetime.date) -> float :module: thermostrife.inference
Closed-form solar daylight hours for when at latitude lat_deg.
Uses the standard astronomical-twilight-free approximation
(sunrise/sunset by solar declination); accurate to ~10 min outside
the polar circles, which is well inside the noise of our daily-Tmax
inference and avoids a hard dependency on astral.
:returns: Daylight in hours. 0.0 at polar night, 24.0 at polar day.
.. py:function:: build_case_crossover_frame(events: list[dict]) -> pandas.DataFrame :module: thermostrife.inference
Stack per-event case + control rows into one long DataFrame.
Each events element must contain:
event_id(str): stratum identifier.lat(float),lon(float): for the daylight covariate.when(date): event day.tmax_event_c(float): event-day Tmax (the case row).baseline(DataFrame): one row per control day, index = date, columntmaxin °C.
Returns a long DataFrame with columns
[event_id, day, is_case, tmax_c, daylight_h], one row per
(event, day). Cases get is_case = 1; controls get 0.
.. py:function:: case_crossover_conditional_logit(frame: pandas.DataFrame, *, covariates: list[str] | None = None) -> dict :module: thermostrife.inference
Conditional logistic regression on matched case-control sets.
Fits
logit P(case | event_id) = β · tmax_c + γ · covariates
using a per-event-id stratum (conditional likelihood); the stratum
intercepts are integrated out. exp(β) is the odds ratio of
uprising-on-this-day per +1 °C above the local same-month baseline.
:param frame: Output of :func:build_case_crossover_frame.
:param covariates: Extra columns to include alongside tmax_c
(default: ["daylight_h"]).
:returns: Dict with the headline estimates and metadata.
.. py:function:: stratified_permutation(frame: pd.DataFrame, n_perm: int = 10000, rng: np.random.Generator | None = None) -> dict :module: thermostrife.inference
Within-event label-shuffle test on the mean case-vs-control gap.
Under H0 the case-day Tmax is exchangeable with the baseline-day Tmax within the same event stratum. For each event we shuffle the case label across (case + controls), compute the (event-mean of case-Tmax) minus (event-mean of control-Tmax), and average over events.
Two-sided p = fraction of permutations whose absolute statistic is ≥ the observed statistic.
.. py:function:: stratify_case_crossover(events: list[dict], key_fn, *, min_events: int = 5, covariates: list[str] | None = None) -> dict :module: thermostrife.inference
Split events into strata by key_fn(event) -> str and run H2 per stratum.
Returns a dict mapping stratum label to the
:func:case_crossover_conditional_logit result dict. Strata with
fewer than min_events events are skipped with a
{'skipped': True, 'reason': ...} marker so the caller can still
see them in the report.
.. py:function:: event_anomaly_profile(events: list[dict], offsets: tuple[int, …] = (-7, -2, -1, 0, 1, 2, 7), fetch_fn=
Fetch per-event Tmax at each offset and return as a long frame.
For each event we look up Tmax on day when + offset via the
same source that resolved the event day, subtract the event’s
baseline mean to get an anomaly, and stack the rows. Empty offsets
(the fetcher returned None) are skipped silently — the
superposed-epoch plotter handles the resulting ragged n per
offset.
Returns columns: event_id, offset_days, anomaly_C.
.. py:function:: h3_within_event_contrast(events: list[dict], window_offsets: tuple[int, …] = (0, -1), surround_offsets: tuple[int, …] = (-7, 7), fetch_fn=
Per-event paired contrast: mean anomaly {t, t-1} minus {t-7, t+7}.
For each resolved event, fetch Tmax on day t + offset for each
offset in window_offsets ∪ surround_offsets via the same
source/station that resolved the event day, convert to anomaly by
subtracting the existing baseline mean, then take
diff = mean(anomaly @ window_offsets) − mean(anomaly @ surround_offsets)
A positive diff means the event day and its immediate neighbour
sit higher above the local baseline than days a week away — the
signature of a “hot day triggers riot” mechanism. A flat profile
(diff ≈ 0) is consistent with the alternative “hot week
happened to contain an event” explanation.
Tests the across-event distribution of diff with a one-sided
Wilcoxon signed-rank against H₁: median > 0. Returns the n actually
used (events where every offset resolved), the mean and median
diff, a bootstrap 95 % CI on the mean, and the p-value.
.. py:function:: hsiang_sigma_rescaled(events: list[dict]) -> dict :module: thermostrife.inference
Per-event z-score (event - baseline_mean) / baseline_std, averaged.
Following Burke, Hsiang & Miguel (2015), expressing each event’s anomaly as a multiple of its own baseline-window standard deviation makes effects comparable across stations and climates. Their headline number for interpersonal violence is +2.4 % per 1 σ contemporaneous warming.
Returns the mean z across events, a bootstrap 95 % CI, and the fraction of events with z > 0.
.. py:function:: benjamini_hochberg(pvalues: dict[str, float], alpha: float = 0.05) -> dict :module: thermostrife.inference
Benjamini–Hochberg step-up FDR correction.
:param pvalues: Dict mapping test name → raw p-value. :param alpha: Target false-discovery rate (default 0.05).
:returns: Dict with per-test raw_p, bh_adjusted_p (the standard
monotone-adjusted q-value), bonferroni_adjusted_p, and the
boolean bh_reject / bonferroni_reject flags. Also
exposes the family-level threshold pair so the report can quote
them.