# 多因子模型怎麼安全調權？Regime、Shadow 與權重漂移實驗

> Canonical: https://vxi.me/zh-tw/posts/adaptive-factor-weighting-regime-shadow-drift/
> Locale: zh-tw
> Published: 2026-10-08
> Updated: 2026-10-08
> Markdown: https://vxi.me/zh-tw/posts/adaptive-factor-weighting-regime-shadow-drift.md

因子出現正 IC 之後，下一步最危險的動作，可能就是立刻把它的權重往上調。

上一輪我先整理了因子驗證鏈路：凍結 signal-time snapshot，再看 Bucket Spread、Rank IC 與多週期 outcome。那一步回答的是「這個因子到底有沒有排序資訊」。但模型真的開始自動調權之後，問題會變成另一種——**最近 20 個 period 看起來比較強的因子，憑什麼立刻改寫 active model？**

這兩個問題很容易混在一起。

一個候選因子可以真的有用，但最近這個視窗剛好把它估得太高；regime 也可能真的切換了，只是保護機制太重，模型反而遲遲跟不上。自動調權不是把研究報告裡的 IC 映射成一組新 weight 就結束，真正麻煩的是模型變更本身。

這次我沒有拿舊的 production 權重或 Notion 裡過去記過的門檻來當證據，而是做了一輪可控制的 regime-switching 合成實驗。因為 ground truth 已知，我可以準確知道什麼時候「因子關係真的改變」，再比較三種 activation policy：

- 每次新權重都立刻啟用；
- 每次啟用，但限制單一因子的最大權重變化；
- candidate 先在 shadow 裡跑，連續贏過 active 後才允許 promotion，而且 promotion 時仍受 delta cap 限制。

實驗用了 12 個固定 random seed。每個 seed 有 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](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) 文件本身就提醒，時間有順序的資料不能像一般樣本一樣任意切分，否則可能出現用未來資料訓練，再回頭評估過去的問題。

如果 candidate weight 已經偷看未來，後面再談 shadow、regime 或 drift budget 都沒有意義。

## 先看「調得快」和「調得穩」到底差多少

candidate 權重的估計方式我刻意做得很簡單：在 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 切換之後跟得最快，平均 adaptation lag 只有 11.67 個 period。代價也很明顯：累積 turnover 最高，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 真的改變以後，你也可能把模型保護到幾乎不肯動。

這次實驗讓我更確定一件事：**guardrail 不是免費的安全層，它本身就是模型的一部分。**

