51  数据框缺失值处理

51.1 引言缺失值的普遍性与挑战

缺失值(Missing Values)是现实世界数据分析中无法回避的普遍问题。无论是社会科学的问卷调查、金融市场的交易数据,还是自然科学实验观测,数据缺失几乎无处不在。

51.1.1 金融数据缺失的典型场景

在金融市场中,数据缺失尤为常见:

  • 停牌事件:股票因重大事项停牌,期间没有交易数据。例如,中国平安(601318.SH)在2024年某次并购期间停牌10个交易日
  • 新股上市:新上市公司上市前没有历史数据,构建长期面板数据时会产生”左截断”问题
  • 节假日效应:A股市场在春节、国庆期间休市,而海外市场继续交易
  • 数据源故障:数据供应商的技术问题可能导致部分数据暂时不可用
  • 公司行为:退市公司被从指数成分股中剔除,历史数据不再维护

51.1.2 缺失值处理的重要性

为什么不能简单忽略缺失值?

  1. 统计偏倚:如果缺失不是随机的,删除缺失数据会导致样本有偏,从而得到错误的结论。例如,研究股票收益时如果删除了所有退市公司的数据,会高估股票的真实收益(因为退市公司通常是表现差的)。

  2. 信息损失:简单删除会丢弃有效数据。在样本量有限的情况下(如中国A股只有约5000只股票),每一笔数据都珍贵。

  3. 算法要求:大多数统计模型和机器学习算法(如线性回归、神经网络)无法直接处理缺失值。

本章学习目标: - 理解缺失值的不同类型(MCAR、MAR、MNAR)及其影响 - 掌握缺失值的识别、度量和可视化方法 - 学习删除策略、插补方法的选择与应用 - 了解金融时间序列数据缺失的特殊处理 - 掌握评估插补质量的方法

51.2 缺失值的数学基础

51.2.1 缺失值的定义

设数据集 \(D = \{x_1, x_2, \ldots, x_n\}\),我们实际观测到的是: \[ D_{\text{observed}} = \{x_i | i \in I_{\text{observed}}\} \] 其中 \(I_{\text{observed}} \subset \{1, 2, \ldots, n\}\) 是观测索引集合。

缺失值集合为: \[ D_{\text{missing}} = \{x_i | i \in I_{\text{missing}}\} \] 其中 \(I_{\text{missing}} = \{1, 2, \ldots, n\} \setminus I_{\text{observed}}\)

51.2.2 Pandas中的缺失值表示

Pandas使用NaN(Not a Number)表示数值型缺失值,这是IEEE 754浮点数标准的一部分。

NaN的特殊性质: - NaN != NaN (自反性不成立) - NaN + x = NaN, ∀x (吸收律) - NaN * x = NaN, ∀x

平台任务2解答代码

以下代码与教学平台任务要求完全一致:

列表 51.1
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
import pandas as pd  # 导入Pandas数据分析库
index_bric = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/20220820/xlsx/1560916966116974592.xlsx",sheet_name="Sheet1",header=0,index_col=0) #导入数据

index_bric_dropna = index_bric.dropna()  #删除存在缺失值的行数并创建一个新的数据框
print(index_bric_dropna.isnull().any())  # 输出缺失值检查结果
print(index_bric.shape)          #查看原数据框的形状参数
print(index_bric_dropna.shape)      #查看新数据框的形状参数

平台任务3解答代码

以下代码与教学平台任务要求完全一致:

列表 51.2
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
import pandas as pd  # 导入Pandas数据分析库
index_bric = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/20220820/xlsx/1560916966116974592.xlsx",sheet_name="Sheet1",header=0,index_col=0) #导入数据

index_bric_ffill = index_bric.fillna(method="ffill")   #向前补齐
print(index_bric_ffill.isnull().any())  # 输出缺失值检查结果
print(index_bric_ffill.loc["2019-06-04":"2019-06-12"])  # 输出2019-06-04

平台任务4解答代码

以下代码与教学平台任务要求完全一致:

