排序学习与模型组合
本章目录
学完后,你应能独立完成
- 定义日期query与pairwise目标
- 构造过去训练、未来预测的OOF
- 比较平均、rank averaging与stacking
前置建议:时间验证与模型选择 · Logistic 与分类决策
改变一个条件,观察发生什么
浏览器内即时教学计算;Python 实验结果另列于代码区。图形与数值一起核对。
排序目标为什么需要日期query
截面选股在同日比较股票。Pointwise直接回归收益或等级,pairwise学习同日股票对先后,listwise关注整个列表。连续有符号收益不能不核对库要求就直接当NDCG相关性等级;很多实现要求非负等级。
query的行应连续、group长度之和等于总行数,日期形成query。跨日混组可能主要学到市场水平。标签分档用未来收益可以,但不可把未来分位或阈值传回输入。
一对股票的损失如何下降
如果y_i>y_j,期望s_i−s_j更大。Logistic pairwise损失在分数差已经较大时梯度变小。它优化次序,不保证收益校准或成本后收益;还要面对同日股票对相关与样本权重。
- 设margin=s_i−s_j。
- 对margin求导为−1/(1+exp(margin))。
- 对s_i和s_j的梯度相反。
- 下降方向提高s_i、降低s_j;分数单位与温度会影响梯度尺度。
平均为什么可能有用
如果模型误差不完全共享,加权平均可能减少非共同部分。先统一输出含义和尺度,再平均。rank averaging只合次序,丢掉收益水平;两个不同模型名可能预测几乎一样,因此名称多样不等于误差多样。
权重若根据验证表现选择,也属于搜索。组合可能减弱最好单模型,尤其在低信噪比、样本短时,不应只展示组合胜出的结果。
stacking的输入必须来自合法OOF
先在成熟过去训练基模型,在后续日期保存预测。元模型只学习这些真正留出的输入,并等待相应目标成熟。早期无OOF就留空,不能用训练内拟合值补齐;所有基模型按同一日期股票键对齐。
元模型开发验证也需要独立的时间边界。后来重训基模型是预先规定的政策,但最终输入分布与历史OOF可能有差异。标准库默认随机K-fold不自动满足股票在线目标。
组合也要检查条件增量
同评价集合比较单模型、简单平均与stacking,查看逐日配对差、覆盖、分时期和风险暴露。低相关可能是噪声,高误差相关说明平均难消除共享错误。开发里比较了多少组合方式都应记录。
本页Python示例真正构造两段时间OOF,元训练在下一段之前过滤成熟标签;最终段只评价固定元模型。示例属于合成流程,不声称现实组合一定有增量。
从算法到一次真实运行
代码在本地 Python 中执行,HTML 内展示参考实现和本轮运行结果。请先独立完成练习,再展开完整参考文件。
def make_panel(seed=42, T=180, N=24, k=5, mechanism="linear"):
"""Illustrative weekdays, 6 AR factors, future open-to-open k-day label."""
if mechanism not in {"null","linear","interaction","regime"}:
raise ValueError("Unknown mechanism")
rng=np.random.default_rng(seed)
dates=pd.bdate_range("2020-01-02",periods=T)
factors=np.empty((T,N,6)); factors[0]=rng.normal(size=(N,6))
for t in range(1,T):
factors[t]=.6*factors[t-1]+.8*rng.normal(size=(N,6))
factors[:,:,3]=.9*factors[:,:,0]+.15*rng.normal(size=(T,N))
prices=np.full((T,N),100.)
for t in range(T-1):
x=factors[max(0,t-1)]
signal=np.zeros(N)
if mechanism!="null": signal=.0015*x[:,0]
if mechanism in {"interaction","regime"}: signal += .0015*x[:,1]*x[:,2]
if mechanism=="regime" and t>=int(.65*T): signal -= .002*x[:,0]
noise=.006*rng.standard_t(5,N)/np.sqrt(5/3)+rng.normal(0,.002)
prices[t+1]=prices[t]*np.exp(signal+noise)
panel=pd.DataFrame(factors.reshape(T*N,6),columns=[f"x{i}" for i in range(6)])
panel["date"]=np.repeat(dates,N); panel["stock"]=np.tile([f"S{i:03}" for i in range(N)],T)
panel["tpos"]=np.repeat(np.arange(T),N); panel["open"]=prices.ravel()
panel["signal_time"]=panel.date+pd.Timedelta(hours=15)
panel["feature_available_time"]=panel.signal_time
labels=panel.groupby("stock").open
panel["y"]=labels.shift(-(k+1))/labels.shift(-1)-1
exit_dates=pd.Series(dates).shift(-(k+1)).to_numpy()[panel.tpos]
panel["label_available_time"]=exit_dates+pd.Timedelta(hours=9,minutes=30)
return panel
def validate_panel(frame):
if frame.duplicated(["date","stock"]).any():
raise ValueError("Duplicate (date,stock) keys")
if frame[["signal_time","feature_available_time"]].isna().any().any():
raise ValueError("Missing availability timestamps")
if (frame.feature_available_time>frame.signal_time).any():
raise ValueError("Feature was unavailable at decision time")
if (frame.y.notna() & frame.label_available_time.isna()).any():
raise ValueError("Known label has unknown availability time")
def time_fold(frame, start, end, train_start=0):
"""Train at prior signal day's 15:00; dates [start,end) are held out."""
validate_panel(frame)
dates=frame.date.drop_duplicates().sort_values().reset_index(drop=True)
if not 1<=start<end<=len(dates) or not 0<=train_start<start:
raise ValueError("Invalid date fold")
cutoff=dates.iloc[start-1]+pd.Timedelta(hours=15)
train=frame[(frame.date>=dates.iloc[train_start]) & (frame.date<dates.iloc[start])
& frame.y.notna() & (frame.label_available_time<=cutoff)].copy()
valid=frame[frame.date.isin(dates.iloc[start:end]) & frame.y.notna()].copy()
if train.empty or valid.empty: raise ValueError("Empty fold")
return train,valid,cutoff
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 make_panel(seed=42, T=180, N=24, k=5, mechanism="linear"):
"""Illustrative weekdays, 6 AR factors, future open-to-open k-day label."""
if mechanism not in {"null","linear","interaction","regime"}:
raise ValueError("Unknown mechanism")
rng=np.random.default_rng(seed)
dates=pd.bdate_range("2020-01-02",periods=T)
factors=np.empty((T,N,6)); factors[0]=rng.normal(size=(N,6))
for t in range(1,T):
factors[t]=.6*factors[t-1]+.8*rng.normal(size=(N,6))
factors[:,:,3]=.9*factors[:,:,0]+.15*rng.normal(size=(T,N))
prices=np.full((T,N),100.)
for t in range(T-1):
x=factors[max(0,t-1)]
signal=np.zeros(N)
if mechanism!="null": signal=.0015*x[:,0]
if mechanism in {"interaction","regime"}: signal += .0015*x[:,1]*x[:,2]
if mechanism=="regime" and t>=int(.65*T): signal -= .002*x[:,0]
noise=.006*rng.standard_t(5,N)/np.sqrt(5/3)+rng.normal(0,.002)
prices[t+1]=prices[t]*np.exp(signal+noise)
panel=pd.DataFrame(factors.reshape(T*N,6),columns=[f"x{i}" for i in range(6)])
panel["date"]=np.repeat(dates,N); panel["stock"]=np.tile([f"S{i:03}" for i in range(N)],T)
panel["tpos"]=np.repeat(np.arange(T),N); panel["open"]=prices.ravel()
panel["signal_time"]=panel.date+pd.Timedelta(hours=15)
panel["feature_available_time"]=panel.signal_time
labels=panel.groupby("stock").open
panel["y"]=labels.shift(-(k+1))/labels.shift(-1)-1
exit_dates=pd.Series(dates).shift(-(k+1)).to_numpy()[panel.tpos]
panel["label_available_time"]=exit_dates+pd.Timedelta(hours=9,minutes=30)
return panel
def validate_panel(frame):
if frame.duplicated(["date","stock"]).any():
raise ValueError("Duplicate (date,stock) keys")
if frame[["signal_time","feature_available_time"]].isna().any().any():
raise ValueError("Missing availability timestamps")
if (frame.feature_available_time>frame.signal_time).any():
raise ValueError("Feature was unavailable at decision time")
if (frame.y.notna() & frame.label_available_time.isna()).any():
raise ValueError("Known label has unknown availability time")
def time_fold(frame, start, end, train_start=0):
"""Train at prior signal day's 15:00; dates [start,end) are held out."""
validate_panel(frame)
dates=frame.date.drop_duplicates().sort_values().reset_index(drop=True)
if not 1<=start<end<=len(dates) or not 0<=train_start<start:
raise ValueError("Invalid date fold")
cutoff=dates.iloc[start-1]+pd.Timedelta(hours=15)
train=frame[(frame.date>=dates.iloc[train_start]) & (frame.date<dates.iloc[start])
& frame.y.notna() & (frame.label_available_time<=cutoff)].copy()
valid=frame[frame.date.isin(dates.iloc[start:end]) & frame.y.notna()].copy()
if train.empty or valid.empty: raise ValueError("Empty fold")
return train,valid,cutoff
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__":
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
panel=make_panel(T=180,N=24,mechanism="interaction")
features=[f"x{i}" for i in range(6)];oof=[]
for start,end in [(80,105),(105,130)]:
train,valid,_=time_fold(panel,start,end)
saved=valid[["date","stock","y","label_available_time"]].copy()
for j,lam in enumerate([.01,1.]):
model=make_pipeline(StandardScaler(),Ridge(alpha=len(train)*lam)).fit(train[features],train.y)
saved[f"p{j}"]=model.predict(valid[features])
oof.append(saved)
meta_data=pd.concat(oof)
train,test,cutoff=time_fold(panel,130,174)
meta_data=meta_data[meta_data.label_available_time<=cutoff]
meta=Ridge(alpha=.01).fit(meta_data[["p0","p1"]],meta_data.y)
future=test[["date","stock"]].copy()
for j,lam in enumerate([.01,1.]):
model=make_pipeline(StandardScaler(),Ridge(alpha=len(train)*lam)).fit(train[features],train.y)
future[f"p{j}"]=model.predict(test[features])
future["pred"]=meta.predict(future[["p0","p1"]])
metrics,_=keyed_metrics(test,future)
margin=.7-.2
pair_loss=np.logaddexp(0,-margin)
pair_grad=np.array([-expit(-margin),expit(-margin)])
print_result({"meta_train_rows":len(meta_data),"latest_meta_label":str(meta_data.label_available_time.max()),
"cutoff":str(cutoff),"stacking_metrics":metrics,"pairwise_loss":pair_loss,"pairwise_gradient":pair_grad})
查看本轮本地运行记录 · 2026-10-12
{
"meta_train_rows": 1056,
"latest_meta_label": "2020-07-01 09:30:00",
"cutoff": "2020-07-01 15:00:00",
"stacking_metrics": {
"mse": 0.00025155172607686224,
"ic": 0.2576110926231855,
"rank_ic": 0.22529644268774698,
"coverage": 1.0,
"rows": 1056,
"dates": 44
},
"pairwise_loss": 0.4740769841801067,
"pairwise_gradient": [
-0.3775406687981454,
0.3775406687981454
]
}先回答,再看提示和答案
model1训练内预测加真实y训练元模型,哪里危险?
一级提示
比较训练拟合值与未来预测的误差。
参考答案与条件
输入误差分布过于乐观,未来基模型不具有同等拟合程度;需成熟过去训练得到的未来OOF。
日期query长度[100,100]用于201行合法吗?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
不合法,group总和必须等于样本数,且每组行要与日期排序对应。
同日收益全加常数,收益排序会改变吗?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
不改变;但绝对收益回归水平改变。排序与水平目标不同。
把知识迁移到一个新任务
写pairwise损失和两个分数的梯度;再给一个面板保存时间OOF,训练元模型时过滤成熟标签。核对所有模型键集合并报告最早缺失OOF的日期。
这里保存学习笔记,不在浏览器执行 Python。A/B/C 由你按独立完成程度填写。
参考与进一步阅读
在线文档可能比本地固定版本更新;API 行为以已验证代码和版本记录为准。
掌握状态与复习
关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。
记录尽量保存在当前浏览器,建议定期导出备份。