Intermediate Advanced 23 min read

Chapter 39: Regression & Generalized Linear Models

Regression is the workhorse of statistical modeling. From estimating the effect of a drug dose on blood pressure to predicting housing prices from neighborhood features, regression answers the fundamental question: how does a response variable change as a function of one or more predictors? Understanding regression deeply — not just as a black-box sklearn call, but as a probabilistic model with precise assumptions, inferential machinery, and diagnostic tools — is what separates engineers who build models from practitioners who understand them.

This chapter treats regression from first principles in the statistical tradition. We begin with simple and multiple linear regression, deriving the ordinary least squares estimator, interpreting the resulting coefficient table, and stress-testing the assumptions through residual diagnostics. We then confront practical challenges — multicollinearity, heteroscedasticity, influential observations — and show how ridge and lasso regularization reshape the bias-variance tradeoff without abandoning the linear model. The second half of the chapter extends the framework to the Generalized Linear Model (GLM), which accommodates non-Gaussian responses by pairing a distributional family with a link function. Logistic regression for binary outcomes, Poisson regression for counts, and negative binomial regression for overdispersed counts each emerge naturally as special cases.

Throughout, we privilege interpretability and rigor. Every fitted model produces a summary table; we will learn to read every row of it. We use Python's statsmodels for full inferential output alongside R's lm and glm for cross-language fluency, because real practitioners encounter both ecosystems. By the end of the chapter you will be able to fit, criticize, and communicate results from the full GLM family with the confidence of a statistician and the efficiency of an engineer.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)

Learning Objectives

  • Derive and interpret the ordinary least squares estimator, its assumptions (LINE), and the full coefficient summary table including standard errors, t-statistics, and p-values.
  • Perform thorough residual diagnostics — residual vs. fitted plots, Q-Q plots, scale-location, and leverage/Cook's D — and identify violations of linearity, homoscedasticity, normality, and independence.
  • Detect and mitigate multicollinearity using Variance Inflation Factors (VIF) and understand how ridge and lasso regularization trade bias for variance.
  • Extend the linear model to the Generalized Linear Model framework by choosing an appropriate exponential-family distribution and link function for non-Gaussian responses.
  • Fit, interpret, and evaluate logistic regression models, including odds-ratio interpretation, the confusion matrix, and likelihood-ratio tests.
  • Apply Poisson and negative binomial regression to count data, recognizing overdispersion and its consequences.
  • Read and critically interpret a statsmodels or R summary() output, including the F-statistic, <!--MATHBLOCK0-->, AIC, and deviance.

39.1 Simple and Multiple Linear Regression: The Statistical Foundation Intermediate

The Population Model and OLS Estimator

Linear regression posits that the expected value of a response $Y$ is a linear function of predictors $\mathbf{x}$:

$$Y_i = \beta_0 + \beta_1 x_{i1} + \cdots + \beta_p x_{ip} + \varepsilon_i, \quad \varepsilon_i \overset{\text{iid}}{\sim} \mathcal{N}(0, \sigma^2)$$

In matrix notation this is $\mathbf{Y} = \mathbf{X}\boldsymbol{\beta} + \boldsymbol{\varepsilon}$ where $\mathbf{X}$ is the $n \times (p+1)$ design matrix (the first column is all ones for the intercept).

The Ordinary Least Squares (OLS) estimator minimises the residual sum of squares:

$$\hat{\boldsymbol{\beta}} = \arg\min_{\boldsymbol{\beta}} \|\mathbf{Y} - \mathbf{X}\boldsymbol{\beta}\|^2 = (\mathbf{X}^\top \mathbf{X})^{-1} \mathbf{X}^\top \mathbf{Y}$$

The Gauss-Markov theorem guarantees that among all linear unbiased estimators, OLS has the smallest variance — it is BLUE (Best Linear Unbiased Estimator) — provided the four LINE assumptions hold:

  • Linearity: <!--MATHBLOCK10--> is a linear function of <!--MATHBLOCK11-->.
  • Independence: observations are mutually independent.
  • Normal errors: <!--MATHBLOCK12--> (needed for exact inference; asymptotically relaxable).
  • Equal variance (homoscedasticity): <!--MATHBLOCK13--> for all <!--MATHBLOCK14-->.
  • Reading the Coefficient Summary

The fitted model returns much more than point estimates. The estimated standard error of each coefficient is:

$$\text{SE}(\hat{\beta}_j) = \hat{\sigma} \sqrt{[(\mathbf{X}^\top \mathbf{X})^{-1}]_{jj}}, \quad \hat{\sigma}^2 = \frac{\text{RSS}}{n - p - 1}$$

Under $H_0: \beta_j = 0$, the test statistic $t_j = \hat{\beta}_j / \text{SE}(\hat{\beta}_j)$ follows a $t_{n-p-1}$ distribution, yielding the p-value shown in every regression output. A 95% confidence interval is $\hat{\beta}_j \pm t_{0.025, n-p-1} \cdot \text{SE}(\hat{\beta}_j)$.

The overall F-test checks whether the model explains any variance beyond the intercept-only baseline:

$$F = \frac{(\text{TSS} - \text{RSS})/p}{\text{RSS}/(n-p-1)} \sim F_{p,\, n-p-1}$$

And the coefficient of determination:

