16  缺失值处理 英国零售商数据清洗

16.1 引言数据质量的核心挑战

在真实的数据分析项目中,缺失值(Missing Values)是数据质量问题中最常见也最棘手的挑战之一。对于金融分析师而言,正确处理缺失值直接关系到分析结果的可靠性和投资决策的有效性。

理论背景:缺失数据的统计理论

从统计学角度来看,缺失值问题远比”补全空缺”复杂。缺失值的产生机制本身可能携带重要信息,而缺失模式的不同决定了我们应该采用的处理策略。Rubin(1976)提出的缺失数据分类理论是处理这一问题的理论基础:

三种缺失机制(Missing Data Mechanisms): 1. 完全随机缺失(MCAR - Missing Completely At Random): 缺失的发生与任何变量(观测或未观测)都无关 2. 随机缺失(MAR - Missing At Random): 缺失的发生仅依赖于已观测的数据 3. 非随机缺失(MNAR - Missing Not At Random): 缺失的发生与缺失值本身相关

金融场景中的缺失值:

  • 停牌交易日: 股票停牌期间无价格和成交量数据
  • 新上市公司: 缺乏历史价格数据
  • 财务数据: 部分公司延迟发布财报
  • 数据源错误: API采集失败、存储故障
  • 数据结构变化: 指数成分股调整导致历史数据不连续

理解缺失机制的重要性:

错误的缺失值处理策略可能导致: - 有偏估计: 删除MNAR数据会系统性扭曲样本 - 损失信息: 过度删除会减少样本量,降低统计功效 - 虚假结论: 不当填充可能创造不存在的模式

16.2 缺失值识别与诊断

16.2.1 基础检测方法

平台任务解答代码

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

列表 16.1
# ⚠️ 平台原始代码 - 请原样输入至教学平台(注释除外),平台才会判定答案正确
# 注:英国零售商data.csv数据文件本地没有,但平台已经内置
import pandas as pd  # 导入Pandas数据分析库
import numpy as np  # 导入NumPy数值计算库
path= '英国零售商data.csv'  # 设置path为"英国零售商data.csv"
df=pd.read_csv(path,dtype={'CustomerID':str,'InvoiceID':str})  # 从CSV文件读取数据存入df
# 剔除CustomerID的缺失数据
df.dropna(subset=['CustomerID'],inplace=True)
print(df.CustomerID.count())  # 输出非缺失值计数
列表 16.2
# =============================================================================
# 题目:缺失值检测与统计
# =============================================================================
# 本任务演示如何在实际商业数据中识别和统计缺失值
# 场景:英国零售商连锁店的销售数据,部分门店数据缺失

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

# ==================== 创建包含缺失值的示例数据 ====================
# 场景:6家零售门店(A-F)的日销售数据
data = {
    # 门店代码列:6家门店的标识符
    'Store': ['A', 'B', 'C', 'D', 'E', 'F'],
    # 销售额列:门店B和E的销售额缺失(可能是系统故障或未营业)
    'Sales': [100, np.nan, 150, 200, np.nan, 180],
    # 利润列:门店C和F的利润缺失(可能是成本数据未录入)
    'Profit': [10, 15, np.nan, 20, 25, np.nan],
    # 客户数列:门店D的客户数缺失(可能是客流计数器故障)
    'Customers': [50, 75, 100, np.nan, 80, 95]
}
# 将字典转换为Pandas数据框(二维表格结构)
df = pd.DataFrame(data)

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

# ==================== 检测方法1:识别缺失值位置 ====================
print('缺失值位置(True表示缺失):')
# df.isnull():检测每个单元格是否为缺失值
# 返回布尔型数据框,True表示该位置是缺失值,False表示不是缺失值
# isnull()和isna()功能完全相同,可以互换使用
print(df.isnull())
# 输出示例:
#    Store  Sales  Profit  Customers
# 0  False  False   False      False
# 1  False   True   False      False  <- 门店B的销售额缺失
# 2  False  False    True      False  <- 门店C的利润缺失
print()

# ==================== 检测方法2:统计每列缺失值数量 ====================
print('各列缺失值数量:')
# df.isnull().sum():统计每列的缺失值总数
# isnull()返回布尔值(True=1, False=0),sum()将True值相加
# 这是评估数据质量的关键指标:哪些变量问题最严重
print(df.isnull().sum())
# 输出解读:Sales列有2个缺失,Profit列有2个缺失,Customers列有1个缺失
print()

# ==================== 检测方法3:计算缺失值比例 ====================
print('各列缺失值比例:')
# df.isnull().mean() * 100:计算每列缺失值占总行数的百分比
# isnull().mean():先计算布尔值的均值(即True的比例)
# * 100:转换为百分比形式
# 缺失比例是决策依据:<5%可简单处理,>20%需考虑删除变量
print(df.isnull().mean() * 100)
# 输出解读:Sales缺失33.3%(2/6),Profit缺失33.3%,Customers缺失16.7%
print()

# ==================== 检测方法4:统计完整行数 ====================
print('完整行数(无缺失):', df.dropna().shape[0])
# df.dropna():删除包含任何缺失值的行,返回无缺失的数据框
# .shape[0]:获取数据框的行数(第一维度)
# 完整行数反映数据质量:完整行越少,数据质量问题越严重
print('总行数:', df.shape[0])
# shape属性返回(行数, 列数)的元组,shape[0]是行数,shape[1]是列数

