第16讲 transform 与组内变换

📎 配套代码第16讲_transform组内变换.py
📊 配套数据data/quote/ data/industry/ —— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:行业中性化,agg 给不了

一个常见的因子处理动作:把每只股票的收益率,减去它所属行业当天的平均收益率。剥掉行业整体涨跌的部分,剩下的才是这只股票自己的表现。

agg 算一下行业均值:

df.groupby("industry")["pct_chg"].mean()
形状 (66,)        ← 66 个行业,每个一行

得到 66 个数,但原表有 17 万行。你没法直接相减——形状对不上。

要把这 66 个数贴回到对应的 17 万行上去,得先 merge 回来,或者手工对齐。

transform 就是干这件事的:

df.groupby("industry")["pct_chg"].transform("mean")
形状 (171347,)    ← 和原表一样长

同样是算行业均值,但结果里每一行都配上了它所属行业的均值。于是相减就是一句:

df["excess"] = df["pct_chg"] - df.groupby("industry")["pct_chg"].transform("mean")

agg 把每组压成一行,transform 把组的统计量广播回每一行。第 07 讲给过一行对照表,这一讲把 transform 这条路走完整——因子中性化、截面标准化、组内排名、组内填充,量化里的这些操作全靠它。


🎯 这一讲结束时,你能

  • 说清 aggtransform 的形状差别,并知道什么时候需要”贴回原表”
  • 写出量化里最常用的几个组内变换:市场中性、行业中性、截面标准化、截面排名
  • 判断该用内置字符串还是 lambda——这里的性能差距比 agg 大得多
  • 避开 transform 返回变长时不报错而补 NaN 的静默行为
  • 知道组内 ffill 之前为什么必须排序,以及不排序会错成什么样

一、🧰 形状:agg 压缩,transform 广播

g = df.groupby("industry")["pct_chg"]

g.mean()                # (66,)        每组一行
g.transform("mean")     # (171347,)    和原表一样长

transform 的结果索引和原表完全一致,所以可以直接参与原表的列运算:

df["行业均值"] = g.transform("mean")           # 直接赋值成新列
df["超额"]    = df["pct_chg"] - g.transform("mean")   # 直接相减

这是它和 agg 的全部区别,也是它存在的理由。

判断用哪个很简单:结果要不要贴回原表。要汇总表用 agg,要给每一行配一个组内统计量用 transform


二、🧰 量化里最常用的几个组内变换

# 市场中性:减去当日全市场均值
df["ret_mkt"] = df["pct_chg"] - df.groupby("trade_date")["pct_chg"].transform("mean")

# 行业中性:减去当日所属行业均值
df["ret_ind"] = df["pct_chg"] - df.groupby(
    ["trade_date", "industry"], observed=True)["pct_chg"].transform("mean")

# 截面标准化(z-score)
g = df.groupby("trade_date")["factor"]
df["z"] = (df["factor"] - g.transform("mean")) / g.transform("std")

# 截面百分位排名
df["pct"] = df.groupby("trade_date")["factor"].rank(pct=True)

# 组内填充
df["pe"] = df["pe"].fillna(df.groupby("ts_code")["pe"].transform("mean"))

真实耗时(17 万行):

截面去均值(市场中性)      3.5 ms
行业内去均值(行业中性)    9.2 ms
截面百分位排名             22.2 ms

行业中性比市场中性慢,因为分组键从 565 个变成了 565 × 66 的组合。百分位排名最慢——排名本身就比求均值贵。

NOTE: 💡 注意分组键里有没有日期
上面所有截面操作的分组键都包含 trade_date。少了它就变成”跨越整个样本期算一个均值”,那不是截面中性化,是把不同时期的数据混在一起比较。
这是第 09 讲说过的:横截面因子必须在同一天的截面内做。


三、🤔 内置字符串 vs lambda:这里的差距比 agg 大得多

第 15 讲实测 agg 里 lambda 只慢一到三倍。transform 上完全不是这样:

分组键 组数 transform("mean") transform(lambda x: x.mean()) 倍数
industry 66 3.9 ms 10.1 ms 2.6×
trade_date 574 3.5 ms 25.6 ms 7.4×

而 z-score 这种要用两次统计量的操作,差距更大:

# 两个内置 transform 组合
(df["pct_chg"] - g.transform("mean")) / g.transform("std")        # 0.9 ms

# 一个 lambda 里算完
g.transform(lambda x: (x - x.mean()) / x.std())                   # 42.0 ms   → 慢 47 倍

慢 47 倍,而且结果完全一致。

为什么 transform 上差距这么大

agg 的 lambda 每组只返回一个数,transform 的 lambda 要返回一整组那么长的结果——构造、对齐、拼接的开销都在里面。而内置的 transform("mean") 走的是编译好的快路径,直接把结果按组号铺开。

IMPORTANT: 🔑 transform 里优先用字符串
transform("mean") / "std" / "sum" / "min" / "max" / "size" / "count" / "first" / "last" / "rank" 都有内置快路径。
需要组合多个统计量时(z-score、去均值除以标准差),拆成多次内置 transform 再做列运算,比写在一个 lambda 里快一个数量级。
这条建议比第 15 讲那条硬得多——agg 上是”能改就改”,transform 上是”应该改”。


