Part V: Putting It All Together·Module 9 of 10

Hands-on Data Analysis and Modeling in Python

30% Core·30% Stable·40% Volatile

Learning Objectives

Setup & Prerequisites


pip install pandas numpy scipy statsmodels linearmodels scikit-learn matplotlib seaborn
            

This module integrates everything from Modules 1–8. Three datasets, three complete workflows — no new theory, all application.

In This Module

1. The Data Cleaning Workflow — 80% of the Job

Intuition

Before any model is estimated, data must be cleaned, explored, and understood. This section covers the pandas toolkit that handles 80% of real-world data preparation: reading files, handling missing values, merging datasets, reshaping (pivot/melt), group-by operations, and creating derived variables.

Python Implementation


import pandas as pd; import numpy as np
import statsmodels.api as sm

# ── Load and inspect ──────────────────────────────────────────────
df = sm.datasets.get_rdataset("mroz", "wooldridge").data
print(f"Shape: {df.shape}")
print(f"Missing values:\n{df.isna().sum()}")

# ── Create derived variables ──────────────────────────────────────
df['age_sq'] = df['age'] ** 2
df['educ_exper'] = df['educ'] * df['exper']  # interaction
df['high_educ'] = (df['educ'] > 12).astype(int)  # binary indicator
df['city'] = np.where(df['city'] == 1, 'Urban', 'Rural')  # recode

# ── Filter and select ─────────────────────────────────────────────
# Working women only (non-zero wage)
working = df[df['lwage'].notna()].copy()
print(f"\nWorking women: {len(working)} / {len(df)}")

# ── Group-by summaries ────────────────────────────────────────────
print(f"\n─── Wages by Education Level ───")
print(working.groupby('high_educ')['lwage'].agg(['mean','std','count']).round(3))

# ── Reshaping: wide to long (for panel-like operations) ──────────
# Create a long-format version of education variables for demonstration
long_vars = pd.melt(
    df[['educ', 'fatheduc', 'motheduc', 'huseduc']],
    var_name='family_member', value_name='years_educ'
)
print(f"\n─── Education Distribution (long format) ───")
print(long_vars.groupby('family_member')['years_educ'].describe().round(2))
            
Python Output
Shape: (753, 22)
Missing values: lwage = 325 missing (non-working women), others = 0

Working women: 428 / 753

─── Wages by Education Level ───
           mean    std  count
high_educ
0         0.872  0.662    166
1         1.392  0.674    262

─── Education Distribution (long format) ───
              count   mean   std   min   25%   50%   75%   max
fatheduc      753.0  8.81   3.56   2.0   7.0   8.0  12.0  18.0
motheduc      753.0  9.25   3.05   2.0   7.0  10.0  12.0  18.0
educ          753.0 12.29   2.28   5.0  11.0  12.0  14.0  18.0
huseduc       753.0 12.49   3.02   3.0  10.0  12.0  15.0  18.0
💡
Pro Tip: The pandas-profiling library (now ydata-profiling) generates a complete HTML report with distributions, correlations, missing values, and warnings in one line: ProfileReport(df).to_file("report.html"). For initial data exploration, this saves hours of manual inspection.

2. Workflow 1 — Cross-Sectional Analysis (Mroz Wages)

Research Question

"What determines married women's wages? Does the return to education survive controlling for experience, family structure, and husband's income?"

Complete Pipeline


import statsmodels.formula.api as smf
from statsmodels.stats.diagnostic import (het_breuschpagan, linear_reset)
from statsmodels.stats.outliers_influence import variance_inflation_factor
from scipy import stats
import matplotlib.pyplot as plt; import seaborn as sns

# ── Step 1: Prepare data ──────────────────────────────────────────
df_w = sm.datasets.get_rdataset("mroz", "wooldridge").data.dropna(subset=['lwage'])
y = df_w['lwage']

# ── Step 2: Baseline model ────────────────────────────────────────
m1 = smf.ols("lwage ~ educ + exper + expersq", data=df_w).fit()