代码深度解析:

  1. isnull()方法:
    • 返回布尔型DataFrame,缺失值位置为True
    • 等价方法: isna()
    • 适用于所有数据类型
  2. 统计策略:
    • 按列统计: 了解哪些变量问题严重
    • 按行统计: 识别数据质量最差的观测
    • 比例计算: 评估数据质量的可接受程度
  3. 决策阈值:
    • 缺失率 < 5%: 通常可删除或简单填充
    • 缺失率 5%-20%: 需要谨慎选择策略
    • 缺失率 > 20%: 考虑删除变量或使用高级插补

16.2.2 缺失模式可视化

列表 16.3
# =============================================================================
# 题目:缺失模式可视化分析
# =============================================================================
# 本任务通过可视化手段展示缺失值的分布模式,帮助判断缺失机制
# 可视化比数字更直观,能快速发现数据质量问题

# ==================== 导入必要的库 ====================
import pandas as pd  # Pandas数据分析库
import numpy as np  # NumPy数值计算库
import matplotlib.pyplot as plt  # Matplotlib绘图库

# ==================== 创建更大的示例数据集 ====================
# 设置随机种子,确保每次运行结果一致
np.random.seed(42)
# 创建100行数据,模拟100个交易日的数据
n_rows = 100
# 创建包含4个变量(A、B、C、D)的数据框
data_vis = pd.DataFrame({
    'A': np.random.rand(n_rows),  # 变量A:100个[0,1)区间的随机数
    'B': np.random.rand(n_rows),  # 变量B:100个[0,1)区间的随机数
    'C': np.random.rand(n_rows),  # 变量C:100个[0,1)区间的随机数
    'D': np.random.rand(n_rows)   # 变量D:100个[0,1)区间的随机数
})

# ==================== 人为引入缺失值(模拟真实场景) ====================
# 场景1:变量A的10%随机缺失(MCAR:完全随机缺失)
# np.random.rand(n_rows)生成100个[0,1)随机数
# < 0.1表示随机选择10%的位置
mask_a = np.random.rand(n_rows) < 0.1
# 将选中的位置设置为NaN(缺失值)
data_vis.loc[mask_a, 'A'] = np.nan

# 场景2:变量B的条件缺失(MAR:随机缺失)
# 当变量A的值<0.5时,变量B缺失
# 这模拟了相关性缺失:A值低时,B数据无法获取
data_vis.loc[data_vis['A'] < 0.5, 'B'] = np.nan

# 场景3:变量C的15%随机缺失
mask_c = np.random.rand(n_rows) < 0.15
data_vis.loc[mask_c, 'C'] = np.nan

# 场景4:变量D无缺失(作为对照组)

# ==================== 计算缺失矩阵 ====================
# data_vis.isnull():生成布尔矩阵,True表示缺失
# 这个矩阵用于可视化:缺失的位置显示为红色
missing_matrix = data_vis.isnull()

# ==================== 设置中文字体(避免中文显示乱码) ====================
plt.rcParams['font.sans-serif'] = ['SimHei']  # 设置黑体字体
plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题

# ==================== 创建画布和子图 ====================
# figsize=(14, 6):画布宽度14英寸,高度6英寸
# 1, 2:创建1行2列的子图布局(共2个图)
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ==================== 子图1:缺失模式热力图 ====================
# imshow():将矩阵以图像形式显示(热力图)
# missing_matrix.T:转置矩阵,使变量名在Y轴,时间在X轴
# aspect='auto':自动调整纵横比
# cmap='Reds':使用红色渐变色(白色=不缺失,红色=缺失)
# interpolation='none':不插值,保持原始像素
axes[0].imshow(missing_matrix.T, aspect='auto', cmap='Reds', interpolation='none')
# set_yticks():设置Y轴刻度位置(变量行号)
axes[0].set_yticks(range(len(data_vis.columns)))
# set_yticklabels():设置Y轴刻度标签(变量名)
axes[0].set_yticklabels(data_vis.columns)
axes[0].set_xlabel('观测序号')  # X轴标签:样本序号1-100
axes[0].set_title('缺失模式热力图(红色=缺失)')  # 图表标题
axes[0].grid(False)  # 关闭网格线

# ==================== 子图2:缺失条形图 ====================
# missing_matrix.sum():计算每列的缺失值数量
missing_counts = missing_matrix.sum()
# missing_counts / len(data_vis) * 100:计算每列缺失比例
missing_ratios = missing_counts / len(data_vis) * 100

# bar():绘制条形图
# range(len(data_vis.columns)):X轴位置(0,1,2,3对应A,B,C,D)
# missing_ratios:条形高度(缺失比例)
# color='steelblue':条形颜色(钢蓝色)
# edgecolor='black':条形边框颜色(黑色)
axes[1].bar(range(len(data_vis.columns)), missing_ratios,
           color='steelblue', edgecolor='black')
# set_xticks():设置X轴刻度位置
axes[1].set_xticks(range(len(data_vis.columns)))
# set_xticklabels():设置X轴刻度标签(变量名A、B、C、D)
axes[1].set_xticklabels(data_vis.columns)
axes[1].set_ylabel('缺失比例(%)')  # Y轴标签
axes[1].set_title('各变量缺失比例')  # 图表标题
axes[1].grid(axis='y', alpha=0.3)  # 显示Y轴网格线,透明度30%

