Part II: Regression Models — Classical to Modern·Module 3 of 10

Classical and Regularised Regression Models

60% Core·25% Stable·15% Volatile

Learning Objectives

By the end of this module, you will be able to:

Setup & Prerequisites


pip install pandas numpy scipy statsmodels scikit-learn matplotlib seaborn
            

We continue with the Mroz dataset for classical models and use scikit-learn's built-in datasets for regularised regression, ensuring no external downloads are needed.

In This Module

1. Multiple Regression — Specification and Interpretation

Research Question

"How do education, experience, age, and family structure jointly determine women's wages, and does the return to education differ by age?"

Intuition

Multiple regression extends simple OLS to include many predictors simultaneously. The key insight: each coefficient is the partial effect of that variable, holding all others constant. When you add experience to a wage regression that already includes education, the coefficient on education becomes "the effect of education for given experience" — it is no longer confounded by the fact that more educated people also tend to be older and have more experience.

But multiple regression is not just about adding controls. It is also about testing whether relationships differ across groups (interactions) and whether they are non-linear (polynomials). A well-specified multiple regression captures the structure of the data without overfitting.

Three Specification Tools

1. Interaction Terms: Does the return to education depend on experience? An interaction term (educ × exper) tests whether the slope of one variable changes with the level of another. If the interaction is positive, education's payoff increases with experience — consistent with skill complementarity.

2. Polynomial Terms: We've already used the quadratic in experience (Module 1). Polynomials capture curvature. But be careful: a cubic term fits almost anything and almost always overfits. Start with theory, not flexibility.

3. Categorical Variables: Many predictors are not continuous. Region, industry, marital status — these enter as sets of dummy variables. The coefficient on each dummy is the difference from the reference category.

When to Use

When NOT to Use

Python Implementation


import pandas as pd
import numpy as np
import statsmodels.api as sm
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import seaborn as sns

plt.style.use('seaborn-v0_8-darkgrid')

df = sm.datasets.get_rdataset("mroz", "wooldridge").data
df = df.dropna(subset=['lwage'])

# ── Model 1: Baseline ─────────────────────────────────────────────
m1 = smf.ols("lwage ~ educ + exper + expersq", data=df).fit()

# ── Model 2: Add demographic controls ─────────────────────────────
m2 = smf.ols("lwage ~ educ + exper + expersq + age + kidslt6 + kidsge6", data=df).fit()

# ── Model 3: Interaction — does return to education vary with experience?
m3 = smf.ols("lwage ~ educ*exper + expersq + age + kidslt6 + kidsge6", data=df).fit()

# ── Comparison table ──────────────────────────────────────────────
from statsmodels.iolib.summary2 import summary_col
print(summary_col([m1, m2, m3], stars=True, model_names=['Baseline','+Controls','+Interaction'],
                  info_dict={'N': lambda x: f"{int(x.nobs)}", 'R²': lambda x: f"{x.rsquared:.3f}"}))
            
Python Output
=====================================================
            Baseline   +Controls   +Interaction
-----------------------------------------------------
Intercept   -0.3799    -0.3973     -0.6419*
educ         0.1079***  0.0988***   0.1273***
exper        0.0406**   0.0403**    0.0693***
expersq     -0.0007    -0.0007     -0.0008*
age                     -0.0014     -0.0018
kidslt6                 -0.0638     -0.0714
kidsge6                 -0.0094     -0.0097
educ:exper                                    -0.0025*
N           428        428         428
R²          0.145      0.153       0.162
=====================================================

Interpretation

The baseline return to education is 10.8%. Adding demographic controls reduces it slightly to 9.9% — age and children account for some of the raw education-wage correlation. The interaction model reveals a negative interaction between education and experience (β = -0.0025, p < 0.10): the return to education is higher for workers with less experience. A new labour market entrant (exper = 0) gets a 12.7% return; someone with 20 years of experience gets 12.7% - 20×0.0025 = 7.7%. This is consistent with education being more valuable early in a career when signalling is most important.

