跳转到内容

因子研究综合实验

机器学习 · 机器学习 · P0
本章目录

学完后,你应能独立完成

  • 运行预先规定的多机制合成实验
  • 解释开发选择、静态最终训练和评价覆盖
  • 交付一页研究报告并说明真实市场迁移限制

前置建议:特征工程与模型增量 · GBDT、XGBoost 与 LightGBM

改变一个条件,观察发生什么

浏览器内即时教学计算;Python 实验结果另列于代码区。图形与数值一起核对。

01

本案例的具体研究问题

给定6个已知因子,对同日股票未来五日开盘到开盘收益预测并排序。数据为180个示意工作日×24股;因子具有AR(.6)持久性,一列与第一列高度相关。价格由过去已知因子的条件漂移及厚尾、共同噪声递推。没有读取本地行情。

四种机制分别是空信号、纯线性、线性加交互、后期线性关系改变。它们用于理解与排错,不代表现实市场规律。数据的状态转折是生成器设定,研究算法不能读取“机制名称”作为特征。

02

先锁定候选与选择政策

开发验证日期位置[80,105)、[105,130);最终测试[130,174),尾部六个无完整五日标签的日期不评分。每个开发折在前一信号日收盘训练,并筛成熟标签。

Ridge候选λ=.01/1,折内StandardScaler;HistGBDT候选15/40轮、7叶、min_samples_leaf=25、学习率.08,关闭内部早停。每模型2候选×2折,每机制共8个开发拟合。平均逐日Rank IC选参数,最终测试前各重训一次,不根据测试再改候选。

03

合理基线与评价集合

单因子预先用x0;等权预先用x0/x1/x2,按训练均值与尺度处理,再在训练内校准成收益单位。Ridge与GBDT使用全部6列。信息集合法,明确特征子集,四模型在完全相同的最终股票日期与标签上评价。

这不是给复杂模型刻意找弱对照:单因子可能表现最好,等权也可能没有增量。更完整的现实研究还需比较经过合理筛选的等权、显式交互的Ridge和不同经济假设,且都算选择压力。

04

页面结果来自本地真实运行

下面表格来自本轮Python脚本运行后的保存记录,seed固定42。模型评分是合成实验上的结果,数值不是手工填写。空机制的单次有限样本IC未必恰好零;交互机制也不保证GBDT在任意样本中获胜。

检查MSE、IC、Rank IC、评分覆盖和测试日期数。展示不同机制的全部结果,避免挑最好的一次来证明通用模型排名。算法只看到训练数据,机制切换在教学界面用于查看不同实验。

05

最终结果如何解释而不夸大

先描述固定政策和固定集合的结果,再提出替代解释。Δ_t逐日配对后可做依赖稳健分析;还要看测试长度、行业/规模、状态、随机性和搜索次数。本案例最终44日,只够作为流程演示。

没有可交易的持仓净值:五日诊断值不能直接日频连乘。费用、停牌、价格限制、历史成分、复权、交易与风险约束尚未落地,不能从这组IC宣布盈利。失败的机制或模型也要进入报告。

06

迁移到你的股票研究

先定义本地数据接口:历史股票资格、OHLCV及复权、实际可得时点、交易日历、标签与可买卖标记。重建原始小样本手核,再运行预先约定的单因子、等权、线性与非线性对照。

一页报告按“假设—数据—时间线—基线—验证—结果—失败与限制—下一实验”组织。新增特征的理由应来自训练期或独立依据;新的最终结论需要新的未使用样本。求职讲述要说明个人判断与实现,不只背库参数。

从算法到一次真实运行

代码在本地 Python 中执行,HTML 内展示参考实现和本轮运行结果。请先独立完成练习,再展开完整参考文件。

research.py / 核心函数
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

