5  数据清洗与准备

5.1 引言与学习目标

学习目标

通过本章学习,你应该能够:

  • 理论目标
    • 深刻理解缺失值处理的数学原理和不同插值方法的适用场景
    • 掌握数据标准化(归一化)的数学基础
    • 理解异常值检测的统计学方法(3σ原则、IQR方法)
    • 理解数据离散化和分箱的数学表示
  • 实践目标
    • 熟练使用pandas处理本地金融数据中的缺失值和异常值
    • 运用各种数据转换方法进行特征工程
    • 使用groupby和apply进行高效的数据聚合分析
    • 实现数据清洗和准备的完整工作流程
  • 应用目标
    • 能够使用本地上市公司财务数据进行数据清洗
    • 实现基于面板数据(panel data)的质量控制框架
    • 运用数据清洗技术准备用于计量经济学建模的数据集
    • 构建可复现的数据处理流程

同学们好。在经济学家和数据分析师的日常工作中,我们有相当一部分时间并非用于构建复杂的计量经济模型,而是投入到了更为基础的数据准备工作上:加载、清洗、转换和重排数据。这个过程,通常被称为数据整理(Data Wrangling or Munging),据报道常常会占据分析师高达80%的时间。经济数据,无论其来源是像美国经济分析局(BEA)这样的政府机构,世界银行这样的国际组织,还是专业的金融数据终端,它们很少以一种能够直接用于分析的完美形态出现。

许多研究人员会使用各种工具进行一些临时的、一次性的处理。幸运的是,pandas 库与 Python 强大而灵活的语言特性相结合,为我们提供了一个高层次、灵活且高效的工具集,能够将数据处理成我们期望的形式。pandas 的设计与实现,深受现实世界中金融与经济应用的实际需求影响。

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

5.2 处理缺失数据

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

缺失值的数学表示

设随机变量 \(X\) 的概率空间为 \(\mathcal{X}\),观测数据为 \(\mathbf{x} = (x_1, x_2, ..., x_n)^T\)。缺失值机制可以表示为:

\[ R_i = \begin{cases} X_i & \text{with probability } p \\ \text{Missing} & \text{with probability } 1-p \end{cases} \]

其中 \(R_i\) 是实际观测到的值,\(p\) 是观测概率。

缺失值类型

  1. 完全随机缺失 (MCAR - Missing Completely At Random)\[ P(R_i = \text{Missing} \mid X_i) = P(R_i = \text{Missing}) = 1-p \]

  2. 随机缺失 (MAR - Missing At Random)\[ P(R_i = \text{Missing} \mid X_i, X_{i-1}) = P(R_i = \text{Missing}) \neq P(R_i = \text{Missing} \mid X_{i-1}, X_{i+1}, ...) \]

  3. 非随机缺失 (MNAR - Missing Not At Random): 缺失过程与数据本身相关

插值方法的数学表示

