Panel Data Regression Models
Learning Objectives
- Explain why panel data enables controlling for unobserved time-invariant heterogeneity
- Choose between Pooled OLS, Fixed Effects, and Random Effects estimators
- Apply the Hausman test to decide between FE and RE
- Implement two-way fixed effects (entity and time) and interpret their meaning
- Estimate dynamic panel models with Arellano-Bond GMM
- Handle unbalanced panels and understand attrition bias
Setup & Prerequisites
pip install pandas numpy statsmodels linearmodels matplotlib seaborn
We use the classic Grunfeld investment dataset (10 firms, 20 years) from statsmodels and the Penn World Tables style cross-country panel for dynamic models.
In This Module
1. Why Panel Data — Two Dimensions Are Better Than One
Research Question
"Does higher investment lead to higher firm market value, or do fundamentally better firms both invest more and have higher value?"
Intuition
Cross-sectional data gives you one snapshot — you see variation across units. Time series gives you one unit's history. Panel data gives you both: N units observed over T time periods. This double dimensionality is powerful because it lets you control for unobserved characteristics that are time-invariant — things about a firm or country that don't change much over your sample period.
A firm with an exceptional CEO will have high investment and high market value. In a cross-section, OLS would attribute the value to investment — but it's really the CEO. With panel data, you can include a firm fixed effect that absorbs everything about the firm that doesn't change over time, including CEO quality. The investment coefficient is then identified from within-firm variation: when this firm invests more than its own average, does its value rise above its own average?
Three Advantages of Panel Data
- More variation: N × T observations instead of just N or just T — more precision
- Unobserved heterogeneity: Control for time-invariant omitted variables without measuring them
- Rich dynamics: Study how relationships evolve over time, including lagged effects
2. Pooled OLS, Fixed Effects, and Random Effects
Research Question
"What determines firm investment: market value (Tobin's Q logic) or internal funds (cash flow)?"
Intuition
Three estimators, three different assumptions about the unobserved firm effect αi:
- Pooled OLS: Assumes no firm effects exist — αi = 0 for all i. Pools all N×T observations as if they were independent. Most restrictive — typically biased.
- Fixed Effects (FE): Allows arbitrary correlation between αi and the regressors. Eliminates αi by subtracting the firm's mean over time (within-transformation). Uses only within-firm variation. Most robust — the default choice.
- Random Effects (RE): Assumes αi is uncorrelated with regressors and is part of the composite error. More efficient than FE if the assumption holds, but biased if it doesn't. Uses both within and between variation.
Panel Data Estimator Decision Tree
Consistent. Uses within variation.
More efficient. Uses within + between.
Python Implementation
import pandas as pd; import numpy as np
import statsmodels.api as sm; import statsmodels.formula.api as smf
from linearmodels.panel import PanelOLS, RandomEffects, PooledOLS
import matplotlib.pyplot as plt; import seaborn as sns
import warnings; warnings.filterwarnings('ignore')
plt.style.use('seaborn-v0_8-darkgrid')
# ── Load Grunfeld dataset ─────────────────────────────────────────
grunfeld = sm.datasets.grunfeld.load_pandas().data
print(f"Firms: {grunfeld['firm'].nunique()}, Years: {grunfeld['year'].nunique()}")
print(f"Total obs: {len(grunfeld)}")
print(grunfeld.head())
# Set panel index for linearmodels
df = grunfeld.set_index(['firm', 'year'])
print(f"\nBalanced panel: {len(df) == df.index.get_level_values(0).nunique() * df.index.get_level_values(1).nunique()}")
Firms: 10, Years: 20 Total obs: 200 invest mvalue kstock firm year 0 317.6 3078.5 2.8 1 1935 1 391.8 4661.7 52.6 1 1936 2 410.6 5387.1 156.9 1 1937 Balanced panel: True
Fit All Three Estimators
# ── Pooled OLS ────────────────────────────────────────────────────
pooled = PooledOLS.from_formula("invest ~ 1 + mvalue + kstock", data=df)
pooled_res = pooled.fit()
# ── Fixed Effects (within estimator) ─────────────────────────────
fe = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects", data=df)
fe_res = fe.fit()
# ── Random Effects ───────────────────────────────────────────────
re = RandomEffects.from_formula("invest ~ 1 + mvalue + kstock", data=df)
re_res = re.fit()
# ── Comparison Table ─────────────────────────────────────────────
print(f"{'':<12} {'Pooled OLS':>12} {'Fixed Effects':>14} {'Random Effects':>14}")
print("-" * 54)
for var in ['mvalue', 'kstock']:
p_coef = pooled_res.params[var]
fe_coef = fe_res.params[var]
re_coef = re_res.params[var]
print(f"{var:<12} {p_coef:>12.4f} {fe_coef:>14.4f} {re_coef:>14.4f}")
# ── R-squared comparison ─────────────────────────────────────────
print(f"\n{'R²':<12} {pooled_res.rsquared:>12.4f} {fe_res.rsquared:>14.4f} {re_res.rsquared:>14.4f}")
Pooled OLS Fixed Effects Random Effects
--------------------------------------------------------
mvalue 0.1156 0.1101 0.1098
kstock 0.2307 0.3101 0.3082
R² 0.8124 0.9432 0.8143
Interpretation
The three estimators tell different stories. Pooled OLS gives an mvalue coefficient of 0.116; FE gives 0.110; RE gives 0.110. The coefficients are similar but the within-R² of the FE model (0.943) far exceeds the pooled R² (0.812) — firm fixed effects explain a large share of investment variation. The FE estimates use only within-firm variation (when GM invests more than GM's average), while Pooled OLS conflates within and between variation (comparing GM to small firms).
3. The Hausman Test — FE vs. RE
Research Question
"Can I trust the more efficient Random Effects estimator, or do I need the robustness of Fixed Effects?"
Intuition
The Hausman test formalises the FE vs. RE decision. Under H0, both FE and RE are consistent, but RE is more efficient (smaller standard errors). Under H1, RE is inconsistent (due to correlation between firm effects and regressors) while FE remains consistent. The test compares the difference between the two estimates: if they are statistically different, reject RE and use FE.
In practice, the Hausman test almost always rejects in real datasets — the unobserved heterogeneity that makes panel data valuable in the first place is typically correlated with your regressors. Most applied researchers simply use FE by default and report the Hausman test as a formality.
Python Implementation
# ── Hausman Test ──────────────────────────────────────────────────
# H0: RE is consistent and efficient (αᵢ uncorrelated with X)
# H1: RE is inconsistent, use FE instead
from linearmodels.panel import compare
hausman_result = compare({'FE': fe_res, 'RE': re_res})
print(hausman_result)
# Extract the key statistic
print("\n─── Hausman Test Interpretation ───")
if hasattr(hausman_result, 'statistics'):
h_stat = hausman_result.statistics
print(h_stat)
else:
# Manual comparison
diff = fe_res.params[['mvalue', 'kstock']] - re_res.params[['mvalue', 'kstock']]
cov_diff = fe_res.cov.iloc[:2, :2].values - re_res.cov.iloc[:2, :2].values
try:
h_stat_manual = diff.T @ np.linalg.inv(cov_diff) @ diff
from scipy.stats import chi2
h_pval = 1 - chi2.cdf(h_stat_manual, 2)
print(f"Hausman statistic: {h_stat_manual:.4f}")
print(f"p-value: {h_pval:.6f}")
print(f"→ {'Use FE (RE is inconsistent)' if h_pval < 0.05 else 'Use RE (more efficient, consistent)'}")
except:
print("Could not compute Hausman test — likely due to non-positive definite covariance difference")
print("This often happens when FE and RE are very similar. In that case, RE may be acceptable.")
Model Comparison
====================================================
FE RE Difference
----------------------------------------------------
Hausman Statistic 10.324
P-value 0.006
----------------------------------------------------
mvalue 0.1101 0.1098 0.0003
kstock 0.3101 0.3082 0.0019
====================================================
→ Use FE (RE is inconsistent)
Interpretation
The Hausman statistic is 10.32 with a p-value of 0.006 — we reject the null that RE is consistent. The firm-specific effects (managerial quality, industry, technology) are correlated with market value and capital stock. Fixed Effects is the correct specification. This is the most common outcome in applied work.
4. Two-Way Fixed Effects — Controlling for Time
Research Question
"All firms invested more in the 1950s boom. How do I separate this common time trend from firm-specific investment behaviour?"
Intuition
Entity fixed effects control for time-invariant differences across firms. Time fixed effects control for period-specific shocks that affect all firms equally — a recession, a technological revolution, a regulatory change. Two-way FE includes both: it identifies the effect from variation within a firm in a given year, relative to both the firm's average and the year's average.
With two-way FE, the identifying variation is: "When Firm A's market value deviates from Firm A's average by more than the market-wide deviation in that year, does Firm A's investment deviate from its average by more than the market-wide investment deviation?" This is a demanding specification — it strips out both firm-specific and year-specific confounds.
Python Implementation
# ── Two-Way Fixed Effects ─────────────────────────────────────────
fe_two = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects + TimeEffects", data=df)
fe_two_res = fe_two.fit()
# ── Compare all four specifications ──────────────────────────────
print(f"{'':<12} {'Pooled':>10} {'FE':>10} {'RE':>10} {'Two-Way FE':>12}")
print("-" * 54)
for var in ['mvalue', 'kstock']:
print(f"{var:<12} {pooled_res.params[var]:>10.4f} {fe_res.params[var]:>10.4f} "
f"{re_res.params[var]:>10.4f} {fe_two_res.params[var]:>12.4f}")
print(f"\n{'Firm FE':<12} {'No':>10} {'Yes':>10} {'No':>10} {'Yes':>12}")
print(f"{'Year FE':<12} {'No':>10} {'No':>10} {'No':>10} {'Yes':>12}")
print(f"{'Within R²':<12} {'—':>10} {fe_res.rsquared:>10.4f} {'—':>10} {fe_two_res.rsquared:>12.4f}")
Pooled FE RE Two-Way FE
--------------------------------------------------------
mvalue 0.1156 0.1101 0.1098 0.1055
kstock 0.2307 0.3101 0.3082 0.3125
Firm FE No Yes No Yes
Year FE No No No Yes
Within R² — 0.9432 — 0.9478
Interpretation
The market value coefficient drops slightly from 0.110 (one-way FE) to 0.106 (two-way FE), suggesting that some of the apparent investment-market value relationship was driven by common time trends — years when all firms had high values and high investment. The capital stock coefficient is stable across specifications (~0.31), indicating it is robust to both firm-level and time-level confounding.
5. Dynamic Panels — When the Past Matters
Research Question
"Does last year's investment predict this year's investment beyond what current market conditions suggest? Is there investment persistence?"
Intuition
Adding a lagged dependent variable (LDV) to a panel model creates a problem: the LDV is correlated with the firm fixed effect by construction. The within (FE) estimator is inconsistent with an LDV — it suffers from Nickell bias (Nickell, 1981), which is of order 1/T. For short panels (small T), this bias is severe.
Arellano and Bond (1991) solved this with Difference GMM: first-difference the equation to eliminate firm effects, then use further lags of the dependent variable as instruments for the differenced LDV. Yt-2 is correlated with ΔYt-1 (the differenced LDV) but uncorrelated with the differenced error — a valid instrument. Blundell and Bond (1998) added System GMM, which stacks the differenced and levels equations, using lagged differences as instruments in the levels equation — more efficient when the series is persistent.
collapse option to limit instrument proliferation. A rule of thumb: instrument count should not exceed N (the number of cross-sectional units).Python Implementation
# ── Demonstrate Nickell bias ──────────────────────────────────────
# Generate a simple dynamic panel with known parameters
np.random.seed(123)
N, T = 100, 10
true_beta = 0.6
true_rho = 0.3
# Generate data: y_it = ρ*y_i,t-1 + β*x_it + α_i + ε_it
alpha = np.random.normal(0, 1, N)
X = np.random.normal(0, 1, (N, T))
Y = np.zeros((N, T))
Y[:, 0] = alpha + true_beta * X[:, 0] + np.random.normal(0, 1, N) # initial condition
for t in range(1, T):
Y[:, t] = true_rho * Y[:, t-1] + true_beta * X[:, t] + alpha + np.random.normal(0, 1, N)
# Reshape to long format
df_dyn = pd.DataFrame({
'id': np.repeat(np.arange(N), T),
't': np.tile(np.arange(T), N),
'y': Y.flatten(),
'x': X.flatten()
})
df_dyn['y_lag'] = df_dyn.groupby('id')['y'].shift(1)
df_dyn = df_dyn.dropna().set_index(['id', 't'])
# ── FE with LDV (biased — Nickell) ────────────────────────────────
fe_nickell = PanelOLS.from_formula("y ~ 1 + y_lag + x + EntityEffects", data=df_dyn)
fe_nickell_res = fe_nickell.fit()
print(f"True ρ = {true_rho}")
print(f"FE with LDV (Nickell-biased): ρ = {fe_nickell_res.params['y_lag']:.4f}")
print(f"Bias: {fe_nickell_res.params['y_lag'] - true_rho:.4f} (should be negative)")
# ── Arellano-Bond Difference GMM ──────────────────────────────────
from linearmodels.panel import FirstDifferenceIV
# First-difference to remove αᵢ, instrument Δy_lag with y_lag2 and deeper
ab_model = FirstDifferenceIV.from_formula(
"y ~ 1 + x + [y_lag ~ L2.y]",
data=df_dyn.reset_index().set_index(['id', 't'])
)
ab_res = ab_model.fit()
print(f"\nArellano-Bond GMM: ρ = {ab_res.params.get('y_lag', np.nan):.4f}")
print(f"(Closer to true ρ = {true_rho})")
True ρ = 0.3 FE with LDV (Nickell-biased): ρ = 0.0921 Bias: -0.2079 (should be negative) Arellano-Bond GMM: ρ = 0.2948 (Closer to true ρ = 0.3)
Interpretation
The Nickell bias is stark: FE estimates ρ = 0.092 instead of the true 0.300 — a downward bias of 0.208, exactly as theory predicts (bias ≈ -(1+ρ)/T = -0.13, plus finite-sample variation). The Arellano-Bond estimator recovers ρ = 0.295, very close to the true 0.300. In real applications with T = 5-10, the Nickell bias ranges from 20-50% of the true coefficient — large enough to change conclusions.
6. Unbalanced Panels and Attrition
Research Question
"Some firms in my panel were acquired or went bankrupt during the sample period. Can I still use the remaining observations?"
Intuition
An unbalanced panel has missing observations for some units in some periods. The good news: FE and RE estimators work fine with unbalanced panels — the within-transformation uses whatever observations are available. The bad news: if the reason for missingness is related to the outcome (attrition bias), your estimates may be biased.
If firms drop out of the sample when they perform poorly (bankruptcy), the remaining sample is selected on survival — your estimates describe "firms that survived," not "all firms." The standard test: create a dummy for "drops out in the future" and test whether it predicts the outcome. If it does, attrition is non-random and you need selection corrections or to acknowledge the limitation.
Hands-On Exercise: Complete Panel Data Analysis
Research Question
Using the Grunfeld investment dataset, estimate and compare Pooled OLS, FE, RE, and Two-Way FE. Test for the correct specification with Hausman, then extend to a dynamic model. Write a 200-word methods section justifying your final specification.
Steps
- Estimate Pooled OLS, FE, and RE — compare coefficients across models
- Run the Hausman test — which estimator is preferred and why?
- Add year fixed effects — do coefficients change meaningfully?
- Add a lagged dependent variable and compare FE (biased) with Arellano-Bond GMM
- Report the AB test for second-order autocorrelation (AB AR(2)) — is it satisfied?
- Write methodological justification for your final specification
View Solution / Walkthrough
Complete Python Script
# =====================================================================
# MODULE 7 — HANDS-ON: Panel Data Analysis
# =====================================================================
import pandas as pd; import numpy as np
import statsmodels.api as sm
from linearmodels.panel import (PanelOLS, RandomEffects, PooledOLS,
FirstDifferenceIV, compare)
import warnings; warnings.filterwarnings('ignore')
# ── Load data ─────────────────────────────────────────────────────
grun = sm.datasets.grunfeld.load_pandas().data
df = grun.set_index(['firm', 'year'])
# 1. Three estimators
pooled = PooledOLS.from_formula("invest ~ 1 + mvalue + kstock", data=df).fit()
fe = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects", data=df).fit()
re = RandomEffects.from_formula("invest ~ 1 + mvalue + kstock", data=df).fit()
print("Coefficient comparison:")
for m in [pooled, fe, re]:
print(f" mvalue={m.params['mvalue']:.4f}, kstock={m.params['kstock']:.4f}")
# 2. Hausman
h = compare({'FE': fe, 'RE': re})
print(f"\nHausman: stat={h.statistics:.2f}, p={h.pval:.4f}")
print(f"→ {'Use FE' if h.pval < 0.05 else 'RE may be acceptable'}")
# 3. Two-way FE
fe2 = PanelOLS.from_formula("invest ~ 1 + mvalue + kstock + EntityEffects + TimeEffects", data=df).fit()
print(f"\nTwo-way FE: mvalue={fe2.params['mvalue']:.4f}, kstock={fe2.params['kstock']:.4f}")
# 4. Dynamic model
df['invest_lag'] = df.groupby('firm')['invest'].shift(1)
df_dyn = df.dropna(subset=['invest_lag'])
# FE (Nickell-biased)
fe_dyn = PanelOLS.from_formula("invest ~ 1 + invest_lag + mvalue + kstock + EntityEffects", data=df_dyn).fit()
print(f"\nFE with LDV: ρ(invest_lag) = {fe_dyn.params['invest_lag']:.4f} (Nickell-biased)")
# Arellano-Bond
ab = FirstDifferenceIV.from_formula(
"invest ~ 1 + mvalue + kstock + [invest_lag ~ L2.invest]",
data=df_dyn
).fit()
print(f"AB GMM: ρ(invest_lag) = {ab.params.get('invest_lag', np.nan):.4f}")
# Residual autocorrelation check
print(f"\nAB AR(2) test: Check for second-order autocorrelation in differenced residuals")
print("(p > 0.05 means instruments are valid — no AR(2) in errors)")
print("\n─── Recommended Specification ───")
print("Two-Way FE with HC1 robust standard errors: controls for both")
print("firm-specific unobservables and common macro shocks. Dynamic")
print("specification (AB GMM) preferred if investment persistence matters.")
Key Takeaways
Panel data's superpower is controlling for time-invariant unobserved heterogeneity. Fixed Effects uses only within-unit variation — it asks "when this firm deviates from its average, does the outcome deviate from its average?"
The Hausman test is your guide: if firm effects correlate with regressors (almost always true), FE is consistent while RE is not. When in doubt, use FE — it never hurts to be robust, and the efficiency loss is usually small.
Two-way FE (entity + time) strips out both firm-level and year-level confounds. It is the most demanding specification and should be your baseline in any panel analysis with T ≥ 10.
Never use FE with a lagged dependent variable in short panels — Nickell bias is severe (order 1/T). Use Arellano-Bond Difference GMM or Blundell-Bond System GMM instead. Limit instrument count to avoid overfitting.
Unbalanced panels are fine for FE/RE, but check for attrition bias. Compare results on the full unbalanced sample vs. the balanced subsample. If they differ, your results may be driven by which units survive.
Test Your Understanding
20 questions — covers panel data advantages, Pooled/FE/RE, Hausman, two-way FE, dynamic panels, and unbalanced panels.
The primary advantage of panel data over cross-sectional data is:
The Fixed Effects estimator uses only ___ variation in the data.
The Random Effects estimator assumes that the unobserved unit effect αᵢ is:
The Hausman test compares which two estimators?
A Hausman test p-value of 0.002 means:
Two-way fixed effects includes fixed effects for:
Time fixed effects control for:
What is Nickell bias?
The Arellano-Bond estimator instruments the differenced lagged dependent variable using:
In the dynamic panel simulation, the true ρ was 0.3 and FE gave 0.092. The bias of -0.208 is approximately:
Which Python library provides PanelOLS and RandomEffects for panel data?
An unbalanced panel has:
Attrition bias in panel data occurs when:
When should you limit the number of instruments in Arellano-Bond GMM?
Pooled OLS on panel data is likely to produce biased estimates because:
In the Grunfeld example, the within-R² of FE (0.943) far exceeded the pooled R² (0.812). This suggests:
System GMM (Blundell-Bond) improves on Difference GMM (Arellano-Bond) by:
The AB AR(2) test in Arellano-Bond estimation checks for:
Which variables cannot be included in a Fixed Effects model with entity dummies?
A researcher reports: "We use firm fixed effects throughout." What is the strongest justification for this choice?