# ==================== 添加数值标签 ====================
# enumerate(missing_ratios):遍历缺失比例列表,返回(索引, 值)
for i, v in enumerate(missing_ratios):
    # text():在图表上添加文本
    # i, v+1:文本位置(X=索引,Y=比例+1,略高于条形顶部)
    # f'{v:.1f}%':文本内容(比例保留1位小数)
    # ha='center':水平居中对齐
    # fontsize=10:字体大小10
    axes[1].text(i, v + 1, f'{v:.1f}%', ha='center', fontsize=10)

# ==================== 调整布局并显示图表 ====================
# tight_layout():自动调整子图间距,避免标题重叠
plt.tight_layout()
# show():显示图表
plt.show()

# ==================== 打印统计信息 ====================
print('缺失值统计:')
print(missing_counts)  # 每列的缺失值数量
print(f'\n缺失比例:')
print(missing_ratios)  # 每列的缺失比例

金融应用:理解缺失模式有助于判断缺失机制,选择合适的处理策略。例如,如果某只股票的成交量在停牌期间系统性缺失(MAR),则不应简单删除这些观测。

16.3 缺失值处理策略

16.3.1 策略1删除法(Deletion)

删除法是最简单的处理方式,但需要谨慎使用。

列表 16.4
# =============================================================================
# 题目:删除缺失值的不同策略
# =============================================================================
# 本任务演示如何使用dropna()方法删除包含缺失值的行或列
# 删除法简单但风险大,需要理解每种策略的影响

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

# ==================== 创建示例数据 ====================
data = {
    # 门店代码列
    'Store': ['A', 'B', 'C', 'D', 'E'],
    # 销售额列:门店B和E缺失
    'Sales': [100, np.nan, 150, 200, np.nan],
    # 利润列:门店C缺失
    'Profit': [10, 15, np.nan, 20, 25]
}
# 转换为数据框
df = pd.DataFrame(data)

# ==================== 显示原始数据 ====================
print('原始数据:')
print(df)
print()

# ==================== 策略1:删除含任何缺失值的行 ====================
# how='any':只要行中有任何一个缺失值,就删除该行
# 这是dropna()的默认参数
df_drop_any = df.dropna(how='any')
print('删除含任何缺失值的行(how="any"):')
print(df_drop_any)
# 结果解读:只保留行A和D(这两行没有任何缺失值)
# 删除了行B(Sales缺失)、行C(Profit缺失)、行E(Sales缺失)
print()

# ==================== 策略2:只删除全部缺失的行 ====================
# how='all':只有当行中所有值都缺失时,才删除该行
# 这是一种宽松的删除策略,保留部分数据
df_drop_all = df.dropna(how='all')
print('只删除全部缺失的行(how="all"):')
print(df_drop_all)
# 结果解读:所有行都保留(因为没有一行是全部缺失的)
# 这个策略在金融数据中很少使用,因为完全缺失的行很少见
print()

# ==================== 策略3:删除特定列含缺失的行 ====================
# subset=['Sales']:只检查Sales列是否缺失
# 如果Sales列缺失,删除该行;其他列缺失不影响
df_drop_subset = df.dropna(subset=['Sales'])
print('只删除Sales列缺失的行:')
print(df_drop_subset)
# 结果解读:删除了行B和E(Sales列缺失),保留行C(Profit缺失但Sales不缺失)
# 应用场景:核心变量(如价格)缺失时,整行数据无意义
print()

# ==================== 策略4:删除含缺失值的列 ====================
# axis=1:按列操作(默认axis=0是按行操作)
# 删除包含任何缺失值的列
df_drop_cols = df.dropna(axis=1)
print('删除含缺失值的列:')
print(df_drop_cols)
# 结果解读:只保留Store列(Sales和Profit列都有缺失,被删除)
# 应用场景:当某列缺失率太高(如>30%),考虑删除该列

补充说明:删除法的适用条件

删除法的使用必须满足以下条件:

  1. MCAR假设: 数据缺失是完全随机的
  2. 样本量充足: 删除后仍有足够的观测进行统计推断
  3. 缺失比例低: 通常建议缺失率 < 5%

金融场景注意事项:

在金融时间序列中,直接删除可能导致: - 时间不连续: 破坏了时间序列的完整性 - 幸存者偏差: 只保留”存活”的数据,高估表现 - 低估风险: 删除极端情况(往往伴随缺失)会低估风险

16.3.2 策略2填充法(Imputation)

填充法用估计值替代缺失值,保留了所有观测。

列表 16.5
# =============================================================================
# 题目:多种填充策略对比
# =============================================================================
# 本任务演示6种常见的缺失值填充方法,并比较它们的优劣
# 填充法保留了所有观测,但可能引入偏差

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

# ==================== 创建金融时间序列示例 ====================
# 创建日期序列:2024年1月1日开始,共10天
dates = pd.date_range('2024-01-01', periods=10)
# 价格序列:包含3个缺失值(第2、5、9天)
prices = [100.5, np.nan, 101.2, 100.8, np.nan, np.nan, 102.5, 103.0, np.nan, 104.2]
# 创建数据框
df_prices = pd.DataFrame({'Date': dates, 'Price': prices})

# ==================== 显示原始数据 ====================
print('原始价格数据:')
print(df_prices)
print()