def run_research(seed=42):
    """Small predeclared grid; future-date validation; one fixed final holdout."""
    from sklearn.pipeline import make_pipeline
    from sklearn.preprocessing import StandardScaler
    from sklearn.linear_model import Ridge
    from sklearn.ensemble import HistGradientBoostingRegressor
    outputs={}; features=[f"x{i}" for i in range(6)]
    folds=[(80,105),(105,130)]; final=(130,174)
    for mechanism in ["null","linear","interaction","regime"]:
        panel=make_panel(seed=seed,mechanism=mechanism)
        grids={"Ridge":[.01,1.],"GBDT":[15,40]}; selected={}; trial_log=[]
        def build(name,p,n):
            if name=="Ridge":return make_pipeline(StandardScaler(),Ridge(alpha=n*p))
            return HistGradientBoostingRegressor(max_iter=p,max_leaf_nodes=7,
                min_samples_leaf=25,learning_rate=.08,early_stopping=False,random_state=seed)
        for name,grid in grids.items():
            trials=[]
            for parameter in grid:
                scores=[]
                for start,end in folds:
                    tr,va,_=time_fold(panel,start,end)
                    model=build(name,parameter,len(tr)).fit(tr[features],tr.y)
                    pred=va[["date","stock"]].assign(pred=model.predict(va[features]))
                    score,_=keyed_metrics(va,pred);scores.append(score["rank_ic"])
                trials.append((np.mean(scores),parameter))
                trial_log.append({"model":name,"parameter":parameter,"fold_rank_ic":scores})
            selected[name]=max(trials,key=lambda v:v[0])[1]
        tr,test,cutoff=time_fold(panel,*final); results={}
        for name,parameter in selected.items():
            model=build(name,parameter,len(tr)).fit(tr[features],tr.y)
            pred=test[["date","stock"]].assign(pred=model.predict(test[features]))
            results[name]=keyed_metrics(test,pred)[0]
        # A predeclared single-factor and equal-weight baseline, train-only calibration.
        for name,cols in [("单因子",["x0"]),("等权",["x0","x1","x2"])]:
            mean,scale=tr[cols].mean(),tr[cols].std(ddof=0)
            ts=((tr[cols]-mean)/scale).mean(axis=1)
            vs=((test[cols]-mean)/scale).mean(axis=1)
            coef=np.linalg.lstsq(np.column_stack([np.ones(len(ts)),ts]),tr.y,rcond=None)[0]
            pred=test[["date","stock"]].assign(pred=coef[0]+coef[1]*vs)
            results[name]=keyed_metrics(test,pred)[0]
        outputs[mechanism]={"metrics":results,"selected":selected,"trials":trial_log,
            "cutoff":str(cutoff),"test_dates":44,"seed":seed,"data":"synthetic",
            "policy":"Frozen grid; 2 development folds; static final training; no trading NAV"}
    return outputs