列表 51.3
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
import pandas as pd  # 导入Pandas数据分析库
index_bric = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/20220820/xlsx/1560916966116974592.xlsx",sheet_name="Sheet1",header=0,index_col=0) #导入数据

index_bric_bfill = index_bric.fillna(method="bfill")   #向后补齐
print(index_bric_bfill.isnull().any())  # 输出缺失值检查结果
print(index_bric_bfill.loc["2019-06-04":"2019-06-13"])  # 输出2019-06-04
列表 51.4
# =============================================================================
# 题目:探索NaN的特殊数学性质
# =============================================================================
# 本任务演示NaN(Not a Number)在Python中的反直觉特性,这些特性源于IEEE 754浮点数标准

# ==================== 导入必要的库 ====================
import numpy as np  # NumPy数值计算库
import pandas as pd  # Pandas数据分析库

# ==================== 创建NaN值 ====================
# np.nan是NumPy库中表示缺失值的常量
# NaN是IEEE 754浮点数标准中定义的特殊值,用于表示无效或未定义的数值运算结果
nan_value = np.nan
# 注意:NaN虽然是float类型,但它不等于任何值,包括它自己

# ==================== 演示NaN的比较特性 ====================
print('NaN的比较特性:')
# nan_value == nan_value:这是最反直觉的特性
# 结果为False!因为NaN在数学上被定义为"不等于任何值,包括它自己"
# 这是IEEE 754标准的规定,用于标识无效的数值运算结果
print(f'NaN == NaN: {nan_value == nan_value}')  # 输出: False!

# nan_value != nan_value:由于NaN不等于它自己,所以"不等于"为True
print(f'NaN != NaN: {nan_value != nan_value}')  # 输出: True!

# nan_value is nan_value:使用is运算符检查对象身份
# is比较的是两个引用是否指向同一个对象,这里显然是同一个对象
# 因此结果为True(这是Python对象的身份比较,不是数值比较)
print(f'NaN is NaN: {nan_value is nan_value}')    # 输出: True

# ==================== 演示NaN的运算特性 ====================
print('\nNaN的运算特性:')
# NaN + 100:任何数与NaN进行算术运算,结果都是NaN
# 这被称为"吸收律"(absorbing law),NaN会"污染"整个计算过程
print(f'NaN + 100: {nan_value + 100}')   # 输出: NaN

# NaN * 2:同理,NaN乘以任何数(包括0)结果仍是NaN
print(f'NaN * 2: {nan_value * 2}')       # 输出: NaN

# ==================== 关键要点总结 ====================
# 在金融数据分析中:
# 1. 永远不要用 == 或 != 来判断NaN,应该使用 pd.isna() 或 np.isnan()
# 2. NaN具有传染性,任何涉及NaN的运算结果都是NaN
# 3. 这意味着如果数据中存在缺失值,必须先处理才能进行计算

关键要点:永远不要用 == 判断NaN,应使用 pd.isna()np.isnan()

51.3 缺失值的识别与诊断

51.3.1 基础检测方法

列表 51.5
# =============================================================================
# 题目:检测数据框中的缺失值
# =============================================================================
# 本任务演示如何在实际金融数据中识别和统计缺失值,这是数据清洗的第一步
# 缺失值可能源于停牌、数据源故障、新股上市等原因

# ==================== 导入必要的库 ====================
import pandas as pd  # Pandas数据分析库
import numpy as np  # NumPy数值计算库,用于创建NaN值

# ==================== 创建包含缺失值的示例数据 ====================
# 场景:某日5只股票的交易数据,其中部分数据缺失
# data是一个字典,键是列名,值是该列的数据
data = {
    # 股票代码列:5只A股的代码(上海交易所.SH,深圳交易所.SZ)
    '股票代码': ['600519.SH', '000858.SZ', '600036.SH', '601318.SH', '000001.SZ'],
    # 收盘价列:第2和第5只股票收盘价缺失(np.nan表示缺失值)
    # 缺失原因可能是:停牌、数据延迟、数据源错误
    '收盘价': [1850.0, np.nan, 45.2, 52.8, np.nan],
    # 涨跌幅列:第3只股票涨跌幅缺失
    '涨跌幅': [0.05, -0.02, np.nan, -0.01, 0.03],
    # 成交量列:第3只股票成交量缺失
    '成交量': [1200, 3500, np.nan, 5600, 2800]
}