给定数据 \(\{(x_i, y_i)\}_{i=1}^{n}\),其中部分 \(y_i\) 缺失。插值目标是估计缺失值 \(\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. 时间序列插值(前向填充)\[ \hat{y}_i = \begin{cases} y_{i-1} & \text{if } y_i \text{ is missing} \\ y_i & \text{otherwise} \end{cases} \]

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

插值误差分析

插值的质量可以通过以下指标评估:

\[ \text{MSE} = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2 \]

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

在经济数据集中,缺失值是一个普遍存在的问题。无论是某个国家未报告特定年份的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. 数据类型向上转型 (Upcasting):当一个整数 (int) 或布尔 (bool) 类型的 Series 中引入缺失值时,为了兼容 NaN(浮点型哨兵值),整个 Series 的数据类型 (dtype) 会被自动提升为 float64。 > [!WARNING] > 这一行为可能会导致内存占用翻倍,并且由于浮点数的舍入精度问题,在执行高精度账户对账或整数索引比较时可能产生逻辑错误。虽然现代 pandas 引入了可空整数类型(如 Int64,使用大写 ‘I’ 开发),但在处理旧版 Parquet 格式或第三方 API 数据时,这种向上转移依然是产生计算偏差的常见隐患。

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

import pandas as pd
import numpy as np

stock_price_series = pd.Series([1.2, -3.5, np.nan, 0])
stock_price_series
列表 5.1
0    1.2
1   -3.5
2    NaN
3    0.0
dtype: float64

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

stock_price_series.isna()
列表 5.2
0    False
1    False
2     True
3    False
dtype: bool

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

Python 内置的 None 值在 pandas 对象中也被视作 NA。当与非数值数据混合时,它在 object 类型的 Series 中保持为 None。如果与数值数据混合,它将被转换为 np.nan

stock_ticker_series = pd.Series(['600519', np.nan, None, '000001'])
stock_ticker_series
列表 5.3
0    600519
1       NaN
2      None
3    000001
dtype: object
stock_ticker_series.isna()
列表 5.4
0    False
1     True
2     True
3    False
dtype: bool

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

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

5.2.2 滤除缺失数据

有多种方式可以滤除缺失数据。虽然使用 notna() 进行手动的布尔索引总是一个选择,但 dropna 方法通常更为便捷。在一个 Series 上使用它,会返回一个新的 Series,其中仅包含非空数据及其对应的索引值。

stock_rating_series = pd.Series([1, np.nan, 3.5, np.nan, 7])
stock_rating_series.dropna()
列表 5.5
0    1.0
2    3.5
4    7.0
dtype: float64

这等价于 data[data.notna()],如下所示:

stock_rating_series[stock_rating_series.notna()]
列表 5.6
0    1.0
2    3.5
4    7.0
dtype: float64

对于 DataFrame 对象,你的选择会更复杂。你可能希望丢弃全部为 NA 的行或列,或者只丢弃含有任何 NA 的行或列。默认情况下,dropna 会丢弃任何包含至少一个缺失值的行。

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

列表 5.7
quarterly_earnings_df = pd.DataFrame([[1., 6.5, 3.], [1., np.nan, np.nan],
                     [np.nan, np.nan, np.nan], [np.nan, 6.5, 3.]])
quarterly_earnings_df.columns = ['Q1', 'Q2', 'Q3']
quarterly_earnings_df.index = ['AAPL', 'GOOG', 'MSFT', 'AMZN']
print('原始数据:')
print(quarterly_earnings_df)

print('\n使用 data.dropna() 后的数据:')
print(quarterly_earnings_df.dropna())
原始数据:
       Q1   Q2   Q3
AAPL  1.0  6.5  3.0
GOOG  1.0  NaN  NaN
MSFT  NaN  NaN  NaN
AMZN  NaN  6.5  3.0

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

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

quarterly_earnings_df.dropna(how='all')
列表 5.8
Q1 Q2 Q3
AAPL 1.0 6.5 3.0
GOOG 1.0 NaN NaN
AMZN NaN 6.5 3.0

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

列表 5.9
quarterly_earnings_df[4] = np.nan # 添加一个全部为 NA 的列
print('含有全 NA 列的数据:')
print(quarterly_earnings_df)

print('\n丢弃全 NA 列:')
print(quarterly_earnings_df.dropna(axis='columns', how='all'))
含有全 NA 列的数据:
       Q1   Q2   Q3   4
AAPL  1.0  6.5  3.0 NaN
GOOG  1.0  NaN  NaN NaN
MSFT  NaN  NaN  NaN NaN
AMZN  NaN  6.5  3.0 NaN

丢弃全 NA 列:
       Q1   Q2   Q3
AAPL  1.0  6.5  3.0
GOOG  1.0  NaN  NaN
MSFT  NaN  NaN  NaN
AMZN  NaN  6.5  3.0

在面板数据分析中,一个常见的场景是只保留那些具有一定数量数据点的观测(行)。thresh 参数允许你指定这个最小数量。例如,我们利用世界银行的数据,观察一些中东国家的人均GDP数据,其中部分年份的数据存在缺失。我们希望只保留至少有3个有效数据点的国家。

列表 5.10
# 使用本地数据展示 thresh 参数
import pandas as pd
import numpy as np
from functools import reduce

# 从本地获取多只股票的收盘价
index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
symbols = ['000001.XSHG', '000300.XSHG', '399001.XSHE', '399006.XSHE']
names = {'000001.XSHG': '上证指数', '000300.XSHG': '沪深300', '399001.XSHE': '深证成指', '399006.XSHE': '创业板指'}

df_list = []
for sym in symbols:
    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'])
    df_list.append(temp)

df_panel = reduce(lambda left, right: pd.merge(left, right, on='datetime', how='outer'), df_list)
df_panel.set_index('datetime', inplace=True)
df_panel = df_panel.sort_index().loc['2023-01-01':'2023-01-20']

# 为了演示,人为引入缺失值
np.random.seed(42)
for col in df_panel.columns:
    df_panel.loc[df_panel.sample(frac=0.3).index, col] = np.nan

print('原始面板数据 (部分指数日期数据缺失):')
print(df_panel)

print('\n只保留至少有3个非NA值的交易日:')
print(df_panel.dropna(thresh=3))
原始面板数据 (部分指数日期数据缺失):
Empty DataFrame
Columns: [上证指数, 沪深300, 深证成指, 创业板指]
Index: []

只保留至少有3个非NA值的交易日:
Empty DataFrame
Columns: [上证指数, 沪深300, 深证成指, 创业板指]
Index: []

5.2.3 填充缺失数据

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

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

列表 5.11
# 使用本地上证指数数据演示 fillna
import pandas as pd
import numpy as np

# 获取上证指数 2018Q1 数据
index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
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')
sse_index_series.set_index('datetime', inplace=True)
sse_index_series = sse_index_series.sort_index().loc['2018-01-01':'2018-03-31']['close']

# 为了演示,人为地引入一些缺失值
sse_index_series.iloc[[3, 4, 15, 30]] = np.nan
print('含有缺失值的指数序列:')
print(sse_index_series.head(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

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

5.2.3.1 使用常数填充

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

列表 5.12
# 为了演示,我们创建一个新的DataFrame
df = pd.DataFrame(np.random.standard_normal((6, 3)))
df.iloc[2:, 1] = np.nan
df.iloc[4:, 2] = np.nan

print('原始 DataFrame:')
print(df)
print('\n使用 df.fillna(0) 后的 DataFrame:')
print(df.fillna(0))
原始 DataFrame:
          0         1         2
0  0.496714 -0.138264  0.647689
1  1.523030 -0.234153 -0.234137
2  1.579213       NaN -0.469474
3  0.542560       NaN -0.465730
4  0.241962       NaN       NaN
5 -0.562288       NaN       NaN

使用 df.fillna(0) 后的 DataFrame:
          0         1         2
0  0.496714 -0.138264  0.647689
1  1.523030 -0.234153 -0.234137
2  1.579213  0.000000 -0.469474
3  0.542560  0.000000 -0.465730
4  0.241962  0.000000  0.000000
5 -0.562288  0.000000  0.000000

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

df.fillna({1: 0.5, 2: 0})
列表 5.13
0 1 2
0 0.496714 -0.138264 0.647689
1 1.523030 -0.234153 -0.234137
2 1.579213 0.500000 -0.469474
3 0.542560 0.500000 -0.465730
4 0.241962 0.500000 0.000000
5 -0.562288 0.500000 0.000000

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

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

列表 5.14
print('使用均值填充后:')
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

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

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

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

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

  1. ffill (前向填充)
    • 逻辑:假设在新的交易或信息披露之前,变量维持其最后一个已知状态。
    • 场景:这是金融回测中最常用的方法。例如,股票的日度收盘价或定期公布的宏观利率。在两次更新之间,市场往往以“最新有效信息”作为定价基准。
    • 优势:避免了“前瞻偏差”(Look-ahead bias),即在 \(t\) 时刻不慎使用了 \(t+1\) 时刻才可能获知的信息。
  2. bfill (后向填充)
    • 逻辑:使用未来的观测值来填补历史空白。
    • *场景:这在实时交易决策中是绝对禁止**的。但在事后学术分析中较为常见,例如当我们需要对齐来自不同国家、披露频率不同但具有同步性假设的宏观指标时。
    • 警告:对于高频量化策略,滥用 bfill 会导致回测业绩虚高,从而产生毁灭性的“幸存者偏差”或虚假信号。
列表 5.15
filled_ffill = sse_index_series.fillna(method='ffill')
print('前向填充后的序列:')
print(filled_ffill.head(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.fillna(method='ffill', limit=2)
列表 5.16
0 1 2
0 0.496714 -0.138264 0.647689
1 1.523030 -0.234153 -0.234137
2 1.579213 -0.234153 -0.469474
3 0.542560 -0.234153 -0.465730
4 0.241962 NaN -0.465730
5 -0.562288 NaN -0.465730

表 5.2 提供了 fillna 函数参数的参考。

表 5.2: fillna 函数的参数
参数 描述
value 用于填充缺失值的标量值或类字典对象。
method 插值方法:'bfill'(后向填充)或 'ffill'(前向填充)之一。
axis 填充的轴('index''columns');默认为 'index'
limit 对于前向和后向填充,可以填充的最大连续期数。

5.3 数据转换

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

5.3.1 移除重复项

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

execution_trade_df = pd.DataFrame({
    'trade_id': ['T001', 'T002', 'T003', 'T002', 'T004'],
    'symbol': ['AAPL', 'GOOG', 'MSFT', 'GOOG', 'AMZN'],
    'volume': [100, 50, 200, 50, 150]
})
execution_trade_df
列表 5.17
trade_id symbol volume
0 T001 AAPL 100
1 T002 GOOG 50
2 T003 MSFT 200
3 T002 GOOG 50
4 T004 AMZN 150

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

execution_trade_df.duplicated()
列表 5.18
0    False
1    False
2    False
3     True
4    False
dtype: bool

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

execution_trade_df.drop_duplicates()
列表 5.19
trade_id symbol volume
0 T001 AAPL 100
1 T002 GOOG 50
2 T003 MSFT 200
4 T004 AMZN 150

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

execution_trade_df.drop_duplicates(subset=['trade_id'])
列表 5.20
trade_id symbol volume
0 T001 AAPL 100
1 T002 GOOG 50
2 T003 MSFT 200
4 T004 AMZN 150

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

execution_trade_df.drop_duplicates(['trade_id'], keep='last')
列表 5.21
trade_id symbol volume
0 T001 AAPL 100
2 T003 MSFT 200
3 T002 GOOG 50
4 T004 AMZN 150

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

对于许多数据集,你可能希望根据某列中的值进行转换。考虑一个关于不同上市公司行业分类的假设数据。

sector_classification_df = pd.DataFrame({'company': ['Midea', 'Haier', 'Midea',
                              'Tencent', 'Alibaba', 'Midea',
                              'Tencent', 'JD', 'NIO'],
                     'price': [50, 20, 52, 300, 80, 51, 305, 40, 10]})
sector_classification_df
列表 5.22
company price
0 Midea 50
1 Haier 20
2 Midea 52
3 Tencent 300
4 Alibaba 80
5 Midea 51
6 Tencent 305
7 JD 40
8 NIO 10

假设我们想添加一列,指明每家公司的所属行业。我们可以定义一个映射关系:

列表 5.23
company_to_sector = {
  'midea': 'consumer_discretionary',
  'haier': 'consumer_discretionary',
  'tencent': 'communication_services',
  'alibaba': 'consumer_discretionary',
  'jd': 'consumer_discretionary',
  'nio': 'consumer_discretionary'
}

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

left_col = sector_classification_df['company'].str.lower()
sector_classification_df['sector'] = left_col.map(company_to_sector)
sector_classification_df
列表 5.24
company price sector
0 Midea 50 consumer_discretionary
1 Haier 20 consumer_discretionary
2 Midea 52 consumer_discretionary
3 Tencent 300 communication_services
4 Alibaba 80 consumer_discretionary
5 Midea 51 consumer_discretionary
6 Tencent 305 communication_services
7 JD 40 consumer_discretionary
8 NIO 10 consumer_discretionary

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

def get_sector(x):
    return company_to_sector[x.lower()]

sector_classification_df['company'].map(get_sector)
列表 5.25
0    consumer_discretionary
1    consumer_discretionary
2    consumer_discretionary
3    communication_services
4    consumer_discretionary
5    consumer_discretionary
6    communication_services
7    consumer_discretionary
8    consumer_discretionary
Name: company, dtype: object

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

5.3.3 替换值

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

market_sentiment_series = pd.Series([0.5, -999., 0.8, -999., -1000., 0.1])
market_sentiment_series
列表 5.26
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)
列表 5.27
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)
列表 5.28
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})
列表 5.29
0    0.5
1    NaN
2    0.8
3    NaN
4    0.0
5    0.1
dtype: float64

5.3.4 重命名轴索引

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

quarterly_regional_sales_df = pd.DataFrame(np.arange(12).reshape((3, 4)),
                    index=['Shanghai', 'Beijing', 'Guangdong'],
                    columns=['Q1', 'Q2', 'Q3', 'Q4'])
quarterly_regional_sales_df
列表 5.30
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)
列表 5.31
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
列表 5.32
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)
列表 5.33
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'},
             columns={'Q3': 'peekaboo'})