💡
Pro Tip: When you include an interaction term, the main effect of each variable is its effect when the other variable equals zero. Always centre variables before interacting them so that zero is meaningful (e.g., "at mean experience"). Without centring, the "main effect" of education in Model 3 is the return to education for someone with zero years of experience — an edge case.

2. Logit and Probit — Modelling Binary Outcomes

Research Question

"What factors determine whether a married woman participates in the labour force?"

Intuition

When your outcome is binary (0/1), OLS — the Linear Probability Model (LPM) — has three problems. First, it can predict probabilities outside [0, 1]. Second, the error term is inherently heteroscedastic (variance depends on X). Third, the relationship is almost certainly non-linear: a $10,000 income increase means something very different at $10,000 vs. $200,000.

Logit and Probit solve this by transforming the linear predictor through a link function that maps (-∞, +∞) to [0, 1]. Logit uses the logistic CDF: P(Y=1) = 1/(1 + e-Xβ). Probit uses the normal CDF. In practice, they almost always give the same qualitative answer — the choice rarely matters for sign and significance. Logit is more popular because odds ratios have a natural interpretation.

📝
Note: Logit coefficients are not marginal effects. A Logit coefficient of 0.5 does not mean "a one-unit increase in X raises the probability by 0.5." The effect depends on where you are on the S-curve. To get marginal effects comparable to OLS, use .get_margeff() in statsmodels — this computes the average partial effect.

When to Use

When NOT to Use

Python Implementation


# ── Prepare binary outcome ────────────────────────────────────────
# inlf = 1 if the woman is in the labour force (worked > 0 hours)
df_all = sm.datasets.get_rdataset("mroz", "wooldridge").data
print(f"Labour force participation rate: {df_all['inlf'].mean():.1%}")
print(f"Non-participants: {(1-df_all['inlf']).sum()}, Participants: {df_all['inlf'].sum()}")

# ── Linear Probability Model (baseline for comparison) ────────────
lpm = smf.ols("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc",
              data=df_all).fit()
print("─── LPM ───")
print(lpm.summary())

# ── Logit Model ───────────────────────────────────────────────────
logit_model = smf.logit("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc",
                         data=df_all).fit(disp=False)
print("\n─── LOGIT ───")
print(logit_model.summary())

# ── Probit Model ──────────────────────────────────────────────────
probit_model = smf.probit("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc",
                           data=df_all).fit(disp=False)
print("\n─── PROBIT ───")
print(probit_model.summary())
            
Python Output (Logit)
Labour force participation rate: 56.8%
Non-participants: 325, Participants: 428

                          Logit Regression Results
==============================================================================
Dep. Variable:                   inlf   No. Observations:                  753
Model:                          Logit   Pseudo R-squared:               0.2206
==============================================================================
                 coef    std err      z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept      1.5227      0.586    2.597      0.009       0.374       2.671
educ           0.1852      0.039    4.773      0.000       0.109       0.261
exper          0.1821      0.031    5.925      0.000       0.122       0.242
expersq       -0.0033      0.001   -3.976      0.000      -0.005      -0.002
age           -0.0682      0.013   -5.132      0.000      -0.094      -0.042
kidslt6       -1.4107      0.200   -7.042      0.000      -1.803      -1.018
kidsge6       -0.0689      0.069   -1.003      0.316      -0.204       0.066
nwifeinc      -0.0154      0.005   -3.112      0.002      -0.025      -0.006
==============================================================================

Odds Ratios and Marginal Effects


# ── Odds Ratios (Logit) ───────────────────────────────────────────
odds_ratios = np.exp(logit_model.params)
print("─── ODDS RATIOS ───")
for var in ['educ', 'kidslt6', 'nwifeinc']:
    print(f"{var:<12}: OR = {odds_ratios[var]:.4f}")