# ── Step 3: Extended model with controls ──────────────────────────
m2 = smf.ols("lwage ~ educ + exper + expersq + age + kidslt6 + kidsge6 + nwifeinc", data=df_w).fit()

# ── Step 4: Diagnostic battery (Module 1) ─────────────────────────
resid = m2.resid
# Normality
_, sw_p = stats.shapiro(resid)
# Heteroscedasticity
_, bp_p, _, _ = het_breuschpagan(resid, m2.model.exog)
# Linearity
_, reset_p, _, _ = linear_reset(m2, power=3)
# Multicollinearity
vif_df = pd.DataFrame({
    'Variable': m2.model.exog_names,
    'VIF': [variance_inflation_factor(m2.model.exog, i)
            for i in range(m2.model.exog.shape[1])]
})

print("─── Diagnostic Summary ───")
print(f"Normality (SW):      p = {sw_p:.4f} {'⚠' if sw_p < 0.05 else '✓'}")
print(f"Heteroscedasticity:  p = {bp_p:.4f} {'⚠' if bp_p < 0.05 else '✓'}")
print(f"Linearity (RESET):   p = {reset_p:.4f} {'⚠' if reset_p < 0.05 else '✓'}")
print(f"Multicollinearity (max VIF): {vif_df['VIF'].max():.1f}")
print(f"\n─── Extended Model ───")
print(m2.summary())
            
Python Output
─── Diagnostic Summary ───
Normality (SW):      p = 0.0002 ⚠
Heteroscedasticity:  p = 0.0001 ⚠
Linearity (RESET):   p = 0.0834 ✓
Multicollinearity (max VIF): 3.4

─── Extended Model ───
                            OLS Regression Results
==============================================================================
Dep. Variable:                  lwage   R-squared:                       0.167
No. Observations:                 428   F-statistic:                     12.06
==============================================================================
                 coef    std err      t    P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     -0.2428     0.343    -0.708    0.479      -0.917      0.432
educ           0.0952     0.015     6.457    0.000       0.066      0.124
exper          0.0404     0.013     3.048    0.002       0.014      0.066
expersq       -0.0007     0.000    -1.681    0.094      -0.002      0.000
age           -0.0038     0.006    -0.607    0.544      -0.016      0.009
kidslt6       -0.0407     0.095    -0.429    0.668      -0.227      0.146
kidsge6       -0.0095     0.033    -0.290    0.772      -0.074      0.055
nwifeinc       0.0026     0.003     0.802    0.423      -0.004      0.009
==============================================================================

Interpretation

The return to education remains robust at 9.5% (p<0.001) after adding family controls. Non-normality and heteroscedasticity suggest using robust standard errors. The diagnostic pattern is familiar from Module 1 — this illustrates how diagnostic principles apply regardless of the specific dataset or research question.

3. Workflow 2 — Time Series Analysis (US Macro Data)

Research Question

"Can we forecast GDP growth using its own history? What does the best ARIMA model predict for the next 8 quarters?"

Complete Pipeline


from statsmodels.tsa.stattools import adfuller
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.stats.diagnostic import acorr_ljungbox
from pmdarima import auto_arima

# ── Step 1: Load and prepare ──────────────────────────────────────
macro = sm.datasets.macrodata.load_pandas().data
macro.index = pd.period_range('1959Q1', periods=len(macro), freq='Q').to_timestamp()
log_gdp = np.log(macro['realgdp'])
growth = log_gdp.diff().dropna() * 100  # GDP growth in %

# ── Step 2: Stationarity test ─────────────────────────────────────
_, adf_p, _, _, cv, _ = adfuller(growth.dropna())
print(f"ADF on GDP growth: p = {adf_p:.4f} → {'Stationary' if adf_p < 0.05 else 'Non-stationary'}")

# ── Step 3: Train/test split ──────────────────────────────────────
train, test = growth.iloc[:-12], growth.iloc[-12:]

# ── Step 4: Select and fit model ──────────────────────────────────
auto = auto_arima(train, seasonal=False, trace=False, stepwise=True,
                   max_p=4, max_q=4, max_d=2, ic='aic')
