5 数据清洗与准备
同学们好。在经济学家和数据分析师的日常工作中,我们有相当一部分时间并非用于构建复杂的计量经济模型,而是投入到了更为基础的数据准备工作上:加载、清洗、转换和重排数据。这个过程,通常被称为数据整理(Data Wrangling or Munging),据报道常常会占据分析师高达80%的时间。经济数据,无论其来源是像美国经济分析局(BEA)这样的政府机构,世界银行这样的国际组织,还是专业的金融数据终端,它们很少以一种能够直接用于分析的完美形态出现。
许多研究人员会使用各种工具进行一些临时的、一次性的处理。幸运的是,pandas 库与 Python 强大而灵活的语言特性相结合,为我们提供了一个高层次、灵活且高效的工具集,能够将数据处理成我们期望的形式。pandas 的设计与实现,深受现实世界中金融与经济应用的实际需求影响。
本章将致力于帮助大家掌握这些至关重要的数据准备工具。我们将涵盖处理缺失数据、识别并移除重复值、使用函数和映射进行数据转换,以及其他关键的数据整理任务。牢固掌握这些技术不仅仅是为了方便,更是产出可靠、有意义分析的先决条件。
5.1 处理缺失数据
在经济数据集中,缺失数据是一个普遍存在的问题。无论是某个国家未报告特定年份的GDP,某位调查对象拒绝透露其收入,还是传感器未能记录到某个价格,缺失值都是我们工作中必须面对的现实。pandas 的一个核心设计目标,就是让处理缺失数据尽可能地无痛。例如,默认情况下,pandas 对象上的所有描述性统计(如求均值、方差等)都会自动排除缺失数据。
在 pandas 中,对于数据类型为 float64 的数据,浮点数值 NaN(Not a Number)被用来表示缺失数据。我们称之为一个“哨兵值”(sentinel value):它的出现标志着一个缺失或空值。
NaN 的技术细节及其影响
在 pandas 和 NumPy 中,NaN (Not a Number) 是一个特殊的浮点数值,遵循 IEEE 754 浮点数标准。一个关键的特性是,NaN 不等于任何值,包括它自身 (np.nan == np.nan 的结果是 False)。
这导致了一个重要的设计决策:当一个整数或布尔类型的 Series 中引入一个缺失值时,为了使用 NaN 来表示这个缺失值,整个 Series 的数据类型 (dtype) 会被自动“向上转型”(upcast) 为 float64。这可能会在不经意间改变你的数据性质,例如,你可能不再能进行精确的整数运算。虽然 pandas 后续引入了可空的整数扩展类型 (e.g., Int64) 来解决这个问题,但理解这个默认行为至关重要,因为它在很多现有代码和库中仍然普遍存在。
我们通过代码来观察这一行为。
import pandas as pd
import numpy as np
float_data = pd.Series([1.2, -3.5, np.nan, 0])
float_dataisna 方法会返回一个布尔型的 Series,用于指明哪些值是空值。
float_data.isna()在 pandas 中,我们通常沿用 R 语言的习惯,将缺失数据称为 NA,意为 not available(不可用)。在统计应用中,NA 可能表示数据不存在,或者数据存在但未被观测到(例如,由于数据收集问题)。在清洗数据时,对缺失模式本身的分析对于识别数据收集中的问题或潜在的偏差至关重要。
Python 内置的 None 值在 pandas 对象中也被视作 NA。当与非数值数据混合时,它在 object 类型的 Series 中保持为 None。如果与数值数据混合,它将被转换为 np.nan。
string_data = pd.Series(['aardvark', np.nan, None, 'avocado'])
string_datastring_data.isna()表 tbl-na-methods 总结了 pandas 中处理缺失数据的主要函数。
| 方法 | 描述 |
|---|---|
dropna |
根据每个标签的值是否存在缺失数据来过滤轴标签,可以设置不同的阈值来容忍不同程度的缺失数据。 |
fillna |
使用某个值或插值方法(如 'ffill' 或 'bfill')来填充缺失数据。 |
isna |
返回一个布尔型的 Series,指明哪些值是缺失/NA。 |
notna |
isna 的布尔否定形式。 |
5.1.1 滤除缺失数据
有多种方式可以滤除缺失数据。虽然使用 notna() 进行手动的布尔索引总是一个选择,但 dropna 方法通常更为便捷。在一个 Series 上使用它,会返回一个新的 Series,其中仅包含非空数据及其对应的索引值。
data = pd.Series([1, np.nan, 3.5, np.nan, 7])
data.dropna()这等价于 data[data.notna()],如下所示:
data[data.notna()]对于 DataFrame 对象,你的选择会更复杂。你可能希望丢弃全部为 NA 的行或列,或者只丢弃含有任何 NA 的行或列。默认情况下,dropna 会丢弃任何包含至少一个缺失值的行。
让我们用一个几家公司假设的季度收益数据集来说明这一点。
data = pd.DataFrame([[1., 6.5, 3.], [1., np.nan, np.nan],
[np.nan, np.nan, np.nan], [np.nan, 6.5, 3.]])
data.columns = ['Q1', 'Q2', 'Q3']
data.index = ['AAPL', 'GOOG', 'MSFT', 'AMZN']
print('原始数据:')
print(data)
print('\n使用 data.dropna() 后的数据:')
print(data.dropna())传入 how='all' 将只丢弃那些所有值都为 NA 的行。这对于移除数据集中的占位行非常有用。
data.dropna(how='all')要以同样的方式丢弃列,可以传入 axis='columns' 或 axis=1。
data[4] = np.nan # 添加一个全部为 NA 的列
print('含有全 NA 列的数据:')
print(data)
print('\n丢弃全 NA 列:')
print(data.dropna(axis='columns', how='all'))在面板数据分析中,一个常见的场景是只保留那些具有一定数量数据点的观测(行)。thresh 参数允许你指定这个最小数量。例如,我们利用世界银行的数据,观察一些中东国家的人均GDP数据,其中部分年份的数据存在缺失。我们希望只保留至少有3个有效数据点的国家。
import wbdata
import datetime
# 定义国家和指标
countries = ['SA', 'IQ', 'IR', 'AE', 'KW'] # 沙特、伊拉克、伊朗、阿联酋、科威特
indicators = {'NY.GDP.PCAP.KD': 'GDP per capita (constant 2015 US$)'}
# 获取数据
df = wbdata.get_dataframe(indicators, country=countries, data_date=(datetime.datetime(2010, 1, 1), datetime.datetime(2018, 1, 1)))
# 重塑数据为国家为行,年份为列的面板格式
df_panel = df.unstack(level=0)
df_panel.columns = df_panel.columns.droplevel(0)
df_panel.index.name = 'Year'
print('原始面板数据 (部分国家年份数据缺失):')
print(df_panel)
print('\n只保留至少有3个非NA值的年份:')
print(df_panel.dropna(thresh=3))5.1.2 填充缺失数据
我们常常不希望直接滤除缺失数据(因为这可能导致同行其他列的有价值信息一同丢失),而是希望填补这些“漏洞”。这个过程被称为插补(Imputation)。对于大多数目的而言,fillna 方法是完成此项任务的主力函数。
让我们来看一个使用美国联邦储备经济数据(FRED)的真实世界经济学案例。我们将获取美国月度平民失业率(UNRATE)序列。
from fredapi import Fred
fred_key = 'f2a2c60b6dc82682031f4ce84bf6da18'
fred = Fred(api_key=fred_key)
# 获取 2018年1月 至 2022年12月 的数据
unrate = fred.get_series('UNRATE', start_date='2018-01-01', end_date='2022-12-31')
# 为了演示,人为地引入一些缺失值
unrate.iloc[[3, 4, 15, 30]] = np.nan
print('含有缺失值的失业率序列:')
print(unrate.head(10))现在,让我们探索填充这些缺失值的不同方法。
5.1.2.1 使用常数填充
最简单的方法是用一个常数(比如0)来填充。
# 为了演示,我们创建一个新的DataFrame
df = pd.DataFrame(np.random.standard_normal((6, 3)))
df.iloc[2:, 1] = np.nan
df.iloc[4:, 2] = np.nan
print('原始 DataFrame:')
print(df)
print('\n使用 df.fillna(0) 后的 DataFrame:')
print(df.fillna(0))如果你需要为每一列使用不同的填充值,可以向 fillna 传递一个字典。
df.fillna({1: 0.5, 2: 0})5.1.2.2 使用计算出的统计量(均值/中位数)填充
一种常见的插补策略是用列的均值或中位数来填充缺失值。这种方法保留了数据的中心趋势。
filled_mean = unrate.fillna(unrate.mean())
print('使用均值填充后:')
print(filled_mean.head(10))5.1.2.3 插值方法(前向与后向填充)
对于时间序列数据,使用 ffill(前向填充)或 bfill(后向填充)非常普遍。ffill 会用最后一个有效观测值向前传播填充,这对于许多变化不频繁的经济序列来说是一个合理的假设。
ffill 和 bfill 的经济学直觉
在处理经济和金融时间序列时,选择插值方法需要有理论依据。
ffill(前向填充): 这种方法假设,在一个新数据点可用之前,变量的值保持其最后一个已知值。这对于那些在交易或事件发生时才更新的变量是合理的,例如股票价格(在两次交易之间,价格被认为是恒定的)或政策利率(在两次利率决策之间,利率不变)。bfill(后向填充): 这种方法使用未来的值来填充当前的缺失。这在某些情况下可能不合逻辑(因为它使用了未来的信息),但在另一些情况下是合理的,比如当你知道数据是定期收集但延迟报告时。
选择哪种方法取决于你对数据生成过程的理解和假设。
filled_ffill = unrate.fillna(method='ffill')
print('前向填充后的序列:')
print(filled_ffill.head(10))你也可以使用 limit 参数来限制连续填充的期数。
df.fillna(method='ffill', limit=2)表 tbl-fillna-args 提供了 fillna 函数参数的参考。
fillna 函数的参数
| 参数 | 描述 |
|---|---|
value |
用于填充缺失值的标量值或类字典对象。 |
method |
插值方法:'bfill'(后向填充)或 'ffill'(前向填充)之一。 |
axis |
填充的轴('index' 或 'columns');默认为 'index'。 |
limit |
对于前向和后向填充,可以填充的最大连续期数。 |
5.2 数据转换
到目前为止,我们专注于处理缺失数据。过滤、清洗和其他转换是另一类至关重要的操作。
5.2.1 移除重复项
由于数据收集或合并过程中的错误等多种原因,DataFrame 中可能会出现重复行。让我们考虑一个假设的交易数据集。
data = pd.DataFrame({
'order_id': ['A001', 'A002', 'A003', 'A002', 'A004'],
'item': ['Milk', 'Bread', 'Butter', 'Bread', 'Eggs'],
'quantity': [2, 1, 1, 1, 12]
})
dataDataFrame 的 duplicated 方法会返回一个布尔 Series,指示每一行是否是前面某行的重复。
data.duplicated()drop_duplicates 方法返回一个移除了重复行的新 DataFrame。
data.drop_duplicates()默认情况下,这两种方法都考虑所有列。你可以指定一个列的子集来检测重复项。例如,如果我们只关心重复的 order_id:
data.drop_duplicates(subset=['order_id'])duplicated 和 drop_duplicates 默认保留第一次出现的组合。传入 keep='last' 将保留最后一次出现的组合。这在某些情况下很有用,例如,当最新的条目是最新更新的数据时。
data.drop_duplicates(['order_id'], keep='last')5.2.2 使用函数或映射转换数据
对于许多数据集,你可能希望根据某列中的值进行转换。考虑一个关于不同肉类消费的假设性调查数据。
data = pd.DataFrame({'food': ['bacon', 'pulled pork', 'bacon',
'pastrami', 'corned beef', 'bacon',
'pastrami', 'honey ham', 'nova lox'],
'ounces': [4, 3, 12, 6, 7.5, 8, 3, 5, 6]})
data假设我们想添加一列,指明每种食物的来源动物。我们可以定义一个映射关系:
meat_to_animal = {
'bacon': 'pig',
'pulled pork': 'pig',
'pastrami': 'cow',
'corned beef': 'cow',
'honey ham': 'pig',
'nova lox': 'salmon'
}Series 上的 map 方法接受一个函数或一个类字典对象来执行值的转换。
data['animal'] = data['food'].map(meat_to_animal)
data我们也可以向 map 传递一个函数。
def get_animal(x):
return meat_to_animal[x]
data['food'].map(get_animal)使用 map 是执行元素级转换和其他数据清洗操作的一种便捷方式。
5.2.3 替换值
使用 fillna 填充缺失数据是值替换的一个特例。replace 方法提供了一种更简单、更灵活的方式来完成此任务。考虑一个 Series,其中某些数值(例如 -999)被用作缺失数据的哨兵值。这在旧的统计软件或调查数据中很常见。
data = pd.Series([1., -999., 2., -999., -1000., 3.])
data我们可以使用 replace 将这些哨兵值替换为 np.nan。
data.replace(-999, np.nan)要一次性替换多个值,可以传递一个列表。
data.replace([-999, -1000], np.nan)要为每个值使用不同的替换,可以传递一个替换值列表或一个字典。
data.replace({-999: np.nan, -1000: 0})5.2.4 重命名轴索引
与 Series 中的值一样,轴标签也可以通过函数或映射进行转换,以产生新的、标签不同的对象。
data = pd.DataFrame(np.arange(12).reshape((3, 4)),
index=['Ohio', 'Colorado', 'New York'],
columns=['one', 'two', 'three', 'four'])
data和 Series 一样,轴的 Index 对象也有一个 map 方法。
transform = lambda x: x[:4].upper()
data.index.map(transform)你可以将转换后的索引赋回给 index 属性,从而原地修改 DataFrame。
pandas 中的许多方法,如 replace、fillna 和 rename,默认情况下会返回新对象,而不会修改原始数据。这通常是好的实践,因为它能防止意外的数据修改。你通常可以通过将结果重新赋给原对象来进行原地修改,例如 data.index = data.index.map(transform)。有些方法也提供了 inplace=True 参数,但 pandas 开发团队现在不鼓励使用它,而是推荐显式地重新赋值。
data.index = data.index.map(transform)
data如果你想创建数据集的一个转换后版本而不修改原始数据,rename 方法很有用。
data.rename(index=str.title, columns=str.upper)rename 也可以与类字典对象一起使用,为轴的子集提供新的标签。
data.rename(index={'OHIO': 'INDIANA'},
columns={'three': 'peekaboo'})5.3 离散化与分箱
在经济分析中,将连续数据离散化为“箱”(bins)通常很有用。例如,我们可能将个体按年龄段或收入五分位数进行分组。pandas 提供了 cut 和 qcut 函数来完成这个任务。
假设我们有一组研究对象的年龄数据。
ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]让我们将这些年龄分为几组:18-25岁,26-35岁,36-60岁,以及61岁及以上。我们可以使用 pd.cut。
bins = [18, 25, 35, 60, 100]
age_categories = pd.cut(ages, bins)
age_categories返回的对象是一个特殊的 Categorical 类型。输出描述了 pandas.cut 计算出的箱。括号 ( 表示该侧是开区间(不包含),而方括号 ] 表示该侧是闭区间(包含)。你可以通过传递 right=False 来改变哪一侧是闭区间。
我们可以使用 value_counts 来获取每个箱中的计数。
pd.value_counts(age_categories)你可以通过向 labels 选项传递一个列表或数组来覆盖默认的基于区间的标签。
group_names = ['Youth', 'YoungAdult', 'MiddleAged', 'Senior']
pd.cut(ages, bins, labels=group_names)另一个密切相关的函数 pandas.qcut,是根据样本分位数对数据进行分箱。这对于创建大小相等的组非常有用,例如五分位数(5组)或十分位数(10组),这是收入不平等研究中的常见做法。pd.cut 通常不会产生大小相等的箱。
让我们用世界银行的数据来说明。我们将获取各国2021年的人均GDP,并使用 pd.qcut 将它们分为五个收入组(五分位数),每个组包含大致相同数量的国家。
# 获取2021年所有人均GDP数据
gdp_indicator = {'NY.GDP.PCAP.KD': 'GDP per capita'}
gdp_data = wbdata.get_dataframe(gdp_indicator, data_date=(datetime.datetime(2021, 1, 1), datetime.datetime(2021, 1, 1)))
gdp_data = gdp_data.dropna().rename(columns={'GDP per capita': 'gdp_per_capita'})
# 使用qcut创建五分位数
quintiles = pd.qcut(gdp_data['gdp_per_capita'], 5, labels=False, retbins=True, precision=2)
print('每个五分位数分组中的国家数量:')
print(pd.value_counts(quintiles[0]))
print('\n分箱的边界值 (Bins):')
print(quintiles[1])你也可以传递自定义的分位数(0到1之间的数字)。
pd.qcut(gdp_data['gdp_per_capita'], [0, 0.1, 0.5, 0.9, 1.]).value_counts()5.4 检测和过滤异常值
检测和处理异常值是关键的一步,尤其是在金融数据中,极端事件可能会对模型估计产生不成比例的影响。异常值是与其他值相比距离异常的观测值。过滤或转换它们很大程度上是应用数组操作的问题。
让我们使用标准普尔500指数的日百分比变化作为我们的数据。众所周知,金融回报具有“肥尾”特性,这意味着极端事件的发生频率比正态分布所预测的要高。
import matplotlib.pyplot as plt
import seaborn as sns
plt.rcParams['font.sans-serif'] = ['SimHei'] # 用于显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号
sp500 = fred.get_series('SP500', start_date='2010-01-01', end_date='2024-01-01')
sp500_pct_change = sp500.pct_change().dropna() * 100 # 以百分比表示
data = pd.DataFrame(sp500_pct_change, columns=['SP500_Ret'])
# 创建图表以可视化收益率和潜在的异常值
plt.figure(figsize=(12, 6))
sns.lineplot(data=data, x=data.index, y='SP500_Ret')
plt.title('标准普尔500指数日收益率 (%)')
plt.ylabel('日百分比变化')
plt.xlabel('日期')
plt.grid(True)
plt.show()图 fig-outlier-data 中的图表清楚地显示了几个尖峰,这些代表了异常事件,例如2020年3月的新冠疫情崩盘。
假设我们想找出所有绝对收益率超过5%的日期。
col = data['SP500_Ret']
col[col.abs() > 5]要选择所有值超过5或-5的行,你可以在一个布尔 DataFrame 上使用 any 方法。(这对于多列 DataFrame 更为相关)。
处理异常值的一种常用技术是“封顶”(capping),也称为 Winsorizing,即将在某个范围之外的值限制在该范围的边界上。例如,我们可以将所有收益率限制在-5%到+5%之间。
Winsorizing 数据是减轻金融数据集中极端异常值影响的常用做法。例如,在构建股票收益的因子模型时,某只股票单日的巨大涨跌可能会严重扭曲回归系数,使模型对整体市场行为的代表性降低。通过对这些极端值进行封顶,我们可以构建一个对这类罕见事件不那么敏感的、更稳健的模型,同时仍然保留了极端事件发生过这一信息。
print('原始数据的摘要统计:')
print(data.describe())
# 对值进行封顶
data[data.abs() > 5] = np.sign(data) * 5
print('\n在 +/- 5% 处封顶后的摘要统计:')
print(data.describe())np.sign(data) 语句会根据 data 中的值是正还是负,产生1和-1,从而有效地在正确的边界上应用封顶。
5.5 排列与随机抽样
随机重排一个 Series 或 DataFrame 的行(排列)对于许多统计程序至关重要,例如自助法(bootstrapping)和为模型验证创建训练/测试集。numpy.random.permutation 函数是实现这一目标的直接方法。用你想要排列的轴的长度调用它,会产生一个整数数组,表示新的顺序。
df = pd.DataFrame(np.arange(5 * 4).reshape((5, 4)))
dfsampler = np.random.permutation(5)
sampler这个整数数组可以用于基于 iloc 的索引或 take 函数。
df.take(sampler)要选择一个不放回的随机子集(一行不能出现多次),你可以使用 sample 方法。
df.sample(n=3)要生成一个有放回的样本(允许重复选择),可以传递 replace=True。这是统计学中自助法(bootstrap)的基础。
choices = pd.Series([5, 7, -1, 6, 4])
choices.sample(n=10, replace=True)5.6 计算指标/虚拟变量
对于统计建模和机器学习而言,另一个关键的转换是将分类变量转换为“虚拟”(dummy)或“指标”(indicator)矩阵。这种技术,也称为独热编码(one-hot encoding),会为原始分类列中的每个不同值创建一个新列,用1和0表示该值的存在与否。
虚拟变量是应用计量经济学的基石。它们允许我们将定性数据纳入回归模型。例如,我们可以用一个虚拟变量来代表性别(如,1代表女性,0代表男性),或者用来控制固定效应,如面板数据集中的国家或年份效应。pandas.get_dummies 函数是我们在数据中创建这些变量的主要工具。
让我们看一个简单的例子。
df = pd.DataFrame({'key': ['b', 'b', 'a', 'c', 'a', 'b'],
'data1': range(6)})
pd.get_dummies(df['key'])在某些情况下,你可能希望在指标 DataFrame 的列名中添加一个前缀,以避免合并时出现列名冲突。prefix 参数可以做到这一点。
dummies = pd.get_dummies(df['key'], prefix='key')
df_with_dummy = df[['data1']].join(dummies)
df_with_dummy一个更复杂的场景是,当单个观测属于多个类别时,通常以分隔符连接的字符串形式编码。MovieLens 1M 数据集提供了一个很好的例子,其中每部电影可以有多种类型。
import requests
import zipfile
from io import BytesIO
# 下载并解压 MovieLens 1M 数据集
url = 'https://files.grouplens.org/datasets/movielens/ml-1m.zip'
response = requests.get(url)
with zipfile.ZipFile(BytesIO(response.content)) as z:
with z.open('ml-1m/movies.dat') as f:
mnames = ['movie_id', 'title', 'genres']
movies = pd.read_csv(f, sep='::', header=None, names=mnames, engine='python', encoding='ISO-8859-1')
print(movies.head())pandas 有一个特殊的 Series.str.get_dummies 方法来处理这种情况。
dummies = movies['genres'].str.get_dummies('|')
dummies.head()然后我们可以将这个 dummies DataFrame 连接回原始的 movies DataFrame。
movies_windic = movies.join(dummies.add_prefix('Genre_'))
movies_windic.iloc[0]一个在统计应用中很有用的技巧是,将 get_dummies 与像 cut 这样的离散化函数结合起来使用。
np.random.seed(12345) # 为了可复现性
values = np.random.uniform(size=10)
bins = [0, 0.2, 0.4, 0.6, 0.8, 1]
pd.get_dummies(pd.cut(values, bins))