Skip to content

8.2 面板数据基础

理解面板数据的结构、变异分解和混合 OLS 的陷阱

难度实战性


本节目标

  • 掌握面板数据的长格式和宽格式
  • 理解组内变异(Within)vs 组间变异(Between)
  • 识别混合 OLS 的问题(遗漏变量偏差)
  • 使用 pandas MultiIndex 处理面板数据
  • 可视化面板数据的时间趋势

面板数据的结构

什么是面板数据?

定义:面板数据(Panel Data)= 多个个体(Cross-Section)× 多个时点(Time Series)

符号表示

三个维度

  1. :个体数量(entities, units, cross-sectional dimension)

    • 例如:公司、个人、国家、城市
  2. :时间期数(time periods, temporal dimension)

    • 例如:年份、季度、月份
  3. 总观测值(理想情况下,平衡面板)


面板数据的类型

1. 平衡面板(Balanced Panel)

定义:每个个体在所有时点都有观测值

示例

ID   Year   Wage   Education
1    2015   5000      12
1    2016   5200      12
1    2017   5500      13
2    2015   6000      16
2    2016   6300      16
2    2017   6600      16

特征

  • 每个 ID 都有 个观测值
  • 总观测值 =
  • 分析简单,无需处理缺失值

2. 不平衡面板(Unbalanced Panel)

定义:某些个体在某些时点缺失观测值

示例

ID   Year   Wage   Education
1    2015   5000      12
1    2016   5200      12
1    2017   5500      13
2    2015   6000      16
2    2017   6600      16    # 2016 年缺失
3    2016   4800      10
3    2017   5000      10    # 2015 年缺失

原因

  • 样本流失(attrition):个体退出调查
  • 公司上市/退市、破产
  • 数据收集问题

处理:现代软件(如 linearmodels)可自动处理不平衡面板


数据格式:长格式 vs 宽格式

长格式(Long Format)⭐ 推荐

结构:每一行是一个 观测值(individual-time pair)

python
   id  year   wage  education  experience
0   1  2015   5000         12           3
1   1  2016   5200         12           4
2   1  2017   5500         13           5
3   2  2015   6000         16           5
4   2  2016   6300         16           6
5   2  2017   6600         16           7

优点

  • 与回归模型直接对应
  • 处理不平衡面板方便
  • linearmodels、statsmodels 的标准格式
  • 易于添加新变量

Python 实现

python
import pandas as pd

# 长格式数据
long_data = pd.DataFrame({
    'id': [1, 1, 1, 2, 2, 2],
    'year': [2015, 2016, 2017, 2015, 2016, 2017],
    'wage': [5000, 5200, 5500, 6000, 6300, 6600],
    'education': [12, 12, 13, 16, 16, 16]
})

宽格式(Wide Format)

结构:每一行是一个 个体,不同时点的数据在不同列

python
   id  wage_2015  wage_2016  wage_2017  education_2015  education_2016  education_2017
0   1       5000       5200       5500              12              12              13
1   2       6000       6300       6600              16              16              16

优点

  • 直观,易于查看单个个体的时间序列
  • 某些可视化更方便

缺点

  • 无法直接用于回归
  • 处理不平衡面板困难
  • 添加新变量需要多列

长宽格式转换

