8  数据聚合与分组操作

8.1 引言与学习目标

学习目标

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

  • 用“拆分—应用—合并”范式解释 groupby 的输入、分组键和输出索引;
  • 比较聚合、转换、过滤与通用 apply 的形状约束和计算成本;
  • 用命名聚合、透视表和交叉表生成可审计的分组统计;
  • 在本地长三角公司和中国指数数据上计算日收益,并按年度估计相关性与市场模型系数;
  • 检查分组样本量、缺失值和自由度约定,避免把描述性排序解释为投资建议;
  • 区分样本内描述、预测评估和因果识别,说明每类结论还需要哪些证据。

目标—正文形成性证据—核心习题评分映射

正式目标 学习活动或示例 正文形成性证据 核心习题 练习评分证据
解释拆分—应用—合并及输出索引 小节 8.2 的图示、遍历与索引活动 组数、组键、结果索引与行数输出 习题 8.1、8.2 分组键、结果索引和分组表行数核验
比较聚合、转换、过滤与 apply 小节 8.3小节 8.4小节 8.5 四类操作的输入—输出形状对照与优化/通用路径成本边界 习题 8.2—8.4 可观察输出、自定义函数结果及适用边界说明
使用命名聚合、透视表和交叉表 小节 8.3.1小节 8.6 命名列、边际汇总与频数守恒输出 习题 8.2、8.5 命名列、边际汇总、频数总和与非缺失收益方向样本数一致
在本地数据上计算日收益、年度相关与年度市场模型 小节 8.4.5小节 8.4.6 共同交易日日收益、年度有效样本数、年度相关矩阵与年度回归系数表 习题 8.1、8.6(仅评价收益聚合) 周收益复合、周×证券矩阵和分布输出;相关与回归不纳入核心习题评分
审计样本量、缺失值与自由度 小节 8.1.1ddof、偶数中位数和扫描断言 count/size 对照、ddof=0/1 与偶数组中位数断言 —(正文形成性核验) 不纳入核心习题评分,以正文可执行断言验收
区分描述、预测与因果证据 本章数据与推断边界及样本内星期汇总 星期均值、样本极值及推断限制说明 习题 8.6、8.7 明确样本内描述,不给择时建议,并列出样本外或识别设计要求

数据与推断边界

本章手工写入的短表、调查记录和随机数均明确属于“机制演示题设值”,只用于展示分组 API,不作公司经验解释。核心公司实证和习题读取本地 HDF5,并以长三角上市公司为主;为演示字典与 Series 分组键,区域映射机制例外使用北京、上海、江苏、浙江、广东、四川、河南和陕西八个省市的样本,不据此作总体或因果推断。分组均值、相关性和原始收益回归属于描述性或关联性结果;预测需要时间外样本,因果解释还需要额外识别设计。

对数据集进行分类,并对每个组应用一个函数(无论是聚合、转换还是过滤),是数据分析工作流程中的一个核心环节。在加载、合并和准备好数据集之后,我们经常需要计算分组统计量,或者为后续的报告和可视化创建数据透视表。pandas 提供了一个功能强大且灵活的 groupby 接口,使我们能够以一种自然的方式对数据集进行切片、切块和汇总。

关系型数据库和 SQL(结构化查询语言)之所以广受欢迎,原因之一在于它们能够轻松地连接、过滤、转换和聚合数据。然而,像 SQL 这样的查询语言对于可以执行的分组运算类型有一定的限制。借助 Python 和 pandas 的表现力,我们可以将复杂的分组运算表达为处理每个组相关数据的自定义 Python 函数。本章将学习如何:

  • 使用一个或多个键(以函数、数组或 DataFrame 列名的形式)将 pandas 对象拆分为多个部分。
  • 计算分组摘要统计,如计数、均值、标准差,或用户自定义的函数。
  • 应用组内转换或其他操作,如标准化、线性回归、排名或子集选择。
  • 计算透视表和交叉表。
  • 执行分位数分析和其他统计分组分析。

8.1.1 理论基础:分组聚合的数学原理

分组运算的数学表示

把数据写成有序记录序列 \(D=(r_i)_{i=1}^{n}\),其中 \(r_i=(x_i,y_i)\),分组函数 \(g:X\to G\) 把分组键映射到组标识。这里使用“序列”而不是普通集合,是为了保留重复记录和原始行序。对实际出现的组 \(j\in J\),定义按原始行号递增排列的索引序列与组内记录序列:

\[ I_j=(i\in\{1,\ldots,n\}:g(x_i)=j),\qquad D_j=(r_i)_{i\in I_j}. \]

若只聚合数值字段,则进一步记 \(Y_j=(y_i)_{i\in I_j}\)\(n_j=|I_j|\),其中 \(n_j\) 是组内总行数。对会跳过缺失值的列聚合,再定义 \(I_j^{\mathrm{obs}}=(i\in I_j:y_i\text{ 非缺失})\)\(Y_j^{\mathrm{obs}}=(y_i)_{i\in I_j^{\mathrm{obs}}}\)\(m_j=|I_j^{\mathrm{obs}}|\)。交换律聚合(如求和)不依赖组内顺序;diffshiftrolling、累计量和连续段长度却依赖顺序,所以执行这些运算前必须先按证券、业务时间等键稳定排序。

聚合运算的定义

给定可接受不同组长度的聚合函数 \(f\)(如求和、均值、最大值等),并由 \(f\) 明确是否跳过缺失值,分组聚合定义为:

\[ \operatorname{Aggregate}_f(D)=((j,f(Y_j)))_{j\in J}. \]

它对每组产生一个结果。与之不同,transform 对每组使用保持长度的映射 \(t_j:Y_j\to Z^{n_j}\),再按原始索引 \(I_j\) 放回 \(n\) 行;filter 用标量谓词 \(p(D_j)\) 保留或删除整组;apply 则允许任意映射 \(\phi_j:D_j\to O_j\),其输出可以是标量、序列或表,形状与索引由函数及 pandas 的组合规则共同决定,不能预设为逐元素变换。pandas 官方的 GroupBy 用户指南也按聚合、转换、筛选和通用应用区分这些输出合同。

常见聚合函数的数学定义

  1. 均值:对 \(m_j>0\),pandas 默认跳过该列缺失值,因而 \[ \bar{y}_j = \frac{1}{m_j}\sum_{i\in I_j^{\mathrm{obs}}}y_i =\frac{1}{|Y_j^{\mathrm{obs}}|}\sum_{y\in Y_j^{\mathrm{obs}}}y. \]

    \(m_j=0\) 时,该列的组均值为 NaN

  2. 方差与自由度:总体方差使用 ddof=0

    \[ s^2_{j,0}=\frac{1}{m_j}\sum_{y\in Y_j^{\mathrm{obs}}}(y-\bar y_j)^2,\qquad m_j>0; \tag{8.1}\]

    pandas 的 SeriesGroupBy.var() 默认使用样本方差 ddof=1

    \[ s^2_{j,1}=\frac{1}{m_j-1}\sum_{y\in Y_j^{\mathrm{obs}}}(y-\bar y_j)^2,\qquad m_j>1. \tag{8.2}\]

    因而组内只有一个非缺失值时,默认方差为 NaN,而不是 0。

  3. 中位数:当 \(m_j>0\) 时,将组内非缺失值排序为 \(y_{j(1)}\leq\cdots\leq y_{j(m_j)}\),则

    \[ \operatorname{Median}(D_j)= \begin{cases} y_{j((m_j+1)/2)}, & m_j\text{ 为奇数},\\ \dfrac{y_{j(m_j/2)}+y_{j(m_j/2+1)}}{2}, & m_j\text{ 为偶数}. \end{cases} \tag{8.3}\]

    pandas 对偶数样本取中间两数的算术平均,而不是下中位数。

  4. 行数与非缺失计数\[ \operatorname{size}(D_j)=n_j,\qquad \operatorname{count}(Y_j)=m_j=\sum_{i\in I_j}\mathbf 1\{y_i\text{ 非缺失}\}. \]

    因此 GroupBy.size() 包含缺失值所在的行,而列上的 GroupBy.count() 排除该列的缺失值。

Split-Apply-Combine的数学表示

给定有序数据 \(D\) 与分组函数 \(g\),Split 阶段产生带索引序列的组 \(((j,I_j,D_j))_{j\in J}\);Apply 阶段分别计算 \(O_j=\phi_j(D_j)\);Combine 阶段再按照操作合同拼接 \((j,O_j)\)。聚合按组形成较短结果,变换按 \(I_j\) 回填到原始行,筛选拼接被保留的完整组,而一般 apply 的组合结果取决于 \(O_j\) 的类型与索引。这一区分比把所有 Apply 都写成逐元素集合映射更准确。

计算复杂度分析

设输入共有 \(n\) 行、形成 \(k\) 个组。即使每次把一个元素累加到“和、计数”等聚合状态只需 \(O(1)\),算法仍必须读取全部输入,因此 summeancount 等可合并聚合的总扫描成本是 \(O(n)\),不是 \(O(1)\)

  • 采用哈希分组时,建立分组状态和扫描聚合通常为期望 \(O(n)\),状态空间通常为 \(O(k)\),另加输出空间。
  • 若需要对键排序,通常还会有 \(O(n\log n)\) 的排序成本;中位数或一般分位数还需要组内选择/排序,不能套用简单累加器的常数更新模型。
  • apply 可执行任意用户函数,其总成本应写为 \(O(n)+\sum_{j=1}^k T_\phi(n_j)\),不能仅由 groupby API 名称推断。
  • 无论采用何种实现,输出 \(r\) 行都至少需要 \(\Omega(r)\) 的物化成本。

透视表的数学表示

透视表是数据的多维汇总表示。设第 \(\ell\) 条记录包含行键 \(a_\ell\)、列键 \(b_\ell\) 与值 \(v_\ell\)。同一单元格可以对应零条、一条或多条原始记录。

透视表 \(P\) 定义为:

\[ P_{ab}=f\big((v_\ell)_{\ell:\,a_\ell=a,\ b_\ell=b}\big). \]

其中 \(f\)pivot_table 指定的聚合函数;同一 \((a,b)\) 的重复记录先由 \(f\) 聚合,没有记录的单元格为缺失值(除非再显式设置填充值)。这也解释了为何不带聚合合同的 pivot 要求行列键组合唯一。

实务观察:横截面聚合与时间序列重采样的逻辑分野

虽然本章主要聚焦于横截面数据(Cross-sectional Data)的分组逻辑,但量化分析师必须意识到 groupby 在时间尺度上的特殊形态。对于高频或日度交易数据,一个核心的应用场景是重采样 (Resampling)

  1. 逻辑差异:普通 groupby 通常基于类别(如行业、地区)进行拆分,而重采样(如使用 resample())是基于时间频率的“桶”进行聚合(如将日K线合成为月K线)。
  2. 工程实践:在构建多因子模型时,我们常先通过 groupby 在行业维度计算因子暴露,再通过时间维度进行聚合分析。关于时间序列特有的分组技术,我们将在后续章节中进行系统性推演。

与前面的章节一样,我们首先导入 NumPy 和 pandas:

import platform  # 识别运行平台以统一后续真实数据路径
from pathlib import Path  # 用basename将行情文件与复权口径显式绑定
import numpy as np                                          # 复算分组方差与偶数组中位数
import pandas as pd                                         # 构造带分组键的验收表
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 选择当前平台的规范数据根目录
PRE_ADJUSTED_PRICE_PATH = Path(DATA_ROOT) / 'stock' / 'stock_price_pre_adjusted.h5'  # 全章真实股票案例统一使用前复权日行情
price_adjustment = '前复权'  # 与教材数据字典的pre_adjusted口径一致
assert (PRE_ADJUSTED_PRICE_PATH.name, price_adjustment) == ('stock_price_pre_adjusted.h5', '前复权')  # 防止文件basename与复权注记被批量改写反转
chapter_rng = np.random.default_rng(2026)                   # 固定种子保证教学输出可复现

下面的断言把 式 8.1式 8.2式 8.3 直接对应到 pandas 默认行为。

aggregation_convention_df = pd.DataFrame({'group': ['A', 'A', 'B'], 'value': [1.0, 3.0, 5.0]})  # A组为偶数样本,B组为单观测
sample_variance = aggregation_convention_df.groupby('group')['value'].var()  # 使用pandas默认ddof=1计算样本方差
population_variance = aggregation_convention_df.groupby('group')['value'].var(ddof=0)  # 显式改用ddof=0计算总体方差
group_median = aggregation_convention_df.groupby('group')['value'].median()  # 按pandas规则计算组内非缺失中位数
assert sample_variance.loc['A'] == 2.0 and pd.isna(sample_variance.loc['B'])  # 核验默认样本方差及单观测NaN
assert population_variance.loc['A'] == 1.0 and population_variance.loc['B'] == 0.0  # 核验总体方差采用各列非缺失数m_j作为分母
assert group_median.loc['A'] == 2.0  # 核验偶数组取1与3的平均数而非下中位数
pd.concat({'ddof_1': sample_variance, 'ddof_0': population_variance, 'median': group_median}, axis='columns')  # 汇总为可见验收表
ddof_1 ddof_0 median
group
A 2.0 1.0 2.0
B NaN 0.0 5.0

8.2 理解分组运算的思维模式

哈德利·威克姆(Hadley Wickham)在 Wickham(2011)《The Split-Apply-Combine Strategy for Data Analysis》中系统表述了 “拆分—应用—合并”(split-apply-combine) 策略。这是理解分组运算的强大心智模型;本文献支持术语与方法史归属,具体 pandas 行为仍以官方文档和本章可执行断言为准。

  1. 拆分 (Split):pandas 对象(如 DataFrameSeries)中包含的数据,根据您提供的一个或多个键分割成若干组。拆分是在对象的特定轴上执行的。例如,DataFrame 可以在其行上(axis='index')或列上(axis='columns')进行分组。
  2. 应用 (Apply): 将一个函数独立地应用于每个组,从而产生一个新值(或一个新的数据结构)。这可以是一个聚合函数,如 sum()mean();也可以是一个转换函数,如对数据进行标准化;或者是一个更复杂的自定义函数。
  3. 合并 (Combine): 将所有函数应用的结果合并成一个单一的结果对象。这个最终对象的结构取决于所执行的操作。

图 8.1 为这个过程提供了一个概念性的图示。

import matplotlib.pyplot as plt                             # 导入matplotlib绑图库用于数据可视化
import matplotlib.patches as patches  # 导入matplotlib库用于数据可视化
import pandas as pd                                         # 将拆分前后字典组织为图示数据框

# 为图示准备数据
data_initial = {                                            # 初始数据:含分组键与对应数值
    'Key': ['A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C'],  # 分组标识列
    'Data': [1, 3, 4, 2, 3, 5, 3, 3, 3]  # 待聚合的数值列
}                                                           # 字典构建完成
df_initial = pd.DataFrame(data_initial)                     # 将字典转换为DataFrame

data_split = {                                              # 按分组键拆分后的数据
    'A': [1, 2, 3],                                         # 键A取出原表第1、4、7行,组和为6
    'B': [3, 3, 3],                                         # 键B的三个观测相同,组和为9
    'C': [4, 5, 3]                                          # 键C保留4、5、3三个值,组和为12
}                                                           # 拆分数据字典构建完成

data_combined = {                                           # 聚合后合并的结果数据
    'Key': ['A', 'B', 'C'],                                 # 各组的分组键
    'Data': [6, 9, 12]                                      # A、B、C三组的和依次为6、9、12
}                                                           # 合并结果字典构建完成
df_combined = pd.DataFrame(data_combined)                   # 将合并结果转换为DataFrame

# 创建图形
fig, axes = plt.subplots(1, 3, figsize=(12, 6))             # 建立显式坐标轴,以分别控制数据映射和出版格式
fig.suptitle('拆分-应用-合并 工作流', fontsize=16)  # 设置图表总标题
# 左面板保留九条Key—Data输入,作为拆分前基准
ax = axes[0]                                                # 左面板承载拆分前九行Key—Data原始表
ax.set_title('初始 DataFrame')                                # 左图呈现拆分前Key与Data的逐行对应关系
ax.axis('off')                                              # 左面板仅呈现初始Key—Data表,不显示数值轴
table_initial = ax.table(cellText=df_initial.values, colLabels=df_initial.columns, loc='center', cellLoc='center')  # 绘制初始数据表格
table_initial.auto_set_font_size(False)                     # 关闭自动字号调整
table_initial.set_fontsize(12)                              # 设置表格字号为12
table_initial.scale(1, 2)                                   # 纵向放大表格便于阅读

# --- 拆分/应用面板 ---
ax = axes[1]                                                # 中面板承载A、B、C三组输入值及逐组求和箭头
ax.set_title('1. 拆分 & 2. 应用 (求和)')                          # 中图显示按Key拆成A/B/C组并对每组Data求和
ax.axis('off')                                              # 中面板用分组小表和箭头表达流程,关闭坐标刻度

# A组:展示原表中不相邻的1、2、3如何被同一键收集
ax.text(0.1, 0.85, '组 A', fontsize=12, weight='bold')       # 在最上方标出首个分组键A
ax.table(cellText=[[v] for v in data_split['A']], loc='center left', bbox=[0.1, 0.6, 0.2, 0.25]).scale(1, 1.5)  # 表格逐行保留A组的三个输入值
# B组:三个相同输入用于说明聚合仍按记录计数
ax.text(0.1, 0.55, '组 B', fontsize=12, weight='bold')       # 将第二个键B放在中部流程位置
ax.table(cellText=[[v] for v in data_split['B']], loc='center left', bbox=[0.1, 0.3, 0.2, 0.25]).scale(1, 1.5)  # 中间小表显示B组的3、3、3
# C组:输入次序4、5、3在求和前保持不变
ax.text(0.1, 0.25, '组 C', fontsize=12, weight='bold')       # 在底部标出最后一个键C
ax.table(cellText=[[v] for v in data_split['C']], loc='center left', bbox=[0.1, 0.0, 0.2, 0.25]).scale(1, 1.5)  # 底部小表呈现C组原始次序

# 箭头和求和操作
ax.add_patch(patches.Arrow(0.4, 0.725, 0.2, 0, width=0.05, color='gray'))  # 箭头把1、2、3指向求和结果6
ax.text(0.5, 0.78, 'Sum', ha='center')                      # 注明A组使用求和归约
ax.add_patch(patches.Arrow(0.4, 0.425, 0.2, 0, width=0.05, color='gray'))  # 中部箭头表示三个3归约为9
ax.text(0.5, 0.48, 'Sum', ha='center')                      # 注明B组执行相同求和函数
ax.add_patch(patches.Arrow(0.4, 0.125, 0.2, 0, width=0.05, color='gray'))  # 底部箭头把4、5、3汇入结果12
ax.text(0.5, 0.18, 'Sum', ha='center')                      # 注明C组求和后进入合并阶段

# 求和结果
ax.table(cellText=[['6'], ['9'], ['12']], loc='center right', bbox=[0.7, 0.3, 0.2, 0.4]).scale(1, 2.5)  # 绘制各组求和结果小表
# --- 合并面板 ---
ax = axes[2]                                                # 右面板只显示Key与组和组成的三行结果schema
ax.set_title('3. 合并')                                       # 右图把A=6、B=9、C=12重组为按键索引的结果表
ax.axis('off')                                              # 右面板只展示Key与组和的合并结果表
table_combined = ax.table(cellText=df_combined.values, colLabels=df_combined.columns, loc='center', cellLoc='center')  # 绘制合并结果表格
table_combined.auto_set_font_size(False)                    # 关闭自动字号调整
table_combined.set_fontsize(12)                             # 设置表格字号为12
table_combined.scale(1, 2)                                  # 纵向放大表格便于阅读

plt.tight_layout(rect=[0, 0, 1, 0.96])                      # 自动调整布局间距
plt.show()                                                  # 输出拆分、逐组求和与合并结果三面板流程图
<Figure size 672x480 with 0 Axes>
图 8.1: 分组聚合(拆分-应用-合并)图示

每个分组键可以有多种形式,而且键的类型不必完全相同: * 一个与分组轴长度相同的列表或数组。 * 一个指示 DataFrame 中列名的值。 * 一个给出分组轴上的值与组名之间对应关系的字典或 Series。 * 一个作用于轴索引或索引中各个标签的函数。

如果这些听起来有些抽象,不必担心。在本章中,我们将为所有这些方法提供许多示例。首先,我们创建一个小型的表格数据集作为 DataFrame

sample_trade_records_df = pd.DataFrame({'stock_code' : ['600000', '600000', None, '600926', '600926', '600000', None],  # 构建含股票代码的结构演示记录
                   'analyst_id': pd.Series([1, 2, 1, 2, 1, None, 1], dtype='float64'),  # 分析师编号,包含缺失值
                   'returns': chapter_rng.standard_normal(7),
                   'volatility': chapter_rng.standard_normal(7)})
sample_trade_records_df  # 输出7行×4列题设表,保留缺失证券代码与分析师编号
表 8.1: 用于分组运算的模拟交易记录DataFrame
stock_code analyst_id returns volatility
0 600000 1.0 -0.793122 0.303835
1 600000 2.0 0.240571 -0.267660
2 None 1.0 -1.896326 -0.225909
3 600926 2.0 1.395772 0.720068
4 600926 1.0 0.638295 0.514705
5 600000 NaN -0.292047 -0.064128
6 None 1.0 -0.311949 -0.085477

假设您想根据股票代码 stock_code 来计算收益率 returns 列的均值。在 pandas 中,最直观的方法是将目标列提取出来,并使用分组列(在这里也是一个 Series)来调用 groupby

grouped = sample_trade_records_df['returns'].groupby(sample_trade_records_df['stock_code'])  # 以stock_code切分returns;缺失证券代码默认不形成组
grouped  # 显示尚未归约的SeriesGroupBy对象及其目标列
<pandas.core.groupby.generic.SeriesGroupBy object at 0x7f191c21a2c0>

核心机制:理解 GroupBy 对象的延迟计算边界

调用 groupby 后返回的 <SeriesGroupBy> 对象保存分组规则和目标数据;部分分组工作会延迟到后续操作触发,但它不是一个承诺所有步骤都惰性执行的通用“惰性序列”。理解这一边界有助于评估大规模金融数据处理的成本:

  • 分组描述:该对象主要封装分组键(映射逻辑)、目标数据和操作配置;创建对象时通常尚未生成最终归约结果。
  • 操作触发:调用 .mean().sum().apply() 才会请求具体结果。内置归约可能走 C/Cython 优化路径,而自定义 apply 往往需要逐组调用 Python 函数;实际内存和时间开销取决于操作、数据类型、组数及 pandas 版本,不能由“延迟”一词单独保证。

例如,要计算各组的均值,我们可以直接调用 GroupBy 对象的 mean 方法:

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

