Arsenic wells: logistic residuals

Source: Arsenic/arsenic_logistic_residuals.Rmd

This ports the ROS binned-residual diagnostics for logistic regression. The fitted models mirror the R formulas through lapylace Bernoulli-logit fits, and matplotlib recreates the residual checks.

Setup and data

from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import lapylace as lp
from scipy.special import expit

root = Path("../../ROS-Examples")
wells = pd.read_csv(root / "Arsenic/data/wells.csv")
wells["dist100"] = wells["dist"] / 100
wells["educ4"] = wells["educ"] / 4
wells["c_dist100"] = wells["dist100"] - wells["dist100"].mean()
wells["c_arsenic"] = wells["arsenic"] - wells["arsenic"].mean()
wells["c_educ4"] = wells["educ4"] - wells["educ4"].mean()
wells.head()
switch arsenic dist dist100 assoc educ educ4 c_dist100 c_arsenic c_educ4
0 1 2.36 16.826000 0.16826 0 0 0.0 -0.315059 0.70307 -1.207119
1 1 0.71 47.321999 0.47322 0 0 0.0 -0.010099 -0.94693 -1.207119
2 0 2.07 20.966999 0.20967 0 10 2.5 -0.273649 0.41307 1.292881
3 1 1.15 21.486000 0.21486 0 12 3.0 -0.268459 -0.50693 1.792881
4 1 1.10 40.874001 0.40874 1 14 3.5 -0.074579 -0.55693 2.292881
def coef_medians(fit):
    return pd.Series(
        [np.median(fit.alpha_draws()), *np.median(fit.beta_draws(), axis=0)],
        index=["Intercept", *fit.columns],
    )

def posterior_mean_probability(fit, data):
    return fit.posterior_epred(data).mean(axis=0)

Fit the interaction model

fit_8 = lp.stan_glm(
    "switch ~ c_dist100 + c_arsenic + c_educ4 + c_dist100:c_educ4 + c_arsenic:c_educ4",
    data=wells,
    family=lp.bernoulli(),
    prior=lp.normal(0, 2.5),
    prior_intercept=lp.normal(0, 5),
    chains=4,
    parallel_chains=4,
    iter_warmup=500,
    iter_sampling=1000,
    seed=15301,
    refresh=100,
)
coef_8 = coef_medians(fit_8)
pred8 = posterior_mean_probability(fit_8, wells)
resid8 = wells["switch"] - pred8
coef_8.round(2)
                                                                                                                                                                                                                                                                                                                                
Intercept            0.35
c_dist100           -0.92
c_arsenic            0.49
c_educ4              0.19
c_dist100:c_educ4    0.33
c_arsenic:c_educ4    0.08
dtype: float64

The null classification rule predicts the majority class; the fitted rule classifies by whether the fitted probability is above 0.5.

error_rate_null = np.mean(np.round(np.abs(wells["switch"] - pred8.mean())))
error_rate = np.mean(np.round(np.abs(wells["switch"] - pred8)))
round(error_rate_null, 2), round(error_rate, 2)
(np.float64(0.42), np.float64(0.38))

Raw residual plot

fig, ax = plt.subplots(figsize=(5, 4))
ax.axhline(0, color="0.7", lw=1)
ax.scatter(pred8, resid8, s=5, color="black", alpha=0.35)
ax.set(xlabel="Estimated Pr(switching)", ylabel="Observed - estimated", title="Residual plot")
ax.spines[["top", "right"]].set_visible(False)

Binned residuals

For binary outcomes, raw residuals form two diagonal bands. Binning makes lack of fit easier to see: the average residual in each bin should usually stay inside approximate two-standard-error bands around zero.

