8.2 面板数据基础
理解面板数据的结构、变异分解和混合 OLS 的陷阱
本节目标
- 掌握面板数据的长格式和宽格式
- 理解组内变异(Within)vs 组间变异(Between)
- 识别混合 OLS 的问题(遗漏变量偏差)
- 使用 pandas MultiIndex 处理面板数据
- 可视化面板数据的时间趋势
面板数据的结构
什么是面板数据?
定义:面板数据(Panel Data)= 多个个体(Cross-Section)× 多个时点(Time Series)
符号表示:
三个维度:
:个体数量(entities, units, cross-sectional dimension)
- 例如:公司、个人、国家、城市
:时间期数(time periods, temporal dimension)
- 例如:年份、季度、月份
总观测值:(理想情况下,平衡面板)
面板数据的类型
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)
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 实现:
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)
结构:每一行是一个 个体,不同时点的数据在不同列
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)
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)
# 从长格式转回宽格式
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 实现:变异分解
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 实现:
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)
分析:
- :能力提高工资
- :聪明人受更多教育
- 因此: → 正向偏差(高估)
Python 演示:OVB 的产生
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)
- 在个体层面聚类:允许同一个体内的误差相关
# 固定效应 + 聚类标准误
model_fe = PanelOLS(y, X, entity_effects=True).fit(
cov_type='clustered',
cluster_entity=True # 在个体层面聚类
)问题 3:异方差(Heteroskedasticity)
定义:误差项的方差在不同个体或时间不同
来源:
- 不同个体的规模差异(大公司 vs 小公司)
- 不同时间的波动性差异(经济危机时期)
解决方案:
- 使用稳健标准误(Robust Standard Errors)
- 或同时使用稳健 + 聚类标准误
# 固定效应 + 稳健聚类标准误
model_fe = PanelOLS(y, X, entity_effects=True).fit(
cov_type='clustered',
cluster_entity=True
)️ pandas MultiIndex:面板数据的基石
什么是 MultiIndex?
MultiIndex:pandas 的多层索引,非常适合面板数据
结构: 作为复合索引
MultiIndex([(1, 2015),
(1, 2016),
(1, 2017),
(2, 2015),
(2, 2016),
(2, 2017)])创建 MultiIndex
方法 1:set_index
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
index = pd.MultiIndex.from_arrays(
[df['id'], df['year']],
names=['id', 'year']
)
df_panel = df.set_index(index)方法 3:from_product(平衡面板)
# 创建所有 (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. 选择数据
# 选择 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. 按层级分组
# 按 id 分组(计算个体均值)
df_panel.groupby(level='id').mean()
# 按 year 分组(计算时间均值)
df_panel.groupby(level='year').mean()3. 排序
# 按索引排序
df_panel.sort_index()
# 按第一层索引排序
df_panel.sort_index(level=0)实战:计算组内/组间变异
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)
用途:展示所有个体的时间趋势
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. 均值趋势图
用途:展示平均时间趋势
# 计算每年的均值和标准误
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()本节小结
核心要点
面板数据结构
- 长格式(推荐)vs 宽格式
- 平衡面板 vs 不平衡面板
- pandas MultiIndex 是处理面板数据的利器
变异分解
- 总变异 = 组间变异 + 组内变异
- 横截面 OLS 利用总变异(包含组间)
- 固定效应只利用组内变异
混合 OLS 的问题
- 遗漏变量偏差(OVB):高估或低估
- 序列相关:标准误有偏
- 需要固定效应 + 聚类标准误
实战技能
- 长宽格式转换(
melt/pivot) - MultiIndex 操作(
set_index,loc,xs) - 计算组内/组间变异
- 可视化面板数据
- 长宽格式转换(
下一步
在 第3节:固定效应模型 中,我们将深入学习:
- 固定效应的估计方法(组内变换、LSDV)
- 单向 FE vs 双向 FE
- linearmodels.PanelOLS 完整实现
- 真实案例:工资决定因素
掌握面板数据的结构,是迈向固定效应的第一步!