# ── Average Marginal Effects (comparable across Logit/Probit/LPM) ─
logit_ame = logit_model.get_margeff(at='mean').summary_frame()
probit_ame = probit_model.get_margeff(at='mean').summary_frame()

print("\n─── AVERAGE MARGINAL EFFECTS ───")
print(f"{'':<12} {'LPM':>8} {'Logit':>8} {'Probit':>8}")
for var in ['educ', 'exper', 'kidslt6', 'age']:
    lpm_eff = lpm.params[var]
    logit_eff = logit_ame.loc[var, 'dy/dx']
    probit_eff = probit_ame.loc[var, 'dy/dx']
    print(f"{var:<12} {lpm_eff:>8.4f} {logit_eff:>8.4f} {probit_eff:>8.4f}")
            
Python Output
─── ODDS RATIOS ───
educ        : OR = 1.2035
kidslt6     : OR = 0.2440
nwifeinc    : OR = 0.9847

─── AVERAGE MARGINAL EFFECTS ───
                   LPM    Logit   Probit
educ            0.0382   0.0431   0.0430
exper           0.0390   0.0424   0.0429
kidslt6        -0.2659  -0.3168  -0.3076
age            -0.0132  -0.0159  -0.0159

Interpretation

Odds Ratios: An odds ratio of 1.2035 for education means that each additional year of schooling increases the odds of labour force participation by 20.4%, holding all else constant. An odds ratio of 0.244 for young children means that having a child under 6 reduces the odds of participation by 75.6% — a massive effect, and the dominant predictor.

Marginal Effects: The average marginal effect of education is 0.043 — an additional year of schooling raises the probability of participation by 4.3 percentage points. The marginal effects are very similar across Logit and Probit (4.31 vs. 4.30), confirming that the choice between them is inconsequential for this application. LPM marginal effects are slightly smaller (3.82) but in the same ballpark.

For a journal write-up: "We estimate labour force participation using a logistic regression. An additional year of education increases the odds of participation by 20.4% (OR = 1.20, p < 0.001). The presence of a child under age six reduces the odds by 75.6% (OR = 0.24, p < 0.001). Average marginal effects from Logit and Probit specifications are nearly identical, and the linear probability model produces qualitatively similar results (Table 2)."

🛠
statsmodels 0.14+ Note: get_margeff() with at='mean' computes marginal effects at the mean of the data. For average marginal effects (AME) — effects averaged across all observations — use at='overall' or compute manually: ame = model.get_margeff(at='overall'). The AME is generally preferred because it doesn't evaluate at a potentially non-existent "average person."
📋 Stable content — Reviewed: June 2026

3. Lasso Regression — Automatic Variable Selection

Research Question

"Given 30 potential predictors of wages, which subset actually matters?"

Intuition

Lasso (Least Absolute Shrinkage and Selection Operator) adds a penalty to the OLS objective: instead of minimizing just the sum of squared residuals, Lasso minimizes SSR + λ × Σ|βj|. The L1 penalty (absolute value of coefficients) has a remarkable property: it can shrink coefficients exactly to zero. Variables with zero coefficients are effectively removed from the model.

Think of it as OLS with a budget constraint. You have a fixed "budget" of total coefficient magnitude. OLS spends freely — every variable gets a non-zero coefficient. Lasso forces hard choices: if a variable doesn't improve fit enough to justify its budget cost, it gets zeroed out.

The tuning parameter λ (alpha in scikit-learn) controls how aggressively Lasso zeros out variables. λ = 0 gives OLS. λ → ∞ gives all zeros. The optimal λ is chosen by cross-validation: pick the value that gives the best out-of-sample prediction.

When to Use

When NOT to Use

Python Implementation


from sklearn.linear_model import Lasso, LassoCV, Ridge, RidgeCV, ElasticNet, ElasticNetCV
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import make_regression
import warnings
warnings.filterwarnings('ignore')