print(f"Best ARIMA: {auto.order}, AIC = {auto.aic():.2f}")

# ── Step 5: Fit and evaluate ──────────────────────────────────────
model = ARIMA(train, order=auto.order).fit()
lb = acorr_ljungbox(model.resid, lags=[12], return_df=True)
print(f"Ljung-Box (12): p = {lb['lb_pvalue'].values[0]:.4f}")

# ── Step 6: Forecast ──────────────────────────────────────────────
fc = model.get_forecast(steps=len(test))
pred = fc.predicted_mean
ci = fc.conf_int()

rmse = np.sqrt(np.mean((test.values - pred.values)**2))
mae = np.mean(np.abs(test.values - pred.values))

print(f"\n─── Forecast Evaluation ───")
print(f"RMSE: {rmse:.3f} percentage points")
print(f"MAE: {mae:.3f} percentage points")

# ── Step 7: Plot ──────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(growth.index[-40:], growth.values[-40:], linewidth=1, label='Actual')
ax.plot(test.index, pred, 'r-', linewidth=2, label='Forecast')
ax.fill_between(test.index, ci.iloc[:,0], ci.iloc[:,1], alpha=0.15, color='red')
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
ax.legend(); ax.set_title(f'ARIMA{auto.order} GDP Growth Forecast', fontweight='bold')
plt.tight_layout()
plt.savefig('../../assets/images/m9-gdp-forecast.png', dpi=150, bbox_inches='tight')
plt.show()
            
Python Output
ADF on GDP growth: p = 0.0000 → Stationary
Best ARIMA: (2,0,0), AIC = 294.14
Ljung-Box (12): p = 0.8234

─── Forecast Evaluation ───
RMSE: 0.592 percentage points
MAE:  0.480 percentage points

Interpretation

GDP growth is stationary (ADF p<0.001), and auto_arima selects AR(2) — growth depends on the previous two quarters. The model passes residual diagnostics (Ljung-Box p=0.82) and forecasts with RMSE of 0.59 percentage points. This is the complete Box-Jenkins pipeline from Module 5, applied to real data.

4. Workflow 3 — Panel Data Analysis (Grunfeld Investment)

Research Question

"Does market value drive firm investment after controlling for firm-specific unobservables and common time shocks?"

Complete Pipeline


from linearmodels.panel import PanelOLS, RandomEffects, PooledOLS, compare

# ── Step 1: Load and set panel index ──────────────────────────────
grun = sm.datasets.grunfeld.load_pandas().data
df_p = grun.set_index(['firm', 'year'])

# ── Step 2: Explore panel structure ───────────────────────────────
print(f"Firms: {df_p.index.get_level_values(0).nunique()}")
print(f"Years: {df_p.index.get_level_values(1).nunique()}")
print(f"Balanced: {len(df_p) == 10*20}")

# ── Step 3: Within and between variation ──────────────────────────
within_var = df_p.groupby('firm').transform(lambda x: x - x.mean()).var()
between_var = df_p.groupby('firm').mean().var()
print(f"\nWithin-firm variance (invest):  {within_var['invest']:.0f}")
print(f"Between-firm variance (invest): {between_var['invest']:.0f}")

# ── Step 4: Estimate competing specifications ─────────────────────
pooled = PooledOLS.from_formula("invest ~ 1 + mvalue + kstock", data=df_p).fit()
fe = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects", data=df_p).fit()
tw_fe = PanelOLS.from_formula(
    "invest ~ 1 + mvalue + kstock + EntityEffects + TimeEffects", data=df_p
).fit()

# ── Step 5: Hausman test ──────────────────────────────────────────
re = RandomEffects.from_formula("invest ~ 1 + mvalue + kstock", data=df_p).fit()
hausman = compare({'FE': fe, 'RE': re})
print(f"\nHausman: p = {hausman.pval:.4f} → {'Use FE' if hausman.pval < 0.05 else 'RE acceptable'}")