# 将字典转换为Pandas数据框(DataFrame)
# DataFrame是二维表格数据结构,类似于Excel工作表
df = pd.DataFrame(data)

# ==================== 显示原始数据 ====================
print('原始数据:')
print(df)  # 打印完整数据框,NaN会显示为NaN

# ==================== 缺失值检测方法1:isna() ====================
print('\n缺失值检测:')
# df.isna():检测每个单元格是否为缺失值
# 返回一个布尔类型的数据框,True表示是缺失值,False表示不是缺失值
print(df.isna())
# 输出示例:
#    股票代码   收盘价   涨跌幅   成交量
# 0  False  False  False  False
# 1  False   True  False  False  <- 第1行收盘价缺失
# 2  False  False   True   True  <- 第2行涨跌幅和成交量缺失

# ==================== 缺失值统计:每列缺失值数量 ====================
print('\n每列缺失值数量:')
# df.isna().sum():统计每列的缺失值数量
# isna()返回布尔值,sum()会将True计为1,False计为0
print(df.isna().sum())
# 输出解读:收盘价有2个缺失,涨跌幅有1个缺失,成交量有1个缺失

# ==================== 缺失值统计:每列缺失值比例 ====================
print('\n每列缺失值比例:')
# df.isna().sum() / len(df) * 100:计算每列缺失值占总行数的百分比
# len(df):数据框的总行数(这里是5行)
# .round(2):保留两位小数
print((df.isna().sum() / len(df) * 100).round(2))
# 输出解读:收盘价缺失40%(2/5),涨跌幅缺失20%(1/5)

# ==================== 完整行统计 ====================
# df.dropna():删除包含任何缺失值的行
# 这里只用于统计,不修改原数据框
complete = df.dropna()
print(f'\n完整行数: {len(complete)} / {len(df)}')
# 输出解读:完整行(无缺失值)有多少行,占总行数的比例
# 这有助于评估数据质量:如果完整行太少,可能需要重新获取数据

51.3.2 缺失值模式分析

理解哪些变量倾向于一起缺失,有助于诊断缺失原因:

列表 51.6
# ==================== 缺失值模式分析 ====================
# 目标:理解哪些变量倾向于一起缺失,有助于诊断缺失原因
# 场景:如果收盘价和成交量总是同时缺失,说明股票可能停牌(无交易)

# 定义函数:计算变量间的缺失模式相关性
def missing_correlation(df):
    """
    计算变量间的缺失模式相关性

    原理:
    1. df.isna() 将数据转换为布尔值(True=缺失,False=不缺失)
    2. .corr() 计算这些布尔值的相关系数

    解读:
    - 相关系数接近1:两个变量倾向于同时缺失
    - 相关系数接近-1:一个缺失时另一个倾向于不缺失
    - 相关系数接近0:缺失模式独立
    """
    return df.isna().corr()

# 调用函数计算缺失相关性
miss_corr = missing_correlation(df)
print('缺失模式相关性:')
print(miss_corr.round(2))  # .round(2)保留两位小数
# 输出示例解读:
#              收盘价   涨跌幅   成交量
# 收盘价       1.00    0.50    0.50
# 涨跌幅       0.50    1.00    1.00  <- 涨跌幅和成交量完全同步缺失
# 成交量       0.50    1.00    1.00
# 这说明涨跌幅和成交量有共同的缺失原因(如停牌)

如果两个变量的缺失相关性接近1,说明它们倾向于同时缺失,这可能提示它们有共同的缺失原因。

51.4 缺失值删除策略

51.4.1 删除方法