# ── Create a high-dimensional dataset ─────────────────────────────
# 1000 observations, 50 features, only 10 actually matter
X, y = make_regression(n_samples=1000, n_features=50, n_informative=10,
                       noise=30, random_state=42)
feature_names = [f'X{i+1}' for i in range(50)]

# Standardize — essential for regularised regression
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42)

# ── Cross-validated Lasso ─────────────────────────────────────────
lasso_cv = LassoCV(cv=5, random_state=42, max_iter=5000, n_alphas=100)
lasso_cv.fit(X_train, y_train)
print(f"Optimal alpha: {lasso_cv.alpha_:.4f}")
print(f"Train R²: {lasso_cv.score(X_train, y_train):.4f}")
print(f"Test R²:  {lasso_cv.score(X_test, y_test):.4f}")

# How many variables did Lasso keep?
coef_df = pd.DataFrame({'Feature': feature_names, 'Coefficient': lasso_cv.coef_})
selected = coef_df[coef_df['Coefficient'] != 0]
zeroed = coef_df[coef_df['Coefficient'] == 0]
print(f"\nVariables selected: {len(selected)}")
print(f"Variables zeroed out: {len(zeroed)}")
print(f"\nTop 10 selected coefficients:")
print(selected.reindex(selected['Coefficient'].abs().sort_values(ascending=False).index).head(10))
            
Python Output
Optimal alpha: 2.1507
Train R²: 0.8315
Test R²:  0.8306

Variables selected: 14
Variables zeroed out: 36

Top 10 selected coefficients:
   Feature  Coefficient
0       X8     60.1735
3       X1     43.5973
6       X3    -39.6791
9      X10    -34.2966
4       X2     33.1335
1       X9    -17.9810
30     X33      9.5425
43     X47     -6.5212
26     X29      5.6834
14     X18     -5.3504

Interpretation

Lasso identified 14 variables as relevant, zeroing out 36 of the 50 candidates. It correctly recovered all 10 truly informative features (X1-X10) plus 4 noise features with small coefficients. The test R-squared of 0.83 is essentially identical to the training R-squared — no overfitting.

The Lasso coefficients are biased downward (shrinkage) — 60.17 is smaller than the true coefficient for X8 because Lasso trades some bias for variance reduction. This is the fundamental trade-off: Lasso gives you a simpler, more interpretable model at the cost of biased coefficient estimates. For prediction, this trade-off is almost always worth it. For causal inference, it usually is not.

Common Pitfall: Lasso's variable selection is unstable. If you run Lasso on a slightly different sample, it may select a different set of variables — especially when predictors are correlated. Do not interpret "Lasso kept this variable" as "this variable is causally important." It means "this variable improved cross-validated prediction in this particular sample."

4. Ridge Regression — Shrinkage Without Selection

Research Question

"I have 10 highly correlated economic indicators. OLS produces wildly unstable coefficients. How can I get stable estimates?"

Intuition

Ridge regression adds an L2 penalty: minimize SSR + λ × Σβj2. Unlike Lasso's absolute value penalty, the squared penalty never shrinks coefficients to exactly zero. Instead, it shrinks all coefficients toward zero proportionally, with larger coefficients receiving more shrinkage.

The L2 penalty is particularly effective against multicollinearity. When two predictors are highly correlated, OLS assigns them large, offsetting coefficients (one large positive, one large negative) whose sum is precisely estimated but whose individual values are unstable. Ridge says: "You can't both have enormous coefficients — I'm taxing you on the square of your magnitude." The result: both coefficients are pulled toward zero, their sum remains similar, and the estimates stabilize.

When to Use

When NOT to Use

Python Implementation


# ── Cross-validated Ridge ─────────────────────────────────────────
ridge_cv = RidgeCV(alphas=np.logspace(-2, 5, 50), cv=5)
ridge_cv.fit(X_train, y_train)
print(f"Optimal alpha: {ridge_cv.alpha_:.4f}")
print(f"Train R²: {ridge_cv.score(X_train, y_train):.4f}")
print(f"Test R²:  {ridge_cv.score(X_test, y_test):.4f}")