$$R^2 = 1 - \frac{\text{RSS}}{\text{TSS}}, \quad \text{Adjusted } R^2 = 1 - \frac{\text{RSS}/(n-p-1)}{\text{TSS}/(n-1)}$$

Adjusted $R^2$ penalises model complexity and should always be preferred when comparing models of different sizes.

A Worked Example with statsmodels

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 200
df = pd.DataFrame({
    'sqft': rng.normal(1500, 400, n),
    'bedrooms': rng.integers(1, 6, n).astype(float),
    'age': rng.uniform(0, 50, n),
})
# True DGP: price = 50 + 0.12*sqft + 8*bedrooms - 0.5*age + noise
df['price'] = (
    50 + 0.12 * df['sqft'] + 8 * df['bedrooms']
    - 0.5 * df['age'] + rng.normal(0, 15, n)
)

model = smf.ols('price ~ sqft + bedrooms + age', data=df).fit()
print(model.summary())

The summary() output contains three tables. The top table reports model-level statistics (R², F-statistic, AIC, BIC, degrees of freedom). The middle table — the one practitioners spend most time on — shows each coefficient's estimate, standard error, t-statistic, p-value, and 95% confidence interval. The bottom table warns about potential issues such as high condition number (multicollinearity) or skewness in residuals.

Pitfall: interpreting coefficients without units. The coefficient on sqft of 0.12 means a one-square-foot increase in floor area is associated with a \$120 increase in price, holding bedrooms and age constant. The phrase "holding other predictors constant" is the ceteris-paribus clause that distinguishes multiple regression from simple bivariate correlation.

The Intercept

The intercept $\hat{\beta}_0$ is the model's predicted value when all predictors equal zero. In many practical models (e.g., age = 0, square footage = 0) this is extrapolation and the intercept has no substantive interpretation. Centering predictors ($x_j' = x_j - \bar{x}_j$) makes the intercept interpretable as the predicted response at the mean of all predictors and also improves numerical conditioning.

Code Examples

Visualising the OLS fit with confidence and prediction bands

Plot the regression line with 95% confidence and prediction intervals for a simple linear regression.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)
n = 80
df = pd.DataFrame({'x': rng.uniform(0, 10, n)})
df['y'] = 2.5 + 1.8 * df['x'] + rng.normal(0, 3, n)

model = smf.ols('y ~ x', data=df).fit()

x_pred = pd.DataFrame({'x': np.linspace(0, 10, 200)})
pred = model.get_prediction(x_pred)
frame = pred.summary_frame(alpha=0.05)

fig, ax = plt.subplots(figsize=(7, 4))
ax.scatter(df['x'], df['y'], alpha=0.5, s=20, label='Data')
ax.plot(x_pred['x'], frame['mean'], color='steelblue', label='Fit')
ax.fill_between(x_pred['x'], frame['mean_ci_lower'], frame['mean_ci_upper'],
                alpha=0.25, label='95% CI (mean)')
ax.fill_between(x_pred['x'], frame['obs_ci_lower'], frame['obs_ci_upper'],
                alpha=0.12, color='orange', label='95% PI (obs)')
ax.legend()
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.set_title('OLS with Confidence and Prediction Bands')
plt.tight_layout()
plt.savefig('ols_bands.png', dpi=120)
print('Intercept:', round(model.params['Intercept'], 3))
print('Slope:    ', round(model.params['x'], 3))
print('R²:       ', round(model.rsquared, 3))
Output
Intercept: 2.456
Slope:     1.821
R²:        0.778

Fitting and summarising a multiple linear regression in R

Reproduce the housing example in R using lm(), demonstrating the canonical summary() output.

R
set.seed(42)
n <- 200
sqft    <- rnorm(n, 1500, 400)
bedrooms <- sample(1:5, n, replace = TRUE)
age     <- runif(n, 0, 50)
price   <- 50 + 0.12 * sqft + 8 * bedrooms - 0.5 * age + rnorm(n, 0, 15)
df <- data.frame(price, sqft, bedrooms, age)

fit <- lm(price ~ sqft + bedrooms + age, data = df)
summary(fit)
confint(fit)   # 95% CIs for every coefficient
Output
Call:
lm(formula = price ~ sqft + bedrooms + age, data = df)

Coefficients:
             Estimate Std. Error t value Pr(>|t|)
(Intercept) 50.24      5.81      8.65  <2e-16 ***
sqft         0.12      0.003    38.4   <2e-16 ***
bedrooms     8.11      1.21      6.71  2e-10  ***
age         -0.49      0.11     -4.38  2e-5   ***

Multiple R-squared: 0.919,  Adjusted R-squared: 0.918
F-statistic: 739 on 3 and 196 DF,  p-value: < 2.2e-16

39.2 Regression Diagnostics: Testing the Assumptions Intermediate

Why Diagnostics Matter

Fitting a linear model is easy; trusting its inferences requires verifying the LINE assumptions. Violations do not always invalidate predictions, but they do invalidate the standard errors, p-values, and confidence intervals — the very quantities that justify regression-based decisions. This section presents a systematic diagnostic workflow.

The Residual Toolkit

All diagnostics begin with the residuals $e_i = y_i - \hat{y}_i$ and their standardised variants.