列表 51.7
# ==================== 缺失值删除策略 ====================
# 删除缺失值是处理缺失数据的最直接方法,但会造成信息损失
# 需要根据缺失比例和业务需求选择合适的删除策略

# ==================== 策略1:删除包含任何缺失值的行 ====================
# df.dropna(how='any'):只要一行中有任何一个缺失值,就删除整行
# how='any'是默认值,可以省略不写
# 适用场景:对数据完整性要求高,不能容忍任何缺失
df_any = df.dropna(how='any')
print('\n删除任何缺失值的行:')
print(df_any)
# 输出解读:只有所有列都没有缺失值的行才会被保留
# 在本例中,第1、2、4行(索引0、2、3)会被保留

# ==================== 策略2:删除全部为缺失值的行 ====================
# df.dropna(how='all'):只有当一行中所有值都缺失时,才删除该行
# 适用场景:容忍部分缺失,只删除完全无效的行
df_all = df.dropna(how='all')
print('\n删除全部为缺失值的行:')
print(df_all)
# 输出解读:本例中没有任何一行是完全缺失的,所以所有行都保留

# ==================== 策略3:删除特定列中有缺失值的行 ====================
# df.dropna(subset=['收盘价', '涨跌幅']):只在指定列中检查缺失值
# subset参数:指定要检查的列名列表
# 适用场景:某些关键指标缺失就无法分析,如核心财务指标
df_subset = df.dropna(subset=['收盘价', '涨跌幅'])
print('\n在收盘价或涨跌幅有缺失的行被删除:')
print(df_subset)
# 输出解读:只要收盘价或涨跌幅任一缺失,该行就会被删除
# 即使成交量没有缺失,如果收盘价缺失也会被删除

删除策略的适用场景:

场景 推荐策略 理由
缺失<5%且MCAR dropna() 信息损失小,无偏估计
关键指标缺失 dropna(subset=[...]) 关键指标缺失无法分析
缺失>50% 考虑删除该变量 信息太少,插补不可靠
MNAR 谨慎处理 删除会导致严重偏倚

51.5 缺失值填充方法

51.5.1 常数填充

列表 51.8
# ==================== 缺失值填充方法1:常数填充 ====================
# 填充缺失值是删除的替代方案,可以保留数据行
# 但填充值可能引入偏差,需要根据业务场景选择填充值

# ==================== 方法1:用0填充 ====================
# df.fillna(0):将所有缺失值填充为0
# 适用场景:成交量、金额等数值型数据,缺失表示"没有交易"
# 警告:0填充可能低估真实值,造成统计偏差
df_zero = df.fillna(0)
print('\n用0填充:')
print(df_zero)
# 输出解读:所有NaN都被替换为0
# 注意:股票价格为0是不合理的,所以0填充不适合价格数据

# ==================== 方法2:用均值填充 ====================
# df['收盘价'].mean():计算收盘价列的均值(忽略NaN)
# df.fillna({'收盘价': mean_price}):只填充收盘价列,其他列保持不变
# 适用场景:数据分布对称,缺失随机(MCAR)
mean_price = df['收盘价'].mean()  # 计算均值:(1850.0 + 45.2 + 52.8) / 3
df_mean = df.fillna({'收盘价': mean_price})
print('\n用均值填充:')
print(df_mean)
# 输出解读:收盘价的缺失值被均值(约649元)填充
# 注意:均值受极端值影响大,如果数据有异常值,均值会被拉偏

# ==================== 方法3:用中位数填充 ====================
# df['收盘价'].median():计算收盘价列的中位数(忽略NaN)
# 中位数是排序后位于中间位置的值,对异常值更稳健
# 适用场景:数据分布偏斜或有极端异常值
median_price = df['收盘价'].median()  # 计算中位数:45.2和52.8之间
df_median = df.fillna({'收盘价': median_price})
print(f'\n用中位数填充(中位数={median_price:.2f}):')
print(df_median)
# 输出解读:收盘价的缺失值被中位数填充
# 优势:中位数不受极端值影响,比如茅台的高价不会影响其他股票的填充值