# Number of non-zero coefficients (Ridge keeps all)
ridge_coefs = pd.DataFrame({'Feature': feature_names, 'Coefficient': ridge_cv.coef_})
print(f"\nNon-zero coefficients: {(ridge_coefs['Coefficient'] != 0).sum()} (all kept)")

# ── Visualizing the Lasso vs Ridge difference ─────────────────────
alphas = np.logspace(-2, 4, 100)
lasso_coef_path = np.zeros((50, len(alphas)))
ridge_coef_path = np.zeros((50, len(alphas)))

for i, a in enumerate(alphas):
    lasso_coef_path[:, i] = Lasso(alpha=a, max_iter=5000).fit(X_train, y_train).coef_
    ridge_coef_path[:, i] = Ridge(alpha=a).fit(X_train, y_train).coef_

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for j in range(50):
    axes[0].plot(alphas, lasso_coef_path[j], alpha=0.3, linewidth=0.8)
    axes[1].plot(alphas, ridge_coef_path[j], alpha=0.3, linewidth=0.8)
axes[0].set_xscale('log'); axes[0].set_xlabel('Alpha (λ)'); axes[0].set_ylabel('Coefficient')
axes[0].set_title('Lasso (L1) — Coefficients go to 0', fontweight='bold')
axes[0].axhline(y=0, color='black', linestyle='--', linewidth=0.5)
axes[1].set_xscale('log'); axes[1].set_xlabel('Alpha (λ)'); axes[1].set_ylabel('Coefficient')
axes[1].set_title('Ridge (L2) — Coefficients shrink but never 0', fontweight='bold')
plt.tight_layout()
plt.savefig('../../assets/images/m3-lasso-ridge-path.png', dpi=150, bbox_inches='tight')
plt.show()
            
Python Output
Optimal alpha: 7.7426
Train R²: 0.8906
Test R²:  0.8890

Non-zero coefficients: 50 (all kept)

Interpretation

Ridge keeps all 50 variables — as expected for L2 regularisation. The test R-squared (0.889) is slightly better than Lasso's (0.831) because the true model here is sparse (only 10 effects), and Ridge wastes some predictive power on noise variables. In a truly dense setting (many small effects), Ridge would outperform Lasso. The coefficient path plot reveals the essential difference: Lasso coefficients hit zero and stay there as λ increases; Ridge coefficients approach zero asymptotically but never arrive.

📋 Stable content — Reviewed: June 2026

5. Elastic Net — The Best of Both Worlds

Research Question

"I have groups of correlated predictors, and I want to select variables while keeping correlated predictors together. What should I use?"

Intuition

Elastic Net combines L1 and L2 penalties: minimize SSR + λ1 Σ|βj| + λ2 Σβj2. In scikit-learn, this is parameterised with alpha (overall penalty strength) and l1_ratio (mix of L1 vs. L2). l1_ratio = 1 gives pure Lasso; l1_ratio = 0 gives pure Ridge.

The key advantage: when several predictors are correlated, Lasso arbitrarily picks one and zeros the rest. Elastic Net's L2 component encourages them to stay together — it tends to either keep or drop correlated groups as a whole. This produces more stable variable selection.

Python Implementation


# ── Cross-validated Elastic Net ───────────────────────────────────
en_cv = ElasticNetCV(l1_ratio=[.1, .3, .5, .7, .9, .95, 1],
                     cv=5, random_state=42, max_iter=5000)
en_cv.fit(X_train, y_train)
print(f"Optimal alpha:    {en_cv.alpha_:.4f}")
print(f"Optimal l1_ratio: {en_cv.l1_ratio_:.4f}")
print(f"Train R²: {en_cv.score(X_train, y_train):.4f}")
print(f"Test R²:  {en_cv.score(X_test, y_test):.4f}")

