株式スクリーナーの一覧に、80点の銘柄が2つ並んでいるとします。片方は複数のテクニカルシグナルが重なった結果、もう片方はトレンドや出来高条件を満たしたスクリーナーの結果です。表示されるのは同じ「スコア」列なので、そのまま降順に並べたくなります。

しかし、この2つの80点は同じものを測っているのでしょうか。

マルチソースの銘柄選別システムについて開発メモを整理していたとき、この問題に改めて気づきました。複合シグナルとプリセットスクリーナーでは、利用できる特徴量がそもそも違います。一方にしか存在しない入力項目を他方で0点として扱うのは不公平です。そこでソース別のスコアリング(source-aware scoring)を使っても、出力された0〜100点が共通の統計的意味を持つとは限りません。

ルールに基づく80点は、5日後に上がる確率80%ではありません。 異なるソースの点数を比べるなら、まず未来のイベントを同じ方法で定義し、確定した観測結果を同じ条件で検証する必要があります。データが足りない段階では、無理に一つの全銘柄ランキングへ統合しない方が健全です。

なぜシグナルの発生源ごとに評価項目が違うのか

2026年6月のプロジェクト設計メモでは、複合テクニカルシグナルの signal_quality に resonance_score(共振度)と technical_rating(テクニカル評価)を使い、当時の説明例として70% / 30% の配分を示していました。

一方、プリセットスクリーナーでは technical_rating、移動平均の評価、RSI、MFI、52週高値からの距離、条件に一致した数などが入力になります。複合シグナル固有の resonance_score が存在するとは限りません。そこを欠損値だからと単純に0点にしてしまうと、スクリーナーであること自体がペナルティーになります。

ソース内部スコアが評価するもの不合理な扱い
technical_signal複数のテクニカル指標が共振しているか利用可能な共振情報を無視する
screener_presetトレンド・価格・出来高条件への一致度複合シグナル専用の項目がないだけで減点する
factor_strategyその因子戦略に定義された条件別の戦略にしかない特徴量を必須にする

これは過去の設計メモの内容です。現在の本番設定や重みが同じだと断定するものではありません。ただし、ソース別の特徴量に意味があることはよく分かります。

残る問題は、ソースAの「共振ルールに強く適合した80点」と、ソースBの「スクリーナー条件に強く適合した80点」を、共通の確率として読めるかどうかです。scikit-learnの確率キャリブレーションの資料は、予測確率と観測頻度の対応を独立したデータで検証する手法を説明しています。単なるルールスコアを、検証せずに確率へ置き換えることはできません。

では、過去の80点の「的中率」をソースごとに集計すれば十分でしょうか。実は、そこにも落とし穴があります。

シンプソンのパラドックス:両方80点でも集計すると順位が逆転する

ここでは市場のノイズを取り除き、**意図的に構成した80件の教育用データ(Toy Example、実運用のバックテストではない)**を使います。各行の raw_score は一律80点。仮想的な相場環境をトレンド(trend)とレンジ(range)に分け、5日後の二値イベントをあらかじめ設定しました。

ソーストレンド相場レンジ相場単純合算
A: technical_signal21 / 30 = 70%3 / 10 = 30%24 / 40 = 60%
B: screener_preset8 / 10 = 80%15 / 30 = 50%23 / 40 = 57.5%

最後の列だけならAが上です。ところが環境別に見ると、トレンドではBの80%がAの70%を上回り、レンジでもBの50%がAの30%を上回っています。Aはイベントの成立しやすいトレンド相場の観測が多く、Bはレンジ相場の観測が多い。母集団の構成が違うため、全体の順位が逆になったのです。

両者に共通の重みとして、仮にトレンド50%・レンジ50%を使うと次のようになります。

ソース元の観測構成共通の50/50構成
A60%50%
B57.5%65%

ここではBが上になります。これが**シンプソンのパラドックス(Simpson’s paradox)**の典型的な例です。50/50は説明用の重みであり、実際の市場構成を推定した数字ではありません。実研究では比較する対象母集団と重みを、結果を見る前に定めます。

分数の割り算より実用的なのは、少数データで生じた高い率をどこまで信用できるかを調べることです。Bのトレンド相場では10件中8件が正例で、見かけの割合は80%。独立した二値試行という前提で求めると、95% Wilson区間の下限は**約49.0%**にとどまります。

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 を0%として返しません。観測が0件であることと、観測した成功率が0%であることは違うからです。またWilson区間はトレードの損益を保証する指標ではありません。同じ銘柄の繰り返し観測や5日間のリターン区間の重なりがある場合、独立性を前提とした区間の解釈は慎重に行う必要があります。

80件の合成データと4つの検証assertを含む完全な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()

完全な標準ライブラリのスクリプトは80行分の架空レコードを生成し、相場環境別の比率、合算比率、Wilson区間を出力して4つのassertで確認します。確かめられるのは集計方法の落とし穴であって、実在する運用戦略の優劣ではありません。

現実にはさらにややこしくなります。同じ銘柄が同じ日に2つのソースから選ばれたり、t日とt+1日の5日先リターンが大きく重なったりします。件数が増えても、独立した証拠が同じ割合で増えるとは限りません。