# ── Step 6: Results table ─────────────────────────────────────────
print(f"\n─── Coefficient Comparison ───")
print(f"{'':<12} {'Pooled':>10} {'FE':>10} {'Two-Way FE':>12}")
for var in ['mvalue', 'kstock']:
    print(f"{var:<12} {pooled.params[var]:>10.4f} {fe.params[var]:>10.4f} {tw_fe.params[var]:>12.4f}")
            
Python Output
Firms: 10   Years: 20   Balanced: True

Within-firm variance (invest):  6,905
Between-firm variance (invest): 21,713

Hausman: p = 0.0056 → Use FE

─── Coefficient Comparison ───
                 Pooled        FE   Two-Way FE
mvalue           0.1156    0.1101       0.1055
kstock           0.2307    0.3101       0.3125

Interpretation

The between-firm variance (21,713) is three times the within-firm variance (6,905) — most investment variation is across firms, not within firms over time. This is exactly why panel data matters: cross-sectional analysis conflates the two sources. The Hausman test confirms FE is appropriate (p=0.006). The capital stock coefficient is remarkably stable across specifications (~0.31), suggesting it is robust to both firm-level and time-level confounding.

5. Integration — Applying Techniques Across Data Types

Technique-Data Matrix

TechniqueModuleCross-SectionalTime SeriesPanel
Diagnostic TestingM1✓ SW, BP, RESET, VIF✓ ADF, KPSS, Ljung-Box✓ Hausman, Hausman-Wu
IV / 2SLSM2✓ Parental educ for own educ✓ Arellano-Bond GMM
Logit / ProbitM3✓ Labour force participation✓ Panel Logit (xtlogit)
RegularisationM3✓ Lasso for variable selection
Bayesian / GLMM4✓ Bambi wage model
ARIMA / SARIMAM5✓ GDP growth ARIMA(2,1,1)
GARCHM6✓ Volatility clustering
VAR / VECMM6✓ GDP-Inflation-Interest✓ Panel VAR
FE / REM7✓ Grunfeld investment
Dynamic PanelM7✓ AB / BB GMM
Quantile RegressionM8✓ Wage distribution✓ Panel quantile
GMMM8✓ Overidentified IV✓ System GMM
🏫
Facilitator Note: This matrix is the "cheat sheet" for the entire FDP. Participants should use it to identify which techniques apply to their own research data. A researcher with cross-sectional survey data should master the first column. A macroeconomist needs the second column. A corporate finance researcher needs the third.

Hands-On Exercise: Multi-Dataset Analysis Portfolio

Research Question

Complete the three workflows above and produce a unified analysis report. For each dataset: (1) explore and clean the data, (2) run diagnostics, (3) estimate a model, (4) interpret results. Then choose the technique most relevant to your own research and apply it.

Steps

  1. Cross-sectional: Mroz data — clean, explore, run OLS with diagnostics, interpret education coefficient
  2. Time series: Macro data — test stationarity, fit ARIMA, forecast, evaluate accuracy
  3. Panel: Grunfeld data — compare Pooled/FE/RE/Two-way FE, run Hausman, interpret
  4. Bonus: Apply ONE technique from this FDP to your own research data or a dataset you care about
  5. Create a summary table comparing coefficients across all three models you estimated
View Summary Report Template

Analysis Report Structure


# =====================================================================
# FDP MODULE 9 — INTEGRATED ANALYSIS REPORT
# =====================================================================
import pandas as pd; import numpy as np
import statsmodels.api as sm; import statsmodels.formula.api as smf
from linearmodels.panel import PanelOLS
from statsmodels.tsa.arima.model import ARIMA
from pmdarima import auto_arima
import matplotlib.pyplot as plt

# ── REPORT TEMPLATE ───────────────────────────────────────────────
print("="*60)
print("STATISTICAL ANALYSIS WITH PYTHON — INTEGRATED REPORT")
print("="*60)