四、🐛 transform 返回变长不报错,而是补 NaN

transform 要求返回同长度或标量。但如果你返回了别的长度,它不会报错:

d.groupby("g")["v"].transform(lambda x: x.head(2))
原表 10 行,2 组
结果 [0.0, 1.0, nan, nan, nan, 5.0, 6.0, nan, nan, nan]

它把返回的短结果按索引对齐回原表,对不上的位置补 NaN。长度仍然是 10,看起来”正常”。

d.groupby("g")["v"].transform(lambda x: x[x > 2])
结果 [nan, nan, nan, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]

同样是按索引对齐,被过滤掉的位置变成 NaN。

对照 agg 的行为:

d.groupby("g")["v"].agg(lambda x: x.head(2))
# ValueError: Must produce aggregated value

agg 会直接报错,transform 不会。

WARNING: ⚠️ 这是个静默的坑
你在 lambda 里不小心做了过滤(x[x > 0]x.dropna()x.head(n)),transform 照样返回一个长度正确的结果,只是被过滤掉的行悄悄变成了 NaN。
结果的形状是对的,len() 检查不出来。要检查的是缺失数量

out = g.transform(f)
print(out.isna().sum(), df["原列"].isna().sum())    # 两者应该相当

如果 transform 之后缺失突然多了一大截,八成是 lambda 里做了不该做的过滤。


五、🐛 组内 ffill 之前必须排序

第 09 讲讲过 groupby 保持组内的原始行顺序。这个规律在 ffill 上会造成一个很隐蔽的错误。

把数据打乱(模拟顺序不可靠的数据源),组内前向填充:

sh.groupby("ts_code")["pe"].ffill()                                    # 不排序
sh.sort_values(["ts_code","trade_date"]).groupby("ts_code")["pe"].ffill()  # 排序
不排序 ffill 后缺失   26,422
排序后 ffill 后缺失   39,239

两个结果不同,都不报错

反直觉的地方是:不排序的版本缺失更少

原因是打乱之后,每只股票组内的第一行可能是原本中间某天的记录(有值),后面的 NaN 就都被它填上了。而正确排序后,真正最早的那些天如果本来就缺失,前面没有值可以拿来填,只能保持 NaN。

所以不排序不是”填得更好”,是用时间上位于后面的值去填了前面的行——这就是未来函数。

IMPORTANT: 🔑 “缺失变少”不等于”数据变好”
填充率上升有时候恰恰是错误的信号。判断标准不是填了多少,而是填的值来自哪里
涉及顺序的组内操作——ffill / bfill / shift / diff / pct_change / cumsum / rolling——之前一律先 sort_values(["ts_code", "trade_date"])


六、🧰 组内填充的两种写法

# 写法一:在 lambda 里 fillna
df.groupby("ts_code")["pe"].transform(lambda x: x.fillna(x.mean()))    # 18.1 ms

# 写法二:先 transform 出均值,再 fillna
df["pe"].fillna(df.groupby("ts_code")["pe"].transform("mean"))          # 4.5 ms   → 快 4 倍

结果一样,写法二快 4 倍——又是”把 lambda 拆成内置”的同一个套路。

写法二还有个好处:transform("mean") 的结果可以留下来复用,比如同时用于填充和计算偏离度。

🎮 随堂快练

QUESTION: 你要把每只股票缺失的 PE 填成它所属行业当天的中位数(而不是自己历史的均值)。写出来。
TIP: 👉 答案

med = df.groupby(["trade_date", "industry"], observed=True)["pe"].transform("median")
df["pe"] = df["pe"].fillna(med)

两个要点:① 分组键要同时含日期和行业——用整个样本期的行业中位数去填某一天,等于混用了不同时期的估值水平;② transform("median") 走内置,别写成 transform(lambda x: x.median())
还要想一层:这样填出来的 PE 是个估计值,不是真实数据。做因子时最好另开一列标记哪些是填出来的,避免后面分析时把它们当成真实观测。


七、🤔 三个出口怎么选

第 07 讲给过对照表,这里补上性能维度:

返回形状 性能 用在什么时候
agg 每组一行 内置最快,lambda 慢 1~3× 做汇总表
transform 和原表一样 内置最快,lambda 慢 2.6~47× 结果要贴回每一行
apply 任意 最慢 确实要看整个子表(组内回归、组内排序取特定行)

顺序是:能用 agg/transform 就别用 apply;用 transform 时能用字符串就别用 lambda。


🏋️ 训练营

QUESTION: 🟢 训练 1:给一份含 trade_dateindustryfactor 的表,写出:① 因子的每日截面 z-score ② 因子在每日行业内的百分位排名
TIP: 👉 参考

g = df.groupby("trade_date")["factor"]
df["z"] = (df["factor"] - g.transform("mean")) / g.transform("std")          # ①

df["pct"] = df.groupby(["trade_date", "industry"],                            # ②
                       observed=True)["factor"].rank(pct=True)