下载本节 .pypython research.py依赖版本见首页,示例只使用合成数据。
查看本轮本地运行记录 · 2026-10-12
{
  "null": {
    "metrics": {
      "Ridge": {
        "mse": 0.0002024382539422924,
        "ic": 0.032828228577948045,
        "rank_ic": 0.0200197628458498,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "GBDT": {
        "mse": 0.00020262226515402202,
        "ic": 0.04344345092542356,
        "rank_ic": 0.031189543184989822,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "单因子": {
        "mse": 0.00020277079266334166,
        "ic": -0.04150114299478816,
        "rank_ic": -0.02990118577075098,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "等权": {
        "mse": 0.00020237435429299735,
        "ic": 0.023270767539945083,
        "rank_ic": 0.047411067193675875,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      }
    },
    "selected": {
      "Ridge": 1.0,
      "GBDT": 15
    },
    "trials": [
      {
        "model": "Ridge",
        "parameter": 0.01,
        "fold_rank_ic": [
          0.03530434782608694,
          0.05412173913043478
        ]
      },
      {
        "model": "Ridge",
        "parameter": 1.0,
        "fold_rank_ic": [
          0.030260869565217386,
          0.06194782608695651
        ]
      },
      {
        "model": "GBDT",
        "parameter": 15,
        "fold_rank_ic": [
          0.014061573828791736,
          0.030152666846221865
        ]
      },
      {
        "model": "GBDT",
        "parameter": 40,
        "fold_rank_ic": [
          -0.005909707796926499,
          -0.005338271931763443
        ]
      }
    ],
    "cutoff": "2020-07-01 15:00:00",
    "test_dates": 44,
    "seed": 42,
    "data": "synthetic",
    "policy": "Frozen grid; 2 development folds; static final training; no trading NAV"
  },
  "linear": {
    "metrics": {
      "Ridge": {
        "mse": 0.0002158594408726212,
        "ic": 0.2805537579177386,
        "rank_ic": 0.2676877470355731,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "GBDT": {
        "mse": 0.0002167885681356451,
        "ic": 0.2451015680796714,
        "rank_ic": 0.24150766773084734,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "单因子": {
        "mse": 0.00021395293380612485,
        "ic": 0.27151456091163106,
        "rank_ic": 0.26418972332015805,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "等权": {
        "mse": 0.0002233433696783631,
        "ic": 0.091735840968381,
        "rank_ic": 0.07229249011857707,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      }
    },
    "selected": {
      "Ridge": 1.0,
      "GBDT": 15
    },
    "trials": [
      {
        "model": "Ridge",
        "parameter": 0.01,
        "fold_rank_ic": [
          0.1873043478260869,
          0.1444173913043478
        ]
      },
      {
        "model": "Ridge",
        "parameter": 1.0,
        "fold_rank_ic": [
          0.19516521739130432,
          0.14890434782608694
        ]
      },
      {
        "model": "GBDT",
        "parameter": 15,
        "fold_rank_ic": [
          0.19080110356373606,
          0.09967646028675535
        ]
      },
      {
        "model": "GBDT",
        "parameter": 40,
        "fold_rank_ic": [
          0.17899456439012795,
          0.08417918558779139
        ]
      }
    ],
    "cutoff": "2020-07-01 15:00:00",
    "test_dates": 44,
    "seed": 42,
    "data": "synthetic",
    "policy": "Frozen grid; 2 development folds; static final training; no trading NAV"
  },
  "interaction": {
    "metrics": {
      "Ridge": {
        "mse": 0.000246534144898484,
        "ic": 0.2599026222431165,
        "rank_ic": 0.2362055335968379,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "GBDT": {
        "mse": 0.0002426443938107238,
        "ic": 0.2728217425785496,
        "rank_ic": 0.25081812422946637,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "单因子": {
        "mse": 0.00024458576483804963,
        "ic": 0.25498628672647655,
        "rank_ic": 0.24037549407114625,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "等权": {
        "mse": 0.0002536787962981721,
        "ic": 0.08624675454051949,
        "rank_ic": 0.07201581027667986,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      }
    },
    "selected": {
      "Ridge": 1.0,
      "GBDT": 40
    },
    "trials": [
      {
        "model": "Ridge",
        "parameter": 0.01,
        "fold_rank_ic": [
          0.17506086956521738,
          0.07137391304347825
        ]
      },
      {
        "model": "Ridge",
        "parameter": 1.0,
        "fold_rank_ic": [
          0.18386086956521738,
          0.08382608695652173
        ]
      },
      {
        "model": "GBDT",
        "parameter": 15,
        "fold_rank_ic": [
          0.15682763114644607,
          0.06077303089778725
        ]
      },
      {
        "model": "GBDT",
        "parameter": 40,
        "fold_rank_ic": [
          0.15493058889724023,
          0.06845350516001593
        ]
      }
    ],
    "cutoff": "2020-07-01 15:00:00",
    "test_dates": 44,
    "seed": 42,
    "data": "synthetic",
    "policy": "Frozen grid; 2 development folds; static final training; no trading NAV"
  },
  "regime": {
    "metrics": {
      "Ridge": {
        "mse": 0.00024392814737696548,
        "ic": -0.01192811463178478,
        "rank_ic": -0.04179841897233202,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "GBDT": {
        "mse": 0.00024356036617342243,
        "ic": 0.06908501292711096,
        "rank_ic": 0.038083236026873496,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "单因子": {
        "mse": 0.00024926921255070695,
        "ic": -0.03825518702962725,
        "rank_ic": -0.05559288537549407,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      },
      "等权": {
        "mse": 0.0002395074547715798,
        "ic": -0.05757267200251119,
        "rank_ic": -0.07569169960474308,
        "coverage": 1.0,
        "rows": 1056,
        "dates": 44
      }
    },
    "selected": {
      "Ridge": 1.0,
      "GBDT": 40
    },
    "trials": [
      {
        "model": "Ridge",
        "parameter": 0.01,
        "fold_rank_ic": [
          0.17506086956521738,
          -0.03933913043478261
        ]
      },
      {
        "model": "Ridge",
        "parameter": 1.0,
        "fold_rank_ic": [
          0.18386086956521738,
          -0.03881739130434781
        ]
      },
      {
        "model": "GBDT",
        "parameter": 15,
        "fold_rank_ic": [
          0.15682763114644607,
          -0.03340877574882897
        ]
      },
      {
        "model": "GBDT",
        "parameter": 40,
        "fold_rank_ic": [
          0.15493058889724023,
          -0.021581788012149272
        ]
      }
    ],
    "cutoff": "2020-07-01 15:00:00",
    "test_dates": 44,
    "seed": 42,
    "data": "synthetic",
    "policy": "Frozen grid; 2 development folds; static final training; no trading NAV"
  }
}

先回答,再看提示和答案

Q01

空信号机制出现正 IC,说明模拟有 bug吗?

一级提示

区分总体零信号和样本相关。

参考答案与条件

未必。有限样本随机偏差和开发选择会产生非零IC。先查流程、再看预定重复与区间,不要求每次恰好零。

Q02

综合案例的五日spread可以作为每天收益算净值吗?

一级提示

先写出目标、信息集和所需条件。

参考答案与条件

不可以,标签重叠且没有持仓和成交政策。需要独立的逐日持仓收益回测。

Q03

四个模型中最终测试最高者可以直接作为已验证赢家吗?

一级提示

先写出目标、信息集和所需条件。

参考答案与条件

固定候选比较可描述,但根据测试挑选未来部署模型仍有选择代价,后续需新样本确认。

把知识迁移到一个新任务

先不运行代码,用一页纸写出研究合同。运行全部四种机制后写三条发现和两条限制。把一个模型失败的结果加入项目口述,并提出不会污染当前最终测试的下一实验。

这里保存学习笔记,不在浏览器执行 Python。A/B/C 由你按独立完成程度填写。

参考与进一步阅读

在线文档可能比本地固定版本更新;API 行为以已验证代码和版本记录为准。

掌握状态与复习

关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。

记录尽量保存在当前浏览器,建议定期导出备份。