キャリブレーションの前に「成功」を固定する

ロジスティック回帰とIsotonic Regressionのどちらを使うかより先に、正例の意味を揃える必要があります。

「5営業日後の終値が高い」「5日後にベンチマークを上回る」「約定可能な価格で売買し、手数料とスリッページを引いて利益が残る」は別の出来事です。1日先と10日先のラベルを同一の確率曲線で扱うこともできません。

例えば研究用途なら、市場と引け時点の意思決定タイムスタンプを固定し、5営業日先のコスト仮定付きリターンがプラスかどうかをイベントにする方法があります。5営業日が経過していないシグナルは未確定のままにします。約定データがなければ価格ベースのproxyであり、実売買の損益とは区別します。

各意思決定には、当時の情報を後から書き換えないスナップショットが必要です。

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 は採点ルールを識別し、model_version は重みを変更する前後の点数を混ぜないために使います。as_of_time は入力値が本当に意思決定時点で利用可能だったことを確認する境界です。3か月後に新しいモデルと修正済みデータで「当時の80点」を再計算すれば、評価対象がすり替わってしまいます。

同じ銘柄・同じ日に2ソースから観測した場合、ソースごとの集計用に2行残すことはできます。しかし信頼区間やポートフォリオ収益を計算するとき、それを互いに独立な2取引と見なしてはいけません。銘柄と日付を基準に重複・相関を扱う必要があります。

pending outcomeの取り扱いも重要です。5営業日を経過していないイベントは「失敗」でも「ゼロ収益」でもありません。ソースごとに確定率が違うのにcompletedだけを抜き出すと、ラベルの成熟度が比較結果を歪めます。銘柄ユニバース、上場廃止銘柄、財務データの発表遅延、株価調整と流動性も揃えるべき条件です。

地味なデータ契約こそ、校正曲線の土台になります。

ランキング性能、確率校正、期待損益は別々の検証項目

まずソース別・時期別・相場環境別にraw_scoreをビン分けして、件数、ラベル確定率、イベント発生率、リターン分布、不確実性を並べます。十分なデータがないのにソース×環境×時間帯ごとに高柔軟性の校正モデルを学習すると、ノイズを確率に見せかけるだけになりかねません。

少数データではソース内ランキングを保ったり、プーリングと縮小推定を組み合わせたりする方が適切な場合があります。十分な確定ラベルがあるなら、Sigmoidのような比較的単純な方式から始め、より柔軟な方式との差を別の将来期間で調べます。

信頼性ダイアグラムの説明では、予測確率のビンごとに平均予測値と観測された正例割合を比べます。Brier Scoreは校正性だけでなく識別能力や結果の不確実性にも左右され、値が改善しただけで売買収益が上がったとは言えません。

時系列では未来情報の混入にも注意が必要です。t日に出たシグナルのラベルがt+5日に確定するなら、学習期間末尾のラベルに検証期間中の株価が含まれることがあります。シグナル日付で訓練・評価を区切っただけでは防げません。TimeSeriesSplitには時間順分割とgapがありますが、gap=5を設定すれば常に安全というわけではなく、ラベルの確定日、取引カレンダー、同銘柄の重なりを考慮します。

知りたいこと見るべき証拠
高いスコアが将来良好な銘柄を上位に並べるか将来期間での順位指標、TopとRestのリターン分布
60%と予測したイベントが約60%起きるか将来期間のreliabilityビン、件数、グループ安定性、確率誤差

順位付けの優れたスコアが確率として校正されているとは限らず、基礎発生率に近い予測をするだけでは銘柄の識別に役立たないかもしれません。Googleのprediction bias解説は平均予測と平均ラベルの簡単な検査には役立ちますが、各ソース・各ビンの校正を証明するものではありません。

そして、確率は損益ではありません。5日先のプラス収益イベントの確率が良く校正されていても、勝ったときの利益幅、負けたときの損失、約定コスト、ドローダウン、ポジション容量は分かりません。高い的中率と悪い損益比は両立します。

証拠が揃うまではソース別のスコアを残す

私なら、すべてのソースを急いで統一ランキングへ押し込むことはしません。共振シグナルとスクリーナーが別々の特徴量を見るのは合理的です。足りないのは、そこから得たスコアがソースを跨いで比較できることを示す実証です。

次の読み取り専用の検証では、通常運用の確定済みバッチから手動テストを除外し、source_key、model_version、市場、session、horizonを固定します。scoreビンと相場環境ごとに正例率、リターン中央値、極端な損失、重複銘柄、観測範囲と成熟度を確認してから、将来期間で確率へのマッピングを評価します。

この話は以前のBucket SpreadとRank ICによる因子検証と、Regime・Shadow・重みの変化にもつながります。前者は因子に順位情報があるか、後者は重みの更新を本番に反映すべきか。その中間にあるのが、異なる評価モデルの「強い」は本当に同じ尺度で比べられるかという問いです。

本当にどちらを上位にすべきかは、条件を合わせた確定済みの将来データでしか判断できません。それまでは、由来を消した「統一勝率」を付けるより、元のスコアとソースを一緒に表示する方が役に立ちます。