grouped.mean()  # 输出证券代码索引的收益均值;缺失代码组默认被排除
表 8.2: 按 stock_code 分组的 returns 均值
stock_code
600000   -0.281533
600926    1.017033
Name: returns, dtype: float64

输出的结果是一个新的 Series。可以看到,600000600926 的收益率均值已经被计算出来,而原始数据中 stock_codeNone 的行已被自动排除在结果之外。

数据(一个 Series)已经通过分组键进行了聚合,产生了一个新的 Series,该 Series 现在由 stock_code 列中的唯一值进行索引。结果索引的名称是 ‘stock_code’,因为 DataFrame 的列 sample_trade_records_df['stock_code'] 的名称就是它。

如果我们传递一个包含多个数组的列表作为分组键,会得到一个层级索引的结果:

grouped_returns_mean_series = sample_trade_records_df['returns'].groupby([sample_trade_records_df['stock_code'], sample_trade_records_df['analyst_id']]).mean()  # 按证券—分析师组合计算returns均值,结果索引为两层键
grouped_returns_mean_series  # 输出以证券代码、分析师编号为两层索引的收益均值Series
stock_code  analyst_id
600000      1.0          -0.793122
            2.0           0.240571
600926      1.0           0.638295
            2.0           1.395772
Name: returns, dtype: float64

这里我们使用了两个键对数据进行分组,得到的 Series 现在有一个由观察到的唯一键对组成的层级索引(MultiIndex)。我们可以使用 unstack 方法将这个结果重塑为一个更熟悉的 DataFrame

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

grouped_returns_mean_series.unstack()                       # 行转列(展开)
表 8.3: 带有层级索引的未堆叠分组均值
analyst_id 1.0 2.0
stock_code
600000 -0.793122 0.240571
600926 0.638295 1.395772

在前面的例子中,分组键都是 Series。然而,它们可以是任何长度正确的数组。这里,我们用中国的省份和年份作为分组键:

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

provinces = np.array(['Beijing', 'Shanghai', 'Shanghai', 'Beijing', 'Beijing', 'Shanghai', 'Beijing'])  # 省份分组键数组
years = np.array([2005, 2005, 2006, 2005, 2006, 2006, 2005])  # 七项年份键与returns逐行等长,并和省份键形成组合
sample_trade_records_df['returns'].groupby([provinces, years]).mean()  # 用同长度外部数组作为省份—年份键,汇总各组合的returns均值
表 8.4: 按外部 province 和 year 数组分组的 returns 均值
Beijing   2005    0.096900
          2006    0.638295
Shanghai  2005    0.240571
          2006   -1.094187
Name: returns, dtype: float64

通常,分组信息与您要处理的数据位于同一个 DataFrame 中。在这种情况下,您可以将列名(无论是字符串、数字还是其他 Python 对象)作为分组键传递:

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

sample_trade_records_df.groupby('stock_code')[['analyst_id', 'returns', 'volatility']].mean()  # 显式限定三列数值合同;stock_code缺失行按默认规则排除
表 8.5: 按单个列名分组
analyst_id returns volatility
stock_code
600000 1.5 -0.281533 -0.009318
600926 1.5 1.017033 0.617387

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

sample_trade_records_df.groupby('analyst_id')[['returns', 'volatility']].mean()  # 每位分析师分别汇总收益与波动率均值,并显式限定数值列
表 8.6: 按另一列名分组,注意“讨厌的列”
returns volatility
analyst_id
1.0 -0.590776 0.126789
2.0 0.818171 0.226204

技术风险分析:“讨厌的列” (Nuisance Columns) 与数值列合同

在量化回测或财务审计中,数据类型的严谨性直接决定了结果的可靠性。当执行 groupby(...).mean() 等数学聚合时:

  1. 默认失败:本书锁定的 pandas 2.3.3 中,DataFrameGroupBy.mean()numeric_only 默认值为 False。分组键本身不进入值列聚合;但若待聚合的 object 值列含有不能转换为数值的字符串,默认调用会抛出 TypeError,而不是静默删除该列。
  2. 防御性编程建议:优先在聚合前显式选择业务合同允许的数值值列,并在聚合后校验列名。若确实需要忽略所有非数值值列,可以明确写 numeric_only=True,同时核验没有本应为数值的列因脏字符串而被排除。

要按多个列进行分组,请传递一个列名列表:

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

sample_trade_records_df.groupby(['stock_code', 'analyst_id'])[['returns', 'volatility']].mean()  # 以联合键分组并显式限定两个数值值列
表 8.7: 按多个列名分组
returns volatility
stock_code analyst_id
600000 1.0 -0.793122 0.303835
2.0 0.240571 -0.267660
600926 1.0 0.638295 0.514705
2.0 1.395772 0.720068

无论使用 groupby 的目标是什么,一个通常很有用的 GroupBy 方法是 size,它返回一个包含各组大小的 Series

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

sample_trade_records_df.groupby(['stock_code', 'analyst_id']).size()  # size统计每个联合键的总行数,不检查各值列是否为NA
表 8.8: 使用多个键的分组大小
stock_code  analyst_id
600000      1.0           1
            2.0           1
600926      1.0           1
            2.0           1
dtype: int64

请注意,默认情况下,分组键中的任何缺失值都会从结果中排除。可以通过向 groupby 传递 dropna=False 来禁用此行为:

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

sample_trade_records_df.groupby('stock_code', dropna=False).size()  # 将缺失stock_code也作为一组,并统计每组全部记录数
表 8.9: 包含NA值的 key1 分组大小
stock_code
600000    3
600926    2
NaN       2
dtype: int64

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

sample_trade_records_df.groupby(['stock_code', 'analyst_id'], dropna=False).size()  # 保留任一键为NA的联合组,size仍按行计数
表 8.10: 包含NA值的 key1 和 key2 分组大小
stock_code  analyst_id
600000      1.0           1
            2.0           1
            NaN           1
600926      1.0           1
            2.0           1
NaN         1.0           2
dtype: int64

size 精神相似的分组函数是 count,它计算每个组中每列的非空值数量:

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

sample_trade_records_df.groupby('stock_code').count()       # count逐列统计非NA数量,故同一证券的各列计数可能不同于size
表 8.11: 每组非空值的计数
analyst_id returns volatility
stock_code
600000 2 3 3
600926 2 2 2

8.2.1 遍历各组

groupby 返回的对象支持迭代,生成一个由包含组名和数据块的二元元组组成的序列。思考以下代码:

for name, group in sample_trade_records_df.groupby('stock_code'):  # 逐个证券代码取得组名与对应的完整记录块
    print(f'组名: {name}')                                    # 输出当前分组的名称
    print(group)                                            # 输出当前分组的数据
    print('-' * 20)                                         # 分隔不同key1组的逐组输出
组名: 600000
  stock_code  analyst_id   returns  volatility
0     600000         1.0 -0.793122    0.303835
1     600000         2.0  0.240571   -0.267660
5     600000         NaN -0.292047   -0.064128
--------------------
组名: 600926
  stock_code  analyst_id   returns  volatility
3     600926         2.0  1.395772    0.720068
4     600926         1.0  0.638295    0.514705
--------------------

如果使用多个键,元组的第一个元素将是一个包含键值的元组:

for (k1, k2), group in sample_trade_records_df.groupby(['stock_code', 'analyst_id']):  # 将证券—分析师联合键解包,并检查该组合的原始行
    print(f'组名: {(k1, k2)}')                                  # 标出证券代码—分析师编号联合键,便于核对其原始行数
    print(group)                                            # 输出当前分组的数据
    print('-' * 20)                                         # 分隔不同key1—key2组合的行块
组名: ('600000', 1.0)
  stock_code  analyst_id   returns  volatility
0     600000         1.0 -0.793122    0.303835
--------------------
组名: ('600000', 2.0)
  stock_code  analyst_id   returns  volatility
1     600000         2.0  0.240571    -0.26766
--------------------
组名: ('600926', 1.0)
  stock_code  analyst_id   returns  volatility
4     600926         1.0  0.638295    0.514705
--------------------
组名: ('600926', 2.0)
  stock_code  analyst_id   returns  volatility
3     600926         2.0  1.395772    0.720068
--------------------

当然,您可以对这些数据块做任何您想做的事情。一个您可能会觉得有用的技巧是,用一行代码计算出一个包含数据块的字典:

pieces = {name: group for name, group in sample_trade_records_df.groupby('stock_code')}  # 把证券代码映射到各自DataFrame切片,便于按键复用
pieces['600926']  # 通过索引访问pieces中的元素
stock_code analyst_id returns volatility
3 600926 2.0 1.395772 0.720068
4 600926 1.0 0.638295 0.514705

默认情况下,groupbyaxis='index'(即行)上进行分组,但您可以在任何其他轴上进行分组。例如,我们可以按数据类型对示例 df 的列进行分组:

column_groups_by_dtype = sample_trade_records_df.dtypes.groupby(sample_trade_records_df.dtypes).groups  # 把每种dtype映射到具有该类型的列标签

for dtype, columns in column_groups_by_dtype.items():       # 逐种dtype读取其列标签集合
    group = sample_trade_records_df.loc[:, list(columns)]    # 按列标签投影该dtype对应的字段,不使用已弃用的列轴groupby
    print(f'数据类型: {dtype}')                                  # 输出当前列组的数据类型
    print(group)                                            # 输出该类型对应的列数据
    print('-' * 20)                                         # 分隔逐组列投影的输出结果
数据类型: float64
   analyst_id   returns  volatility
0         1.0 -0.793122    0.303835
1         2.0  0.240571   -0.267660
2         1.0 -1.896326   -0.225909
3         2.0  1.395772    0.720068
4         1.0  0.638295    0.514705
5         NaN -0.292047   -0.064128
6         1.0 -0.311949   -0.085477
--------------------
数据类型: object
  stock_code
0     600000
1     600000
2       None
3     600926
4     600926
5     600000
6       None
--------------------

8.2.2 选择一列或列的子集

使用列名或列名数组对从 DataFrame 创建的 GroupBy 对象进行索引,其效果是为聚合操作选择列的子集。这意味着:

df.groupby('key1')['data1']  # 先按业务键分组,再选择单列,结果是SeriesGroupBy
df.groupby('key1')[['data2']]  # 保留双括号以维持二维列结构,结果是DataFrameGroupBy

是以下写法的语法糖 (syntactic sugar):

df['data1'].groupby(df['key1'])  # 先选单列再传入同索引分组键,语义等价于上方单列写法
df[['data2']].groupby(df['key1'])  # 先保留二维子表再分组,确保后续聚合仍返回DataFrame

特别是对于大型数据集,可能只需要对少数几列进行聚合。例如,在前面的数据集中,要仅计算 data2 列的均值并将结果作为 DataFrame 获取,我们可以这样写:

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

sample_trade_records_df.groupby(['stock_code', 'analyst_id'])[['volatility']].mean()  # 联合键下仅聚合volatility,并保留二维DataFrame结果
表 8.12: 对列的子集进行聚合
volatility
stock_code analyst_id
600000 1.0 0.303835
2.0 -0.267660
600926 1.0 0.514705
2.0 0.720068

如果传递的是列表或数组,此索引操作返回的对象是一个分组的 DataFrame;如果只传递单个列名作为标量,则返回一个分组的 Series

s_grouped = sample_trade_records_df.groupby(['stock_code', 'analyst_id'])['volatility']  # 联合键下选择单列,得到待归约的SeriesGroupBy
s_grouped  # 显示联合键下仅选择volatility列后的SeriesGroupBy类型
<pandas.core.groupby.generic.SeriesGroupBy object at 0x7f191c2417e0>

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

s_grouped.mean()  # 将证券—分析师联合键下的volatility归约为标量均值
表 8.13: 对分组Series进行聚合的结果
stock_code  analyst_id
600000      1.0           0.303835
            2.0          -0.267660
600926      1.0           0.514705
            2.0           0.720068
Name: volatility, dtype: float64

8.2.3 使用字典和Series进行分组

分组信息可能以非数组的形式存在。为了演示这一点,我们来看一个更具中国情境的例子。这里使用课程数据管理员维护的本地受控快照 stock_basic_data.h5;现有文件没有保存足以证明具体上游供应商的获取日志,因此本章只陈述其可核验的本地身份与 schema,不推断外部数据血缘。

import pandas as pd                                         # 读取股票主表并执行地区、行业层级分组
import numpy as np                                          # 核验发行价等数值聚合结果

# 加载本地上市公司基本信息
china_stock_basic_dataframe = pd.read_hdf(f'{DATA_ROOT}/stock/stock_basic_data.h5', key='stock_basic_info')  # 读取本地股票主表中的代码、省份、行业与发行价字段

# 筛选感兴趣的省份(注意清理名称以匹配后续的映射)
provinces = ['北京市', '上海市', '江苏省', '浙江省', '广东省', '四川省', '河南省', '陕西省']  # 选取的目标省份列表
china_listed_companies_df = china_stock_basic_dataframe[china_stock_basic_dataframe['province'].isin(provinces)].copy()  # 筛选目标省份的上市公司并复制

# 清理省份名称,去除“省”和“市”后缀以匹配映射字典
china_listed_companies_df['province_cn'] = china_listed_companies_df['province'].str.replace('省', '').str.replace('市', '')  # 替换指定值

# 选择需要的列:省份、行业和发行价
china_listed_companies_df = china_listed_companies_df[['province_cn', 'industry_name', 'issue_price']].copy()  # 只保留省份、行业和发行价三列
china_listed_companies_df.head()                            # 核验八个目标省市筛选后的省份、行业和发行价字段
表 8.14: 部分省市上市公司样本数据
province_cn industry_name issue_price
0 广东 货币金融服务 40.0
1 广东 房地产业 1.0
2 广东 未知 10.0
3 广东 软件和信息技术服务业 1.0
4 广东 未知 10.0

现在,假设我们有一个按地理区域对这些省份进行分组的对应关系,并希望按此分组对上市公司的发行价进行汇总。

mapping = {'北京': '华北', '上海': '华东', '江苏': '华东', '浙江': '华东',  # 省份到地理区域的映射字典
           '广东': '华南', '四川': '西南', '河南': '华中', '陕西': '西北'}  # 覆盖华北、华东、华南、西南、华中、西北六类区域

# 将省份名称映射到区域以创建分组键
by_region = china_listed_companies_df.groupby(china_listed_companies_df['province_cn'].map(mapping))  # 将八个省市映射为六类区域后建立地区分组器

现在我们可以应用聚合操作,例如,同时计算每个地理区域的平均发行价、公司总数和平均值所用的非缺失发行价样本数。size 统计组内全部公司,count 只统计 issue_price 非缺失的公司;两者并列可以显式暴露缺失值造成的口径差异。

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

by_region.agg(                                              # 按六类区域同时报告均值、总公司数和均值有效样本数
    mean_issue_price=('issue_price', 'mean'),               # 发行价均值自动跳过缺失发行价
    company_count=('province_cn', 'size'),                  # 组内全部公司数,不受发行价缺失影响
    valid_issue_price_count=('issue_price', 'count'),       # 实际进入发行价均值的非缺失样本数
).round(2)                                                  # 仅对展示结果统一保留两位小数
表 8.15: 按地理区域划分的平均发行价、公司总数和有效样本数
mean_issue_price company_count valid_issue_price_count
province_cn
华东 22.23 1875 1873
华中 17.77 108 108
华北 22.94 481 479
华南 22.55 909 908
西北 22.29 85 85
西南 19.81 184 184

Series 也具有相同的功能,可以将其视为一个固定大小的映射:

map_series = pd.Series(mapping, name='region')              # 创建Series序列
map_series  # 输出省份为索引、地理区域为值的八项映射Series
北京    华北
上海    华东
江苏    华东
浙江    华东
广东    华南
四川    西南
河南    华中
陕西    西北
Name: region, dtype: object

我们也可以将这个 Series 传递给 groupby

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

china_listed_companies_df.groupby(china_listed_companies_df['province_cn'].map(map_series))[['issue_price']].mean().round(2)  # 按省份映射出的经济区域比较平均发行价
表 8.16: 使用Series进行分组的各区域平均发行价
issue_price
province_cn
华东 22.23
华中 17.77
华北 22.94
华南 22.55
西北 22.29
西南 19.81

8.2.4 使用函数进行分组

与字典或 Series 相比,使用 Python 函数是定义分组映射的一种更通用的方法。任何作为分组键传递的函数都将对每个索引值调用一次(如果使用 axis='columns',则对每个列值调用一次),其返回值将用作组名。

让我们使用 表 8.14 中的上市公司数据 DataFrame,但首先,我们将设置一个更有意义的索引。

china_stocks_hierarchical_df = china_listed_companies_df.set_index(['province_cn', 'industry_name'])  # 建立“省份—行业”两级索引以演示层级分组
china_stocks_hierarchical_df.head()                         # 展示“省份—行业”两级索引的前五条公司记录
表 8.17: 以省份和行业为索引的上市公司数据
issue_price
province_cn industry_name
广东 货币金融服务 40.0
房地产业 1.0
未知 10.0
软件和信息技术服务业 1.0
未知 10.0

假设您想按省份名称的长度进行分组。虽然您可以计算一个字符串长度的数组,但直接传递 len 函数更简单。pandas 会将其应用于索引的第一层(‘province_cn’)。

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

# 获取索引的第一层 ('province_cn')
province_index = china_stocks_hierarchical_df.index.get_level_values(0)  # 提取多级索引的第一层(省份名称)
china_stocks_hierarchical_df.groupby(province_index.map(len)).mean().round(2)  # 以省份中文名称长度为题设键汇总数值列均值
表 8.18: 按省份名称长度分组的平均发行价
issue_price
province_cn
2 22.15

将函数与数组、字典或 Series 混合使用没有问题,因为所有东西在内部都会被转换为数组。

对于具有层级索引的数据集,最后一个便利之处是能够使用轴索引的某个级别进行聚合。使用 表 8.17 中的 china_stocks_hierarchical_df DataFrame,我们可以按索引的 ‘industry_name’ 级别进行分组。

要按级别分组,请使用 level 关键字传递级别编号或名称:

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

china_stocks_hierarchical_df.groupby(level='industry_name').mean().head(10).round(2)  # 沿多级索引的行业层汇总数值列均值,并展示前10个行业
表 8.19: 按 “industry_name” 索引级别进行聚合
issue_price
industry_name
专业技术服务业 24.58
专用设备制造业 26.93
互联网和相关服务 24.77
仓储业 15.28
仪器仪表制造业 24.75
住宿业 6.82
体育 10.67
保险业 21.85
公共设施管理业 6.88
其他制造业 38.78

8.3 数据聚合

本节把“聚合”限定为把每组行轴归约成一个标量或固定数量摘要的操作,例如 meancountsumGroupBy 还提供选择和保持组内行数的转换方法;它们同样可能具有优化实现,但不应都称为聚合。表 8.20 按输出合同区分这些方法。

表 8.20: 经过优化的 groupby 归约、选择与转换方法
函数名 输出类型 描述
any, all 归约 每组返回一个布尔摘要
count 归约 每组返回非NA值数量
describe 多摘要归约 每组返回计数、位置与分位数等固定统计项
cummin, cummax 转换 按组内顺序返回累积最小值或最大值,通常保持行数
cumsum 转换 按组内顺序返回累积和,通常保持行数
cumprod 转换 按组内顺序返回累积积,通常保持行数
first, last 归约 每组返回第一个或最后一个非NA值
mean 归约 每组返回非NA值均值
median 归约 每组返回非NA值中位数
min, max 归约 每组返回非NA值最小值或最大值
nth 选择 按每组当前行顺序取得位置 n 的行;不会按值自动排序
ohlc 多摘要归约 每组返回“开—高—低—收”四项统计量
prod 归约 每组返回非NA值乘积
quantile 归约 每组返回指定样本分位数
rank 转换 返回组内序数排名,通常与输入行对齐
size 归约 每组返回包含缺失值在内的总行数
sum 归约 每组返回非NA值之和
std, var 归约 每组返回样本标准差或方差

您可以使用自己设计的聚合函数,并额外调用任何也在被分组对象上定义的方法。例如,nsmallest Series 方法从数据中选择所请求数量的最小值。虽然 nsmallest 没有为 GroupBy 显式实现,但我们仍然可以通过一个未优化的实现来使用它。在内部,GroupBySeries 分片,对每个分片调用 piece.nsmallest(n),然后将这些结果组装成结果对象。让我们回到 表 8.1 中的初始示例 sample_trade_records_df

sample_trade_records_df  # 重列七条原始记录,便于核对随后每个证券选出的两个最小收益
stock_code analyst_id returns volatility
0 600000 1.0 -0.793122 0.303835
1 600000 2.0 0.240571 -0.267660
2 None 1.0 -1.896326 -0.225909
3 600926 2.0 1.395772 0.720068
4 600926 1.0 0.638295 0.514705
5 600000 NaN -0.292047 -0.064128
6 None 1.0 -0.311949 -0.085477

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

grouped = sample_trade_records_df.groupby('stock_code')     # 建立证券代码到记录块的映射,供后续每组取两个最小收益
grouped['returns'].nsmallest(2)                             # 获取最小的n个值
表 8.21: 为 stock_code 中的每个组选择 returns 的两个最小值
stock_code   
600000      0   -0.793122
            5   -0.292047
600926      4    0.638295
            3    1.395772
Name: returns, dtype: float64

要使用您自己的聚合函数,请将任何聚合数组的函数传递给 aggregate 方法或其简写别名 agg

def peak_to_peak(arr):                                      # 自定义聚合函数:计算极差
    return arr.max() - arr.min()                            # 返回最大值与最小值之差

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

grouped.agg(peak_to_peak)                                   # 对每个证券组的数值列计算最大值减最小值
表 8.22: 应用自定义的 peak_to_peak 聚合
analyst_id returns volatility
stock_code
600000 1.0 1.033694 0.571496
600926 1.0 0.757477 0.205363

性能深度评估:Cython 优化与 Python 原生函数开销

在构建高性能交易研究流程或处理大规模财务面板数据时,聚合函数的实现路径可能影响耗时,但影响幅度取决于数据类型、分组数量与倾斜程度、Pandas 版本、硬件和缓存状态。

  1. 内置优化路径:对于常见数值数据类型,pandas 的部分内置归约(如 summeanstd)可进入 Cython、向量化或其他专用优化路径,从而减少逐组 Python 调用;具体分派、GIL 行为和内存布局取决于函数、数据类型、扩展数组及 pandas 版本,不能一概而论。
  2. 自定义函数开销:如 peak_to_peak 这样的 Python 自定义函数,需要 pandas 对每个分组调用 Python 函数,通常不能完整利用内置归约的优化路径;不过,自定义逻辑并不总能由内置归约等价表达。
  3. 工程建议:只有在两个实现语义等价、对同一输入通过结果一致性断言,并在目标环境中经过预热与重复计时确认有收益时,才用内置函数组合替换自定义逻辑。例如,可先验证 peak_to_peakgrouped.max() - grouped.min() 的缺失值及分组行为一致,再决定是否替换。

