6  数据清洗与准备

6.1 引言与学习目标

学习目标

完成本章后,你应该能够:

  • 解释 MCAR、MAR 与 MNAR 的区别,并依据数据生成过程选择删除或插补方案;
  • pandas 实现缺失值、重复值、字符串和异常值处理,并核验处理前后的样本变化;
  • 比较标准化、缩尾与分箱的数学口径及适用边界;
  • 在本地长三角上市公司数据上构造可复现的清洗流水线和质量报告;
  • 区分描述性清洗、预测特征处理与因果识别,避免把清洗规则解释为经济因果证据。

目标—活动/示例—核心练习—答案证据映射

正式目标 学习活动或示例 核心练习 可评分答案证据
区分 MCAR、MAR 与 MNAR 并选择处理边界 小节 6.2.1 的条件独立定义与三情境辨析 习题 6.1 分开识别整行与字段缺失,并逐项判定机制、说明可识别性边界
实现缺失、重复、字符串与异常值处理 缺失检测、去重、映射、小节 6.7.2 与异常值活动 习题 6.1、6.2、6.4、6.5 缺失计数、键唯一性、规范化类别与异常值审计输出
比较标准化、缩尾与分箱口径 小节 6.7.1、离散化及异常值章节的口径比较 习题 6.3、6.5 变换前后统计量、阈值和样本保留数
构造本地数据清洗流水线与质量报告 本地行情、估值与题设情绪表的多源整合 习题 6.7 统一输入合同、日期对齐、质量指标和最终 schema
区分描述、预测与因果清洗 本章数据与推断边界及各方法的时点提示 习题 6.1、6.7 对后向填充、全样本统计量和机制假设给出用途限制,不把清洗结果解释为因果证据

数据与推断边界

本章凡由代码直接写入的短数组、字符串和阈值,均属于“机制演示题设值”,只用于观察 API 行为,不代表真实公司观测,也不据此作经验判断。标明“本地真实数据”的案例才读取 HDF5,并保留证券、日期、单位与字段口径。清洗结果首先是描述性数据产品;若进入预测或因果研究,还必须沿用第 15—19 章的训练期拟合、信息时点与研究设计约束。

在经济与金融分析中,加载、核验、清洗、转换和重排决定了后续模型实际使用的样本。这个过程通常称为数据整理(data wrangling)。不同来源的数据可能采用不同的缺失标记、键、单位和更新时间,因此不能假定读入后的表已经满足分析合同。本章不使用缺少可核查出处的“清洗占工作时间若干百分比”说法;学习重点是如何记录每一步对样本和信息集的影响。

临时的一次性处理若没有保留规则和核验结果,往往难以复查。pandas 与 Python 的组合提供了缺失处理、转换和重排接口;本章将把这些接口组织为可记录输入合同、样本变化和输出检查的清洗步骤。

本章将致力于帮助大家掌握这些至关重要的数据准备工具。我们将涵盖处理缺失数据、识别并移除重复值、使用函数和映射进行数据转换,以及其他关键的数据整理任务。牢固掌握这些技术不仅仅是为了方便,更是产出可靠、有意义分析的先决条件。

6.2 处理缺失数据

6.2.1 理论基础:缺失值处理的数学原理

缺失值的数学表示

设完整数据向量为 \(Y=(Y_1,\ldots,Y_p)\),定义二元响应指示向量 \(R=(R_1,\ldots,R_p)\)

\[ R_j= \begin{cases} 1, & Y_j\text{ 被观测到},\\ 0, & Y_j\text{ 缺失}. \end{cases} \tag{6.1}\]

式 6.1 给出本节后续实现与解释采用的数学关系。

给定一个实际缺失模式 \(R=r\),把完整数据相应拆成已观测分量 \(Y_{\mathrm{obs}}\) 与未观测分量 \(Y_{\mathrm{mis}}\)\(R\) 描述“是否可见”,不等于观测值本身;缺失机制研究的是条件分布 \(P(R\mid Y)\)

缺失值类型

以下 MCAR、MAR 与 MNAR 的条件分布表述沿用 Rubin(1976)《Inference and Missing Data》建立的经典缺失机制框架。三类名称描述的是数据生成机制假设,不是由缺失率或 NaN 外观直接识别出的标签。

  1. 完全随机缺失(MCAR, Missing Completely At Random):响应模式与完整数据独立,

    \[ P(R=r\mid Y_{\mathrm{obs}},Y_{\mathrm{mis}})=P(R=r). \tag{6.2}\]

式 6.2 给出本节后续实现与解释采用的数学关系。

  1. 随机缺失(MAR, Missing At Random):给定当前模式下已经观测到的数据后,缺失概率不再依赖未观测值,

    \[ P(R=r\mid Y_{\mathrm{obs}},Y_{\mathrm{mis}})=P(R=r\mid Y_{\mathrm{obs}}). \tag{6.3}\]

式 6.3 给出本节后续实现与解释采用的数学关系。

  1. 非随机缺失(MNAR, Missing Not At Random):即使给定 \(Y_{\mathrm{obs}}\),响应模式仍依赖 \(Y_{\mathrm{mis}}\),因而一般有

    \[ P(R=r\mid Y_{\mathrm{obs}},Y_{\mathrm{mis}})\ne P(R=r\mid Y_{\mathrm{obs}}). \tag{6.4}\]

式 6.4 给出本节后续实现与解释采用的数学关系。

易混淆概念辨析:三种机制看的是生成过程,不是缺失率大小

  • 文件传输故障以固定小概率随机丢掉记录,且与记录中所有变量无关:MCAR。
  • 企业是否披露某项费用只取决于已观测的行业和规模;给定行业与规模后,不再取决于未披露费用:MAR。
  • 即使控制已观测行业和规模,费用越高的企业越不愿披露该费用:MNAR。

前两类中的独立关系也是模型假设,不能只看含缺失值的数据就自动证明。MNAR 涉及不可见值,通常需要敏感性分析、选择模型、外部验证数据或额外识别假设;简单插补不会把 MNAR 自动变成 MAR。

常见插补方法的数学表示

给定数据 \(\{(x_i, y_i)\}_{i=1}^{n}\),其中部分 \(y_i\) 缺失。插补(imputation)是均值、回归、近邻和时序传播等方法的总称;插值(interpolation)只指利用相邻位置或函数关系估计内部缺口的更窄情形。这里的目标是为声明的缺失位置构造 \(\hat{y}_i\)

  1. 均值插补\[ \hat{y}_i = \bar{y}_{\text{complete}} = \frac{1}{n_c}\sum_{j \in \text{complete}} y_j \]

  2. 线性回归插补\[ \hat{y}_i = \hat{\beta}_0 + \hat{\beta}_1 x_i \] 其中参数通过完整数据估计

  3. 时间序列传播(前向填充):令 \(R_i=1\) 表示 \(y_i\) 已观测,并定义缺失位置 \(i\) 以前最后一个有效位置的集合与索引

    \[ J_i=\{j<i:R_j=1\},\qquad j(i)=\max J_i. \]

    前向填充定义为

    \[ \hat y_i= \begin{cases} y_i, & R_i=1,\\ y_{j(i)}, & R_i=0\text{ 且 }J_i\ne\varnothing,\\ \mathrm{NA}, & R_i=0\text{ 且 }J_i=\varnothing. \end{cases} \]

    因而一段连续缺失值都会继承缺口以前最后一个有效观测;若序列开头不存在历史有效值,则仍保持缺失。pandas 官方 ffill 文档还允许用 limit 限制一次最多传播的连续缺失位置数。

  4. K近邻插补\[ \hat{y}_i = \frac{1}{k}\sum_{j \in N_k(x_i)} y_j \] 其中 \(N_k(x_i)\)\(x_i\)\(k\) 个最近邻

插补误差分析

插补质量只能在原值可知的评价位置上验证,例如从完整样本中人工遮蔽一组位置。令 \(M_{\mathrm{eval}}\) 为这些评价位置,则均方误差定义为

\[ \operatorname{MSE}_{\mathrm{eval}} =\frac{1}{|M_{\mathrm{eval}}|} \sum_{i\in M_{\mathrm{eval}}}(y_i-\hat y_i)^2. \]

真实缺失位置的 \(y_i\) 本来不可见,不能直接进入这个误差公式;对真实任务还需报告评价位置如何抽取,以及它是否近似目标缺失机制。

选择插补方法时需要权衡偏差方差

在经济数据集中,缺失值是一个普遍存在的问题。无论是某个国家未报告特定年份的GDP,某位调查对象拒绝透露其收入,还是传感器未能记录到某个价格,缺失值都是我们工作中必须面对的现实。pandas 的一个核心设计目标,就是让处理缺失数据尽可能地无痛。例如,默认情况下,pandas 对象上的所有描述性统计(如求均值、方差等)都会自动排除缺失数据。

pandas 中,对于数据类型为 float64 的数据,浮点数值 NaN(Not a Number)被用来表示缺失数据。我们称之为一个“哨兵值”(sentinel value):它的出现标志着一个缺失或空值。

核心概念:NaN 的技术本质及其在量化数据中的影响

pandasNumPy 中,NaN (Not a Number) 是一个特殊的浮点数值,遵循 IEEE 754 浮点数标准。理解这一底层机制对于量化分析师至关重要。

  1. 比较悖论NaN 不等于任何值,包括它自身。这意味着 np.nan == np.nan 的结果是 False。这一特性直接导致我们不能使用普通的逻辑等于运算符来检测缺失值,而必须使用 isna()isnull()
  2. 缺失表示取决于 dtype:传统 NumPy int64 和普通 bool 本身都没有缺失哨兵,但不能据此断言加入缺失后都会转成 float64。在当前 pandas 的构造推断中,整数与 None 混合通常得到 float64np.nan,布尔与 None 混合通常得到 object 并保留 None;未来版本也不保证对“不兼容赋值”继续静默改 dtype。需要缺失值时,应在构造阶段显式选择可空 Int64boolean,或者在安装 pyarrow 后选择 int64[pyarrow]bool[pyarrow]

注意:整数列向浮点类型提升的风险

整数标识符若被推断为 float64,可能增加内存占用,并在大整数精确比较或账户对账中造成风险;布尔列若退化为 object,则会失去紧凑布尔表示并使逻辑运算语义更复杂。Int64booleanpandas 的可空扩展类型,缺失统一显示为 pd.NA;Arrow-backed dtype 也可表达缺失,但依赖 pyarrow,且部分第三方库可能要求先转换为 NumPy-backed dtype。数据合同应同时记录列名、dtype、缺失语义和存储后端。

我们通过代码来观察这一行为。

import platform  # 识别运行平台以统一后续本地数据血缘
import pandas as pd                                         # 用 Series/DataFrame 观察缺失哨兵与类型提升
import numpy as np                                          # 提供 NaN 哨兵值以复现缺失传播
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 选择当前平台的规范数据根目录

stock_price_series = pd.Series([1.2, -3.5, np.nan, 0])      # 构造机制演示题设值以观察NaN传播
stock_price_series  # 显示序列内容,观察NaN的表示方式
0    1.2
1   -3.5
2    NaN
3    0.0
dtype: float64

下面的对照直接按目标 dtype 构造序列,不向既有整数列或布尔列赋入不兼容值,因此在当前 pandas 下不触发不兼容赋值警告。传统类型两行说明其“无缺失”合同;含缺失的构造推断行展示当前实际结果;可空与 Arrow-backed 行展示显式缺失合同。

表 6.1 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

import warnings  # 捕获构造阶段警告,验证示例没有依赖不兼容赋值

with warnings.catch_warnings(record=True) as dtype_construction_warnings:  # 审计本段全部 dtype 构造是否产生警告
    warnings.simplefilter('always')  # 不隐藏 FutureWarning 或后端兼容性警告
    traditional_integer_series = pd.Series([1, 2], dtype='int64')  # 传统整数只承载非缺失整数
    traditional_boolean_series = pd.Series([True, False], dtype='bool')  # 普通布尔只承载真或假
    inferred_integer_missing_series = pd.Series([1, None])  # 当前构造推断把整数与None表示为float64和NaN
    inferred_boolean_missing_series = pd.Series([True, None])  # 当前构造推断把布尔与None表示为object
    nullable_integer_series = pd.Series([1, pd.NA], dtype='Int64')  # 显式可空整数保留整数语义
    nullable_boolean_series = pd.Series([True, pd.NA], dtype='boolean')  # 显式可空布尔支持三值逻辑
    arrow_integer_series = pd.Series([1, pd.NA], dtype='int64[pyarrow]')  # Arrow整数以后端位图记录缺失
    arrow_boolean_series = pd.Series([True, pd.NA], dtype='bool[pyarrow]')  # Arrow布尔以后端位图记录缺失
assert not dtype_construction_warnings  # 确认示例没有不兼容赋值或后端警告
dtype_series_by_contract = {'传统整数(无缺失)': traditional_integer_series, '普通布尔(无缺失)': traditional_boolean_series, '整数与None的当前推断': inferred_integer_missing_series, '布尔与None的当前推断': inferred_boolean_missing_series, '可空整数': nullable_integer_series, '可空布尔': nullable_boolean_series, 'Arrow整数': arrow_integer_series, 'Arrow布尔': arrow_boolean_series}  # 汇集八种可比较的数据合同
dtype_comparison_df = pd.DataFrame({'dtype': {name: str(series.dtype) for name, series in dtype_series_by_contract.items()}, '值': {name: repr(series.tolist()) for name, series in dtype_series_by_contract.items()}})  # 将实际dtype和缺失表示整理为审计表
assert list(dtype_comparison_df['dtype']) == ['int64', 'bool', 'float64', 'object', 'Int64', 'boolean', 'int64[pyarrow]', 'bool[pyarrow]']  # 锁定本教材所展示的当前pandas结果
dtype_comparison_df  # 输出传统、推断、可空和Arrow-backed结果供逐行比较
表 6.1: 传统、可空与 Arrow-backed 整数/布尔 dtype 对照
dtype
传统整数(无缺失) int64 [1, 2]
普通布尔(无缺失) bool [True, False]
整数与None的当前推断 float64 [1.0, nan]
布尔与None的当前推断 object [True, None]
可空整数 Int64 [1, <NA>]
可空布尔 boolean [True, <NA>]
Arrow整数 int64[pyarrow] [1, <NA>]
Arrow布尔 bool[pyarrow] [True, <NA>]

isna 方法会返回一个布尔型的 Series,用于指明哪些值是空值。

stock_price_series.isna()                                   # 返回与原序列同索引的 NA 位置布尔标记
0    False
1    False
2     True
3    False
dtype: bool

pandas 中,我们通常沿用 R 语言的习惯,将缺失数据称为 NA,意为 not available(不可用)。在统计应用中,NA 可能表示数据不存在,或者数据存在但未被观测到(例如,由于数据收集问题)。在清洗数据时,对缺失模式本身的分析对于识别数据收集中的问题或潜在的偏差至关重要。

Python 内置的 None 会被 isna() 识别为缺失,但其内部表示是 dtype-dependent:在 object 序列中可能仍是 None,在构造推断得到的浮点序列中通常成为 np.nan,在 Int64boolean 和 Arrow-backed 可空序列中通常规范为 pd.NA,在日期时间序列中则通常成为 NaT。因此,不能只凭输入值是 None 推断存储表示;应同时检查 series.dtype 与缺失掩码。

stock_ticker_series = pd.Series(['600276', np.nan, None, '000001'])  # 构造含NaN和None的股票代码序列
stock_ticker_series  # 核对 object 序列保留两个代码,并将 NaN 与 None 作为两处缺失展示
0    600276
1       NaN
2      None
3    000001
dtype: object
stock_ticker_series.isna()                                  # 确认 object 序列中 None 与 NaN 均被识别为缺失
0    False
1     True
2     True
3    False
dtype: bool

表 6.2 总结了 pandas 中处理缺失数据的主要函数。

表 6.2: 处理 NA 的主要方法
方法 描述
dropna 根据每个标签的值是否存在缺失数据来过滤轴标签,可以设置不同的阈值来容忍不同程度的缺失数据。
fillna 使用某个值或插值方法(如 'ffill''bfill')来填充缺失数据。
isna 返回一个布尔型的 Series,指明哪些值是缺失/NA。
notna isna 的布尔否定形式。

6.2.2 滤除缺失数据

一旦识别出了缺失值,最直接的处理方式往往是将其“滤除”(Filter out)。这在缺失值比例较低,且删除这些数据不会对整体分析产生重大偏差时是非常有效的。虽然使用 notna() 进行手动的布尔索引总是一个选择,但 dropna 方法通常更为便捷。

在一个 Series 上使用它,会返回一个新的 Series,其中仅包含非空数据及其对应的索引值:

stock_rating_series = pd.Series([1, np.nan, 3.5, np.nan, 7])  # 构造含两个缺失值的评级序列
stock_rating_series.dropna()                                # 仅保留三个已观测评级,并沿用原索引标签
0    1.0
2    3.5
4    7.0
dtype: float64

对于 DataFrame 对象,情况会稍微复杂一些,因为你必须决定是删除整行还是整列。默认情况下,dropna 会丢弃任何包含至少一个缺失值的行。

让我们用一个几家公司假设的季度收益数据集来说明这一点:

quarterly_earnings_df = pd.DataFrame([[1., 6.5, 3.], [1., np.nan, np.nan],  # 构造4个中性实体的题设值,含不同缺失模式
                     [np.nan, np.nan, np.nan], [np.nan, 6.5, 3.]])  # 公司丙行全部缺失,用于演示how='all'
quarterly_earnings_df.columns = ['Q1', 'Q2', 'Q3']  # 设置列名为三个季度标识
quarterly_earnings_df.index = ['公司甲', '公司乙', '公司丙', '公司丁']  # 使用不对应真实证券的中性行标签
print('原始数据:')  # 下表保留账户数据中三种 NA 分布,作为行删除前基线
print(quarterly_earnings_df)  # 打印完整的DataFrame查看缺失值分布

print('\n使用 data.dropna() 后的数据:')                           # 提示下一表采用“任一季度缺失即删行”的完整案例口径
print(quarterly_earnings_df.dropna())                       # 剔除含任一季度NA的实体,仅保留三期齐全的公司甲
原始数据:
      Q1   Q2   Q3
公司甲  1.0  6.5  3.0
公司乙  1.0  NaN  NaN
公司丙  NaN  NaN  NaN
公司丁  NaN  6.5  3.0

使用 data.dropna() 后的数据:
      Q1   Q2   Q3
公司甲  1.0  6.5  3.0

可以看到,在执行 dropna() 后,只有“公司甲”这一行被保留,因为它是唯一没有缺失数据的行。“公司甲”至“公司丁”只是机制演示标签,不对应真实证券或公司事实;其余三行都因为包含至少一个 NaN 而被丢弃。

传入 how='all' 将只丢弃那些所有值都为 NA 的行。这对于移除数据集中的占位行非常有用。

quarterly_earnings_df.dropna(how='all')                     # 只剔除三期均为空的公司丙,含部分NA的实体仍保留
Q1 Q2 Q3
公司甲 1.0 6.5 3.0
公司乙 1.0 NaN NaN
公司丁 NaN 6.5 3.0

要以同样的方式丢弃列,可以传入 axis='columns'axis=1

quarterly_earnings_df[4] = np.nan # 添加一个全部为 NA 的列
print('含有全 NA 列的数据:')  # 先展示新增的第4列全为 NA,确立 how='all' 的删除对象
print(quarterly_earnings_df)  # 打印DataFrame查看新增的全NA列

print('\n丢弃全 NA 列:')                                        # 对照结果应仅减少一列,Q1—Q3 与全部公司行仍保留
print(quarterly_earnings_df.dropna(axis='columns', how='all'))  # 删除新增的全NA列,保留Q1—Q3及全部公司行
含有全 NA 列的数据:
      Q1   Q2   Q3   4
公司甲  1.0  6.5  3.0 NaN
公司乙  1.0  NaN  NaN NaN
公司丙  NaN  NaN  NaN NaN
公司丁  NaN  6.5  3.0 NaN

丢弃全 NA 列:
      Q1   Q2   Q3
公司甲  1.0  6.5  3.0
公司乙  1.0  NaN  NaN
公司丙  NaN  NaN  NaN
公司丁  NaN  6.5  3.0

在面板数据分析中,一个常见场景是只保留至少具有若干有效字段的观测。thresh参数用于指定这一最小数量。下面读取四个中国股票指数的真实日行情,再人为遮蔽一部分值以演示数据质量规则;遮蔽只服务于方法教学,不代表原始数据真实缺失。

# 使用本地数据展示 thresh 参数
import pandas as pd                                         # 组装四个指数的日度收盘价面板
import numpy as np                                          # 生成与行情值无关的可复现遮蔽位置
from functools import reduce                                # 将四张日期—指数收盘价子表依次按交易日外连为一个宽面板

# 以全市场指数表为同源底表,避免把跨表缺口误当成行情缺失
index_data = pd.read_hdf(f'{DATA_ROOT}/index/indexes.h5')  # 读取含 symbol、datetime 和 close 的指数日行情表
symbols = ['000001.XSHG', '000300.XSHG', '399001.XSHE', '399006.XSHE']  # 待查询的4只主要指数代码
names = {'000001.XSHG': '上证指数', '000300.XSHG': '沪深300', '399001.XSHE': '深证成指', '399006.XSHE': '创业板指'}  # 指数代码与中文名称的映射关系

