Source code for morie.mrm_primitives.synthetic_exposure
"""Synthetic small-area-estimated exposure offset.
Adapted from Laniyonu & Goff (2021) BMC Psychiatry 21(1):500. The
key trick: when you need a rate-per-hidden-subpopulation (force-per-
PwSMI, contact-per-undocumented, contact-per-homeless) and no
administrative census of that subpopulation exists, you can:
1. Fit P(trait | covariates) on a national probability sample
(NCS-R for SMI; ACS-style survey for other traits) using ONLY
covariates also available at the area level.
2. Apply the fitted coefficients to area-level marginals from the
ACS / census to predict P(trait) per area.
3. Multiply by area-level adult population to get a synthetic
"population at risk" denominator.
Generalises far beyond Laniyonu & Goff's SMI application:
- homelessness rates of police force
- LGBTQ stop-and-frisk rates
- undocumented-immigrant ICE-contact rates
- any "rate per hidden subpopulation" estimand
The primitive returns the per-area exposure offset suitable for use
in a Poisson / negative-binomial GLM as the ``exposure=`` argument.
We intentionally use a simple logistic via :mod:`scipy.optimize` so
the primitive runs on the base morie install (no statsmodels
dependency-of-dependency). Callers who want fancier survey-design
weighting can pass a fitted estimator via the ``fit_callable``
argument.
"""
from __future__ import annotations
from collections.abc import Callable
from morie.fn import _array_core as np
from morie.fn import _frame_core as pd
[docs]
def synthetic_area_exposure(
*,
survey_df: pd.DataFrame,
survey_trait_col: str,
survey_covariate_cols: list[str],
area_df: pd.DataFrame,
area_population_col: str,
fit_callable: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None,
return_per_area_rate: bool = False,
) -> pd.Series | tuple[pd.Series, pd.Series]:
"""Compute synthetic exposure offset per area.
Parameters
----------
survey_df : pd.DataFrame
Survey microdata (e.g., NCS-R long-form). One row per
respondent. Must contain ``survey_trait_col`` (0/1) and the
named covariates.
survey_trait_col : str
Binary column indicating the trait of interest (e.g., SMI).
survey_covariate_cols : list[str]
Covariates available in BOTH the survey and the area dataset.
Logistic coefficients are fit on the survey and applied to
the area marginals.
area_df : pd.DataFrame
One row per area (tract, precinct, etc.). Must contain the
same covariate columns as ``survey_covariate_cols`` (as
area-level *means* / *proportions*).
area_population_col : str
Adult-population column in ``area_df``.
fit_callable : callable, optional
Override for the default logistic fit. Signature:
``fit_callable(X, y) -> coef``, where ``coef`` is a vector of
length len(survey_covariate_cols) + 1 (intercept first).
return_per_area_rate : bool, default False
If True, returns a tuple (exposure_offset, predicted_rate).
Returns
-------
pd.Series indexed by ``area_df.index``: synthetic exposure offset
= predicted P(trait | area marginals) × area population.
Suitable for ``exposure=`` in a Poisson / negative-binomial
GLM that counts trait-specific events.
Notes
-----
This is "synthetic" because the trait is not directly observed at
the area level — it's predicted from a national probability
sample. Cite the underlying survey explicitly when reporting.
"""
# 1. Fit P(trait | covariates) on the survey
if fit_callable is None:
coef = _logistic_fit(
survey_df[survey_covariate_cols].to_numpy(dtype=float),
survey_df[survey_trait_col].to_numpy(dtype=float),
)
else:
coef = fit_callable(
survey_df[survey_covariate_cols].to_numpy(dtype=float),
survey_df[survey_trait_col].to_numpy(dtype=float),
)
# 2. Apply to area marginals
X_area = area_df[survey_covariate_cols].to_numpy(dtype=float)
X_area_with_int = np.column_stack([np.ones(X_area.shape[0]), X_area])
eta = X_area_with_int @ coef
pred_rate = 1.0 / (1.0 + np.exp(-eta))
# 3. Multiply by population to get exposure offset
exposure = pred_rate * area_df[area_population_col].to_numpy(dtype=float)
exposure_series = pd.Series(exposure, index=area_df.index, name="synthetic_exposure")
if return_per_area_rate:
rate_series = pd.Series(pred_rate, index=area_df.index, name="predicted_rate")
return exposure_series, rate_series
return exposure_series
def _logistic_fit(X: np.ndarray, y: np.ndarray, max_iter: int = 200, tol: float = 1e-6) -> np.ndarray:
"""Minimal-dependency logistic regression via Newton-IRLS.
Returns coef of length p+1 (intercept first). Adequate for the
SAE use case where the survey is well-conditioned. Falls back
gracefully on non-convergence by returning the last iterate.
"""
n, p = X.shape
X_int = np.column_stack([np.ones(n), X])
beta = np.zeros(p + 1)
for _ in range(max_iter):
eta = X_int @ beta
# Stable sigmoid
mu = np.where(eta >= 0, 1.0 / (1.0 + np.exp(-eta)), np.exp(eta) / (1.0 + np.exp(eta)))
# Weights for IRLS
w = mu * (1 - mu)
# Guard against zero-variance weight rows
w = np.clip(w, 1e-10, None)
XW = X_int * w[:, None]
XWX = XW.T @ X_int
XWz = XW.T @ (X_int @ beta + (y - mu) / w)
try:
new_beta = np.linalg.solve(XWX, XWz)
except np.linalg.LinAlgError:
return beta # singular Hessian — return last good iterate
if np.max(np.abs(new_beta - beta)) < tol:
return new_beta
beta = new_beta
return beta