# 多策略選股評分如何比較？從同為 80 分看辛普森悖論與機率校準

> Canonical: https://vxi.me/zh-tw/posts/stock-score-calibration-simpsons-paradox/
> Locale: zh-tw
> Published: 2026-10-15
> Updated: 2026-10-15
> Markdown: https://vxi.me/zh-tw/posts/stock-score-calibration-simpsons-paradox.md

同一個選股頁面，兩檔股票都得到 80 分。一檔來自技術訊號共振，另一檔來自趨勢與量價條件的篩選器。介面只放了一欄總分，直覺上當然會把兩檔股票直接排名。

但這裡藏著一個問題：**這兩個 80 分，真的在量同一件事嗎？**

整理多來源選股系統的開發筆記時，我發現當初有一個刻意的取捨：綜合技術訊號與篩選器預設可以共用展示介面，但它們不必具備完全相同的特徵。把某個來源天生沒有的欄位當作零分，是不合理的。來源感知評分（source-aware scoring）可以避開這個問題，卻不代表兩套 0–100 分的數字從此具有相同的統計意義。

**規則分數可以先用於來源內排序；跨來源比較要先定義一致的未來事件，再從真正結算的歷史結果驗證。** 80 分不能直接解讀成 80% 的上漲機率。證據不足時，保留來源身分比硬湊一份全市場排行榜更可靠。

## 同樣叫訊號品質，不同來源看的證據本來就不同

一份 2026 年 6 月的專案設計筆記，記錄了綜合訊號的 signal_quality 使用 resonance_score（共振得分）和 technical_rating（技術評級），並以 70% / 30% 作為當時的規則配比範例。

篩選器路徑則可能使用 technical_rating、均線評級、RSI、MFI、距離 52 週高點，以及符合幾項篩選條件；它未必有綜合訊號專屬的 resonance_score。不能因為沒有另一種來源才會生成的欄位，就把篩選器判成低品質。

| 來源 | 內部評分要回答什麼 | 不合理的扣分 |
| --- | --- | --- |
| technical_signal | 多個技術訊號是否形成共振 | 明明有共振輸入，卻忽略它 |
| screener_preset | 趨勢、量價與預設條件是否符合 | 沒有綜合訊號的專屬欄位就扣分 |
| factor_strategy | 策略定義的因子條件是否成立 | 把其他來源的缺失欄位當成失敗 |

這份歷史設計記錄解釋了為什麼要區分來源，卻還沒有回答：不同來源都拿到 80 分後，能否直接比較？