# ==================== 策略1:零填充 ====================
# fillna(0):将所有缺失值填充为0
# 警告:这通常是不合适的,因为价格不可能是0
df_zero = df_prices.fillna(0)
print('零填充(通常不推荐):')
print(df_zero[['Price']].head())
# 结果解读:第2、5、9天的价格被设为0,这在金融数据中没有意义
# 零填充适用于:计数类数据(如交易次数)
print()

# ==================== 策略2:均值填充 ====================
# df_prices['Price'].mean():计算价格序列的均值(忽略NaN)
mean_price = df_prices['Price'].mean()
# fillna(mean_price):将所有缺失值填充为均值
df_mean = df_prices.fillna(mean_price)
print(f'均值填充(均值={mean_price:.2f}):')
print(df_mean[['Price']].head())
# 结果解读:所有缺失位置都填入同一个值(102.02)
# 优点:保持数据的中心趋势
# 缺点:低估方差,破坏时间序列的自相关性
print()

# ==================== 策略3:中位数填充(更稳健) ====================
# df_prices['Price'].median():计算价格序列的中位数
# 中位数比均值更稳健,不受极端值影响
median_price = df_prices['Price'].median()
# fillna(median_price):将所有缺失值填充为中位数
df_median = df_prices.fillna(median_price)
print(f'中位数填充(中位数={median_price:.2f}):')
print(df_median[['Price']].head())
# 优点:对异常值不敏感
# 适用场景:存在极端价格(如涨跌停)时
print()

# ==================== 策略4:前向填充(适合时间序列) ====================
# method='ffill':forward fill,用前一个有效值填充
# 这是时间序列数据的常用方法
df_ffill = df_prices.fillna(method='ffill')
print('前向填充(用前一个值填充):')
print(df_ffill[['Price']])
# 结果解读:
# - 第2天:用第1天的100.5填充
# - 第5天:用第4天的100.8填充
# - 第6天:用第5天(已填充)的100.8填充
# - 第9天:用第8天的103.0填充
# 金融应用:填充停牌期间的价格(假设价格保持不变)
print()

# ==================== 策略5:后向填充 ====================
# method='bfill':backward fill,用后一个有效值填充
# 这在金融数据中较少使用
df_bfill = df_prices.fillna(method='bfill')
print('后向填充(用后一个值填充):')
print(df_bfill[['Price']])
# 结果解读:
# - 第2天:用第3天的101.2填充
# - 第5天:用第7天的102.5填充
# - 第6天:用第7天的102.5填充
# - 第9天:用第10天的104.2填充
# 问题:使用了"未来"的信息,可能导致前视偏差
print()

# ==================== 策略6:线性插值 ====================
# method='linear':线性插值,在两个已知点之间连线
# interpolate():Pandas的插值方法,支持多种算法
df_linear = df_prices.interpolate(method='linear')
print('线性插值:')
print(df_linear[['Price']])
# 结果解读:
# - 第2天:在100.5和101.2之间线性插值
# - 第5天:在100.8和102.5之间线性插值
# - 第6天:继续插值
# - 第9天:在103.0和104.2之间线性插值
# 公式:y = y1 + (y2-y1)*(x-x1)/(x2-x1)
# 金融应用:填充高频数据的小缺口(假设价格平滑变化)

代码深度解析:

  1. 均值/中位数填充:
    • 假设:缺失值围绕中心趋势分布
    • 优点:简单,保持数据分布中心
    • 缺点:低估方差,破坏变量间关系
    • 金融适用:横截面数据(如多只股票的PE)
  2. 前向填充(Forward Fill):
    • 假设:状态持续(价格保持不变)
    • 适用:时间序列数据,如股票停牌
    • 风险:可能引入滞后偏差
    • 金融场景:填充实停期间的价格
  3. 线性插值:
    • 假设:变化是平滑的
    • 适用:高频数据,小幅缺失
    • 风险:可能创造不存在的价格
    • 金融场景:日内价格的小缺口

理论背景:插值方法的数学基础

线性插值假设两个已知点之间的变化是线性的。对于时间点 \(t_1\)\(t_3\) 之间的缺失值 \(t_2\):

\[ y_{t_2} = y_{t_1} + \frac{t_2 - t_1}{t_3 - t_1} (y_{t_3} - y_{t_1}) \]

这等价于假设价格遵循几何布朗运动时的条件期望。

16.3.3 策略3高级填充方法

对于复杂的缺失模式,需要更精细的方法。

列表 16.6
# =============================================================================
# 题目:高级插值方法与滚动统计填充
# =============================================================================
# 本任务演示更复杂的填充策略,包括时间插值、多项式插值和滚动统计
# 这些方法考虑了数据的时序特征和局部模式

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

# ==================== 创建含缺失值的金融数据 ====================
# 设置随机种子
np.random.seed(42)
# 创建日期序列:50个交易日
dates = pd.date_range('2024-01-01', periods=50)
# 生成随机游走价格序列(模拟股票价格)
# np.random.randn(50).cumsum():随机数的累积和(布朗运动)
prices = 100 + np.random.randn(50).cumsum()

# ==================== 人为引入缺失值 ====================
# prices[10:15]:将第10-14个元素(索引10到14)设为NaN
# 这模拟连续5天的停牌
prices[10:15] = np.nan
# prices[30:32]:将第30-31个元素设为NaN
# 这模拟连续2天的数据缺失
prices[30:32] = np.nan
# prices[45]:将第45个元素设为NaN
# 这模拟单日数据缺失
prices[45] = np.nan

