Part IV: Panel Data and Advanced Estimation·Module 7 of 10

Panel Data Regression Models

75% Core·20% Stable·5% Volatile

Learning Objectives

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?

📚
In Published Research: Panel data methods are the default in empirical corporate finance (Compustat firms over time), development economics (countries over decades), labour economics (individuals over survey waves), and educational research (students over grades). The fixed effects estimator is arguably the most used identification strategy after OLS and IV.

Three Advantages of Panel Data

  1. More variation: N × T observations instead of just N or just T — more precision
  2. Unobserved heterogeneity: Control for time-invariant omitted variables without measuring them
  3. 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:

Panel Data Estimator Decision Tree

Do you have panel data?
Run Hausman Test: Are αᵢ and X correlated?
p < 0.05 → Correlated
Fixed Effects (FE)
Consistent. Uses within variation.
p ≥ 0.05 → Uncorrelated
Random Effects (RE)
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()}")
            
Python Output
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}")
            
Python Output
                 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.")
            
Python Output
                    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}")
            
Python Output
                 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.

Common Pitfall: Difference and System GMM can generate dozens of instruments (all available lags). Too many instruments overfit the endogenous variable and weaken the Hansen J-test. Always report the instrument count and use the 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})")
            
Python Output
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.

💡
Pro Tip: With unbalanced panels, report both: (1) results on the full unbalanced sample, and (2) results on the balanced subsample (only units observed in all periods). If they differ, attrition is distorting your results — discuss why and which sample is more appropriate for your research question.

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

  1. Estimate Pooled OLS, FE, and RE — compare coefficients across models
  2. Run the Hausman test — which estimator is preferred and why?
  3. Add year fixed effects — do coefficients change meaningfully?
  4. Add a lagged dependent variable and compare FE (biased) with Arellano-Bond GMM
  5. Report the AB test for second-order autocorrelation (AB AR(2)) — is it satisfied?
  6. 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

1

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?"

2

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.

3

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.

4

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.

5

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.

📋 Stable content — Reviewed: June 2026

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?