describe 为每个组返回一组固定的描述性统计量,按本章的输出合同属于多摘要归约。

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

# 输出可能很宽,所以我们进行转置以便于阅读
grouped.describe().T                                        # 转置操作
表 8.23: 每个组的描述性统计
stock_code 600000 600926
analyst_id count 2.000000 2.000000
mean 1.500000 1.500000
std 0.707107 0.707107
min 1.000000 1.000000
25% 1.250000 1.250000
50% 1.500000 1.500000
75% 1.750000 1.750000
max 2.000000 2.000000
returns count 3.000000 2.000000
mean -0.281533 1.017033
std 0.516927 0.535617
min -0.793122 0.638295
25% -0.542585 0.827664
50% -0.292047 1.017033
75% -0.025738 1.206402
max 0.240571 1.395772
volatility count 3.000000 2.000000
mean -0.009318 0.617387
std 0.289664 0.145213
min -0.267660 0.514705
25% -0.165894 0.566046
50% -0.064128 0.617387
75% 0.119854 0.668727
max 0.303835 0.720068

8.3.1 逐列及多函数应用

下面使用四只A股的本地日行情探索更高级的聚合。每行保留证券代码、交易所、交易日、星期、收盘价、成交量、成交额、日内振幅和日收益率;因此每个统计量都能追溯到仓库指定的数据文件。这里的日收益率按证券分别计算,避免把不同证券相邻行误连在一起。

groupby_symbols = ['600000.XSHG', '002415.XSHE', '600104.XSHG', '600276.XSHG']  # 选择四家长三角公司用于真实分组示例
# 逐证券收集行情,确保每条收益率只连接同一证券的相邻观测
groupby_market_frames = []
for symbol in groupby_symbols:  # 按既定证券池逐只读取,保留各自真实交易日
    # 在 HDF5 层按证券筛选,避免载入与案例无关的全市场记录
    symbol_frame = pd.read_hdf(
        PRE_ADJUSTED_PRICE_PATH,
        where=f"order_book_id='{symbol}'"
    ).reset_index()
    groupby_market_frames.append(symbol_frame)  # 保存单只证券切片,稍后统一规范字段

# 合并后先按证券和日期排序,再计算每只证券内部的相邻收益率
groupby_market_df = (
    pd.concat(groupby_market_frames, ignore_index=True)
    .assign(date=lambda x: pd.to_datetime(x['date']))
    .sort_values(['order_book_id', 'date'])
    .loc[lambda x: x['date'].between('2022-01-01', '2023-12-31')]
    .assign(
        exchange=lambda x: x['order_book_id'].str.split('.').str[-1],
        weekday=lambda x: x['date'].dt.day_name(),
        year=lambda x: x['date'].dt.year,
        turnover_billion=lambda x: x['total_turnover'] / 1e9,
        intraday_range_pct=lambda x: (x['high'] - x['low']) / x['close'],
        daily_return=lambda x: x.groupby('order_book_id')['close'].pct_change(fill_method=None),
    )
    .dropna(subset=['daily_return'])
)
groupby_market_df.head()  # 抽查样本期、证券键与派生收益率是否一致
order_book_id date open high low close volume total_turnover exchange weekday year turnover_billion intraday_range_pct daily_return
7922 002415.XSHE 2022-01-05 46.7036 46.7036 45.5226 45.9252 22958073.0 1.183391e+09 XSHE Wednesday 2022 1.183391 0.025716 -0.020607
7923 002415.XSHE 2022-01-06 45.6836 45.8715 44.6279 44.7084 25229848.0 1.266977e+09 XSHE Thursday 2022 1.266977 0.027816 -0.026495
7924 002415.XSHE 2022-01-07 44.7173 45.3526 44.4668 44.6189 18430171.0 9.233793e+08 XSHE Friday 2022 0.923379 0.019853 -0.002002
7925 002415.XSHE 2022-01-10 44.4042 45.2542 43.9389 44.9857 22587445.0 1.128295e+09 XSHE Monday 2022 1.128295 0.029238 0.008221
7926 002415.XSHE 2022-01-11 44.9142 45.1647 44.3595 44.4668 20117192.0 1.006253e+09 XSHE Tuesday 2022 1.006253 0.018108 -0.011535

SeriesDataFrame的列进行聚合,只需使用aggregate(或agg)并附上期望的函数。下面先按星期和交易所分组:

grouped = groupby_market_df.groupby(['weekday', 'exchange'])  # 以星期和交易所形成交叉分组口径

请注意,对于像 表 8.20 中的描述性统计,您可以将函数名作为字符串传递:

grouped_pct = grouped['daily_return']  # 只选择日收益率列以演示 SeriesGroupBy 聚合
grouped_pct.agg('mean')                                     # 报告每个星期—交易所组合的日收益率算术均值
weekday    exchange
Friday     XSHE       -1.122940e-03
           XSHG        1.474464e-03
Monday     XSHE       -1.464051e-05
           XSHG       -1.427402e-03
Thursday   XSHE       -3.134495e-03
           XSHG       -8.554984e-07
Tuesday    XSHE        1.137987e-03
           XSHG       -1.113269e-03
Wednesday  XSHE        6.145469e-04
           XSHG       -5.257541e-04
Name: daily_return, dtype: float64

如果您传递一个函数或函数名的列表,您将得到一个 DataFrame,其列名取自这些函数:

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

grouped_pct.agg(['mean', 'std', peak_to_peak])              # 同时给出组内收益均值、样本标准差与极差
表 8.24: 对分组Series应用多个聚合函数
mean std peak_to_peak
weekday exchange
Friday XSHE -1.122940e-03 0.024461 0.151521
XSHG 1.474464e-03 0.015828 0.144766
Monday XSHE -1.464051e-05 0.023359 0.111761
XSHG -1.427402e-03 0.019930 0.198295
Thursday XSHE -3.134495e-03 0.020251 0.146752
XSHG -8.554984e-07 0.014143 0.100473
Tuesday XSHE 1.137987e-03 0.020870 0.104306
XSHG -1.113269e-03 0.014776 0.120854
Wednesday XSHE 6.145469e-04 0.018337 0.087292
XSHG -5.257541e-04 0.012063 0.098715

您不必接受 GroupBy 为列指定的名称;特别是 lambda 函数的名称为 '<lambda>',这使得它们难以识别。因此,如果您传递一个 (name, function) 元组的列表,每个元组的第一个元素将用作 DataFrame 的列名:

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

grouped_pct.agg([('average', 'mean'), ('stdev', 'std')])    # 字符串std固定pandas样本标准差口径(ddof=1),避免未来NumPy callable语义漂移
表 8.25: 使用自定义名称应用多个聚合
average stdev
weekday exchange
Friday XSHE -1.122940e-03 0.024461
XSHG 1.474464e-03 0.015828
Monday XSHE -1.464051e-05 0.023359
XSHG -1.427402e-03 0.019930
Thursday XSHE -3.134495e-03 0.020251
XSHG -8.554984e-07 0.014143
Tuesday XSHE 1.137987e-03 0.020870
XSHG -1.113269e-03 0.014776
Wednesday XSHE 6.145469e-04 0.018337
XSHG -5.257541e-04 0.012063

对于DataFrame,可以为所有列指定一个函数列表,或者为每列指定不同函数。首先对daily_returnturnover_billion计算相同的三个统计量:

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

functions = ['count', 'mean', 'max']                        # 待应用的聚合函数名称列表
result = grouped[['daily_return', 'turnover_billion']].agg(functions)  # 对收益与成交额使用同一统计口径
result  # 输出交易所索引、字段—统计量两层列的聚合表
表 8.26: 对多列应用多个函数
daily_return turnover_billion
count mean max count mean max
weekday exchange
Friday XSHE 97 -1.122940e-03 0.061563 97 1.578822 8.571684
XSHG 291 1.474464e-03 0.099885 291 0.747696 4.420471
Monday XSHE 94 -1.464051e-05 0.059985 94 1.685181 5.154249
XSHG 282 -1.427402e-03 0.098295 282 0.836929 6.226335
Thursday XSHE 99 -3.134495e-03 0.046728 99 1.355579 5.651599
XSHG 297 -8.554984e-07 0.057924 297 0.717166 4.757344
Tuesday XSHE 96 1.137987e-03 0.058448 96 1.455607 4.904280
XSHG 288 -1.113269e-03 0.054861 288 0.749223 5.837540
Wednesday XSHE 97 6.145469e-04 0.050365 97 1.395407 5.124739
XSHG 291 -5.257541e-04 0.041974 291 0.711153 4.002904

如您所见,结果 DataFrame 具有层级列。您可以像通常那样访问列的子集:

result['daily_return']  # 从层级列中提取收益率的计数、均值和最大值
count mean max
weekday exchange
Friday XSHE 97 -1.122940e-03 0.061563
XSHG 291 1.474464e-03 0.099885
Monday XSHE 94 -1.464051e-05 0.059985
XSHG 282 -1.427402e-03 0.098295
Thursday XSHE 99 -3.134495e-03 0.046728
XSHG 297 -8.554984e-07 0.057924
Tuesday XSHE 96 1.137987e-03 0.058448
XSHG 288 -1.113269e-03 0.054861
Wednesday XSHE 97 6.145469e-04 0.050365
XSHG 291 -5.257541e-04 0.041974

和之前一样,可以传递一个带有自定义名称的元组列表:

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

ftuples = [('Average', 'mean'), ('Variance', 'var')]        # 字符串var固定pandas样本方差口径(ddof=1)
grouped[['daily_return', 'turnover_billion']].agg(ftuples)  # 用可读列名比较两项指标的均值与方差
表 8.27: 对多列应用命名聚合
daily_return turnover_billion
Average Variance Average Variance
weekday exchange
Friday XSHE -1.122940e-03 0.000598 1.578822 1.548539
XSHG 1.474464e-03 0.000251 0.747696 0.589319
Monday XSHE -1.464051e-05 0.000546 1.685181 1.144244
XSHG -1.427402e-03 0.000397 0.836929 1.046205
Thursday XSHE -3.134495e-03 0.000410 1.355579 0.956492
XSHG -8.554984e-07 0.000200 0.717166 0.576461
Tuesday XSHE 1.137987e-03 0.000436 1.455607 0.707780
XSHG -1.113269e-03 0.000218 0.749223 0.691127
Wednesday XSHE 6.145469e-04 0.000336 1.395407 0.800012
XSHG -5.257541e-04 0.000146 0.711153 0.584684

现在,假设您想对一个或多个列应用可能不同的函数。为此,向 agg 传递一个字典,其中包含列名到目前为止列出的任何函数规范的映射:

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

grouped.agg({'intraday_range_pct': 'max', 'volume': 'sum'})  # 分别汇总组内最大振幅与总成交量
表 8.28: 对不同列应用不同函数
intraday_range_pct volume
weekday exchange
Friday XSHE 0.100435 4.325989e+09
XSHG 0.087821 9.318628e+09
Monday XSHE 0.097668 4.437300e+09
XSHG 0.114625 9.557458e+09
Thursday XSHE 0.071598 3.710139e+09
XSHG 0.071266 9.028754e+09
Tuesday XSHE 0.092853 3.923437e+09
XSHG 0.082943 9.049305e+09
Wednesday XSHE 0.081377 3.773664e+09
XSHG 0.079472 8.697179e+09

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

# 对收益保留分布摘要,同时按可加口径汇总成交量
grouped.agg({'daily_return': ['min', 'max', 'mean', 'std'],
             'volume': 'sum'})
表 8.29: 对某些列应用多个函数,对其他列应用单个函数
daily_return volume
min max mean std sum
weekday exchange
Friday XSHE -0.089959 0.061563 -1.122940e-03 0.024461 4.325989e+09
XSHG -0.044882 0.099885 1.474464e-03 0.015828 9.318628e+09
Monday XSHE -0.051777 0.059985 -1.464051e-05 0.023359 4.437300e+09
XSHG -0.100000 0.098295 -1.427402e-03 0.019930 9.557458e+09
Thursday XSHE -0.100024 0.046728 -3.134495e-03 0.020251 3.710139e+09
XSHG -0.042548 0.057924 -8.554984e-07 0.014143 9.028754e+09
Tuesday XSHE -0.045858 0.058448 1.137987e-03 0.020870 3.923437e+09
XSHG -0.065993 0.054861 -1.113269e-03 0.014776 9.049305e+09
Wednesday XSHE -0.036926 0.050365 6.145469e-04 0.018337 3.773664e+09
XSHG -0.056741 0.041974 -5.257541e-04 0.012063 8.697179e+09

只有当至少有一列应用了多个函数时,DataFrame 才会有层级列。

8.3.2 返回不带行索引的聚合数据

到目前为止,在所有示例中,聚合后的数据都带有一个由唯一分组键组合构成的索引(可能是层级索引)。因为这并不总是理想的,您可以在大多数情况下通过向 groupby 传递 as_index=False 来禁用此行为。

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

# 将分组键保留为普通列,方便结果继续连接或写出
groupby_market_df.groupby(['weekday', 'exchange'], as_index=False)[
    ['close', 'volume', 'turnover_billion', 'intraday_range_pct']
].mean()
表 8.30: 带有扁平索引的分组聚合
weekday exchange close volume turnover_billion intraday_range_pct
0 Friday XSHE 33.238956 4.459782e+07 1.578822 0.032259
1 Friday XSHG 20.507092 3.202278e+07 0.747696 0.020700
2 Monday XSHE 33.158226 4.720532e+07 1.685181 0.035246
3 Monday XSHG 20.544007 3.389170e+07 0.836929 0.022738
4 Thursday XSHE 33.241743 3.747615e+07 1.355579 0.026851
5 Thursday XSHG 20.460260 3.039985e+07 0.717166 0.019731
6 Tuesday XSHE 33.098962 4.086913e+07 1.455607 0.030939
7 Tuesday XSHG 20.465720 3.142120e+07 0.749223 0.020790
8 Wednesday XSHE 33.222549 3.890375e+07 1.395407 0.029641
9 Wednesday XSHG 20.487964 2.988721e+07 0.711153 0.019558

当然,总是可以通过在结果上调用 reset_index() 来获得这种格式的结果。使用 as_index=False 参数可以避免一些不必要的计算。

8.4 Apply: 通用的拆分-应用-合并

最通用的 GroupBy 方法是 applyapply 将被操作的对象拆分成块,对每个块调用传递的函数,然后尝试将这些块连接起来。

回到日行情数据,假设要按组选择日收益率最高的五行。先编写一个可指定排序列的函数:

def top(df, n=5, column='daily_return'):                    # 自定义函数:选取指定列前N大的行
    return df.sort_values(column, ascending=False)[:n]      # 按指定列降序排列后取前N行
    
top(groupby_market_df, n=6)  # 先在全样本验证函数按日收益率选取最高六行
order_book_id date open high low close volume total_turnover exchange weekday year turnover_billion intraday_range_pct daily_return
18309 600276.XSHG 2022-10-14 34.5808 37.5350 34.5314 37.5350 95015743.0 3.531438e+09 XSHG Friday 2022 3.531438 0.080021 0.099885
18374 600276.XSHG 2023-01-16 38.7996 42.6826 38.7996 42.6134 137880485.0 5.856679e+09 XSHG Monday 2023 5.856679 0.091122 0.098295
13481 600104.XSHG 2023-11-27 14.4718 15.1204 14.2782 14.9945 94795391.0 1.442680e+09 XSHG Monday 2023 1.442680 0.056167 0.062407
8211 002415.XSHE 2023-03-17 35.3379 37.5465 35.3195 37.1324 94618489.0 3.786373e+09 XSHE Friday 2023 3.786373 0.059975 0.061563
18236 600276.XSHG 2022-06-27 33.2162 35.5094 33.2162 34.9976 111401072.0 3.917628e+09 XSHG Monday 2022 3.917628 0.065524 0.060858
8192 002415.XSHE 2023-02-20 33.1293 35.8441 32.9913 35.6140 92302594.0 3.514053e+09 XSHE Monday 2023 3.514053 0.080103 0.059985

按交易所分组并调用这个函数,可得到各交易所日收益率最高的五条观测:

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

groupby_market_df.groupby('exchange').apply(top, include_groups=False)  # 分组键留在结果索引,不把exchange列重复传入top
表 8.31: 各交易所日收益率最高的5条观测
order_book_id date open high low close volume total_turnover weekday year turnover_billion intraday_range_pct daily_return
exchange
XSHE 8211 002415.XSHE 2023-03-17 35.3379 37.5465 35.3195 37.1324 94618489.0 3.786373e+09 Friday 2023 3.786373 0.059975 0.061563
8192 002415.XSHE 2023-02-20 33.1293 35.8441 32.9913 35.6140 92302594.0 3.514053e+09 Monday 2023 3.514053 0.080103 0.059985
8223 002415.XSHE 2023-04-04 38.9729 42.4883 38.6508 41.3288 111194896.0 4.904280e+09 Tuesday 2023 4.904280 0.092853 0.058448
8269 002415.XSHE 2023-06-13 29.8992 31.6923 29.7303 31.5233 78126912.0 2.577306e+09 Tuesday 2023 2.577306 0.062240 0.057637
8019 002415.XSHE 2022-06-06 30.2765 32.2919 30.1845 32.0802 124786137.0 4.236360e+09 Monday 2022 4.236360 0.065692 0.056684
XSHG 18309 600276.XSHG 2022-10-14 34.5808 37.5350 34.5314 37.5350 95015743.0 3.531438e+09 Friday 2022 3.531438 0.080021 0.099885
18374 600276.XSHG 2023-01-16 38.7996 42.6826 38.7996 42.6134 137880485.0 5.856679e+09 Monday 2023 5.856679 0.091122 0.098295
13481 600104.XSHG 2023-11-27 14.4718 15.1204 14.2782 14.9945 94795391.0 1.442680e+09 Monday 2023 1.442680 0.056167 0.062407
18236 600276.XSHG 2022-06-27 33.2162 35.5094 33.2162 34.9976 111401072.0 3.917628e+09 Monday 2022 3.917628 0.065524 0.060858
18533 600276.XSHG 2023-09-11 40.4039 42.7934 40.4039 42.6348 75735706.0 3.231974e+09 Monday 2023 3.231974 0.056046 0.059637

top函数在groupby的每个交易所组上调用,再由pandas.concat拼接结果并以组名标记。因此结果含层级索引,内层保留原始行情表的行索引。

如果您向 apply 传递一个接受其他参数或关键字的函数,您可以在函数之后传递它们:

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

# 在交易所×星期组内按成交额选出一条代表性最高观测
groupby_market_df.groupby(['exchange', 'weekday']).apply(
    top, n=1, column='turnover_billion', include_groups=False
)
表 8.32: 交易所×星期组内成交额最高的观测
order_book_id date open high low close volume total_turnover year turnover_billion intraday_range_pct daily_return
exchange weekday
XSHE Friday 7999 002415.XSHE 2022-05-06 32.1288 32.5225 30.7957 31.1357 245212961.0 8.571684e+09 2022 8.571684 0.055460 -0.089959
Monday 8069 002415.XSHE 2022-08-15 30.1753 30.1753 28.6108 29.2642 162324812.0 5.154249e+09 2022 5.154249 0.053461 -0.048189
Thursday 8234 002415.XSHE 2023-04-20 39.7183 40.0772 37.3349 38.3011 136778685.0 5.651599e+09 2023 5.651599 0.071598 -0.035681
Tuesday 8223 002415.XSHE 2023-04-04 38.9729 42.4883 38.6508 41.3288 111194896.0 4.904280e+09 2023 4.904280 0.092853 0.058448
Wednesday 8007 002415.XSHE 2022-05-18 28.8005 30.3305 28.1921 29.7937 156388629.0 5.124739e+09 2022 5.124739 0.071774 0.034486
XSHG Friday 18507 600276.XSHG 2023-08-04 42.1886 42.3076 40.6518 40.7212 106460887.0 4.420471e+09 2023 4.420471 0.040662 -0.028389
Monday 18503 600276.XSHG 2023-07-31 48.6235 48.9110 43.8444 44.2014 135924169.0 6.226335e+09 2023 6.226335 0.114625 -0.091131
Thursday 18377 600276.XSHG 2023-01-19 41.6550 44.2338 41.1610 43.1173 109486409.0 4.757344e+09 2023 4.757344 0.071266 0.049795
Tuesday 18504 600276.XSHG 2023-08-01 43.9337 43.9337 41.9407 43.0116 134400131.0 5.837540e+09 2023 5.837540 0.046336 -0.026918
Wednesday 18505 600276.XSHG 2023-08-02 42.8827 43.6164 41.7028 41.9209 93481863.0 4.002904e+09 2023 4.002904 0.045648 -0.025358

除了这些基本的使用机制,要充分利用 apply 可能需要一些创造力。传递的函数内部发生什么完全取决于您;它必须返回一个 pandas 对象或一个标量值。

例如,可以在按交易所分组的日收益序列上调用 describe

daily_return_by_exchange = groupby_market_df.groupby('exchange')['daily_return']  # 固定分组键与输入列,供两种写法公平比较
result = daily_return_by_exchange.describe()  # 生成各交易所收益率的完整描述统计
result  # 输出每个交易所收益率的count、均值、分位数与极值schema
count mean std min 25% 50% 75% max
exchange
XSHE 483.0 -0.000521 0.021517 -0.100024 -0.012592 -0.002002 0.011050 0.061563
XSHG 1449.0 -0.000309 0.015544 -0.100000 -0.008167 -0.001214 0.006951 0.099885

为了得到不同的视图,可以将其 unstack:

result.stack().unstack('exchange')  # 将统计量放在行上以横向比较交易所
exchange XSHE XSHG
count 483.000000 1449.000000
mean -0.000521 -0.000309
std 0.021517 0.015544
min -0.100024 -0.100000
25% -0.012592 -0.008167
50% -0.002002 -0.001214
75% 0.011050 0.006951
max 0.061563 0.099885

同样的“逐组生成多项摘要再组合”行为也可用 apply 表达;这只是输出合同的等价写法,不代表 pandas 的内部实现:

def describe_group(group):  # 为每个分组返回多统计量Series,输出形状不再等同输入组
    return group.describe()  # 生成计数、均值与分位数等完整组内摘要

result_from_apply = daily_return_by_exchange.apply(describe_group).unstack()  # 使用与上例相同的分组键和输入列
pd.testing.assert_frame_equal(result_from_apply, result)  # 核验两种写法的索引、列与数值均一致
result_from_apply
count mean std min 25% 50% 75% max
exchange
XSHE 483.0 -0.000521 0.021517 -0.100024 -0.012592 -0.002002 0.011050 0.061563
XSHG 1449.0 -0.000309 0.015544 -0.100000 -0.008167 -0.001214 0.006951 0.099885

8.4.1 不将分组键写入 apply 结果索引