# ==================== 创建数据框 ====================
df = pd.DataFrame({'Date': dates, 'Price': prices})
# set_index():将Date列设为索引(便于时间序列操作)
# inplace=True:直接修改原数据框,不创建副本
df.set_index('Date', inplace=True)

# ==================== 显示原始数据 ====================
print('原始数据(含缺失):')
print(df.head(15))  # 显示前15行,可以看到第10-14天的缺失
print()

# ==================== 策略1:时间插值(考虑日期间隔) ====================
# method='time':基于实际时间间隔进行插值
# 这与linear不同:time考虑了日期之间的实际天数间隔
df_time = df.interpolate(method='time')
print('时间插值(考虑实际日期间隔):')
print(df_time.head(15))
# 适用场景:日期不连续(如跳过周末)
# 优点:更符合实际时间流逝
# 注意:如果日期是连续的,time和linear结果相同
print()

# ==================== 策略2:多项式插值 ====================
# method='polynomial':使用多项式插值
# order=2:二阶多项式(二次函数)
# 这假设价格变化遵循二次曲线
df_poly = df.interpolate(method='polynomial', order=2)
print('多项式插值(二阶):')
print(df_poly.head(15))
# 数学原理:拟合一个二次函数 y = ax² + bx + c
# 优点:可以捕捉非线性趋势
# 缺点:可能在缺失区间边缘产生振荡(龙格现象)
# 金融慎用:价格通常不遵循简单的多项式
print()

# ==================== 策略3:滚动均值填充(利用历史信息) ====================
# rolling(window=5, min_periods=1):计算滚动窗口统计量
# window=5:窗口大小为5(使用前5个数据点)
# min_periods=1:最少需要1个非缺失值(这样窗口起始处也能计算)
# .mean():计算均值
df_rolling = df.fillna(df.rolling(window=5, min_periods=1).mean())
print('滚动均值填充(窗口=5):')
print(df_rolling.head(15))
# 原理:用前5天的平均价格填充缺失值
# 优点:考虑了局部趋势,比全局均值更合理
# 金融应用:填充短期停牌价格(参考近期水平)
# 注意:这里fillna()的参数是一个Series(滚动均值序列)
print()

# ==================== 策略4:分组填充(不同资产分别处理) ====================
# 场景:多只股票的数据,每只股票独立处理
# 创建多只股票的数据
stocks = pd.DataFrame({
    # 日期列:10个日期,每只股票重复一次
    'Date': pd.date_range('2024-01-01', periods=10).repeat(2),
    # 股票代码列:股票A重复10次,股票B重复10次
    'Stock': ['A'] * 10 + ['B'] * 10,
    # 价格列:包含多个缺失值
    'Price': [100, 101, np.nan, 103, 104, np.nan, 106, 107, np.nan, 109,
              50, np.nan, 52, 53, 54, np.nan, np.nan, 58, 59, 60]
})

# ==================== 显示多股票数据 ====================
print('多只股票数据:')
print(stocks)
print()

# ==================== 分组前向填充 ====================
# groupby('Stock'):按股票代码分组
# .fillna(method='ffill'):对每组分别前向填充
# 这确保股票A的缺失不会用股票B的数据填充
stocks_filled = stocks.groupby('Stock').fillna(method='ffill')
print('分组前向填充(每只股票独立处理):')
print(stocks_filled)
# 结果解读:
# - 股票A的第3天缺失:用第2天的101填充
# - 股票B的第2天缺失:用第1天的50填充(不是用股票A的数据)
# 金融应用:处理面板数据(多只股票的时间序列)
# 关键:不同股票的数据不能混用

补充说明:金融时间序列的特殊性

金融时间序列的特殊性质决定了填充策略:

  1. 非平稳性: 价格通常不是平稳的,收益率更平稳
    • 建议: 对收益率序列进行插值,而非价格
  2. 波动聚集: 方差随时间变化
    • 建议: 使用滚动窗口统计,而非全局统计
  3. 杠杆效应: 负收益和正收益的影响不对称
    • 建议: 分别对正负收益进行插值
  4. 交易机制: 涨跌停、停牌等导致缺失
    • 建议: 保留这些信息,不强制填充

16.4 实战案例英国零售商数据清洗

16.4.1 数据加载与初步诊断

列表 16.7
# =============================================================================
# 题目:英国零售商数据加载与质量评估
# =============================================================================
# 本任务演示真实商业数据的加载、诊断和质量评估流程
# 数据来源于英国在线零售商的交易记录

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

# ==================== 注意:实际数据加载(注释掉) ====================
# 在实际使用中,需要提供正确的文件路径
# path = '英国零售商data.csv'  # 数据文件路径
# df = pd.read_csv(path, dtype={'CustomerID': str, 'InvoiceID': str})
# dtype参数:指定列的数据类型(防止客户ID被误读为数值)

# ==================== 模拟英国零售商数据(演示用) ====================
# 设置随机种子,确保结果可重现
np.random.seed(42)
# 记录数:1000条交易记录
n_records = 1000