The internally studentised residual is $r_i = e_i / (\hat{\sigma}\sqrt{1 - h_{ii}})$, where $h_{ii}$ is the $i$-th diagonal element of the hat matrix $\mathbf{H} = \mathbf{X}(\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top$. The value $h_{ii}$ — called leverage — measures how far observation $i$ is from the centre of the predictor space. A point with high leverage has the potential to pull the regression line toward itself.

Cook's Distance combines leverage and residual size into a single influence metric:

$$D_i = \frac{e_i^2}{(p+1)\hat{\sigma}^2} \cdot \frac{h_{ii}}{(1-h_{ii})^2}$$

A rule of thumb flags $D_i > 4/n$ as potentially influential.

The Four Diagnostic Plots

1. Residuals vs. Fitted. Plot $e_i$ against $\hat{y}_i$. A random scatter around zero indicates linearity and homoscedasticity. A U-shape suggests a missing quadratic term. A funnel shape (residuals spreading as $\hat{y}$ grows) signals heteroscedasticity — the most common real-world violation.

2. Normal Q-Q Plot. Plot the quantiles of standardised residuals against theoretical normal quantiles. Heavy tails or S-curves indicate non-normality. Mild non-normality matters little for large samples (CLT); severe skew or outliers warrant concern.

3. Scale-Location (Spread-Location). Plot $\sqrt{|r_i|}$ against $\hat{y}_i$. A flat LOESS smoother confirms homoscedasticity; a rising smoother confirms heteroscedasticity. This plot amplifies the funnel pattern.

4. Residuals vs. Leverage. Overlaying Cook's distance contours reveals which observations are both influential (high leverage) and poorly fitted (large residual). Points in the upper-right or lower-right corners of this plot warrant investigation.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
import statsmodels.graphics.gofplots as gof
import matplotlib.pyplot as plt
from statsmodels.stats.outliers_influence import OLSInfluence

rng = np.random.default_rng(7)
n = 150
df = pd.DataFrame({'x': rng.uniform(1, 10, n)})
# Introduce heteroscedasticity: variance grows with x
df['y'] = 3 + 2 * df['x'] + rng.normal(0, 0.5 * df['x'], n)

fit = smf.ols('y ~ x', data=df).fit()
infl = OLSInfluence(fit)

fig, axes = plt.subplots(2, 2, figsize=(10, 8))

# 1. Residuals vs Fitted
axes[0, 0].scatter(fit.fittedvalues, fit.resid, alpha=0.5, s=15)
axes[0, 0].axhline(0, color='red', lw=1)
axes[0, 0].set(xlabel='Fitted', ylabel='Residuals', title='Residuals vs Fitted')

# 2. Q-Q plot
gof.qqplot(fit.resid, line='s', ax=axes[0, 1], alpha=0.5)
axes[0, 1].set_title('Normal Q-Q')

# 3. Scale-Location
stud_resid = infl.resid_studentized_internal
axes[1, 0].scatter(fit.fittedvalues, np.sqrt(np.abs(stud_resid)), alpha=0.5, s=15)
axes[1, 0].set(xlabel='Fitted', ylabel='√|Studentized Resid|',
               title='Scale-Location')

# 4. Residuals vs Leverage
lev = infl.hat_matrix_diag
axes[1, 1].scatter(lev, stud_resid, alpha=0.5, s=15)
axes[1, 1].axhline(0, color='red', lw=1)
axes[1, 1].set(xlabel='Leverage', ylabel='Studentized Residuals',
               title='Residuals vs Leverage')

plt.tight_layout()
plt.savefig('diagnostics.png', dpi=120)

Formal Tests

While plots are primary, several formal tests supplement visual inspection:

  • Breusch-Pagan / White test for heteroscedasticity: regresses <!--MATHBLOCK14--> on the predictors; a significant F-statistic rejects homoscedasticity.
  • Shapiro-Wilk / Anderson-Darling for normality of residuals.
  • Durbin-Watson for serial autocorrelation (values near 2 are ideal; < 1.5 or > 2.5 suggest correlation).

PYTHON
from statsmodels.stats.diagnostic import het_breuschpagan, linear_harvey_collier
from statsmodels.stats.stattools import durbin_watson

lm_stat, p_bp, _, _ = het_breuschpagan(fit.resid, fit.model.exog)
print(f'Breusch-Pagan p-value: {p_bp:.4f}')   # p < 0.05 → heteroscedasticity
dw = durbin_watson(fit.resid)
print(f'Durbin-Watson: {dw:.3f}')             # near 2 → no autocorrelation

Remedies

Once a violation is identified, standard remedies are:

  • Heteroscedasticity: use HC3 robust standard errors (cov_type=&#039;HC3&#039; in statsmodels), or transform <!--MATHBLOCK15--> (log, square root), or switch to Weighted Least Squares.
  • Non-linearity: add polynomial terms, splines, or interaction terms.
  • Influential outliers: investigate whether the observation is a data error or a genuinely extreme case; never delete automatically.
  • Non-normality in small samples: use bootstrap confidence intervals.

Code Examples

Robust standard errors (HC3) vs. classical OLS standard errors

Demonstrate how heteroscedasticity-consistent (HC3) standard errors differ from classical OLS SEs when heteroscedasticity is present.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

rng = np.random.default_rng(99)
n = 300
x = rng.uniform(0, 20, n)
# Strong heteroscedasticity: SD of errors ~ x
y = 5 + 1.5 * x + rng.normal(0, 0.8 * x, n)
df = pd.DataFrame({'x': x, 'y': y})

fit_ols = smf.ols('y ~ x', data=df).fit()
fit_hc3 = fit_ols.get_robustcov_results(cov_type='HC3')

print('=== Classical SE ===')
print(fit_ols.summary().tables[1])
print('\n=== HC3 Robust SE ===')
print(fit_hc3.summary().tables[1])
Output
=== Classical SE ===
           coef    std err          t      P>|t|    [0.025    0.975]
x         1.4973      0.024     63.21      0.000     1.451     1.544

=== HC3 Robust SE ===
           coef    std err          t      P>|t|    [0.025    0.975]
x         1.4973      0.089     16.78      0.000     1.321     1.673

R diagnostic plots and Breusch-Pagan test

Use R's built-in plot.lm() and lmtest/sandwich packages for diagnostics.

R
set.seed(7)
n <- 150
x <- runif(n, 1, 10)
y <- 3 + 2 * x + rnorm(n, 0, 0.5 * x)   # heteroscedastic
df <- data.frame(x, y)
fit <- lm(y ~ x, data = df)

# Base-R diagnostic plots (generates 4 plots)
par(mfrow = c(2, 2))
plot(fit)

# Breusch-Pagan test
if (!requireNamespace('lmtest', quietly=TRUE)) install.packages('lmtest')
library(lmtest)
bptest(fit)

# HC3 robust standard errors
if (!requireNamespace('sandwich', quietly=TRUE)) install.packages('sandwich')
library(sandwich)
coeftest(fit, vcov = vcovHC(fit, type = 'HC3'))
Output
studentized Breusch-Pagan test
data:  fit
BP = 47.2, df = 1, p-value = 6.3e-12

t test of coefficients (HC3 robust SE):
            Estimate Std. Error t value  Pr(>|t|)
(Intercept)   3.1042     0.4521   6.868 4.7e-10 ***
x             1.9831     0.0881  22.507 < 2.2e-16 ***

39.3 Multicollinearity and Regularization Intermediate

The Multicollinearity Problem

Multicollinearity occurs when two or more predictors are highly linearly correlated. It does not bias $\hat{\boldsymbol{\beta}}$, but it inflates standard errors dramatically — sometimes to the point where no individual coefficient is statistically significant even though the model as a whole is highly significant. Geometrically, when $\mathbf{X}^\top\mathbf{X}$ is nearly singular, small changes in the data cause large swings in the coefficient estimates.

Variance Inflation Factor

The Variance Inflation Factor for predictor $j$ is:

$$\text{VIF}_j = \frac{1}{1 - R^2_j}$$

where $R^2_j$ is the $R^2$ from regressing $x_j$ on all other predictors. A $\text{VIF}_j = 10$ means the variance of $\hat{\beta}_j$ is 10 times larger than it would be if $x_j$ were uncorrelated with the rest. Common thresholds: VIF > 5 is a warning; VIF > 10 is severe.

PYTHON
import pandas as pd
import numpy as np
from statsmodels.stats.outliers_influence import variance_inflation_factor
import statsmodels.formula.api as smf

rng = np.random.default_rng(3)
n = 200
z = rng.normal(0, 1, n)
# x2 is nearly a linear function of x1
df = pd.DataFrame({
    'x1': z + rng.normal(0, 0.05, n),
    'x2': z + rng.normal(0, 0.05, n),   # very collinear with x1
    'x3': rng.normal(0, 1, n),
})
df['y'] = 1 + 2*df['x1'] + 3*df['x3'] + rng.normal(0, 1, n)

exog = smf.ols('y ~ x1 + x2 + x3', data=df).fit().model.exog
vif = pd.DataFrame({
    'feature': ['intercept', 'x1', 'x2', 'x3'],
    'VIF': [variance_inflation_factor(exog, i) for i in range(exog.shape[1])]
})
print(vif.round(1))

Expected output reveals enormous VIFs for x1 and x2 while x3 remains low.

Remedies for Multicollinearity

  • Drop redundant predictors when theory supports it.
  • Combine collinear predictors into a composite (e.g., principal components).
  • Collect more data — collinearity is partly a small-sample problem.
  • Regularization — the preferred remedy in high-dimensional settings.
  • Ridge Regression

Ridge adds an $L_2$ penalty to the OLS objective:

$$\hat{\boldsymbol{\beta}}^\text{ridge} = \arg\min_{\boldsymbol{\beta}} \|\mathbf{Y} - \mathbf{X}\boldsymbol{\beta}\|^2 + \lambda\|\boldsymbol{\beta}\|^2$$

The closed-form solution is:

$$\hat{\boldsymbol{\beta}}^\text{ridge} = (\mathbf{X}^\top\mathbf{X} + \lambda\mathbf{I})^{-1}\mathbf{X}^\top\mathbf{Y}$$

Adding $\lambda\mathbf{I}$ to the (potentially near-singular) $\mathbf{X}^\top\mathbf{X}$ matrix conditions it away from singularity. Ridge shrinks all coefficients toward zero but never sets them exactly to zero. Crucially, predictors must be standardised before applying ridge, or the penalty is applied unequally.

Lasso Regression

Lasso uses an $L_1$ penalty:

$$\hat{\boldsymbol{\beta}}^\text{lasso} = \arg\min_{\boldsymbol{\beta}} \|\mathbf{Y} - \mathbf{X}\boldsymbol{\beta}\|^2 + \lambda\|\boldsymbol{\beta}\|_1$$

Because the $L_1$ ball has corners at the coordinate axes, lasso solutions often have exactly zero components — lasso performs automatic feature selection. It is the preferred method when sparsity (many irrelevant predictors) is expected.

Elastic Net interpolates: $\lambda[\alpha\|\boldsymbol{\beta}\|_1 + (1-\alpha)\|\boldsymbol{\beta}\|^2]$, inheriting lasso's sparsity and ridge's grouping of correlated features.

PYTHON
import numpy as np
import pandas as pd
from sklearn.linear_model import RidgeCV, LassoCV, ElasticNetCV
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n, p = 200, 20
X = rng.normal(0, 1, (n, p))
# Only 5 of 20 features matter
true_beta = np.zeros(p)
true_beta[:5] = [2, -1.5, 1, -0.8, 0.6]
y = X @ true_beta + rng.normal(0, 1, n)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
sc = StandardScaler()
Xt = sc.fit_transform(X_train); Xv = sc.transform(X_test)

ridge = RidgeCV(alphas=np.logspace(-3, 3, 50), cv=5).fit(Xt, y_train)
lasso = LassoCV(alphas=np.logspace(-3, 1, 50), cv=5, random_state=0).fit(Xt, y_train)

print(f'Ridge best alpha: {ridge.alpha_:.4f},  Test R²: {ridge.score(Xv, y_test):.3f}')
print(f'Lasso best alpha: {lasso.alpha_:.4f},  Test R²: {lasso.score(Xv, y_test):.3f}')
print(f'Lasso non-zero coefs: {(lasso.coef_ != 0).sum()}')

Interpreting Regularised Coefficients

A critical warning: regularised coefficient estimates are biased and their standard errors from OLS theory are invalid. Do not report p-values from a lasso fit. For inference after variable selection, use post-selection inference methods (e.g., the selectiveInference R package) or report coefficients from a refit OLS on the selected subset — with appropriate multiple-testing caution.

Code Examples

Ridge regularisation path: tracing coefficients vs. log(lambda)

Plot how ridge coefficients shrink toward zero as the regularisation parameter increases.

PYTHON
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_diabetes

X, y = load_diabetes(return_X_y=True)
sc = StandardScaler()
Xs = sc.fit_transform(X)

alphas = np.logspace(-2, 5, 200)
coefs = np.array([Ridge(alpha=a).fit(Xs, y).coef_ for a in alphas])

fig, ax = plt.subplots(figsize=(8, 4))
for j in range(coefs.shape[1]):
    ax.plot(np.log10(alphas), coefs[:, j])
ax.axhline(0, color='k', lw=0.5)
ax.set_xlabel('log₁₀(λ)')
ax.set_ylabel('Standardised coefficient')
ax.set_title('Ridge Regularisation Path — Diabetes Dataset')
plt.tight_layout()
plt.savefig('ridge_path.png', dpi=120)
print('Coefficients near zero when log10(lambda)=5:', (np.abs(coefs[-1]) < 0.1).sum())
Output
Coefficients near zero when log10(lambda)=5: 9

Lasso path and comparing sparsity patterns

Use sklearn's lasso_path to show which features survive at different lambda levels.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import lasso_path
from sklearn.datasets import load_diabetes
from sklearn.preprocessing import StandardScaler

X, y = load_diabetes(return_X_y=True)
Xs = StandardScaler().fit_transform(X)

alphas, coefs, _ = lasso_path(Xs, y, n_alphas=100)

fig, ax = plt.subplots(figsize=(8, 4))
for j in range(coefs.shape[0]):
    ax.plot(np.log10(alphas), coefs[j])
ax.axhline(0, color='k', lw=0.5)
ax.invert_xaxis()   # path goes from right (sparse) to left (dense)
ax.set_xlabel('log₁₀(λ)')
ax.set_ylabel('Coefficient')
ax.set_title('Lasso Path — Diabetes Dataset')
plt.tight_layout()
plt.savefig('lasso_path.png', dpi=120)
print('Features active at smallest lambda:', (coefs[:, -1] != 0).sum())
Output
Features active at smallest lambda: 10

39.4 Logistic Regression and the GLM Framework Advanced

From Linear to Generalized Linear Models

Linear regression assumes $Y \sim \mathcal{N}(\mu, \sigma^2)$. Real data rarely cooperates: binary outcomes violate this assumption fundamentally (predictions can exceed [0,1]), count data have non-negative integer support, and proportions are bounded. The Generalized Linear Model (GLM) unifies these cases with a single elegant framework.

A GLM has three components:

  1. Random component: <!--MATHBLOCK5--> follows a distribution from the exponential family with mean <!--MATHBLOCK6-->.
  2. Systematic component: a linear predictor <!--MATHBLOCK7-->.
  3. Link function: a monotone differentiable function <!--MATHBLOCK8--> such that <!--MATHBLOCK9-->.

The GLM is estimated by Maximum Likelihood via Iteratively Reweighted Least Squares (IRLS), not OLS. Consequently, goodness-of-fit is measured by deviance rather than RSS:

$$D = 2[\ell(\hat{\boldsymbol{\mu}}_{\text{saturated}}) - \ell(\hat{\boldsymbol{\mu}}_{\text{model}})]$$

The null deviance (intercept only) and residual deviance (fitted model) appear in every GLM summary, and their difference, divided by the degrees-of-freedom difference, follows a $\chi^2$ distribution under $H_0$.

Logistic Regression

For a binary outcome $Y_i \in \{0, 1\}$, the natural distributional family is Bernoulli with $\mu_i = P(Y_i = 1 | \mathbf{x}_i)$. The canonical link is the logit:

$$\text{logit}(\mu_i) = \log\frac{\mu_i}{1 - \mu_i} = \mathbf{x}_i^\top \boldsymbol{\beta}$$

Inverting, the predicted probability is:

$$\hat{P}(Y=1|\mathbf{x}) = \sigma(\mathbf{x}^\top\hat{\boldsymbol{\beta}}) = \frac{1}{1 + e^{-\mathbf{x}^\top\hat{\boldsymbol{\beta}}}}$$

Interpreting Logistic Regression Coefficients

The coefficient $\hat{\beta}_j$ is the change in the log-odds of the event per one-unit increase in $x_j$, holding others constant. Exponentiated, $e^{\hat{\beta}_j}$ is the multiplicative change in the odds ratio:

$$\text{OR}_j = e^{\hat{\beta}_j}$$

An odds ratio of 1.5 means the odds of the event are 50% higher for each one-unit increase in $x_j$. A crucial caution: odds ratios are not probability ratios (relative risks), and the distinction matters clinically when the baseline probability is not near zero.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from sklearn.datasets import load_breast_cancer

bc = load_breast_cancer()
df = pd.DataFrame(bc.data[:, :5], columns=bc.feature_names[:5])
df.columns = ['radius', 'texture', 'perimeter', 'area', 'smoothness']
df['malignant'] = (bc.target == 0).astype(int)  # 1 = malignant

# Standardise for numerical stability
for col in df.columns[:-1]:
    df[col] = (df[col] - df[col].mean()) / df[col].std()

fit = smf.logit(
    'malignant ~ radius + texture + perimeter + area + smoothness',
    data=df
).fit()
print(fit.summary())

# Odds ratios with 95% CI
odds = pd.DataFrame({
    'OR': np.exp(fit.params),
    'lower': np.exp(fit.conf_int()[0]),
    'upper': np.exp(fit.conf_int()[1]),
}).round(3)
print('\nOdds Ratios:')
print(odds)

Model Evaluation for Logistic Regression

Unlike linear regression, logistic $R^2$ analogues (McFadden, Nagelkerke) are not intuitive. Prefer:

  • Likelihood-ratio test: compare nested models with <!--MATHBLOCK19-->.
  • AUC-ROC: the area under the Receiver Operating Characteristic curve; 0.5 = random, 1.0 = perfect.
  • Brier score: mean squared error of predicted probabilities; lower is better.
  • Calibration: do predicted probabilities match observed proportions (use a calibration plot or Hosmer-Lemeshow test)?

PYTHON
from sklearn.metrics import roc_auc_score, brier_score_loss
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression

X = df[['radius', 'texture', 'perimeter', 'area', 'smoothness']].values
y = df['malignant'].values

lr = LogisticRegression(max_iter=500)
print('CV AUC: ', cross_val_score(lr, X, y, cv=5, scoring='roc_auc').mean().round(3))
print('CV Brier:', cross_val_score(lr, X, y, cv=5,
      scoring='neg_brier_score').mean().__neg__().round(3))

The Wald Test vs. the Likelihood-Ratio Test

The summary table reports Wald z-statistics ($\hat{\beta}_j / \text{SE}(\hat{\beta}_j)$). These can be unreliable when the sample is small or when separation (a predictor perfectly predicts the outcome) causes coefficients to diverge. The likelihood-ratio test (LRT) is more reliable: fit the full and reduced models, compute $\Delta D$, and compare to $\chi^2$.

PYTHON
fit_reduced = smf.logit(
    'malignant ~ radius + texture + area + smoothness',  # drop perimeter
    data=df
).fit(disp=0)

lr_stat = fit_reduced.llr_pvalue  # full model vs null
delta_d = 2 * (fit.llf - fit_reduced.llf)  # compare full vs reduced
import scipy.stats as st
p_lrt = st.chi2.sf(delta_d, df=1)
print(f'LRT for perimeter: Δdeviance={delta_d:.2f}, p={p_lrt:.4f}')

Code Examples

ROC curve and optimal threshold selection

Plot the ROC curve and identify the threshold that maximises the Youden index.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_curve, roc_auc_score

X, y = load_breast_cancer(return_X_y=True)
y = 1 - y  # 1 = malignant
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
sc = StandardScaler()
lr = LogisticRegression(max_iter=1000).fit(sc.fit_transform(Xtr), ytr)
probs = lr.predict_proba(sc.transform(Xte))[:, 1]

fpr, tpr, thresholds = roc_curve(yte, probs)
auc = roc_auc_score(yte, probs)
youden = tpr - fpr
best_idx = np.argmax(youden)

fig, ax = plt.subplots(figsize=(5, 5))
ax.plot(fpr, tpr, label=f'AUC = {auc:.3f}')
ax.scatter(fpr[best_idx], tpr[best_idx], color='red', zorder=5,
           label=f'Best threshold = {thresholds[best_idx]:.2f}')
ax.plot([0, 1], [0, 1], 'k--')
ax.set(xlabel='FPR', ylabel='TPR', title='ROC Curve')
ax.legend()
plt.tight_layout()
plt.savefig('roc.png', dpi=120)
print(f'AUC: {auc:.3f}')
print(f'Optimal threshold: {thresholds[best_idx]:.3f}')
print(f'Sensitivity: {tpr[best_idx]:.3f}, Specificity: {1-fpr[best_idx]:.3f}')
Output
AUC: 0.993
Optimal threshold: 0.488
Sensitivity: 0.974, Specificity: 0.962

Logistic regression with glm() in R, including odds ratios

Fit a logistic regression using R's glm(), extract odds ratios, and run a likelihood-ratio test.

R
data(Titanic)
tit <- as.data.frame(Titanic)
tit_long <- tit[rep(seq_len(nrow(tit)), tit$Freq), c('Class','Sex','Age','Survived')]
tit_long$Survived <- ifelse(tit_long$Survived == 'Yes', 1, 0)

fit_full <- glm(Survived ~ Class + Sex + Age,
                family = binomial(link = 'logit'),
                data = tit_long)
summary(fit_full)

# Odds ratios and 95% profile-likelihood CIs
exp(cbind(OR = coef(fit_full), confint(fit_full)))

# Likelihood-ratio test vs intercept-only
fit_null <- glm(Survived ~ 1, family = binomial, data = tit_long)
anova(fit_null, fit_full, test = 'Chisq')
Output
Coefficients:
               Estimate Std. Error z value Pr(>|z|)
(Intercept)     2.0438     0.1703  12.00  <2e-16 ***
Class2nd       -1.0181     0.1960  -5.19  2e-7  ***
Class3rd       -1.7778     0.1716 -10.36  <2e-16 ***
ClassCrew      -0.8577     0.1573  -5.45  5e-8  ***
SexMale        -2.4201     0.1404 -17.24  <2e-16 ***
AgeChild        1.0615     0.2440   4.35  1e-5  ***

39.5 Count Regression: Poisson, Negative Binomial, and Beyond Advanced

When the Response is a Count

Count data — number of insurance claims, hospital readmissions, website visits, mutations per genome — are non-negative integers. Applying OLS to counts violates homoscedasticity (variance grows with mean for counts) and can produce negative predictions. The natural starting point is Poisson regression.

Poisson Regression

The Poisson GLM assumes $Y_i \sim \text{Poisson}(\mu_i)$ with the canonical log link:

$$\log(\mu_i) = \mathbf{x}_i^\top \boldsymbol{\beta}$$

Since $\text{Var}(Y_i) = \mu_i = E[Y_i]$ for the Poisson distribution, the model constrains variance to equal the mean — a strong assumption.

Interpretation of Poisson Coefficients

Exponentiating: $\mu_i = e^{\mathbf{x}_i^\top \boldsymbol{\beta}}$. A one-unit increase in $x_j$ multiplies the expected count by $e^{\hat{\beta}_j}$. This is the incidence rate ratio (IRR). An IRR of 1.3 means 30% more events per unit increase in $x_j$.

Exposure and Offsets

When counts are collected over different periods or population sizes, the expected count should be proportional to the exposure $E_i$:

$$\log(\mu_i) = \log(E_i) + \mathbf{x}_i^\top \boldsymbol{\beta}$$

The term $\log(E_i)$ is added as an offset — a predictor whose coefficient is fixed at 1.0 rather than estimated.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

rng = np.random.default_rng(5)
n = 500
df = pd.DataFrame({
    'days_observed': rng.integers(30, 365, n),   # exposure varies
    'age_group': rng.choice(['young', 'middle', 'old'], n),
    'treatment': rng.integers(0, 2, n),
})
log_mu = (
    np.log(df['days_observed'])
    + np.where(df['age_group'] == 'young', 0.0,
        np.where(df['age_group'] == 'middle', 0.4, 0.9))
    - 0.5 * df['treatment']
)
df['events'] = rng.poisson(np.exp(log_mu))

fit = smf.poisson(
    'events ~ age_group + treatment + offset(np.log(days_observed))',
    data=df
).fit()
print(fit.summary())
print('\nIRR (exp(coef)):')
print(np.exp(fit.params).round(3))

Overdispersion: The Achilles Heel of Poisson

Real count data almost always exhibit overdispersion: $\text{Var}(Y_i) > E[Y_i]$. Overdispersion inflates the Poisson deviance and deflates standard errors, producing overconfident (spuriously significant) inferences. Detect it by comparing the residual deviance to its degrees of freedom — a ratio $\gg 1$ is a warning sign — or with a formal test.

PYTHON
# Quasi-Poisson: scales SE by sqrt(dispersion) — corrects SEs but not fit
fit_quasi = smf.glm(
    'events ~ age_group + treatment + offset(np.log(days_observed))',
    data=df,
    family=smf.families.family.Poisson()
).fit()

dispersion = fit_quasi.pearson_chi2 / fit_quasi.df_resid
print(f'Dispersion parameter: {dispersion:.2f}')  # ≈1 is fine; >>1 = overdispersed

Negative Binomial Regression

The Negative Binomial model addresses overdispersion by introducing a per-observation random effect $\nu_i \sim \text{Gamma}(\theta, \theta)$, leading to:

$$\text{Var}(Y_i) = \mu_i + \frac{\mu_i^2}{\theta}$$

As $\theta \to \infty$, the negative binomial converges to the Poisson. Smaller $\theta$ means more overdispersion. This model is preferable to quasi-Poisson because it is a proper likelihood, allowing AIC comparison and model selection.

PYTHON
import statsmodels.api as sm

# Simulate overdispersed count data
rng = np.random.default_rng(11)
n = 800
df2 = pd.DataFrame({
    'x1': rng.normal(0, 1, n),
    'x2': rng.choice([0, 1], n),
})
true_mu = np.exp(0.5 + 0.8 * df2['x1'] - 0.4 * df2['x2'])
# Negative binomial with theta=2 (moderately overdispersed)
df2['y'] = rng.negative_binomial(2, 2 / (2 + true_mu))

exog = sm.add_constant(df2[['x1', 'x2']])
nb_fit = sm.NegativeBinomial(df2['y'], exog).fit(disp=0)
print(nb_fit.summary())
print(f'\nAlpha (1/theta): {nb_fit.params["alpha"]:.3f}')  # alpha = 1/theta
print(f'IRR for x1: {np.exp(nb_fit.params["x1"]):.3f}')

Zero-Inflated Models

Some count datasets have excess zeros beyond what any Poisson or NB model can explain — e.g., the number of cigarettes smoked per day when many participants are non-smokers. Zero-Inflated Poisson (ZIP) and Zero-Inflated Negative Binomial (ZINB) models mix a point mass at zero with a count component. The statsmodels ZeroInflatedPoisson and ZeroInflatedNegativeBinomialP classes handle these.

Model Selection Checklist

  • Start with Poisson and check residual deviance / degrees of freedom.
  • If dispersion <!--MATHBLOCK16-->, switch to Negative Binomial or use quasi-Poisson standard errors.
  • If the zero count is notably higher than the Poisson/NB prediction, consider zero-inflated or hurdle models.
  • Use AIC to compare non-nested models (Poisson vs. NB); use LRT (<!--MATHBLOCK17--> on <!--MATHBLOCK18--> deviance) for nested models.

Code Examples

Poisson vs. Negative Binomial in R with overdispersion test

Fit both Poisson and Negative Binomial models in R, compare AIC, and test for overdispersion using the AER package.

R
# Simulate overdispersed count data
set.seed(42)
n <- 600
x1 <- rnorm(n)
x2 <- rbinom(n, 1, 0.4)
true_mu <- exp(0.5 + 0.7 * x1 - 0.3 * x2)
# Draw from NB with size=1.5 (strong overdispersion)
y <- rnbinom(n, size = 1.5, mu = true_mu)
df <- data.frame(y, x1, x2)

# Poisson
fit_pois <- glm(y ~ x1 + x2, family = poisson, data = df)

# Negative Binomial (MASS::glm.nb)
library(MASS)
fit_nb <- glm.nb(y ~ x1 + x2, data = df)

cat('Poisson AIC:', AIC(fit_pois), '\n')
cat('NB      AIC:', AIC(fit_nb), '\n')
cat('NB theta (dispersion):', fit_nb$theta, '\n')

# Dispersion test (AER package)
if (!requireNamespace('AER', quietly=TRUE)) install.packages('AER')
library(AER)
dispersiontest(fit_pois)
Output
Poisson AIC: 3421.8
NB      AIC: 2104.3
NB theta (dispersion): 1.487

Overdispersion test
z = 12.43, p-value < 2.2e-16
alternative hypothesis: true dispersion is greater than 1

Zero-Inflated Poisson regression with statsmodels

Fit a Zero-Inflated Poisson model when excess zeros are present in the count data.

PYTHON
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy.stats import poisson

rng = np.random.default_rng(77)
n = 500
x = rng.normal(0, 1, n)
# ZIP: 30% structural zeros, rest Poisson(exp(0.5 + 0.6*x))
structural_zero = rng.uniform(0, 1, n) < 0.30
mu = np.exp(0.5 + 0.6 * x)
y = np.where(structural_zero, 0, rng.poisson(mu))
print(f'Fraction zeros observed:  {(y == 0).mean():.2f}')
print(f'Fraction zeros if pure Poisson: {poisson.pmf(0, mu.mean()):.2f}')

df = pd.DataFrame({'y': y, 'x': x})
exog = sm.add_constant(df['x'])

zip_fit = sm.ZeroInflatedPoisson(df['y'], exog, exog_infl=exog).fit(
    method='bfgs', maxiter=500, disp=False
)
print(zip_fit.summary())
print('\nIRR for x:', round(np.exp(zip_fit.params['x']), 3))
Output
Fraction zeros observed:  0.51
Fraction zeros if pure Poisson: 0.24

Zero-Inflated Poisson Results
...
IRR for x: 1.822

39.6 Model Comparison, Selection, and Practical Workflow Advanced

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.

Information Criteria: AIC and BIC

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.

PYTHON
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}')

Feature Engineering Inside the Model Formula

Both statsmodels (via Patsy) and R's formula interface support inline transformations, making the specification concise and reproducible.

PYTHON
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.

PYTHON
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.

PYTHON
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)
Output
Forward 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.

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.

PYTHON
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_))
Output
Done — coefficients plotted for: ['age', 'bmi', 'smoker', 'exercise', 'male']