在前面的例子中,结果对象有一个由分组键形成的外层索引,以及原始对象每个部分的索引。向 groupby 传递 group_keys=False,可以在组合 apply 结果时不加入这个外层组键;它不会取消分组本身,也不同于控制分组列是否传给函数的 include_groups=False

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

groupby_market_df.groupby('exchange', group_keys=False).apply(top, include_groups=False)  # group_keys控制结果索引;include_groups控制传给top的列
表 8.33: 各交易所收益率最高观测,不将组键加入结果索引
order_book_id date open high low close volume total_turnover weekday year turnover_billion intraday_range_pct daily_return
8211 002415.XSHE 2023-03-17 35.3379 37.5465 35.3195 37.1324 94618489.0 3.786373e+09 Friday 2023 3.786373 0.059975 0.061563
8192 002415.XSHE 2023-02-20 33.1293 35.8441 32.9913 35.6140 92302594.0 3.514053e+09 Monday 2023 3.514053 0.080103 0.059985
8223 002415.XSHE 2023-04-04 38.9729 42.4883 38.6508 41.3288 111194896.0 4.904280e+09 Tuesday 2023 4.904280 0.092853 0.058448
8269 002415.XSHE 2023-06-13 29.8992 31.6923 29.7303 31.5233 78126912.0 2.577306e+09 Tuesday 2023 2.577306 0.062240 0.057637
8019 002415.XSHE 2022-06-06 30.2765 32.2919 30.1845 32.0802 124786137.0 4.236360e+09 Monday 2022 4.236360 0.065692 0.056684
18309 600276.XSHG 2022-10-14 34.5808 37.5350 34.5314 37.5350 95015743.0 3.531438e+09 Friday 2022 3.531438 0.080021 0.099885
18374 600276.XSHG 2023-01-16 38.7996 42.6826 38.7996 42.6134 137880485.0 5.856679e+09 Monday 2023 5.856679 0.091122 0.098295
13481 600104.XSHG 2023-11-27 14.4718 15.1204 14.2782 14.9945 94795391.0 1.442680e+09 Monday 2023 1.442680 0.056167 0.062407
18236 600276.XSHG 2022-06-27 33.2162 35.5094 33.2162 34.9976 111401072.0 3.917628e+09 Monday 2022 3.917628 0.065524 0.060858
18533 600276.XSHG 2023-09-11 40.4039 42.7934 40.4039 42.6348 75735706.0 3.231974e+09 Monday 2023 3.231974 0.056046 0.059637

8.4.2 分位数和分箱分析

您可能还记得,pandas 有一些工具,特别是 pandas.cutpandas.qcut,用于将数据按您选择的箱或样本分位数切分成桶。将这些函数与 groupby 结合起来,可以方便地对数据集进行分箱或分位数分析。考虑一个简单的随机数据集,并使用 pandas.cut 进行等宽分箱:

# 构造固定种子的机制演示样本,只用于说明等宽与分位数分箱 API
frame = pd.DataFrame({'data1': chapter_rng.standard_normal(1000),
                      'data2': chapter_rng.standard_normal(1000)})
frame.head()                                                # 核验固定随机样本的data1与data2两列
data1 data2
0 0.160916 -1.667790
1 -0.614018 0.251218
2 -0.403750 -2.532762
3 0.548260 -0.690259
4 -0.130483 0.437492
equal_width_bins = pd.cut(frame['data1'], 4)                # 按data1取值范围切成四个等宽区间,而非等样本数分位组
equal_width_bins.head(10)                                   # 查看前10行的等宽区间标签
0      (-0.256, 1.49]
1    (-2.002, -0.256]
2    (-2.002, -0.256]
3      (-0.256, 1.49]
4      (-0.256, 1.49]
5    (-2.002, -0.256]
6    (-2.002, -0.256]
7      (-0.256, 1.49]
8      (-0.256, 1.49]
9      (-0.256, 1.49]
Name: data1, dtype: category
Categories (4, interval[float64, right]): [(-3.754, -2.002] < (-2.002, -0.256] < (-0.256, 1.49] < (1.49, 3.236]]

cut 返回的 Categorical 对象可以直接传递给 groupby。因此,我们可以为这四个等宽分箱区间计算一组分组统计量,如下所示:

def get_stats(group):                                       # 自定义函数:计算分组摘要统计量
    return pd.DataFrame(                                    # 返回包含四项统计量的DataFrame
        {'min': group.min(), 'max': group.max(),  # 各列的最小值和最大值
         'count': group.count(), 'mean': group.mean()}  # 各列的计数和均值
    )  # DataFrame构建完成

grouped = frame.groupby(equal_width_bins, observed=False)   # 保留四个预设等宽区间,包括可能没有观测的空箱
grouped.apply(get_stats)                                    # 对四个data1等宽区间分别返回两列的最值、计数与均值
min max count mean
data1
(-3.754, -2.002] data1 -3.747414 -2.037089 27 -2.399853
data2 -2.571780 1.688671 27 -0.449329
(-2.002, -0.256] data1 -1.957228 -0.262795 349 -0.911597
data2 -3.058795 3.333527 349 -0.127137
(-0.256, 1.49] data1 -0.246030 1.486060 556 0.484254
data2 -2.994512 2.596231 556 -0.030364
(1.49, 3.236] data1 1.516021 3.236232 68 1.998755
data2 -1.749319 2.036021 68 -0.257845

请记住,使用 agg 可以更简单地计算出相同的结果:

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

grouped.agg(['min', 'max', 'count', 'mean'])                # 每个区间逐列给出范围、非缺失计数与均值
表 8.34: 使用 agg 方法汇总等宽分箱
data1 data2
min max count mean min max count mean
data1
(-3.754, -2.002] -3.747414 -2.037089 27 -2.399853 -2.571780 1.688671 27 -0.449329
(-2.002, -0.256] -1.957228 -0.262795 349 -0.911597 -3.058795 3.333527 349 -0.127137
(-0.256, 1.49] -0.246030 1.486060 556 0.484254 -2.994512 2.596231 556 -0.030364
(1.49, 3.236] 1.516021 3.236232 68 1.998755 -1.749319 2.036021 68 -0.257845

以上是等宽分箱。要依据样本分位数形成观测数尽量相等的等频分箱,请使用 pandas.qcut。我们可以传递 labels=False 来获取四分位组编号而不是区间标签:

quantile_bins = pd.qcut(frame['data1'], 4, labels=False)    # 按样本分位数形成四个等频分箱
quantile_bins.head()                                        # 查看data1等频分箱编号是否为0—3
0    2
1    1
2    1
3    2
4    1
Name: data1, dtype: int64

现在我们可以按这些样本分位数进行分组:

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

frame.groupby(quantile_bins).apply(get_stats)               # 对四个等频分位组分别生成data1与data2摘要
表 8.35: 按样本分位数进行聚合
min max count mean
data1
0 data1 -3.747414 -0.675733 250 -1.292756
data2 -2.784654 3.333527 250 -0.099234
1 data1 -0.675704 0.036493 250 -0.299438
data2 -3.058795 2.446730 250 -0.158523
2 data1 0.043370 0.712300 250 0.361977
data2 -2.994512 2.596231 250 -0.072443
3 data1 0.717782 3.236232 250 1.319087
data2 -2.591410 2.280767 250 -0.033473

8.4.3 示例:使用特定于组的值填充缺失值

在清理缺失数据时,有时您会使用 dropna 删除数据观测值,但在其他情况下,您可能希望使用固定值或从数据中派生的某个值来填充空值(NA)。fillna 是实现此目的的正确工具。

假设您需要填充值因组而异。一种方法是对数据进行分组,并使用 apply 和一个在每个数据块上调用 fillna 的函数。这里有一些关于中国各省的数据,分为南方和北方地区(以秦岭-淮河为界):

provinces = ['黑龙江', '北京', '山东', '河南',                       # 中国各省份名称(北方四省)
          '上海', '湖北', '四川', '广东']  # 南方四省
group_key = ['北方'] * 4 + ['南方'] * 4                         # 地理分区标签:前4为北方,后4为南方
data = pd.Series(chapter_rng.standard_normal(8), index=provinces)  # 构造固定题设值演示按地区填补,不作区域实证

# 将某些值设为缺失
data[['山东', '湖北', '广东']] = np.nan                           # 缺失值标识符
data  # 显示八省题设Series及山东、湖北、广东三个待填补缺失值
黑龙江   -1.326071
北京    -1.138246
山东          NaN
河南    -0.138084
上海     0.906558
湖北          NaN
四川     1.321637
广东          NaN
dtype: float64

我们现在可以使用各组的均值来填充 NA 值:

def fill_mean(group):                                       # 自定义函数:用组内均值填充NA
    return group.fillna(group.mean())                       # 将组内缺失值替换为组均值

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

filled_by_region = data.groupby(group_key).apply(fill_mean)  # 在南北两组内分别以非缺失题设值均值填补山东、湖北与广东
filled_by_region.index.names = ['地区', '省份']               # 明示外层分组键和内层原Series索引的含义
fill_mean_result_df = filled_by_region.rename('填补后题设值(标准化单位)').to_frame()  # 将Series转为具有语义列名的二维验收表
assert fill_mean_result_df.shape == (8, 1) and not fill_mean_result_df.isna().any().any()  # 核验八省均保留且三处缺失已全部填补
fill_mean_result_df                                        # 输出“地区—省份”两级索引及无量纲机制演示值
表 8.36: 用区域均值填充缺失值后的数据
填补后题设值(标准化单位)
地区 省份
北方 黑龙江 -1.326071
北京 -1.138246
山东 -0.867467
河南 -0.138084
南方 上海 0.906558
湖北 1.114097
四川 1.321637
广东 1.114097

表中外层索引是南北地区,内层索引是省份;“填补后题设值”是为演示 API 生成的无量纲标准化数值,不是省级经济指标。

在另一种情况下,您可能在代码中预定义了因组而异的填充值。由于分组内部有一个 name 属性,我们可以利用它:

fill_values = {'北方': 0.5, '南方': -1}                         # 各地区预定义的填充值
def fill_func(group):                                       # 自定义函数:用分组特定值填充NA
    return group.fillna(fill_values[group.name])            # 根据组名查找对应填充值

data.groupby(group_key).apply(fill_func)                    # 按组名查表:北方缺失填0.5、南方缺失填-1
北方  黑龙江   -1.326071
    北京    -1.138246
    山东     0.500000
    河南    -0.138084
南方  上海     0.906558
    湖北    -1.000000
    四川     1.321637
    广东    -1.000000
dtype: float64

8.4.4 示例:随机抽样和排列

假设您想为蒙特卡洛模拟目的从一个大型数据集中抽取随机样本(有放回或无放回)。我们可以使用 Seriessample 方法。为了演示,这里有一种构建一副扑克牌的方法:

# H=红桃, S=黑桃, C=梅花, D=方块 (Mocking Sectors: Finance, Tech, Consumer, Energy)
sectors = ['Finance', 'Tech', 'Consumer', 'Energy']         # 四个题设行业各生成13个代码,总证券池规模为52
stock_list = []                                             # 初始化空的股票代码列表
for sector in sectors:  # 遍历sectors中的每个元素
    stock_list.extend([f'{sector}_{i}' for i in range(13)])  # 将序列元素逐一添加到列表
stock_pool = pd.Series(np.arange(52), index=stock_list)     # 创建Series序列
stock_pool.head(13)                                         # 查看前13行数据
Finance_0      0
Finance_1      1
Finance_2      2
Finance_3      3
Finance_4      4
Finance_5      5
Finance_6      6
Finance_7      7
Finance_8      8
Finance_9      9
Finance_10    10
Finance_11    11
Finance_12    12
dtype: int64

从股票池中随机抽取五只股票可以写成:

def draw_stocks(pool, n=5):                                 # 自定义函数:从股票池中随机抽取n只股票
    '''使用章级固定随机数流从当前股票池无放回抽样。'''  # 保留原索引以追溯每个抽样证券的板块
    return pool.sample(n, random_state=chapter_rng)          # 复用章级固定随机数流保证冷渲染结果可重复
draw_stocks(stock_pool)  # 演示:从所有股票中随机抽取5只
Energy_4      43
Consumer_2    28
Consumer_0    26
Tech_8        21
Energy_6      45
dtype: int64

假设您想从每种行业板块中随机抽取两个股票。由于板块名称是每个股票代码名称的第一个部分,我们可以基于此进行分组并使用 apply

def get_sector(stock_code):                                 # 自定义函数:从股票代码中提取板块名称
    # 股票代码的前缀是板块
    return stock_code.split('_')[0]                         # 按下划线分割后取第一部分

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

stock_pool.groupby(get_sector).apply(draw_stocks, n=2)  # 在每个板块内部独立无放回抽样,并保留分组键索引
表 8.37: 从每个板块中随机抽取两只股票
Consumer  Consumer_2    28
          Consumer_4    30
Energy    Energy_1      40
          Energy_12     51
Finance   Finance_0      0
          Finance_9      9
Tech      Tech_11       24
          Tech_7        20
dtype: int64

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

stock_pool.groupby(get_sector, group_keys=False).apply(draw_stocks, n=2)  # 组合apply结果时不增加板块组键,保留原股票索引
表 8.38: 从每个板块中抽样,不将组键加入结果索引
Consumer_6     32
Consumer_11    37
Energy_7       46
Energy_1       40
Finance_6       6
Finance_3       3
Tech_7         20
Tech_2         15
dtype: int64

8.4.5 示例:分组加权平均和相关性

groupby 的拆分-应用-合并范式下,DataFrame 中的列之间或两个 Series 之间的操作是可能的。举个例子,这个数据集包含分组键、值和一些权重:

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

# 构造固定题设的板块收益与市值权重,专门演示加权聚合机制
portfolio_data = pd.DataFrame({'sector': ['Tech', 'Tech', 'Tech', 'Tech', 'Finance', 'Finance', 'Finance', 'Finance'],
                   'return': chapter_rng.standard_normal(8),
                   'market_cap': chapter_rng.uniform(size=8)})
portfolio_data  # 输出八行板块收益与正市值权重,供加权均值分母核对
表 8.39: 用于分组加权平均计算的投资组合数据
sector return market_cap
0 Tech -0.205991 0.546526
1 Tech 1.537792 0.007713
2 Tech -0.931683 0.886912
3 Tech -1.340775 0.241519
4 Finance 1.126705 0.573491
5 Finance 1.276795 0.673295
6 Finance -0.949081 0.406774
7 Finance 0.898422 0.908846

按板块计算的加权平均收益率将是:

def get_wavg_return(group):                                 # 自定义函数:计算市值加权平均收益率
    return np.average(group['return'], weights=group['market_cap'])  # 以市值为权重计算加权平均收益

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

sector_wavg_return = portfolio_data.groupby('sector').apply(get_wavg_return, include_groups=False)  # 板块键由索引保留,函数只接收收益与市值列
sector_wavg_return.index.name = '板块'                       # 将分组键写入结果索引名称以便读表
wavg_result_df = sector_wavg_return.rename('市值加权收益率(小数)').to_frame()  # 将标量Series转为带单位说明的二维表
assert wavg_result_df.shape == (2, 1) and np.isfinite(wavg_result_df.to_numpy()).all()  # 核验Tech与Finance两组均得到有限结果
wavg_result_df                                             # 输出板块索引和小数口径的加权收益列
表 8.40: 按板块的分组加权平均收益率
市值加权收益率(小数)
板块
Finance 0.755649
Tech -0.743379

表的行索引是板块,数值列是以题设 market_cap 相对权重计算的收益率小数;例如 0.01 表示 1%,但这些随机题设值不代表真实板块回报。

再举一个更贴近金融实践的例子。我们将使用本地数据获取几只A股龙头股票(恒瑞医药、上汽集团、科大讯飞)和沪深300指数(000300.XSHG)历史收盘价数据。

import pandas as pd                                         # 组织四资产共同交易日价格与年度结果表
import numpy as np                                          # 核验相关系数和回归参数均为有限值

# 股票映射:代码 -> 名称
tickers = {'600276.XSHG': '恒瑞医药', '600104.XSHG': '上汽集团', '002230.XSHE': '科大讯飞'}  # 股票代码与名称的映射字典
index_symbol = '000300.XSHG'                                # 沪深300指数代码

all_data = {}                                               # 初始化空字典,用于存储各资产收盘价序列

# 获取股票数据
stock_px_frames = []  # 收集三只股票的前复权日行情,稍后按同一字段纵向合并
for ticker_code in tickers.keys():  # 依次读取恒瑞、上汽和科大讯飞三个证券代码
    single_stock = pd.read_hdf(  # 从本地前复权行情表下推当前证券代码条件
        PRE_ADJUSTED_PRICE_PATH,
        where=f'order_book_id={ticker_code!r}'
    ).reset_index()  # 恢复date索引,使三个证券可按日期字段审计并重塑
    stock_px_frames.append(single_stock)  # 将当前证券的完整日行情追加到同构表列表
stock_px = pd.concat(stock_px_frames, ignore_index=True)  # 形成三证券长表,保留date、close与代码供后续转为宽表
# 转换日期并重塑数据
stock_px['date'] = pd.to_datetime(stock_px['date'])         # 统一股票交易日为DatetimeIndex兼容类型
for code, name in tickers.items():                          # 将证券代码映射为中文列名
    all_data[name] = stock_px.loc[stock_px['order_book_id'].eq(code)].set_index('date')['close']  # 形成“交易日→收盘价”的单资产序列

股票侧读取完成后,下面再读取指数、解析其来源日期,并在共同交易日上合并。这样既保持 all_data 的依赖顺序,也把每个非绘图代码块控制在 25 行可执行代码以内。

# 获取指数数据
index_all = pd.read_hdf(f'{DATA_ROOT}/index/indexes.h5')  # Fixed格式不能下推where,故读取指数表后再筛选
index_px = index_all.loc[index_all['symbol'].eq(index_symbol)].copy()  # 只保留000300.XSHG并复制以安全修改时间列
index_px['datetime'] = pd.to_datetime(                     # 把14位YYYYMMDDhhmmss整数按数据字典显式解析
    index_px['datetime'].astype(str),
    format='%Y%m%d%H%M%S',
    errors='raise'
)
assert index_px['datetime'].min() <= pd.Timestamp('2005-01-04') and index_px['datetime'].max() >= pd.Timestamp('2023-12-29')  # 核验指数时间覆盖而非误落在1970年纳秒区间
all_data['沪深300'] = index_px.set_index('datetime')['close']  # 形成“交易日→指数收盘点位”的基准序列

# 合并数据并处理缺失值
asset_closing_prices_df = pd.DataFrame(all_data).sort_index()  # 合并所有资产数据并按日期排序
asset_closing_prices_df = asset_closing_prices_df.loc['2018-01-01':'2023-12-31']  # 固定六个完整自然年的研究窗口
asset_closing_prices_df.dropna(inplace=True)                # 只保留三只股票与沪深300均有收盘价的共同交易日
assert asset_closing_prices_df.shape == (1457, 4)           # 核验四资产2018—2023共同样本为1457个交易日
assert asset_closing_prices_df.index.min() == pd.Timestamp('2018-01-02') and asset_closing_prices_df.index.max() == pd.Timestamp('2023-12-29')  # 核验共同样本起止交易日
asset_closing_prices_df.tail()                              # 查看后5行数据
恒瑞医药 上汽集团 科大讯飞 沪深300
2023-12-25 43.6263 12.9520 46.4289 3347.4508
2023-12-26 43.6065 12.8649 44.9656 3324.7902
2023-12-27 43.7155 12.8746 44.8461 3336.3572
2023-12-28 44.3699 13.0876 45.8416 3414.5403
2023-12-29 44.8458 13.0972 46.1701 3431.1099

DataFrameinfo() 方法在这里是一种方便的方式,可以概览 DataFrame 的内容。

asset_closing_prices_df.info()                              # 查看数据基本信息
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 1457 entries, 2018-01-02 to 2023-12-29
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   恒瑞医药    1457 non-null   float64
 1   上汽集团    1457 non-null   float64
 2   科大讯飞    1457 non-null   float64
 3   沪深300   1457 non-null   float64
dtypes: float64(4)
memory usage: 56.9 KB

一个在投资组合分析中常见的任务是计算个股的每日收益率与市场指数(此处为沪深300)的年度相关性。作为一种实现方式,我们首先创建一个函数,计算每列与 ‘沪深300’ 列的成对相关性:

def csi300_corr(group):                                     # 自定义函数:计算各列与沪深300的相关性
    return group.corrwith(group['沪深300'])                   # 成对计算相关系数

接下来,我们使用 pct_change 计算 close_px 的百分比变化(即日收益率):

daily_returns_df = asset_closing_prices_df.pct_change(fill_method=None).dropna()  # 剔除共同样本首日NA,不跨缺失日期填充价格
annual_observation_counts = daily_returns_df.groupby(daily_returns_df.index.year).size()  # 逐年清点四资产均有效的日收益行数
expected_annual_counts = pd.Series([242, 244, 243, 243, 242, 242], index=range(2018, 2024))  # 固定数据快照的年度验收基准
assert annual_observation_counts.equals(expected_annual_counts)  # 核验2018—2023各年有效收益观测数

最后,我们按年份对这些收益率进行分组,年份可以从每个行标签(日期)中提取:

def get_year(x):                                            # 自定义函数:从日期索引中提取年份
    return x.year                                           # 返回日期的year属性

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

by_year = daily_returns_df.groupby(get_year)                # 以日期索引年份为键切分共同交易日日收益
yearly_corr_df = by_year.apply(csi300_corr)                 # 每年计算三只股票及指数自身相对沪深300的相关系数
yearly_corr_df.index.name = '年份'                           # 标明结果六行对应2018—2023自然年
assert yearly_corr_df.shape == (6, 4) and np.isfinite(yearly_corr_df.to_numpy()).all()  # 核验六年四列相关系数均存在且有限
yearly_corr_df.round(4)                                    # 输出年度×资产相关系数表
表 8.41: 股票收益与沪深300指数的年度相关性
恒瑞医药 上汽集团 科大讯飞 沪深300
年份
2018 0.6164 0.5498 0.5828 1.0
2019 0.5326 0.6116 0.5859 1.0
2020 0.5131 0.6072 0.6349 1.0
2021 0.5206 0.3223 0.5116 1.0
2022 0.5438 0.6490 0.6116 1.0
2023 0.3648 0.5434 0.3394 1.0

您还可以计算任意两列之间的相关性。这里我们计算恒瑞医药和上汽集团之间的年度相关性:

def corr_hengrui_saic(group):                                # 定义函数以计算两家长三角公司的年度收益相关性
    return group['恒瑞医药'].corr(group['上汽集团'])                # 两只股票收益率的相关系数

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