# ==================== 创建数据框 ====================
df_retail = pd.DataFrame({
    # 发票ID列:生成1000个唯一的发票编号
    # f'INV{i:06d}':格式化字符串,如INV000001, INV000002
    'InvoiceID': [f'INV{i:06d}' for i in range(n_records)],
    # 客户ID列:10%的记录缺失客户ID
    # np.random.randint(1, 201):随机生成1-200的客户ID
    # np.random.rand() > 0.1:90%的概率有客户ID,10%概率为NaN
    'CustomerID': [f'CUST{np.random.randint(1, 201):05d}' if np.random.rand() > 0.1 else np.nan
                   for _ in range(n_records)],
    # 交易日期列:从2023-01-01开始的1000个连续日期
    'InvoiceDate': pd.date_range('2023-01-01', periods=n_records),
    # 商品数量列:随机生成1-100之间的整数
    'Quantity': np.random.randint(1, 100, n_records),
    # 单价列:随机生成1-50之间的浮点数(英镑)
    'UnitPrice': np.random.uniform(1, 50, n_records),
    # 国家列:所有记录都是英国(UK)
    'Country': ['UK'] * n_records
})

# ==================== 人为引入缺失值(模拟真实场景) ====================
# 随机选择50个位置,将CustomerID设为NaN
df_retail.loc[np.random.choice(n_records, 50), 'CustomerID'] = np.nan
# 随机选择30个位置,将Quantity设为NaN
df_retail.loc[np.random.choice(n_records, 30), 'Quantity'] = np.nan
# 随机选择20个位置,将UnitPrice设为NaN
df_retail.loc[np.random.choice(n_records, 20), 'UnitPrice'] = np.nan

# ==================== 数据基本信息 ====================
print('数据形状:', df_retail.shape)
# .shape返回(行数, 列数),输出(1000, 6)表示1000行6列

print('\n数据类型:')
# .dtypes:显示每列的数据类型
print(df_retail.dtypes)

print('\n前10行数据:')
# .head(10):显示前10行数据,快速了解数据结构
print(df_retail.head(10))

# ==================== 缺失值诊断 ====================
print('\n缺失值统计:')
# .isnull().sum():统计每列的缺失值数量
print(df_retail.isnull().sum())

print('\n缺失值比例:')
# .isnull().mean() * 100:计算每列缺失值百分比
print(df_retail.isnull().mean() * 100)

16.4.2 缺失值处理决策

列表 16.8
# =============================================================================
# 题目:零售商数据缺失值处理流程
# =============================================================================
# 本任务演示一个完整的缺失值处理决策流程
# 关键:根据业务逻辑选择不同的处理策略

# ==================== 复制数据用于处理 ====================
# .copy():创建数据框的副本,避免修改原始数据
# 好习惯:保留原始数据,以便对比处理前后的差异
df_clean = df_retail.copy()

print('=== 缺失值处理流程 ===\n')

# ==================== 步骤1:处理CustomerID缺失 ====================
# 决策:删除CustomerID缺失的行
# 理由:客户ID是关联交易的关键字段,无法合理推测
# 业务逻辑:没有客户ID的交易无法进行客户行为分析
before_customer = len(df_clean)  # 处理前的行数
# dropna(subset=['CustomerID']):删除CustomerID列缺失的行
# subset参数:只检查指定列的缺失值
# inplace=True:直接修改df_clean(不需要重新赋值)
df_clean.dropna(subset=['CustomerID'], inplace=True)
after_customer = len(df_clean)  # 处理后的行数
print(f'1. CustomerID处理:')
print(f'   - 删除前: {before_customer} 行')
print(f'   - 删除后: {after_customer} 行')
# 计算删除比例:(处理前行数 - 处理后行数) / 处理前行数
print(f'   - 删除比例: {(before_customer - after_customer) / before_customer:.2%}')
# .2%:格式化为百分比,保留2位小数
print()

# ==================== 步骤2:处理Quantity缺失 ====================
# 决策:用中位数填充
# 理由:数量是数值型变量,中位数稳健,不受异常值影响
# 业务逻辑:用"典型订单量"替代缺失值
median_quantity = df_clean['Quantity'].median()
# .median():计算中位数(50%分位数)
# fillna(median_quantity):将Quantity列的NaN填充为中位数(使用赋值形式,兼容Pandas 2.x)
df_clean['Quantity'] = df_clean['Quantity'].fillna(median_quantity)
print(f'2. Quantity处理:')
print(f'   - 填充值(中位数): {median_quantity}')
# .isnull().sum():检查剩余缺失值数量(应该为0)
print(f'   - 剩余缺失: {df_clean["Quantity"].isnull().sum()}')
print()

# ==================== 步骤3:处理UnitPrice缺失 ====================
# 决策:按CustomerID分组填充均值
# 理由:同一客户的消费水平可能相似(价格敏感性一致)
# 业务逻辑:老客户通常购买相似价位的商品
# groupby('CustomerID'):按客户ID分组
# ['UnitPrice']:选择UnitPrice列
# .transform(lambda x: x.fillna(x.mean())):对每组应用转换函数
# lambda x:匿名函数,x是每个客户的UnitPrice序列
# x.fillna(x.mean()):用该客户的平均价格填充缺失值
df_clean['UnitPrice'] = df_clean.groupby('CustomerID')['UnitPrice'].transform(
    lambda x: x.fillna(x.mean())
)
# 如果仍有缺失(某客户所有记录的UnitPrice都缺失),用全局均值填充(赋值形式兼容Pandas 2.x)
df_clean['UnitPrice'] = df_clean['UnitPrice'].fillna(df_clean['UnitPrice'].mean())
print(f'3. UnitPrice处理:')
print(f'   - 分组均值填充')
print(f'   - 剩余缺失: {df_clean["UnitPrice"].isnull().sum()}')
print()