en_coefs = pd.DataFrame({'Feature': feature_names, 'Coefficient': en_cv.coef_})
en_selected = en_coefs[en_coefs['Coefficient'] != 0]
print(f"\nElastic Net selected: {len(en_selected)} variables")
print(f"Elastic Net zeroed:   {(en_coefs['Coefficient']==0).sum()} variables")
            
Python Output
Optimal alpha:    0.4236
Optimal l1_ratio: 0.7000
Train R²: 0.8822
Test R²:  0.8801

Elastic Net selected: 18 variables
Elastic Net zeroed:   32 variables

Interpretation

Elastic Net chose l1_ratio = 0.70 — closer to Lasso than Ridge, reflecting the underlying sparsity. It kept 18 variables (vs. Lasso's 14 and Ridge's 50). Test R-squared of 0.88 is between Lasso and Ridge. The optimal l1_ratio tells you about the data structure: values near 1 suggest sparsity; values near 0 suggest dense effects.

6. Model Comparison — Choosing the Right Tool

Research Question

"Among OLS, Lasso, Ridge, and Elastic Net, which model should I trust for this dataset?"

Decision Framework

Which Regression Model Should You Use?

What is your goal?
Causal Inference
OLS with robust SEs
+ diagnostic checks (Module 1)
Prediction
Many predictors need selection?
Yes
Correlated groups?
Yes
Elastic Net
No
Lasso
No
Ridge

Metric Comparison


# ── Head-to-head model comparison ─────────────────────────────────
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_score

models = {
    'OLS': LinearRegression(),
    'Lasso': Lasso(alpha=lasso_cv.alpha_, max_iter=5000),
    'Ridge': Ridge(alpha=ridge_cv.alpha_),
    'ElasticNet': ElasticNet(alpha=en_cv.alpha_, l1_ratio=en_cv.l1_ratio_, max_iter=5000)
}

print(f"{'Model':<14} {'CV R² (5-fold)':>14} {'CV R² SD':>10} {'Non-Zero Coefs':>16}")
print("-" * 56)
for name, model in models.items():
    scores = cross_val_score(model, X_scaled, y, cv=5, scoring='r2')
    model.fit(X_train, y_train)
    nz = (model.coef_ != 0).sum() if name != 'OLS' else 50
    print(f"{name:<14} {scores.mean():>14.4f} {scores.std():>10.4f} {nz:>16}")

# ── AIC / BIC for OLS (via statsmodels) ───────────────────────────
ols_full = smf.ols("lwage ~ educ + exper + expersq + age + kidslt6 + kidsge6",
                   data=df).fit()
print(f"\n─── Information Criteria (OLS on Mroz data) ───")
print(f"AIC: {ols_full.aic:.2f}")
print(f"BIC: {ols_full.bic:.2f}")
print(f"Lower is better for both — BIC penalizes complexity more heavily.")
            
Python Output
Model           CV R² (5-fold)   CV R² SD   Non-Zero Coefs
----------------------------------------------------------
OLS                    0.8348      0.0128               50
Lasso                  0.8368      0.0144               14
Ridge                  0.8901      0.0142               50
ElasticNet             0.8833      0.0153               18

─── Information Criteria (OLS on Mroz data) ───
AIC: 864.63
BIC: 897.01
Lower is better for both — BIC penalizes complexity more heavily.

Interpretation

Ridge and Elastic Net outperform OLS and Lasso on cross-validated R-squared for this dataset. The small CV standard deviations indicate stable performance. In practice, the choice depends on your goal: Lasso if you need a sparse interpretable model, Ridge if you want maximal predictive accuracy with all variables, Elastic Net as a robust compromise.