51.5.2 前向与后向填充

列表 51.9
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
import pandas as pd  # 导入Pandas数据分析库
index_bric = pd.read_excel("https://huoran.oss-cn-shenzhen.aliyuncs.com/20220820/xlsx/1560916966116974592.xlsx",sheet_name="Sheet1",header=0,index_col=0) #导入数据
print(index_bric.isnull().any())    #查找每一列是否存在缺失值(用isnull函数)

print(index_bric.isna().any())     #查找每一列是否存在缺失值(用isna函数)
print(index_bric[index_bric.isnull().values==True]) #查找存在缺失值所在的行

金融应用:前向填充是处理停牌数据的标准方法,假设停牌期间价格保持不变。

51.5.3 插值法

列表 51.10
# ==================== 缺失值填充方法3:插值法 ====================
# 插值法根据已知数据点的模式,估算缺失值
# 比简单填充更智能,可以反映数据的趋势和变化

# ==================== 创建时间序列数据 ====================
# pd.date_range('2024-01-01', periods=10):生成10个连续日期
# 场景:模拟某金融产品在10个交易日的价格,其中多个日期价格缺失
dates = pd.date_range('2024-01-01', periods=10)
ts_data = pd.DataFrame({
    '日期': dates,  # 日期列
    '价格': [10.5, np.nan, np.nan, 11.2, np.nan, 11.8, np.nan, np.nan, 12.5, 12.8]
    # 价格列:第2、3、5、7、8个交易日价格缺失(NaN)
})

# ==================== 线性插值 ====================
# .interpolate(method='linear'):线性插值方法
# 原理:在两个已知点之间画一条直线,用直线上的值填充缺失值
# 公式:缺失值 = 前值 + (后值 - 前值) × (位置比例)
ts_linear = ts_data.copy()  # 复制原数据,避免修改原始数据框
ts_linear['价格_线性插值'] = ts_linear['价格'].interpolate(method='linear')
# 新增列"价格_线性插值",将缺失值填充

print('线性插值:')
print(ts_linear)
# 输出解读:
# - 第2个位置(索引1):10.5和11.2之间的线性插值
# - 第3个位置(索引2):继续线性趋势
# - 线性插值假设价格在两个已知点之间均匀变化

51.6 金融应用停牌数据处理

51.6.1 案例招商银行停牌期间数据处理

列表 51.11
# =============================================================================
# 金融应用案例:招商银行停牌期间的数据处理
# =============================================================================
# 停牌是A股市场的常见现象,公司因重大事项停牌期间没有交易数据
# 本案例演示两种常用处理策略:前向填充 vs 删除

# ==================== 创建停牌场景模拟数据 ====================
# pd.date_range('2024-01-01', periods=10):生成10个交易日
dates = pd.date_range('2024-01-01', periods=10)
suspension_data = pd.DataFrame({
    '日期': dates,
    # 收盘价:第4-8个交易日(索引3-7)缺失,模拟停牌5个交易日
    '收盘价': [10.5, 10.8, 11.0, np.nan, np.nan, np.nan,
                np.nan, np.nan, 11.5, 11.7]
})

# ==================== 标记停牌期 ====================
# suspension_data['收盘价'].isna():检测收盘价是否缺失
# True表示停牌(无交易),False表示正常交易
# 结果存储在新列"是否停牌"中
suspension_data['是否停牌'] = suspension_data['收盘价'].isna()

print('停牌数据:')
print(suspension_data)
# 输出解读:
# - 前3天(索引0-2):正常交易,有收盘价
# - 中5天(索引3-7):停牌,收盘价为NaN,是否停牌为True
# - 后2天(索引8-9):复牌,恢复交易

# ==================== 策略1:前向填充 ====================
# 在金融分析中,前向填充是处理停牌数据的标准方法
# 原因:停牌期间虽然无交易,但股票仍持有,假设价值保持不变
df_strategy1 = suspension_data.copy()  # 复制数据,避免修改原始数据
# df_strategy1['收盘价'].fillna(method='ffill'):
#   将停牌期间的NaN用停牌前的最后一个价格填充
#   即第4-8天的价格都用11.0填充
df_strategy1['收盘价'] = df_strategy1['收盘价'].fillna(method='ffill')