① 用两次内置 transform 而不是一个 lambda,快 47 倍。
② 分组键必须含 trade_date,否则就是跨期比较。observed=True 防止分类列产生空组(第 11 讲)。

QUESTION: 🟡 训练 2:下面这行代码想算”每只股票剔除极端值后的均价”,结果里出现了大量 NaN。解释原因。

df["clean_mean"] = df.groupby("ts_code")["close"].transform(
    lambda x: x[x < x.quantile(0.99)].mean())

TIP: 👉 参考
这里其实不会产生 NaN——.mean() 让 lambda 返回的是一个标量transform 会把它广播到整组,是正确的。
但如果漏掉最后的 .mean()

lambda x: x[x < x.quantile(0.99)]        # 返回的是过滤后的变长 Series

transform 不会报错,而是按索引对齐——被过滤掉的那 1% 极端值位置变成 NaN,其余保留原值。结果长度正确,len() 检查不出问题。
这就是本讲第四节那个坑:agg 遇到变长返回会 ValueErrortransform 只会静默补 NaN
排查方法:比较 transform 前后的缺失数量,突然多出一截就是这个原因。

QUESTION: 🔴 训练 3:构造一个”行业中性 + 市值中性”的动量因子:20 日收益率,先剔除行业均值,再剔除市值分档均值,最后做截面标准化。指出三处如果做错会静默出错的地方。
TIP: 👉 参考

df = df.sort_values(["ts_code", "trade_date"])                     # ①
df["mom"] = df.groupby("ts_code")["close"].pct_change(
                20, fill_method=None)                              # ②

df["mv_q"] = df.groupby("trade_date")["total_mv"].transform(
                lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))

for key in [["trade_date", "industry"], ["trade_date", "mv_q"]]:   # ③
    df["mom"] -= df.groupby(key, observed=True)["mom"].transform("mean")

g = df.groupby("trade_date")["mom"]
df["factor"] = (df["mom"] - g.transform("mean")) / g.transform("std")

三处静默出错的地方
① 不排序就 pct_changegroupby 保持组内原序,数据源顺序不保证按时间,结果全错且不报错(第 09 讲实测 67% 的日涨幅超过涨停板)。
② 漏掉 fill_method=Nonepct_change 默认会先填充缺失再算,在停牌处造出假收益(第 14 讲)。
③ 中性化的分组键漏掉 trade_date:只按 industry 分组等于用整个样本期的行业均值去减,跨期混用。
另外这里的分档 transform(lambda ...) 是少数不得不用 lambda 的情况——qcut 没有内置的 transform 字符串形式。这时候慢一点是合理代价,但要清楚它慢在哪。


🐛 常见坑

  • ⚠️ 该用 transform 的地方用了 agg:形状对不上,还要手工 merge 回来。要贴回原表就用 transform
  • ⚠️ transform 里写 lambda:内置字符串快 2.6~47 倍。组合多个统计量时拆成多次内置 transform
  • ⚠️ transform 返回变长不报错:按索引对齐、其余补 NaN,长度看起来正常。检查缺失数量而不是长度。
  • ⚠️ 截面操作的分组键漏掉日期:变成跨期比较,因子含义完全不同。
  • ⚠️ 组内 ffill 前没排序:会用时间上靠后的值填前面的行,是未来函数。而且”缺失变少”看起来还像是好事。
  • ⚠️ 填充值没有标记:填出来的是估计值不是观测值,后续分析里最好能区分。
  • ⚠️ 多列分组忘了 observed=True:分类列产生全组合空组(第 11 讲)。

✍️ 作业

  1. 对同一份数据分别做 groupby(...).mean()groupby(...).transform("mean"),打印两者的形状,并说明第二个结果为什么可以直接和原表的列相减。
  2. transform("mean")transform(lambda x: x.mean()) 各算一次,在两个组数相差较大的分组键上分别计时,记录倍数。和第 15 讲 agg 的倍数对比,说明为什么 transform 上差距更大。
  3. 用两次内置 transform 组合和一个 lambda 各算一次截面 z-score,计时并用 np.allclose 验证结果一致。
  4. 复现第四节:写一个返回变长结果的 transform(比如 lambda x: x[x > x.median()]),打印结果长度和缺失数量。再把同样的函数传给 agg,把报错信息抄下来。
  5. 把一份行情数据打乱行顺序,分别在排序和不排序的情况下做组内 ffill,比较两者的缺失数量。挑一只股票,把它排序前后的填充结果并排打印,找出被”未来的值”填错的行。
  6. 思考题:本讲说 transform 要求返回同长度或标量。那么 groupby(...).rank() 属于哪一种?它和 transform("rank") 有区别吗?(提示:跑一下 df.groupby(k)["x"].rank()df.groupby(k)["x"].transform("rank"),比较形状和结果。)

🔮 下讲预告:第 17 讲——透视表与交叉表。第 14 讲用 pivot_table 做过一次双重分组的收益表,下一讲把它讲完整:多层行列、margins 总计、多个聚合函数并存,以及因子分析里那张”行业 × 市值档”的标准表格怎么一次做出来。


← 上一讲  ·  返回课程  ·  下一讲 →