AWS SageMaker 的 [shadow tests](https://docs.aws.amazon.com/sagemaker/latest/dg/shadow-tests.html) 用了很接近的 production 模式：新的 variant 可以收到鏡像過來的 production request，但 response 不直接回給 caller，先拿來和 production variant 比較，再決定要不要 promotion。這個工程模式很適合借到量化系統裡，但 SageMaker 文件並沒有告訴你「連續贏幾次就該上線」，更不可能替交易系統決定 weight cap。

這些門檻只能從自己的 outcome、換手成本，以及對 regime 變化反應速度的容忍度裡找。

## Regime 不是一個標籤，它決定哪些歷史值得相信

動態權重最容易出現的一個錯覺，是樣本越多就覺得估計一定越穩。

如果底層關係真的穩定，多一點 observation 當然有幫助。但 factor 行為本來就可能隨 regime 改變。把很久以前、完全不同環境的資料都揉進同一個 trailing window，樣本數增加的同時，也可能把現在真正重要的關係稀釋掉。

Shu 和 Mulvey 2025 年在 *Journal of Portfolio Management* 發表的 [Dynamic Factor Allocation Leveraging Regime-Switching Signals](https://collaborate.princeton.edu/en/publications/dynamic-factor-allocation-leveraging-regime-switching-signals/) 就直接研究 factor-specific regime inference 下的動態 allocation。我的實驗沒有重現他們的模型，也不能拿他們的結果替我的系統背書；但至少可以確認，把 regime 納入 factor allocation 並不是專案裡憑空造出的概念。

對自動調權系統來說，我比較願意把 market / regime 視為評估上下文，而不是最後才補上的標籤。

同一個 momentum factor，可以把證據上下文想成：

```text
momentum evidence
    + market
    + regime
    + session / universe
    + data quality
        ↓
candidate evidence
```

你不一定要把所有東西切成很多獨立小桶，也可以用 hierarchical shrinkage 或其他 pooling 方法。重點是不要把「過去整體有效」直接翻譯成「現在就該加權」。

美國聯準會目前的 [model risk management guidance](https://www.federalreserve.gov/frrs/guidance/supervisory-guidance-on-model-risk-management.htm) 談的是銀行模型治理，不是交易策略教學；但其中有一個很通用的觀念：ongoing monitoring 要觀察資料相關性、exposure、activity 與 market conditions 是否變化，如果模型不再照預期運作，就需要 adjustment 或 redevelopment。

這個框架放到量化系統裡也很實用——**權重不是一次訓練後永遠不動的常數，也不該是每個視窗隨手重算的臨時變數。它需要版本、上下文與明確的變更條件。**

這也是為什麼我把上一篇 [因子有效性驗證](/zh-tw/posts/quant-factor-validation-bucket-spread-rank-ic/) 放在這篇前面。先確認 factor evidence 值不值得信，再討論 candidate weight 怎麼變；順序反過來，模型就可能一直對噪音做出非常精確的反應。

## 一個 5% 上限，差點被 normalize 悄悄改成 9.1%

這次最值得留下來的不是結果表，而是一個中途真的踩到的實作錯誤。

第一版 delta cap 我寫得很直覺：

```text
target weight
    ↓
逐因子 clip 到 old ± 5%
    ↓
normalize，讓權重總和回到 1
```

直覺上已經 clip 到 5%，最後 normalize 只是把總和修正回 1。

實際一跑，名義 5% cap 的 observed max weight jump 平均卻到了大約 **9.1%**。

原因不複雜：clip 只約束了 normalize **之前**的座標。所有權重一起重新縮放後，其中某些維度又被推得更遠。

也就是說，這段看起來合理的程式：

```python
clipped = np.clip(target, old - cap, old + cap)
weights = clipped / clipped.sum()
```

不能保證：

```text
abs(weights[i] - old[i]) <= cap
```

這種 bug 比「某個 IC 小數點算錯」更危險，因為監控畫面仍可能顯示 `cap=0.05`，但系統實際執行的權重漂移已經超過它。

最後我改成 bounded simplex projection，要求新的 weight 同時滿足：非負、總和等於 1，而且每個維度仍在 `old ± cap` 裡。

核心約束是：

```text
sum(w) = 1
0 <= w_i <= 1
old_i - cap <= w_i <= old_i + cap
```

實作上用一個很小的二分搜尋找投影偏移量：

```python
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 最好不要只存在設定檔。

如果系統裡真的有：

```yaml
max_weight_delta: 0.05
```

runtime 還應該直接 assert：

```text
max(abs(new_weight - old_weight)) <= max_weight_delta + epsilon
```

否則你有的只是一個參數，不是一個約束。

## Shadow 的意義不是預測更準，而是把研究結論和 production state 隔開

我現在比較願意把自適應因子的生命週期拆成四層：

```text
observe
   ↓
candidate
   ↓
shadow
   ↓
active
```

Observe 只收集證據，不改任何線上評分。

Candidate 表示研究層已經產生值得比較的新 weight profile，但它仍不是 production state。

Shadow 開始用未來 outcome 和 active 並行比較。重點是 shadow 不能因為「用了更新的資料」就天然取得上線資格；它仍要和 active 在同一段 future window 裡比較。

Promotion 才是真正的狀態變更。

四層拆開後，很多關係會變得比較清楚：

```text
factor IC 變好
≠ candidate 一定更好

candidate 最近贏了一次
≠ 應該 promotion

promotion 被允許
≠ 可以無限制改權重
```

這次實驗裡，false promotion 的定義很窄：新 profile 被 promoted 之後，如果下一段 future evaluation 反而輸給 promotion 前的 active，就算一次 false promotion。

它不是唯一合理的定義，但足以暴露問題：最近估計視窗裡看起來較強的 candidate，上線後其實有相當一部分馬上就會輸。

Shadow confirmation 可以把這類事件壓低，但確認次數越多，真正 regime 變化後的反應就越慢。

所以如果現在要設計 production pipeline，我不會直接寫死：

```text
連續 3 次贏 → 上線
```

我會先保存：

```text
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% 之類的漂移門檻。這次實驗沒有證明其中任何一個數字正確，只證明這些控制確實會改變模型行為，而且交換方向很清楚。

## 我現在會把自動調權做成「受限制的模型變更流程」

如果把這次實驗壓回系統設計，最小閉環其實不複雜：

```text
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 物件。每次系統說「現在應該換權重」，之後都回頭問：如果當時不換會怎樣？如果再晚一個 window 才換會怎樣？

累積足夠多紀錄後，才有資格用真實證據討論 shadow confirmation 應該是 1、2 還是 3，delta cap 應該是 5% 還是 10%。

到這裡，它已經不太像一般的調參。

自適應調權系統要學的不只是「哪個 factor 最近有效」，還包括**自己的模型變更規則，過去到底有沒有幫忙**。

這次合成實驗剛好停在這裡：Immediate 很敏捷，但 churn 大；Shadow 很克制，但可能慢到錯過 regime；delta cap 能限制每次動作大小，卻不能取代 future outcome validation。

我不會從這張表挑一個數字直接塞進 production 設定。

更合理的下一步，是讓這些 guardrail 先跟真實 outcome 跑一段 shadow：記錄每一次系統本來想 promotion 的 candidate，再看它後來是否持續勝出。等真實資料夠多，系統才有機會學會自己能承受多少錯誤 promotion，又願意為穩定付出多少 adaptation lag。

## Verification context

- Level: first-party
- Verified: 2026-10-08T00:00:00.000Z
- Environment: Python
- Environment: Synthetic regime-switching simulation
- Environment: 12 fixed seeds / 240 periods / 200 assets
- Evidence mode: generated
- Evidence receipt: https://vxi.me/evidence/adaptive-factor-weighting-regime-shadow-drift.json