A 的 80 分可能只是高度符合共振規則；B 的 80 分可能表示符合篩選器自己的條件。相同顯示刻度不代表相同的成功機率。[scikit-learn 的機率校準文件](https://scikit-learn.org/stable/modules/calibration.html)討論了如何用獨立資料檢驗預測機率與實際事件頻率的對應關係。規則分數和已校準的事件機率，不能直接畫上等號。

那把每個來源「80 分的歷史勝率」算出來就夠了嗎？還是得先確認樣本分布。

## 辛普森悖論：兩邊都是 80 分，總體排名卻反過來了

以下用**人為設計的 80 筆教學資料（Toy Example，非實盤回測）**隔離市場雜訊，純粹展示跨來源彙總可能踩到的陷阱。每筆 raw_score 都是 80 分，市場狀態分成趨勢（trend）與盤整（range），「成功」是預設的五日二元標籤。

| 來源 | 趨勢狀態 | 盤整狀態 | 直接彙總 |
| --- | ---: | ---: | ---: |
| A：technical_signal | 21 / 30 = 70% | 3 / 10 = 30% | **24 / 40 = 60%** |
| B：screener_preset | 8 / 10 = 80% | 15 / 30 = 50% | **23 / 40 = 57.5%** |

只看最後一欄，A 好像稍微領先。分開看卻相反：趨勢狀態 B 是 80%，A 是 70%；盤整狀態 B 是 50%，A 只有 30%。A 的樣本大多來自比較容易出現正標籤的趨勢環境，B 則有更多盤整樣本。兩邊樣本比例不同，直接混合就可能顛倒比較結果。

若先把兩個來源都調整到同樣的 50% 趨勢／50% 盤整配比：

| 來源 | 原始樣本比例 | 共同 50/50 狀態配比 |
| --- | ---: | ---: |
| A | 60% | 50% |
| B | 57.5% | 65% |

排序反轉了。這就是**辛普森悖論（Simpson's paradox）**的一種典型情境。50/50 是教學用的共同權重，正式研究得預先訂出目標市場的狀態分布，而不是看完結果再挑讓自己領先的比例。

與其再用程式重算表格中的除法，我更想把小樣本的風險寫進工具。B 在趨勢狀態有 8 / 10 = 80% 的表面成功率；如果暫時把樣本當作獨立二元事件，**95% Wilson 信賴區間下界其實只有約 49.0%**。

~~~python
import math

def wilson_lower_bound(wins: int, n: int, z: float = 1.96) -> float:
    if n <= 0 or not 0 <= wins <= n:
        raise ValueError("invalid wins or sample size")
    p = wins / n
    denom = 1 + z*z/n
    center = p + z*z/(2*n)
    margin = z * math.sqrt(p*(1-p)/n + z*z/(4*n*n))
    return (center - margin) / denom

print(f"8/10 = 80%; Wilson 下界 = {wilson_lower_bound(8, 10):.1%}")
# 8/10 = 80%; Wilson 下界 = 49.0%
~~~

這裡故意讓 n=0 丟出錯誤：**沒有樣本，不等於驗證出零勝率。** Wilson 區間不是保證可交易的保守勝率；同一檔股票重複出現或五日遠期報酬區間重疊時，還得處理相關性，不能直接把每筆當作獨立觀察。

<details>
<summary>展開 80 筆合成資料的完整重現程式與四項斷言</summary>

```python
#!/usr/bin/env python3
"""Deterministic synthetic check of group-conditional scoring; NOT a market backtest.

All labels below are intentionally constructed. There are no real stock prices,
no actual trading outcomes, and no production scoring-engine output.
"""
import json
import math
from collections import defaultdict

# Fixed illustrative counts: (source, hypothetical regime, observations, positive outcomes)
SPEC = [
    ("technical_signal", "trend", 30, 21),
    ("technical_signal", "range", 10, 3),
    ("screener_preset", "trend", 10, 8),
    ("screener_preset", "range", 30, 15),
]


def wilson_interval(positive, total, z=1.959963984540054):
    if total < 1:
        raise ValueError("empty group")
    p = positive / total
    denom = 1 + z*z/total
    center = (p + z*z/(2*total)) / denom
    half = z * math.sqrt(p*(1-p)/total + z*z/(4*total*total)) / denom
    return [round(center-half, 4), round(center+half, 4)]


def make_group(total, positive):
    return {"n": total, "positive": positive, "rate": round(positive / total, 4),
            "wilson_95": wilson_interval(positive, total)}


def main():
    rows = []
    for source, regime, total, positive in SPEC:
        rows.extend({"source": source, "regime": regime, "raw_score": 80,
                     "positive_event_5d": int(i < positive)} for i in range(total))
    summaries = defaultdict(dict)
    pooled = defaultdict(lambda: [0, 0])
    for source, regime, n, yes in SPEC:
        summaries[source][regime] = make_group(n, yes)
        pooled[source][0] += n
        pooled[source][1] += yes
    result = {"scope": "CONSTRUCTED_SYNTHETIC_EXAMPLE_NOT_MARKET_DATA",
              "event_definition": "binary illustrative positive_event_5d, not executed trade PnL",
              "raw_score": 80, "total_rows": len(rows), "sources": {}}
    for source in sorted(summaries):
        trend = summaries[source]["trend"]["rate"]
        range_ = summaries[source]["range"]["rate"]
        n, yes = pooled[source]
        result["sources"][source] = {
            "by_regime": summaries[source], "pooled": make_group(n, yes),
            "equal_regime_mix_rate": round((trend + range_) / 2, 4)
        }
    a = result["sources"]["technical_signal"]
    b = result["sources"]["screener_preset"]
    assert len(rows) == 80
    assert a["pooled"]["rate"] == 0.6 and b["pooled"]["rate"] == 0.575
    assert a["equal_regime_mix_rate"] == 0.5 and b["equal_regime_mix_rate"] == 0.65
    assert all(b["by_regime"][r]["rate"] > a["by_regime"][r]["rate"] for r in ("trend", "range"))
    result["test_assertions"] = {
        "fixed_80_records": "PASS", "pooled_order_a_above_b": "PASS",
        "within_each_regime_b_above_a": "PASS", "fixed_equal_mix_order_b_above_a": "PASS"
    }
    print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
```

</details>

完整的 Python 標準函式庫程式會建立 80 筆示例資料、計算各狀態與整體比例、Wilson 區間，並核對四項斷言。它驗證的是彙總機制，不是任何交易策略真的比較會賺錢。

真實世界的資料通常更麻煩：同一檔股票同一天可能同時觸發兩條策略；t 日與 t+1 日的五日 forward return 大幅重疊；一個來源偏好趨勢，另一個偏好拉回。資料列變多，不代表獨立證據也等比例增加。

## 要校準分數，先把「成功」定義清楚

第一步不是決定 Logistic Regression 或 Isotonic Regression，而是定義要估計的事件。

「五個交易日後股價為正」、「五日後跑贏大盤」、「扣掉手續費與滑價後確實獲利」，這三件事不一樣。一日與十日結果也不能塞進同一條校準曲線。

如果只做研究，可以先固定市場、收盤決策時點與持有期，以「五個交易日後、依預設成本模型計算的收益是否大於零」作為標籤。只有時間走完才納入已結算樣本。若沒有實際成交價，應稱為價格報酬 proxy，不能寫成實際交易損益。

每次決策至少需要凍結一份 Point-in-time 快照：

~~~text
decision_id / symbol / trade_date / as_of_time
source_key / raw_score / model_version
market / session / regime
horizon_days / label_status / realized_outcome
~~~

source_key 告訴我們 80 分來自哪一套規則；model_version 避免把調權前後的分數混在一起；as_of_time 確保特徵在當下已經可得。如果三個月後才用新版模型和最新資料重算「歷史評分」，檢驗的就不是當時真正看到的訊號。

同一檔股票同一天命中兩個來源，可以留兩條資料研究來源歸屬，但統計信賴區間與組合報酬不能假裝它們是兩筆互不相關的行情。必須明確處理 decision 去重、標的與日期分群。

還有 pending outcome：五日尚未結算的訊號不是失敗，也不是零報酬。只挑 completed 資料，又讓不同來源的成熟比例不一樣，就會引入結算偏差。不同股票池、下市股票、財報發布時間、還原股價與流動性條件也都會改變可比較的樣本群。

這些不是漂亮的演算法，但會決定後面的機率曲線有沒有意義。

## 排序能力、機率校準與交易報酬要分開驗收

先依 source、時間與市場狀態把原始分數分桶，呈現每桶樣本數、已結算比例、正標籤比例、報酬分布及不確定性，再問資料夠不夠做機率校準。

樣本很少時，保留來源內排名，或考慮有收縮效果的分層估計，比替每個 source × regime × session 擬合一條靈活曲線更合理。小桶的 100% 很可能只是樣本數太少。

資料夠多時可先測試 Sigmoid，再與非參數方法比較；不論哪種方法，都不能拿訓練映射的同一批資料證明它在未來有效。scikit-learn 的[機率校準指南](https://scikit-learn.org/stable/modules/calibration.html)介紹 reliability diagram：在真正的機率預測下，比較桶內平均預測機率與實際事件比例。Brier Score 同時受區分度、校準與事件不確定性影響，單一分數下降不等於策略獲利能力提高。

金融時間序列尤其要防未來資料洩漏。t 日的訊號，五日後才會產生 outcome；若訓練期尾端已使用驗證期間才出現的價格變動，即使訊號日期比測試早，模型仍可能偷看到未來。[TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html)支援依時間切分及 gap，但不能機械設定 gap=5 就當作萬無一失；還要看標籤完成時間、交易日曆、持有期間和同標的重疊。

| 想回答的問題 | 需要的證據 |
| --- | --- |
| 高分是否真的把較好的未來標的排在前面？ | 時間外排序指標、Top vs Rest 的未來報酬分布 |
| 預測 60% 的事件，未來是否約有 60% 發生？ | 時間外 reliability 分桶、樣本數、分組穩定性與機率誤差 |

好的排序分數未必是機率；接近基準發生率的機率模型，也可能缺乏挑選標的的區分能力。[Google 的 prediction bias 說明](https://developers.google.com/machine-learning/crash-course/classification/prediction-bias)可以作粗略檢查，但平均預測與平均結果相等，不代表每個來源、每個分桶都校準良好。

更不能把**機率當成報酬**。五日正報酬事件機率即使很準，也沒有回答賺錢時能賺多少、輸時會虧多少、滑價、回撤與部位容量。高勝率可能搭配很差的盈虧比。

## 在證據成熟前，先保留來源內評分

我不會急著把所有來源重新排成一張全球統一榜單。綜合技術訊號適合用共振資訊，篩選器適合用自己的條件，各自合理的評分框架並沒有錯；真正要補的是跨來源比較的實證。

下一步若能只讀檢查真實的已結算 outcome，應先排除測試與手動驗證批次、固定 source_key 和 model_version，統一市場、session、horizon，再看各 score bin 的正標籤率、報酬中位數、極端虧損、重複標的與狀態構成，最後才在時間外樣本評估機率映射。

這與先前[用 Bucket Spread 和 Rank IC 驗證因子](/posts/quant-factor-validation-bucket-spread-rank-ic/)以及[Regime、Shadow 和權重漂移](/posts/adaptive-factor-weighting-regime-shadow-drift/)的文章接得上：前者問因子有沒有資訊；後者問調權能不能上線；本篇問的是中間那一步——兩種策略眼中的「強」，到底能不能互相比較？

要真的決定哪個來源應該排前面，還是得靠相同口徑、完整結算、時間外的真實資料。那之前，讓讀者看得到原始分數及其來源，比提供一個過度精確的「統一勝率」更可靠。