print('\n策略1 - 前向填充:')
print(df_strategy1)
# 输出解读:停牌期间价格保持为11.0(停牌前最后价格)
# 优点:保留时间序列的连续性,便于计算收益率
# 缺点:低估真实波动率,延迟反映价格跳跃

# ==================== 策略2:删除停牌行 ====================
# 某些分析(如波动率计算)不能包含人为填充的数据
# df.dropna(subset=['收盘价']):删除收盘价缺失的所有行
df_strategy2 = suspension_data.dropna(subset=['收盘价'])

print('\n策略2 - 删除停牌行:')
print(df_strategy2)
# 输出解读:只保留有实际交易数据的5天
# 优点:数据真实,没有人为填充
# 缺点:破坏时间连续性,丢失停牌期间的信息

51.6.2 策略比较

前向填充: - ✓ 简单直观 - ✗ 低估真实波动率 - ✗ 延迟反映价格跳跃

删除行: - ✓ 避免虚假数据 - ✗ 破坏时间连续性 - ✗ 可能丢失重要信息

51.7 插补方法比较

51.7.1 不同插值方法的评估

列表 51.12
# =============================================================================
# 题目:评估不同插值方法的准确性
# =============================================================================
# 本任务通过模拟数据,比较线性插值和样条插值的预测误差
# 均方误差(MSE)越小,说明插值方法越准确

# ==================== 导入NumPy库 ====================
import numpy as np  # NumPy数值计算库,用于生成测试数据

# ==================== 创建测试数据 ====================
# np.linspace(0, 10, 20):生成0到10之间均匀分布的20个点
# x代表时间或位置,y代表观测值
x = np.linspace(0, 10, 20)

# y_true:生成"真实"数据,用于验证插值方法的准确性
# np.sin(x):正弦函数,模拟周期性数据
# np.random.normal(0, 0.1, 20):添加均值为0、标准差为0.1的正态分布噪声
y_true = np.sin(x) + np.random.normal(0, 0.1, 20)

# ==================== 人为制造缺失值 ====================
# y_missing:复制真实数据,然后随机设置部分值为NaN
# np.random.choice(20, 5, replace=False):
#   从20个位置中随机选择5个不重复的位置
# y_missing[...] = np.nan:将这5个位置的值设为缺失
y_missing = y_true.copy()
y_missing[np.random.choice(20, 5, replace=False)] = np.nan

# ==================== 方法1:线性插值 ====================
# pd.Series(y_missing):将NumPy数组转换为Pandas序列
# .interpolate(method='linear'):线性插值方法
#   原理:在两个已知点之间画直线
y_linear = pd.Series(y_missing).interpolate(method='linear')

# ==================== 方法2:样条插值 ====================
# .interpolate(method='cubic'):三次样条插值方法
#   原理:使用三次多项式拟合数据点,曲线更平滑
#   优点:可以捕捉非线性趋势
#   缺点:可能过拟合,对噪声敏感
y_spline = pd.Series(y_missing).interpolate(method='cubic')

# ==================== 计算插值误差(MSE) ====================
# MSE (Mean Squared Error):均方误差
# 公式:MSE = Σ(真实值 - 预测值)² / n
# MSE越小,说明插值结果越接近真实值
print(f'线性插值MSE: {np.mean((y_true - y_linear)**2):.4f}')
print(f'样条插值MSE: {np.mean((y_true - y_spline)**2):.4f}')

# ==================== 结果解读 ====================
# 如果线性插值MSE < 样条插值MSE:
#   说明数据接近线性变化,线性插值更合适
# 如果样条插值MSE < 线性插值MSE:
#   说明数据有非线性趋势,样条插值更合适
# 在金融数据中:
#   - 短期缺失:线性插值通常足够
#   - 长期缺失:建议使用删除或领域知识填充