hengrui_saic_corr_df = by_year.apply(corr_hengrui_saic).rename('收益相关系数').to_frame()  # 将逐年标量结果整理为语义化二维表
hengrui_saic_corr_df.index.name = '年份'                    # 明示行索引是自然年而非任意组号
assert hengrui_saic_corr_df.shape == (6, 1) and np.isfinite(hengrui_saic_corr_df.to_numpy()).all()  # 核验六个年度相关系数均有限
hengrui_saic_corr_df.round(4)                              # 输出年度、恒瑞—上汽收益相关系数
表 8.42: 恒瑞医药和上汽集团收益的年度相关性
收益相关系数
年份
2018 0.3208
2019 0.3867
2020 0.2300
2021 0.1279
2022 0.3144
2023 0.1524

8.4.6 示例:分组线性回归

与前一个示例的主题相同,您可以使用 groupby 执行更复杂的分组统计分析,只要函数返回一个 pandas 对象或标量值即可。例如,我们可以定义以下 regress 函数(使用 statsmodels 计量经济学库),它对每个数据块执行普通最小二乘法(OLS)回归。

实证视角:原始收益市场模型的分组回归分析

在投资组合分析与学术研究中,对特定因子(如行业、市值、动量)进行分组并执行滚动回归是一项标准流程。上述操作在金融研究中具有明确的计量含义:

  1. 市场暴露描述:当前代码使用原始个股收益和沪深 300 收益,逐年估计 \(R_{i,t}=\alpha_i+\beta_iR_{m,t}+\epsilon_{i,t}\),因此应称为市场模型,而不是超额收益 CAPM。斜率 \(\beta_i\) 描述样本内市场暴露。
  2. 动态 Beta (Dynamic Beta):分组回归可以描述上汽集团或恒瑞医药的市场暴露如何随样本年份变化,但不能据此断言宏观因素造成了变化。
  3. 估计与推断条件:OLS 系数的一致性需要条件均值为零等外生性条件、有限矩、充分变异和合适的模型设定;异方差或序列相关本身不自动保证或破坏一致性。若外生性成立,异方差会使常规同方差标准误失效,可使用异方差稳健标准误;若误差还存在序列相关,则应预先说明滞后阶数并使用 HAC/Newey–West 标准误。当前示例只报告样本内系数,不据此进行显著性检验。
import statsmodels.api as sm                                # 导入statsmodels统计建模库
def regress(data, yvar, xvars):                             # 自定义函数:对分组数据执行OLS回归
    Y = data[yvar]  # 提取因变量列
    X = data[xvars]  # 提取自变量列
    X = sm.add_constant(X) # 添加截距项
    result = sm.OLS(Y, X).fit()                             # 构建OLS线性回归模型
    return result.params                                    # 返回回归系数估计值

现在,要对“上汽集团”原始收益相对“沪深300”原始收益进行年度市场模型回归,我们执行:

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

yearly_regression_df = by_year.apply(regress, yvar='上汽集团', xvars=['沪深300'])  # 每年估计截距与沪深300市场暴露系数
yearly_regression_df.index.name = '年份'                    # 将回归分组键标记为自然年
yearly_regression_df.columns = ['截距(日收益小数)', '沪深300斜率']  # 写明两列参数的统计含义和截距单位
assert yearly_regression_df.shape == (6, 2) and np.isfinite(yearly_regression_df.to_numpy()).all()  # 核验六年十二个OLS参数均有限
yearly_regression_df.round(4)                              # 输出可审计的年度市场模型参数表
表 8.43: 上汽集团原始收益对沪深300原始收益的年度市场模型回归
截距(日收益小数) 沪深300斜率
年份
2018 0.0005 0.7457
2019 -0.0012 0.8473
2020 -0.0006 1.1240
2021 -0.0003 0.5611
2022 -0.0005 0.7919
2023 0.0002 0.6944

8.5 分组转换和“展开的”GroupBy

小节 8.4 中,我们研究了用于执行转换的 apply 方法。还有一个名为 transform 的内置方法,它与 apply 类似,但对您可以使用的函数类型施加了更多限制:

  • 它可以产生一个标量值,该值将被广播到组的形状。
  • 它可以产生一个与输入组形状相同的对象。
  • 它不能改变其输入。

让我们看一个简单的例子来说明:

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

sample_trading_volume_df = pd.DataFrame({'stock': ['600276', '002415', '002230'] * 4,  # 构建示例日成交量数据框
                   'volume': chapter_rng.integers(1000, 10000, 12)})
sample_trading_volume_df  # 输出三只证券各四条成交量记录的12行输入表
表 8.44: 用于演示 transform 的日成交量数据
stock volume
0 600276 7996
1 002415 2141
2 002230 8012
3 600276 4794
4 002415 6962
5 002230 5313
6 600276 6299
7 002415 6846
8 002230 8189
9 600276 5166
10 002415 1827
11 002230 1448

以下是按股票分组的平均成交量:

g = sample_trading_volume_df.groupby('stock')['volume']     # 按股票代码切分volume列,准备计算各证券平均成交量
g.mean()  # 计算各股票的平均成交量
stock
002230    5740.50
002415    4444.00
600276    6063.75
Name: volume, dtype: float64

假设我们要生成一个与 sample_trading_volume_df['volume'] 形状相同但值被替换为按 ‘stock’ 分组的平均值的 Series。我们可以将一个计算单个组均值的函数传递给 transform

def get_mean(group):                                        # 自定义函数:计算分组均值
    return group.mean()                                     # 返回组内均值
    
g.transform(get_mean)                                       # 将各证券成交量均值广播回该证券每条原记录
0     6063.75
1     4444.00
2     5740.50
3     6063.75
4     4444.00
5     5740.50
6     6063.75
7     4444.00
8     5740.50
9     6063.75
10    4444.00
11    5740.50
Name: volume, dtype: float64

对于内置的聚合函数,我们可以像 GroupBy agg 方法一样传递一个字符串别名:

g.transform('mean')                                         # 用内置均值获得与输入行数相同的证券基准列
0     6063.75
1     4444.00
2     5740.50
3     6063.75
4     4444.00
5     5740.50
6     6063.75
7     4444.00
8     5740.50
9     6063.75
10    4444.00
11    5740.50
Name: volume, dtype: float64

transform vs. apply

transform 的核心合同是组合后的结果必须与所选输入对象具有相同的索引和形状。传给单个组的函数可以返回一个标量,由 pandas 广播到该组;也可以返回与该组同形的对象。apply 则灵活得多,可以返回任意形状的对象,再由 pandas 按分组键组合。

group.describe() 是不适合交给 transform 的例子:它返回以 countmean、分位数等摘要名为索引的 Series,既不是标量,也不与原组索引同形。不同 pandas 路径可能报形状错误,也可能按索引对齐后产生全缺失结果;两者都不是所需的摘要表,不能把“未抛异常”误当作合同成立。

# 不要这样做:摘要Series无法与原成交量行一一对应,可能报错或对齐成缺失值
# g.transform(lambda x: x.describe())

# apply 可以组合每组返回的多项摘要
g.apply(lambda x: x.describe())

选择 transform 还是 apply 取决于您的目标:如果您想基于分组计算来“改变”或“转换”原始数据框中的值(例如中心化、标准化),请使用 transform。如果您想对每个组进行汇总,得到一个比原分组更小的结果,请使用 applyagg

apply 类似,transform 适用于返回 Series 的函数,但结果的大小必须与输入相同。例如,我们可以使用一个辅助函数将每个组乘以2:

def times_two(group):                                       # 自定义函数:将组内元素翻倍
    return group * 2                                        # 返回每个元素乘以2的结果
g.transform(times_two)                                      # 在每只证券内逐行把成交量乘以2且保持原索引
0     15992
1      4282
2     16024
3      9588
4     13924
5     10626
6     12598
7     13692
8     16378
9     10332
10     3654
11     2896
Name: volume, dtype: int64

作为一个更复杂的例子,我们可以计算每个组内降序的排名:

def get_ranks(group):                                       # 自定义函数:计算组内降序排名
    return group.rank(ascending=False)                      # 按降序排列并返回排名
g.transform(get_ranks)                                      # 生成组内降序序数排名;最大成交量的名次为1,并非百分位
0     1.0
1     3.0
2     2.0
3     4.0
4     1.0
5     3.0
6     2.0
7     2.0
8     1.0
9     3.0
10    4.0
11    4.0
Name: volume, dtype: float64

考虑一个由简单聚合组成的分组转换函数,例如,将数据进行标准化(计算z-score):

def normalize(x):                                           # 自定义函数:分组标准化(z-score)
    return (x - x.mean()) / x.std()                         # 返回z-score标准化值

在这种情况下,我们可以使用 transformapply 获得等效的结果:

g.transform(normalize)                                      # 将各证券成交量标准化后对齐回原始12行
0     1.343315
1    -0.809818
2     0.721163
3    -0.882740
4     0.885420
5    -0.135724
6     0.163548
7     0.844630
8     0.777358
9    -0.624122
10   -0.920232
11   -1.362797
Name: volume, dtype: float64
g.apply(normalize)                                          # 返回带证券分组键的标准化Series以对比索引结构
stock     
002230  2     0.721163
        5    -0.135724
        8     0.777358
        11   -1.362797
002415  1    -0.809818
        4     0.885420
        7     0.844630
        10   -0.920232
600276  0     1.343315
        3    -0.882740
        6     0.163548
        9    -0.624122
Name: volume, dtype: float64

'mean''sum' 这样的内置聚合函数通常比通用的 apply 函数快得多。当与 transform 一起使用时,它们也有一个“快速路径”。这使我们能够执行所谓的 “展开的” (unwrapped) 分组操作:

normalized = (sample_trading_volume_df['volume'] - g.transform('mean')) / g.transform('std')  # 以同证券均值和样本标准差手工复算z-score
normalized  # 输出与原12行同索引的组内成交量z-score Series
0     1.343315
1    -0.809818
2     0.721163
3    -0.882740
4     0.885420
5    -0.135724
6     0.163548
7     0.844630
8     0.777358
9    -0.624122
10   -0.920232
11   -1.362797
Name: volume, dtype: float64

在这里,我们是在多个 GroupBy 操作的输出之间进行算术运算,而不是编写一个函数并将其传递给 groupby(...).apply。这种向量化的方法就是所谓的“展开的”,它通常要快得多。

8.6 透视表和交叉表

透视表是一种数据汇总工具,常见于Excel等电子表格程序和其他数据分析软件中。它通过一个或多个键聚合数据表,将数据排列在一个矩形中,其中一些分组键沿行排列,一些沿列排列。在 Python 中使用 pandas 创建透视表是通过 groupby 功能以及利用层级索引的重塑操作实现的。DataFrame 还有一个 pivot_table 方法,并且还有一个顶级的 pandas.pivot_table 函数。

回到真实日行情,先计算一个按星期与交易所排列在行上的分组均值表(pivot_table默认使用均值):

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

# 按星期和交易所汇总真实行情均值,形成最简单的行分组透视表
groupby_market_df.pivot_table(
    index=['weekday', 'exchange'], values=['daily_return', 'turnover_billion', 'volume']
)
表 8.45: 一个简单的均值透视表
daily_return turnover_billion volume
weekday exchange
Friday XSHE -1.122940e-03 1.578822 4.459782e+07
XSHG 1.474464e-03 0.747696 3.202278e+07
Monday XSHE -1.464051e-05 1.685181 4.720532e+07
XSHG -1.427402e-03 0.836929 3.389170e+07
Thursday XSHE -3.134495e-03 1.355579 3.747615e+07
XSHG -8.554984e-07 0.717166 3.039985e+07
Tuesday XSHE 1.137987e-03 1.455607 4.086913e+07
XSHG -1.113269e-03 0.749223 3.142120e+07
Wednesday XSHE 6.145469e-04 1.395407 3.890375e+07
XSHG -5.257541e-04 0.711153 2.988721e+07

这也可以用groupby生成。下面把交易所放在列上,把年份和星期放在行上,同时汇总收益率与成交额:

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

# 将交易所旋转到列,并在年份×星期层面比较收益与成交额
groupby_market_df.pivot_table(
    index=['year', 'weekday'], columns='exchange',
    values=['daily_return', 'turnover_billion']
)
表 8.46: 带有行和列分组的透视表
daily_return turnover_billion
exchange XSHE XSHG XSHE XSHG
year weekday
2022 Friday -0.001304 0.003362 1.755222 0.832512
Monday -0.003352 -0.005587 1.775057 0.845170
Thursday -0.005755 -0.000816 1.405026 0.786739
Tuesday 0.000638 -0.002156 1.550622 0.811507
Wednesday 0.003099 0.000784 1.640091 0.815092
2023 Friday -0.000938 -0.000452 1.398748 0.661113
Monday 0.003183 0.002559 1.599049 0.829031
Thursday -0.000460 0.000831 1.305122 0.646173
Tuesday 0.001618 -0.000113 1.364471 0.689482
Wednesday -0.001921 -0.001862 1.145625 0.605048

我们可以通过传递 margins=True 来扩充此表以包含部分总计。这会添加 All 行和列标签,其对应的值是单个层级内所有数据的分组统计信息:

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

# 加入边际汇总,用同一均值口径展示小计和总计
groupby_market_df.pivot_table(
    index=['year', 'weekday'], columns='exchange',
    values=['daily_return', 'turnover_billion'], margins=True
)
表 8.47: 带有用于小计和总计的边距的透视表
daily_return turnover_billion
exchange XSHE XSHG All XSHE XSHG All
year weekday
2022 Friday -0.001304 0.003362 0.002196 1.755222 0.832512 1.063190
Monday -0.003352 -0.005587 -0.005028 1.775057 0.845170 1.077642
Thursday -0.005755 -0.000816 -0.002051 1.405026 0.786739 0.941311
Tuesday 0.000638 -0.002156 -0.001458 1.550622 0.811507 0.996285
Wednesday 0.003099 0.000784 0.001362 1.640091 0.815092 1.021342
2023 Friday -0.000938 -0.000452 -0.000574 1.398748 0.661113 0.845522
Monday 0.003183 0.002559 0.002715 1.599049 0.829031 1.021536
Thursday -0.000460 0.000831 0.000508 1.305122 0.646173 0.810910
Tuesday 0.001618 -0.000113 0.000320 1.364471 0.689482 0.858229
Wednesday -0.001921 -0.001862 -0.001877 1.145625 0.605048 0.740192
All -0.000521 -0.000309 -0.000362 1.492439 0.751769 0.936937

要使用除 mean 之外的聚合函数,请将其传递给 aggfunc 关键字参数。例如,'count'len 会给您一个分组大小的交叉表:

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

# 用计数聚合审计每个年份—交易所—星期组合的样本量
groupby_market_df.pivot_table(
    index=['year', 'exchange'], columns='weekday',
    values='daily_return', aggfunc='count', margins=True
)
表 8.48: 使用 len 作为聚合函数的透视表
weekday Friday Monday Thursday Tuesday Wednesday All
year exchange
2022 XSHE 49 46 50 47 49 241
XSHG 147 138 150 141 147 723
2023 XSHE 48 48 49 49 48 242
XSHG 144 144 147 147 144 726
All 388 376 396 384 388 1932

如果某些组合为空(或为NA),您可能希望传递一个 fill_value

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

# 将未观察到的组合填为零,仅表示该组合没有收益率观测
groupby_market_df.pivot_table(
    index=['year', 'order_book_id', 'exchange'], columns='weekday',
    values='daily_return', fill_value=0
)
表 8.49: 使用 fill_value 处理缺失组合的透视表
weekday Friday Monday Thursday Tuesday Wednesday
year order_book_id exchange
2022 002415.XSHE XSHE -0.001304 -0.003352 -0.005755 0.000638 0.003099
600000.XSHG XSHG 0.002867 -0.005737 -0.001038 0.002062 -0.000377
600104.XSHG XSHG 0.002784 -0.006187 -0.001562 -0.001468 0.000073
600276.XSHG XSHG 0.004435 -0.004837 0.000153 -0.007063 0.002655
2023 002415.XSHE XSHE -0.000938 0.003183 -0.000460 0.001618 -0.001921
600000.XSHG XSHG -0.000361 -0.000213 0.001569 -0.000535 -0.001332
600104.XSHG XSHG -0.001366 0.002751 -0.000216 -0.000022 -0.001676
600276.XSHG XSHG 0.000370 0.005139 0.001139 0.000219 -0.002579

有关 pivot_table 选项的摘要,请参见 表 8.50

