A Unified Workflow for GLM Analysis
Fitting a regression model is only the beginning. A principled workflow moves through six stages: exploratory data analysis, model specification, estimation, diagnostics, comparison/selection, and communication of results. This section ties those stages together and addresses the most common practical issues.
When comparing non-nested models or models of different complexity, likelihood-ratio tests are not valid. Information criteria penalise log-likelihood by model complexity:
$$\text{AIC} = -2\ell(\hat{\boldsymbol{\theta}}) + 2k, \quad \text{BIC} = -2\ell(\hat{\boldsymbol{\theta}}) + k\ln(n)$$
where $k$ is the number of estimated parameters. Lower is better. BIC penalises complexity more severely and tends toward sparser models; AIC minimises prediction error on new data (in expectation). For model selection in a predictive context, prefer AIC or cross-validation; for identifying the true data-generating model when it is in the candidate set, prefer BIC.
Warning: never interpret the absolute value of AIC — only differences between models on the same dataset are meaningful.
Cross-Validation for Regression Models
For predictive models, k-fold cross-validation on a held-out metric (RMSE, log-loss, AUC) is more reliable than information criteria because it estimates generalisation error directly and makes no parametric assumptions about model fit.
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge, Lasso, LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import KFold, cross_val_score
from sklearn.datasets import load_diabetes, load_breast_cancer
# Regression comparison: Ridge vs. Lasso vs. OLS (alpha → 0)
X, y = load_diabetes(return_X_y=True)
kf = KFold(n_splits=10, shuffle=True, random_state=42)
for name, est in [
('Ridge', Pipeline([('sc', StandardScaler()), ('m', Ridge(alpha=1.0))])),
('Lasso', Pipeline([('sc', StandardScaler()), ('m', Lasso(alpha=0.5))])),
('OLS', Pipeline([('sc', StandardScaler()), ('m', Ridge(alpha=1e-6))])),
]:
scores = cross_val_score(est, X, y, cv=kf, scoring='neg_root_mean_squared_error')
print(f'{name:6s}: RMSE = {(-scores.mean()):.2f} ± {scores.std():.2f}')
Both statsmodels (via Patsy) and R's formula interface support inline transformations, making the specification concise and reproducible.
import statsmodels.formula.api as smf
import pandas as pd, numpy as np
rng = np.random.default_rng(0)
n = 300
df = pd.DataFrame({
'age': rng.uniform(20, 70, n),
'income': rng.lognormal(10, 0.5, n),
'gender': rng.choice(['M', 'F'], n),
})
df['y'] = (
0.02 * df['age']**2 - 2 * df['age']
+ 0.3 * np.log(df['income'])
+ np.where(df['gender'] == 'F', 5, 0)
+ rng.normal(0, 8, n)
)
# Patsy handles np.log, I() for arithmetic, and C() for categorical
fit = smf.ols(
'y ~ I(age**2) + age + np.log(income) + C(gender, Treatment("M"))',
data=df
).fit()
print(fit.summary().tables[1])
Communicating Results
A regression table published in a paper or technical report should include:
- Coefficient estimates with standard errors (not just p-values).
- Confidence intervals.
- Sample size and degrees of freedom.
- Model fit statistics (R², adjusted R², AIC, or deviance for GLMs).
- For logistic/count models: effect sizes on the natural scale (ORs, IRRs) with 95% CIs.
The table is not the end. Supplement with effect plots showing the predicted response as one predictor varies while others are held at their median values — these communicate non-linearity and interaction effects that coefficient tables obscure.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.formula.api as smf
rng = np.random.default_rng(8)
n = 400
df = pd.DataFrame({
'dose': rng.uniform(0, 10, n),
'weight': rng.normal(70, 15, n),
'treated': rng.integers(0, 2, n),
})
logit_p = -3 + 0.6 * df['dose'] - 0.02 * df['weight'] + 1.5 * df['treated']
df['response'] = rng.binomial(1, 1 / (1 + np.exp(-logit_p)))
fit = smf.logit('response ~ dose + weight + treated', data=df).fit(disp=0)
# Effect plot: response probability vs. dose, at median weight, both treatment arms
dose_grid = np.linspace(0, 10, 200)
med_weight = df['weight'].median()
fig, ax = plt.subplots(figsize=(6, 4))
for t, color, label in [(0, 'steelblue', 'Control'), (1, 'tomato', 'Treated')]:
pred_df = pd.DataFrame({'dose': dose_grid,
'weight': med_weight,
'treated': t})
preds = fit.get_prediction(pred_df).summary_frame()
ax.plot(dose_grid, preds['mean'], color=color, label=label)
ax.fill_between(dose_grid, preds['mean_ci_lower'], preds['mean_ci_upper'],
alpha=0.2, color=color)
ax.set_xlabel('Dose'); ax.set_ylabel('P(Response)')
ax.set_title('Effect Plot: Dose vs. Response Probability')
ax.legend()
plt.tight_layout()
plt.savefig('effect_plot.png', dpi=120)
Common Pitfalls Summarised
- Confusing statistical significance with practical significance: a tiny effect can be highly significant with large <!--MATHBLOCK2-->. Always report effect sizes.
- Data leakage in cross-validation: standardisation scalers and imputers must be fit only on the training fold.
- Ignoring the scale: never compare AIC across datasets or models fit on different samples.
- Extrapolation: the model's predictions outside the observed covariate range are unreliable.
- Post-selection inference: selecting variables based on p-values and then reporting those p-values as valid is anti-conservative. Use Bonferroni corrections, FWER, or FDR procedures, or pre-register your model.
Code Examples
Stepwise AIC model selection with statsmodels
Implement a forward-stepwise AIC selection procedure for linear regression, since statsmodels does not include this natively.
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from itertools import combinations
def forward_aic(response, candidates, data):
"""Greedy forward AIC selection."""
selected = []
best_aic = smf.ols(f'{response} ~ 1', data=data).fit().aic
while candidates:
results = []
for c in candidates:
formula = f'{response} ~ ' + ' + '.join(selected + [c])
aic = smf.ols(formula, data=data).fit().aic
results.append((aic, c))
best_new_aic, best_c = min(results)
if best_new_aic < best_aic:
selected.append(best_c)
candidates.remove(best_c)
best_aic = best_new_aic
print(f' Added {best_c:12s} AIC = {best_aic:.2f}')
else:
break
return selected
rng = np.random.default_rng(0)
n = 200
cols = [f'x{i}' for i in range(1, 11)]
df = pd.DataFrame(rng.normal(0, 1, (n, 10)), columns=cols)
# Only x1, x3, x7 are truly relevant
df['y'] = 2*df['x1'] - 1.5*df['x3'] + df['x7'] + rng.normal(0, 1, n)
print('Forward AIC Selection:')
chosen = forward_aic('y', list(cols), df)
print('\nSelected features:', chosen)
OutputForward AIC Selection:
Added x1 AIC = 583.42
Added x3 AIC = 552.17
Added x7 AIC = 541.83
Selected features: ['x1', 'x3', 'x7']
Model comparison with AIC, BIC, and anova() in R
Compare nested and non-nested GLMs using AIC, BIC, and likelihood-ratio tests in R.
set.seed(1)
n <- 300
age <- runif(n, 18, 70)
smoke <- rbinom(n, 1, 0.35)
bmi <- rnorm(n, 26, 4)
logit <- -4 + 0.04 * age + 1.2 * smoke + 0.05 * bmi
y <- rbinom(n, 1, plogis(logit))
df <- data.frame(y, age, smoke, bmi)
# Three nested models
m0 <- glm(y ~ 1, family = binomial, data = df)
m1 <- glm(y ~ age, family = binomial, data = df)
m2 <- glm(y ~ age + smoke, family = binomial, data = df)
m3 <- glm(y ~ age + smoke + bmi, family = binomial, data = df)
# Compare with LRT
anova(m0, m1, m2, m3, test = 'Chisq')
# AIC/BIC table
aic_bic <- data.frame(
model = c('null','age','age+smoke','age+smoke+bmi'),
AIC = sapply(list(m0,m1,m2,m3), AIC),
BIC = sapply(list(m0,m1,m2,m3), BIC)
)
print(aic_bic)
Output model AIC BIC
1 null 392.1 395.7
2 age 363.4 370.6
3 age+smoke 319.2 330.1
4 age+smoke+bmi 320.1 334.7
Publishing-quality coefficient plot with confidence intervals
Create a forest-plot style coefficient plot for a fitted logistic regression model.
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
rng = np.random.default_rng(15)
n = 500
df = pd.DataFrame({
'age': rng.normal(50, 10, n),
'bmi': rng.normal(27, 5, n),
'smoker': rng.integers(0, 2, n).astype(float),
'exercise': rng.integers(0, 2, n).astype(float),
'male': rng.integers(0, 2, n).astype(float),
})
# standardise continuous predictors
for c in ['age', 'bmi']:
df[c] = (df[c] - df[c].mean()) / df[c].std()
logit = -1 + 0.6*df['age'] + 0.3*df['bmi'] + 0.8*df['smoker'] - 0.5*df['exercise'] + 0.1*df['male']
df['y'] = rng.binomial(1, 1/(1+np.exp(-logit)))
fit = smf.logit('y ~ age + bmi + smoker + exercise + male', data=df).fit(disp=0)
ci = fit.conf_int()
coefs = fit.params
vars_ = coefs.index[1:] # drop intercept
fig, ax = plt.subplots(figsize=(6, 4))
for i, v in enumerate(vars_):
ax.plot([ci.loc[v, 0], ci.loc[v, 1]], [i, i], 'steelblue', lw=2)
ax.scatter(coefs[v], i, color='steelblue', zorder=5, s=50)
ax.axvline(0, color='red', linestyle='--', lw=1)
ax.set_yticks(range(len(vars_)))
ax.set_yticklabels(vars_)
ax.set_xlabel('Log-Odds Coefficient (95% CI)')
ax.set_title('Logistic Regression Coefficient Plot')
plt.tight_layout()
plt.savefig('coef_plot.png', dpi=120)
print('Done — coefficients plotted for:', list(vars_))
OutputDone — coefficients plotted for: ['age', 'bmi', 'smoker', 'exercise', 'male']