# ==================== 最终验证 ====================
print('=== 处理后数据质量 ===')
print(f'总行数: {len(df_clean)}')
# df_clean.dropna():删除仍有缺失的行(应该没有)
print(f'完整行数: {df_clean.dropna().shape[0]}')
print(f'\n最终缺失值统计:')
print(df_clean.isnull().sum())
# 期望输出:所有列的缺失值都是0

代码深度解析:

  1. 删除CustomerID缺失行:
    • 业务逻辑:CustomerID是客户分析的关键,无法推测
    • 删除合理性:假设缺失是完全随机的(数据录入错误)
    • 影响评估:删除比例控制在可接受范围
  2. Quantity中位数填充:
    • 选择中位数原因:数量可能有极端值(大额批发)
    • 中位数稳健性:不受异常值影响
    • 业务合理性:用”典型订单量”替代缺失
  3. UnitPrice分组填充:
    • 分组逻辑:同一客户购买相似商品,价格水平接近
    • 两步策略:先分组填充,再全局填充
    • 优势:保留客户特定的价格信息

16.4.3 数据质量报告

列表 16.9
# =============================================================================
# 题目:生成数据质量报告
# =============================================================================
# 本任务演示如何生成一份全面的数据质量报告
# 报告包含基本统计、数据分布、业务指标等

# ==================== 打印报告标题 ====================
print('=== 数据质量报告 ===\n')

# ==================== 1. 基本统计信息 ====================
print('1. 数据概览:')
# len(df_clean):总记录数(行数)
print(f'   - 总记录数: {len(df_clean)}')
# .nunique():唯一值数量(有多少个不同客户)
print(f'   - 唯一客户数: {df_clean["CustomerID"].nunique()}')
# .min()和.max():时间范围(从哪天到哪天)
print(f'   - 时间范围: {df_clean["InvoiceDate"].min()}{df_clean["InvoiceDate"].max()}')
print()

# ==================== 2. 数值变量统计 ====================
print('2. 数值变量统计:')
# [['Quantity', 'UnitPrice']]:选择这两列
# .describe():计算描述性统计量(计数、均值、标准差、最小值、分位数、最大值)
print(df_clean[['Quantity', 'UnitPrice']].describe())
# 输出解读:
# - count:非缺失值数量
# - mean:平均值
# - std:标准差(衡量波动性)
# - min:最小值
# - 25%:第一四分位数(25%分位)
# - 50%:中位数
# - 75%:第三四分位数(75%分位)
# - max:最大值
print()

# ==================== 3. 计算总金额 ====================
# Quantity * UnitPrice:计算每笔交易的金额
# 这创建了一个新列TotalAmount
df_clean['TotalAmount'] = df_clean['Quantity'] * df_clean['UnitPrice']
print('3. 交易金额统计:')
# .sum():总交易额(所有交易金额之和)
print(f'   - 总交易额: £{df_clean["TotalAmount"].sum():,.2f}')
# :,.2f:格式化为千分位,保留2位小数,如£1,234,567.89
# .mean():平均交易额
print(f'   - 平均交易额: £{df_clean["TotalAmount"].mean():.2f}')
# .max():最大交易额
print(f'   - 最大交易额: £{df_clean["TotalAmount"].max():.2f}')
print()

# ==================== 4. 客户分析 ====================
# groupby('CustomerID'):按客户ID分组
# .agg({...}):对每列应用不同的聚合函数
customer_stats = df_clean.groupby('CustomerID').agg({
    'TotalAmount': 'sum',  # 总金额:求和
    'InvoiceID': 'count'   # 交易次数:计数
}).rename(columns={'InvoiceID': 'TransactionCount'})  # 重命名列(InvoiceID改为TransactionCount)

print('4. 客户统计(Top 5):')
# .nlargest(5, 'TotalAmount'):选择TotalAmount最大的5个客户
print(customer_stats.nlargest(5, 'TotalAmount'))
# 应用场景:识别VIP客户,进行精准营销

16.5 金融应用案例股票停牌数据处理

16.5.1 场景描述

中国A股市场的停牌机制导致价格序列出现缺失。正确处理这些缺失值对计算收益率、波动率等指标至关重要。

列表 16.10
# =============================================================================
# 题目:股票停牌数据处理
# =============================================================================
# 本任务演示如何处理股票停牌期间的价格缺失
# 停牌是中国A股市场的特殊机制,可能导致连续多日无交易数据

# ==================== 导入必要的库 ====================
import pandas as pd  # Pandas数据分析库
import numpy as np  # NumPy数值计算库
import matplotlib.pyplot as plt  # Matplotlib绘图库

# ==================== 设置中文字体 ====================
plt.rcParams['font.sans-serif'] = ['SimHei']  # 设置黑体字体
plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题

# ==================== 模拟股票价格数据(含停牌) ====================
# 创建日期序列:30个交易日
dates = pd.date_range('2024-01-01', periods=30)
# 生成正常价格序列:随机游走
# np.random.randn(30).cumsum():30个随机数的累积和
# * 0.5:调整波动幅度
prices_normal = 100 + np.random.randn(30).cumsum() * 0.5

# ==================== 人为创建停牌(价格缺失) ====================
# .copy():复制正常价格序列
prices_suspended = prices_normal.copy()
# 第10-14行(索引10:15)设为NaN:模拟第一次停牌5天
prices_suspended[10:15] = np.nan
# 第22-23行(索引22:24)设为NaN:模拟第二次停牌2天
prices_suspended[22:24] = np.nan