def binned_resids(x, y, nclass=40):
    x = np.asarray(x)
    y = np.asarray(y)
    order = np.argsort(x)
    groups = np.array_split(order, nclass)
    rows = []
    for idx in groups:
        yy = y[idx]
        xx = x[idx]
        rows.append({
            "xbar": xx.mean(),
            "ybar": yy.mean(),
            "n": len(idx),
            "x_lo": xx.min(),
            "x_hi": xx.max(),
            "two_se": 2 * yy.std(ddof=1) / np.sqrt(len(idx)),
        })
    return pd.DataFrame(rows)

def plot_binned(x, residual, xlabel, title="Binned residual plot", nclass=40):
    br = binned_resids(x, residual, nclass=nclass)
    fig, ax = plt.subplots(figsize=(5, 4))
    ax.axhline(0, color="0.7", lw=1)
    ax.plot(br["xbar"], br["two_se"], color="0.7", lw=1)
    ax.plot(br["xbar"], -br["two_se"], color="0.7", lw=1)
    ax.scatter(br["xbar"], br["ybar"], s=18, color="black")
    ax.set(xlabel=xlabel, ylabel="Average residual", title=title)
    ax.spines[["top", "right"]].set_visible(False)
    return br, ax

br_pred, _ = plot_binned(pred8, resid8, "Estimated Pr(switching)")
br_pred.head()
xbar ybar n x_lo x_hi two_se
0 0.247416 0.002584 76 0.040457 0.320674 0.100959
1 0.347617 -0.031828 76 0.320774 0.369698 0.106880
2 0.386337 -0.123179 76 0.369939 0.403405 0.101770
3 0.417595 -0.036016 76 0.403885 0.429703 0.112211
4 0.442810 0.044032 76 0.431046 0.451170 0.115704

br_dist, _ = plot_binned(wells["dist"], resid8, "Distance to nearest safe well")
br_arsenic, _ = plot_binned(wells["arsenic"], resid8, "Arsenic level")

Log-arsenic model

The R example then replaces arsenic with log(arsenic) and checks whether the residual pattern against arsenic improves.

wells["log_arsenic"] = np.log(wells["arsenic"])
fit_8b = lp.stan_glm(
    "switch ~ dist100 + log_arsenic + educ4 + dist100:educ4 + log_arsenic:educ4",
    data=wells,
    family=lp.bernoulli(),
    prior=lp.normal(0, 2.5),
    prior_intercept=lp.normal(0, 5),
    chains=4,
    parallel_chains=4,
    iter_warmup=500,
    iter_sampling=1000,
    seed=15302,
    refresh=100,
)
coef_8b = coef_medians(fit_8b)
pred8b = posterior_mean_probability(fit_8b, wells)
resid8b = wells["switch"] - pred8b
round(np.mean(np.round(np.abs(wells["switch"] - pred8b))), 2)
                                                                                                                                                                                                                                                                                                                                
np.float64(0.36)
rng = np.random.default_rng(123)
y_jit = wells["switch"] + (1 - 2 * wells["switch"]) * rng.uniform(0, 0.05, len(wells))
xs = np.linspace(0.5, wells["arsenic"].max(), 300)
b = coef_8b
mean_educ4 = wells["educ4"].mean()

fig, ax = plt.subplots(figsize=(5, 4))
ax.scatter(wells["arsenic"], y_jit, s=4, color="black", alpha=0.25)
for dist100, label in [(0, "dist = 0"), (0.5, "dist = 50m")]:
    p = expit(
        b["Intercept"] + b["dist100"] * dist100 + b["log_arsenic"] * np.log(xs)
        + b["educ4"] * mean_educ4 + b["dist100:educ4"] * dist100 * mean_educ4
        + b["log_arsenic:educ4"] * np.log(xs) * mean_educ4
    )
    ax.plot(xs, p, lw=1, label=label)
ax.set(xlabel="Arsenic concentration in well water", ylabel="Pr(switching)")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)

br_log_arsenic, _ = plot_binned(
    wells["arsenic"], resid8b, "Arsenic level",
    title="Binned residual plot for model with log(arsenic)",
)