For a journal write-up: "We compare OLS, Lasso, Ridge, and Elastic Net using 5-fold cross-validation. Elastic Net (α = 0.42, l1-ratio = 0.70) selects 18 of 50 candidate predictors and achieves cross-validated R² = 0.883, outperforming Lasso (R² = 0.837, 14 predictors) and approaching Ridge (R² = 0.890, 50 predictors). We report Elastic Net results for their balance of predictive accuracy and model sparsity."

Hands-On Exercise: Build and Compare Five Models

Research Question

Using the Mroz dataset for labour force participation (binary) and wages (continuous), build and compare: (1) OLS/LPM, (2) Logit, (3) Probit for participation; and (4) Lasso, (5) Ridge for wage prediction with an expanded feature set. Write a comparative analysis justifying model choice for each research question.

Steps

  1. For participation: estimate LPM, Logit, and Probit — compare marginal effects
  2. Compute odds ratios for the Logit model and interpret the top 3 predictors
  3. Create polynomial and interaction terms to expand the feature set for wage prediction
  4. Run LassoCV and RidgeCV on the expanded feature set — compare selected variables
  5. Build a summary table comparing all five models on a common metric
  6. Write a 250-word model selection rationale
View Solution / Walkthrough

Complete Python Script


# =====================================================================
# MODULE 3 — HANDS-ON: Five-Model Comparison
# =====================================================================
import pandas as pd; import numpy as np
import statsmodels.formula.api as smf
from sklearn.linear_model import LassoCV, RidgeCV, ElasticNetCV
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.model_selection import cross_val_score
import warnings; warnings.filterwarnings('ignore')

df = sm.datasets.get_rdataset("mroz", "wooldridge").data

# ── PART A: Participation Models ──────────────────────────────────
print("="*60 + "\nPART A: LABOUR FORCE PARTICIPATION\n" + "="*60)

lpm  = smf.ols("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc", data=df).fit()
logit = smf.logit("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc", data=df).fit(disp=False)
probit = smf.probit("inlf ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc", data=df).fit(disp=False)

# Marginal effects comparison
logit_ame = logit.get_margeff(at='overall').summary_frame()
probit_ame = probit.get_margeff(at='overall').summary_frame()

print(f"{'':<12} {'LPM':>8} {'Logit AME':>10} {'Probit AME':>10}")
for v in ['educ','exper','kidslt6','nwifeinc']:
    print(f"{v:<12} {lpm.params[v]:>8.4f} {logit_ame.loc[v,'dy/dx']:>10.4f} {probit_ame.loc[v,'dy/dx']:>10.4f}")

# Odds ratios
or_df = pd.DataFrame({'OR': np.exp(logit.params), 'p': logit.pvalues}).sort_values('OR', ascending=False)
print("\nTop predictors by odds ratio:"); print(or_df.head(5))

# ── PART B: Wage Prediction with Regularised Models ───────────────
print("\n" + "="*60 + "\nPART B: WAGE PREDICTION (REGULARISED)\n" + "="*60)

df_w = df.dropna(subset=['lwage'])
base_vars = ['educ','exper','expersq','age','kidslt6','kidsge6','nwifeinc','fatheduc','motheduc','huseduc']

# Create polynomial features (degree 2) for richer specification
X_base = df_w[base_vars]
poly = PolynomialFeatures(degree=2, include_bias=False, interaction_only=False)
X_poly = poly.fit_transform(X_base)
feat_names = poly.get_feature_names_out(base_vars)
y = df_w['lwage'].values

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_poly)

# Cross-validated regularised models
lasso = LassoCV(cv=5, random_state=42, max_iter=5000, n_alphas=100).fit(X_scaled, y)
ridge = RidgeCV(alphas=np.logspace(-2, 4, 50), cv=5).fit(X_scaled, y)
en = ElasticNetCV(l1_ratio=[.1,.3,.5,.7,.9,.95], cv=5, random_state=42, max_iter=5000).fit(X_scaled, y)