列表 5.34
Q1 Q2 peekaboo Q4
ZHEJIANG 0 1 2 3
BEIJING 4 5 6 7
GUANGDONG 8 9 10 11

5.4 离散化与分箱

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

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

列表 5.35
research_subject_ages = [20, 22, 25, 27, 21, 23, 37, 31, 61, 45, 41, 32]

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

bins = [18, 25, 35, 60, 100]
subject_age_categories = pd.cut(research_subject_ages, bins)
subject_age_categories
列表 5.36
[(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 来获取每个箱中的计数。

pd.value_counts(subject_age_categories)
列表 5.37
(18, 25]     5
(25, 35]     3
(35, 60]     3
(60, 100]    1
Name: count, dtype: int64

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

age_labels = ['Youth', 'YoungAdult', 'MiddleAged', 'Senior']
pd.cut(research_subject_ages, bins, labels=age_labels)
列表 5.38
['Youth', 'Youth', 'Youth', 'YoungAdult', 'Youth', ..., 'YoungAdult', 'Senior', 'MiddleAged', 'MiddleAged', 'YoungAdult']
Length: 12
Categories (4, object): ['Youth' < 'YoungAdult' < 'MiddleAged' < 'Senior']

另一个密切相关的函数 pandas.qcut,是根据样本分位数对数据进行分箱。这对于创建大小相等的组非常有用,例如五分位数(5组)或十分位数(10组),这是收入不平等研究中的常见做法。pd.cut 通常不会产生大小相等的箱。

让我们用世界银行的数据来说明。我们将获取各国2021年的人均GDP,并使用 pd.qcut 将它们分为五个收入组(五分位数),每个组包含大致相同数量的国家。

列表 5.39
# 从本地 Parquet 文件读取数据代替 HDF5
indicator_data = pd.read_parquet('C:/qiufei/data/stock/financial_statement.parquet')
# 筛选 2023 年第四季度的资产总额
assets = indicator_data[indicator_data['quarter'] == '2023q4'][['order_book_id', 'total_assets']].dropna()
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('每个规模分组中的公司数量:')
print(pd.value_counts(quintiles[0]))
print('\n分箱的边界值 (资产额):')
print(quintiles[1])
每个规模分组中的公司数量:
assets
0    1064
4    1063
1    1063
3    1063
2    1063
Name: count, dtype: int64

分箱的边界值 (资产额):
[1.17128966e+08 1.67821704e+09 2.93294537e+09 5.52686367e+09
 1.49371148e+10 4.46970790e+13]

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

# 使用自定义分位数与 pandas.qcut
pd.qcut(assets['assets'], [0, 0.1, 0.5, 0.9, 1.]).value_counts()
assets
(1186403155.5, 3888973258.235]         2126
(3888973258.235, 36597744010.925]      2126
(117128966.339, 1186403155.5]           532
(36597744010.925, 44697079000000.0]     532
Name: count, dtype: int64

5.5 检测和过滤异常值

检测和处理异常值是关键的一步,尤其是在金融数据中,极端事件可能会对模型估计产生不成比例的影响。异常值是与其他值相比距离异常的观测值。过滤或转换它们很大程度上是应用数组操作的问题。

让我们使用标准普尔500指数的日百分比变化作为我们的数据。众所周知,金融回报具有“肥尾”特性,这意味着极端事件的发生频率比正态分布所预测的要高。

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 

# 获取上证指数历史数据
index_data = pd.read_parquet('C:/qiufei/data/index/indexes.parquet')
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']

sse_daily_returns = sse_price_index.pct_change().dropna() * 100 # 以百分比表示
sse_return_df = pd.DataFrame(sse_daily_returns, columns=['SSE_Ret'])
图 5.1

6 创建图表以可视化收益率和潜在的异常值

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()

@fig-outlier-data 中的图表清楚地显示了几个尖峰,这些代表了异常事件,例如2020年3月的新冠疫情崩盘。

假设我们想找出所有绝对收益率超过5%的日期。

::: {#cell-lst-outlier-find .cell execution_count=42}
``` {.python .cell-code lst-cap="寻找绝对值超过阈值的收益率"}
returns = sse_return_df['SSE_Ret']
returns[returns.abs() > 5]
列表 6.1
Series([], Name: SSE_Ret, dtype: object)

:::

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

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

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

Winsorizing(缩尾处理)是量化金融研究中减轻极端异常值对统计估计影响的强制性标准操作。

  1. 数学必要性:普通最小二乘法 (OLS) 对离群值(Outliers)极度敏感,因为残差是以平方形式进入损失函数的。即使是一个极端的个股收益率异常值,也可能严重扭曲全市场的 Beta 计算。
  2. 与剔除法的对比
    • 剔除 (Discarding):直接删除异常样本,这会导致样本量的减少(Data Loss),且在平衡面板数据中可能破坏时间序列的完整性。
    • 缩尾 (Winsorizing):将异常值强制“拉回”到指定的百分位边界(如 1% 或 99%)。这既保留了样本数量,又保留了“该时间点发生了大规模价格变动”这一信息。
  3. 实战准则:在处理 A 股财报因子(如市盈率、资产负债率)或截面收益率时,通常会在 1% 和 99% 分位数处进行缩尾处理,以剔除因操纵、错报或极度非理性情绪产生的统计噪点。
列表 6.2
print('原始数据的摘要统计:')
print(sse_return_df.describe())

# 对值进行封顶
sse_return_df[sse_return_df.abs() > 5] = np.sign(sse_return_df) * 5

print('\n在 +/- 5% 处封顶后的摘要统计:')
print(sse_return_df.describe())
原始数据的摘要统计:
       SSE_Ret
count        0
unique       0
top        NaN
freq       NaN

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

np.sign(return_df) 语句会根据 return_df 中的值是正还是负,产生1和-1,从而有效地在正确的边界上应用封顶。

6.1 排列与随机抽样

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

random_sample_matrix_df = pd.DataFrame(np.arange(5 * 4).reshape((5, 4)))
random_sample_matrix_df
列表 6.3
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)
perm_indices
列表 6.4
array([0, 1, 2, 4, 3], dtype=int32)

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

random_sample_matrix_df.take(perm_indices)
列表 6.5
0 1 2 3
0 0 1 2 3
1 4 5 6 7
2 8 9 10 11
4 16 17 18 19
3 12 13 14 15

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

random_sample_matrix_df.sample(n=3)
列表 6.6
0 1 2 3
0 0 1 2 3
1 4 5 6 7
4 16 17 18 19

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

credit_rating_series = pd.Series([5, 7, -1, 6, 4])
credit_rating_series.sample(n=10, replace=True)
列表 6.7
0    5
2   -1
4    4
2   -1
4    4
0    5
1    7
3    6
0    5
3    6
dtype: int64

6.2 计算指标/虚拟变量

对于统计建模和机器学习而言,另一个关键的转换是将分类变量转换为“虚拟”(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'],
                   'data1': range(6)})
pd.get_dummies(category_key_df['key'])
列表 6.8
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')
df_with_dummy = category_key_df[['data1']].join(dummies)
df_with_dummy
列表 6.9
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

一个更复杂的场景是,当单个观测属于多个类别时,通常以分隔符连接的字符串形式编码。MovieLens 1M 数据集提供了一个很好的例子,其中每部电影可以有多种类型。

# 创建一个模拟的股票概念数据集
multi_label_stock_concepts = pd.DataFrame({
    'symbol': ['000001', '000002', '600519', '601398', '300750'],
    'concepts': ['Banking|Shenzhen|BlueChip', 
                 'RealEstate|Shenzhen|BlueChip', 
                 'Consumer|Alcohol|BlueChip', 
                 'Banking|BlueChip|StateOwned', 
                 'NewEnergy|Battery|Shenzhen']
})

print(multi_label_stock_concepts)

# 使用 str.get_dummies 处理管道分隔的概念
dummies = multi_label_stock_concepts['concepts'].str.get_dummies('|')
print('\n生成的虚拟变量:')
print(dummies.head())

# 将虚拟变量合并回原数据
stock_with_dummies = multi_label_stock_concepts.join(dummies.add_prefix('Concept_'))
stock_with_dummies.iloc[0]
   symbol                      concepts
0  000001     Banking|Shenzhen|BlueChip
1  000002  RealEstate|Shenzhen|BlueChip
2  600519     Consumer|Alcohol|BlueChip
3  601398   Banking|BlueChip|StateOwned
4  300750    NewEnergy|Battery|Shenzhen

生成的虚拟变量:
   Alcohol  Banking  Battery  BlueChip  Consumer  NewEnergy  RealEstate  \
0        0        1        0         1         0          0           0   
1        0        0        0         1         0          0           1   
2        1        0        0         1         1          0           0   
3        0        1        0         1         0          0           0   
4        0        0        1         0         0          1           0   

   Shenzhen  StateOwned  
0         1           0  
1         1           0  
2         0           0  
3         0           1  
4         1           0  
列表 6.10
symbol                                   000001
concepts              Banking|Shenzhen|BlueChip
Concept_Alcohol                               0
Concept_Banking                               1
Concept_Battery                               0
Concept_BlueChip                              1
Concept_Consumer                              0
Concept_NewEnergy                             0
Concept_RealEstate                            0
Concept_Shenzhen                              1
Concept_StateOwned                            0
Name: 0, 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]
pd.get_dummies(pd.cut(values, bins))
列表 6.11
(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.3 习题

6.3.1 习题 7.1: 缺失数据处理基础

问题描述

宁波港 (601018.SH) 在 2023 年的股票数据中存在一些缺失值(可能是由于节假日或停牌)。请完成以下任务:

  1. 识别数据中的缺失值位置和数量
  2. 使用前向填充和后向填充方法填充缺失值
  3. 使用均值填充缺失值
  4. 比较不同填充方法的效果

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')

# 筛选宁波港 2023 年的数据
nbz_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')].copy()
nbz_data.set_index('datetime', inplace=True)

# 创建包含缺失值的DataFrame(模拟一些缺失值)
np.random.seed(42)
missing_indices = np.random.choice(len(nbz_data), size=20, replace=False)
nbz_missing = nbz_data.copy()
nbz_missing.iloc[missing_indices, nbz_missing.columns.get_loc('close')] = np.nan

# 1. 识别缺失值
print('=== 缺失值统计 ===')
print(f'总行数: {len(nbz_missing)}')
print(f'缺失值数量: {nbz_missing["close"].isna().sum()}')
print(f'缺失值比例: {nbz_missing["close"].isna().mean()*100:.2f}%')
print(f'\n缺失值位置:\n{nbz_missing[nbz_missing["close"].isna()].index[:10]}')

# 2. 不同填充方法
ffill_data = nbz_missing['close'].ffill()
bfill_data = nbz_missing['close'].bfill()
mean_data = nbz_missing['close'].fillna(nbz_missing['close'].mean())

# 3. 可视化对比
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 原始数据(含缺失值)
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. 计算填充误差(与原始真实值对比)
actual_values = nbz_data['close'].iloc[missing_indices]
ffill_errors = np.abs(ffill_data.iloc[missing_indices] - actual_values)
bfill_errors = np.abs(bfill_data.iloc[missing_indices] - actual_values)
mean_errors = np.abs(mean_data.iloc[missing_indices] - actual_values)

print('\n=== 填充误差对比(绝对误差均值)===')
print(f'前向填充平均误差: {ffill_errors.mean():.4f}')
print(f'后向填充平均误差: {bfill_errors.mean():.4f}')
print(f'均值填充平均误差: {mean_errors.mean():.4f}')
=== 缺失值统计 ===
总行数: 242
缺失值数量: 20
缺失值比例: 8.26%

缺失值位置:
DatetimeIndex(['2023-01-11', '2023-01-16', '2023-01-31', '2023-02-13',
               '2023-03-14', '2023-04-13', '2023-05-10', '2023-06-26',
               '2023-07-14', '2023-08-16'],
              dtype='datetime64[ns]', name='datetime', freq=None)

宁波港股票价格不同缺失值填充方法对比

=== 填充误差对比(绝对误差均值)===
前向填充平均误差: 0.0261
后向填充平均误差: 0.0196
均值填充平均误差: 0.0812

关键要点: - 前向填充使用前一个有效值,适合时间序列数据 - 后向填充使用后一个有效值,可能引入未来信息 - 均值填充使用整体均值,不保留时间序列特征 - 金融时间序列中,前向填充通常是最合理的方法


6.3.2 习题 7.2: 数据去重与合并

问题描述

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

  1. 数据集A:包含每日交易数据(日期、收盘价、成交量)
  2. 数据集B:包含财务指标数据(日期、市盈率、市净率)

请完成以下任务: 1. 检查并去除数据集中的重复行 2. 按日期将两个数据集合并 3. 处理合并后产生的缺失值 4. 分析合并后数据的完整性

完整解答

import pandas as pd
import numpy as np

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).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'})
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]

# 筛选宁波银行的数据
stock_data['symbol'] = '002142.SZ'
nby_data = stock_data.copy()
nby_data.set_index('datetime', inplace=True)

# 创建数据集A:交易数据
dataset_a = pd.DataFrame({
    'date': nby_data.index,
    'close': nby_data['close'],
    'volume': nby_data['volume']
}).reset_index(drop=True)

# 添加一些重复行(模拟数据质量问题)
dataset_a = pd.concat([dataset_a, dataset_a.iloc[10:15]], ignore_index=True)

# 创建数据集B:财务指标(模拟数据)
dates_b = nby_data.index[::5]  # 每5天有一个财务数据点
dataset_b = pd.DataFrame({
    'date': dates_b,
    'pe_ratio': np.random.uniform(8, 15, len(dates_b)),
    'pb_ratio': np.random.uniform(0.8, 1.5, len(dates_b))
}).reset_index(drop=True)

print('=== 原始数据集信息 ===')
print(f'数据集A形状: {dataset_a.shape}')
print(f'数据集B形状: {dataset_b.shape}')

# 1. 检查并去除重复行
print('\n=== 数据集A 重复行检查 ===')
duplicates_a = dataset_a.duplicated()
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}')

# 2. 按日期合并数据集
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=== 合并后数据示例 ===')
print(merged.head(10))

# 3. 处理缺失值
print('\n=== 缺失值统计 ===')
print(merged.isna().sum())

# 对财务指标使用前向填充
merged['pe_ratio'] = merged['pe_ratio'].ffill()
merged['pb_ratio'] = merged['pb_ratio'].ffill()

print('\n=== 填充后缺失值统计 ===')
print(merged.isna().sum())

# 4. 分析数据完整性
completeness = (1 - merged.isna().mean()) * 100
print('\n=== 数据完整性 ===')
for col in merged.columns:
    print(f'{col}: {completeness[col]:.2f}%')

# 显示最终数据
print('\n=== 最终合并数据 ===')
print(merged.head(15))
=== 原始数据集信息 ===
数据集A形状: (247, 3)
数据集B形状: (49, 3)

=== 数据集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)

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

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

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

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

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

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

关键要点: - drop_duplicates() 默认保留第一次出现的行 - 内连接 (inner) 只保留两个数据集都有的日期 - 外连接 (outer) 保留所有日期,可能产生缺失值 - 左连接 (left) 保留左侧数据集的所有日期 - 对财务指标使用前向填充是合理的,因为财务数据更新频率较低


6.3.3 习题 7.3: 数据转换与标准化

问题描述

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

  1. 计算两只股票的日收益率
  2. 使用 Z-score 标准化方法对收益率进行标准化
  3. 使用 Min-Max 标准化方法对收益率进行标准化
  4. 比较两种标准化方法的效果

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

在比较像“中石油”与“宁德时代”这类市值与价位迥异的资产时,直接比较其绝对变动毫无意义。通过标准化,我们将数据特征映射到同一维度:

  1. Z-score 标准化 (Standardization)\[ z_i = \frac{x_i - \mu}{\sigma} \]
    • 特性:转换后的序列均值为 0,标准差为 1。
    • 优势:它不改变原始数据的分布形状,特别适用于需要保留离群值(Outliers)影响的统计建模(如回归分析)。它也是衡量某个观测值离均值“有多远”的标准尺。
  2. Min-Max 标准化 (Normalization/Scaling)\[ x'_i = \frac{x_i - \min(x)}{\max(x) - \min(x)} \]
    • 特性:将数据精确地压缩在 \([0, 1]\) 区间。
    • 场景:常用于多指标权重的可视化(如雷达图)以及对输入范围敏感的机器学习算法(如神经网络)。

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data_nbz = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data_nby = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})
stock_data_nbz['datetime'] = pd.to_datetime(stock_data_nbz['trade_date'], format='%Y%m%d')
stock_data_nby['datetime'] = pd.to_datetime(stock_data_nby['trade_date'], format='%Y%m%d')

nbz = stock_data_nbz[(stock_data_nbz['datetime'] >= '2023-01-01') & (stock_data_nbz['datetime'] <= '2023-12-31')][['datetime', 'close']].copy()
nby = stock_data_nby[(stock_data_nby['datetime'] >= '2023-01-01') & (stock_data_nby['datetime'] <= '2023-12-31')][['datetime', 'close']].copy()

nbz.set_index('datetime', inplace=True)
nby.set_index('datetime', inplace=True)

# 1. 计算日收益率
nbz_returns = nbz['close'].pct_change().dropna()
nby_returns = nby['close'].pct_change().dropna()

returns_df = pd.DataFrame({
    '宁波港': nbz_returns,
    '宁波银行': nby_returns
}).dropna()

print('=== 收益率描述性统计 ===')
print(returns_df.describe())

# 2. Z-score 标准化
zscore_df = (returns_df - returns_df.mean()) / returns_df.std()

print('\n=== Z-score 标准化后统计 ===')
print(zscore_df.describe())

# 3. Min-Max 标准化
minmax_df = (returns_df - returns_df.min()) / (returns_df.max() - returns_df.min())

print('\n=== Min-Max 标准化后统计 ===')
print(minmax_df.describe())

# 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)
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')
axes[1].grid(True, alpha=0.3)

# Min-Max 标准化
minmax_df.plot(ax=axes[2], alpha=0.7, linewidth=1)
axes[2].set_title('Min-Max 标准化', fontsize=14, fontweight='bold')
axes[2].set_ylabel('标准化值')
axes[2].set_ylim([0, 1])
axes[2].legend(loc='upper left')
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# 计算标准化后的统计特性
print('\n=== 标准化方法对比 ===')
print('Z-score 标准化特性:')
print(f'  均值接近0: {np.allclose(zscore_df.mean(), 0, atol=1e-10)}')
print(f'  标准差接近1: {np.allclose(zscore_df.std(), 1, atol=1e-10)}')

print('\nMin-Max 标准化特性:')
print(f'  最小值接近0: {np.allclose(minmax_df.min(), 0, atol=1e-10)}')
print(f'  最大值接近1: {np.allclose(minmax_df.max(), 1, atol=1e-10)}')
=== 收益率描述性统计 ===
              宁波港        宁波银行
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

=== 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

=== 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

Z-score 与 Min-Max 标准化对比

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

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

关键要点: - Z-score 标准化保留了原始数据的分布形状,突出异常值 - Min-Max 标准化将数据压缩到 [0, 1] 区间,便于可视化 - 标准化是许多机器学习算法的必要预处理步骤 - 在金融数据分析中,标准化有助于比较不同量级的指标


6.3.4 习题 7.4: 字符串数据处理

问题描述

假设你有一份包含股票代码和公司名称的数据,数据格式不统一:

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

完整解答

import pandas as pd
import numpy as np
import re

# 创建模拟数据
data = {
    'stock_info': [
        '601018.SH-宁波港',
        '002142.SZ-宁波银行',
        '600519.SH-贵州茅台',
        '000001.SZ-平安银行',
        '601318.SH-中国平安'
    ],
    'exchange': [
        '上海证券交易所',
        '深圳证券交易所',
        'SHANGHAI',
        'SHENZHEN',
        'Shanghai Stock Exchange'
    ],
    'sector': [
        '交通运输|港口',
        '金融服务|银行',
        '消费品|白酒',
        '金融|银行',
        '金融服务|保险'
    ]
}

df = pd.DataFrame(data)

print('=== 原始数据 ===')
print(df)

# 1. 提取股票代码
def extract_stock_code(info):
    """从复合字段中提取股票代码"""
    match = re.match(r'(\d{6}\.\w{2})', info)
    return match.group(1) if match else None

def extract_company_name(info):
    """从复合字段中提取公司名称"""
    parts = info.split('-')
    return parts[1] if len(parts) > 1 else None

df['stock_code'] = df['stock_info'].apply(extract_stock_code)
df['company_name'] = df['stock_info'].apply(extract_company_name)

print('\n=== 提取股票代码和公司名称 ===')
print(df[['stock_code', 'company_name']])

# 2. 统一交易所名称的大小写
def normalize_exchange(name):
    """统一交易所名称格式"""
    name_upper = name.upper()
    if 'SHANGHAI' in name_upper or '上海' in name:
        return '上海证券交易所'
    elif 'SHENZHEN' in name_upper or '深圳' in name:
        return '深圳证券交易所'
    else:
        return name

df['exchange_normalized'] = df['exchange'].apply(normalize_exchange)

print('\n=== 统一交易所名称 ===')
print(df[['exchange', 'exchange_normalized']])

# 3. 从复合字段拆分信息
df[['primary_sector', 'secondary_sector']] = df['sector'].str.split('|', expand=True)

print('\n=== 拆分行业信息 ===')
print(df[['sector', 'primary_sector', 'secondary_sector']])

# 4. 高级字符串操作
# 提取股票代码的前缀(市场标识)
df['market_prefix'] = df['stock_code'].str.extract(r'(\d{6})\.(\w{2})')[1]

# 判断是否为沪市主板
df['is_sh_main'] = df['stock_code'].str.startswith('60').astype(int)

# 判断是否为金融类
df['is_financial'] = df['primary_sector'].isin(['金融服务', '金融']).astype(int)

print('\n=== 高级字符串处理结果 ===')
print(df[['stock_code', 'market_prefix', 'is_sh_main', 'primary_sector', 'is_financial']])

# 5. 批量替换和清理
# 创建一个包含不规范文本的列
df['notes'] = [
    '  宁波港是  中国  最大的港口之一  ',
    '宁波银行  在  深圳上市',
    '  贵州茅台  是  白酒  龙头  ',
    '平安银行  总部在深圳  ',
    '中国平安是  保险  龙头企业'
]

# 去除多余空格
df['notes_cleaned'] = df['notes'].str.replace(r'\s+', ' ', regex=True).str.strip()

print('\n=== 清理不规则空格 ===')
print(df[['notes', 'notes_cleaned']])
=== 原始数据 ===
       stock_info                 exchange   sector
0   601018.SH-宁波港                  上海证券交易所  交通运输|港口
1  002142.SZ-宁波银行                  深圳证券交易所  金融服务|银行
2  600519.SH-贵州茅台                 SHANGHAI   消费品|白酒
3  000001.SZ-平安银行                 SHENZHEN    金融|银行
4  601318.SH-中国平安  Shanghai Stock Exchange  金融服务|保险

=== 提取股票代码和公司名称 ===
  stock_code company_name
0  601018.SH          宁波港
1  002142.SZ         宁波银行
2  600519.SH         贵州茅台
3  000001.SZ         平安银行
4  601318.SH         中国平安

=== 统一交易所名称 ===
                  exchange exchange_normalized
0                  上海证券交易所             上海证券交易所
1                  深圳证券交易所             深圳证券交易所
2                 SHANGHAI             上海证券交易所
3                 SHENZHEN             深圳证券交易所
4  Shanghai Stock Exchange             上海证券交易所

=== 拆分行业信息 ===
    sector primary_sector secondary_sector
0  交通运输|港口           交通运输               港口
1  金融服务|银行           金融服务               银行
2   消费品|白酒            消费品               白酒
3    金融|银行             金融               银行
4  金融服务|保险           金融服务               保险

=== 高级字符串处理结果 ===
  stock_code market_prefix  is_sh_main primary_sector  is_financial
0  601018.SH            SH           1           交通运输             0
1  002142.SZ            SZ           0           金融服务             1
2  600519.SH            SH           1            消费品             0
3  000001.SZ            SZ           0             金融             1
4  601318.SH            SH           1           金融服务             1

=== 清理不规则空格 ===
                   notes    notes_cleaned
0    宁波港是  中国  最大的港口之一    宁波港是 中国 最大的港口之一
1          宁波银行  在  深圳上市      宁波银行 在 深圳上市
2      贵州茅台  是  白酒  龙头       贵州茅台 是 白酒 龙头
3          平安银行  总部在深圳         平安银行 总部在深圳
4        中国平安是  保险  龙头企业    中国平安是 保险 龙头企业

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


6.3.5 习题 7.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]\) 之外。
    • 金融特征:相比均值,四分位数对极端波动的抵抗力极强(Robust)。在 A 股这类厚尾分布市场中,IQR 能更好地锁定真正的非市场噪音。
  2. Z-score 方法
    • 公式\(|z_i| > 3\)
    • 限制:该方法假设数据服从正态分布。然而,金融资产收益率通常表现出“尖峰厚尾”(Fat Tails),导致该方法在实际回测中可能会漏掉大量具有统计显著性的极端行情。
  3. Winsorization (缩尾处理)
    • 策略:不直接删除,而是将超出边界的值通过 clip 操作强制收敛到特定的百分位(如 5% 或 95%)。
    • 价值:在维护样本完整性的同时,最大程度减少了单一交易日异常天量成交对全样本统计特性的“挟持”。

完整解答

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).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'})
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]

# 筛选宁波银行的数据
stock_data['symbol'] = '002142.SZ'
nby = stock_data.rename(columns={'vol': 'volume'})[['datetime', 'close', 'volume']].copy()
nby.set_index('datetime', inplace=True)

# 添加一些人为异常值(模拟数据错误或大宗交易)
np.random.seed(42)
anomaly_indices = np.random.choice(len(nby), size=5, replace=False)
nby_with_anomalies = nby.copy()
nby_with_anomalies.iloc[anomaly_indices, nby_with_anomalies.columns.get_loc('volume')] *= 10

volume = nby_with_anomalies['volume']

# 1. IQR 方法检测异常值
Q1 = volume.quantile(0.25)
Q3 = volume.quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

outliers_iqr = (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'检测到异常值数量: {outliers_iqr.sum()}')

# 2. Z-score 方法检测异常值
mean_volume = volume.mean()
std_volume = volume.std()
z_scores = (volume - mean_volume) / std_volume

outliers_zscore = np.abs(z_scores) > 3

print('\n=== Z-score 方法 ===')
print(f'均值: {mean_volume:,.0f}')
print(f'标准差: {std_volume:,.0f}')
print(f'检测到异常值数量: {outliers_zscore.sum()}')

# 3. 处理异常值
# 方法1: 删除异常值
volume_cleaned_drop = volume[~outliers_iqr]

# 方法2: 替换为均值
volume_cleaned_mean = volume.copy()
volume_cleaned_mean[outliers_iqr] = mean_volume

# 方法3: Winsorization(缩尾处理)
def winsorize(series, lower_percentile=5, upper_percentile=95):
    """对序列进行缩尾处理"""
    lower = series.quantile(lower_percentile / 100)
    upper = series.quantile(upper_percentile / 100)
    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}')

# 4. 可视化对比
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 原始数据(含异常值)
volume.plot(ax=axes[0, 0], color='blue', alpha=0.7, linewidth=1)
axes[0, 0].scatter(volume[outliers_iqr].index, volume[outliers_iqr],
                   color='red', s=100, zorder=5, label=f'异常值 ({outliers_iqr.sum()}个)')
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)

# 删除异常值
volume_cleaned_drop.plot(ax=axes[0, 1], color='green', alpha=0.7, linewidth=1)
axes[0, 1].set_title(f'删除异常值(样本数: {len(volume_cleaned_drop)})', fontsize=12, fontweight='bold')
axes[0, 1].set_ylabel('成交量')
axes[0, 1].grid(True, alpha=0.3)

# 均值替换
volume_cleaned_mean.plot(ax=axes[1, 0], color='orange', alpha=0.7, linewidth=1)
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)
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')

plt.tight_layout()
plt.show()
=== IQR 方法 ===
Q1 (25%): 21,081,494
Q3 (75%): 37,737,304
IQR: 16,655,811
异常值下界: -3,902,222
异常值上界: 62,721,021
检测到异常值数量: 13

=== Z-score 方法 ===
均值: 37,499,582
标准差: 54,780,447
检测到异常值数量: 3

=== 异常值处理方法对比 ===
原始数据 - 均值: 37,499,582, 标准差: 54,780,447
删除异常值 - 均值: 29,029,478, 标准差: 11,974,333
均值替换 - 均值: 29,484,483, 标准差: 11,803,061
缩尾处理 - 均值: 30,911,465, 标准差: 13,836,083

异常值检测与处理方法对比

关键要点: - IQR 方法对偏态分布更鲁棒,不依赖均值和标准差 - Z-score 方法假设数据服从正态分布 - 删除异常值会丢失数据,可能引入偏差 - 替换为均值会低估方差 - Winsorization 保留了数据点,同时限制了极端值的影响 - 在金融数据分析中,需要仔细判断是数据错误还是真实的市场极端情况


6.3.6 习题 7.6: 数据重塑与透视表

问题描述

给定多只股票(宁波港、宁波银行、贵州茅台)的多日交易数据,请:

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

完整解答

import pandas as pd
import numpy as np

# 从本地 Parquet 文件读取数据代替 HDF5
stock_data_nbz = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_nby = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_m = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '600519.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_nbz['symbol'] = '601018.SH'
stock_data_nby['symbol'] = '002142.SZ'
stock_data_m['symbol'] = '600519.SH'
stock_data = pd.concat([stock_data_nbz, stock_data_nby, stock_data_m])
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')]

# 筛选三只股票的数据
symbols = ['601018.SH', '002142.SZ', '600519.SH']
filtered_data = stock_data[stock_data['symbol'].isin(symbols)][['datetime', 'symbol', 'close', 'volume']].copy()
filtered_data = filtered_data.rename(columns={'vol': 'volume'})

# 添加涨跌标志
filtered_data = filtered_data.sort_values(['symbol', 'datetime'])
filtered_data['price_change'] = filtered_data.groupby('symbol')['close'].pct_change()
filtered_data['direction'] = pd.cut(filtered_data['price_change'],
                                    bins=[-np.inf, 0, np.inf],
                                    labels=['下跌', '上涨'])

# 删除第一天的NaN
filtered_data = filtered_data.dropna(subset=['price_change'])

# 重命名股票代码
filtered_data['symbol'] = filtered_data['symbol'].map({
    '601018.SH': '宁波港',
    '002142.SZ': '宁波银行',
    '600519.SH': '贵州茅台'
})

print('=== 数据示例 ===')
print(filtered_data.head(10))

# 1. 创建透视表
pivot_close = pd.pivot_table(filtered_data,
                             values='close',
                             index='datetime',
                             columns='symbol',
                             aggfunc='mean')

print('\n=== 透视表:各股票收盘价 ===')
print(pivot_close.head())

# 2. 创建交叉表:涨跌天数统计
crosstab_direction = pd.crosstab(
    index=filtered_data['symbol'],
    columns=filtered_data['direction'],
    margins=True
)

print('\n=== 交叉表:涨跌天数统计 ===')
print(crosstab_direction)

# 计算上涨比例
crosstab_direction['上涨比例'] = crosstab_direction['上涨'] / crosstab_direction['All']
print('\n=== 上涨比例 ===')
print(crosstab_direction[['上涨', '上涨比例']])

# 3. 使用 melt 进行数据重塑
# 将宽格式数据转换为长格式
wide_data = pivot_close.reset_index().melt(
    id_vars='datetime',
    var_name='股票',
    value_name='收盘价'
)

print('\n=== Melt 后的长格式数据 ===')
print(wide_data.head(10))

# 4. 多层索引操作
# 创建多层索引的DataFrame
multi_index_df = filtered_data.set_index(['datetime', 'symbol']).sort_index()

print('\n=== 多层索引数据结构 ===')
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'], :])

print('\n=== 选择特定股票的所有日期数据 ===')
print(multi_index_df.loc[(slice(None), '宁波港'), :].head())

# 5. 高级透视表:多值聚合
advanced_pivot = pd.pivot_table(filtered_data,
                                values=['close', 'volume'],
                                index='symbol',
                                aggfunc={
                                    'close': ['mean', 'std', 'min', 'max'],
                                    'volume': ['mean', 'sum']
                                })

print('\n=== 高级透视表:多值聚合 ===')
print(advanced_pivot)

# 6. 使用 pivot_table 创建热力图数据
# 创建按周聚合的涨跌幅数据
filtered_data['week'] = pd.to_datetime(filtered_data['datetime']).dt.isocalendar().week
weekly_returns = pd.pivot_table(filtered_data,
                                values='price_change',
                                index='week',
                                columns='symbol',
                                aggfunc='mean')

print('\n=== 按周聚合的平均涨跌幅 ===')
print(weekly_returns.head(10).round(4))
=== 数据示例 ===
       datetime symbol    close      volume  price_change direction
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        上涨
3771 2023-01-17   宁波银行  30.8135  31131828.0     -0.012283        下跌

=== 透视表:各股票收盘价 ===
symbol         宁波港     宁波银行       贵州茅台
datetime                              
2023-01-04  3.2944  30.5399  1562.3863
2023-01-05  3.2852  30.1933  1631.2125
2023-01-06  3.2669  29.7645  1633.7213
2023-01-09  3.2669  29.9470  1667.6226
2023-01-10  3.2485  29.6551  1679.6235

=== 交叉表:涨跌天数统计 ===
direction   下跌  上涨  All
symbol                 
宁波港         36  22   58
宁波银行        38  20   58
贵州茅台        33  25   58
All        107  67  174

=== 上涨比例 ===
direction  上涨      上涨比例
symbol                 
宁波港        22  0.379310
宁波银行       20  0.344828
贵州茅台       25  0.431034
All        67  0.385057

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

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

=== 选择特定日期的所有股票数据 ===
                       close      volume  price_change direction
datetime   symbol                                               
2023-01-04 宁波港        3.2944   8787221.0      0.005586        上涨
           宁波银行      30.5399  37930714.0      0.039430        上涨
           贵州茅台    1562.3863   2041575.0     -0.002890        下跌
2023-01-05 宁波港        3.2852  10407133.0     -0.002793        下跌
           宁波银行      30.1933  25570577.0     -0.011349        下跌
           贵州茅台    1631.2125   4794285.0      0.044052        上涨

=== 选择特定股票的所有日期数据 ===
                    close      volume  price_change direction
datetime   symbol                                            
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        下跌
2023-01-10 宁波港     3.2485   5882400.0     -0.005632        下跌

=== 高级透视表:多值聚合 ===
            close                                           volume  \
              max         mean        min        std          mean   
symbol                                                               
宁波港        3.4137     3.335388     3.2302   0.041861  9.649736e+06   
宁波银行      31.1967    27.817962    24.8570   2.050024  3.008607e+07   
贵州茅台    1732.5632  1643.558588  1562.3863  40.878781  2.436423e+06   

                      
                 sum  
symbol                
宁波港     5.596847e+08  
宁波银行    1.744992e+09  
贵州茅台    1.413125e+08  

=== 按周聚合的平均涨跌幅 ===
symbol     宁波港    宁波银行    贵州茅台
week                          
1      -0.0009  0.0046  0.0142
2      -0.0006  0.0082  0.0092
3       0.0050 -0.0045 -0.0028
5       0.0006 -0.0084 -0.0045
6       0.0000 -0.0026 -0.0008
7      -0.0022 -0.0087  0.0013
8       0.0017 -0.0002 -0.0035
9       0.0038  0.0055  0.0034
10     -0.0054 -0.0205 -0.0076
11      0.0061 -0.0035 -0.0009

关键要点: - pivot_table 适合进行数据汇总和分析,可以指定聚合函数 - crosstab 专门用于计算交叉表,统计频数 - melt 将宽格式转换为长格式,便于某些分析 - pivot 将长格式转换为宽格式,便于查看 - 多层索引提供了灵活的数据选择方式 - stack()unstack() 可以在多层索引和列之间转换


6.3.7 习题 7.7: 综合数据清洗项目

问题描述

你从三个不同数据源获取了宁波港的股票数据,需要进行综合清洗和整合:

  1. 数据源A:本地 HDF5 文件的日度行情数据(可能包含重复)
  2. 数据源B:API获取的财务数据(可能包含缺失值)
  3. 数据源C:外部数据文件的市场情绪数据(格式不统一)

请完成: 1. 检查并处理各数据源的质量问题 2. 统一数据格式和日期对齐 3. 整合三个数据源 4. 创建清洗报告

完整解答

import pandas as pd
import numpy as np
from datetime import datetime

print('=== 开始数据清洗项目 ===\n')

# ============================================================================
# 1. 加载数据源A:本地HDF5日度行情数据
# ============================================================================
print('[1/6] 加载数据源A:日度行情数据')
# 从本地 Parquet 文件读取数据代替 HDF5
stock_data_nbz = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '601018.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_nby = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '002142.XSHE')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_m = pd.read_parquet(
    'C:/qiufei/data/stock/stock_price_pre_adjusted.parquet',
    filters=[('order_book_id', '==', '600519.XSHG')]
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})

stock_data_nbz['symbol'] = '601018.SH'
stock_data_nby['symbol'] = '002142.SZ'
stock_data_m['symbol'] = '600519.SH'
stock_data = pd.concat([stock_data_nbz, stock_data_nby, stock_data_m])
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')]
data_a = stock_data[stock_data['symbol'] == '601018.SH'][['datetime', 'open', 'high', 'low', 'close', 'volume']].copy()
data_a = data_a.rename(columns={'vol': 'volume'})
data_a.set_index('datetime', inplace=True)

# 添加一些重复行(模拟数据问题)
data_a = pd.concat([data_a, data_a.iloc[10:13]], ignore_index=False)
data_a = data_a.sort_index()

print(f'  数据形状: {data_a.shape}')
print(f'  重复行数: {data_a.index.duplicated().sum()}')
print(f'  缺失值数: {data_a.isna().sum().sum()}')

# ============================================================================
# 2. 创建数据源B:财务数据(模拟)
# ============================================================================
print('\n[2/6] 创建数据源B:财务数据(模拟)')

# 财务数据通常是季度更新,这里模拟每10天有一个财务数据点
dates_b = pd.date_range(start='2023-01-01', end='2023-03-31', freq='10D')
data_b = pd.DataFrame({
    'datetime': dates_b,
    'pe_ratio': np.random.uniform(8, 12, len(dates_b)),
    'pb_ratio': np.random.uniform(0.6, 1.0, len(dates_b)),
    'market_cap': np.random.uniform(100e9, 120e9, len(dates_b))
})

# 添加一些缺失值
data_b.loc[2, 'pe_ratio'] = np.nan
data_b.loc[4, 'pb_ratio'] = np.nan

data_b.set_index('datetime', inplace=True)

print(f'  数据形状: {data_b.shape}')
print(f'  缺失值数: {data_b.isna().sum().sum()}')

# ============================================================================
# 3. 创建数据源C:市场情绪数据(模拟,格式不统一)
# ============================================================================
print('\n[3/6] 创建数据源C:市场情绪数据(模拟)')

# 情绪数据可能是从新闻或社交媒体提取的文本
dates_c = pd.date_range(start='2023-01-01', end='2023-03-31', freq='7D')
sentiments = ['积极', '中性', '消极', '积极', '积极', '中性', '消极', '积极',
              '中性', '积极', '积极', '消极', '中性']

data_c = pd.DataFrame({
    'date': [d.strftime('%Y-%m-%d') for d in dates_c[:len(sentiments)]],
    'sentiment_text': sentiments,
    'news_count': np.random.randint(5, 20, len(sentiments))
})

# 格式不统一: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()}')

# ============================================================================
# 4. 数据清洗
# ============================================================================
print('\n[4/6] 清洗各数据源')

# 清洗数据源A
data_a_clean = data_a[~data_a.index.duplicated(keep='first')]
print(f'  数据源A - 去除重复后: {data_a_clean.shape}')

# 清洗数据源B
# 对缺失值使用前向填充
data_b_clean = data_b.ffill()
print(f'  数据源B - 填充缺失值后: {data_b_clean.isna().sum().sum()} 个缺失值')

# 清洗数据源C
# 统一日期格式
data_c['date'] = pd.to_datetime(data_c['date'])
data_c = data_c.set_index('date')

# 统一情绪文本
def normalize_sentiment(text):
    """统一情绪文本格式"""
    text = str(text).strip().lower()
    if text in ['积极', 'positive']:
        return '积极'
    elif text in ['中性', 'neutral']:
        return '中性'
    elif text in ['消极', 'negative']:
        return '消极'
    else:
        return '未知'

data_c_clean = data_c.copy()
data_c_clean['sentiment'] = data_c_clean['sentiment_text'].apply(normalize_sentiment)
data_c_clean = data_c_clean[['sentiment', 'news_count']]
print(f'  数据源C - 清洗后唯一情绪值: {data_c_clean["sentiment"].unique()}')

# ============================================================================
# 5. 整合数据源
# ============================================================================
print('\n[5/6] 整合三个数据源')

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

# 对财务数据使用前向填充(因为财务数据更新频率低)
merged_data[['pe_ratio', 'pb_ratio', 'market_cap']] = merged_data[['pe_ratio', 'pb_ratio', 'market_cap']].ffill()

# 对情绪数据使用后向填充(今天的情绪适用于未来几天,直到有新情绪)
merged_data[['sentiment', 'news_count']] = merged_data[['sentiment', 'news_count']].bfill()

# 删除仍然有缺失值的行
merged_data = merged_data.dropna(subset=['open', 'high', 'low', 'close', 'volume'])

print(f'  整合后数据形状: {merged_data.shape}')
print(f'  整合后缺失值数: {merged_data.isna().sum().sum()}')

# ============================================================================
# 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数据范围:')
print(f'  日期范围: {merged_data.index.min()}{merged_data.index.max()}')
print(f'  总交易日数: {len(merged_data)}')

# 统计摘要
print('\n数值型变量统计:')
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}')
print(f'  数据损失率: {data_loss_rate:.2f}%')
print(f'  重复行去除率: {(data_a.index.duplicated().sum() / len(data_a) * 100):.2f}%')

# 显示最终数据样本
print('\n最终整合数据样本(前10行):')
print(merged_data.head(10))
=== 开始数据清洗项目 ===

[1/6] 加载数据源A:日度行情数据
  数据形状: (62, 5)
  重复行数: 3
  缺失值数: 0

[2/6] 创建数据源B:财务数据(模拟)
  数据形状: (9, 3)
  缺失值数: 2

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

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

[5/6] 整合三个数据源
  整合后数据形状: (59, 10)
  整合后缺失值数: 10

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

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

数据范围:
  日期范围: 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     10.49      0.85
std     0.04   4834033.37      0.88      0.12
min     3.23   3979300.00      8.84      0.67
25%     3.31   6286030.00      9.59      0.81
50%     3.34   8161311.00     10.78      0.82
75%     3.36  11008555.50     10.94      0.99
max     3.41  25577104.00     11.76      1.00

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

情绪比例:
sentiment
积极    0.370
中性    0.352
消极    0.278
Name: proportion, dtype: float64

数据质量指标:
  原始数据总行数: 84
  最终整合数据行数: 59
  数据损失率: 4.84%
  重复行去除率: 4.84%

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

              market_cap sentiment  news_count  
2023-01-03  1.037741e+11        中性        10.0  
2023-01-04  1.037741e+11        中性        10.0  
2023-01-05  1.037741e+11        中性        10.0  
2023-01-06  1.037741e+11        中性        10.0  
2023-01-09  1.037741e+11        消极        12.0  
2023-01-10  1.037741e+11        消极        12.0  
2023-01-11  1.055774e+11        消极        12.0  
2023-01-12  1.055774e+11        消极        12.0  
2023-01-13  1.055774e+11        消极        12.0  
2023-01-16  1.055774e+11        积极         8.0  

关键要点: - 真实的数据分析项目往往需要整合多个数据源 - 每个数据源可能有不同的质量问题需要针对性处理 - 外连接 (outer join) 可以保留所有数据,便于后续分析 - 不同类型的数据需要不同的填充策略 - 数据清洗报告是数据科学项目的重要交付物 - 数据清洗往往占据数据科学项目 60%-80% 的时间


6.4 延伸阅读

为了进一步深化您在数据清洗和预处理方面的理解,我们推荐以下精选资源:

6.4.1 Python与pandas进阶

1. pandas官方文档 - 链接: https://pandas.pydata.org/docs/ - 说明: pandas的官方文档是学习和查询数据操作方法的最权威资源。特别推荐深入学习: - Working with Missing Data (缺失数据处理) - Group By: split-apply-combine (分组聚合) - Reshaping and Pivot Tables (数据重塑) - Time Series/Date functionality (时间序列)

2. Python for Data Analysis, 3rd Edition by Wes McKinney - 作者: Wes McKinney (pandas库创始人) - 出版社: O’Reilly Media - 说明: 这是pandas数据分析的权威教材,深入讲解了pandas的设计理念和最佳实践。第7-9章详细讨论了数据清洗、整理和聚合。

3. Effective Pandas: Boost Data Workflow Efficiency - 网络课程: DataCamp - 链接: https://www.datacamp.com/courses/effective-pandas - 说明: 专注于教授pandas的高效使用模式,包括向量化操作、链式方法和性能优化。

6.4.2 数据清洗理论

4. Data Cleaning: A Practical Perspective (论文) - 作者: F. Naumann & L. Rollet - 期刊: ACM SIGKDD Explorations - 链接: https://dl.acm.org/doi/10.1145/3381108 - 说明: 从学术角度系统性地讨论了数据清洗的挑战、方法和工具,适合希望深入理解数据清洗理论基础的学习者。

5. Tidy Data (论文) - 作者: Hadley Wickham - 期刊: Journal of Statistical Software - 链接: https://www.jstatsoft.org/article/view/v059i10 - 说明: 提出了”整洁数据”(Tidy Data)的概念,定义了数据规范化的原则,是理解数据结构设计的重要文献。

6.4.3 缺失值处理专题

6. Flexible Imputation of Missing Data (论文) - 作者: J. L. Schafer - 专著: Chapman & Hall/CRC - 说明: 系统性地介绍了各种插值方法的理论基础,包括单一插值、多重插值等,是缺失值处理领域的经典教材。

7. Multiple Imputation for Nonresponse in Surveys (论文) - 作者: R. J. A. Little & D. B. Rubin - 出版社: Wiley - 说明: 深入讨论了调查数据中缺失值的处理方法,特别是多重插值(Multiple Imputation)的理论和实践。

6.4.4 金融数据清洗实践

8. Python for Finance Cookbook (第2章: Data Preprocessing) - 作者: James Powell & Felix Zumstein - 出版社: Packt Publishing - 说明: 包含了大量金融数据清洗的实际案例,包括: - 处理不同时间粒度的金融数据 - 处理公司行为数据(如股票分拆、分红) - 对齐不同市场的交易时间

9. Advances in Financial Machine Learning (第3章) - 编辑: G. N. Saldanheto, C. J. Costa, et al. - 期刊: Springer - 说明: 虽然聚焦机器学习,但第3章详细讨论了金融数据的特征工程和数据清洗,包括异常值检测、缺失值处理等。

6.4.5 在线资源与社区

10. Kaggle Data Cleaning Courses - 链接: https://www.kaggle.com/learn - 说明: Kaggle提供了一系列免费的数据清洗课程,包含大量实战练习,适合动手学习。

11. Stack Overflow - Pandas Tag - 链接: https://stackoverflow.com/questions/tagged/pandas - 说明: 当遇到具体的数据清洗问题时,Stack Overflow的pandas标签是寻求帮助的最佳社区资源。

12. GitHub Awesome Pandas Resources - 链接: https://github.com/TomAugspurger/pandas-exercises - 说明: 收集了大量pandas练习和教程,适合系统性地提升pandas技能。

6.4.6 数据质量评估框架

13. Data Quality Assessment Framework (技术报告) - 来源: MIT Data Quality Initiative - 说明: 提供了评估数据质量的系统性框架,包括完整性、准确性、一致性、及时性等维度。

14. Six Dimensions of Data Quality (论文) - 作者: R. Y. Wang & D. M. Strong - 期刊: Communications of the ACM - 说明: 提出了数据质量的六个维度理论,是理解和评估数据质量的理论基础。