表 8.50: pivot_table 选项
参数 描述
values 要聚合的列名或列名列表;默认情况下,聚合所有数值列
index 用于在结果透视表的行上分组的列名或其他分组键
columns 用于在结果透视表的列上分组的列名或其他分组键
aggfunc 聚合函数或函数列表(默认为 ‘mean’);可以是 groupby 上下文中任何有效的函数
fill_value 替换结果表中的缺失值
dropna 如果为 True,则不包括条目全为 NA 的列
margins 添加行/列小计和总计(默认为 False
margins_name margins=True 时,用于边距行/列标签的名称;默认为 ‘All’
observed 对于分类分组键,如果为 True,则只显示键中观察到的类别值,而不是所有类别

8.6.1 交叉表: Crosstab

交叉表(crosstab)是透视表的一种特殊情况,用于计算分组频率。假设我们想按省份和饮食口味偏好来总结一些调查数据。pandas.crosstab 函数对于此任务可能比 pivot_table 更方便。

from io import StringIO                                     # 将内嵌CSV调查文本包装为pandas可读取的字符流

# 用固定CSV题设保留省份与口味的原始一行一答结构
data = """Sample,Province,Flavor
1,四川,重辣
2,广东,清淡
3,四川,微辣
4,湖南,重辣
5,广东,清淡
6,江苏,清淡
7,四川,重辣
8,北京,清淡
9,湖南,重辣
10,广东,清淡"""  # 调查数据CSV字符串

data = pd.read_csv(StringIO(data))                          # 从CSV文件读取数据

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

pd.crosstab(data['Province'], data['Flavor'], margins=True)  # 按省份与口味统计频数并添加边际总计
表 8.51: 省份和口味偏好的交叉表
Flavor 微辣 清淡 重辣 All
Province
北京 0 1 0 1
四川 1 0 2 3
广东 0 3 0 3
江苏 0 1 0 1
湖南 0 0 2 2
All 1 5 4 10

crosstab的前两个参数可以是数组、Series或数组列表。真实行情表也可以按年份、星期与交易所计数:

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

# 对真实行情按年份和星期计数,并按交易所展开列
pd.crosstab(
    [groupby_market_df['year'], groupby_market_df['weekday']],
    groupby_market_df['exchange'], margins=True
)
表 8.52: 真实行情观测按年份、星期和交易所的交叉表
exchange XSHE XSHG All
year weekday
2022 Friday 49 147 196
Monday 46 138 184
Thursday 50 150 200
Tuesday 47 141 188
Wednesday 49 147 196
2023 Friday 48 144 192
Monday 48 144 192
Thursday 49 147 196
Tuesday 49 147 196
Wednesday 48 144 192
All 483 1449 1932

8.7 习题

8.7.1 习题 8.1: 基础分组聚合

问题描述

使用宁波港和宁波银行的股票数据,进行基础分组聚合:

  1. 按股票代码分组计算基本统计量
  2. 计算各股票的平均收益率和波动率
  3. 找出每个股票的最高价和最低价及其日期
  4. 按周分组计算累计收益率

完整解答

import pandas as pd                                         # 组织两证券第一季度分组统计与周收益矩阵
import numpy as np                                          # 计算收益波动比及复合收益
from functools import reduce                                # 依次内连接多只证券的周收益Series

# 读取两只长三角股票的前复权日行情,练习使用固定的2023年第一季度样本
port_prices = pd.read_hdf(                              # 从本地前复权行情表读取宁波港记录
    PRE_ADJUSTED_PRICE_PATH,  # 使用统一前复权股票行情数据源
    where="order_book_id='601018.XSHG'"         # 在存储层只选择601018.XSHG
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日索引并统一成交量列名
bank_prices = pd.read_hdf(                              # 从同一行情表读取宁波银行记录
    PRE_ADJUSTED_PRICE_PATH,  # 保持与宁波港完全相同的前复权口径
    where="order_book_id='002142.XSHE'"         # 在存储层只选择002142.XSHE
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期索引供两只证券纵向拼接
port_prices['symbol'] = '宁波港'  # 为601018.XSHG写入可读分组键
bank_prices['symbol'] = '宁波银行'  # 为002142.XSHE写入可读分组键
stock_data = pd.concat([port_prices, bank_prices])          # 纵向合并两只股票并保留各自symbol标签
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 将YYYYMMDD交易日解析为可筛选时间戳
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]  # 固定极值和周收益练习的第一季度窗口

# 筛选宁波港和宁波银行的数据
filtered = stock_data.copy()                                # 复制季度样本后设置时间索引,保留上游长表
filtered = filtered.sort_values(['symbol', 'datetime'])     # 保证每只股票内部严格按交易日递增
filtered.set_index('datetime', inplace=True) # 设置索引以便后续分析

1. 基本统计量

print('=== 1. 基本统计量 ===')                                   # 标识收盘价与成交量的证券级摘要区段
basic_stats = filtered.groupby('symbol')[['close', 'volume']].agg([  # 每只股票分别汇总收盘价与成交量的四项描述统计
    ('mean', 'mean'),  # 计算均值
    ('std', 'std'),  # 计算标准差
    ('min', 'min'),  # 计算最小值
    ('max', 'max')  # 计算最大值
]).round(2)  # 收盘价与成交量摘要统一显示两位小数
print(basic_stats)  # 输出基本统计量表格
=== 1. 基本统计量 ===
        close                           volume                          \
         mean   std    min    max         mean         std         min   
symbol                                                                   
宁波港      3.33  0.04   3.23   3.41   9668266.51  4834033.37   3979300.0   
宁波银行    27.84  2.04  24.86  31.20  30043131.53  9824164.07  15884425.0   

                    
               max  
symbol              
宁波港     25577104.0  
宁波银行    65203908.0  

2. 收益率和波动率

print('\n=== 2. 收益率分析 ===')                                 # 标识日收益均值、波动率和年化比率区段
filtered['returns'] = filtered.groupby('symbol')['close'].pct_change(fill_method=None)  # 分证券计算且不跨价格缺口填补

returns_stats = filtered.groupby('symbol')['returns'].agg([  # 每只股票汇总日收益均值、波动率与年化收益波动比
    ('日均收益率', 'mean'),  # 计算日均收益率
    ('收益率标准差', 'std'),  # 计算收益率标准差
    ('收益波动比', lambda daily_returns: daily_returns.mean() / daily_returns.std() * np.sqrt(252) if daily_returns.std() > 0 else 0)  # 未扣无风险收益,仅年化日均收益与日波动之比
]).round(4)  # 日收益、波动率及年化比率显示四位小数
print(returns_stats)  # 输出收益率统计表格

=== 2. 收益率分析 ===
         日均收益率  收益率标准差   收益波动比
symbol                        
宁波港     0.0003  0.0063  0.6545
宁波银行   -0.0027  0.0165 -2.6092

这里没有给出与日收益同频的无风险利率,因此表中指标只能称为“收益波动比”,不能称为夏普比率。若要计算夏普比率,应先为每个交易日匹配可得的无风险收益,再对超额收益进行同样的年化。

3. 最高价和最低价

print('\n=== 3. 极值分析 ===')                                  # 标识每只股票最高与最低收盘价日期区段

def get_extreme_dates(group, col='close', extreme='max'):   # 自定义函数:获取极值及其日期
    """获取极值及其对应的日期"""  # 说明函数同时返回价格与发生时点
    if extreme == 'max':  # 若查找最大值
        idx = group[col].idxmax()                           # 获取最大值的索引
    else:                                                   # 若查找最小值
        idx = group[col].idxmin()                           # 获取最小值的索引
    return pd.Series({                                      # 返回极值信息
        f'{extreme}_value': group.loc[idx, col],            # 极值对应的价格
        f'{extreme}_date': idx                              # 极值对应的日期
    })

max_info = filtered.groupby('symbol').apply(lambda g: get_extreme_dates(g, 'close', 'max'), include_groups=False)  # 证券键留在索引,函数只读取行情列
min_info = filtered.groupby('symbol').apply(lambda g: get_extreme_dates(g, 'close', 'min'), include_groups=False)  # 每只证券返回最低收盘价及其交易日

print('最高价信息:')  # 输出最高价结果标题
print(max_info)  # 输出各股票最高价详情
print('\n最低价信息:')                                           # 输出最低价结果标题
print(min_info)  # 输出各股票最低价详情

=== 3. 极值分析 ===
最高价信息:
        max_value   max_date
symbol                      
宁波港        3.4137 2023-03-17
宁波银行      31.1967 2023-01-16

最低价信息:
        min_value   min_date
symbol                      
宁波港        3.2302 2023-01-12
宁波银行      24.8570 2023-03-17

4. 按周累计收益率

print('\n=== 4. 周累计收益率 ===')                                # 标识按证券和ISO周复合日收益的结果区段
filtered['week'] = filtered.index.isocalendar().week  # 从日期索引中提取ISO周数

# 计算每周累计收益率
weekly_returns = filtered.groupby(['symbol', 'week'])['returns'].apply(  # 在每个证券—ISO周组内复合有效日收益
    lambda x: (1 + x).prod() - 1  # 将日收益率复合为周累计收益率
).reset_index()                                             # 将证券与周键展开为“symbol、week、returns”三列
weekly_returns.columns = ['股票', '周', '累计收益率']  # 重命名列为中文标题

print('每周累计收益率(前10行):')  # 输出周累计收益率标题
print(weekly_returns.head(10))  # 输出前10行周累计收益率

# 按股票汇总周表现
weekly_summary = weekly_returns.pivot(index='周', columns='股票', values='累计收益率')  # 数据透视变形
print('\n周收益率矩阵:')                                          # 输出周收益率矩阵标题
print(weekly_summary.round(4).head())  # 输出保留4位小数的周收益率矩阵

=== 4. 周累计收益率 ===
每周累计收益率(前10行):
    股票   周         累计收益率
0  宁波港   1 -2.808217e-03
1  宁波港   2 -2.816125e-03
2  宁波港   3  2.535531e-02
3  宁波港   5  2.754244e-03
4  宁波港   6 -1.110223e-16
5  宁波港   7 -1.095686e-02
6  宁波港   8  8.301135e-03
7  宁波港   9  1.921983e-02
8  宁波港  10 -2.693494e-02
9  宁波港  11  3.045762e-02

周收益率矩阵:
股票     宁波港    宁波银行
周                 
1  -0.0028  0.0130
2  -0.0028  0.0411
3   0.0254 -0.0224
5   0.0028 -0.0416
6  -0.0000 -0.0132

关键要点: - groupby + agg 是最常用的聚合模式 - 聚合函数可以是字符串、函数或列表 - apply 允许自定义聚合逻辑 - 命名聚合使结果更清晰 - 分组前记得排序


8.7.2 习题 8.2: 多级分组与自定义聚合

问题描述

对股票数据按多个维度进行分组分析:

  1. 按股票和月份分组
  2. 计算各月交易天数和平均成交量
  3. 识别各股票的”活跃月份”(成交量最大的月份)
  4. 创建自定义聚合函数计算价格振幅

完整解答

import pandas as pd                                         # 组织三证券全年月份分组表
import numpy as np                                          # 支持振幅百分比的有限数值运算

# 读取宁波港、宁波银行和恒瑞医药的前复权行情,比较2023年月份分组
port_prices = pd.read_hdf(                              # 港口日频OHLCV用于月度价格与振幅统计
    PRE_ADJUSTED_PRICE_PATH,  # 指向统一前复权行情文件
    where="order_book_id='601018.XSHG'"         # 港口样本提供月初末价格、振幅和成交量统计
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开date并把vol统一为volume
bank_prices = pd.read_hdf(                              # 取得宁波银行日频OHLCV记录
    PRE_ADJUSTED_PRICE_PATH,  # 复用相同前复权价格与成交量口径
    where="order_book_id='002142.XSHE'"         # 银行样本提供深市金融行业的月度对照行
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期供月份字段计算
hengrui_prices = pd.read_hdf(                            # 取得恒瑞医药日频OHLCV记录
    PRE_ADJUSTED_PRICE_PATH,  # 与两只宁波股票共享同一前复权数据快照
    where="order_book_id='600276.XSHG'"         # 医药样本补充沪市跨行业月度比较
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 医药表输出trade_date与volume,字段与另外两表兼容
port_prices['symbol'] = '宁波港'  # 将港口样本映射为月度分组键宁波港
bank_prices['symbol'] = '宁波银行'  # 将银行样本映射为月度分组键宁波银行
hengrui_prices['symbol'] = '恒瑞医药'  # 将医药样本映射为月度分组键恒瑞医药
stock_data = pd.concat([port_prices, bank_prices, hengrui_prices])  # 拼接三只股票数据
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 按YYYYMMDD解析三只股票的交易日
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 固定月份交易统计的2023年全年样本

# 三只股票已在读取阶段确定,此处复制全年样本用于派生月份键
filtered = stock_data.copy()                                # 复制全年长表后派生month列
filtered['month'] = filtered['datetime'].dt.month           # 从交易日提取1—12月作为第二分组键

1. 按股票和月份分组

print('=== 1. 按股票和月份的基本统计 ===')                             # 标识证券—月份价格和成交量摘要区段
monthly_stats = filtered.groupby(['symbol', 'month']).agg({  # 以股票—月份为键汇总月初末均价及成交量
    'close': ['first', 'last', 'mean'],  # 收盘价的开盘、收盘、均价
    'volume': ['sum', 'mean']  # 成交量的总和和均值
}).round(2)  # 价格保留两位并使成交量摘要便于阅读
print(monthly_stats.head())  # 输出月度统计前5行
=== 1. 按股票和月份的基本统计 ===
             close                    volume             
             first  last  mean           sum         mean
symbol month                                             
宁波港    1      3.28  3.37  3.28  1.327776e+08   8298598.69
       2      3.38  3.34  3.35  1.508354e+08   7541771.50
       3      3.34  3.32  3.36  2.868147e+08  12470205.00
       4      3.35  3.48  3.43  3.399837e+08  17893879.26
       5      3.49  3.24  3.36  1.010306e+09  50515303.55

2. 交易天数和平均成交量

print('\n=== 2. 交易统计 ===')                                  # 标识各证券月份的交易日数与成交量区段
trading_stats = filtered.groupby(['symbol', 'month']).agg({  # 每个股票—月份组合统计交易日数和成交量规模
    'datetime': 'count',  # 交易天数
    'volume': ['mean', 'sum']  # 日均成交量和月总成交量
}).round(0)  # 保留整数
trading_stats.columns = ['交易天数', '日均成交量', '月总成交量']  # 重命名列为中文标题
print(trading_stats.head(10))  # 输出交易统计前10行

=== 2. 交易统计 ===
              交易天数       日均成交量         月总成交量
symbol month                                
宁波港    1        16   8298599.0  1.327776e+08
       2        20   7541772.0  1.508354e+08
       3        23  12470205.0  2.868147e+08
       4        19  17893879.0  3.399837e+08
       5        20  50515304.0  1.010306e+09
       6        20  12558214.0  2.511643e+08
       7        21  11256187.0  2.363799e+08
       8        23  14739590.0  3.390106e+08
       9        20  15133539.0  3.026708e+08
       10       17  12737156.0  2.165317e+08

3. 活跃月份识别

print('\n=== 3. 活跃月份识别 ===')                                # 标识按月总成交量选择组内最大值的区段

most_active_month = filtered.groupby(['symbol', 'month'])['volume'].sum().reset_index()  # 计算各股票每月总成交量
most_active_month = most_active_month.loc[                  # 筛选成交量最大的月份
    most_active_month.groupby('symbol')['volume'].idxmax()  # 找到每只股票成交量最大处的索引
]
print('各股票成交量最大的月份:')  # 输出活跃月份结果标题
print(most_active_month)  # 输出各股票最活跃月份详情

=== 3. 活跃月份识别 ===
各股票成交量最大的月份:
   symbol  month        volume
4     宁波港      5  1.010306e+09
15   宁波银行      4  8.424915e+08
31   恒瑞医药      8  1.386410e+09

4. 自定义聚合:价格振幅

print('\n=== 4. 价格振幅分析 ===')                                # 标识月内高低价差相对均价的百分比区段

def price_amplitude(group):                                 # 自定义函数:计算价格振幅
    """计算价格振幅(最高价-最低价)/ 均价"""  # 明确分母为组内平均收盘价
    high = group['high'].max()                              # 获取组内最高价
    low = group['low'].min()                                # 获取组内最低价
    avg_price = group['close'].mean()                       # 分母为当前证券—月份组的日收盘价算术均值,单位为元
    amplitude = (high - low) / avg_price * 100 if avg_price > 0 else 0  # 月内最高价减最低价后除以均价,输出百分数
    return pd.Series({                                      # 返回振幅统计结果
        '振幅(%)': amplitude,  # 振幅百分比
        '最高价': high,  # 组内最高价
        '最低价': low,  # 组内最低价
        '均价': avg_price  # 组内均价
    })

amplitude_by_month = filtered.groupby(['symbol', 'month']).apply(price_amplitude, include_groups=False).reset_index()  # 组键由索引恢复,函数只接收行情字段
amplitude_by_month = amplitude_by_month.sort_values(['symbol', 'month'])  # 按证券及自然月排列便于逐期核验

print('月度价格振幅:')  # 输出月度价格振幅标题
print(amplitude_by_month.head(10))  # 输出前10行振幅数据

# 找出振幅最大的月份
max_amplitude = amplitude_by_month.loc[                     # 筛选振幅最大的月份
    amplitude_by_month.groupby('symbol')['振幅(%)'].idxmax()  # 找到每只股票振幅最大处的索引
]
print('\n振幅最大的月份:')                                         # 输出振幅最大月份标题
print(max_amplitude)  # 输出振幅最大月份的详细数据

=== 4. 价格振幅分析 ===
月度价格振幅:
  symbol  month      振幅(%)     最高价     最低价        均价
0    宁波港      1   4.749337  3.3862  3.2302  3.284669
1    宁波港      2   2.192295  3.3862  3.3128  3.348090
2    宁波港      3   4.646940  3.4412  3.2852  3.357048
3    宁波港      4   6.418870  3.5422  3.3219  3.432068
4    宁波港      5  14.462245  3.7074  3.2210  3.363240
5    宁波港      6   5.538913  3.3024  3.1236  3.228070
6    宁波港      7   5.793231  3.3683  3.1801  3.248619
7    宁波港      8   6.938901  3.3777  3.1519  3.254117
8    宁波港      9   4.360241  3.3118  3.1707  3.236060
9    宁波港     10   7.706393  3.2930  3.0484  3.173988

振幅最大的月份:
   symbol  month      振幅(%)      最高价      最低价         均价
4     宁波港      5  14.462245   3.7074   3.2210   3.363240
18   宁波银行      7  22.051447  27.5764  22.3721  23.600719
31   恒瑞医药      8  17.714319  43.9337  36.7849  40.356052

关键要点: - 多级分组使用列表指定分组键 - 自定义聚合函数通过 apply 实现 - idxmax() / idxmin() 找出极值位置 - 命名聚合清晰描述结果 - 振幅衡量价格波动程度


8.7.3 习题 8.3: 组变换与过滤

问题描述

使用组变换和组过滤操作:

  1. 计算滚动统计量(移动平均、标准差)
  2. 计算每组内的排名和百分位
  3. 过滤出满足特定条件的组
  4. 使用 transform 填充缺失值

完整解答

import pandas as pd                                         # 组织两证券滚动、排名和组过滤结果
import numpy as np                                          # 人为设置缺失并核验填补结果

# 两只证券均限定2023年第一季度,窗口和填补始终以symbol隔离
port_prices = pd.read_hdf(                              # 读取宁波港前复权收盘价和成交量
    PRE_ADJUSTED_PRICE_PATH,  # 使用本地统一前复权日行情快照
    where="order_book_id='601018.XSHG'"         # 限定宁波港证券代码
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 港口表恢复trade_date列,volume保留原始成交量单位
bank_prices = pd.read_hdf(                              # 读取宁波银行的同口径日行情
    PRE_ADJUSTED_PRICE_PATH,  # 与宁波港共享字段及前复权方式
    where="order_book_id='002142.XSHE'"         # 银行记录提供第二个独立滚动分组,禁止与港口价格串接
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 银行交易日恢复为列,随后在symbol内排序形成窗口顺序
port_prices['symbol'] = '宁波港'  # 为港口行情添加滚动窗口分组键
bank_prices['symbol'] = '宁波银行'  # 为银行行情添加滚动窗口分组键
stock_data = pd.concat([port_prices, bank_prices])          # 纵向长表仍保留symbol,确保rolling、rank与填补不跨证券
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 解析交易日后限定2023年第一季度
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]  # 固定滚动统计演示的第一季度窗口

# 复制并排序,防止滚动窗口跨证券或倒序计算
filtered = stock_data.copy()                                # 复制季度样本后按证券—日期排序
filtered = filtered.sort_values(['symbol', 'datetime'])     # 每个symbol内按日期升序建立窗口顺序

1. 滚动统计量

print('=== 1. 滚动统计量 ===')                                   # 随后两列分别从第5与第10个组内交易观测起产生有效值
filtered['MA5'] = filtered.groupby('symbol')['close'].transform(  # 将各证券5日收盘均值对齐回原始交易日
    lambda x: x.rolling(5).mean()                           # 在各股票内部用连续5个交易日收盘价计算移动均值
)  # 计算5日移动平均线
filtered['rolling_std'] = filtered.groupby('symbol')['close'].transform(  # 将各证券10日样本标准差对齐回原始交易日
    lambda x: x.rolling(10).std()                           # 在各股票内部估计10个交易日收盘价样本标准差
)  # 计算10日滚动标准差

print('带滚动统计的数据示例:')  # 输出含移动平均和波动率的数据
print(filtered[['datetime', 'symbol', 'close', 'MA5', 'rolling_std']].head(10))  # 展示前10行滚动统计结果
=== 1. 滚动统计量 ===
带滚动统计的数据示例:
       datetime symbol   close      MA5  rolling_std
2981 2023-01-03    宁波港  3.2761      NaN          NaN
2982 2023-01-04    宁波港  3.2944      NaN          NaN
2983 2023-01-05    宁波港  3.2852      NaN          NaN
2984 2023-01-06    宁波港  3.2669      NaN          NaN
2985 2023-01-09    宁波港  3.2669  3.27790          NaN
2986 2023-01-10    宁波港  3.2485  3.27238          NaN
2987 2023-01-11    宁波港  3.2394  3.26138          NaN
2988 2023-01-12    宁波港  3.2302  3.25038          NaN
2989 2023-01-13    宁波港  3.2577  3.24854          NaN
2990 2023-01-16    宁波港  3.2761  3.25038     0.020305

2. 组内排名

print('\n=== 2. 组内排名 ===')                                  # 标识证券内收盘价序位与百分位区段
filtered['daily_rank'] = filtered.groupby('symbol')['close'].rank()  # 在每只股票自身样本期内按收盘价从低到高编号
filtered['daily_pct_rank'] = filtered.groupby('symbol')['close'].rank(pct=True)  # 将组内收盘价名次缩放为0—1百分位

print('各股票收盘价排名(前10天):')  # 输出排名结果标题
print(filtered[['datetime', 'symbol', 'close', 'daily_rank', 'daily_pct_rank']].head(10))  # 展示前10行排名与百分位数据

=== 2. 组内排名 ===
各股票收盘价排名(前10天):
       datetime symbol   close  daily_rank  daily_pct_rank
2981 2023-01-03    宁波港  3.2761         8.0        0.135593
2982 2023-01-04    宁波港  3.2944        12.0        0.203390
2983 2023-01-05    宁波港  3.2852        10.5        0.177966
2984 2023-01-06    宁波港  3.2669         5.5        0.093220
2985 2023-01-09    宁波港  3.2669         5.5        0.093220
2986 2023-01-10    宁波港  3.2485         3.0        0.050847
2987 2023-01-11    宁波港  3.2394         2.0        0.033898
2988 2023-01-12    宁波港  3.2302         1.0        0.016949
2989 2023-01-13    宁波港  3.2577         4.0        0.067797
2990 2023-01-16    宁波港  3.2761         8.0        0.135593

3. 组过滤

print('\n=== 3. 组过滤 ===')                                   # 标识以证券平均收盘价阈值整组筛选的区段

# 定义过滤函数:只保留平均价格大于10元的组
def filter_by_avg_price(group):                             # 自定义函数:按平均收盘价过滤分组
    return group['close'].mean() > 10                       # 仅保留组内均价大于10元的股票

# 使用 filter
filtered_groups = filtered.groupby('symbol').filter(filter_by_avg_price)  # 整组保留样本均价高于10元的股票,其他证券全部剔除

print(f'过滤前形状: {filtered.shape}')  # 输出过滤前的数据维度
print(f'过滤后形状: {filtered_groups.shape}')  # 输出过滤后的数据维度
print(f'保留的股票: {filtered_groups["symbol"].unique().tolist()}')  # 获取唯一值

=== 3. 组过滤 ===
过滤前形状: (118, 14)
过滤后形状: (59, 14)
保留的股票: ['宁波银行']

4. Transform 填充缺失值

print('\n=== 4. Transform 填充缺失值 ===')                       # 标识用证券内均价填补人为缺失的区段

# 创建一些缺失值演示
data_with_na = filtered.copy()                              # 复制数据用于缺失值演示
data_with_na.loc[data_with_na.index[10:15], 'close'] = np.nan  # 按标签选取数据

# 使用组均值填充
data_with_na['close_filled'] = data_with_na.groupby('symbol')['close'].transform(  # 每只证券独立填补并维持原交易日行数
    lambda x: x.fillna(x.mean())                            # 填充缺失值
)  # 用各组均值填充缺失的收盘价

print('缺失值填充结果:')  # 输出填充前后对比
print(data_with_na[['symbol', 'close', 'close_filled']].iloc[8:18])  # 展示第8~17行的填充效果

# 比较填充前后的统计
print('\n填充效果验证:')                                          # 输出验证标题
print(f'填充前缺失: {data_with_na["close"].isna().sum()}')       # 清点人为置空的close观测数
print(f'填充后缺失: {data_with_na["close_filled"].isna().sum()}')  # 核验证券内均值填补后close_filled无缺失

=== 4. Transform 填充缺失值 ===
缺失值填充结果:
     symbol   close  close_filled
2989    宁波港  3.2577      3.257700
2990    宁波港  3.2761      3.276100
2991    宁波港     NaN      3.336724
2992    宁波港     NaN      3.336724
2993    宁波港     NaN      3.336724
2994    宁波港     NaN      3.336724
2995    宁波港     NaN      3.336724
2996    宁波港  3.3678      3.367800
2997    宁波港  3.3770      3.377000
2998    宁波港  3.3678      3.367800

填充效果验证:
填充前缺失: 5
填充后缺失: 0

关键要点: - transform 保持原始数据形状 - filter 根据组条件筛选整个组 - rank() 计算组内排名 - pct=True 计算百分位排名 - 组内填充避免使用其他组的数据


8.7.4 习题 8.4: 分组应用分析

问题描述

使用 apply 进行复杂的组操作:

  1. 计算每只股票的最大回撤
  2. 找出连续上涨/下跌的最长天数
  3. 计算收益率的偏度和峰度
  4. 识别价格突变点

实证风险评估:最大回撤与高阶矩的统计意义

在评估量化策略或单只绩优股(如“宁波银行”)的风险时,单纯的均值-方差分析往往是不够的。

  1. 最大回撤 (MDD) 的逻辑:对正价格序列 \(P_t\),先定义 \(DD_t=P_t/\max_{s\le t}P_s-1\),再取 \(\operatorname{MDD}=\min_t DD_t\)。因此 \(-1<\operatorname{MDD}\le 0\),而与最深谷值配对的峰值必须发生在该谷值之前或当日;不能拿全样本最高价与更早的最低价配对。
  2. 偏度 (Skewness) 与样本非对称性:正样本偏度表示较大的正向离均差对样本三阶中心矩贡献更多,负样本偏度则表示较大的负向离均差贡献更多。偏度是样本内分布形状的描述,单凭正负号不能识别尾部概率、未来极端风险或交易机会;这些判断还需要分位数、尾部风险指标、显式模型和样本外证据。
  3. Fisher 超额峰度的边界:本节使用 \(\kappa=E[(R-\mu)^4]/\sigma^4-3\),总体量满足 \(\kappa\ge-2\)。正值只说明标准化四阶矩高于正态分布的 3;单凭它不能分别证明“更尖”、两侧都更厚,也不能推出未来极端事件概率。峰度应与直方图、分位数和尾部指标联合解释。

完整解答

import pandas as pd                                         # 组织两证券全年风险与突变结果
import numpy as np                                          # 计算收益、回撤和连续段长度
from scipy import stats                                     # 对两只股票收益执行偏度、峰度及分布统计

# 为回撤和收益分布练习读取两只宁波股票的2023年完整行情
port_prices = pd.read_hdf(                              # 读取宁波港日频前复权价格序列
    PRE_ADJUSTED_PRICE_PATH,  # 使用固定本地前复权行情快照
    where="order_book_id='601018.XSHG'"         # 只提取601018.XSHG记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 将date展开为列以限制样本年份
bank_prices = pd.read_hdf(                              # 读取宁波银行同口径价格序列
    PRE_ADJUSTED_PRICE_PATH,  # 与宁波港使用同一前复权数据源
    where="order_book_id='002142.XSHE'"         # 只提取002142.XSHE记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 规范date与volume字段供后续函数共用
port_prices['symbol'] = '宁波港'  # 港口价格路径独立计算峰值、回撤和连续涨跌段
bank_prices['symbol'] = '宁波银行'  # 标记银行价格路径供回撤分组
stock_data = pd.concat([port_prices, bank_prices])          # 合并证券记录并保留symbol作为分组键
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 解析YYYYMMDD以筛选2023年
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 固定最大回撤与突变检测的全年窗口

# 建立证券内有序时间索引,供峰值路径和突变日期返回使用
filtered = stock_data.copy()                                # 复制全年样本后建立有序价格路径
filtered = filtered.sort_values(['symbol', 'datetime'])     # 先按证券、再按交易日排序价格路径
filtered.set_index('datetime', inplace=True)                # 将交易日设为索引以保留极值与突变发生日期

1. 最大回撤计算

print('=== 1. 最大回撤分析 ===')                                  # 标识证券价格相对历史峰值的最深跌幅区段

def summarize_max_drawdown(prices):                         # 返回满足先峰后谷约束的回撤摘要
    valid_prices = prices.dropna()                           # 仅使用实际观测到的价格
    assert valid_prices.gt(0).all()                          # 正价格保证回撤严格大于负百分之百
    running_peak = valid_prices.cummax()                     # 每个时点只允许引用当时及以前的峰值
    drawdown = valid_prices.div(running_peak).sub(1)         # 按定义计算相对历史峰值的回撤
    trough_date = drawdown.idxmin()                          # 找到最深回撤发生日
    peak_date = valid_prices.loc[:trough_date].idxmax()      # 只在谷值以前寻找配对峰值
    maximum_drawdown = drawdown.loc[trough_date]             # 提取最深回撤率
    assert peak_date <= trough_date                          # 阻止把未来峰值配给更早谷值
    assert -1 < maximum_drawdown <= 0                        # 核验正价格下的理论取值边界
    return pd.Series({'peak_date': peak_date, 'trough_date': trough_date, 'maximum_drawdown': maximum_drawdown})  # 输出可审计路径

max_dd = filtered.groupby('symbol')['close'].apply(summarize_max_drawdown)  # 每只证券独立计算峰谷日期与回撤
print('最大回撤摘要:')  # 输出各股票的峰值、谷值和回撤率
print(max_dd)                                             # 保留日期证据而不只展示格式化百分比
=== 1. 最大回撤分析 ===
最大回撤摘要:
symbol                  
宁波港     peak_date           2023-05-05 00:00:00
        trough_date         2023-10-23 00:00:00
        maximum_drawdown              -0.138987
宁波银行    peak_date           2023-01-16 00:00:00
        trough_date         2023-12-26 00:00:00
        maximum_drawdown              -0.419852
Name: close, dtype: object

2. 连续上涨/下跌天数

print('\n=== 2. 连续涨跌天数 ===')                                # 标识证券内收益符号连续段长度区段

def longest_true_run(condition):                            # 计算布尔序列中最长连续真值段
    normalized = condition.fillna(False).astype(bool)       # 把首日缺失收益明确视为非上涨且非下跌
    segment_id = normalized.ne(normalized.shift(fill_value=False)).cumsum()  # 每次状态变化都累计生成新段编号
    segment_lengths = normalized.groupby(segment_id).sum()  # 对每个互不相邻的连续段分别计数
    return int(segment_lengths.max()) if not segment_lengths.empty else 0  # 返回最长真值段长度

probe_returns = pd.Series([0.01, 0.02, -0.01, 0.03, 0.04, 0.05, 0.0, -0.02, -0.03, -0.04, -0.05, 0.01])  # 构造含多个分离涨跌段的确定性测试
assert longest_true_run(probe_returns.gt(0)) == 3           # 核验两个上涨段不会被错误合并
assert longest_true_run(probe_returns.lt(0)) == 4           # 核验最长下跌段由连续四日组成

returns_df = filtered.reset_index()[['datetime', 'symbol', 'close']].sort_values(['symbol', 'datetime'])  # 恢复列并明确证券内时间顺序
returns_df['daily_return'] = returns_df.groupby('symbol')['close'].pct_change(fill_method=None)  # 仅连接同一证券相邻交易日
run_length_summary = returns_df.groupby('symbol')['daily_return'].agg(  # 按证券汇总正负收益的最长连续段
    longest_up=lambda values: longest_true_run(values.gt(0)),  # 对正收益布尔序列使用状态段编号
    longest_down=lambda values: longest_true_run(values.lt(0))  # 对负收益布尔序列使用相同算法
)  # 完成长表到每只证券一行的汇总
print(run_length_summary)                                   # 输出可直接比较的最长涨跌段表

=== 2. 连续涨跌天数 ===
        longest_up  longest_down
symbol                          
宁波港              7             4
宁波银行             6            13

3. 偏度和峰度

print('\n=== 3. 收益率分布形状 ===')                               # 标识有效日收益偏度与Fisher峰度区段

returns_stats = filtered.groupby('symbol')['close'].agg([   # 每只股票分别估计日收益分布的偏度与超额峰度
    ('偏度', lambda x: stats.skew(x.pct_change(fill_method=None).dropna(), bias=True)),  # 明确采用有偏标准化三阶矩估计
    ('Fisher超额峰度', lambda x: stats.kurtosis(x.pct_change(fill_method=None).dropna(), fisher=True, bias=True))  # 明确减去正态基准3
])  # 计算各股票收益率的偏度和Fisher超额峰度
assert np.isfinite(returns_stats.to_numpy()).all()           # 阻止常数或空收益样本产生不可解释统计量
assert returns_stats['Fisher超额峰度'].ge(-2 - 1e-12).all()  # 核验标准化四阶矩对应的理论下界
print(returns_stats)  # 输出偏度与峰度结果

=== 3. 收益率分布形状 ===
              偏度  Fisher超额峰度
symbol                      
宁波港    -0.047406    0.295470
宁波银行    0.925386    3.231664

4. 价格突变点

print('\n=== 4. 价格突变检测 ===')                                # 标识绝对日收益超过3%的日期扫描区段

def detect_price_jumps(group, threshold=0.05):              # 自定义函数:检测价格突变
    """检测价格突变(收益率超过阈值)"""  # 识别单日收益率超过指定阈值的异常交易日
    returns = group['close'].pct_change(fill_method=None)  # 禁止隐式填补组内价格缺口后计算变化率
    jumps = returns[returns.abs() > threshold]              # 筛选绝对值超过阈值的收益率
    return len(jumps), jumps.index.tolist()                 # 返回突变次数和对应日期

jump_analysis = filtered.groupby('symbol').apply(           # 每只证券分别返回突变次数与对应交易日列表
    lambda g: detect_price_jumps(g, threshold=0.03), include_groups=False  # 以3%为阈值检测价格突变,不把symbol列传入函数
)  # 对每只股票应用突变检测函数

print('价格突变次数:')  # 输出各股票突变统计
for symbol, (count, dates) in jump_analysis.items():        # 获取字典所有键值对
    print(f'{symbol}: {count}次')  # 输出突变次数
    if count > 0:  # 如果存在突变事件
        print(f'  突变日期: {[str(d) for d in dates[:3]]}...')  # 输出前3个突变日期

=== 4. 价格突变检测 ===
价格突变次数:
宁波港: 0次
宁波银行: 24次
  突变日期: ['2023-01-04 00:00:00', '2023-01-11 00:00:00', '2023-02-20 00:00:00']...

关键要点

  • apply 适合复杂的组操作
  • 最大回撤是重要的风险指标
  • 连续涨跌长度只描述样本路径中的连续段;判断趋势能否持续需要样本外证据
  • 偏度和峰度描述样本分布形状,不能单独推出尾部概率或未来风险
  • 突变检测识别异常波动

8.7.5 习题 8.5: 透视表与交叉表进阶

问题描述

使用宁波港、宁波银行和恒瑞医药的数据创建高级透视表:

  1. 创建多维透视表
  2. 使用多个聚合函数
  3. 添加总计和小计
  4. 创建交叉表分析涨跌关系

完整解答

import pandas as pd                                         # 生成证券行、月份列及多层统计量列的第一季度矩阵
import numpy as np                                          # 为收益方向分箱提供无穷边界

# 为透视表练习读取三只股票的2023年第一季度前复权行情
port_prices = pd.read_hdf(                              # 读取宁波港的价格与成交量列
    PRE_ADJUSTED_PRICE_PATH,  # 使用统一前复权股票行情HDF5
    where="order_book_id='601018.XSHG'"         # 港口OHLCV构成透视表的运输行业记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期并统一透视表所需字段名
bank_prices = pd.read_hdf(                              # 读取宁波银行同字段行情
    PRE_ADJUSTED_PRICE_PATH,  # 保持与宁波港相同的前复权数据版本
    where="order_book_id='002142.XSHE'"         # 银行OHLCV提供深市金融行业记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期供月份和周数派生
hengrui_prices = pd.read_hdf(                            # 读取恒瑞医药同字段行情
    PRE_ADJUSTED_PRICE_PATH,  # 三只证券共享前复权口径
    where="order_book_id='600276.XSHG'"         # 医药OHLCV形成第三个行业的比较记录
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 形成与前两表一致的schema

port_prices['symbol'] = '宁波港'  # 港口记录归入透视表symbol行键的运输行业组
bank_prices['symbol'] = '宁波银行'  # 银行记录归入同一symbol层级的金融行业组
hengrui_prices['symbol'] = '恒瑞医药'  # 医药记录补足透视表第三个跨行业行组

stock_data = pd.concat([port_prices, bank_prices, hengrui_prices])  # 合并三只证券供多维透视
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 将交易日解析为月份和ISO周可用类型
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-03-31')]  # 限定透视表的2023年第一季度样本
# 用中文证券名核验目标集合,并派生透视表维度
symbols_map = {                                             # 列出本练习允许进入透视表的三只中文证券名
    '601018.SH': '宁波港',  # 沪市航运代码校验运输行业行的来源
    '002142.SZ': '宁波银行',  # 深市银行代码校验金融行业行的来源
    '600276.SH': '恒瑞医药'  # 沪市医药代码校验第三行业行的来源
}  # 完成交易所代码到中文证券名的核验映射
# symbol列在拼接时已写入证券中文名,因此这里直接按中文名筛选
filtered = stock_data[stock_data['symbol'].isin(symbols_map.values())].copy()  # 只保留三只证券后派生收益、月份和周数
# 不再重复映射symbol,避免已转换的中文名因找不到代码键而变成缺失值
filtered['returns'] = filtered.groupby('symbol')['close'].pct_change(fill_method=None)  # 仅连接同一证券的真实相邻收盘价计算收益
filtered['month'] = pd.to_datetime(filtered['datetime']).dt.month  # 提取自然月作为透视表列维度
filtered['week'] = pd.to_datetime(filtered['datetime']).dt.isocalendar().week  # 提取ISO周供后续周度分组复用

# 添加收益方向;首个不可计算收益保持缺失,零收益单列为平盘
return_direction = pd.Series(pd.NA, index=filtered.index, dtype='string')  # 为每只证券首个缺失收益保留缺失方向
return_direction.loc[filtered['returns'] < 0] = '下跌'      # 负收益归入下跌
return_direction.loc[filtered['returns'] == 0] = '平盘'     # 零收益单列,避免混入下跌
return_direction.loc[filtered['returns'] > 0] = '上涨'      # 正收益归入上涨
filtered['direction'] = return_direction                    # 写回可审计的三类收益方向

1. 多维透视表

print('=== 1. 多维透视表:股票×月份 ===')                             # 标识证券行、月份列的收盘价多统计量矩阵
pivot_month = pd.pivot_table(                               # 按证券和月份旋转收盘价的均值、离散度与范围
    filtered,  # 使用已限定三只证券和第一季度的长表
    values='close',                                         # 单元格保存证券—月份组的均价、标准差和价格范围
    index='symbol',                                         # 三个证券中文名构成行键,月份与统计量构成两层列键
    columns='month',                                        # 列索引为月份
    aggfunc=['mean', 'std', 'min', 'max']                   # 同时计算均值、标准差、最小值、最大值
)  # 形成证券×月份的四类收盘价统计列
print(pivot_month.round(2))  # 输出保留2位小数的透视表
=== 1. 多维透视表:股票×月份 ===
         mean                 std                min                  max  \
month       1      2      3     1     2     3      1      2      3      1   
symbol                                                                      
宁波港      3.28   3.35   3.36  0.04  0.02  0.03   3.23   3.31   3.31   3.37   
宁波银行    30.37  28.23  25.75  0.52  0.76  0.98  29.38  26.95  24.86  31.20   
恒瑞医药    40.14  43.15  41.76  2.06  0.91  0.93  37.34  41.60  39.92  43.12   

                      
month       2      3  
symbol                
宁波港      3.38   3.41  
宁波银行    29.67  28.08  
恒瑞医药    44.68  43.25  

2. 多聚合函数透视表

print('\n=== 2. 多聚合函数透视表 ===')                              # 标识不同数值列采用不同聚合规则的矩阵
multi_agg_pivot = pd.pivot_table(                           # 按证券汇总价格、成交量与有效收益数
    filtered,  # 使用包含close、volume、returns的证券长表
    values=['close', 'volume', 'returns'],                  # 对收盘价、成交量、收益率三列聚合
    index='symbol',                                         # 按证券组织不同字段的聚合结果
    aggfunc={                                               # 为不同列指定不同聚合函数
        'close': ['mean', 'std'],  # 收盘价计算均值和标准差
        'volume': 'sum',  # 成交量计算总和
        'returns': ['mean', 'count']  # 收益率计算均值和计数
    }  # 聚合函数字典定义结束
)  # 形成价格、成交量和收益率的多层列schema
print(multi_agg_pivot.round(2))  # 输出保留2位小数的多聚合透视表

=== 2. 多聚合函数透视表 ===
        close       returns             volume
         mean   std   count mean           sum
symbol                                        
宁波港      3.33  0.04      58  0.0  5.704277e+08
宁波银行    27.84  2.04      58 -0.0  1.772545e+09
恒瑞医药    41.79  1.75      58  0.0  2.259267e+09

3. 带总计的透视表

print('\n=== 3. 带总计的透视表 ===')                               # 标识包含证券和月份边际总和的成交量矩阵
pivot_with_totals = pd.pivot_table(                         # 对证券×月份成交量求和并添加总计
    filtered,  # 使用第一季度三证券成交量记录
    values='volume',                                        # 单元格为证券—月份组内日成交量总和
    index='symbol',                                         # 三个证券行与一至三月列共同形成成交量矩阵
    columns='month',                                        # 列索引为月份
    aggfunc='sum',                                          # 使用求和聚合
    margins=True,  # 添加行列边距(小计与总计)
    margins_name='总计'                                       # 边距名称设为“总计”
)  # 形成带“总计”边际行列的成交量表
print(pivot_with_totals)  # 输出含总计的月度成交量透视表

=== 3. 带总计的透视表 ===
month              1             2             3            总计
symbol                                                        
宁波港     1.327776e+08  1.508354e+08  2.868147e+08  5.704277e+08
宁波银行    4.474780e+08  5.886367e+08  7.364300e+08  1.772545e+09
恒瑞医药    8.859242e+08  7.611805e+08  6.121624e+08  2.259267e+09
总计      1.466180e+09  1.500653e+09  1.635407e+09  4.602240e+09

4. 交叉表:涨跌关系

print('\n=== 4. 交叉表:收益方向天数统计 ===')                        # 标识证券—月份与下跌/平盘/上涨类别的频数矩阵

# 按股票和月份统计有效收益方向天数
crosstab_direction = pd.crosstab(                           # 统计证券—月份组合中的下跌、平盘与上涨交易日数
    [filtered['symbol'], filtered['month']],  # 行索引为股票+月份组合
    filtered['direction'],  # 列索引为涨跌方向
    margins=True,                                           # 添加行列边距
    margins_name='总计'                                     # 使用中文总计标签供频数守恒断言定位
)  # 交叉表创建完成
valid_direction_count = int(filtered['direction'].notna().sum())  # 仅统计可计算收益方向的有效记录
assert crosstab_direction.index[-1][0] == '总计' and crosstab_direction.columns[-1] == '总计'  # 核验末行末列均为边际总计
assert int(crosstab_direction.iloc[-1, -1]) == valid_direction_count  # 核验交叉表总频数与非缺失收益方向样本数一致
print(crosstab_direction)  # 输出涨跌天数交叉表

=== 4. 交叉表:收益方向天数统计 ===
direction     上涨  下跌  平盘   总计
symbol month                 
宁波港    1       7   5   3   15
       2       8  10   2   20
       3       7  12   4   23
宁波银行   1       6   9   0   15
       2       5  15   0   20
       3       9  14   0   23
恒瑞医药   1       6   9   0   15
       2       9  11   0   20
       3      10  13   0   23
总计            67  98   9  174

5. 归一化交叉表(行百分比)

print('\n=== 5. 收益方向比例(按股票) ===')                         # 标识各证券内部下跌、平盘与上涨交易日占比
crosstab_pct = pd.crosstab(                                 # 将每只证券的有效收益方向频数归一为行比例
    filtered['symbol'],  # 每行对应一只证券的有效收益日
    filtered['direction'],  # 列索引为涨跌方向
    normalize='index'                                       # 按行归一化计算比例
).round(3)  # 保留3位小数
np.testing.assert_allclose(crosstab_pct.sum(axis=1).to_numpy(), 1.0, atol=1e-3)  # 核验每只证券有效方向比例合计为1
print(crosstab_pct)  # 输出各股票收益方向比例

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

6. 堆叠柱状图数据

print('\n=== 6. 堆叠柱状图数据准备 ===')                             # 标识月份行、证券列的日收益均值矩阵
stacked_data = pd.pivot_table(                              # 将证券月均日收益旋转为绘图宽表
    filtered,  # 使用去除首日不可计算收益后的三证券样本
    values='returns',                                       # 汇总日收益率小数
    index='month',                                          # 行索引为月份
    columns='symbol',                                       # 每列对应一只证券
    aggfunc='mean'                                          # 每个证券—月份单元取日收益算术均值
).round(4)  # 月均日收益率以小数显示四位
print('月平均收益率:')  # 输出月度平均收益率标题
print(stacked_data)  # 输出各股票月度平均收益率数据

=== 6. 堆叠柱状图数据准备 ===
月平均收益率:
symbol     宁波港    宁波银行    恒瑞医药
month                         
1       0.0019  0.0014  0.0068
2      -0.0004 -0.0051  0.0016
3      -0.0002 -0.0033 -0.0006

关键要点: - 透视表适合多维度汇总 - 多聚合函数提供全面视角 - margins=True 添加总计 - 交叉表专门用于频数统计 - normalize 计算比例 - 透视表结果便于可视化


8.7.6 习题 8.6: 当前样本的描述性周与星期汇总

问题描述

使用透视表描述 2023 年当前样本的时间分组特征:

  1. 按周内日收益复合得到周收益率,并创建周度热力图数据
  2. 描述不同星期的日收益率均值
  3. 比较不同股票在当前样本中的标准化相对表现
  4. 报告样本内周收益分布与星期汇总,并说明推断边界

完整解答

import pandas as pd                                         # 以ISO周为行、三只证券为列复合全年日收益
import numpy as np                                          # 支持证券内z-score标准化

# 为周与星期描述练习读取三只股票的2023年全年行情
port_prices = pd.read_hdf(                              # 读取宁波港前复权日行情
    PRE_ADJUSTED_PRICE_PATH,  # 使用本地固定前复权行情版本
    where="order_book_id='601018.XSHG'"         # 港口日价供周复合收益热力图的运输行业列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开交易日并规范成交量字段
bank_prices = pd.read_hdf(                              # 读取宁波银行同口径行情
    PRE_ADJUSTED_PRICE_PATH,  # 与宁波港保持同源前复权数据
    where="order_book_id='002142.XSHE'"         # 银行日价供深市金融行业周收益列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 形成相同日期与成交量schema
hengrui_prices = pd.read_hdf(                            # 读取恒瑞医药同口径行情
    PRE_ADJUSTED_PRICE_PATH,  # 使用同一前复权价格表
    where="order_book_id='600276.XSHG'"         # 医药日价补充跨行业周度收益列
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开索引供全年时间维度提取

port_prices['symbol'] = '宁波港'  # 运输行业标签成为热力图的一列
bank_prices['symbol'] = '宁波银行'  # 金融行业标签与港口列共享ISO周行键
hengrui_prices['symbol'] = '恒瑞医药'  # 医药标签补足第三条跨行业周收益序列

stock_data = pd.concat([port_prices, bank_prices, hengrui_prices])  # 汇集三只股票供横向比较周收益
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 把YYYYMMDD转换为星期与ISO周可用时间戳
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 限定星期与周复合收益的2023年样本
# 限定中文证券集合并构造时间分组键
symbols_map = {                                             # 核验周度比较所含三只证券的代码—名称关系
    '601018.SH': '宁波港',  # 沪市运输代码对应周矩阵的港口列
    '002142.SZ': '宁波银行',  # 深市金融代码对应银行列并校验交易所后缀
    '600276.SH': '恒瑞医药'  # 沪市医药代码对应跨行业比较列
}  # 完成周度样本的证券集合定义
filtered = stock_data[stock_data['symbol'].isin(symbols_map.values())].copy()  # 复制三证券全年样本以派生日收益
filtered['returns'] = filtered.groupby('symbol')['close'].pct_change(fill_method=None)  # 按证券真实相邻收盘价计算日收益率

# 派生日历日期、星期编码与ISO周号三种时间键,小时列只核验日频粒度
filtered['date'] = pd.to_datetime(filtered['datetime']).dt.date  # 保留不含时区的自然交易日期用于展示
filtered['hour'] = pd.to_datetime(filtered['datetime']).dt.hour  # 核验日频记录时间分量均为零时
filtered['day_of_week'] = pd.to_datetime(filtered['datetime']).dt.dayofweek  # 生成周一为0、周五为4的分组编码
filtered['week'] = pd.to_datetime(filtered['datetime']).dt.isocalendar().week  # 生成ISO周号用于周内复合

days_map = {0: '周一', 1: '周二', 2: '周三', 3: '周四', 4: '周五'}    # 工作日数字到中文名称的映射
filtered['day_name'] = filtered['day_of_week'].map(days_map)  # 把0—4编码映射为中文工作日标签

1. 周度热力图数据

print('=== 1. 周×股票复合收益率热力图 ===')  # 明确周收益采用复合口径而非日收益算术均值
weekly_compound_returns = (                                # 先在证券×周内复合,再将证券旋转为列
    filtered.dropna(subset=['returns'])
    .groupby(['week', 'symbol'])['returns']
    .apply(lambda daily_returns: (1 + daily_returns).prod() - 1)
    .unstack('symbol')
    .round(4)
)
print(weekly_compound_returns)  # 展示当前样本每周实际复合收益率
=== 1. 周×股票复合收益率热力图 ===
symbol     宁波港    宁波银行    恒瑞医药
week                          
1      -0.0028  0.0130  0.0226
2      -0.0028  0.0411 -0.0010
3       0.0254 -0.0224  0.1087
5       0.0028 -0.0416 -0.0170
6      -0.0000 -0.0132  0.0187
7      -0.0110 -0.0436  0.0131
8       0.0083 -0.0027 -0.0222
9       0.0192  0.0274  0.0049
10     -0.0269 -0.0988 -0.0355
11      0.0305 -0.0177 -0.0248
12     -0.0188  0.0169  0.0216
13     -0.0082 -0.0144  0.0269
14      0.0194  0.0048  0.0544
15      0.0108  0.0456  0.0569
16      0.0161 -0.0568 -0.0084
17     -0.0000  0.0118  0.0281
18      0.0211  0.0110 -0.0243
19     -0.0414 -0.0137 -0.0413
20     -0.0323 -0.0286  0.0795
21     -0.0028 -0.0211 -0.0149
22     -0.0033 -0.0054 -0.0490
23      0.0057  0.0364 -0.0017
24     -0.0143 -0.0153  0.0373
25     -0.0174 -0.0345 -0.0232
26     -0.0000 -0.0055  0.0323
27      0.0118 -0.0221  0.0102
28      0.0000  0.0425 -0.0180
29      0.0088 -0.0226 -0.0335
30      0.0202  0.1502  0.0679
31      0.0028 -0.0088 -0.1627
32     -0.0254 -0.0199 -0.0455
33      0.0029 -0.0091  0.0191
34     -0.0174 -0.0183  0.0193
35      0.0059  0.0305  0.0199
36     -0.0117 -0.0040 -0.0229
37      0.0207  0.0004  0.0744
38      0.0058  0.0123  0.0106
39     -0.0058 -0.0373  0.0200
41     -0.0145 -0.0718 -0.0065
42     -0.0265 -0.0329 -0.0432
43      0.0212  0.0112  0.0627
44      0.0118  0.0275  0.0465
45      0.0088 -0.0088  0.0185
46      0.0029  0.0016 -0.0258
47      0.0318 -0.0092 -0.0045
48      0.0140 -0.0824 -0.0075
49     -0.0028 -0.0796 -0.0213
50      0.0028 -0.0207 -0.0204
51      0.0055 -0.0397 -0.0146
52     -0.0220  0.0271  0.0277

2. 星期维度的描述性汇总

print('\n=== 2. 星期维度日收益率均值 ===')  # 仅报告样本分组均值,不将其解释为已检验异象
weekday_pivot = pd.pivot_table(                             # 将证券×星期日收益样本均值旋转为宽表
    filtered,  # 使用三只证券2023年有效日收益记录
    values='returns',                                       # 汇总当前星期的日收益小数
    index='day_name',                                       # 行索引为星期名称
    columns='symbol',                                       # 证券列便于横向比较同一星期
    aggfunc='mean'                                          # 单元格为样本内星期日收益均值
).round(4)  # 星期收益均值显示四位小数
print(weekday_pivot)  # 输出各股票周内效应数据

=== 2. 星期维度日收益率均值 ===
symbol       宁波港    宁波银行    恒瑞医药
day_name                        
周一        0.0014 -0.0022  0.0051
周三       -0.0013 -0.0046 -0.0026
周二       -0.0007 -0.0015  0.0003
周五        0.0003  0.0001  0.0004
周四        0.0009 -0.0004  0.0011

3. 相对表现分析

print('\n=== 3. 相对表现矩阵 ===')                                # 标识证券内标准化后按ISO周汇总的矩阵

# 标准化收益率(z-score)
def z_score(group):                                         # 自定义函数:计算z-score标准化值
    return (group - group.mean()) / group.std()             # 返回去均值后除以标准差的标准化值

normalized_returns = filtered.groupby('symbol')['returns'].transform(z_score)  # 在各证券全年样本内计算并回填日收益z-score

# 创建相对表现矩阵
relative_performance = pd.pivot_table(                      # 按ISO周汇总各证券标准化日收益均值
    filtered.assign(norm_returns=normalized_returns),       # 添加标准化收益率列
    values='norm_returns',                                  # 对标准化收益率进行聚合
    index='week',                                           # 行索引为周数
    columns='symbol',                                       # 每列对应一只证券的标准化收益
    aggfunc='mean'                                          # 每个ISO周取证券内z-score均值
).round(2)  # 标准化相对表现显示两位小数
print(relative_performance)  # 输出各股票相对表现矩阵

=== 3. 相对表现矩阵 ===
symbol   宁波港  宁波银行  恒瑞医药
week                    
1      -0.14  0.35  0.35
2      -0.09  0.55 -0.04
3       0.65 -0.15  1.10
5       0.06 -0.37 -0.22
6      -0.01 -0.05  0.15
7      -0.30 -0.39  0.10
8       0.20  0.08 -0.28
9       0.49  0.40  0.01
10     -0.73 -1.05 -0.42
11      0.78 -0.10 -0.31
12     -0.51  0.29  0.19
13     -0.23 -0.06  0.24
14      0.62  0.16  0.67
15      0.27  0.60  0.55
16      0.41 -0.55 -0.13
17     -0.02  0.26  0.25
18      1.37  0.40 -0.67
19     -1.11 -0.04 -0.48
20     -0.87 -0.22  0.78
21     -0.09 -0.13 -0.20
22     -0.10  0.06 -0.56
23      0.14  0.50 -0.06
24     -0.39 -0.07  0.35
25     -0.78 -0.55 -0.45
26     -0.01  0.04  0.29
27      0.30 -0.15  0.07
28     -0.01  0.57 -0.23
29      0.22 -0.15 -0.40
30      0.51  1.71  0.65
31      0.06  0.01 -1.85
32     -0.69 -0.13 -0.52
33      0.07 -0.00  0.16
34     -0.47 -0.11  0.17
35      0.14  0.46  0.17
36     -0.32  0.07 -0.29
37      0.53  0.10  0.73
38      0.14  0.23  0.07
39     -0.21 -0.43  0.22
41     -0.40 -0.71 -0.11
42     -0.72 -0.27 -0.51
43      0.55  0.22  0.60
44      0.30  0.40  0.44
45      0.21 -0.00  0.15
46      0.06  0.11 -0.32
47      0.81 -0.01 -0.09
48      0.35 -0.85 -0.12
49     -0.08 -0.81 -0.27
50      0.06 -0.13 -0.26
51      0.13 -0.35 -0.20
52     -0.60  0.40  0.24

4. 当前样本的分布与解释边界

print('\n=== 4. 当前样本周收益分布 ===')  # 用分位数描述周收益离散程度,不输出择时推荐
weekly_distribution = weekly_compound_returns.describe(    # 汇总每只股票周复合收益的样本分布
    percentiles=[0.1, 0.5, 0.9]
).round(4)
print(weekly_distribution)  # 展示计数、分位数和极值以识别样本异质性

# 按星期汇总
weekday_summary = pd.pivot_table(                           # 再按星期与证券组织日收益样本均值供边界说明
    filtered,  # 使用同一2023年日收益样本保证口径一致
    values='returns',                                       # 沿用同一日收益口径
    index='day_name',                                       # 行索引为星期名称
    columns='symbol',                                       # 证券列与前一星期表保持一致
    aggfunc='mean'                                          # 重算星期均值用于结论边界展示
).round(4)  # 输出四位小数以保留微小日收益差异

print('\n当前样本按星期的日均收益率:')  # 再次明确星期表属于当前样本描述
print(weekday_summary)  # 输出各股票按星期分组的样本均值

=== 4. 当前样本周收益分布 ===
symbol      宁波港     宁波银行     恒瑞医药
count   50.0000  50.0000  50.0000
mean     0.0006  -0.0082   0.0042
std      0.0165   0.0393   0.0430
min     -0.0414  -0.0988  -0.1627
10%     -0.0223  -0.0449  -0.0361
50%      0.0028  -0.0092   0.0020
90%      0.0207   0.0311   0.0575
max      0.0318   0.1502   0.1087

当前样本按星期的日均收益率:
symbol       宁波港    宁波银行    恒瑞医药
day_name                        
周一        0.0014 -0.0022  0.0051
周三       -0.0013 -0.0046 -0.0026
周二       -0.0007 -0.0015  0.0003
周五        0.0003  0.0001  0.0004
周四        0.0009 -0.0004  0.0011

关键要点

  • 周收益必须按 \((1+r_1)\cdots(1+r_n)-1\) 复合,不能用日收益算术均值替代。
  • 透视表与 z-score 只描述当前样本的分组结构。
  • 星期均值和样本内极值没有报告估计不确定性、多重检验、交易成本与样本外稳定性,因此不能证明周内效应,也不能用于投资择时。

8.7.7 习题 8.7: 综合数据聚合项目

问题描述

构建一个完整的股票分析报告,整合各种聚合操作:

  1. 创建数据质量报告
  2. 生成各股票的绩效指标
  3. 按指标排序并比较排序敏感性
  4. 输出带推断边界的描述性报告

完整解答

import pandas as pd                                         # 组织综合质量、绩效与排序报告
import numpy as np                                          # 处理波动比零分母和缺失结果
from scipy import stats                                     # 为综合报告计算收益偏度与峰度质量指标

print('=== 股票分析报告生成器 ===\n')                                # 输出报告生成器标题

# 为综合报告读取三只股票的2023年完整前复权行情
port_prices = pd.read_hdf(                              # 读取宁波港用于质量与绩效审计的日记录
    PRE_ADJUSTED_PRICE_PATH,  # 使用本地前复权股票行情源
    where="order_book_id='601018.XSHG'"         # 下推宁波港代码过滤以减少载入量
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开日期并规范报告字段
bank_prices = pd.read_hdf(                              # 读取宁波银行的同口径日记录
    PRE_ADJUSTED_PRICE_PATH,  # 复用同一前复权行情快照确保可比性
    where="order_book_id='002142.XSHE'"         # 下推宁波银行代码过滤
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 银行审计表与港口表共享trade_date、close、volume列合同
hengrui_prices = pd.read_hdf(                            # 读取恒瑞医药的同口径日记录
    PRE_ADJUSTED_PRICE_PATH,  # 与两只宁波股票使用相同前复权数据版本
    where="order_book_id='600276.XSHG'"         # 下推恒瑞医药代码过滤
).reset_index().rename(columns={'date': 'trade_date', 'vol': 'volume'})  # 展开索引供样本覆盖检查

port_prices['symbol'] = '宁波港'  # 运输行业样本形成质量与绩效报告的一行
bank_prices['symbol'] = '宁波银行'  # 金融行业样本形成独立风险统计行
hengrui_prices['symbol'] = '恒瑞医药'  # 医药行业样本提供第三个横向比较对象

stock_data = pd.concat([port_prices, bank_prices, hengrui_prices])  # 合并三证券并保留symbol识别列
stock_data['datetime'] = pd.to_datetime(stock_data['trade_date'], format='%Y%m%d')  # 解析交易日以限定2023年报告窗口
stock_data = stock_data[(stock_data['datetime'] >= '2023-01-01') & (stock_data['datetime'] <= '2023-12-31')]  # 固定质量与绩效报告的2023年样本期
=== 股票分析报告生成器 ===
# 根据已写入的中文symbol标签保留报告对象并建立稳定排序
symbols_map = {                                             # 声明综合报告覆盖的三证券代码与名称
    '601018.SH': '宁波港',  # 沪市运输代码连接港口记录与审计行
    '002142.SZ': '宁波银行',  # 深市金融代码连接银行记录与审计行
    '600276.SH': '恒瑞医药'  # 沪市医药代码连接第三行业记录与审计行
}  # 完成报告对象集合定义
filtered = stock_data[stock_data['symbol'].isin(symbols_map.values())].copy()  # 复制报告样本并按证券—日期稳定排序
filtered = filtered.sort_values(['symbol', 'datetime'])     # 保证收益、回撤和首尾价格均沿证券时间顺序计算
# 保留datetime为普通列,便于后续同时按年份、月份和星期构造分组键

1. 数据质量报告

print('[1/5] 数据质量检查')  # 输出数据质量检查标题

quality_report = filtered.groupby('symbol').agg({           # 按证券核对观测数、日期覆盖范围及两列缺失数量
    'datetime': ['count', 'min', 'max'],  # 统计数据点数、起止日期
    'close': lambda x: x.isna().sum(),                      # 清点各证券收盘价缺失行
    'volume': lambda x: x.isna().sum()                      # 清点各证券成交量缺失行
})  # 聚合结果完成
quality_report.columns = ['数据点数', '开始日期', '结束日期', '收盘价缺失', '成交量缺失']  # 重命名列为中文描述
print(quality_report)  # 输出数据质量报告
[1/5] 数据质量检查
        数据点数       开始日期       结束日期  收盘价缺失  成交量缺失
symbol                                          
宁波港      242 2023-01-03 2023-12-29      0      0
宁波银行     242 2023-01-03 2023-12-29      0      0
恒瑞医药     242 2023-01-03 2023-12-29      0      0

2. 绩效指标计算

本题把最低可接受收益率设为日目标 \(MAR_d=0\),并统一报告年化目标下行偏差(MAR=0)。对 \(m\) 个有效日收益区间,先把未低于目标的区间记为零短缺,再以全部 \(m\) 个有效区间为分母:

\[ DD_{MAR,d}=\sqrt{\frac{1}{m}\sum_{t=1}^{m}\min(r_t-MAR_d,0)^2},\qquad DD_{MAR,a}=\sqrt{252}\,DD_{MAR,d}. \tag{8.4}\]

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

若研究者另设年目标 \(MAR_a\),应先按 \(MAR_d=(1+MAR_a)^{1/252}-1\) 转成同频日目标。该统计量不是“负收益子样本的标准差”:零短缺区间仍进入分母,且不对负收益子样本重新中心化。本题也不把它称为 Sortino 比率,因为没有用超额收益分子除以下行偏差。

def summarize_valid_return_intervals(close_prices):         # 把缺口处理、复合收益和几何年化集中为同一口径
    '''汇总不跨价格缺口的有效相邻收益区间。'''  # 明确函数不会插值或前向填补缺失价格
    valid_close_pairs = close_prices.notna() & close_prices.shift(1).notna()  # 标记前后两条记录价格均可用的相邻区间
    valid_returns = close_prices.pct_change(fill_method=None).dropna()  # 排除任何端点缺失的收益区间
    interval_count = len(valid_returns)                     # 以实际有效收益区间数作为几何年化分母
    assert interval_count == int(valid_close_pairs.sum())   # 核验收益序列长度与缺口政策逐项一致
    compounded_return = (1 + valid_returns).prod() - 1      # 只复合可观测的相邻区间收益
    annualized_return = (1 + compounded_return) ** (252 / interval_count) - 1 if interval_count > 0 else np.nan  # 按有效区间数年化
    return valid_returns, interval_count, compounded_return, annualized_return  # 同时返回风险统计所需收益序列与年化结果
def calculate_annualized_target_downside_deviation(valid_returns, daily_mar=0.0):  # 按全部有效区间计算目标下行偏差
    '''计算与日目标同频、再按252个交易区间年化的目标下行偏差。'''  # 分母不只包含负收益日
    if len(valid_returns) == 0:  # 空样本没有可定义的目标下行偏差
        return np.nan  # 用缺失值显式表示没有有效收益区间
    downside_shortfalls = np.minimum(valid_returns - daily_mar, 0.0)  # 未低于MAR的区间贡献零平方短缺
    daily_target_downside_deviation = np.sqrt(np.mean(np.square(downside_shortfalls)))  # 均方分母包含全部有效区间
    return daily_target_downside_deviation * np.sqrt(252)  # 用平方根时间规则年化日目标下行偏差

two_interval_returns = pd.Series([-0.02, 0.01])  # 反例一含一次-2%和一个非负区间
four_interval_returns = pd.Series([-0.02, 0.01, 0.01, 0.01])  # 反例二保留同幅负收益并增加两个零短缺区间
two_interval_downside_deviation = calculate_annualized_target_downside_deviation(two_interval_returns)  # 计算两区间目标下行偏差
four_interval_downside_deviation = calculate_annualized_target_downside_deviation(four_interval_returns)  # 计算四区间目标下行偏差
assert np.isclose(two_interval_downside_deviation / four_interval_downside_deviation, np.sqrt(2))  # 同幅负收益仍因全部区间分母不同而产生不同结果
print('\n[2/5] 绩效指标计算')                                     # 输出绩效指标计算标题

def calculate_performance_metrics(group):                   # 自定义函数:计算股票综合绩效指标
    '''计算综合绩效指标。'''  # 包含收益、风险与不扣无风险收益的收益波动比
    returns, effective_return_interval_count, total_return, annual_return = summarize_valid_return_intervals(group['close'])  # 复用统一的缺口与年化口径

    # 基本收益指标
    volatility = returns.std() * np.sqrt(252)               # 按252个交易区间将有效日收益标准差年化

    # 未引入无风险利率时只报告收益波动比,不冒充夏普比率
    return_volatility_ratio = annual_return / volatility if volatility > 0 else np.nan  # 计算样本年化收益与总波动之比
    annualized_target_downside_deviation = calculate_annualized_target_downside_deviation(returns, daily_mar=0.0)  # 按全部有效区间计算MAR为0的标准目标下行偏差

    # 最大回撤
    cumulative_max = group['close'].cummax()                # 计算累积最大值
    drawdown = (group['close'] - cumulative_max) / cumulative_max  # 计算各时点回撤率
    max_drawdown = drawdown.min()                           # 取最大回撤值(负值)

    # 统计特性
    skewness = stats.skew(returns)                          # 计算收益率偏度
    kurtosis = stats.kurtosis(returns)                      # 计算收益率峰度

    return pd.Series({                                      # 返回包含各绩效指标的Series
        '总收益率': total_return,  # 期间总收益率
        '有效收益区间数': effective_return_interval_count,  # 报告几何年化实际使用的分母
        '年化收益率': annual_return,  # 年化收益率
        '年化波动率': volatility,  # 年化波动率
        '收益波动比': return_volatility_ratio,  # 明示该比率没有扣除无风险收益
        '年化目标下行偏差(MAR=0)': annualized_target_downside_deviation,  # 报告日目标为0且按252区间年化的目标下行偏差
        '最大回撤': max_drawdown,  # 最大回撤
        '收益偏度': skewness,  # 收益率偏度
        '收益峰度': kurtosis,  # 收益率峰度
        '胜率(%)': (returns > 0).mean() * 100  # 收益为正的交易日占比
    })  # Series构建完成

[2/5] 绩效指标计算

缺口政策是:不对收盘价插值或前向填充,仅当相邻两条记录的收盘价都非空时才计算该区间收益;缺失端点涉及的区间全部排除。令有效区间数为 \(m\),总收益按这些有效区间复合,年化收益为 \((1+R)^{252/m}-1\)。因此这里描述的是“已观测有效区间样本”的年化结果;若存在价格缺口,它不等于跨完整日历持有期的实现收益。

performance = filtered.groupby('symbol').apply(calculate_performance_metrics, include_groups=False)  # 证券键留在结果索引,函数只处理数值行情列
print(performance.round(4))  # 输出各股票绩效指标汇总表
          总收益率  有效收益区间数   年化收益率   年化波动率   收益波动比  年化目标下行偏差(MAR=0)    最大回撤  \
symbol                                                                     
宁波港     0.0224    241.0  0.0234  0.1207  0.1939           0.0840 -0.1390   
宁波银行   -0.3628    241.0 -0.3757  0.2854 -1.3168           0.1949 -0.4199   
恒瑞医药    0.1808    241.0  0.1898  0.3022  0.6280           0.1928 -0.2133   

          收益偏度    收益峰度    胜率(%)  
symbol                           
宁波港    -0.0474  0.2955  40.6639  
宁波银行    0.9254  3.2317  38.1743  
恒瑞医药    0.4001  4.7496  47.3029  

3. 描述性排序与敏感性审计

# 分别按收益、波动和回撤排序,以暴露单一综合分数掩盖的权衡
return_ranking = performance['年化收益率'].sort_values(ascending=False)  # 按样本年化收益降序排列
volatility_ranking = performance['年化波动率'].sort_values()  # 按样本年化波动升序排列
drawdown_ranking = performance['最大回撤'].sort_values(ascending=False)  # 按最大回撤较小者优先排列
ranking_audit_df = pd.concat({  # 并列三种互不等价的描述性排序
    '收益排序': return_ranking.index.to_series().reset_index(drop=True),  # 保存收益口径下的顺序
    '低波动排序': volatility_ranking.index.to_series().reset_index(drop=True),  # 保存波动口径下的顺序
    '低回撤排序': drawdown_ranking.index.to_series().reset_index(drop=True)  # 保存回撤口径下的顺序
}, axis=1)  # 形成便于逐列比较的审计表
print(ranking_audit_df)  # 展示排序是否随评价口径改变
   收益排序 低波动排序 低回撤排序
0  恒瑞医药   宁波港   宁波港
1   宁波港  宁波银行  恒瑞医药
2  宁波银行  恒瑞医药  宁波银行

4. 结论边界

print('\n[4/4] 描述性报告边界')  # 标明报告性质而非输出交易指令
print('各指标仅概括2023年样本,不构成收益预测、因果结论或投资建议。')  # 防止将样本内排序外推到未来
print('若用于预测,需预先固定指标、执行时间外验证并计入交易成本。')  # 指出进入预测研究所需的最低证据

[4/4] 描述性报告边界
各指标仅概括2023年样本,不构成收益预测、因果结论或投资建议。
若用于预测,需预先固定指标、执行时间外验证并计入交易成本。

关键要点: - 整合多种聚合操作 - 多维度评估绩效 - 比较不同指标下的排序敏感性 - 明确描述、预测与投资决策之间的证据边界

8.8 结论

对于利用python进行数据分析而言,掌握 pandas 的数据分组工具是一项不可或缺的技能。“拆分-应用-合并”策略为将复杂的数据操作问题分解为可管理的步骤提供了一个强大的心智模型。无论是执行简单的聚合、复杂的组内转换,还是创建富有洞察力的透视表,groupby 功能都是驱动大量数据清洗、建模和统计分析工作的核心引擎。

实际选择操作时,先写清输出合同:归约把每组压缩为一个或固定数量摘要,选择保留指定组内观测,转换把结果对齐回原行,filter 按条件保留或剔除整组,而通用 apply 接纳更自由的输出形状但通常成本更高。验收结果时,应同时核对索引与形状、count(非缺失数)和 size(总行数)、标准差自由度,以及透视表或交叉表的有效样本频数守恒。市场模型系数还必须服从共同交易日、外生性、矩条件和模型设定等边界;本章样本内描述不能直接外推为显著性、因果、预测或投资结论。

章节导航:上一章为数据规整:连接、合并与重塑,下一章为时间序列分析