df_list = []                                                # 暂存四张一指数一数值列的子表,随后按 datetime 取并集
for sym in symbols:  # 每个来源代码生成一张 datetime—中文指数收盘价子表,确保一列对应一个指数
    temp = index_data[index_data['symbol'] == sym][['datetime', 'close']].copy()  # 筛选当前指数的日期和收盘价并复制一份
    temp.rename(columns={'close': names[sym]}, inplace=True)  # 用中文指数名作值列,为宽表保留品种语义
    temp['datetime'] = pd.to_datetime(temp['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 统一指数日期类型,确保跨品种外连接按同一时间键匹配
    df_list.append(temp)  # 收集当前指数的 datetime—中文收盘价两列子表,供循环后取日期并集

df_panel = reduce(lambda left, right: pd.merge(left, right, on='datetime', how='outer'), df_list)  # 按交易时点取四指数并集,显式保留非共同观测
df_panel.set_index('datetime', inplace=True)                # 将唯一时间键升为行索引,使 thresh 按交易日判定
df_panel = df_panel.sort_index().loc['2023-01-01':'2023-01-20']  # 先固定时序,再取2023年1月前20日的教学窗口
# 为了演示,人为引入缺失值
np.random.seed(42)                                          # 设置随机数种子保证可复现
for col in df_panel.columns:  # 各指数列独立随机遮蔽 30%,避免四列在同一交易日同步缺失
    df_panel.loc[df_panel.sample(frac=0.3).index, col] = np.nan  # 随机将30%的行设为NaN,模拟真实数据中的缺失场景

print('原始面板数据 (部分指数日期数据缺失):')  # 暴露四指数各自30%随机遮蔽后的逐日非空计数差异
print(df_panel)  # 打印完整的面板数据查看缺失分布

print('\n只保留至少有3个非NA值的交易日:')                                # 验证 thresh=3 后每行至少还有三个指数收盘价
print(df_panel.dropna(thresh=3))                            # 每个交易日至少保留四个指数中的三个有效收盘价
原始面板数据 (部分指数日期数据缺失):
                 上证指数      沪深300        深证成指       创业板指
datetime                                               
2023-01-03        NaN        NaN  11117.1303  2356.4239
2023-01-04  3123.5164  3892.9477         NaN  2335.1221
2023-01-05  3155.2162  3968.5782  11332.0103        NaN
2023-01-06  3157.6365  3980.8888  11367.7321  2422.1413
2023-01-09  3176.0845  4013.1196         NaN  2440.3660
2023-01-10  3169.5072  4017.4737  11506.7938  2474.0052
2023-01-11  3161.8376        NaN  11439.4395  2445.9713
2023-01-12  3163.4509  4017.8692  11465.7280        NaN
2023-01-13  3195.3059  4074.3772  11602.3042  2493.1261
2023-01-16        NaN        NaN  11785.7679  2539.5215
2023-01-17  3224.2448  4137.2422         NaN        NaN
2023-01-18        NaN        NaN  11810.6598  2543.8965
2023-01-19        NaN  4156.0077  11913.2639        NaN
2023-01-20  3264.8138  4181.5267         NaN  2585.9628

只保留至少有3个非NA值的交易日:
                 上证指数      沪深300        深证成指       创业板指
datetime                                               
2023-01-04  3123.5164  3892.9477         NaN  2335.1221
2023-01-05  3155.2162  3968.5782  11332.0103        NaN
2023-01-06  3157.6365  3980.8888  11367.7321  2422.1413
2023-01-09  3176.0845  4013.1196         NaN  2440.3660
2023-01-10  3169.5072  4017.4737  11506.7938  2474.0052
2023-01-11  3161.8376        NaN  11439.4395  2445.9713
2023-01-12  3163.4509  4017.8692  11465.7280        NaN
2023-01-13  3195.3059  4074.3772  11602.3042  2493.1261
2023-01-20  3264.8138  4181.5267         NaN  2585.9628

6.2.3 填充缺失数据

我们常常不希望直接滤除缺失数据(因为这可能导致同行其他列的有价值信息一同丢失),而是希望填补这些“漏洞”。这个过程被称为插补(Imputation)。对于大多数目的而言,fillna 方法是完成此项任务的主力函数。

让我们来看一个使用本地上证指数数据的真实世界经济学案例。我们将获取上证指数的收盘价序列。

# 使用本地上证指数数据演示 fillna
import pandas as pd                                         # 处理上证综指时间索引与插补结果
import numpy as np                                          # 在指定位置写入 NaN 以构造可审计缺口

# 获取上证指数 2018Q1 数据;复用本章首次读取的fixed底表,避免再次全量解码
sse_index_series = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 从全指数表投影上证综指,后续仅对该副本遮蔽收盘价
# 转换日期格式:YYYYMMDDHHMMSS -> datetime
sse_index_series['datetime'] = pd.to_datetime(sse_index_series['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 按来源14位整数合同解析时点,避免纳秒误读
sse_index_series.set_index('datetime', inplace=True)        # 用可排序的交易时间承载收盘价序列
sse_index_series = sse_index_series.sort_index().loc['2018-01-01':'2018-03-31']['close']  # 限定2018Q1交易日样本并只保留收盘价

# 为了演示,人为地引入一些缺失值
sse_index_series.iloc[[3, 4, 15, 30]] = np.nan              # 在第3、4、15、30个位置人为设置缺失值以供演示
print('含有缺失值的指数序列:')  # 抽查2018Q1上证综指第4、5个位置的连续缺口及其邻近价格
print(sse_index_series.head(10))  # 打印前10行查看缺失值位置
含有缺失值的指数序列:
datetime
2018-01-02    3348.3259
2018-01-03    3369.1084
2018-01-04    3385.7102
2018-01-05          NaN
2018-01-08          NaN
2018-01-09    3413.8996
2018-01-10    3421.8343
2018-01-11    3425.3449
2018-01-12    3428.9407
2018-01-15    3410.4882
Name: close, dtype: float64

现在,让我们探索填充这些缺失值的不同方法。

6.2.3.1 使用常数填充

最简单的方法是用一个常数(比如0)来填充。

# 构造两列具有不同起点和长度的连续缺口,以比较常数填充的影响
df = pd.DataFrame(np.random.standard_normal((6, 3)))        # 构造6行3列的标准正态随机数DataFrame
df.iloc[2:, 1] = np.nan                                     # 将第2列的第3行起设为NaN
df.iloc[4:, 2] = np.nan                                     # 将第3列的第5行起设为NaN

print('原始 DataFrame:')  # 展示6×3题设矩阵中第2列长缺口与第3列短缺口
print(df)  # 检查第2列自第3行起为 NA、第3列自第5行起为 NA 的两种连续缺口
print('\n使用 df.fillna(0) 后的 DataFrame:')                    # 提示下表把两列中人工设置的六个NA统一替换为0
print(df.fillna(0))                                         # 用常数0替代全部NA;非缺失单元和6×3表形状保持不变
原始 DataFrame:
          0         1         2
0  0.291034 -0.635560 -1.021552
1 -0.161755 -0.533649 -0.005528
2 -0.229450       NaN -1.265119
3  1.091992       NaN  1.193640
4  0.218638       NaN       NaN
5 -1.583294       NaN       NaN

使用 df.fillna(0) 后的 DataFrame:
          0         1         2
0  0.291034 -0.635560 -1.021552
1 -0.161755 -0.533649 -0.005528
2 -0.229450  0.000000 -1.265119
3  1.091992  0.000000  1.193640
4  0.218638  0.000000  0.000000
5 -1.583294  0.000000  0.000000

如果你需要为每一列使用不同的填充值,可以向 fillna 传递一个字典。

df.fillna({1: 0.5, 2: 0})                                   # 第1列缺口填0.5、第2列缺口填0,第0列不指定也无缺口
0 1 2
0 0.291034 -0.635560 -1.021552
1 -0.161755 -0.533649 -0.005528
2 -0.229450 0.500000 -1.265119
3 1.091992 0.500000 1.193640
4 0.218638 0.500000 0.000000
5 -1.583294 0.500000 0.000000

6.2.3.2 使用计算出的统计量(均值/中位数)填充

一种常见的插补策略是用列的均值或中位数来填充缺失值。这种方法保留了数据的中心趋势。

print('使用均值填充后:')  # 检查四个人工缺口是否全部写入2018Q1已观测收盘价均值
print(sse_index_series.fillna(sse_index_series.mean()).head(10))  # 用该序列其余已观测交易日的样本均值填补四个人工缺口
使用均值填充后:
datetime
2018-01-02    3348.325900
2018-01-03    3369.108400
2018-01-04    3385.710200
2018-01-05    3334.863755
2018-01-08    3334.863755
2018-01-09    3413.899600
2018-01-10    3421.834300
2018-01-11    3425.344900
2018-01-12    3428.940700
2018-01-15    3410.488200
Name: close, dtype: float64

6.2.3.3 插值方法(前向与后向填充)

对于时间序列数据,使用 ffill(前向填充)或 bfill(后向填充)非常普遍。ffill 会用最后一个有效观测值向前传播填充,这对于许多变化不频繁的经济序列来说是一个合理的假设。

理论深度:ffillbfill 在金融计量中的经济学直觉

在处理经济和金融时间序列时,选择插值方法必须严格遵循数据的生成逻辑因果律

  1. ffill (前向填充)
    • 逻辑:假设在新的交易或信息披露之前,变量维持其最后一个已知状态。
    • 场景:这是金融回测中最常用的方法。例如,股票的日度收盘价或定期公布的宏观利率。在两次更新之间,市场往往以“最新有效信息”作为定价基准。
    • 优势:避免了“前瞻偏差”(Look-ahead bias),即在 \(t\) 时刻不慎使用了 \(t+1\) 时刻才可能获知的信息。
  2. bfill (后向填充)
    • 逻辑:使用未来的观测值来填补历史空白。
    • 场景:这在实时交易决策中是绝对禁止的。但在事后学术分析中较为常见,例如当我们需要对齐来自不同国家、披露频率不同但具有同步性假设的宏观指标时。
    • 警告:对于高频量化策略,bfill 会把未来才观察到的数值填入过去,造成前视泄漏(look-ahead leakage)并虚高回测业绩;这不同于因只保留存续样本而产生的幸存者偏差。
filled_ffill = sse_index_series.ffill()                      # 每个缺口只使用其前一有效交易日收盘价,不引入未来信息
print('前向填充后的序列:')  # 核对连续缺口内两日均继承缺口之前最后收盘价
print(filled_ffill.head(10))  # 打印前10行查看缺失值已被前一个有效值填充
前向填充后的序列:
datetime
2018-01-02    3348.3259
2018-01-03    3369.1084
2018-01-04    3385.7102
2018-01-05    3385.7102
2018-01-08    3385.7102
2018-01-09    3413.8996
2018-01-10    3421.8343
2018-01-11    3425.3449
2018-01-12    3428.9407
2018-01-15    3410.4882
Name: close, dtype: float64

你也可以使用 limit 参数来限制连续填充的期数。

df.ffill(limit=2)                                           # 每列每段最多向后传播两个位置,超出上限的连续NA仍保留
0 1 2
0 0.291034 -0.635560 -1.021552
1 -0.161755 -0.533649 -0.005528
2 -0.229450 -0.533649 -1.265119
3 1.091992 -0.533649 1.193640
4 0.218638 NaN 1.193640
5 -1.583294 NaN 1.193640

前向和后向传播应直接使用 .ffill().bfill()fillna(method=...) 自 pandas 2.1 起已弃用。表 6.3 提供了 fillna 用固定值填补时的常用参数参考。

表 6.3: fillna 函数的参数
参数 描述
value 用于填充缺失值的标量值或类字典对象。
axis 填充的轴('index''columns');默认为 'index'
limit 使用固定 value 时,沿指定轴最多填补的缺失项总数;.ffill(limit=...).bfill(limit=...) 则限制每段连续传播长度。

6.3 数据转换

到目前为止,我们专注于处理缺失数据。过滤、清洗和其他转换是另一类至关重要的操作。

6.3.1 移除重复项

由于数据收集或合并过程中的错误等多种原因,DataFrame 中可能会出现重复行。让我们考虑一个假设的交易数据集。

execution_trade_df = pd.DataFrame({                         # 构造包含重复交易记录的DataFrame
    'trade_id': ['T001', 'T002', 'T003', 'T002', 'T004'],  # 交易编号,T002出现了两次
    'entity': ['Entity_A', 'Entity_B', 'Entity_C', 'Entity_B', 'Entity_D'],  # 使用不对应真实证券的中性实体标签
    'volume': [100, 50, 200, 50, 150]  # T002 两行均为 50,形成可按全行识别的重复成交量证据
})  # 五行题设记录包含一组完全重复键值,其余三组记录均唯一
execution_trade_df  # 确认仅 T002 的实体与成交量组合重复一次,为全行去重提供输入证据
trade_id entity volume
0 T001 Entity_A 100
1 T002 Entity_B 50
2 T003 Entity_C 200
3 T002 Entity_B 50
4 T004 Entity_D 150

DataFrameduplicated 方法会返回一个布尔 Series,指示每一行是否是前面某行的重复。

execution_trade_df.duplicated()                             # 标记与先前完整成交记录相同的行
0    False
1    False
2    False
3     True
4    False
dtype: bool

drop_duplicates 方法返回一个移除了重复行的新 DataFrame

execution_trade_df.drop_duplicates()                        # 按全部字段去重,默认保留首次出现的成交
trade_id entity volume
0 T001 Entity_A 100
1 T002 Entity_B 50
2 T003 Entity_C 200
4 T004 Entity_D 150

默认情况下,这两种方法都考虑所有列。你可以指定一个列的子集来检测重复项。例如,如果我们只关心重复的 order_id

execution_trade_df.drop_duplicates(subset=['trade_id'])     # 仅以成交编号判重,忽略其他字段差异
trade_id entity volume
0 T001 Entity_A 100
1 T002 Entity_B 50
2 T003 Entity_C 200
4 T004 Entity_D 150

duplicateddrop_duplicates 默认保留第一次出现的组合。传入 keep='last' 将保留最后一次出现的组合。这在某些情况下很有用,例如,当最新的条目是最新更新的数据时。

execution_trade_df.drop_duplicates(['trade_id'], keep='last')  # 同一成交编号只留最后一版记录
trade_id entity volume
0 T001 Entity_A 100
2 T003 Entity_C 200
3 T002 Entity_B 50
4 T004 Entity_D 150

6.3.2 使用函数或映射转换数据

对于许多数据集,你可能希望根据某列中的值进行转换。下面使用不对应真实企业的确定性实体和类别标签,只演示映射机制。

category_mapping_df = pd.DataFrame({'entity': ['Entity_A', 'Entity_B', 'Entity_A',  # 构造包含重复实体的确定性标签
                              'Entity_C', 'Entity_D', 'Entity_A',  # 第二组中性实体标签
                              'Entity_C', 'Entity_E', 'Entity_F'],  # 第三组中性实体标签
                     'metric_value': [50, 20, 52, 300, 80, 51, 305, 40, 10]})  # 设置仅供 API 演示的无单位题设值
category_mapping_df  # 抽查实体与题设数值,不赋予公司、行业或价格含义
entity metric_value
0 Entity_A 50
1 Entity_B 20
2 Entity_A 52
3 Entity_C 300
4 Entity_D 80
5 Entity_A 51
6 Entity_C 305
7 Entity_E 40
8 Entity_F 10

假设需要为每个实体添加一个题设类别,可以定义如下映射关系:

entity_to_category = {                                      # 定义中性实体到题设类别的映射字典
  'entity_a': 'category_1',  # 实体A映射到类别1
  'entity_b': 'category_1',  # 实体B映射到类别1
  'entity_c': 'category_2',  # 实体C映射到类别2
  'entity_d': 'category_3',  # 实体D映射到类别3
  'entity_e': 'category_3',  # 实体E映射到类别3
  'entity_f': 'category_2'  # 实体F映射到类别2
}  # 固化六个中性实体的类别映射,供后续 Series.map 逐值替换

Series 上的 map 方法接受一个函数或一个类字典对象来执行值的转换。

entity_key = category_mapping_df['entity'].str.lower()  # 把实体标签规范为映射字典使用的小写键
category_mapping_df['category'] = entity_key.map(entity_to_category)  # 按规范键回填三个题设类别
category_mapping_df  # 显示添加类别列后的完整 DataFrame
entity metric_value category
0 Entity_A 50 category_1
1 Entity_B 20 category_1
2 Entity_A 52 category_1
3 Entity_C 300 category_2
4 Entity_D 80 category_3
5 Entity_A 51 category_1
6 Entity_C 305 category_2
7 Entity_E 40 category_3
8 Entity_F 10 category_2

我们也可以向 map 传递一个函数。

def get_category(entity):                                   # 把实体标签规范为小写键并返回题设类别
    return entity_to_category[entity.lower()]               # 返回确定性映射结果

category_mapping_df['entity'].map(get_category)             # 把实体标签逐项映射为规范类别
0    category_1
1    category_1
2    category_1
3    category_2
4    category_3
5    category_1
6    category_2
7    category_3
8    category_2
Name: entity, dtype: object

使用 map 是执行元素级转换和其他数据清洗操作的一种便捷方式。

6.3.3 替换值

使用 fillna 填充缺失数据是值替换的一个特例。replace 方法提供了一种更简单、更灵活的方式来完成此任务。考虑一个 Series,其中某些数值(例如 -999)被用作缺失数据的哨兵值。这在旧的统计软件或调查数据中很常见。

market_sentiment_series = pd.Series([0.5, -999., 0.8, -999., -1000., 0.1])  # 构造含哨兵值-999和-1000的市场情绪序列
market_sentiment_series  # 显示序列查看哨兵值位置
0       0.5
1    -999.0
2       0.8
3    -999.0
4   -1000.0
5       0.1
dtype: float64

我们可以使用 replace 将这些哨兵值替换为 np.nan

market_sentiment_series.replace(-999, np.nan)               # 将单一历史缺失哨兵 -999 显式转为 NA
0       0.5
1       NaN
2       0.8
3       NaN
4   -1000.0
5       0.1
dtype: float64

要一次性替换多个值,可以传递一个列表。

market_sentiment_series.replace([-999, -1000], np.nan)      # 把两种旧编码统一归入同一缺失语义
0    0.5
1    NaN
2    0.8
3    NaN
4    NaN
5    0.1
dtype: float64

要为每个值使用不同的替换,可以传递一个替换值列表或一个字典。

market_sentiment_series.replace({-999: np.nan, -1000: 0})   # 区分哨兵含义:-999 为缺失,-1000 按题设改为零
0    0.5
1    NaN
2    0.8
3    NaN
4    0.0
5    0.1
dtype: float64

6.3.4 重命名轴索引

Series 中的值一样,轴标签也可以通过函数或映射进行转换,以产生新的、标签不同的对象。

quarterly_regional_sales_df = pd.DataFrame(np.arange(12).reshape((3, 4)),  # 构造3行4列的季度区域销售数据
                    index=['Shanghai', 'Beijing', 'Guangdong'],  # 设置行索引为三个省市名称
                    columns=['Q1', 'Q2', 'Q3', 'Q4'])       # 设置列名为四个季度标识
quarterly_regional_sales_df  # 展示3个地区行与 Q1—Q4 列,作为轴标签变换的基准 schema
Q1 Q2 Q3 Q4
Shanghai 0 1 2 3
Beijing 4 5 6 7
Guangdong 8 9 10 11

Series 一样,轴的 Index 对象也有一个 map 方法。

transform = lambda x: x.upper()                             # 转换为大写
quarterly_regional_sales_df.index.map(transform)            # 返回 SHANGHAI、BEIJING、GUANGDONG 新标签但不改写原数据轴
Index(['SHANGHAI', 'BEIJING', 'GUANGDONG'], dtype='object')

你可以将转换后的索引赋回给 index 属性,从而原地修改 DataFrame

实证建议:原地操作 vs. 返回新对象

pandas 中的许多方法,如 replacefillnarename,默认情况下会返回新对象,而不会修改原始数据。这通常是好的实践,因为它能防止意外的数据修改。

提高代码健壮性:优先使用返回新对象的方法

你通常可以通过将结果重新赋给原对象来进行原地修改,例如 data.index = data.index.map(transform)。有些方法也提供了 inplace=True 参数,但 pandas 社区现在越来越不鼓励使用它,而是推荐显式地重新赋值,这样可以使代码的“纯函数”特性更明显,也更利于调试和链式操作(Method Chaining)。

quarterly_regional_sales_df.index = quarterly_regional_sales_df.index.map(transform)  # 将行索引全部转为大写并重新赋值
quarterly_regional_sales_df  # 显示修改后的DataFrame确认索引已变为大写
Q1 Q2 Q3 Q4
SHANGHAI 0 1 2 3
BEIJING 4 5 6 7
GUANGDONG 8 9 10 11

如果你想创建数据集的一个转换后版本而不修改原始数据,rename 方法很有用。

quarterly_regional_sales_df.rename(index=str.title, columns=str.upper)  # 行名改为标题格式,季度列名统一大写
Q1 Q2 Q3 Q4
Shanghai 0 1 2 3
Beijing 4 5 6 7
Guangdong 8 9 10 11

rename 也可以与类字典对象一起使用,为轴的子集提供新的标签。

quarterly_regional_sales_df.rename(index={'SHANGHAI': 'ZHEJIANG'},  # 将行索引中SHANGHAI重命名为ZHEJIANG
             columns={'Q3': 'peekaboo'})                    # 将列名Q3重命名为peekaboo
Q1 Q2 peekaboo Q4
ZHEJIANG 0 1 2 3
BEIJING 4 5 6 7
GUANGDONG 8 9 10 11

6.4 离散化与分箱

在经济分析中,将连续数据离散化为“箱”(bins)通常很有用。例如,我们可能将个体按年龄段或收入五分位数进行分组。pandas 提供了 cutqcut 函数来完成这个任务。

假设我们有一组研究对象的年龄数据。

research_subject_ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]  # 12个研究对象的年龄数据,跨越多个年龄段

让我们将这些年龄分为几组:18-25岁,26-35岁,36-60岁,以及61岁及以上。我们可以使用 pd.cut

bins = [18, 25, 35, 60, 100]                                # 定义4个年龄组的分箱边界:18-25、26-35、36-60、61+
subject_age_categories = pd.cut(research_subject_ages, bins)  # 将连续年龄数据按边界分入各箱
subject_age_categories  # 核对 12 个年龄均落入右闭的四个区间,边界 25 归入第一箱
[(18, 25], (18, 25], (18, 25], (25, 35], (18, 25], ..., (25, 35], (60, 100], (35, 60], (35, 60], (25, 35]]
Length: 12
Categories (4, interval[int64, right]): [(18, 25] < (25, 35] < (35, 60] < (60, 100]]

返回的对象是一个特殊的 Categorical 类型。输出描述了 pandas.cut 计算出的箱。括号 ( 表示该侧是开区间(不包含),而方括号 ] 表示该侧是闭区间(包含)。你可以通过传递 right=False 来改变哪一侧是闭区间。

我们可以使用 value_counts 来获取每个箱中的计数。

subject_age_categories.value_counts()                       # 计数四个年龄箱的观测数,检查分箱是否留下空组
(18, 25]     5
(25, 35]     3
(35, 60]     3
(60, 100]    1
Name: count, dtype: int64

你可以通过向 labels 选项传递一个列表或数组来覆盖默认的基于区间的标签。

age_labels = ['Youth', 'YoungAdult', 'MiddleAged', 'Senior']  # 为4个年龄组定义自定义英文标签
pd.cut(research_subject_ages, bins, labels=age_labels)      # 将连续数据分箱离散化
['Youth', 'Youth', 'Youth', 'YoungAdult', 'Youth', ..., 'YoungAdult', 'Senior', 'MiddleAged', 'MiddleAged', 'YoungAdult']
Length: 12
Categories (4, object): ['Youth' < 'YoungAdult' < 'MiddleAged' < 'Senior']

另一个密切相关的函数 pandas.qcut,是根据样本分位数对数据进行分箱。它的目标是创建样本数尽量接近的组,例如五分位数(5组)或十分位数(10组),这是收入不平等研究中的常见做法;样本数不能整除组数或分位点并列时,各组不一定严格等频。若分位点产生重复边界,默认 duplicates='raise' 会抛出 ValueError;只有显式设置 duplicates='drop' 才会删除重复边界,此时实际组数可能少于请求组数。pd.cut 则通常不会产生样本数接近的箱。

下面使用本地财务报表中的2023年第四季度总资产,利用 pd.qcut 把公司划分为五个分位规模组,目标是使各组样本数尽量接近。分位数组是相对排名,不表示监管或会计口径下的企业规模分类。

# 读取公司—季度财务底表,本例只用报告期和总资产构造横截面
indicator_data = pd.read_hdf(  # 在table节点上把季度与列投影下推到磁盘读取端
    f'{DATA_ROOT}/stock/financial_statement.h5',  # 定位本地季度财务报表
    where="quarter == '2023q4'",  # 只读取本例需要的2023年第四季度横截面
    columns=['order_book_id', 'quarter', 'total_assets']  # 只保留分箱所需证券、季度与总资产字段
)  # 完成选择性读取而非解码三百余列全表
# 筛选 2023 年第四季度的资产总额
assets = indicator_data[indicator_data['quarter'] == '2023q4'][['order_book_id', 'total_assets']].dropna()  # 排除证券代码或总资产缺失的公司,形成可排序的2023Q4横截面
assets = assets.rename(columns={'total_assets': 'assets', 'order_book_id': 'symbol'})  # 把来源字段改为分箱代码使用的证券与资产列名

# 使用qcut将公司按资产规模分为五组
quintiles = pd.qcut(assets['assets'], 5, labels=False, retbins=True, precision=2)  # 按分位数将数据分箱
print('每个规模分组中的公司数量:')  # 检查2023Q4总资产五分位组是否近似等频
print(quintiles[0].value_counts())                          # 统计各分位组中的公司数量
print('\n分箱的边界值 (资产额):')                                    # 输出六个总资产切点,明确组号只是样本内相对排名
print(quintiles[1])  # 打印五分位数的实际资产额分界点
每个规模分组中的公司数量:
assets
4    1068
0    1068
1    1068
3    1068
2    1068
Name: count, dtype: int64

分箱的边界值 (资产额):
[1.17128966e+08 1.67350086e+09 2.92135615e+09 5.51758446e+09
 1.48657108e+10 4.46970790e+13]

你也可以传递自定义的分位数(0到1之间的数字)。

# 使用自定义分位数与 pandas.qcut
pd.qcut(assets['assets'], [0, 0.1, 0.5, 0.9, 1.]).value_counts()  # 核对10%、40%、40%、10%非等宽分位组的公司数
assets
(1178619453.758, 3863521155.57]        2136
(3863521155.57, 36461422788.084]       2136
(117128966.339, 1178619453.758]         534
(36461422788.084, 44697079000000.0]     534
Name: count, dtype: int64

6.5 检测和过滤异常值

极端观测可能对模型估计产生不成比例的影响,但“数值极端”不等于“数据错误”。IQR、Z-score 和固定阈值都只是生成待核查候选标记的计算规则;候选值可能来自录入或接口错误、除权复权口径不一致等公司行为,也可能是真实的市场尾部事件。是否删除、替换或缩尾,必须结合原始公告、行情口径和研究目标判断。

下面使用本地上证综指的日百分比变化。金融收益常呈现厚尾,但是否偏离特定理论分布仍需正式检验;本节只演示异常值诊断与过滤方法。

import matplotlib.pyplot as plt                             # 设置上证综指收益时序图的画布、轴签和字体
import seaborn as sns                                       # 用统计折线层标出日收益尖峰的真实日期位置

plt.rcParams['font.sans-serif'] = ['Source Han Serif SC', 'Microsoft YaHei', 'SimHei']  # 设置中文字体显示
plt.rcParams['axes.unicode_minus'] = False                  # 设置负号正确显示

# 获取上证指数历史数据;继续复用本章首次读取的fixed底表,不产生第三份全表对象
sse_price_index = index_data[index_data['symbol'] == '000001.XSHG'].copy()  # 独立取出上证综指路径,缩尾改写不会污染其他指数行
sse_price_index['datetime'] = pd.to_datetime(sse_price_index['datetime'].astype(str), format='%Y%m%d%H%M%S')  # 依来源时间格式解析为交易时点
sse_price_index.set_index('datetime', inplace=True)         # 以交易日索引计算相邻收盘点位收益
sse_price_index = sse_price_index.sort_index().loc['2010-01-01':'2024-01-01']['close']  # 在声明的2010—2024样本窗口取 close 列

sse_daily_returns = sse_price_index.pct_change(fill_method=None).dropna() * 100  # 不跨缺口填补,并以百分比表示有效日收益
sse_return_df = pd.DataFrame(sse_daily_returns, columns=['SSE_Ret'])  # 将收益率序列转为单列DataFrame便于后续分析
图 6.1
# 创建图表以可视化收益率和潜在的异常值
plt.figure(figsize=(12, 6))
sns.lineplot(x=sse_return_df.index, y=sse_return_df['SSE_Ret'], label='上证指数日收益率', linewidth=1)  # 按交易日连线,定位收益率序列中的异常尖峰
plt.title('上证指数日收益率 (%)')  # 标明图中变量是百分比收益率而非指数点位
plt.ylabel('日百分比变化')  # 用百分比单位约束纵轴解释口径
plt.xlabel('日期')  # 保留事件发生的真实时间位置以便回查
plt.grid(True)  # 加入参考网格,便于比较尖峰与阈值的距离
plt.show()  # 输出异常值诊断图供后续阈值筛选核对

图 6.1 中的尖峰是需要回查的候选日期。它们与 2020 年 3 月市场剧烈波动在时间上重合,但仅凭图形不能判定数据错误,也不能识别事件对收益的因果效应。

假设我们先用“绝对收益率超过 5%”生成候选日期。这个 5% 是教学用固定筛选规则,不是由概率模型自动证明的异常边界。

returns = sse_return_df['SSE_Ret']  # 保留日期索引与指数收益值,供绝对值 5% 阈值回查异常日
returns[returns.abs() > 5]  # 筛选绝对值超过5%的极端收益率记录
Series([], Name: SSE_Ret, dtype: object)

要选择所有值超过5或-5的行,你可以在一个布尔 DataFrame 上使用 any 方法。(这对于多列 DataFrame 更为相关)。

处理异常值的一种常用技术是“封顶”(capping),也称为 Winsorizing,即将在某个范围之外的值限制在该范围的边界上。例如,我们可以将所有收益率限制在-5%到+5%之间。

核心方法:金融计量中的 Winsorization (缩尾处理)

Winsorizing(缩尾处理)是减弱尾部观测影响的一种可选稳健化方案,不是量化研究的强制步骤,也不是 OLS 的数学必要条件。OLS 的平方损失确实会放大大残差的影响,但研究者还可以核查源数据、改变模型、使用稳健标准误或稳健估计,并报告保留原值的基准结果。

  1. 计算影响与判断边界:普通最小二乘法 (OLS) 以平方残差计入损失,因此大残差可能显著改变系数;这说明需要影响诊断,却不自动授权改写观测。应先区分源数据错误、拆分分红等公司行为或复权问题、以及可交易市场中真实发生的尾部事件。
  2. 与剔除法的对比
    • 剔除 (Discarding):直接删除异常样本,这会导致样本量的减少(Data Loss),且在平衡面板数据中可能破坏时间序列的完整性。
    • 缩尾 (Winsorizing):将边界外的数值改写为指定分位点或业务阈值。它保留行数,却不保留原始幅度;因此必须另存原值、处理后值、规则、阈值、处理时间和理由,不能把缩尾后的值当成原始事实。
  3. 研究治理:对于 A 股财报因子或截面收益,可把 1%/99% 等分位点作为候选方案,但不能据此自动识别操纵、错报或“非理性噪音”。预测或回测任务只能在训练期拟合分位点、IQR 或均值/标准差,然后把冻结阈值应用到验证期和测试期;报告应比较不处理、不同阈值、删除与缩尾等敏感性结果。
print('原始数据的摘要统计:')  # 保留封顶前的极值、分位数与标准差作比较基线
print(sse_return_df.describe())  # 显示封顶前的描述性统计信息

# 在独立副本中把指数收益限制在 ±5%,原始表保持不变以支持回溯
is_fixed_threshold_candidate = sse_return_df['SSE_Ret'].abs() > 5  # 标记需要来源核查的固定阈值候选日
sse_return_capped_df = sse_return_df.clip(lower=-5, upper=5)  # 只改写处理副本,保留原始收益幅度
fixed_threshold_audit_df = pd.DataFrame({'原值': sse_return_df.loc[is_fixed_threshold_candidate, 'SSE_Ret'], '处理后值': sse_return_capped_df.loc[is_fixed_threshold_candidate, 'SSE_Ret'], '规则': '固定±5%候选边界'})  # 记录逐日原值、处理值与规则供审计
assert sse_return_df.loc[is_fixed_threshold_candidate, 'SSE_Ret'].abs().gt(5).all()  # 核验原始候选值未被原位覆盖
assert sse_return_capped_df['SSE_Ret'].abs().le(5).all()  # 核验处理副本严格遵守固定边界

print('\n在 +/- 5% 处封顶后的摘要统计:')                              # 验证最小/最大收益已改为±5%,同时观察方差如何收缩
print(sse_return_capped_df.describe())  # 显示处理副本的描述性统计以对比变化
fixed_threshold_audit_df.head()  # 回读前五条审计记录,保留真实原值与改写证据
原始数据的摘要统计:
       SSE_Ret
count        0
unique       0
top        NaN
freq       NaN

在 +/- 5% 处封顶后的摘要统计:
       SSE_Ret
count        0
unique       0
top        NaN
freq       NaN
原值 处理后值 规则

clip 只作用于独立副本,原始 sse_return_df 与逐日审计表仍可用于事件回查。固定 ±5% 规则不需要拟合;若边界来自样本分位数、IQR 或 Z-score,则进入预测流程前必须只用训练期估计并冻结参数。

6.6 排列与随机抽样

随机重排一个 SeriesDataFrame 的行(排列)对于许多统计程序至关重要,例如自助法(bootstrapping)和为模型验证创建训练/测试集。numpy.random.permutation 函数是实现这一目标的直接方法。用你想要排列的轴的长度调用它,会产生一个整数数组,表示新的顺序。

random_sample_matrix_df = pd.DataFrame(np.arange(5 * 4).reshape((5, 4)))  # 构建5行4列的整数矩阵DataFrame用于演示抽样
random_sample_matrix_df  # 展示4×4标准正态矩阵,为后续无放回行抽样保留原顺序基线
0 1 2 3
0 0 1 2 3
1 4 5 6 7
2 8 9 10 11
3 12 13 14 15
4 16 17 18 19
perm_indices = np.random.permutation(5)                     # 生成0-4的随机排列作为新的行索引顺序
perm_indices  # 核对 0—4 各位置恰出现一次,使无放回重排行数保持不变
array([4, 2, 3, 0, 1])

这个整数数组可以用于基于 iloc 的索引或 take 函数。

random_sample_matrix_df.take(perm_indices)  # 按随机排列的索引顺序重新排列DataFrame的行
0 1 2 3
4 16 17 18 19
2 8 9 10 11
3 12 13 14 15
0 0 1 2 3
1 4 5 6 7

要选择一个不放回的随机子集(一行不能出现多次),你可以使用 sample 方法。

random_sample_matrix_df.sample(n=3)  # 不放回地随机抽取3行数据
0 1 2 3
1 4 5 6 7
3 12 13 14 15
0 0 1 2 3

要生成一个有放回的样本(允许重复选择),可以传递 replace=True。这是统计学中自助法(bootstrap)的基础。

credit_rating_series = pd.Series([5, 7, -1, 6, 4])          # 五个题设评级作为自助抽样总体,负值也保留为可抽取观测
credit_rating_series.sample(n=10, replace=True)  # 有放回地抽取10个样本,允许重复选取(自助法基础)
0    5
0    5
0    5
3    6
2   -1
2   -1
0    5
2   -1
2   -1
0    5
dtype: int64

6.7 计算指标/虚拟变量

对于统计建模和机器学习而言,另一个关键的转换是将分类变量转换为“虚拟”(dummy)或“指标”(indicator)矩阵。这种技术,也称为独热编码(one-hot encoding),会为原始分类列中的每个不同值创建一个新列,用1和0表示该值的存在与否。

核心工具:计量经济学中的虚拟变量 (Dummy Variables)

虚拟变量是量化研究从“定性”跨越到“定量”的桥梁。

  1. 定性效应捕捉:它们允许我们将分类数据(非数值)纳入回归模型。例如,使用一个 0-1 变量代表“是否属于长三角地区”,以捕捉区域性经济政策对上市公司的额外溢价。
  2. 固定效应模型:在面板数据 (Panel Data) 分析中,我们通过引入年份虚拟变量或行业虚拟变量来控制“随时间变化但个人相同”或“行业内共性”的无法观测因素。
  3. 独热编码 (One-Hot Encoding)pandas.get_dummies 实现的逻辑与此一致。它确保了分类标签在数学计算中不会被错误地赋予大小关系(例如,若用 1, 2, 3 代表行业,回归模型会误认为行业 3 的权重是行业 1 的三倍;而虚拟变量则消除了这种量纲误区)。

让我们看一个简单的例子。

category_key_df = pd.DataFrame({'key': ['b', 'b', 'a', 'c', 'a', 'b'],  # 构建含分类列key的DataFrame
                   'data1': range(6)})  # 添加数值列data1作为原始数据
pd.get_dummies(category_key_df['key'])                      # 将 a、b、c 三个类别展开为互斥的布尔指标列
a b c
0 False True False
1 False True False
2 True False False
3 False False True
4 True False False
5 False True False

在某些情况下,你可能希望在指标 DataFrame 的列名中添加一个前缀,以避免合并时出现列名冲突。prefix 参数可以做到这一点。

dummies = pd.get_dummies(category_key_df['key'], prefix='key')  # 为三个类别列增加 key_ 前缀,避免连接后重名
df_with_dummy = category_key_df[['data1']].join(dummies)    # 按索引连接数据表
df_with_dummy  # 核对 data1 与 key_a、key_b、key_c 三列哑变量按原索引对齐
data1 key_a key_b key_c
0 0 False True False
1 1 False True False
2 2 True False False
3 3 False False True
4 4 True False False
5 5 False True False

更复杂的场景是单个观测同时属于多个类别,并以分隔符连接。下面从本地上市公司基本信息选择行业、省份和板块三个真实分类字段,构造一个仅为演示字符串拆分而存在的复合列;三个组成字段的值仍可回查原表。

# 读取公司基础表,保留行业、省份和板块的可追溯真实分类
stock_basic_clean_df = pd.read_hdf(f'{DATA_ROOT}/stock/stock_basic_data.h5')
# 仅拼接原表中的三个标签字段,用可核验类别演示多标签拆分
multi_label_stock_concepts = (
    stock_basic_clean_df.loc[
        stock_basic_clean_df['order_book_id'].isin(
            ['600000.XSHG', '600104.XSHG', '600276.XSHG', '600926.XSHG', '002415.XSHE']
        ),
        ['order_book_id', 'industry_name', 'province', 'board_type'],
    ]
    .dropna()
    .assign(concepts=lambda x: x[['industry_name', 'province', 'board_type']]
            .astype(str).agg('|'.join, axis=1))
    .rename(columns={'order_book_id': 'symbol'})
    [['symbol', 'concepts']]
)

print(multi_label_stock_concepts)  # 核对一行一证券及 industry、province、board 三标签仍以管道符共存

# 使用 str.get_dummies 处理管道分隔的概念
dummies = multi_label_stock_concepts['concepts'].str.get_dummies('|')  # 将管道符分隔的多标签字符串转为独立的哑变量列
print('\n生成的虚拟变量:')                                         # 核对行业、省份与板块标签已各自展开为0/1列
print(dummies.head())  # 显示生成的哑变量矩阵前5行

# 将虚拟变量合并回原数据
stock_with_dummies = multi_label_stock_concepts.join(dummies.add_prefix('Concept_'))  # 拼接并为哑变量列添加Concept_前缀
stock_with_dummies.iloc[0]                                  # 核对首只证券的三个原标签各激活一个 Concept_ 指标列
           symbol                        concepts
1035  002415.XSHE  计算机、通信和其他电子设备制造业|浙江省|MainBoard
3075  600000.XSHG            货币金融服务|上海市|MainBoard
3166  600104.XSHG             汽车制造业|上海市|MainBoard
3320  600276.XSHG             医药制造业|江苏省|MainBoard
3899  600926.XSHG            货币金融服务|浙江省|MainBoard

生成的虚拟变量:
      MainBoard  上海市  医药制造业  江苏省  汽车制造业  浙江省  计算机、通信和其他电子设备制造业  货币金融服务
1035          1    0      0    0      0    1                 1       0
3075          1    1      0    0      0    0                 0       1
3166          1    1      0    0      1    0                 0       0
3320          1    0      1    1      0    0                 0       0
3899          1    0      0    0      0    1                 0       1
symbol                                         002415.XSHE
concepts                    计算机、通信和其他电子设备制造业|浙江省|MainBoard
Concept_MainBoard                                        1
Concept_上海市                                              0
Concept_医药制造业                                            0
Concept_江苏省                                              0
Concept_汽车制造业                                            0
Concept_浙江省                                              1
Concept_计算机、通信和其他电子设备制造业                                 1
Concept_货币金融服务                                           0
Name: 1035, dtype: object

一个在统计应用中很有用的技巧是,将 get_dummies 与像 cut 这样的离散化函数结合起来使用。

np.random.seed(12345) # 为了可复现性
values = np.random.uniform(size=10)                         # 生成随机数据
bins = [0, 0.2, 0.4, 0.6, 0.8, 1]                           # 定义5个等距分箱边界将[0,1]区间分成5段
pd.get_dummies(pd.cut(values, bins))                        # 先分箱再独热编码,一步将连续值转为哑变量矩阵
(0.0, 0.2] (0.2, 0.4] (0.4, 0.6] (0.6, 0.8] (0.8, 1.0]
0 False False False False True
1 False True False False False
2 True False False False False
3 False True False False False
4 False False True False False
5 False False True False False
6 False False False False True
7 False False False True False
8 False False False True False
9 False False False True False

6.7.1 标准化的输出合同与常数列边界

标准化必须同时声明公式、自由度和异常输入策略。对样本 \(x_1,\ldots,x_n\),Z-score 使用样本均值与样本标准差

\[ z_i=\frac{x_i-\bar{x}}{s},\qquad s=\sqrt{\frac{1}{n-1}\sum_{i=1}^n(x_i-\bar{x})^2}, \]

而 Min-Max 变换为

\[ m_i=\frac{x_i-x_{\min}}{x_{\max}-x_{\min}}. \]

下面的题设数组只用于核验两种变换的输出合同。Series.std() 默认使用 ddof=1,因此此处的 Z-score 变换后应具有样本均值 0、样本标准差 1;Min-Max 结果则落在 \([0,1]\)。若标准差或极差为 0,分母不可用,不能把常数列悄悄解释成具有尺度差异的数据;流水线应明确选择保留原值、返回缺失或删除该特征。

scaling_contract_values = pd.Series([1.0, 2.0, 3.0], name='metric')  # 题设值仅用于核验公式,不代表真实观测
scaling_contract_z = (scaling_contract_values - scaling_contract_values.mean()) / scaling_contract_values.std()  # 使用 pandas 默认的样本标准差
scaling_contract_minmax = (scaling_contract_values - scaling_contract_values.min()) / (scaling_contract_values.max() - scaling_contract_values.min())  # 将非退化样本映射到[0,1]

assert np.isclose(scaling_contract_z.mean(), 0.0)  # 核验中心化后的样本均值
assert np.isclose(scaling_contract_z.std(), 1.0)  # 核验与 ddof=1 一致的样本标准差
assert np.isclose(scaling_contract_minmax.min(), 0.0)  # 核验 Min-Max 下界
assert np.isclose(scaling_contract_minmax.max(), 1.0)  # 核验 Min-Max 上界

scaling_constant_values = pd.Series([2.0, 2.0, 2.0], name='constant_metric')  # 构造极差与标准差均为0的边界输入
assert scaling_constant_values.std() == 0 and scaling_constant_values.max() - scaling_constant_values.min() == 0  # 在变换前识别不可用分母

描述性分析可以在完整研究样本上报告这些统计量;预测任务则必须只在训练期拟合均值、标准差、最小值和最大值,再把同一参数应用到验证期和测试期,避免未来信息泄漏。

6.7.2 字符串拆分、提取与正则替换

字符串清洗应保留原列,并把“按固定分隔符拆分”“按模式提取”和“按模式替换”区分开。str.split(..., regex=False) 适合已知的字面分隔符,str.extract 返回捕获组,str.replace(..., regex=True) 才按正则模式替换。下面的题设字符串演示这三种合同;真实项目还应审计未匹配行,而不能假定所有文本都符合模板。

string_contract_raw = pd.Series(                            # 题设复合字段保留为独立原始副本
    ['600000.XSHG | 浦发银行 ', '002142.XSHE|宁波银行'],
    name='raw_entity', dtype='string'
)
string_contract_parts = string_contract_raw.str.split('|', n=1, expand=True, regex=False)  # 按字面管道符拆成代码侧与名称侧
string_contract_codes = string_contract_parts[0].str.extract(  # 只提取满足六位数字与来源场所后缀合同的代码
    r'(?P<order_book_id>\d{6}\.XS(?:HG|HE))', expand=True
)
string_contract_names = (string_contract_parts[1]           # 在名称侧压缩连续空白并删除首尾空白
                         .str.replace(r'\s+', ' ', regex=True)
                         .str.strip())

assert string_contract_codes['order_book_id'].tolist() == ['600000.XSHG', '002142.XSHE']  # 核验正则只返回目标捕获组
assert string_contract_names.tolist() == ['浦发银行', '宁波银行']  # 核验替换与去空白后的名称合同
assert string_contract_raw.iloc[0].endswith(' ')  # 核验原始审计列没有被就地改写

6.8 习题

6.8.1 习题 6.1: 区分缺失整行、字段缺失与缺失机制

问题描述

以本地宁波港(601018.XSHG)2023 年真实行情为基准日历,创建一份明确标注为机制演示的教学副本:独立随机删除 3 条原有交易记录以表示采集环节缺失整行,再在另外 5 条仍存在的记录中把 close 置空以表示字段缺失。请完成以下任务:

  1. 以原始来源表的交易日期为审计基准,分别定位缺失整行和已有行中的字段缺失;不得把周末、节假日或停牌日凭空解释为字段缺失。
  2. 判断本题人为缺失机制属于 MCAR、MAR 还是 MNAR,并分别给出一个 MAR 与 MNAR 的业务生成过程作为对照。
  3. reindex 到声明的来源交易日期,再比较前向填充、后向填充和均值填充;说明后向填充为何可能泄漏未来信息。
  4. 利用教学副本被遮蔽前的真实值计算三种方法的绝对误差,但不得把本次小样本排序推广为普遍最优插补法。

完整解答

图 6.2 给出本题的可复核解答。

import pandas as pd                                         # 对齐宁波港来源交易日历与三种插补序列
import numpy as np                                          # 用独立随机状态生成 MCAR 遮蔽位置并计算误差
import matplotlib.pyplot as plt                             # 将原序列与三种插补路径置于四子图对照

# 从复权日行情底表下推宁波港单证券数据,作为未遮蔽的评分真值
stock_data = pd.read_hdf(                               # 用 HDF5 查询直接取得 601018.XSHG 的完整日行情
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 来源合同包含 date、OHLC 和 vol 字段
    where="order_book_id='601018.XSHG'"         # 在读取端限定宁波港,避免其他证券混入日历
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复 date 并统一 volume 列名,保留可回查的来源行
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按8位交易日合同严格解析时间键

# 将 MCAR 教学副本的来源日历限定为宁波港2023年实际交易日
nbz_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')].copy()  # 隔离宁波港 2023 年真值副本,后续遮蔽不污染来源行情
nbz_data.set_index('datetime', inplace=True)                # 以2023年真实交易日作遮蔽与评分的共同索引

# 构造可复核的MCAR教学副本:抽样位置与所有行情值独立
missingness_rng = np.random.default_rng(42)  # 固定随机状态,使遮蔽位置可重复而不依赖价格高低
selected_positions = np.sort(missingness_rng.choice(len(nbz_data), size=8, replace=False))  # 一次抽取互不重叠的位置
row_missing_dates = nbz_data.index[selected_positions[:3]]  # 前3个日期表示采集环节丢失整条来源记录
field_missing_dates = nbz_data.index[selected_positions[3:]]  # 后5个日期只遮蔽已有记录的收盘价字段
nbz_observed = nbz_data.drop(index=row_missing_dates).copy()  # 删除整行,保留其余真实交易记录
nbz_observed.loc[field_missing_dates, 'close'] = np.nan  # 在仍存在的记录内构造字段级缺失
nbz_missing = nbz_observed.reindex(nbz_data.index)  # 以声明的来源交易日历显式暴露被删除的行
图 6.2

1. 识别缺失值

row_missing_mask = nbz_missing.index.isin(row_missing_dates)  # 用来源日历识别所有字段同时消失的整行缺失
field_missing_mask = nbz_missing['close'].isna() & ~row_missing_mask  # 排除整行后识别仍有其他字段的收盘价缺失
print('=== 两类缺失的审计统计 ===')  # 区分数据粒度,避免把没有记录的日期混成单列NaN
print(f'来源交易日总数: {len(nbz_data)}')  # 报告审计基准日历的记录数
print(f'采集环节缺失整行: {row_missing_mask.sum()}')  # 核对被删除的完整交易记录数
print(f'已有交易行的close字段缺失: {field_missing_mask.sum()}')  # 核对单字段遮蔽数
assert row_missing_mask.sum() == 3  # 确保整行缺失检测与题设合同一致
assert field_missing_mask.sum() == 5  # 确保字段缺失没有误计整行缺失
print(f'整行缺失日期: {list(row_missing_dates.date)}')  # 给出可人工复核的来源交易日期
print(f'字段缺失日期: {list(field_missing_dates.date)}')  # 给出可人工复核的字段遮蔽日期
=== 两类缺失的审计统计 ===
来源交易日总数: 242
采集环节缺失整行: 3
已有交易行的close字段缺失: 5
整行缺失日期: [datetime.date(2023, 2, 7), datetime.date(2023, 6, 8), datetime.date(2023, 6, 9)]
字段缺失日期: [datetime.date(2023, 8, 23), datetime.date(2023, 9, 11), datetime.date(2023, 10, 9), datetime.date(2023, 11, 10), datetime.date(2023, 12, 28)]

本题的 8 个位置由与行情值独立的固定随机状态抽取,因此按题设生成过程属于 MCAR。若“接口在已观测成交量超过阈值时更容易漏传收盘价”,给定成交量后不再依赖未观测收盘价,可建模为 MAR;若“收盘价极端下跌越大越容易被人为删除”,且给定其他已观测字段后仍依赖被删收盘价,则是 MNAR。数据表中出现同样的 NaN 外观,并不能替代这项生成过程判断。

2. 不同填充方法

ffill_data = nbz_missing['close'].ffill()                   # 前向填充:用前一个有效值填补NaN
bfill_data = nbz_missing['close'].bfill()                   # 后向填充:用后一个有效值填补NaN
mean_data = nbz_missing['close'].fillna(nbz_missing['close'].mean())  # 用含缺失样本中已观测收盘价的全期均值填补八个遮蔽位置

3. 可视化对比

fig, axes = plt.subplots(2, 2, figsize=(14, 10))            # 固定四格布局,使未插补、ffill、bfill 与均值结果同屏比较

# 左上面板保留两段人工遮蔽的收盘价缺口,作为三种插补方法的共同基线
nbz_missing['close'].plot(ax=axes[0, 0], color='red', alpha=0.7,  # 绘制原始数据含缺失值的折线图
                            title='原始数据(含缺失值)', linewidth=1)  # 标题声明红线保留人工遮蔽断点
axes[0, 0].set_ylabel('收盘价')                                # 红线仍使用来源收盘价单位,断点对应两类遮蔽日
axes[0, 0].grid(True, alpha=0.3)                            # 用淡网格定位原序列的缺口日期与价格层级

# 因果可用基线:缺口只继承上一个已观测收盘价
ffill_data.plot(ax=axes[0, 1], color='blue', alpha=0.7,     # 蓝线在遮蔽日延续上一有效交易日价格
                 title='前向填充(Forward Fill)', linewidth=1)   # 标题强调本图只传播已知历史收盘价
nbz_missing['close'].plot(ax=axes[0, 1], color='red', alpha=0.3, linewidth=1)  # 叠加未插补红线,核对发生替换的交易日
axes[0, 1].set_ylabel('收盘价')                                # 蓝线与原价格共用单位,水平段表示历史值延续
axes[0, 1].grid(True, alpha=0.3)                            # 辅助读取前值水平延续的起止日期

# 前视对照:缺口使用下一个有效交易日价格
bfill_data.plot(ax=axes[1, 0], color='green', alpha=0.7,    # 绿线把后继收盘价回填到更早遮蔽日,暴露信息泄漏
                 title='后向填充(Backward Fill)', linewidth=1)  # 标题提醒该结果使用了后继交易日信息
nbz_missing['close'].plot(ax=axes[1, 0], color='red', alpha=0.3, linewidth=1)  # 以红线核对未观测位置,显示前视值落点
axes[1, 0].set_ylabel('收盘价')                                # 绿线的平台取自后继交易日,单位未变但信息集已变
axes[1, 0].grid(True, alpha=0.3)                            # 用网格核对后值向前回填的时间距离

# 无时间结构对照:用全期已观测价格均值替代缺口
mean_data.plot(ax=axes[1, 1], color='purple', alpha=0.7,    # 紫线在所有遮蔽日写入同一全期均值,显示局部轨迹被抹平
                title='均值填充(Mean Fill)', linewidth=1)       # 标题声明缺口被全期已观测均值替代
nbz_missing['close'].plot(ax=axes[1, 1], color='red', alpha=0.3, linewidth=1)  # 将真实轨迹叠加为底图,暴露均值填补的平滑偏差
axes[1, 1].set_ylabel('收盘价')                                # 紫线在缺口处回到全期均值,便于识别非局部插补偏差
axes[1, 1].grid(True, alpha=0.3)                            # 辅助比较均值水平与缺口前后局部价格

plt.tight_layout()                                          # 防止四个方法标题与坐标单位互相遮挡
plt.show()                                                  # 输出插补对照图,检查缺口日期及三种信息集差异

4. 计算填充误差(与原始真实值对比)

evaluation_dates = row_missing_dates.union(field_missing_dates)  # 汇总两类遮蔽日期形成共同评分集合
actual_values = nbz_data.loc[evaluation_dates, 'close']  # 从未改动的真实来源表提取遮蔽前答案
ffill_errors = np.abs(ffill_data.loc[evaluation_dates] - actual_values)  # 计算前向填充的逐日绝对误差
bfill_errors = np.abs(bfill_data.loc[evaluation_dates] - actual_values)  # 计算后向填充的逐日绝对误差
mean_errors = np.abs(mean_data.loc[evaluation_dates] - actual_values)  # 计算全期均值填充的逐日绝对误差

print('\n=== 填充误差对比(绝对误差均值)===')                            # 以被遮蔽日真实收盘价为基准比较三种插补的平均绝对偏差
print(f'前向填充平均误差: {ffill_errors.mean():.4f}')  # 以遮蔽前真值评分“仅用历史”方法的 MAE
print(f'后向填充平均误差: {bfill_errors.mean():.4f}')  # 单独报告使用未来价格方案的 MAE,不将其用于实时决策
print(f'均值填充平均误差: {mean_errors.mean():.4f}')  # 量化忽略时序结构后的 MAE,只作本次遮蔽样本对照

=== 填充误差对比(绝对误差均值)===
前向填充平均误差: 0.0200
后向填充平均误差: 0.0211
均值填充平均误差: 0.0604

关键要点

  • 缺失整行只能相对于明确的来源日历或主键集合识别;周末、节假日和停牌不等于某个已有交易行的字段缺失。
  • 前向填充只使用此前值,但仍会虚构未观测价格;后向填充直接使用未来值,不得进入实时预测特征。
  • 全样本均值填充同时忽略时间结构并可能使用未来样本。本题误差只是教学副本上的描述性评分,不证明某方法在其他缺失机制下最优。
  • 对真实停牌日,常见正确做法是保留“无交易”状态并明确估值口径,而不是机械生成一条成交记录。

6.8.2 习题 6.2: 数据去重与合并

问题描述

给定两个包含宁波银行 (002142.SZ) 股票信息的数据集:

  1. 数据集A:包含每日交易数据(日期、收盘价、成交量)
  2. 数据集B:包含季度估值快照(匹配日期、估值来源日期、市盈率、市净率)

请完成以下任务: 1. 检查并去除数据集中的重复行 2. 运行答案已提供的日期连接脚手架,观察连接前后的行数与缺失变化 3. 处理合并后产生的缺失值 4. 分析合并后数据的完整性

评分边界:本题核心评分只覆盖重复键识别、日期键合同、连接后缺失审计与处理。答案中的 merge_asof 以及 inner、outer、left 三种 merge 均是第 7 章正式讲授的连接脚手架,不要求学生选择或独立实现,也不纳入第 6 章核心评分;首次学习时可以直接运行已经提供的 dataset_bmerged_* 代码。

完整解答

import pandas as pd                                         # 对齐宁波银行日行情与低频季度估值
import numpy as np                                          # 为去重与插补诊断提供缺失值运算

# 下推宁波银行日行情,将真实交易日作为左连接主样本
stock_data = pd.read_hdf(                               # 只读取 002142.XSHE 的复权价格与成交量记录
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 来源日行情表的主键为 order_book_id—date
    where="order_book_id='002142.XSHE'"         # 在 HDF 查询端锁定宁波银行,避免代码间交叉
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 将交易日恢复为键列并统一成交量字段
stock_data['trade_date'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按8位来源日期解析连接键
stock_data = stock_data.rename(columns={'trade_date': 'datetime'})  # 把日行情时间键统一为 datetime
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 将去重练习的左表样本固定为2023年交易日

# 为日行情补充题设展示代码,不改变来源证券范围
stock_data['symbol'] = '002142.SZ'  # 为全部行写入唯一证券键,确保重复检测不会跨品种混淆
nby_data = stock_data.copy()                                # 复制一份数据避免修改原始数据
nby_data.set_index('datetime', inplace=True)                # 以2023年宁波银行交易日作数据集A的唯一索引

# A 表以交易日为键保留收盘价和成交量,并额外复制五行制造全字段重复
dataset_a = pd.DataFrame({                                  # 构建数据集A:包含日期、收盘价、成交量
    'date': nby_data.index,  # 使用行索引作为日期列
    'close': nby_data['close'],  # 日行情价格字段与 date 一一对应
    'volume': nby_data['volume']  # 日行情流量字段与 date 一一对应
}).reset_index(drop=True)                                   # 清除筛选遗留行号,使位置运算与结果行一一对应

# 添加一些重复行(模拟数据质量问题)
dataset_a = pd.concat([dataset_a, dataset_a.iloc[10:15]], ignore_index=True)  # 拼接多个数据框
# B表以宁波银行季末 PE/PB 为真实低频估值来源,不伪造财务报表或日频估值
valuation_all = pd.read_hdf(                               # 读取含证券—季末索引的15年估值因子表
    f'{DATA_ROOT}/stock/valuation_factors_quarterly_15_years.h5'
)
ningbo_bank_quarterly = valuation_all.loc[                  # 投影 002142.XSHE 的季末 PE/PB,作为日行情左连接的低频右表
    '002142.XSHE'][['pe_ratio_ttm', 'pb_ratio_ttm']
].dropna().reset_index()
ningbo_bank_quarterly.columns = ['valuation_source_date', 'pe_ratio', 'pb_ratio']  # 保留估值来源日期以审计信息时点

# B 表每五个交易日设一个时点,并仅继承此前最近季末的 PE、PB
dates_b = nby_data.index[::5]                               # 每5个交易日设置一个估值采样点
dataset_b = pd.merge_asof(                                  # 将最近季度真实估值映射到采样日期
    pd.DataFrame({'date': dates_b}),                        # 左表为采样日期框架
    ningbo_bank_quarterly.sort_values('valuation_source_date'),  # 右表按估值来源日期排序
    left_on='date', right_on='valuation_source_date', direction='backward'  # 每个采样日只匹配不晚于它的最近估值来源日
)
dataset_b_source_known = dataset_b['valuation_source_date'].notna()  # 仅对已命中估值快照的采样日核验时点
assert (dataset_b.loc[dataset_b_source_known, 'valuation_source_date'].array
        <= dataset_b.loc[dataset_b_source_known, 'date'].array).all()  # 估值来源日不得晚于目标采样日
print('=== 原始数据集信息 ===')                                    # 开始审计日行情 A 与低频估值 B 的行列规模和日期键质量
print(f'数据集A形状: {dataset_a.shape}')  # A表行数包括额外拼入的5条重复交易,共有 date/close/volume 三列
print(f'数据集B形状: {dataset_b.shape}')  # B表每5个交易日一行,字段是 date、pe_ratio 与 pb_ratio
=== 原始数据集信息 ===
数据集A形状: (247, 3)
数据集B形状: (49, 4)

1. 检查并去除重复行

print('\n=== 数据集A 重复行检查 ===')                               # 报告按 date、close、volume 全字段识别出的五条人工重复记录
duplicates_a = dataset_a.duplicated()                       # 检测各行是否为重复行,返回布尔Series
print(f'重复行数量: {duplicates_a.sum()}')  # 统计重复行总数
print(f'重复行位置:\n{dataset_a[duplicates_a]}')  # 显示重复行的具体内容

dataset_a_clean = dataset_a.drop_duplicates()               # 删除重复行保留首次出现的记录
print(f'\n去重后数据集A形状: {dataset_a_clean.shape}')              # 确认去重后的数据规模

=== 数据集A 重复行检查 ===
重复行数量: 5
重复行位置:
          date    close      volume
242 2023-01-17  30.8135  31131828.0
243 2023-01-18  30.7588  18387040.0
244 2023-01-19  30.3392  27044979.0
245 2023-01-20  30.2936  20936398.0
246 2023-01-30  30.6676  30275933.0

去重后数据集A形状: (242, 3)

2. 按日期合并数据集

下面的三种连接由答案直接提供,只用于暴露连接后样本行数与缺失结构;第 6 章评分从这些结果的质量审计开始。

merged_inner = pd.merge(dataset_a_clean, dataset_b, on='date', how='inner')  # 按日期列进行内连接合并
merged_outer = pd.merge(dataset_a_clean, dataset_b, on='date', how='outer')  # 按日期列进行外连接合并

print('\n=== 合并结果对比 ===')                                   # 对照日期交集与并集的行数,量化连接类型造成的样本差异
print(f'内连接形状: {merged_inner.shape}')  # 输出内连接合并后的行列数
print(f'外连接形状: {merged_outer.shape}')  # 量化日期并集相对交集新增的观测行及合并字段数

# 使用左连接并按日期排序
merged = pd.merge(dataset_a_clean, dataset_b, on='date', how='left')  # 按日期列进行左连接合并
merged = merged.sort_values('date').reset_index(drop=True)  # 固定交易日顺序,使插补前后的逐期比较一一对应

print('\n=== 合并后数据示例 ===')                                  # 抽查日行情键与低频 PE、PB 是否在共同日期正确共存
print(merged.head(10))  # 抽查 date 主键与行情、估值字段是否按时间顺序对齐

=== 合并结果对比 ===
内连接形状: (49, 6)
外连接形状: (242, 6)

=== 合并后数据示例 ===
        date    close      volume valuation_source_date  pe_ratio  pb_ratio
0 2023-01-03  29.3814  27552512.0            2022-09-30  9.783672   1.39286
1 2023-01-04  30.5399  37930714.0                   NaT       NaN       NaN
2 2023-01-05  30.1933  25570577.0                   NaT       NaN       NaN
3 2023-01-06  29.7645  39761873.0                   NaT       NaN       NaN
4 2023-01-09  29.9470  26419619.0                   NaT       NaN       NaN
5 2023-01-10  29.6551  21273610.0            2022-09-30  9.783672   1.39286
6 2023-01-11  30.7406  35349805.0                   NaT       NaN       NaN
7 2023-01-12  30.7041  21726225.0                   NaT       NaN       NaN
8 2023-01-13  30.9868  21424496.0                   NaT       NaN       NaN
9 2023-01-16  31.1967  33378365.0                   NaT       NaN       NaN

3. 处理缺失值

print('\n=== 缺失值统计 ===')                                    # 定位左连接后因估值表低频而产生的 PE、PB 结构性空缺
print(merged.isna().sum())                                  # 统计合并后各列的缺失值数量

# PE/PB 在日行情左连接后的缺口只继承前一已知季末值
merged[['valuation_source_date', 'pe_ratio', 'pb_ratio']] = (  # 让估值值与其来源日期作为同一合同向后传播
    merged[['valuation_source_date', 'pe_ratio', 'pb_ratio']].ffill()
)
merged_valuation_known = merged['valuation_source_date'].notna()  # 期初尚无估值快照的交易日可以继续为空
assert (merged.loc[merged_valuation_known, 'valuation_source_date'].array
        <= merged.loc[merged_valuation_known, 'date'].array).all()  # 前向传播后仍不得使用未来估值来源

print('\n=== 填充后缺失值统计 ===')                                 # 验证历史值前向传播后估值列是否仍有期初未覆盖缺口
print(merged.isna().sum())                                  # 统计填充后各列的剩余缺失值数量

=== 缺失值统计 ===
date                       0
close                      0
volume                     0
valuation_source_date    193
pe_ratio                 193
pb_ratio                 193
dtype: int64

=== 填充后缺失值统计 ===
date                     0
close                    0
volume                   0
valuation_source_date    0
pe_ratio                 0
pb_ratio                 0
dtype: int64

4. 分析数据完整性

completeness = (1 - merged.isna().mean()) * 100             # 计算各列数据完整性百分比
print('\n=== 数据完整性 ===')                                    # 逐字段报告非空率,区分连接缺失与源表已有缺失
for col in merged.columns:  # 对每个输出字段计算非空单元数占合并样本行数的比例
    print(f'{col}: {completeness[col]:.2f}%')  # 输出该列的数据完整性百分比

# 左连接与时点填充完成后,抽查交易日是否保持原顺序
print('\n=== 最终合并数据 ===')                                   # 展示清除重复、左连估值并按历史值填充后的最终日频 schema
print(merged.head(15))  # 前15日应同时含价量与最近已知 PE/PB;披露前的估值仍可为 NA

=== 数据完整性 ===
date: 100.00%
close: 100.00%
volume: 100.00%
valuation_source_date: 100.00%
pe_ratio: 100.00%
pb_ratio: 100.00%

=== 最终合并数据 ===
         date    close      volume valuation_source_date  pe_ratio  pb_ratio
0  2023-01-03  29.3814  27552512.0            2022-09-30  9.783672   1.39286
1  2023-01-04  30.5399  37930714.0            2022-09-30  9.783672   1.39286
2  2023-01-05  30.1933  25570577.0            2022-09-30  9.783672   1.39286
3  2023-01-06  29.7645  39761873.0            2022-09-30  9.783672   1.39286
4  2023-01-09  29.9470  26419619.0            2022-09-30  9.783672   1.39286
5  2023-01-10  29.6551  21273610.0            2022-09-30  9.783672   1.39286
6  2023-01-11  30.7406  35349805.0            2022-09-30  9.783672   1.39286
7  2023-01-12  30.7041  21726225.0            2022-09-30  9.783672   1.39286
8  2023-01-13  30.9868  21424496.0            2022-09-30  9.783672   1.39286
9  2023-01-16  31.1967  33378365.0            2022-09-30  9.783672   1.39286
10 2023-01-17  30.8135  31131828.0            2022-09-30  9.783672   1.39286
11 2023-01-18  30.7588  18387040.0            2022-09-30  9.783672   1.39286
12 2023-01-19  30.3392  27044979.0            2022-09-30  9.783672   1.39286
13 2023-01-20  30.2936  20936398.0            2022-09-30  9.783672   1.39286
14 2023-01-30  30.6676  30275933.0            2022-09-30  9.783672   1.39286

关键要点: - drop_duplicates() 默认保留第一次出现的行 - 以下连接性质只用于读懂已提供的脚手架,不作为本章评分点:inner 保留日期交集,outer 保留日期并集,left 保留左表日期 - 只有在已核验估值来源日不晚于目标日、且业务合同允许快照分段常数传播时,才可前向填充 PE/PB;真正财务报表必须按披露可用日对齐


6.8.3 习题 6.3: 标准化与分箱口径比较

问题描述

对宁波港和宁波银行的股票数据进行标准化处理,以便进行比较分析:

  1. 计算两只股票的日收益率
  2. 使用 Z-score 标准化方法对收益率进行标准化
  3. 使用 Min-Max 标准化方法对收益率进行标准化
  4. 比较两种标准化方法的效果
  5. 对宁波港收益率分别进行三组等宽与等频分箱,报告边界、每组样本数并解释两种口径的差异

核心算法:Z-score 与 Min-Max 标准化的数学逻辑

在比较宁波港与宁波银行这类价位和波动尺度不同的资产时,直接比较其绝对变动意义有限。通过标准化,我们将数据特征映射到同一维度:

  1. Z-score 标准化 (Standardization)\[ z_i = \frac{x_i - \bar{x}}{s} \]
    • 特性:按正文的样本标准差 ddof=1 口径,转换后的序列样本均值为 0、样本标准差为 1。
    • 优势:它不改变原始数据的分布形状,特别适用于需要保留离群值(Outliers)影响的统计建模(如回归分析)。它也是衡量某个观测值离均值“有多远”的标准尺。
  2. Min-Max 标准化 (Normalization/Scaling)\[ x'_i = \frac{x_i - \min(x)}{\max(x) - \min(x)} \]
    • 特性:当 \(\max(x)>\min(x)\) 时,将样本最小值与最大值分别映射到 0 和 1;常数列必须另行声明策略。
    • 场景:常用于多指标权重的可视化(如雷达图)以及对输入范围敏感的机器学习算法(如神经网络)。

完整解答

图 6.3 给出本题的可复核解答。

import pandas as pd                                         # 按共同交易日对齐两只股票收益序列
import numpy as np                                          # 验证标准化后的均值、方差与区间端点
import matplotlib.pyplot as plt                             # 对照原收益、Z-score 与 Min-Max 时序路径

# 从同一复权行情表分别下推两只证券,以消除来源口径差异
stock_data_nbz = pd.read_hdf(                           # 取得宁波港 601018.XSHG 的日度收盘价
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 同源复权行情表,含交易日索引
    where="order_book_id='601018.XSHG'"         # 读取端限定宁波港证券代码
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复宁波港交易日键并对齐成交量列名
stock_data_nby = pd.read_hdf(                           # 取得宁波银行 002142.XSHE 的日度收盘价
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 继续使用相同复权口径与字段合同
    where="order_book_id='002142.XSHE'"         # 读取端限定宁波银行证券代码
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复宁波银行交易日键并统一 volume 字段
stock_data_nbz['datetime'] = pd.to_datetime(stock_data_nbz['trade_date'], format='%Y%m%d')  # 将宁波港8位交易日解析为时间键
stock_data_nby['datetime'] = pd.to_datetime(stock_data_nby['trade_date'], format='%Y%m%d')  # 将宁波银行8位交易日解析为时间键

nbz = stock_data_nbz[(stock_data_nbz['datetime'] >= '2023-01-01') & (stock_data_nbz['datetime'] <= '2023-12-31')][['datetime', 'close']].copy()  # 港口股只保留2023年交易日与收盘价,作为收益尺度之一
nby = stock_data_nby[(stock_data_nby['datetime'] >= '2023-01-01') & (stock_data_nby['datetime'] <= '2023-12-31')][['datetime', 'close']].copy()  # 银行股使用相同年度及两列 schema,后续再按共同日期取交集

nbz.set_index('datetime', inplace=True)                     # 以宁波港交易日承载收盘价序列
nby.set_index('datetime', inplace=True)                     # 以宁波银行交易日承载收盘价序列
图 6.3

1. 计算日收益率

nbz_returns = nbz['close'].pct_change(fill_method=None).dropna()  # 不填补价格缺口,保留宁波港可计算日收益
nby_returns = nby['close'].pct_change(fill_method=None).dropna()  # 使用同一缺失政策保留宁波银行有效日收益

returns_df = pd.DataFrame({                                 # 按共同交易日内连两只证券收益,避免不同休市日错位比较
    '宁波港': nbz_returns,  # 宁波港日收益率列
    '宁波银行': nby_returns  # 宁波银行日收益率列
}).dropna()                                                 # 删除含有缺失值的行

print('=== 收益率描述性统计 ===')                                   # 建立两只证券原收益的均值、波动和极值基准供标准化前后对照
print(returns_df.describe())  # 显示两只股票收益率的描述性统计摘要
=== 收益率描述性统计 ===
              宁波港        宁波银行
count  241.000000  241.000000
mean     0.000121   -0.001709
std      0.007604    0.017976
min     -0.026119   -0.055661
25%     -0.005434   -0.011418
50%      0.000000   -0.003604
75%      0.005566    0.007378
max      0.022126    0.087864

2. Z-score 标准化

zscore_df = (returns_df - returns_df.mean()) / returns_df.std()  # 对收益率进行Z-score标准化计算

print('\n=== Z-score 标准化后统计 ===')                           # 验证各证券收益转换后均值接近 0、样本标准差接近 1
print(zscore_df.describe())  # 显示标准化后的描述性统计(均值应接近0,标准差接近1)

=== Z-score 标准化后统计 ===
                宁波港          宁波银行
count  2.410000e+02  2.410000e+02
mean  -7.370775e-18  4.606735e-18
std    1.000000e+00  1.000000e+00
min   -3.450586e+00 -3.001367e+00
25%   -7.304283e-01 -5.401615e-01
50%   -1.586178e-02 -1.054694e-01
75%    7.160761e-01  5.054840e-01
max    2.893771e+00  4.982974e+00

3. Min-Max 标准化

minmax_df = (returns_df - returns_df.min()) / (returns_df.max() - returns_df.min())  # 对收益率进行Min-Max标准化计算

print('\n=== Min-Max 标准化后统计 ===')                           # 用描述统计验证各收益序列最小值为 0、最大值为 1
print(minmax_df.describe())  # 显示标准化后的描述统计(值应在[0,1]区间)

=== Min-Max 标准化后统计 ===
              宁波港        宁波银行
count  241.000000  241.000000
mean     0.543883    0.375907
std      0.157620    0.125245
min      0.000000    0.000000
25%      0.428752    0.308254
50%      0.541383    0.362697
75%      0.656751    0.439216
max      1.000000    1.000000

4. 可视化对比

fig, axes = plt.subplots(3, 1, figsize=(14, 12))            # 用共享时间方向的三层布局对照三种尺度

# 原始收益率
returns_df.plot(ax=axes[0], alpha=0.7, linewidth=1)         # 按共同交易日叠加两只股票的原始日简单收益路径
axes[0].set_title('原始日收益率', fontsize=14, fontweight='bold')  # 第一幅保留标准化前的收益尺度作为基准
axes[0].set_ylabel('收益率')                                   # 第一层保留小数日收益口径,零线是涨跌分界
axes[0].axhline(y=0, color='black', linestyle='--', alpha=0.3)  # 以0收益线区分上涨和下跌交易日
axes[0].legend(loc='upper left')                            # 标明原尺度下宁波港与宁波银行两条收益线
axes[0].grid(True, alpha=0.3)                               # 用淡参考线辅助识别收益正负与极端日期

# Z-score 标准化
zscore_df.plot(ax=axes[1], alpha=0.7, linewidth=1)          # 展示各股票按自身均值和标准差换算后的可比序列
axes[1].set_title('Z-score 标准化', fontsize=14, fontweight='bold')  # 第二幅突出相对各自均值的标准差距离
axes[1].set_ylabel('标准化值')                                  # 第二层把每只股票换算为偏离自身均值的标准差倍数
axes[1].axhline(y=0, color='black', linestyle='--', alpha=0.3)  # 标出各股自身样本均值对应的零标准分
axes[1].axhline(y=2, color='red', linestyle=':', alpha=0.5, label='±2σ')  # 标出正向两个样本标准差的诊断阈值
axes[1].axhline(y=-2, color='red', linestyle=':', alpha=0.5)  # 与上界对称地标出负向两个标准差
axes[1].legend(loc='upper left')                            # 同时识别两只股票的 Z-score 曲线与 ±2σ 阈值
axes[1].grid(True, alpha=0.3)                               # 参考线配合0与±2σ阈值判断相对异常程度

# Min-Max 标准化
minmax_df.plot(ax=axes[2], alpha=0.7, linewidth=1)          # 将每只股票样本内最小值和最大值分别映射到0与1
axes[2].set_title('Min-Max 标准化', fontsize=14, fontweight='bold')  # 第三幅比较两序列在各自样本区间内的相对位置
axes[2].set_ylabel('标准化值')                                  # 底层的0和1分别对应各证券样本内最小与最大收益
axes[2].set_ylim([0, 1])                                    # 设置y轴范围
axes[2].legend(loc='upper left')                            # 标明两只证券在各自极差区间中的相对位置
axes[2].grid(True, alpha=0.3)                               # 水平参考线辅助读取0—1区间中的相对水平

plt.tight_layout()                                          # 为三种纵轴口径保留独立标题与单位空间
plt.show()                                                  # 输出同日对照图,核对标准化是改变尺度而非时序顺序

# 计算标准化后的统计特性
print('\n=== 标准化方法对比 ===')                                  # 汇总中心化尺度与区间尺度两种不变量检查
print('Z-score 标准化特性:')  # 检查均值和标准差是否满足中心化、单位方差约束
print(f'  均值接近0: {np.allclose(zscore_df.mean(), 0, atol=1e-10)}')  # 验证Z-score后均值是否接近0
print(f'  标准差接近1: {np.allclose(zscore_df.std(), 1, atol=1e-10)}')  # 验证Z-score后标准差是否接近1

print('\nMin-Max 标准化特性:')                                   # 检查每列样本极值是否恰好映射到 0 与 1
print(f'  最小值接近0: {np.allclose(minmax_df.min(), 0, atol=1e-10)}')  # 验证Min-Max后最小值是否接近0
print(f'  最大值接近1: {np.allclose(minmax_df.max(), 1, atol=1e-10)}')  # 验证Min-Max后最大值是否接近1

=== 标准化方法对比 ===
Z-score 标准化特性:
  均值接近0: True
  标准差接近1: True

Min-Max 标准化特性:
  最小值接近0: True
  最大值接近1: True

5. 比较等宽与等频分箱

binning_returns = returns_df['宁波港']                      # 用同一题目的有效宁波港收益率比较两种分箱口径
equal_width_breaks = np.linspace(                           # 显式生成四个等距切点,避免标量 bins 触发 pandas 端点扩展
    binning_returns.min(), binning_returns.max(), 4
)
equal_width_bins, equal_width_edges = pd.cut(               # 默认区间右闭;include_lowest 确保样本最小值进入第一组
    binning_returns, bins=equal_width_breaks, retbins=True, include_lowest=True
)
equal_frequency_bins, equal_frequency_edges = pd.qcut(     # 以样本分位点形成三组,尽量平衡每组样本数
    binning_returns, q=3, retbins=True, duplicates='drop'
)
equal_width_counts = equal_width_bins.value_counts(sort=False)  # 保留区间顺序报告等宽分组频数
equal_frequency_counts = equal_frequency_bins.value_counts(sort=False)  # 保留区间顺序报告等频分组频数

assert int(equal_width_counts.sum()) == len(binning_returns)  # 核验等宽分组没有丢失有效收益
assert int(equal_frequency_counts.sum()) == len(binning_returns)  # 核验等频分组使用相同有效样本
assert len(equal_width_counts) == 3 and len(equal_frequency_counts) == 3  # 本样本不存在导致分位点合并的重复边界
assert np.allclose(np.diff(equal_width_edges), np.diff(equal_width_edges)[0])  # 核验显式切点严格等距
print('\n等宽分箱边界:', equal_width_edges)                  # 显式切点严格等距,频数由分布决定
print('等宽分箱频数:\n', equal_width_counts)
print('\n等频分箱边界:', equal_frequency_edges)              # 边界间距随分布变化,频数尽量均衡
print('等频分箱频数:\n', equal_frequency_counts)

等宽分箱边界: [-0.02611887 -0.01003728  0.0060443   0.02212589]
等宽分箱频数:
 宁波港
(-0.027100000000000003, -0.01]     19
(-0.01, 0.00604]                  180
(0.00604, 0.0221]                  42
Name: count, dtype: int64

等频分箱边界: [-0.02611887 -0.00286271  0.00290437  0.02212589]
等频分箱频数:
 宁波港
(-0.027100000000000003, -0.00286]    81
(-0.00286, 0.0029]                   81
(0.0029, 0.0221]                     79
Name: count, dtype: int64

等宽分箱适合解释固定数值区间,但偏态分布可能使组间样本数悬殊;上例用显式 np.linspace 切点保证理论边界严格等距,并以默认右闭区间和 include_lowest=True 纳入两个极值。等频分箱便于比较规模接近的组,却不保证区间宽度相等;样本数不能整除组数或分位点附近存在并列值时,频数也未必严格相等。若重复分位点使边界不唯一,duplicates='drop' 还会合并边界,因此必须报告实际边界、实际组数和实际频数。

关键要点: - Z-score 标准化保留了原始数据的分布形状,突出异常值 - 非退化样本的 Min-Max 标准化将极值映射到 [0, 1] 两端,便于可视化 - cut 固定区间宽度,qcut 尽量平衡组内样本数;两者都要审计实际边界与频数 - 标准化是许多机器学习算法的必要预处理步骤 - 在金融数据分析中,标准化有助于比较不同量级的指标


6.8.4 习题 6.4: 字符串数据处理

问题描述

假设你有一份包含题设实体代码和名称的数据,格式不统一。为避免把字符串练习误当作公司资料,以下代码、名称、交易场所和类别均为中性占位符,不对应真实证券:

  1. 提取实体代码中的数字部分
  2. 统一实体名称的大小写格式
  3. 从复合字段中拆分信息
  4. 使用正则表达式进行模式匹配

完整解答

import pandas as pd                                         # 承载字符串字段的拆分、标准化与派生列
import numpy as np                                          # 保留与全章数值处理环境一致的数组接口
import re                                                   # 用六位数字加题设场所后缀的模式拆分 entity_info
# 创建确定性机制数据
data = {                                                    # 定义包含中性实体信息的字典
    'entity_info': [  # 实体信息复合字段列表(题设代码-名称)
        '111111.XA-Entity_A',  # 场所A实体,用于验证 .XA 后缀抽取
        '222222.XB-Entity_B',  # 场所B实体,用于验证 .XB 后缀抽取
        '333333.XA-Entity_C',  # 第二个场所A实体
        '444444.XB-Entity_D',  # 第二个场所B实体
        '555555.XA-Entity_E'  # 第三个场所A实体
    ],  # 五个复合值均遵循“六位题设代码.场所后缀-名称”合同
    'venue': [  # 交易场所题设名称列表(格式不统一)
        '交易场所A',  # 中文格式的场所A名称
        '交易场所B',  # 中文格式的场所B名称
        'MARKET_A',  # 英文大写格式的场所A名称
        'MARKET_B',  # 英文大写格式的场所B名称
        'Market A Exchange'  # 英文全称格式的场所A名称
    ],  # 同一题设场所故意使用中文、英文大写和英文全称三种别名
    'category': [  # 类别复合字段列表(用|分隔)
        '类别甲|子类一',  # 主类别与子类别均应从管道符两侧保留
        '类别乙|子类二',  # 第二个主类别和子类别组合
        '类别丙|子类三',  # 第三个主类别和子类别组合
        '类别乙|子类四',  # 复用主类别以验证分类标记
        '类别甲|子类五'  # 复用主类别但使用不同子类别
    ]  # 每项均含管道符分隔的两层中性类别
}  # 汇集代码、名称与复合类别,作为字符串拆分练习的输入模式

df = pd.DataFrame(data)                                     # 形成五行、三列字符串表,分别承担复合键、别名和层级标签清洗
print('=== 原始数据 ===')                                       # 标明尚未拆分实体代码、名称与类别的输入阶段
print(df)  # 检查 entity_info、venue 与 category 三个未标准化字符串字段
=== 原始数据 ===
          entity_info              venue category
0  111111.XA-Entity_A              交易场所A  类别甲|子类一
1  222222.XB-Entity_B              交易场所B  类别乙|子类二
2  333333.XA-Entity_C           MARKET_A  类别丙|子类三
3  444444.XB-Entity_D           MARKET_B  类别乙|子类四
4  555555.XA-Entity_E  Market A Exchange  类别甲|子类五

1. 提取实体代码

def extract_entity_code(info):                              # 定义提取题设实体代码的函数
    """从复合字段中提取实体代码"""  # 输入如“111111.XA-Entity_A”,输出带场所后缀的代码或 None
    match = re.match(r'(\d{6}\.\w{2})', info)               # 用正则表达式匹配六位数字和两位场所后缀
    return match.group(1) if match else None                # 匹配成功则返回代码,否则返回None

def extract_entity_name(info):                              # 定义提取实体名称的函数
    """从复合字段中提取实体名称"""  # 以首个短横线后半段为名称,无分隔符时返回 None
    parts = info.split('-')                                 # 按短横线拆分字符串
    return parts[1] if len(parts) > 1 else None             # 拆分成功则返回第二部分(实体名)

df['entity_code'] = df['entity_info'].apply(extract_entity_code)  # 从复合文本左端提取六位代码及场所后缀
df['entity_name'] = df['entity_info'].apply(extract_entity_name)  # 取短横线右侧文本为实体名并单独存储
df['entity_name_normalized'] = df['entity_name'].str.upper()  # 统一题设英文名称的大小写格式

print('\n=== 提取实体代码和名称 ===')                                # 核对复合字段已拆成代码与名称两列
print(df[['entity_code', 'entity_name', 'entity_name_normalized']])  # 显示提取结果和标准化名称

=== 提取实体代码和名称 ===
  entity_code entity_name entity_name_normalized
0   111111.XA    Entity_A               ENTITY_A
1   222222.XB    Entity_B               ENTITY_B
2   333333.XA    Entity_C               ENTITY_C
3   444444.XB    Entity_D               ENTITY_D
4   555555.XA    Entity_E               ENTITY_E

2. 统一交易场所名称的大小写

def normalize_venue(name):                                  # 将中英文及大小写别名折叠为两个题设场所
    """统一题设交易场所名称格式"""  # 将场所A/B的别名映射为两个规范中文类别
    name_upper = name.upper()                               # 生成不区分英文大小写的匹配副本,中文原值仍单独判断
    if 'MARKET_A' in name_upper or 'MARKET A' in name_upper or '场所A' in name:  # 判断是否属于题设场所A
        return '交易场所A'                                         # 返回统一的场所A中文名称
    elif 'MARKET_B' in name_upper or 'MARKET B' in name_upper or '场所B' in name:  # 判断是否属于题设场所B
        return '交易场所B'                                         # 返回统一的场所B中文名称
    else:                                                   # 其他情况保持原始名称
        return name                                         # 返回未修改的原始名称

df['venue_normalized'] = df['venue'].apply(normalize_venue)  # 保留原 venue 供追溯,并新增两个标准类别的结果列

print('\n=== 统一交易场所名称 ===')                                # 并列原始别名与规范值,检查多种写法是否归类正确
print(df[['venue', 'venue_normalized']])  # 对比显示原始与统一后的场所名称

=== 统一交易场所名称 ===
               venue venue_normalized
0              交易场所A            交易场所A
1              交易场所B            交易场所B
2           MARKET_A            交易场所A
3           MARKET_B            交易场所B
4  Market A Exchange            交易场所A

3. 从复合字段拆分信息

df[['primary_category', 'secondary_category']] = df['category'].str.split('|', expand=True)  # 按|拆分主类别和子类别

print('\n=== 拆分类别信息 ===')                                   # 验证复合字段已拆成主类别和子类别
print(df[['category', 'primary_category', 'secondary_category']])  # 核对管道符左右两列一一对应且无样本丢失

=== 拆分类别信息 ===
  category primary_category secondary_category
0  类别甲|子类一              类别甲                子类一
1  类别乙|子类二              类别乙                子类二
2  类别丙|子类三              类别丙                子类三
3  类别乙|子类四              类别乙                子类四
4  类别甲|子类五              类别甲                子类五

4. 高级字符串操作

# 提取实体代码的场所后缀
df['venue_suffix'] = df['entity_code'].str.extract(r'(\d{6})\.(\w{2})')[1]  # 用正则提取题设代码中的场所后缀

# 判断是否属于题设场所A
df['is_venue_a'] = df['entity_code'].str.endswith('.XA').astype(int)  # 判断题设代码是否带 .XA 后缀并转为整数标记

# 判断是否属于题设类别甲
df['is_category_a'] = df['primary_category'].eq('类别甲').astype(int)  # 把类别甲转换为可计算的整数标记

print('\n=== 高级字符串处理结果 ===')                                # 检查场所后缀和两个类别标记
print(df[['entity_code', 'venue_suffix', 'is_venue_a', 'primary_category', 'is_category_a']])  # 展示题设代码和派生标记

=== 高级字符串处理结果 ===
  entity_code venue_suffix  is_venue_a primary_category  is_category_a
0   111111.XA           XA           1              类别甲              1
1   222222.XB           XB           0              类别乙              0
2   333333.XA           XA           1              类别丙              0
3   444444.XB           XB           0              类别乙              0
4   555555.XA           XA           1              类别甲              1

5. 批量替换和清理

# 五条备注同时包含首尾空白和词间连续空白,作为正则压缩的输入
df['notes'] = [  # 每条备注与原五行公司顺序一致,清洗前后可按行对照
    '  Entity_A  belongs to  category one  ',  # 实体A的中性备注(含多余空格)
    'Entity_B  uses  venue B',  # 实体B的中性备注
    '  Entity_C  has  complete  fields  ',  # 实体C的中性备注
    'Entity_D  uses venue B  ',  # 实体D的中性备注
    'Entity_E belongs to  category one'  # 实体E的中性备注
]  # 保留五行样本数,只改变字符串内部空白

# 去除多余空格
df['notes_cleaned'] = df['notes'].str.replace(r'\s+', ' ', regex=True).str.strip()  # 用正则将多个连续空格替换为单个空格并去除首尾空格

print('\n=== 清理不规则空格 ===')                                  # 对照备注原文与清洗列,确认连续和首尾空白均被压缩
print(df[['notes', 'notes_cleaned']])  # 对比显示清理前后的备注文本

=== 清理不规则空格 ===
                                    notes                     notes_cleaned
0    Entity_A  belongs to  category one    Entity_A belongs to category one
1                 Entity_B  uses  venue B             Entity_B uses venue B
2       Entity_C  has  complete  fields        Entity_C has complete fields
3                Entity_D  uses venue B               Entity_D uses venue B
4       Entity_E belongs to  category one  Entity_E belongs to category one

关键要点: - 使用 str.extract() 和正则表达式可以从复杂文本中提取信息 - str.split() 可以将复合字段拆分为多个列 - str.replace() 配合正则表达式可以批量替换文本模式 - 字符串处理是数据清洗中的重要环节,特别是处理来自不同源的数据


6.8.5 习题 6.5: 极端值候选标记与处理审计

问题描述

宁波银行股票在某个交易日可能出现极端成交量,其来源可能是数据错误、公司行为或真实交易事件。请:

  1. 使用 IQR 和 Z-score 规则生成待核查候选标记
  2. 可视化候选值并保留真实日期与原值
  3. 比较删除、替换和 winsorization,但不得覆盖原始序列
  4. 建立审计表并评估不同处理方法和阈值的敏感性

理论基础:量化分析中的候选标记规则

在处理宁波银行这类金融序列时,统计规则只能缩小人工核查范围,不能单独确认某条记录“错误”,更不能据此建立可靠投资策略。

  1. IQR (Interquartile Range) 方法
    • 计算规则\(IQR = Q_3 - Q_1\),把 \([Q_1 - 1.5 \times IQR, Q_3 + 1.5 \times IQR]\) 之外的观测标为候选。四分位数不直接依赖均值和标准差,但 1.5 倍仍是经验常数,不是错误概率。
    • 金融解释:候选值既可能是接口重复、单位错位等数据错误,也可能反映除权复权等公司行为或真实的大宗交易、市场尾部事件;必须回查来源。
  2. Z-score 方法
    • 计算规则:只要样本标准差有限且非零,就可计算 \(z_i=(x_i-\bar{x})/s\) 并用 \(|z_i|>3\) 生成候选;计算本身不要求正态分布。
    • 概率解释限制:只有附加正态或其他分布模型后,才能把“三个标准差”解释为特定尾部概率。金融数据常偏态厚尾,因此不能把阈值命中自动称为统计显著、噪音或错误。
  3. Winsorization (缩尾处理)
    • 策略:不删除行,而是在处理副本中用 clip 把边界外数值改写到指定分位点(如 5% 或 95%)。
    • 代价:它保留观测数却改变幅度和分布,必须与未处理基准、其他阈值及删除方案共同报告。

本题在真实 2023 年成交量副本中注入五个十倍值,只用于检验候选标记和审计代码能否回收已知改动,不代表真实市场中的大成交量都是错误。若把规则用于预测,应只在训练期拟合 IQR、均值/标准差或分位点,再把冻结阈值应用于验证期和测试期;本题使用全期阈值,只作描述性机制演示。

完整解答

图 6.4 给出本题的可复核解答。

import pandas as pd                                         # 处理宁波银行成交量时序及异常值版本
import numpy as np                                          # 指定注入日期并计算 IQR、Z-score 和缩尾界
import matplotlib.pyplot as plt                             # 对照剪除、均值替换与缩尾后的成交量分布

# 以宁波银行2023年真实成交量为基线,异常放大只发生在教学副本
stock_data = pd.read_hdf(                               # 从复权行情底表取得宁波银行价量字段
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 来源路径对应证券日频复权行情口径
    where="order_book_id='002142.XSHE'"         # 异常值实验只使用 002142.XSHE,确保10倍成交量注入不跨证券
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复交易日列并统一行情字段名,确保后续连接使用同一数据合同
stock_data['trade_date'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按八位来源格式解析交易键,错误格式会立即暴露
stock_data = stock_data.rename(columns={'trade_date': 'datetime'})  # 将交易日期列重命名为datetime
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 将异常值实验限定为宁波银行2023年日样本

# 投影交易日、收盘价与成交量,其余行情字段不进入异常检测
stock_data['symbol'] = '002142.SZ'  # 将异常实验的所有观测固定到同一证券,隔离跨股规模差异
nby = stock_data.rename(columns={'vol': 'volume'})[['datetime', 'close', 'volume']].copy()  # 输出一行一交易日的价格—成交量 schema,其他行情列不参与检测
nby.set_index('datetime', inplace=True)                     # 用交易日保留被注入成交量异常的可回查位置

# 在独立教学副本中注入已知改动,只验证候选标记和审计流程
np.random.seed(42)                                          # 设置随机数种子保证结果可复现
anomaly_indices = np.random.choice(len(nby), size=5, replace=False)  # 随机选取5个交易日作为异常值注入点
nby_with_anomalies = nby.copy()                             # 复制原始数据以保留原始序列
nby_with_anomalies.iloc[anomaly_indices, nby_with_anomalies.columns.get_loc('volume')] *= 10  # 将选中日期成交量放大10倍形成已知教学改动

volume = nby_with_anomalies['volume']  # 保留异常日期索引,使三种处理结果可与原五个注入点逐日对照
injected_dates = nby.index[anomaly_indices]  # 保存已知教学改动日期作为候选规则的核对基准
injection_audit_df = pd.DataFrame({'原始成交量': nby.loc[injected_dates, 'volume'], '教学副本成交量': nby_with_anomalies.loc[injected_dates, 'volume'], '来源状态': '教学注入,非真实错误结论'})  # 逐日保留原值、改写值与来源边界
assert nby.loc[injected_dates, 'volume'].equals(injection_audit_df['原始成交量'])  # 核验真实基线从未被注入步骤覆盖
图 6.4

1. IQR 方法检测异常值

Q1 = volume.quantile(0.25)                                  # 计算成交量的第一四分位数(25%)
Q3 = volume.quantile(0.75)                                  # 计算成交量的第三四分位数(75%)
IQR = Q3 - Q1  # 计算四分位距(反映数据的中间分散程度)
lower_bound = Q1 - 1.5 * IQR  # 计算异常值下界(低于此值为异常)
upper_bound = Q3 + 1.5 * IQR  # 计算异常值上界(高于此值为异常)

is_iqr_candidate = (volume < lower_bound) | (volume > upper_bound)  # 把超出经验边界的记录标为待核查候选

print('=== IQR 方法 ===')                                     # 报告四分位边界及其命中的五个放大成交量日期
print(f'Q1 (25%): {Q1:,.0f}')  # 显示第一四分位数
print(f'Q3 (75%): {Q3:,.0f}')  # 显示第三四分位数
print(f'IQR: {IQR:,.0f}')  # 显示四分位距
print(f'异常值下界: {lower_bound:,.0f}')  # 显示异常值下界
print(f'异常值上界: {upper_bound:,.0f}')  # 显示异常值上界
print(f'候选值数量: {is_iqr_candidate.sum()}')  # 计数超出 Q1-1.5IQR 或 Q3+1.5IQR 的成交量日,与注入5日对照
=== IQR 方法 ===
Q1 (25%): 21,081,494
Q3 (75%): 37,737,304
IQR: 16,655,811
异常值下界: -3,902,222
异常值上界: 62,721,021
候选值数量: 13

2. Z-score 方法检测异常值

mean_volume = volume.mean()                                 # 计算成交量序列的均值
std_volume = volume.std()                                   # 计算成交量序列的标准差
z_scores = (volume - mean_volume) / std_volume              # 把每日成交量换算为偏离全年均值的样本标准差倍数

is_zscore_candidate = np.abs(z_scores) > 3                 # 把绝对Z-score超过3的记录标为待核查候选

print('\n=== Z-score 方法 ===')                               # 用标准差距离复核 IQR 命中的极端成交量是否一致
print(f'均值: {mean_volume:,.0f}')  # 显示成交量均值
print(f'标准差: {std_volume:,.0f}')  # 显示成交量标准差
print(f'候选值数量: {is_zscore_candidate.sum()}')  # 显示Z-score规则生成的候选值个数

=== Z-score 方法 ===
均值: 37,499,582
标准差: 54,780,447
候选值数量: 3

3. 处理异常值

# 方法1: 删除异常值
volume_cleaned_drop = volume[~is_iqr_candidate]  # 仅在处理版本中删除IQR候选,原始序列仍可审计

# 方法2: 替换为均值
volume_cleaned_mean = volume.copy()                         # 复制原始数据以保留非异常点
volume_cleaned_mean[is_iqr_candidate] = mean_volume  # 仅在处理版本中把IQR候选改写为全期均值

# 方法3: Winsorization(缩尾处理)
def winsorize(series, lower_percentile=5, upper_percentile=95):  # 定义缩尾处理函数,默认5%-95%分位数
    """对序列进行缩尾处理"""  # 按给定分位数截断尾部,保留原索引和观测数
    lower = series.quantile(lower_percentile / 100)         # 计算下界分位数(5%)
    upper = series.quantile(upper_percentile / 100)         # 计算上界分位数(95%)
    return series.clip(lower=lower, upper=upper)            # 将超出上下界的值截断到边界值

volume_cleaned_winsor = winsorize(volume)  # 对成交量序列执行缩尾处理

print('\n=== 异常值处理方法对比 ===')                                # 比较删行、均值替换与缩尾对样本数、均值和波动率的影响
print(f'原始数据 - 均值: {volume.mean():,.0f}, 标准差: {volume.std():,.0f}')  # 以含五个十倍值的均值和标准差作为处理效果基准
print(f'删除异常值 - 均值: {volume_cleaned_drop.mean():,.0f}, 标准差: {volume_cleaned_drop.std():,.0f}')  # 显示删除异常值后的统计特征
print(f'均值替换 - 均值: {volume_cleaned_mean.mean():,.0f}, 标准差: {volume_cleaned_mean.std():,.0f}')  # 显示均值替换后的统计特征
print(f'缩尾处理 - 均值: {volume_cleaned_winsor.mean():,.0f}, 标准差: {volume_cleaned_winsor.std():,.0f}')  # 检查 IQR 边界改写极端成交量后均值与标准差的收缩幅度

=== 异常值处理方法对比 ===
原始数据 - 均值: 37,499,582, 标准差: 54,780,447
删除异常值 - 均值: 29,029,478, 标准差: 11,974,333
均值替换 - 均值: 29,484,483, 标准差: 11,803,061
缩尾处理 - 均值: 30,911,465, 标准差: 13,836,083
# 将候选标记、原值和三种处理结果汇入同一可追溯审计表
candidate_treatment_audit_df = pd.DataFrame({'原值': volume, 'IQR候选': is_iqr_candidate, 'Z-score候选': is_zscore_candidate, '均值替换值': volume_cleaned_mean, '5%/95%缩尾值': volume_cleaned_winsor})  # 按交易日对齐候选与处理结果
candidate_treatment_audit_df['1%/99%缩尾值'] = winsorize(volume, 1, 99)  # 加入较宽边界以检查阈值敏感性
assert candidate_treatment_audit_df['原值'].equals(volume)  # 核验审计表中的原值与未处理教学序列逐日一致
candidate_treatment_audit_df.loc[is_iqr_candidate | is_zscore_candidate].head()  # 回读候选日的原值、标记与两种缩尾阈值结果
原值 IQR候选 Z-score候选 均值替换值 5%/95%缩尾值 1%/99%缩尾值
datetime
2023-01-11 353498050.0 True True 3.749958e+07 62775864.5 2.864379e+08
2023-02-13 652039080.0 True True 3.749958e+07 62775864.5 2.864379e+08
2023-04-24 76968167.0 True False 3.749958e+07 62775864.5 7.696817e+07
2023-05-08 67852726.0 True False 3.749958e+07 62775864.5 6.785273e+07
2023-07-25 96646696.0 True False 3.749958e+07 62775864.5 9.664670e+07

4. 可视化对比

fig, axes = plt.subplots(2, 2, figsize=(14, 10))            # 四格共享成交量口径,对照原样本与三种处理后样本

# 原始数据(含异常值)
volume.plot(ax=axes[0, 0], color='blue', alpha=0.7, linewidth=1)  # 蓝色基线保留被人工放大10倍的5个交易日
axes[0, 0].scatter(volume[is_iqr_candidate].index, volume[is_iqr_candidate],  # 红色点层只覆盖超出 IQR 上下界的候选日
                   color='red', s=100, zorder=5, label=f'候选值 ({is_iqr_candidate.sum()}个)')  # 用红色突出 IQR 候选,并在标签中披露数量
axes[0, 0].set_title('原始数据(IQR规则候选值)', fontsize=12, fontweight='bold')  # 左上面板明确红点只是待核查候选
axes[0, 0].set_ylabel('成交量')                                # 左上数值仍是行情源表成交量,未经剪除或缩尾
axes[0, 0].legend()                                         # 显示图例
axes[0, 0].grid(True, alpha=0.3)                            # 用网格定位红色 IQR 异常点的日期和数量层级

# IQR 剪除会降低观测数,因而时间索引将出现空洞
volume_cleaned_drop.plot(ax=axes[0, 1], color='green', alpha=0.7, linewidth=1)  # 绿色序列完全移除 IQR 标记日,日期索引因而缩短
axes[0, 1].set_title(f'删除异常值(样本数: {len(volume_cleaned_drop)})', fontsize=12, fontweight='bold')  # 右上标题直接披露剪除后观测数,便于核对样本损失
axes[0, 1].set_ylabel('成交量')                                # 右上幅度只来自未被 IQR 标记的交易日
axes[0, 1].grid(True, alpha=0.3)                            # 辅助查看删除异常日后时间索引的断点

# 均值替换
volume_cleaned_mean.plot(ax=axes[1, 0], color='orange', alpha=0.7, linewidth=1)  # 橙线保留全部日期,但在 IQR 异常日改写为全期均值
axes[1, 0].set_title('均值替换异常值', fontsize=12, fontweight='bold')  # 左下面板指向“保留行、改写值”方案
axes[1, 0].set_ylabel('成交量')                                # 左下平台位于样本均值,可观察方差被人为压低
axes[1, 0].grid(True, alpha=0.3)                            # 辅助识别被均值替换后重复出现的水平线

# 分位边界截断保留全部交易日,但改写尾部数值
volume_cleaned_winsor.plot(ax=axes[1, 1], color='purple', alpha=0.7, linewidth=1)  # 紫线把两侧尾部拉回5%和95%分位界,观测数不变
axes[1, 1].set_title('缩尾处理(Winsorization)', fontsize=12, fontweight='bold')  # 右下面板明确所用的分位截断机制
axes[1, 1].set_ylabel('成交量')                                # 右下数值上下限由样本分位点决定,非固定经济阈值
axes[1, 1].grid(True, alpha=0.3)                            # 将缩尾上下界与存量观测的时间位置对齐

plt.tight_layout()                                          # 为样本数、方法名和成交量轴签同时留出空间
plt.show()                                                  # 输出时序对照,核对三种处理对观测数与幅度的不同影响

# 箱线图对比
fig, axes = plt.subplots(1, 4, figsize=(16, 5))             # 并排四个箱线图,对照中位数、四分位距与尾部范围

data_sets = [volume, volume_cleaned_drop, volume_cleaned_mean, volume_cleaned_winsor]  # 将四种处理结果组成列表用于循环绘图
titles = ['原始数据', '删除异常值', '均值替换', '缩尾处理']                  # 定义四个箱线图对应的标题

for ax, data, title in zip(axes, data_sets, titles):  # 将原始、删行、均值替换、缩尾四种成交量样本逐一绑定到比较面板
    ax.boxplot(data, vert=True)                             # 绘制竖向箱线图展示数据分布
    ax.set_title(title, fontsize=11, fontweight='bold')     # 标明当前面板对应的样本删除或数值改写规则,防止分布误读
    ax.set_ylabel('成交量')                                    # 四幅箱线图统一使用来源行情的成交量单位
    ax.grid(True, alpha=0.3, axis='y')                      # 添加y方向网格线提高可读性

plt.tight_layout()                                          # 防止四种处理标题与成交量轴签重叠
plt.show()                                                  # 输出分布对照,检查删除、替换和缩尾对尾部的不同压缩
<Figure size 672x480 with 0 Axes>

关键要点

  • IQR 计算不依赖均值和标准差,但其倍数阈值只是候选标记规则;
  • Z-score 的计算不要求正态分布,正态性影响的是尾部概率解释;
  • 删除候选值会损失数据并可能引入选择偏差,均值替换通常压低方差;
  • Winsorization 保留行数但改写原始幅度,必须保留原值和审计日志;
  • 数据错误、公司行为与真实尾部事件需要不同处置,统计标记不能自动区分三者;
  • 预测研究须在训练期拟合阈值,并报告未处理基准及多种阈值的敏感性分析。

6.8.6 前瞻选做 6.6: 数据重塑与透视表(不纳入第 6 章核心评分)

问题描述

本题预览第 7 章的数据重塑接口,与习题 7.5 形成衔接;pivot_tablemeltpivotMultiIndex 均不纳入第 6 章核心评分,首次学习时可以跳过。

给定多只股票(宁波港、宁波银行、恒瑞医药)的多日交易数据,请:

  1. 创建透视表,分析不同股票在不同交易日的收盘价
  2. 创建交叉表,统计各股票的下跌、平盘与上涨天数
  3. 使用 melt 和 pivot 进行数据重塑
  4. 进行多层索引的数据操作

完整解答

import pandas as pd                                         # 将三只股票的长表重塑为透视表、交叉表与 MultiIndex
import numpy as np                                          # 核验收益方向比例逐证券合计为1

# 从同源复权行情表构造“交易日—证券”唯一键长表
stock_data_nbz = pd.read_hdf(                           # 以港口股为透视表的交通运输价量样本
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 三只证券统一使用复权行情底表
    where="order_book_id='601018.XSHG'"         # 下推 601018.XSHG,防止其他沪市证券进入港口子表
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 将港口子表转为 trade_date、OHLC 与 volume 的统一 schema

stock_data_nby = pd.read_hdf(                           # 引入同城银行股,用于比较不同价格尺度与行业
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 延续相同价格调整与日期口径
    where="order_book_id='002142.XSHE'"         # 仅取 002142.XSHE,同时测试深市来源代码的映射
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复银行交易日,使其列合同与港口子表相同

stock_data_m = pd.read_hdf(                             # 加入医药股作跨行业第三列,检查重塑的普适性
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 第三只证券仍使用同一来源数据合同
    where="order_book_id='600276.XSHG'"         # 限定 600276.XSHG,与港口股共用沪市后缀但保持独立证券键
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 把医药子表整理成可纵向拼接的第三份行情 schema

stock_data_nbz['symbol'] = '601018.SH'  # 将来源代码规范为透视表使用的展示代码
stock_data_nby['symbol'] = '002142.SZ'  # 银行行情写入深市展示键,纵向拼接后仍可分组
stock_data_m['symbol'] = '600276.SH'  # 医药行情写入独立沪市展示键,不与港口股共享标签
stock_data = pd.concat([stock_data_nbz, stock_data_nby, stock_data_m])  # 将三只股票数据纵向拼接为一个DataFrame
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 把三表同名日期解析为统一时间键,支持排序与季度筛选
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]  # 重塑样本统一截取2023Q1,避免各证券使用不同时间窗口

# 投影日期—证券—收盘价—成交量长表,其余 OHLC 列不进入 pivot
symbols = ['601018.SH', '002142.SZ', '600276.SH']           # 定义目标股票代码列表
filtered_data = stock_data[stock_data['symbol'].isin(symbols)][['datetime', 'symbol', 'close', 'volume']].copy()  # 保留三只证券的日期—代码复合键及收盘价、成交量两个待透视指标
filtered_data = filtered_data.rename(columns={'vol': 'volume'})  # 将三只证券的成交量字段统一为 volume
# 添加收益方向标志
filtered_data = filtered_data.sort_values(['symbol', 'datetime'])  # 按值排序
filtered_data['price_change'] = filtered_data.groupby('symbol')['close'].pct_change(fill_method=None)  # 分证券计算且不跨价格缺口填补
return_direction = pd.Series(pd.NA, index=filtered_data.index, dtype='string')  # 每只证券首个不可计算收益保持缺失
return_direction.loc[filtered_data['price_change'] < 0] = '下跌'  # 负收益归入下跌
return_direction.loc[filtered_data['price_change'] == 0] = '平盘'  # 零收益单列,避免高估下跌天数
return_direction.loc[filtered_data['price_change'] > 0] = '上涨'  # 正收益归入上涨
filtered_data['direction'] = pd.Categorical(                # 固定三分类顺序与输出schema
    return_direction, categories=['下跌', '平盘', '上涨'], ordered=True
)

# 将三只证券的规范代码改为列标签,便于比较各自涨跌幅分布
filtered_data['symbol'] = filtered_data['symbol'].map({     # 将股票代码映射为中文名称
    '601018.SH': '宁波港',  # 港口股作为交通运输列标签
    '002142.SZ': '宁波银行',  # 银行股作为金融服务列标签
    '600276.SH': '恒瑞医药'  # 医药股作为化学制药列标签
})  # 完成股票名称映射

print('=== 数据示例 ===')                                       # 抽查三只证券的日期—代码键、收盘价、成交量和首日缺失收益
print(filtered_data.head(10))  # 抽查排序后的复合键、两项数值及每股首日收益与方向缺失是否符合预期
=== 数据示例 ===
       datetime symbol    close      volume  price_change direction
3761 2023-01-03   宁波银行  29.3814  27552512.0           NaN       NaN
3762 2023-01-04   宁波银行  30.5399  37930714.0      0.039430        上涨
3763 2023-01-05   宁波银行  30.1933  25570577.0     -0.011349        下跌
3764 2023-01-06   宁波银行  29.7645  39761873.0     -0.014202        下跌
3765 2023-01-09   宁波银行  29.9470  26419619.0      0.006131        上涨
3766 2023-01-10   宁波银行  29.6551  21273610.0     -0.009747        下跌
3767 2023-01-11   宁波银行  30.7406  35349805.0      0.036604        上涨
3768 2023-01-12   宁波银行  30.7041  21726225.0     -0.001187        下跌
3769 2023-01-13   宁波银行  30.9868  21424496.0      0.009207        上涨
3770 2023-01-16   宁波银行  31.1967  33378365.0      0.006774        上涨

1. 创建透视表

pivot_close = pd.pivot_table(filtered_data,                 # 将日期—证券—收盘价长表展开为日期行、证券列的价格矩阵
                             values='close',                # 设置透视表的值为收盘价
                             index='datetime',              # 设置行索引为交易日期
                             columns='symbol',              # 每只证券展开为一列收盘价
                             aggfunc='mean')                # 若日期—证券键重复,以均值显式聚合

print('\n=== 透视表:各股票收盘价 ===')                               # 检查长表日期—证券键已展开为一日一行、每只证券一列的收盘价宽表
print(pivot_close.head())  # 显示透视表前5行

=== 透视表:各股票收盘价 ===
symbol         宁波港     宁波银行     恒瑞医药
datetime                            
2023-01-03  3.2761  29.3814  37.9796
2023-01-04  3.2944  30.5399  38.3056
2023-01-05  3.2852  30.1933  39.0763
2023-01-06  3.2669  29.7645  38.8392
2023-01-09  3.2669  29.9470  39.0763

2. 创建交叉表:收益方向天数统计

valid_direction_rows = filtered_data.loc[                   # 显式排除每只证券首个不可计算收益
    filtered_data['direction'].notna(), ['symbol', 'direction']
]
crosstab_direction = pd.crosstab(                           # 创建证券×三类收益方向的交叉频数表
    index=valid_direction_rows['symbol'],  # 设置行为股票名称
    columns=valid_direction_rows['direction'],  # 设置列为下跌、平盘与上涨
    margins=True,                                           # 添加行列合计
    margins_name='总计',                                    # 使用稳定标签定位边际总计
    dropna=False                                            # 即使某类当前为零也保留完整三分类schema
)  # 完成交叉表创建
valid_direction_count = len(valid_direction_rows)           # 统计三只证券可计算收益方向的有效记录
assert crosstab_direction.index[-1] == '总计' and crosstab_direction.columns[-1] == '总计'  # 核验边际位置
assert int(crosstab_direction.iloc[-1, -1]) == valid_direction_count  # 核验总频数等于有效方向记录数

print('\n=== 交叉表:收益方向天数统计 ===')                           # 统计证券与三类方向的频数,核对有效收益日总数
print(crosstab_direction)  # 显示各股票的下跌、平盘与上涨天数

# 计算三类收益方向比例
crosstab_pct = pd.crosstab(                                 # 创建按证券行归一化的三分类比例表
    index=valid_direction_rows['symbol'],  # 每行对应一只证券的有效方向记录
    columns=valid_direction_rows['direction'],  # 保留下跌、平盘与上涨三列
    normalize='index',                                      # 按证券归一化为比例
    dropna=False                                            # 与频数表保持相同三分类schema
)
np.testing.assert_allclose(crosstab_pct.sum(axis=1).to_numpy(), 1.0)  # 核验各证券方向比例合计为1
print('\n=== 收益方向比例 ===')                                 # 以同一有效样本分母比较三类方向占比
print(crosstab_pct.round(3))  # 显示各证券下跌、平盘与上涨比例

=== 交叉表:收益方向天数统计 ===
direction  下跌  平盘  上涨   总计
symbol                    
宁波港        27   9  22   58
宁波银行       38   0  20   58
恒瑞医药       33   0  25   58
总计         98   9  67  174

=== 收益方向比例 ===
direction     下跌     平盘     上涨
symbol                        
宁波港        0.466  0.155  0.379
宁波银行       0.655  0.000  0.345
恒瑞医药       0.569  0.000  0.431

3. 使用 melt 进行数据重塑

# 宽表的一行三证券价格将恢复为三行日期—证券—收盘价观测
wide_data = pivot_close.reset_index().melt(                 # reset_index 保留日期键,melt 将证券列名下推为观测值
    id_vars='datetime',                                     # 保留日期列作为标识变量
    var_name='股票',                                          # 把原宽表的三只证券列名下推为长表“股票”键
    value_name='收盘价'                                        # 原价格矩阵单元统一进入长表数值列
)  # 完成melt操作

print('\n=== Melt 后的长格式数据 ===')                             # 验证一日三证券宽表恢复为 datetime、股票、收盘价三列长表
print(wide_data.head(10))  # 验证输出恰含 datetime、股票、收盘价三列且日期会重复跨证券出现

=== Melt 后的长格式数据 ===
    datetime   股票     收盘价
0 2023-01-03  宁波港  3.2761
1 2023-01-04  宁波港  3.2944
2 2023-01-05  宁波港  3.2852
3 2023-01-06  宁波港  3.2669
4 2023-01-09  宁波港  3.2669
5 2023-01-10  宁波港  3.2485
6 2023-01-11  宁波港  3.2394
7 2023-01-12  宁波港  3.2302
8 2023-01-13  宁波港  3.2577
9 2023-01-16  宁波港  3.2761

4. 多层索引操作

# 由日期—证券两列建立行 MultiIndex,指标列保持普通单层 schema
multi_index_df = filtered_data.set_index(['datetime', 'symbol']).sort_index()  # 设置日期和股票为多层索引并排序

print('\n=== 多层索引数据结构 ===')                                 # 核对行索引依次为 datetime、symbol,数值列仍含价格、成交量与收益
print(f'索引层级: {multi_index_df.index.names}')  # 显示多层索引的层级名称
print(f'数据形状: {multi_index_df.shape}')  # 验证“日期—证券”索引不改变长表行数,值列只剩价量与涨跌标记

# 使用多层索引选择数据
print('\n=== 选择特定日期的所有股票数据 ===')                            # 以外层日期键取得同日三只证券横截面,验证层级索引切片
print(multi_index_df.loc[pd.IndexSlice['2023-01-03':'2023-01-05'], :])  # 使用IndexSlice筛选1月3日到5日的所有股票数据

print('\n=== 选择特定股票的所有日期数据 ===')                            # 以证券层筛出其完整季度时序,不改变日期层顺序
print(multi_index_df.loc[(slice(None), '宁波港'), :].head())   # 按第二层索引筛选宁波港的数据

=== 多层索引数据结构 ===
索引层级: ['datetime', 'symbol']
数据形状: (177, 4)

=== 选择特定日期的所有股票数据 ===
                     close      volume  price_change direction
datetime   symbol                                             
2023-01-03 宁波港      3.2761  10743034.0           NaN       NaN
           宁波银行    29.3814  27552512.0           NaN       NaN
           恒瑞医药    37.9796  25756493.0           NaN       NaN
2023-01-04 宁波港      3.2944   8787221.0      0.005586        上涨
           宁波银行    30.5399  37930714.0      0.039430        上涨
           恒瑞医药    38.3056  25766800.0      0.008584        上涨
2023-01-05 宁波港      3.2852  10407133.0     -0.002793        下跌
           宁波银行    30.1933  25570577.0     -0.011349        下跌
           恒瑞医药    39.0763  40846418.0      0.020120        上涨

=== 选择特定股票的所有日期数据 ===
                    close      volume  price_change direction
datetime   symbol                                            
2023-01-03 宁波港     3.2761  10743034.0           NaN       NaN
2023-01-04 宁波港     3.2944   8787221.0      0.005586        上涨
2023-01-05 宁波港     3.2852  10407133.0     -0.002793        下跌
2023-01-06 宁波港     3.2669  10450610.0     -0.005570        下跌
2023-01-09 宁波港     3.2669   6518398.0      0.000000        平盘

5. 进一步选做:高级透视表的多值聚合

以下两项只展示接口边界,不纳入本题要求或评分。

advanced_pivot = pd.pivot_table(filtered_data,              # 一行一证券,列轴形成指标—统计量两层聚合 schema
                                values=['close', 'volume'],  # 对收盘价和成交量两列进行聚合
                                index='symbol',             # 设置行索引为股票名称
                                aggfunc={                   # 为不同列指定不同的聚合函数
                                    'close': ['mean', 'std', 'min', 'max'],  # 收盘价计算均值、标准差、最小值、最大值
                                    'volume': ['mean', 'sum']  # 成交量计算均值和总和
                                })  # 完成多值聚合透视表

print('\n=== 高级透视表:多值聚合 ===')                               # 展示每股收盘价均值与成交量合计形成的两指标宽表 schema
print(advanced_pivot)  # 显示每只股票的多维度统计摘要

=== 高级透视表:多值聚合 ===
          close                                      volume              
            max       mean      min       std          mean           sum
symbol                                                                   
宁波港      3.4137   3.334383   3.2302  0.042210  9.668267e+06  5.704277e+08
宁波银行    31.1967  27.844461  24.8570  2.042442  3.004313e+07  1.772545e+09
恒瑞医药    44.6784  41.788493  37.3374  1.754276  3.829266e+07  2.259267e+09

6. 进一步选做:使用 pivot_table 创建热力图数据

# 将日收益按 ISO 周—证券分组,生成热力图所需的二维均值矩阵
filtered_data['week'] = pd.to_datetime(filtered_data['datetime']).dt.isocalendar().week  # 从日期中提取ISO周数编号
weekly_returns = pd.pivot_table(filtered_data,              # 创建按周聚合的涨跌幅透视表
                                values='price_change',      # 以涨跌幅为聚合值
                                index='week',               # 设置行索引为周数
                                columns='symbol',           # 三只证券分别形成周平均收益列
                                aggfunc='mean')             # 在“ISO周—证券”组内聚合日收益

print('\n=== 按周聚合的平均涨跌幅 ===')                               # 把日收益压缩为 ISO 周—证券均值,检查时间聚合后的行键变化
print(weekly_returns.head(10).round(4))  # 显示前10周的平均涨跌幅,保留4位小数

=== 按周聚合的平均涨跌幅 ===
symbol     宁波港    宁波银行    恒瑞医药
week                          
1      -0.0009  0.0046  0.0075
2      -0.0006  0.0082  0.0000
3       0.0050 -0.0045  0.0219
5       0.0006 -0.0084 -0.0033
6       0.0000 -0.0026  0.0038
7      -0.0022 -0.0087  0.0028
8       0.0017 -0.0002 -0.0044
9       0.0038  0.0055  0.0010
10     -0.0054 -0.0205 -0.0071
11      0.0061 -0.0035 -0.0050

关键要点: - 本题全部内容属于第 7 章的前瞻选做,第 6 章核心练习不要求掌握这些接口 - pivot_table 适合进行数据汇总和分析,可以指定聚合函数 - crosstab 专门用于计算交叉表,统计频数 - melt 将宽格式转换为长格式,便于某些分析 - pivot 将长格式转换为宽格式,便于查看 - 多层索引提供了灵活的数据选择方式 - stack()unstack() 可以在多层索引和列之间转换


6.8.7 习题 6.7: 综合数据清洗项目

问题描述

你需要整合三个输入合同明确的数据源:

  1. 数据源 A:本地 HDF5 中宁波港(601018.XSHG)的真实日度行情;在教学副本中复制三行以演示重复键。
  2. 数据源 B:本地季度估值因子 HDF5 中同一公司的真实 pe_ratio_ttmpb_ratio_ttmmarket_cap;在教学副本中选择两个采样点,将“估值来源日期、PE、PB、市值”组成的完整估值快照整组设为缺失,以演示原子快照传播。该表是估值观测,不冒充财务报表或 API 数据。
  3. 数据源 C:题面直接给出的固定情绪标签与新闻计数,仅用于演示异构字符串清洗,不是真实外部情绪观测。

请完成: 1. 检查并处理各数据源的质量问题 2. 统一数据格式和日期对齐 3. 运行答案已提供的三源连接骨架,并审计连接后的信息时点与缺失结构 4. 创建清洗报告

评分边界:本题核心评分覆盖分源质量处理、字段与日期合同、只使用当时已知信息的检查、连接后缺失审计、最终输出合同和清洗报告。估值来源日期和三个估值值必须视为原子快照:只允许整组空快照继承此前完整快照,部分缺失必须报告为质量异常,不得逐列静默填补;评分证据包括全有/全无、非未来和来源回连一致性断言。通用原子传播函数、构造 data_bmerge_asof 及三源 outer join 均为已提供脚手架,不要求学生从零实现,也不纳入第 6 章核心评分。

最终输出合同:结果以升序、唯一的 DatetimeIndex 表示交易日;列顺序固定为 OHLCV、估值来源日与三个估值值、情绪来源日、规范情绪和新闻计数。两个来源日列必须是日期时间类型,OHLCV、估值值与新闻计数必须是数值类型,情绪列使用 pandas string 类型且只含规范类别。答案须显式报告部分快照异常数,并断言索引、列、dtype 与键唯一性满足该合同。

完整解答

import pandas as pd                                         # 整合日行情、季度估值与题设周度情绪表
import numpy as np                                          # 注入可核验缺失值并计算清洗质量指标
from datetime import datetime                               # 统一机制日期与行情日期类型,支撑三源按时点对齐

print('=== 开始数据清洗项目 ===\n')                                 # 标明三源数据从加载、清洗到时点对齐的流水线起点

# 项目初始化结束;后续六步按数据血缘分段展示
=== 开始数据清洗项目 ===

1. 加载数据源A:本地HDF5日度行情数据

# 数据源A:日度行情是最终样本粒度
print('[1/6] 加载数据源A:日度行情数据')  # 首先建立2023Q1宁波港交易日主样本与 OHLCV 合同
# 数据源A是宁波港2023Q1复权日行情,其交易日决定最终分析样本
stock_data_nbz = pd.read_hdf(                           # 从证券日行情表取得宁波港 OHLCV
    f'{DATA_ROOT}/stock/stock_price_pre_adjusted.h5',  # 来源为复权价格底表,非题设模拟值
    where="order_book_id='601018.XSHG'"         # 在读取端只保留宁波港证券代码
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 恢复交易日键并将 vol 对齐为管道的 volume 字段

stock_data_nbz['symbol'] = '601018.SH'  # 为数据源A记录宁波港展示代码,便于后续追溯
stock_data_nbz['datetime'] = pd.to_datetime(stock_data_nbz['trade_date'], format='%Y%m%d')  # 将来源交易日期转成统一时间类型
stock_data_nbz = stock_data_nbz[(stock_data_nbz['datetime'] >= '2023-01-01') & (stock_data_nbz['datetime'] <= '2023-03-31')]  # 将真实来源限定在题设季度
data_a = stock_data_nbz[['datetime', 'open', 'high', 'low', 'close', 'volume']].copy()  # 按题面合同只保留宁波港行情字段
data_a = data_a.rename(columns={'vol': 'volume'})           # 对齐A表输出合同的成交量列(若上游仍保留 vol)
data_a.set_index('datetime', inplace=True)                  # 以行情交易日为三源外连的统一索引

# 添加一些重复行(模拟数据问题)
data_a = pd.concat([data_a, data_a.iloc[10:13]], ignore_index=False)  # 复制第10-12行模拟重复数据
data_a = data_a.sort_index()                                # 按日期排序保持时序
[1/6] 加载数据源A:日度行情数据
print(f'  数据形状: {data_a.shape}')  # 核对行情表的交易日行数与 OHLCV 五列
print(f'  重复行数: {data_a.index.duplicated().sum()}')         # 统计索引重复的行数
print(f'  缺失值数: {data_a.isna().sum().sum()}')               # 统计所有列的缺失值总数

# 输出A表行数、重复日和缺失单元的基线诊断
  数据形状: (62, 5)
  重复行数: 3
  缺失值数: 0

2. 创建数据源 B:本地真实季度估值因子

# 数据源B:季度估值将按最近已知时点映射到高频日期
print('\n[2/6] 创建数据源B:本地真实季度估值因子')  # 明确该表不是API财务报表

# 读取宁波港真实季度估值因子数据(PE、PB、总市值)
valuation_pipeline = valuation_all  # 复用本章前文唯一一次fixed估值全表读取,后续只建立证券级子表
port_quarterly = valuation_pipeline.loc[                    # 从 601018.XSHG 季度时序投影 PE、PB 与总市值,作为10日采样右表
    '601018.XSHG'][['pe_ratio_ttm', 'pb_ratio_ttm', 'market_cap']
].dropna().reset_index()
port_quarterly.columns = ['valuation_source_date', 'pe_ratio', 'pb_ratio', 'market_cap']  # 保留估值来源日以形成可审计时点合同

# 以10日框架作左表,每个采样日只可匹配当时或更早的宁波港季末估值
dates_b = pd.date_range(                                   # 创建每10天的日期序列
    start='2023-01-01', end='2023-03-31', freq='10D'
)
data_b = pd.merge_asof(                                    # 将最近季度真实估值映射到10天采样日期
    pd.DataFrame({'datetime': dates_b}),                   # 左表为10天采样日期
    port_quarterly.sort_values('valuation_source_date'),  # 右表按同一公司的估值来源日期排序
    left_on='datetime', right_on='valuation_source_date', direction='backward'  # 每个采样点只匹配不晚于它的估值快照
)
data_b_source_known = data_b['valuation_source_date'].notna()  # 仅核验已经命中历史估值的采样点
assert (data_b.loc[data_b_source_known, 'valuation_source_date'].array
        <= data_b.loc[data_b_source_known, 'datetime'].array).all()  # 来源估值日期不得晚于目标采样日期

# 按完整快照原子遮蔽两行,避免制造“旧数值—新来源日”的伪血缘
valuation_value_columns = ['pe_ratio', 'pb_ratio', 'market_cap']  # 三个值必须与同一估值来源日共同解释
valuation_snapshot_columns = ['valuation_source_date', *valuation_value_columns]  # 定义不可拆分的估值快照元组
data_b.loc[[2, 4], valuation_value_columns] = np.nan        # 两个教学缺口同时遮蔽该来源日的全部估值字段
data_b.loc[[2, 4], 'valuation_source_date'] = pd.NaT        # 来源日与数值共同失效,禁止保留新日期再借用旧值

data_b.set_index('datetime', inplace=True)                  # 将日期设为索引以便时序对齐

print(f'  数据形状: {data_b.shape}')  # 确认10日采样框架行数及 PE、PB、市值三个估值列
print(f'  缺失值数: {data_b.isna().sum().sum()}')               # 统计估值因子数据的缺失值总数

# 输出B表人工缺口后的形状与缺失总数

[2/6] 创建数据源B:本地真实季度估值因子
  数据形状: (9, 4)
  缺失值数: 8

3. 创建数据源C:机制演示题设的市场情绪数据(非真实观测)

# 数据源C:周度情绪与计数仅是机制演示输入
print('\n[3/6] 创建数据源C:市场情绪机制演示题设')                          # 明示该表只用于演练异构字段清洗

# 情绪表是题面固定机制数据,不代表新闻或社交媒体的真实测量
dates_c = pd.date_range(start='2023-01-01', end='2023-03-31', freq='7D')  # 题设情绪事件每7天一次,频率低于行情日历且不代表真实抓取
sentiments = ['积极', '中性', '消极', '积极', '积极', '中性', '消极', '积极',  # 定义市场情绪文本列表(前8个)
              '中性', '积极', '积极', '消极', '中性']  # 定义市场情绪文本列表(后5个)
news_counts = [12, 8, 15, 9, 11, 7, 18, 10, 6, 14, 13, 16, 5]  # 使用固定题设计数保证输入合同完全可复核

data_c = pd.DataFrame({                                     # 创建情绪数据框
    'date': [d.strftime('%Y-%m-%d') for d in dates_c[:len(sentiments)]],  # 将日期格式化为字符串
    'sentiment_text': sentiments,  # 情绪文本列
    'news_count': news_counts  # 写入题面固定计数,不将其冒充真实新闻样本
})  # 完成情绪数据框创建

# 格式不统一:sentiment_text 有不同的表示方式
data_c.loc[0, 'sentiment_text'] = '  积极  '                  # 添加前后空格模拟格式不统一
data_c.loc[3, 'sentiment_text'] = 'Positive'                # 使用英文模拟格式不统一
data_c.loc[6, 'sentiment_text'] = 'NEGATIVE'                # 使用大写英文模拟格式不统一

print(f'  数据形状: {data_c.shape}')  # 报告题设周度日期、情绪文本与计数的三列布局
print(f'  唯一情绪值: {data_c["sentiment_text"].unique()}')      # 获取唯一值

# 输出C表尚未归并的中英文情绪类别

[3/6] 创建数据源C:市场情绪机制演示题设
  数据形状: (13, 3)
  唯一情绪值: ['  积极  ' '中性' '消极' 'Positive' '积极' 'NEGATIVE']

4. 数据清洗

# 分源清洗:A去重、B按已知值填充、C规范化字段
print('\n[4/6] 清洗各数据源')                                     # 本步分别处理行情重复日、估值缺口和情绪文本别名

# 行情表按交易日去重,保留每日首条 OHLCV
data_a_clean = data_a[~data_a.index.duplicated(keep='first')]  # 保留第一次出现的记录,删除重复行
print(f'  数据源A - 去除重复后: {data_a_clean.shape}')  # 与清洗前行数对照,确认仅复制的日期键记录被移除

# 估值快照只允许整组缺失行联合传播;部分字段缺失必须保留为数据质量异常
def forward_fill_atomic_snapshots(frame, snapshot_columns):  # 将“来源日+全部估值值”视为一个不可拆分元组
    result = frame.copy()                                   # 不修改输入表,保留清洗前审计证据
    snapshot_missing = result[snapshot_columns].isna()      # 逐行检查快照四个组成字段的缺失模式
    partial_snapshot = snapshot_missing.any(axis=1) & ~snapshot_missing.all(axis=1)  # 识别新旧字段可能混配的部分缺失
    assert not partial_snapshot.any()                       # 部分缺失不得由逐列 ffill 悄悄修补
    empty_snapshot = snapshot_missing.all(axis=1)           # 仅整组缺失行获准继承此前完整快照
    propagated = result[snapshot_columns].ffill()           # 先计算候选历史快照,但不覆盖任何非空元组
    result.loc[empty_snapshot, snapshot_columns] = propagated.loc[empty_snapshot, snapshot_columns]  # 原子写入四字段
    filled_missing = result[snapshot_columns].isna()        # 核验输出仍只有整组有效或期初整组缺失两种状态
    assert not (filled_missing.any(axis=1) & ~filled_missing.all(axis=1)).any()
    return result

data_b_clean = forward_fill_atomic_snapshots(data_b, valuation_snapshot_columns)  # 联合传播两个完整快照缺口
data_b_clean_source_known = data_b_clean['valuation_source_date'].notna()  # 期初无历史估值时允许继续缺失
assert (data_b_clean.loc[data_b_clean_source_known, 'valuation_source_date'].array
        <= data_b_clean.index[data_b_clean_source_known]).all()  # 填充后估值来源日仍不得晚于采样日
print(f'  数据源B - 填充缺失值后: {data_b_clean.isna().sum().sum()} 个缺失值')  # 确认填充后的缺失值数量

# 题设情绪表先解析日期,再归并中英文类别
# 统一日期格式
data_c['date'] = pd.to_datetime(data_c['date'])             # 将 C 表事件日转成可与 A、B 索引比较的统一键类型
data_c['sentiment_source_date'] = data_c['date']            # 保留题设情绪事件来源日以审计后续传播时点
data_c = data_c.set_index('date')                           # 将日期设为索引以便与其他数据源对齐

# 统一情绪文本
def normalize_sentiment(text):                              # 定义情绪文本标准化函数
    """统一情绪文本格式"""  # 将空格和中英文别名归并为积极、中性、消极或未知
    text = str(text).strip().lower()                        # 去除首尾空格并转为小写
    if text in ['积极', 'positive']:  # 判断是否为积极情绪(中英文)
        return '积极'                                         # 把中文正向词或 positive 归入积极类
    elif text in ['中性', 'neutral']:                         # 判断是否为中性情绪
        return '中性'                                         # 对无方向情绪保留中性业务含义
    elif text in ['消极', 'negative']:                        # 将中英文负向标签识别为同一消极类别
        return '消极'                                         # 将 negative 与中文负向词合并为消极类
    else:                                                   # 无法识别的情绪文本
        return '未知'                                         # 返回“未知”作为默认分类

data_c_clean = data_c.copy()                                # 复制情绪数据以保留原始数据
data_c_clean['sentiment'] = data_c_clean['sentiment_text'].apply(normalize_sentiment).astype('string')  # 归并中英文标签并固定为 pandas string 输出类型
data_c_clean = data_c_clean[['sentiment_source_date', 'sentiment', 'news_count']]  # 同时保留来源日、清洗后情绪和题设计数
print(f'  数据源C - 清洗后唯一情绪值: {data_c_clean["sentiment"].unique()}')  # 确认情绪文本已统一为三种标准类别

# 输出三个清洗后数据合同,为索引连接做准备

[4/6] 清洗各数据源
  数据源A - 去除重复后: (59, 5)
  数据源B - 填充缺失值后: 0 个缺失值
  数据源C - 清洗后唯一情绪值: <StringArray>
['积极', '中性', '消极']
Length: 3, dtype: string

5. 整合数据源

以下连接代码是已提供的第 7 章预览脚手架;本题从检查其信息时点、缺失结构和最终 schema 开始评分。

# 三源整合:先取日期并集暴露缺口,再按信息时点填充
print('\n[5/6] 整合三个数据源')                                    # 先取三表日期并集暴露结构性缺失,再按历史信息前向填充

# 以日度行情数据为基准,外连接其他数据
merged_data = data_a_clean.join(data_b_clean, how='outer').join(data_c_clean, how='outer')  # 按索引外连接三个数据源

# 只对连接产生的整组空行原子传播最近估值快照,不按列混合不同来源
merged_data = forward_fill_atomic_snapshots(merged_data, valuation_snapshot_columns)  # 来源日与 PE/PB/市值共同传播

# 对情绪数据使用前向填充,确保每个交易日只能继承当时已知信息
merged_data[['sentiment_source_date', 'sentiment', 'news_count']] = merged_data[['sentiment_source_date', 'sentiment', 'news_count']].ffill()  # 延续最近已知题设情绪及其来源日

valuation_time_known = merged_data['valuation_source_date'].notna()  # 分别核验估值与情绪信息集的可得时点
sentiment_time_known = merged_data['sentiment_source_date'].notna()
assert (merged_data.loc[valuation_time_known, 'valuation_source_date'].array
        <= merged_data.index[valuation_time_known]).all()  # 每行估值来源日不得晚于当前目标日
assert (merged_data.loc[sentiment_time_known, 'sentiment_source_date'].array
        <= merged_data.index[sentiment_time_known]).all()  # 每行情绪来源日不得晚于当前目标日

# 回连真实季度来源表,核验每个非空估值值确实属于所标来源快照
valuation_expected = (                                      # 以最终来源日查回真实季度 PE/PB 与市值
    merged_data.loc[valuation_time_known, ['valuation_source_date']]
    .merge(port_quarterly, on='valuation_source_date', how='left', validate='many_to_one')
)
assert valuation_expected[valuation_value_columns].notna().all().all()  # 每个最终来源日都必须在真实快照表中存在
for valuation_column in valuation_value_columns:            # 逐字段排除“旧值—新来源日”错配
    np.testing.assert_allclose(
        merged_data.loc[valuation_time_known, valuation_column].to_numpy(dtype=float),
        valuation_expected[valuation_column].to_numpy(dtype=float),
    )

# 删除仍然有缺失值的行
merged_data = merged_data.dropna(subset=['open', 'high', 'low', 'close', 'volume']).sort_index()  # 只保留真实交易行并固定升序索引

# 最终 schema 合同:索引、列顺序、dtype、键唯一性和原子快照状态均可直接评分
final_output_columns = [                                    # 固定跨数据源输出的列顺序,避免隐式 schema 漂移
    'open', 'high', 'low', 'close', 'volume',
    'valuation_source_date', 'pe_ratio', 'pb_ratio', 'market_cap',
    'sentiment_source_date', 'sentiment', 'news_count',
]
numeric_output_columns = [                                  # 价量、估值与题设计数均必须保持数值类型
    'open', 'high', 'low', 'close', 'volume',
    'pe_ratio', 'pb_ratio', 'market_cap', 'news_count',
]
final_snapshot_missing = merged_data[valuation_snapshot_columns].isna()  # 显式统计最终估值元组的部分缺失异常
partial_snapshot_count_final = int(                         # 全空或全有效都不是部分缺失
    (final_snapshot_missing.any(axis=1) & ~final_snapshot_missing.all(axis=1)).sum()
)
assert isinstance(merged_data.index, pd.DatetimeIndex)      # 交易日键必须为日期时间索引
assert merged_data.index.is_unique and merged_data.index.is_monotonic_increasing  # 一日一行且时间升序
assert merged_data.columns.tolist() == final_output_columns  # 最终列集合与顺序必须完全稳定
assert all(pd.api.types.is_numeric_dtype(merged_data[column]) for column in numeric_output_columns)  # 数值列 dtype 合同
assert pd.api.types.is_datetime64_any_dtype(merged_data['valuation_source_date'])  # 估值来源日保持 datetime64
assert pd.api.types.is_datetime64_any_dtype(merged_data['sentiment_source_date'])  # 情绪来源日保持 datetime64
assert isinstance(merged_data['sentiment'].dtype, pd.StringDtype)  # 规范情绪使用 pandas string dtype
assert merged_data['sentiment'].dropna().isin(['积极', '中性', '消极', '未知']).all()  # 情绪值域合同
assert partial_snapshot_count_final == 0                    # 最终不得出现来源日与估值值部分有效的快照

print(f'  整合后数据形状: {merged_data.shape}')  # 显示整合后的行数和列数
print(f'  整合后缺失值数: {merged_data.isna().sum().sum()}')       # 确认整合后的数据完整性
print(f'  部分缺失估值快照数: {partial_snapshot_count_final}')     # 将原子快照质量异常显式暴露为可评分输出

# 输出整合后行列数与剩余缺失单元总数

[5/6] 整合三个数据源
  整合后数据形状: (59, 12)
  整合后缺失值数: 0
  部分缺失估值快照数: 0

6. 创建清洗报告

# 质量报告:核对完整性、样本窗口、分布和损失率
print('\n[6/6] 数据清洗报告')                                     # 将最终表的字段完整率、交易日窗口、数值分布和行损失率联合审计
print('=' * 60)                                             # 打印分隔线

# 数据完整性
completeness = (1 - merged_data.isna().mean()) * 100        # 计算每列的数据完整性百分比
print('\n数据完整性:')                                           # 逐字段量化四源连接和前向填充后的非空覆盖率
for col in merged_data.columns:  # 以最终行数为统一分母计算每列完整率,定位残余结构性缺失
    print(f'  {col}: {completeness[col]:.2f}%')  # 逐列暴露三源连接后仍未被历史值覆盖的空缺比例

# 数据范围
print('\n数据范围:')                                            # 核对整合表起止日仍落在 2023 年第一季度行情窗口
print(f'  日期范围: {merged_data.index.min()}{merged_data.index.max()}')  # 确认删除非交易日行后仍覆盖声明的2023Q1窗口
print(f'  总交易日数: {len(merged_data)}')  # 显示整合后的总交易日数

# 统计摘要
print('\n数值型变量统计:')                                         # 比较 OHLCV、估值与新闻计数的量纲、极值和有效样本数
print(merged_data[['close', 'volume', 'pe_ratio', 'pb_ratio']].describe().round(2))  # 显示核心数值列的描述性统计

# 分类变量统计
print('\n分类变量统计:')                                          # 核对情绪标准类别及其非空频数是否符合机制输入
print(merged_data['sentiment'].value_counts())              # 显示各情绪类别的出现次数
print('\n情绪比例:')                                            # 用有效情绪日为分母报告三类占比,避免空值扭曲比例
print(merged_data['sentiment'].value_counts(normalize=True).round(3))  # 显示各情绪类别的占比

# 质量指标
print('\n数据质量指标:')                                          # 汇总重复键、缺失单元和日期连续性,作为整合结果验收证据
total_rows_original = len(data_a) + len(data_b) + len(data_c)  # 计算三个数据源的原始总行数
total_rows_final = len(merged_data)  # 记录整合后的最终行数
data_loss_rate = (1 - total_rows_final / len(data_a)) * 100  # 计算相对于行情数据的数据损失率

print(f'  原始数据总行数: {total_rows_original}')  # 显示合并前的总行数
print(f'  最终整合数据行数: {total_rows_final}')  # 报告保留 OHLCV 完整的宁波港交易日数
print(f'  数据损失率: {data_loss_rate:.2f}%')  # 分母固定为去重前行情A表,不用三源行数之和
print(f'  重复行去除率: {(data_a.index.duplicated().sum() / len(data_a) * 100):.2f}%')  # 用 A 表原始行数作分母量化人工复制记录的污染程度
print(f'  最终交易日键唯一: {merged_data.index.is_unique}')       # 显式报告最终一日一行合同
print(f'  最终交易日升序: {merged_data.index.is_monotonic_increasing}')  # 显式报告时间顺序合同
print(f'  部分缺失估值快照数: {partial_snapshot_count_final}')     # 在清洗报告中重复呈现核心血缘异常指标
print('\n最终输出 dtype:')                                      # 让教师和学生直接核对日期、数值与字符串类型
print(merged_data.dtypes)

# 抽查前10个交易日的价量、估值与题设情绪是否已在同一行对齐
print('\n最终整合数据样本(前10行):')                                  # 抽查 OHLCV、低频估值与周度情绪在同一日期键上的共存关系
print(merged_data.head(10))  # 前10个行情日应同时含 OHLCV、最近季度估值和当时已知情绪

[6/6] 数据清洗报告
============================================================

数据完整性:
  open: 100.00%
  high: 100.00%
  low: 100.00%
  close: 100.00%
  volume: 100.00%
  valuation_source_date: 100.00%
  pe_ratio: 100.00%
  pb_ratio: 100.00%
  market_cap: 100.00%
  sentiment_source_date: 100.00%
  sentiment: 100.00%
  news_count: 100.00%

数据范围:
  日期范围: 2023-01-03 00:00:00 至 2023-03-31 00:00:00
  总交易日数: 59

数值型变量统计:
       close       volume  pe_ratio  pb_ratio
count  59.00        59.00     59.00     59.00
mean    3.33   9668266.51     15.92      1.28
std     0.04   4834033.37      0.00      0.00
min     3.23   3979300.00     15.92      1.28
25%     3.31   6286030.00     15.92      1.28
50%     3.34   8161311.00     15.92      1.28
75%     3.36  11008555.50     15.92      1.28
max     3.41  25577104.00     15.92      1.28

分类变量统计:
sentiment
积极    24
中性    20
消极    15
Name: count, dtype: Int64

情绪比例:
sentiment
积极    0.407
中性    0.339
消极    0.254
Name: proportion, dtype: Float64

数据质量指标:
  原始数据总行数: 84
  最终整合数据行数: 59
  数据损失率: 4.84%
  重复行去除率: 4.84%
  最终交易日键唯一: True
  最终交易日升序: True
  部分缺失估值快照数: 0

最终输出 dtype:
open                            float64
high                            float64
low                             float64
close                           float64
volume                          float64
valuation_source_date    datetime64[ns]
pe_ratio                        float64
pb_ratio                        float64
market_cap                      float64
sentiment_source_date    datetime64[ns]
sentiment                string[python]
news_count                      float64
dtype: object

最终整合数据样本(前10行):
              open    high     low   close      volume valuation_source_date  \
2023-01-03  3.2761  3.2944  3.2577  3.2761  10743034.0            2022-09-30   
2023-01-04  3.2669  3.2944  3.2669  3.2944   8787221.0            2022-09-30   
2023-01-05  3.2944  3.3219  3.2761  3.2852  10407133.0            2022-09-30   
2023-01-06  3.2852  3.2944  3.2577  3.2669  10450610.0            2022-09-30   
2023-01-09  3.2669  3.2852  3.2577  3.2669   6518398.0            2022-09-30   
2023-01-10  3.2669  3.2761  3.2485  3.2485   5882400.0            2022-09-30   
2023-01-11  3.2394  3.2669  3.2394  3.2394   4453499.0            2022-09-30   
2023-01-12  3.2394  3.2577  3.2302  3.2302   4491700.0            2022-09-30   
2023-01-13  3.2302  3.2669  3.2302  3.2577   4929600.0            2022-09-30   
2023-01-16  3.2669  3.2944  3.2577  3.2761   8098816.0            2022-09-30   

             pe_ratio  pb_ratio    market_cap sentiment_source_date sentiment  \
2023-01-03  15.915554  1.284611  7.061943e+10            2023-01-01        积极   
2023-01-04  15.915554  1.284611  7.061943e+10            2023-01-01        积极   
2023-01-05  15.915554  1.284611  7.061943e+10            2023-01-01        积极   
2023-01-06  15.915554  1.284611  7.061943e+10            2023-01-01        积极   
2023-01-09  15.915554  1.284611  7.061943e+10            2023-01-08        中性   
2023-01-10  15.915554  1.284611  7.061943e+10            2023-01-08        中性   
2023-01-11  15.915554  1.284611  7.061943e+10            2023-01-08        中性   
2023-01-12  15.915554  1.284611  7.061943e+10            2023-01-08        中性   
2023-01-13  15.915554  1.284611  7.061943e+10            2023-01-08        中性   
2023-01-16  15.915554  1.284611  7.061943e+10            2023-01-15        消极   

            news_count  
2023-01-03        12.0  
2023-01-04        12.0  
2023-01-05        12.0  
2023-01-06        12.0  
2023-01-09         8.0  
2023-01-10         8.0  
2023-01-11         8.0  
2023-01-12         8.0  
2023-01-13         8.0  
2023-01-16        15.0  

关键要点: - 真实的数据分析项目往往需要整合多个数据源 - 每个数据源可能有不同的质量问题需要针对性处理 - 外连接由本题脚手架提供;连接类型选择与实现留到第 7 章,本章只审计其输出 - 不同类型的数据需要不同的填充策略 - 数据清洗报告是数据科学项目的重要交付物

6.9 本章小结

本章从缺失机制出发,依次讨论了删除与插补、重复记录、映射与字符串规范化、异常值识别、标准化、分箱和多源清洗报告。可复现清洗不只是让代码“跑通”,还要记录样本为何变化、阈值在哪个信息集上估计,以及处理是否可能引入未来信息。机制演示题设只能说明 API 行为;经验结论必须来自带证券、日期、单位和字段口径的本地真实数据。

完成清洗后,应至少核验行数、键唯一性、缺失率、取值范围和信息时点。下一章将把这些已审计的数据按键连接、纵向拼接并重塑为面板结构。

章节导航:上一章为开始使用 pandas,下一章为数据规整:连接、合并与重塑