# ==================== 创建数据框 ====================
df_stock = pd.DataFrame({
    'Date': dates,
    'Price': prices_suspended,  # 含停牌的价格序列
    'Price_Normal': prices_normal  # 真实价格序列(用于对比)
})

# ==================== 显示数据 ====================
print('股票价格数据(含停牌):')
print(df_stock.head(15))  # 显示前15行,可以看到第一次停牌
print()
print('缺失值统计:')
# .isnull().sum():统计Price列的缺失值数量
print(f'缺失天数: {df_stock["Price"].isnull().sum()}')
# .isnull().mean():计算缺失比例
print(f'缺失比例: {df_stock["Price"].isnull().mean():.2%}')
print()

# ==================== 不同处理策略对比 ====================
# 策略1:前向填充(用前一个有效值填充)
df_stock['ForwardFill'] = df_stock['Price'].fillna(method='ffill')

# 策略2:线性插值(在两个已知点之间连线)
df_stock['LinearInterp'] = df_stock['Price'].interpolate(method='linear')

# 策略3:零填充(通常不推荐)
df_stock['ZeroFill'] = df_stock['Price'].fillna(0)

# ==================== 计算日收益率 ====================
# .pct_change():计算百分比变化(收益率)
# 公式:(当前值 - 前一个值) / 前一个值
df_stock['Return_Normal'] = df_stock['Price_Normal'].pct_change()
df_stock['Return_FF'] = df_stock['ForwardFill'].pct_change()
df_stock['Return_Lin'] = df_stock['LinearInterp'].pct_change()

# ==================== 可视化对比 ====================
# 创建2行1列的子图布局
fig, axes = plt.subplots(2, 1, figsize=(14, 10))

# ==================== 子图1:价格对比 ====================
# 绘制真实价格(绿色实线)
axes[0].plot(df_stock['Date'], df_stock['Price_Normal'], 'g-', label='真实价格(无缺失)', linewidth=2)
# 绘制前向填充价格(蓝色虚线)
axes[0].plot(df_stock['Date'], df_stock['ForwardFill'], 'b--', label='前向填充', linewidth=1.5)
# 绘制线性插值价格(红色虚线,透明度70%)
axes[0].plot(df_stock['Date'], df_stock['LinearInterp'], 'r--', label='线性插值', linewidth=1.5, alpha=0.7)
# 标记停牌期间的真实价格(红色散点)
# df_stock[df_stock['Price'].isnull()]:选择Price为NaN的行
axes[0].scatter(df_stock[df_stock['Price'].isnull()]['Date'],
               df_stock['Price_Normal'][df_stock['Price'].isnull()],
               color='red', s=50, zorder=5, label='停牌期间真实价格')
axes[0].set_ylabel('价格', fontsize=12)
axes[0].set_title('股票停牌数据处理:价格对比', fontsize=14)
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)

# ==================== 子图2:收益率对比 ====================
# 绘制真实收益率(绿色实线)
axes[1].plot(df_stock['Date'], df_stock['Return_Normal'], 'g-', label='真实收益率', linewidth=2)
# 绘制前向填充收益率(蓝色虚线)
axes[1].plot(df_stock['Date'], df_stock['Return_FF'], 'b--', label='前向填充收益率', linewidth=1.5)
# 绘制线性插值收益率(红色虚线)
axes[1].plot(df_stock['Date'], df_stock['Return_Lin'], 'r--', label='线性插值收益率', linewidth=1.5, alpha=0.7)
# 添加零线(黑色细线)
axes[1].axhline(y=0, color='k', linestyle='-', linewidth=0.5)
axes[1].set_xlabel('日期', fontsize=12)
axes[1].set_ylabel('日收益率', fontsize=12)
axes[1].set_title('股票停牌数据处理:收益率对比', fontsize=14)
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)

# ==================== 调整布局并显示 ====================
plt.tight_layout()
plt.show()

# ==================== 误差分析 ====================
# 计算均方误差(MSE):预测值与真实值偏差的平方的平均
# ((ForwardFill - Price_Normal) ** 2):计算偏差的平方
# .mean():求平均
mse_ff = ((df_stock['ForwardFill'] - df_stock['Price_Normal']) ** 2).mean()
mse_lin = ((df_stock['LinearInterp'] - df_stock['Price_Normal']) ** 2).mean()

print('填充误差评估:')
print(f'前向填充MSE: {mse_ff:.4f}')
print(f'线性插值MSE: {mse_lin:.4f}')
# MSE越小,填充效果越好
# 通常前向填充的MSE更小(因为停牌期间价格往往保持不变)

金融应用要点:

  1. 前向填充(Forward Fill):
    • 适用场景:停牌期间价格保持不变的假设
    • 优势:保持价格水平,收益率在复牌时反映真实变化
    • 风险:复牌时的收益率包含了停牌期间的全部变化
  2. 线性插值:
    • 适用场景:假设停牌期间价格持续平滑变化
    • 优势:分散变化到整个停牌期
    • 风险:可能创造不存在的中间价格
  3. 最佳实践:
    • 对于价格序列:使用前向填充
    • 对于收益率序列:直接设为0或NaN(停牌无收益)
    • 保留”停牌”标识,避免计算统计量时误入