特征工程与模型增量
本章目录
学完后,你应能独立完成
- 区分训练学习的变换与当日截面规则
- 实现截面中性化并解释它的边界
- 检查相关组、组消融和逐日配对增量
前置建议:时间验证与模型选择 · Ridge、Lasso 与因子合成
改变一个条件,观察发生什么
浏览器内即时教学计算;Python 实验结果另列于代码区。图形与数值一起核对。
两类标准化是不同的信息集
训练 scaler存储过去数据的均值和尺度;预测时只 transform。当日完整可得截面的 rank或z-score则可是一项预先固定的在线规则。前者不能在全时期 fit,后者不能把未来股票资格或尚未到齐的数据当作当日集合。
同日单因子做正仿射变换 af+b,a>0,且有效集合相同、非退化时,Pearson IC与Rank IC均不变。多因子中的相对尺度、正则与距离却会改变。页面实验同时显示相关和 MSE,以免把“缩放后好看”误当增量。
缺失、异常与类别编码
先查缺失来自未上市、无交易、供应商晚到还是字段本身没有披露;填补不能恢复未知真实值。训练均值/中位数用于之后缺失,不能按测试分布重算。缺失指示器可以建模缺失状态,但仍须排除时点问题。
异常值先查数据错误,再按事先规则截尾或稳健处理;不能事后删掉亏损样本。行业整数给线性模型人为距离,可用one-hot。目标编码直接使用标签,需成熟过去或时间合法OOF及明确的未知类别回退。
截面中性化到底去掉了什么
在同日用已知规模、行业等暴露回归因子,取残差。投影消掉与所用暴露设计空间的线性关系;它可以减小风格暴露,也可能去掉有用信息。非线性或遗漏暴露不自动被消除。
因子中性不等于最后持仓中性:排序、截断、可买集合和权重政策会重新改变暴露。组合风险需要在最终权重层面检查。下面实现保留同日期原索引,样本不足时给NA而不是编一个残差。
特征选择属于模型选择
过滤用单变量统计,包裹法用模型验证,嵌入法在优化中筛选。只要用标签,都必须在允许的训练/开发层完成。全数据 SelectKBest后再CV会让留出标签回到输入里;加Pipeline还需正确的日期切分。
低两两相关不等于对整个已有特征集合有条件增量;零相关也可能有非线性依赖。相关组的成员可互替,一列重要性低不代表整组无信息。看滚动入选频率和同集合的组消融。
模型解释与增量要分别验证
分裂重要性反映训练分裂贡献;置换衡量已训练模型对某列的依赖;SHAP在指定背景和缺失语义下分配预测贡献;PDP/ICE展示修改输入时模型如何变化。它们都不自动识别因果。
相关列的单独置换可能制造无支持的组合,也可能被替代列掩盖。组置换保留组内关系;重训组消融问另一类问题。股票面板要声明日期内、行业内还是时间区块置换,零假设不同。解释最终测试后再改模型会消耗该测试。
把“新模型更好”写成可复核比较
同日同股票集合先配对计算新旧指标差Δ_t,再观察均值、时序依赖、时期与风格分层。不同覆盖率下两个平均数不能直接比较。HAC或块bootstrap也依赖相应弱依赖与稳定条件,短样本和状态变化下不能盲信区间。
记录所有候选与失败尝试,给成本、换手和暴露分析留出接口。低相关的新分数可能只是噪声,复杂模型也可能只学到规模效应。增量的判断来自合法验证,而不是漂亮的解释图。
从算法到一次真实运行
代码在本地 Python 中执行,HTML 内展示参考实现和本轮运行结果。请先独立完成练习,再展开完整参考文件。
def cross_section_neutralize(frame, factor="factor", exposures=("size",)):
"""Same-date observed exposures only; residual factor, not portfolio constraints."""
if frame.duplicated(["date","stock"]).any(): raise ValueError("Duplicate keys")
result=pd.Series(index=frame.index,dtype=float)
for _,g in frame.groupby("date"):
X=g[list(exposures)].to_numpy(float); y=g[factor].to_numpy(float)
valid=np.isfinite(X).all(axis=1)&np.isfinite(y)
design=np.column_stack([np.ones(valid.sum()),X[valid]])
if valid.sum()<=design.shape[1]: continue
coef=np.linalg.lstsq(design,y[valid],rcond=None)[0]
result.loc[g.index[valid]]=y[valid]-design@coef
return result
def keyed_metrics(frame, predictions):
"""predictions is a (date,stock,pred) table; exact keys required, order free."""
if frame.duplicated(["date","stock"]).any() or predictions.duplicated(["date","stock"]).any():
raise ValueError("Duplicate prediction/evaluation keys")
aligned=frame[["date","stock","y"]].merge(predictions[["date","stock","pred"]],
on=["date","stock"],how="outer",validate="1:1",indicator=True)
if not aligned._merge.eq("both").all(): raise ValueError("Prediction keys do not match")
daily=[]
for date,group in aligned.groupby("date"):
finite=np.isfinite(group.y)&np.isfinite(group.pred)
g=group.loc[finite]; ic=rank_ic=spread=np.nan
if len(g)>=3 and g.y.nunique()>1 and g.pred.nunique()>1:
ic=g.y.corr(g.pred); rank_ic=g.y.rank().corr(g.pred.rank())
ordered=g.sort_values(["pred","stock"]); size=max(1,len(g)//5)
spread=ordered.tail(size).y.mean()-ordered.head(size).y.mean()
daily.append({"date":date,"n":len(g),"ic":ic,"rank_ic":rank_ic,"spread":spread})
table=pd.DataFrame(daily)
valid=np.isfinite(aligned.y)&np.isfinite(aligned.pred)
mse=float(np.mean((aligned.loc[valid,"y"]-aligned.loc[valid,"pred"])**2)) if valid.any() else np.nan
return {"mse":mse,"ic":float(table.ic.mean()),"rank_ic":float(table.rank_ic.mean()),
"coverage":float(table.ic.notna().mean()),"rows":int(valid.sum()),"dates":len(table)},table"""Standalone synthetic teaching example. Run locally with Python, not in the HTML."""
import json
import numpy as np
import pandas as pd
from scipy.special import expit
def clean(value):
if isinstance(value, dict): return {str(k):clean(v) for k,v in value.items()}
if isinstance(value,(list,tuple,np.ndarray)): return [clean(v) for v in value]
if isinstance(value,(np.integer,)): return int(value)
if isinstance(value,(float,np.floating)): return float(value) if np.isfinite(value) else None
if isinstance(value,(np.bool_,)): return bool(value)
return value
def print_result(result):
print(json.dumps(clean(result),ensure_ascii=False,allow_nan=False))
def cross_section_neutralize(frame, factor="factor", exposures=("size",)):
"""Same-date observed exposures only; residual factor, not portfolio constraints."""
if frame.duplicated(["date","stock"]).any(): raise ValueError("Duplicate keys")
result=pd.Series(index=frame.index,dtype=float)
for _,g in frame.groupby("date"):
X=g[list(exposures)].to_numpy(float); y=g[factor].to_numpy(float)
valid=np.isfinite(X).all(axis=1)&np.isfinite(y)
design=np.column_stack([np.ones(valid.sum()),X[valid]])
if valid.sum()<=design.shape[1]: continue
coef=np.linalg.lstsq(design,y[valid],rcond=None)[0]
result.loc[g.index[valid]]=y[valid]-design@coef
return result
def keyed_metrics(frame, predictions):
"""predictions is a (date,stock,pred) table; exact keys required, order free."""
if frame.duplicated(["date","stock"]).any() or predictions.duplicated(["date","stock"]).any():
raise ValueError("Duplicate prediction/evaluation keys")
aligned=frame[["date","stock","y"]].merge(predictions[["date","stock","pred"]],
on=["date","stock"],how="outer",validate="1:1",indicator=True)
if not aligned._merge.eq("both").all(): raise ValueError("Prediction keys do not match")
daily=[]
for date,group in aligned.groupby("date"):
finite=np.isfinite(group.y)&np.isfinite(group.pred)
g=group.loc[finite]; ic=rank_ic=spread=np.nan
if len(g)>=3 and g.y.nunique()>1 and g.pred.nunique()>1:
ic=g.y.corr(g.pred); rank_ic=g.y.rank().corr(g.pred.rank())
ordered=g.sort_values(["pred","stock"]); size=max(1,len(g)//5)
spread=ordered.tail(size).y.mean()-ordered.head(size).y.mean()
daily.append({"date":date,"n":len(g),"ic":ic,"rank_ic":rank_ic,"spread":spread})
table=pd.DataFrame(daily)
valid=np.isfinite(aligned.y)&np.isfinite(aligned.pred)
mse=float(np.mean((aligned.loc[valid,"y"]-aligned.loc[valid,"pred"])**2)) if valid.any() else np.nan
return {"mse":mse,"ic":float(table.ic.mean()),"rank_ic":float(table.rank_ic.mean()),
"coverage":float(table.ic.notna().mean()),"rows":int(valid.sum()),"dates":len(table)},table
if __name__ == "__main__":
rng=np.random.default_rng(42)
frame=pd.DataFrame({"date":np.repeat(pd.date_range("2020-01-02",periods=3),12),
"stock":np.tile([f"S{i}" for i in range(12)],3),"size":rng.normal(size=36)})
frame["factor"]=2+3*frame["size"]+rng.normal(size=36)
neutral=cross_section_neutralize(frame)
orthogonality=[]
for _,g in frame.groupby("date"):
design=np.column_stack([np.ones(len(g)),g["size"]])
orthogonality.append(abs(design.T@neutral.loc[g.index]).max())
assert max(orthogonality)<1e-10
panel=frame.rename(columns={"factor":"y"})
pred=panel[["date","stock"]].assign(pred=panel["size"])
original,_=keyed_metrics(panel,pred)
transformed,_=keyed_metrics(panel,pred.assign(pred=7*pred.pred+3))
np.testing.assert_allclose(original["ic"],transformed["ic"],atol=1e-12)
print_result({"neutralization_max_inner_product":max(orthogonality),
"original_ic":original["ic"],"transformed_ic":transformed["ic"],
"original_mse":original["mse"],"transformed_mse":transformed["mse"]})
查看本轮本地运行记录 · 2026-10-12
{
"neutralization_max_inner_product": 2.0761170560490427e-14,
"original_ic": 0.9490713941477673,
"transformed_ic": 0.9490713941477673,
"original_mse": 7.247994713924744,
"transformed_mse": 14.349298979063393
}先回答,再看提示和答案
因子做每日 z-score,单因子 IC必然提高吗?
一级提示
写出 corr(af+b,y)。
参考答案与条件
同日正仿射且集合不变时,Pearson和Spearman均不变。多因子学习与惩罚可能改变,但不能外推成必提高。
因子对行业中性,组合也行业中性吗?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
不保证。排序、截断、可交易集合和持仓权重都会改变最终暴露,需在权重层面检查。
新因子与旧因子相关很低,是否必有增量?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
不是,可能只是噪声。需在同样时间政策和留出集合中检验加入后的条件增量。
把知识迁移到一个新任务
造同日规模与因子数据,做截面回归并检查残差与所用设计列的内积。随后加入一列高相关替代因子,比较单列置换、组置换和重训组消融的不同问题。
这里保存学习笔记,不在浏览器执行 Python。A/B/C 由你按独立完成程度填写。
参考与进一步阅读
在线文档可能比本地固定版本更新;API 行为以已验证代码和版本记录为准。
掌握状态与复习
关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。
记录尽量保存在当前浏览器,建议定期导出备份。