多因子模型怎么安全调权?我用 Regime、Shadow 和权重漂移跑了一次对照实验
因子有了正 IC,下一步最危险的动作,可能就是立刻把它的权重调高。
上一轮我刚把因子验证链路整理完:先冻结 signal-time snapshot,再看 Bucket Spread、Rank IC 和多周期 outcome。那一步解决的是“这个因子有没有排序信息”。但模型真正开始自动调权以后,问题会变成另一种——最近 20 个周期看起来更强的因子,凭什么马上改写 active model?
这两个问题很容易被混在一起。
一个候选因子可以是真的有用,同时最近这个窗口对它的估计又刚好偏高;一个 regime 也可能真的已经切换,只是你加了太多保护以后,模型反而迟迟不肯跟上。自动调权不是把研究报告里的 IC 映射成一组新 weight 就结束了,真正麻烦的是模型变更本身。
我这次没有拿旧的生产权重或 Notion 里以前记过的阈值做证明,而是做了一轮可控的 regime-switching 合成实验。因为 ground truth 是已知的,我可以准确知道什么时候“因子关系真的变了”,再比较三种 activation policy:
- 新权重每次都立即上线;
- 每次上线,但限制单个因子的最大权重变化;
- 候选先在 shadow 里跑,连续赢过 active 后才允许 promotion,同时再受 delta cap 限制。
实验用了 12 个固定随机种子,每个种子 240 个连续 period、每期 200 个 synthetic assets、4 个 factor;在 period 80 和 160 明确切换 regime。每 5 个 period 生成一次 candidate,估计窗口只看过去 20 个 period,candidate 只能用之后的 5 个 period 做 out-of-sample 评价。
这个时间边界我刻意做得很死。scikit-learn 的 TimeSeriesSplit 文档本身就提醒,时间有顺序的数据不能像普通样本那样随便切,否则会出现用未来训练、再回头评价过去的问题。
如果连 candidate weight 都偷看了未来,后面讨论 shadow、regime 或 drift budget 都没有意义。
我先比较“调得快”和“调得稳”到底差多少
候选权重的估计方式没有故意做复杂:在 trailing window 里计算每个 factor 的横截面 Rank IC,负值截到 0,再把正向证据归一化成 candidate weight。
真正变化的是 activation policy。
这次我记录了几个指标:
- future block 的 combined-score Rank IC;
- 累计 L1 weight turnover;
- 单次最大权重跳变;
- promotion 次数;
- promotion 后下一个 evaluation block 反而输给旧 active profile 的比例;
- regime 真正切换以后,active profile 的最大权重多久才转到新 regime 的最强 factor。
最后一个指标我叫 adaptation lag。它不是金融行业通用定义,只是这次实验里为了量化“模型到底多慢才肯承认 regime 已经变了”。
12 个 seed 的均值如下:
| Policy | Cap | Shadow confirmations | Mean OOS Rank IC | L1 turnover | Max weight jump | False promotion rate | Adaptation lag |
|---|---|---|---|---|---|---|---|
| Immediate | — | — | 0.2199 | 7.305 | 0.333 | 39.0% | 11.67 |
| Delta capped | 5% | — | 0.2092 | 5.591 | 0.050 | 27.8% | 22.08 |
| Shadow first | 5% | 1 | 0.2001 | 4.197 | 0.050 | 20.6% | 29.79 |
| Shadow first | 5% | 2 | 0.1882 | 2.409 | 0.050 | 15.0% | 52.29 |
| Shadow first | 5% | 3 | 0.1850 | 1.659 | 0.050 | 10.2% | 75.83 |
| Delta capped | 10% | — | 0.2172 | 6.884 | 0.100 | 36.2% | 14.58 |
| Shadow first | 10% | 1 | 0.2077 | 5.015 | 0.100 | 27.6% | 22.71 |
| Shadow first | 10% | 2 | 0.1937 | 3.472 | 0.100 | 13.7% | 37.08 |
| Shadow first | 10% | 3 | 0.1869 | 2.411 | 0.100 | 11.2% | 47.71 |
这个结果没有给我一个“最安全参数”。
Immediate 反而拿到了最高的 mean OOS Rank IC,而且 regime 切换后跟得最快,平均只慢 11.67 个 period。代价也很明显:累计换手最高,44 个 checkpoint 基本每次都换,false promotion rate 接近 39%。
加一个真正的 5% delta cap 以后,mean OOS Rank IC 从 0.2199 降到 0.2092,适应速度也慢了一截;但 L1 turnover 从 7.305 降到 5.591,false promotion rate 从 39.0% 降到 27.8%。
Shadow 再往前一步。5% cap 下要求连续 3 次 shadow 胜出,false promotion rate 只剩约 10.2%,turnover 也压到 1.659。看起来很稳,可 adaptation lag 拉到了 75.83 个 period。
如果只看“错误 promotion 越少越好”,很容易把最后一组当成最佳答案。问题是一个真的 regime switch 发生以后,模型也可能被你保护到几乎不愿意动。
这次实验让我更确定一件事:guardrail 不是免费的安全层,它本身就是模型的一部分。
AWS SageMaker 的 shadow tests 用的是类似的生产思想:新 variant 可以接收复制过来的真实请求,但 response 不直接返回给调用方,先和 production variant 对比,再决定要不要 promotion。这个工程模式很适合借到量化系统里,但 SageMaker 文档并没有告诉你“连续赢几次就应该上线”,更不会替交易系统决定 weight cap。
这些阈值必须从自己的 outcome、换手成本和对 regime 变化的容忍度里找。
Regime 不是一个标签,它决定你该拿哪些历史去相信
动态权重最容易出现的一个错觉,是样本一多就觉得估计会更稳。
如果市场结构一直没变,这句话没什么问题。但一个 factor 的作用本来就可能有周期性,把很久以前的另一种环境全部揉进同一个 trailing window,样本数变多的同时,也可能把当前关系稀释掉。
Shu 和 Mulvey 2025 年发表在 Journal of Portfolio Management 的 Dynamic Factor Allocation Leveraging Regime-Switching Signals 就是直接把 factor-specific regime inference 放进动态 allocation。我的实验没有复现他们的模型,也不能拿他们的结果替自己的系统背书,但至少说明“因子分配需要考虑 regime”不是我项目里凭空造出来的概念。
对自动调权系统来说,我更愿意把 market / regime 看成评价上下文,而不是一个最后才加上的标签。
比如同样是 momentum factor:
momentum evidence
+ market
+ regime
+ session / universe
+ data quality
↓
candidate evidence
你可以选择完全分开统计,也可以做层级 shrinkage,不一定要切成很多小桶。重点是别把“过去整体有效”直接翻译成“现在应该继续加权”。
美国联邦储备当前的 model risk management guidance 讲的是银行模型治理,不是交易策略教程;但它里面有一条很通用:ongoing monitoring 要看数据相关性、exposure、activity 和 market conditions 是否已经变化,如果模型不再按预期工作,就需要 adjustment 或 redevelopment。
我觉得这层思想放在量化系统里很实用——权重不是一次训练后的常数,也不该是每个窗口随手重算的临时变量。它需要版本、上下文和变更条件。
这也是为什么我会把上一篇 因子有效性验证 放在这篇前面。先确认 factor evidence 值不值得信,再讨论 candidate weight 怎么变;顺序倒过来,就会变成模型不断对噪声做出精确反应。
一个 5% 上限,差点被 normalize 悄悄改成 9.1%
这次最值得留下来的不是结果表,而是中间一个实现错误。
第一版 delta cap 我写得很自然:
target weight
↓
逐因子 clip 到 old ± 5%
↓
normalize,让权重和重新等于 1
直觉上看,已经 clip 到 5% 了,最后 normalize 只是把总和修正回 1。
实际一跑,名义 5% cap 的 observed max weight jump 平均到了大约 9.1%。
原因也不复杂:clip 只约束了 normalize 之前的坐标。所有权重一起重新缩放以后,其中某些维度又被推得更远。
也就是说,这段看起来没问题的逻辑:
clipped = np.clip(target, old - cap, old + cap)
weights = clipped / clipped.sum()
并不能保证:
abs(weights[i] - old[i]) <= cap
这类 bug 比“某个 IC 算错一位小数”危险得多,因为监控页面上可能仍然写着 cap=0.05,系统实际执行的权重漂移却已经超过了它。
最后我改成 bounded simplex projection:新的 weight 同时满足三件事——非负、总和等于 1、每个维度仍然在 old ± cap 里。
核心约束就是:
sum(w) = 1
0 <= w_i <= 1
old_i - cap <= w_i <= old_i + cap
实现里我用一个很小的二分过程去找投影偏移量:
lower = np.maximum(0.0, old - cap)
upper = np.minimum(1.0, old + cap)
lo = np.min(target - upper) - 1.0
hi = np.max(target - lower) + 1.0
for _ in range(80):
lam = (lo + hi) / 2.0
w = np.clip(target - lam, lower, upper)
if w.sum() > 1.0:
lo = lam
else:
hi = lam
修完以后,最终结果里 5% cap 的最大 observed jump 就是 0.05,10% cap 就是 0.10,只有浮点 epsilon 的误差。
这件事让我觉得“drift budget”最好不要只存在配置层。
如果系统真有:
max_weight_delta: 0.05
那运行时还应该直接 assert:
max(abs(new_weight - old_weight)) <= max_weight_delta + epsilon
否则你有的是一个参数,不是一个约束。
Shadow 的意义不是预测更准,而是把“研究结论”和“线上状态”隔开
我现在更愿意把自适应因子的生命周期拆成四层:
observe
↓
candidate
↓
shadow
↓
active
Observe 只收集证据,不影响任何线上评分。
Candidate 表示研究层已经给出一个可以比较的新 weight profile,但它还不是生产状态。
Shadow 开始用未来 outcome 和 active 做并行比较。这里最重要的是,shadow 不能因为“自己用了更近的数据”就天然拿到上线资格;它仍然需要和 active 在同一段 future window 里比。
Promotion 才是真正的状态变更。
这四层分开以后,很多东西会突然清楚:
factor IC 变好
≠ candidate 一定更好
candidate 最近赢了一次
≠ 应该 promotion
promotion 被允许
≠ 可以无限幅度改权重
我这次实验里 false promotion 的定义很窄:如果一个新 profile 被 promoted,下一段 future evaluation 反而输给 promotion 前的 active,我就算一次 false promotion。
它不是唯一合理定义,但足够暴露一个问题:最近窗口里看着更好的 candidate,真的上线以后,有相当一部分马上就会输。
Shadow confirmation 可以把这种次数压下去,可 confirmation 越多,真正 regime 变化以后的响应就越慢。
所以如果让我现在设计生产链路,我不会直接写死:
连续 3 次赢 → 上线
我会先保存:
candidate_version
active_version
market / regime
evidence_window
future_eval_window
weight_delta
turnover
shadow_outcome
promotion_decision
然后再从真实 outcome 里看,不同确认次数和 drift budget 到底在交换什么。
Notion 里我以前记过 2 次、3 次确认,以及 5%、8%、12%-15% 这种漂移阈值。这次实验没有证明其中任何一个数字是正确的。它只证明这些参数确实会改变模型行为,而且变化方向很明显。
我现在会把自动调权做成一个“受限的模型变更流程”
如果把这次实验压回系统设计,最小闭环其实不复杂:
1. 冻结当前 active weights
2. 只用过去窗口估计 factor evidence
3. 带上 market / regime 上下文
4. 生成 candidate weights
5. 做 hard constraint / drift check
6. candidate 进入 shadow
7. 等未来 outcome 结算
8. 对比 active 与 shadow
9. 满足 promotion rule 才生成新 active version
10. 保存整个 decision trail
真正重要的是第 9 步不要只写成一个 if ic > threshold。
至少应该同时知道:
- candidate 赢了多久;
- 这些胜利是不是来自同一个 regime;
- promotion 会造成多大的 weight jump;
- 最近累计 turnover 已经多高;
- candidate 和 active 的差异到底来自一个 factor,还是整个 profile;
- 如果不 promotion,真正的 regime change 会让你慢多久。
这几个问题没有一个可以从“最近 Rank IC = 0.08”直接推出答案。
更实际一点,我甚至会把 promotion decision 本身也当成一种 outcome tracking 对象。每次系统说“这次应该换权重”,以后都回来问一句:如果当时不换,会怎样?如果当时晚一个窗口换,会怎样?
时间久了以后,你才有资格讨论自己的 shadow confirmation 是 1、2 还是 3,delta cap 是 5% 还是 10%。
这和单纯调参的感觉已经不太一样了。
自动调权系统真正需要学习的不只是“哪个 factor 最近更有效”,还包括“我的模型变更规则,过去到底有没有帮忙”。
这次合成实验给我的答案也刚好停在这里:Immediate 很敏捷,但 churn 大;Shadow 很克制,但可能慢到错过 regime;delta cap 能收住动作,却不能替代 outcome validation。
我不会从这张表里挑一个数字塞进生产配置。
更合理的下一步,是让这些 guardrail 先跟着真实 outcome 跑一段 shadow,记录每次本来想 promotion 的候选,以及它之后到底有没有继续赢。等真实数据足够,再让系统决定自己能承受多少错误 promotion,又愿意为稳定付出多少 adaptation lag。