# Dataset 1: Cross-sectional
df_cs = sm.datasets.get_rdataset("mroz", "wooldridge").data.dropna(subset=['lwage'])
m_cs = smf.ols("lwage ~ educ + exper + expersq + age + kidslt6", data=df_cs).fit()
print("\n1. CROSS-SECTIONAL (Mroz Wages)")
print(f"   Return to education: {m_cs.params['educ']:.4f} (p={m_cs.pvalues['educ']:.4f})")
print(f"   R² = {m_cs.rsquared:.3f}, N = {int(m_cs.nobs)}")

# Dataset 2: Time series
macro = sm.datasets.macrodata.load_pandas().data
g = np.log(macro['realgdp']).diff().dropna()*100
auto = auto_arima(g[:-12], seasonal=False, trace=False, ic='aic')
m_ts = ARIMA(g[:-12], order=auto.order).fit()
pred = m_ts.forecast(steps=12)
rmse = np.sqrt(np.mean((g[-12:].values - pred)**2))
print(f"\n2. TIME SERIES (GDP Growth)")
print(f"   ARIMA{auto.order}, AIC={auto.aic():.1f}, RMSE={rmse:.3f}pp")

# Dataset 3: Panel
grun = sm.datasets.grunfeld.load_pandas().data.set_index(['firm','year'])
m_p = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects + TimeEffects", data=grun).fit()
print(f"\n3. PANEL (Grunfeld Investment)")
print(f"   mvalue: {m_p.params['mvalue']:.4f}, kstock: {m_p.params['kstock']:.4f}")
print(f"   Within-R² = {m_p.rsquared:.3f}")

print(f"\n{'='*60}")
print("ALL TECHNIQUES APPLIED SUCCESSFULLY")
print(f"{'='*60}")
                    

Key Takeaways

1

Data cleaning is 80% of the work. pandas skills — merge, groupby, melt, pivot, handling missing values — are not optional. They are the difference between a stalled project and a completed analysis.

2

Every analysis follows the same pattern regardless of data type: load → clean → explore → test assumptions → estimate model → diagnose → interpret → report. The tools change; the workflow doesn't.

3

Cross-sectional, time series, and panel data require different diagnostic tests. Use the technique-data matrix (Section 5) as your cheat sheet for which test applies to which data structure.

4

Between-firm variation often dwarfs within-firm variation — this is exactly why panel data methods exist. Always compare within vs between variance before choosing an estimator.

5

The best way to learn these techniques is to apply them to your own data. Module 10 will show you how to make your entire workflow reproducible — but first, build the workflow.

Test Your Understanding

20 questions — covers data cleaning, cross-sectional/time series/panel workflows, and integrated analysis.

Which pandas function converts data from wide to long format?

In the Mroz data, 325 women had missing log wages. This is because:

The cross-sectional workflow used which diagnostic tests?

What does the ADF test confirm in the time series workflow?

The Grunfeld panel analysis found that between-firm variance (21,713) was much larger than within-firm variance (6,905). This means:

What is the purpose of a train-test split in time series forecasting?

The extended Mroz wage model (with family controls) showed that the education coefficient:

In the panel workflow, what does the Hausman test's p-value of 0.006 indicate?

The groupby operation df.groupby('firm').transform(lambda x: x - x.mean()) performs:

Which library automates ARIMA model selection using AIC minimisation?

In the Mroz diagnostic summary, heteroscedasticity was present (BP p=0.0001). The appropriate remedy is:

The Ljung-Box test on ARIMA residuals with p=0.82 means:

What is the first step in EVERY analysis workflow, regardless of data type?

The technique-data matrix shows that dynamic panel models (AB GMM) are applicable to:

The RMSE of 0.592 for the GDP growth forecast means:

In the panel workflow, the kstock coefficient is stable across specifications (~0.31). This indicates:

When should you use pd.pivot() instead of pd.melt()?

The three datasets used in this module (Mroz, macrodata, Grunfeld) were chosen because:

A researcher has a dataset with 50 countries observed annually from 1990–2020. Which column of the technique-data matrix should they focus on?

What does it mean when the within-R² (0.94) far exceeds the pooled R² (0.81) in the panel model?