← 返回模块
3.2.2.3beta 可读 · 未来付费内容校验中内容版本 2026-05-27

分组、合并与重塑

3.2.2 · Pandas · 编程

周二下午两点,私募研究台。你手里堆着三张表:一张是 20 个交易日 × 3 只票(600519.SH000001.SZ600036.SH)的长格式日收益,共 60 行 (date, ticker, return);一张是申万一级行业查找表(600519.SH → 食品饮料000001.SZ → 银行600036.SH → 银行);还有上月的 A 股切片与本月的另一份切片,正等着沿日期轴拼起来。今天的活儿不是建模,是把这三张表对齐成模块后续课程要用的 (T, N) 收益矩阵,再算出「过去 20 个交易日,银行行业的日均收益与日均成交」。这一节给你三个真正把 Pandas 与 NumPy 拉开距离的动词:groupbymergereshape。每一个都该长进你的右手肌肉里。

groupby:拆—算—合

df.groupby('sector') 本身 并不 算东西,它返回的是 DataFrameGroupBy 对象,惰性持有「按 sector 切片」这个分组定义。计算真正发生在你把聚合器(aggregator)落到这个对象上:

g = df.groupby('sector')
summary = g.agg({'return': 'mean', 'volume': 'sum'})

summary 形状 (n_sectors, 2)——一行一个 sector,两列分别是该 sector 的均值收益与汇总成交。常用聚合器分三家:(1) 单列规约——g['return'].mean()g['return'].std()g['return'].agg(['mean', 'std', 'count']) 一次出多列;(2) 多列字典聚合——g.agg({'return': 'mean', 'volume': 'sum'});(3) 自定义函数——g.agg(my_func)my_func 收一个 Series 返回一个标量。

agg vs transform vs apply

三者的区别是承重柱。​**agg** 把每组 塌成一行,输出 (n_groups, n_cols)。​**transform** 返回与输入 同形 的结果,把组内统计量广播回原始行——这正是组内 z-score 想要的:

g = df.groupby('sector')
df['z'] = (df['return'] - g['return'].transform('mean')) / g['return'].transform('std')

一行算清「每行减去该行所在行业的均值、再除以该行业的标准差」,完全向量化,无任何 Python 循环。​**apply** 是慢速通用逃生口(escape hatch):它退出 C 路径,运行时常常比对应的 agg / transform 慢 10–100 倍。规则只一条:先问「这件事 aggtransform 能不能做?」,能做就别碰 apply。顺手记下 filter——保留满足组级条件的整组行,例如 g.filter(lambda x: len(x) >= 5) 留下样本数 ≥5 的行业。

merge 与承重的 validate=

pd.merge 是把两张「按标签关联」的表对上的工具。最常用是 left join:

joined = pd.merge(prices, sectors, on='ticker', how='left', validate='m:1')

四种 howinner(交集)、left / right(保一边)、outer(并集——通常是两边数据源对不齐的信号)。left_on / right_on 在两边列名不同时使用,suffixes=('_x', '_y') 给同名非 join 列消歧。

真正吃重的是 validate=。当价格表(每个 ticker 多行)merge 到行业表(每个 ticker 一行)时,你期望每条价格行最多匹配一条行业行。validate='m:1' 表示 many-to-one;只要左边出现重复 ticker,Pandas 立即报错。一句你应当背下来的规则:When merging a price table (many rows per ticker) to a sector lookup (one row per ticker), always pass validate="m:1"; an unintended many-to-many merge silently multiplies rows and is the textbook cause of inflated back-test PnL. 缺了这一个关键字,行业表里偷偷多出来一行重复 ticker,所有价格行就翻倍,三个月后才在 PnL 上看到鬼影。

concat:沿轴堆叠

concat 做的事比 merge 更直白——按轴堆:

stacked_rows = pd.concat([df_jan, df_feb], axis=0)
stacked_cols = pd.concat([prices_a_shares, prices_h_shares], axis=1)

axis=0 多日期同列(历史归档接 live feed),axis=1 多列同日期(A 股与 H 股两个 universe 拼成同一张收益矩阵)。ignore_index=True 抛弃源索引;keys=['jan', 'feb'] 构造一个标记来源的 MultiIndex。merge 强调按标签匹配,concat 强调按位置堆叠——选哪个看你信任的对齐方式。

pivot_table / melt:长 ↔ 宽

A 股数据库吐出来的总是长格式 (date, ticker, return),一行一个观测。但截面分析要的是宽格式 (T, N),一行一日期、一列一 ticker:

wide = long_df.pivot_table(index='date', columns='ticker', values='return', aggfunc='mean')

aggfunc='mean' 是承重的:透视必须为 (date, ticker) 重复键给出明确的去重策略。干净数据里不该有重复键;脏数据里这个参数把碰撞显式化(explicit),而不是悄悄丢一行。

反向走:

long_again = wide.reset_index().melt(id_vars=['date'], var_name='ticker', value_name='return')

stack / unstack 是 MultiIndex 下的对应物——df.stack() 把最内层列推进索引(宽 → 长),df.unstack() 把最内层行拉出来当列。

MultiIndex 一瞥

两种方式拿到一个 MultiIndex:df.set_index(['date', 'ticker']) 把两列升成层次索引,或 df.groupby(['sector', 'ticker']).mean()groupby 输出多级索引。取数用 .loc[('食品饮料', '600519.SH')] 拿单格,.loc[('银行', slice(None))] 拿银行行业下所有 ticker。swaplevelsort_index(level=)reset_index 这套工具用来在不同布局之间回切。

一段端到端

把今天的三个动词串起来:

wide = long_df.pivot_table(index='date', columns='ticker', values='return', aggfunc='mean')   # (20, 3) 收益矩阵
per_ticker = long_df.groupby('ticker').agg({'return': ['mean', 'std']})                          # 每只票的均值与波动
joined = pd.merge(per_ticker.reset_index(), sectors, on='ticker', how='left', validate='m:1')   # 接上行业
by_sector = joined.groupby('sector').mean(numeric_only=True)                                     # 按行业再汇总

最终 groupby('sector') 的结果两行——食品饮料银行——因为 600519.SH 一只属于食品饮料,000001.SZ600036.SH 两只属于银行。

下一课接力

到这里你已经能分组、合并、重塑。但日期此刻还只是 groupbymerge 的一个键,并没有作为「时间轴」被任何时间感知(time-aware)操作处理。下一课把这个时间维度装上去:DatetimeIndex 的部分串切片、resample 改频率、rolling 算滑动统计、shift 做滞后特征,以及跨交易所拼盘内数据时绕不开的时区对齐——量化数据天生是时间序列。

练习

Exercise

给定一个长格式 DataFrame long_df,列为 datetickerreturn(每个 (date, ticker) 一行),以及一张行业查找表 sectors,列为 tickersector(每个 ticker 一行)。请写 Pandas 代码,返回一个以 sector 为索引的 Series,其值是该行业的「日均横截面收益」——也就是:对每个日期,先把该行业内 ticker 的 return 取一次均值;再把所有日期的这些日均值在时间维度上取一次均值。要求用上 mergegroupbymean;不允许任何 Python for 循环。

提示
先用 pd.merge(long_df, sectors, on='ticker', how='left', validate='m:1') 把行业列接到长表上;接着 groupby(['date', 'sector'])['return'].mean() 算出「每日 × 每行业」的横截面均值。
提示
把上一步结果再做一次 .groupby('sector').mean(),把所有交易日的日均值在行业维度上塌成一个 Series——它的索引就是 sector,正是要返回的最终对象。