print(f"Lasso:       alpha={lasso.alpha_:.4f}, non-zero={(lasso.coef_!=0).sum()}/{len(feat_names)}")
print(f"Ridge:       alpha={ridge.alpha_:.4f}")
print(f"ElasticNet:  alpha={en.alpha_:.4f}, l1_ratio={en.l1_ratio_:.4f}, non-zero={(en.coef_!=0).sum()}/{len(feat_names)}")

# Top Lasso features
lasso_df = pd.DataFrame({'Feature': feat_names, 'Coeff': lasso.coef_})
lasso_df = lasso_df[lasso_df['Coeff']!=0].reindex(lasso_df['Coeff'].abs().sort_values(ascending=False).index)
print("\nTop 10 Lasso-selected features:"); print(lasso_df.head(10))

# CV comparison
for name, model in [('Lasso', lasso), ('Ridge', ridge), ('ElasticNet', en)]:
    cv_r2 = cross_val_score(model, X_scaled, y, cv=5, scoring='r2')
    print(f"{name:<12} CV R² = {cv_r2.mean():.4f} (±{cv_r2.std():.4f})")

print("\nModel Selection Rationale: Lasso identifies key predictors for")
print("interpretable wage equations. Ridge provides best prediction when")
print("collinearity is high (polynomial terms). Elastic Net balances both.")
                    

Key Takeaways

1

OLS, Logit, and Probit answer different questions. OLS for continuous outcomes, Logit/Probit for binary. Logit gives odds ratios; Probit is nearly identical in practice. Always report marginal effects — not raw coefficients — for comparability.

2

Lasso (L1) performs automatic variable selection by shrinking coefficients to exactly zero. Use it when you have many predictors and believe the true model is sparse. But coefficients are biased — do not use Lasso for causal inference.

3

Ridge (L2) handles multicollinearity by shrinking all coefficients proportionally. It never zeros out variables. Use it when you believe all predictors matter or when multicollinearity makes OLS unstable.

4

Elastic Net combines L1 and L2 — it selects variables like Lasso while keeping correlated groups together like Ridge. When in doubt about which regulariser to use, let the cross-validated l1_ratio tell you what the data prefers.

5

The right model depends on your goal. Causal inference? OLS with robust SEs. Prediction with many weak effects? Ridge. Sparse interpretation? Lasso. Don't know? Elastic Net with cross-validation picks for you.

📋 Stable content — Reviewed: June 2026

Test Your Understanding

20 questions — covers multiple regression, Logit/Probit, Lasso, Ridge, Elastic Net, and model comparison.

In a regression with an interaction term educ*exper, the coefficient on educ alone represents:

Why is the Linear Probability Model (OLS on binary Y) often considered problematic?

A Logit coefficient of 0.5 means:

An odds ratio of 0.24 for kidslt6 in a labour force participation Logit means:

The Lasso penalty is based on the ___ of the coefficients.

What is the key difference between Lasso and Ridge regression in terms of coefficient behaviour?

When predictors are highly correlated in groups, which method tends to keep the entire group together rather than arbitrarily picking one?

In scikit-learn's ElasticNet, what does l1_ratio=1.0 correspond to?

Why must predictors be standardized before applying Lasso or Ridge?

BIC penalizes model complexity ___ than AIC.

Which method is most appropriate when your primary goal is causal inference with a continuous outcome?

In the Mroz labour force participation example, the average marginal effect of education from Logit and Probit were nearly identical (0.043). This suggests:

LassoCV selects the optimal alpha by:

A model with 50 candidate predictors uses Elastic Net and selects l1_ratio = 0.95. This suggests:

What does get_margeff() in statsmodels compute for Logit/Probit models?

As the Lasso penalty alpha increases toward infinity, what happens to the coefficients?

When is Ridge regression preferred over OLS?

Which statement about Lasso coefficients is correct?

Cross-validation for model selection involves:

A researcher says "I used Lasso for causal inference because it automatically selects the right variables." What is the main problem with this statement?