📎 配套代码:
第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 这条路走完整——因子中性化、截面标准化、组内排名、组内填充,量化里的这些操作全靠它。
🎯 这一讲结束时,你能
- 说清
agg和transform的形状差别,并知道什么时候需要”贴回原表” - 写出量化里最常用的几个组内变换:市场中性、行业中性、截面标准化、截面排名
- 判断该用内置字符串还是 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_date、industry、factor的表,写出:① 因子的每日截面 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遇到变长返回会ValueError,transform只会静默补 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_change:groupby保持组内原序,数据源顺序不保证按时间,结果全错且不报错(第 09 讲实测 67% 的日涨幅超过涨停板)。
② 漏掉fill_method=None:pct_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 讲)。
✍️ 作业
- 对同一份数据分别做
groupby(...).mean()和groupby(...).transform("mean"),打印两者的形状,并说明第二个结果为什么可以直接和原表的列相减。 - 用
transform("mean")和transform(lambda x: x.mean())各算一次,在两个组数相差较大的分组键上分别计时,记录倍数。和第 15 讲agg的倍数对比,说明为什么transform上差距更大。 - 用两次内置
transform组合和一个 lambda 各算一次截面 z-score,计时并用np.allclose验证结果一致。 - 复现第四节:写一个返回变长结果的
transform(比如lambda x: x[x > x.median()]),打印结果长度和缺失数量。再把同样的函数传给agg,把报错信息抄下来。 - 把一份行情数据打乱行顺序,分别在排序和不排序的情况下做组内
ffill,比较两者的缺失数量。挑一只股票,把它排序前后的填充结果并排打印,找出被”未来的值”填错的行。 - 思考题:本讲说
transform要求返回同长度或标量。那么groupby(...).rank()属于哪一种?它和transform("rank")有区别吗?(提示:跑一下df.groupby(k)["x"].rank()和df.groupby(k)["x"].transform("rank"),比较形状和结果。)
🔮 下讲预告:第 17 讲——透视表与交叉表。第 14 讲用
pivot_table做过一次双重分组的收益表,下一讲把它讲完整:多层行列、margins总计、多个聚合函数并存,以及因子分析里那张”行业 × 市值档”的标准表格怎么一次做出来。