宽格式 → 长格式(melt

python
import pandas as pd

# 宽格式数据
wide_data = pd.DataFrame({
    'id': [1, 2],
    'wage_2015': [5000, 6000],
    'wage_2016': [5200, 6300],
    'wage_2017': [5500, 6600],
    'education': [12, 16]  # 不随时间变化
})

# 转换为长格式
long_data = wide_data.melt(
    id_vars=['id', 'education'],           # 不变的列
    value_vars=['wage_2015', 'wage_2016', 'wage_2017'],
    var_name='year_var',
    value_name='wage'
)

# 清理年份列
long_data['year'] = long_data['year_var'].str.extract('(\d+)').astype(int)
long_data = long_data.drop('year_var', axis=1).sort_values(['id', 'year'])

print(long_data)
#    id  education   wage  year
# 0   1         12   5000  2015
# 1   1         12   5200  2016
# 2   1         12   5500  2017
# 3   2         16   6000  2015
# 4   2         16   6300  2016
# 5   2         16   6600  2017

长格式 → 宽格式(pivot

python
# 从长格式转回宽格式
wide_data_back = long_data.pivot(
    index='id',
    columns='year',
    values='wage'
).reset_index()

# 重命名列
wide_data_back.columns = ['id'] + [f'wage_{c}' for c in wide_data_back.columns[1:]]

print(wide_data_back)
#    id  wage_2015  wage_2016  wage_2017
# 0   1       5000       5200       5500
# 1   2       6000       6300       6600

变异的分解:Within vs Between

面板数据的核心优势是包含两种变异

1. 总变异(Total Variation)

定义:所有观测值围绕总均值的变异

其中 总均值


2. 组间变异(Between Variation)

定义:不同个体的均值之间的差异

其中 个体 的时间均值

直觉

  • 比较不同个体的"平均水平"
  • 例如:ID=1 的平均工资 vs ID=2 的平均工资
  • 这是横截面回归利用的变异

3. 组内变异(Within Variation)⭐

定义:同一个体不同时间的变异

直觉

  • 个体围绕自己的平均水平的波动
  • 例如:ID=1 在 2016 年的工资 vs ID=1 的平均工资
  • 这是固定效应回归利用的变异

关键性质


Python 实现:变异分解

python
import numpy as np
import pandas as pd

# 模拟数据
np.random.seed(42)
data = []
for i in range(1, 6):  # 5 个个体
    for t in range(2015, 2020):  # 5 年
        # 个体效应 + 时间趋势 + 噪音
        wage = 5000 + i * 500 + (t - 2015) * 100 + np.random.normal(0, 200)
        data.append({'id': i, 'year': t, 'wage': wage})

df = pd.DataFrame(data)

print("=" * 70)
print("数据预览")
print("=" * 70)
print(df.head(10))

# 计算均值
overall_mean = df['wage'].mean()  # 总均值
group_means = df.groupby('id')['wage'].transform('mean')  # 个体均值

# 1. 总变异
total_var = np.var(df['wage'], ddof=1)

# 2. 组间变异
between_var = np.var(df.groupby('id')['wage'].mean(), ddof=1)

# 3. 组内变异(残差方法)
df['wage_demeaned'] = df['wage'] - group_means
within_var = np.var(df['wage_demeaned'], ddof=1)

print("\n" + "=" * 70)
print("变异分解")
print("=" * 70)
print(f"总变异(Total):     {total_var:,.2f}")
print(f"组间变异(Between): {between_var:,.2f}  ({between_var/total_var*100:.1f}%)")
print(f"组内变异(Within):  {within_var:,.2f}  ({within_var/total_var*100:.1f}%)")
print(f"Between + Within:   {between_var + within_var:,.2f}")

# 可视化
import matplotlib.pyplot as plt
import seaborn as sns

plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左图:组间变异(个体均值的差异)
group_means_unique = df.groupby('id')['wage'].mean()
axes[0].bar(group_means_unique.index, group_means_unique.values, color='steelblue', alpha=0.7)
axes[0].axhline(overall_mean, color='red', linestyle='--', linewidth=2, label='总均值')
axes[0].set_xlabel('个体 ID', fontweight='bold')
axes[0].set_ylabel('平均工资', fontweight='bold')
axes[0].set_title('组间变异(Between Variation)\n不同个体的平均工资', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 右图:组内变异(个体内的时间变化)
for i in [1, 2, 3]:
    subset = df[df['id'] == i]
    axes[1].plot(subset['year'], subset['wage'], 'o-', label=f'ID {i}', linewidth=2)
    # 画出个体均值线
    axes[1].axhline(subset['wage'].mean(), color='gray', linestyle=':', alpha=0.5)

axes[1].set_xlabel('年份', fontweight='bold')
axes[1].set_ylabel('工资', fontweight='bold')
axes[1].set_title('组内变异(Within Variation)\n个体内的时间变化', fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

输出解读

  • 组间变异大:个体之间差异大(如不同行业、不同教育水平)
  • 组内变异大:个体随时间变化大(如工资增长、经验积累)
  • 固定效应只利用组内变异,消除组间差异

️ 混合 OLS 的问题

混合 OLS(Pooled OLS)

模型

假设:所有个体和时间都相同, 是 i.i.d.

Python 实现

python
import statsmodels.api as sm

# 忽略面板结构,直接 OLS
X = sm.add_constant(df[['education']])
model_pooled = sm.OLS(df['wage'], X).fit()
print(model_pooled.summary())

优点

  • 简单,易于实现
  • 效率高(如果模型正确)

致命缺陷


问题 1:遗漏变量偏差(Omitted Variable Bias)⭐⭐⭐

场景:研究教育对工资的影响

真实模型

其中 不可观测的个体特征(能力、家庭背景、性格等)

混合 OLS 估计的模型

其中

问题:如果 ,则 有偏!

直觉

  • 高能力的人既受更多教育,也赚更多钱
  • 混合 OLS 把能力的效应误归因于教育的效应
  • 结果:高估教育回报率

遗漏变量偏差的方向

OVB 公式(省略第三个变量 时):

其中:

  • 的真实效应
  • 的协方差

应用到我们的例子

  • :教育(education)
  • :能力(ability,不可观测)
  • :工资(wage)

分析

  1. :能力提高工资
  2. :聪明人受更多教育
  3. 因此:正向偏差(高估)

Python 演示:OVB 的产生

python
import numpy as np
import pandas as pd
import statsmodels.api as sm
from linearmodels.panel import PanelOLS

np.random.seed(123)

# 参数设置
N = 500
T = 5
true_beta_education = 0.08  # 教育的真实效应

# 模拟数据
data = []
for i in range(N):
    # 个体固定效应(能力、家庭背景等)
    ability = np.random.normal(0, 0.5)

    for t in range(T):
        # 教育水平(与能力正相关!)
        education = 12 + 0.8 * ability + t * 0.2 + np.random.normal(0, 0.3)

        # 工资(对数)
        log_wage = 1.5 + true_beta_education * education + 0.5 * ability + np.random.normal(0, 0.1)

        data.append({
            'id': i,
            'year': 2015 + t,
            'log_wage': log_wage,
            'education': education,
            'ability': ability  # 在实际研究中不可观测!
        })

df = pd.DataFrame(data)

print("=" * 70)
print("数据生成过程(DGP)")
print("=" * 70)
print("真实模型:log(wage) = 1.5 + 0.08*education + 0.5*ability + noise")
print(f"教育的真实效应:{true_beta_education}")
print(f"能力与教育的相关系数:{df[['education', 'ability']].corr().iloc[0, 1]:.3f}")

# 方法 1:混合 OLS(遗漏能力)
X_pooled = sm.add_constant(df[['education']])
model_pooled = sm.OLS(df['log_wage'], X_pooled).fit()

print("\n" + "=" * 70)
print("方法 1:混合 OLS(遗漏能力 → 有偏估计)")
print("=" * 70)
print(f"教育系数:{model_pooled.params['education']:.4f}")
print(f"标准误:  {model_pooled.bse['education']:.4f}")
print(f"偏差:    {model_pooled.params['education'] - true_beta_education:.4f} (高估!)")

# 方法 2:混合 OLS(包含能力,仅为对比)
X_with_ability = sm.add_constant(df[['education', 'ability']])
model_with_ability = sm.OLS(df['log_wage'], X_with_ability).fit()

print("\n" + "=" * 70)
print("方法 2:混合 OLS(包含能力 → 无偏估计,但实际中做不到)")
print("=" * 70)
print(f"教育系数:{model_with_ability.params['education']:.4f}")
print(f"能力系数:{model_with_ability.params['ability']:.4f}")

# 方法 3:固定效应(消除能力)
df_panel = df.set_index(['id', 'year'])
model_fe = PanelOLS(df_panel['log_wage'],
                    df_panel[['education']],
                    entity_effects=True).fit(cov_type='clustered',
                                             cluster_entity=True)

print("\n" + "=" * 70)
print("方法 3:固定效应(差分消除能力 → 无偏估计)")
print("=" * 70)
print(f"教育系数:{model_fe.params['education']:.4f}")
print(f"标准误:  {model_fe.std_errors['education']:.4f}")

# 对比总结
print("\n" + "=" * 70)
print("估计对比")
print("=" * 70)
print(f"真实参数:          {true_beta_education:.4f}")
print(f"混合 OLS(遗漏):  {model_pooled.params['education']:.4f}  (偏差: {model_pooled.params['education'] - true_beta_education:+.4f})")
print(f"混合 OLS(包含):  {model_with_ability.params['education']:.4f}  (偏差: {model_with_ability.params['education'] - true_beta_education:+.4f})")
print(f"固定效应:          {model_fe.params['education']:.4f}  (偏差: {model_fe.params['education'] - true_beta_education:+.4f})")

核心结论

  • 混合 OLS 高估了教育效应(因为遗漏了能力)
  • 固定效应通过差分消除了能力,得到无偏估计
  • 这就是面板数据的威力!

问题 2:序列相关(Serial Correlation)

定义:同一个体不同时间的误差项相关

后果

  • 系数估计仍然无偏
  • 标准误有偏(通常被低估)
  • 导致 统计量虚高,假阳性增加

解决方案

  • 使用聚类标准误(Clustered Standard Errors)
  • 在个体层面聚类:允许同一个体内的误差相关
python
# 固定效应 + 聚类标准误
model_fe = PanelOLS(y, X, entity_effects=True).fit(
    cov_type='clustered',
    cluster_entity=True  # 在个体层面聚类
)

问题 3:异方差(Heteroskedasticity)

定义:误差项的方差在不同个体或时间不同

来源

  • 不同个体的规模差异(大公司 vs 小公司)
  • 不同时间的波动性差异(经济危机时期)

解决方案

  • 使用稳健标准误(Robust Standard Errors)
  • 或同时使用稳健 + 聚类标准误
python
# 固定效应 + 稳健聚类标准误
model_fe = PanelOLS(y, X, entity_effects=True).fit(
    cov_type='clustered',
    cluster_entity=True
)

️ pandas MultiIndex:面板数据的基石

什么是 MultiIndex?

MultiIndex:pandas 的多层索引,非常适合面板数据

结构 作为复合索引

python
MultiIndex([(1, 2015),
            (1, 2016),
            (1, 2017),
            (2, 2015),
            (2, 2016),
            (2, 2017)])

创建 MultiIndex

方法 1:set_index

python
import pandas as pd

# 原始数据(长格式)
df = pd.DataFrame({
    'id': [1, 1, 2, 2],
    'year': [2015, 2016, 2015, 2016],
    'wage': [5000, 5200, 6000, 6300]
})

# 设置双层索引
df_panel = df.set_index(['id', 'year'])

print(df_panel)
#            wage
# id year
# 1  2015   5000
#    2016   5200
# 2  2015   6000
#    2016   6300

方法 2:from_arrays

python
index = pd.MultiIndex.from_arrays(
    [df['id'], df['year']],
    names=['id', 'year']
)
df_panel = df.set_index(index)

方法 3:from_product(平衡面板)

python
# 创建所有 (id, year) 组合
index = pd.MultiIndex.from_product(
    [[1, 2, 3], [2015, 2016, 2017]],
    names=['id', 'year']
)
print(index)
# MultiIndex([(1, 2015), (1, 2016), (1, 2017),
#             (2, 2015), (2, 2016), (2, 2017),
#             (3, 2015), (3, 2016), (3, 2017)])

MultiIndex 的操作

1. 选择数据

python
# 选择 id=1 的所有时间
df_panel.loc[1]

# 选择 id=1, year=2016 的观测
df_panel.loc[(1, 2016)]

# 选择多个 id
df_panel.loc[[1, 2]]

# 使用 xs(cross-section)
df_panel.xs(1, level='id')        # id=1 的所有时间
df_panel.xs(2015, level='year')   # 所有个体在 2015 年

2. 按层级分组

python
# 按 id 分组(计算个体均值)
df_panel.groupby(level='id').mean()

# 按 year 分组(计算时间均值)
df_panel.groupby(level='year').mean()

3. 排序

python
# 按索引排序
df_panel.sort_index()

# 按第一层索引排序
df_panel.sort_index(level=0)

实战:计算组内/组间变异

python
import pandas as pd
import numpy as np

# 模拟数据
np.random.seed(42)
data = []
for i in range(1, 4):
    for t in range(2015, 2020):
        wage = 5000 + i * 1000 + (t - 2015) * 200 + np.random.normal(0, 100)
        data.append({'id': i, 'year': t, 'wage': wage})

df = pd.DataFrame(data)
df_panel = df.set_index(['id', 'year'])

print("=" * 70)
print("数据预览")
print("=" * 70)
print(df_panel.head(10))

# 计算个体均值(组间)
group_means = df_panel.groupby(level='id')['wage'].mean()
print("\n" + "=" * 70)
print("个体均值(组间变异)")
print("=" * 70)
print(group_means)

# 计算去均值后的数据(组内)
df_panel['wage_within'] = df_panel.groupby(level='id')['wage'].transform(
    lambda x: x - x.mean()
)

print("\n" + "=" * 70)
print("去均值后的工资(组内变异)")
print("=" * 70)
print(df_panel[['wage', 'wage_within']].head(10))

# 可视化
import matplotlib.pyplot as plt

plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左图:原始数据
for i in [1, 2, 3]:
    subset = df[df['id'] == i]
    axes[0].plot(subset['year'], subset['wage'], 'o-', label=f'ID {i}', linewidth=2)

axes[0].set_xlabel('年份', fontweight='bold')
axes[0].set_ylabel('工资', fontweight='bold')
axes[0].set_title('原始数据(总变异 = 组间 + 组内)', fontweight='bold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# 右图:去均值后的数据(组内变异)
for i in [1, 2, 3]:
    subset = df_panel.loc[i]
    axes[1].plot(subset.index, subset['wage_within'], 'o-', label=f'ID {i}', linewidth=2)

axes[1].axhline(0, color='black', linestyle='--', linewidth=1)
axes[1].set_xlabel('年份', fontweight='bold')
axes[1].set_ylabel('去均值工资', fontweight='bold')
axes[1].set_title('组内变异(FE 利用的变异)', fontweight='bold')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

核心洞察

  • 左图:不同个体的水平差异明显(组间变异)
  • 右图:去均值后,只剩下个体内的时间变化(组内变异)
  • 固定效应只使用右图的变异

可视化面板数据

1. 意大利面条图(Spaghetti Plot)

用途:展示所有个体的时间趋势

python
import matplotlib.pyplot as plt
import seaborn as sns

plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']
sns.set_style("whitegrid")

# 模拟数据
np.random.seed(42)
data = []
for i in range(1, 21):  # 20 个个体
    for t in range(2010, 2020):
        y = 100 + i * 5 + (t - 2010) * 3 + np.random.normal(0, 10)
        data.append({'id': i, 'year': t, 'y': y})

df = pd.DataFrame(data)

# 绘制
plt.figure(figsize=(12, 6))
for i in df['id'].unique():
    subset = df[df['id'] == i]
    plt.plot(subset['year'], subset['y'], alpha=0.4, linewidth=1)

plt.xlabel('年份', fontweight='bold', fontsize=12)
plt.ylabel('结果变量', fontweight='bold', fontsize=12)
plt.title('意大利面条图:所有个体的时间趋势', fontweight='bold', fontsize=14)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

观察

  • 个体之间的水平差异(组间)
  • 共同的时间趋势(如果存在)
  • 异质性(不同个体的斜率)

2. 均值趋势图

用途:展示平均时间趋势

python
# 计算每年的均值和标准误
yearly_stats = df.groupby('year')['y'].agg(['mean', 'std', 'count'])
yearly_stats['se'] = yearly_stats['std'] / np.sqrt(yearly_stats['count'])

# 绘制
plt.figure(figsize=(12, 6))
plt.plot(yearly_stats.index, yearly_stats['mean'], 'o-',
         color='darkblue', linewidth=2, markersize=8, label='均值')
plt.fill_between(yearly_stats.index,
                 yearly_stats['mean'] - 1.96 * yearly_stats['se'],
                 yearly_stats['mean'] + 1.96 * yearly_stats['se'],
                 alpha=0.3, label='95% 置信区间')

plt.xlabel('年份', fontweight='bold', fontsize=12)
plt.ylabel('结果变量', fontweight='bold', fontsize=12)
plt.title('平均时间趋势(± 95% CI)', fontweight='bold', fontsize=14)
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

本节小结

核心要点

  1. 面板数据结构

    • 长格式(推荐)vs 宽格式
    • 平衡面板 vs 不平衡面板
    • pandas MultiIndex 是处理面板数据的利器
  2. 变异分解

    • 总变异 = 组间变异 + 组内变异
    • 横截面 OLS 利用总变异(包含组间)
    • 固定效应只利用组内变异
  3. 混合 OLS 的问题

    • 遗漏变量偏差(OVB):高估或低估
    • 序列相关:标准误有偏
    • 需要固定效应 + 聚类标准误
  4. 实战技能

    • 长宽格式转换(melt / pivot
    • MultiIndex 操作(set_index, loc, xs
    • 计算组内/组间变异
    • 可视化面板数据

下一步

第3节:固定效应模型 中,我们将深入学习:

  • 固定效应的估计方法(组内变换、LSDV)
  • 单向 FE vs 双向 FE
  • linearmodels.PanelOLS 完整实现
  • 真实案例:工资决定因素

掌握面板数据的结构,是迈向固定效应的第一步!

基于 MIT 许可证发布。内容版权归作者所有。