多策略选股评分如何对齐?同为 80 分的辛普森悖论与概率校准
一个选股页面上,两只股票都拿了 80 分。第一只来自技术共振信号,第二只来自财务和趋势筛选器。页面只有一个总分列,最自然的操作就是把它们放进同一张表里排序。
可这里隐藏着一个前提:它们的 80 分,衡量的是同一件事吗?
我在整理一套多来源选股系统的开发笔记时,碰到了一个很典型的设计选择。综合技术信号、筛选器预设和因子策略可以共用一套展示界面,却不该强迫它们提供完全相同的输入字段。否则一些策略天生没有的指标,会被错误地当成零分。
这解决了第一层公平问题,却留下第二层:不同评分剖面算出的 80 分,可能只是各自规则内部的“较好”,并不等于同一概率,更不是“80% 会涨”。
结论是:规则分数可以先用于来源内部排序;要跨来源比较,必须先统一未来事件的定义,再拿独立结算的历史结果检验。 如果数据不够,保持分来源展示,比把一组整齐的数字强行排成全市场榜单更诚实。
同样叫信号质量,两条策略来源读的字段不一样
我保存的一份 2026 年 6 月项目设计笔记里,综合信号的 signal_quality 使用技术共振得分 resonance_score 和技术评级 technical_rating。当时的笔记举过 70% / 30% 的维度内规则配比。
筛选器路线则不同。它可能看 technical_rating、ma_rating、RSI、MFI、距离 52 周高点和策略命中数,但未必有综合信号来源专属的 resonance_score。把这个字段的缺失直接记成零,筛选器会因为“不是综合信号”而吃亏。
来源感知评分(source-aware scoring)的目的,是让不同来源在各自合理的证据体系里得分:
| 来源 | 主要关心什么 | 不应出现的情况 |
|---|---|---|
| technical_signal | 多个技术信号是否形成共振 | 明明有核心共振输入,却无故忽略 |
| screener_preset | 条件筛选、趋势、量价是否匹配 | 没有综合信号专属字段,就强行扣成低分 |
| factor_strategy | 已定义的因子条件与评分是否成立 | 缺少另一条来源的字段,被当作模型失败 |
这份历史设计记录解释了为什么要区分策略来源,却还回答不了一个新问题:两个来源各得 80 分,放在一起有没有统计意义?
假如三种来源都把内部结果压成 0–100 的 raw_score,A 的 80 分可能代表“与技术共振规则较匹配”,B 的 80 分代表“与筛选器条件较匹配”。它们共享显示刻度,却未必共享统计语义。
这和概率校准是两件事。scikit-learn 官方概率校准文档解释了怎样评估预测概率与真实事件发生频率的关系,也提醒校准模型应当使用独立数据。对于原本就不是概率的规则分数,更不能直接把 80 读成 0.80。
那么,简单统计“各来源拿到 80 分的历史胜率”就够了吗?还不够。
辛普森悖论:同为 80 分,为什么总体比较反过来了
为了隔离市场噪音,下面用一组**人为构造的 80 条教学记录(Toy Example,非实盘回测)**演示聚合陷阱:两条策略来源的 raw_score 都固定为 80,市场状态分为趋势与震荡,“成功”是预设的 5 日二元标签。
| 来源 | 趋势状态 | 震荡状态 | 全部混合 |
|---|---|---|---|
| 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 更好。
可是分开看,无论趋势还是震荡,都是 B 的成功比例更高。A 的总体比例反而较高,是因为 A 的大部分样本恰好落在成功事件更频繁的趋势状态。两条来源的样本构成不同,聚合结果就会骗人。
如果把两个来源的市场状态占比统一成 50% 趋势、50% 震荡,再加权一次:
| 来源 | 原样本混合比例 | 统一状态占比之后 |
|---|---|---|
| A | 60% | 50% |
| B | 57.5% | 65% |
比较方向倒过来了。这是典型的聚合反转(Simpson’s paradox)。
50/50 是为了把两条来源放到相同的状态构成下比较。真实研究应当事先选定目标市场与状态权重,而不是看完结果再挑一个让自己领先的配比。
表格里的四则运算不难,真正值得写成工具的是样本太少时,应该如何防止一个漂亮的成功率误导判断。例如 B 在趋势状态下命中 8/10,比例是 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%:没有样本不等于验证出零胜率。 Wilson 下界也不是可交易的保守胜率保证,它依赖独立二元观测的假设;如果同一股票反复入样、5 日远期窗口彼此重叠,就要按标的与时间处理相关性。
展开:80 条合成记录的完整可运行脚本(含四项断言)
#!/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()
完整脚本会逐来源输出总体比例、各状态比例、共同状态权重下的比例与 Wilson 区间,并用四项断言核对反转关系。表格不是从任何真实行情推导的,它验证的是这条统计方法本身。
实际研究会更复杂:同一只股票可能在一天里同时命中两个来源;一个策略偏爱强趋势,另一个偏爱震荡回撤;5 日收益区间还可能和相邻交易日的信号高度重叠。把这些记录直接累加,样本数变大了,不一定意味着独立证据变多。
也就是说,跨来源校准前应当先检查样本构成:市场环境、来源覆盖、标签成熟度都可能改变你看到的汇总比例。
真想知道 80 分意味着什么,先规定“成功”是哪种成功
我不会先讨论选用 Logistic Regression 还是 Isotonic Regression。第一步是明确问题:所谓“成功”究竟是什么?
“5 个交易日后股价上涨”和“5 个交易日后跑赢指数”会产生不同的标签;“收盘价上涨”与“实际能按信号成交、扣除点差和手续费后赚钱”,又是两回事。1 日和 10 日结果也不能混在同一概率表里当作一种事件。
如果暂时只研究评分质量,可以先规定一个较窄的目标:
固定一个市场和收盘决策时点,定义五个交易日后、按明确价格与成本假设计算的收益是否大于零,并且只在结果完整结算后纳入标签。
如果只有收盘价数据,就把它称为价格收益的 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 决定一条 80 分来自哪一种评分剖面;model_version 防止把不同月份规则调整前后的分数当成相同东西;as_of_time 约束因子必须是决策当时可以知道的值。等五天后才重新计算当年的“历史分”,验证对象可能早就变了。
同一标的同一天同时出现于两个来源,可以在“来源归属”研究里留两条记录,却不能在统计置信区间里假装是两笔互不相干的行情,也不能在组合收益里重复算出两份独立交易。分析时需要明确 decision 去重规则,并处理 symbol / trade_date 级别的相关性。
pending outcome 同样重要。五天还没走完的信号,不是失败,也不是零收益。不同来源如果结算成熟度不同,只挑 completed 记录算一个漂亮胜率,会引入另一种偏差。
还要确认分数到底是在什么股票池里形成的。一个只覆盖大盘高流动性股票的来源,与一个覆盖小微盘甚至退市风险标的的来源,即便 score bin 相同,也未必处于可比较的总体。基本面字段的发布滞后、复权方式和样本退市处理,都可能悄悄改变标签或候选池。
这些细节不如“训练一个校准器”听起来新鲜,但没有它们,再复杂的映射也只是给脏口径增加小数位。
校准后的分数,需要两种不同的验收
有了可比数据,先做的是分来源、分市场和状态的原始分数分桶。每个分桶同时报告记录数、完成率、成功比例、中位收益和不确定性。之后才讨论是否需要把分数转换成 event probability。
如果各来源样本很少,我宁可保留来源内部排名,或考虑共享数据的分层估计与收缩,也不急着给每个 source × regime × session 单独拟合一条曲线。小桶上的 100% 常常只意味着样本不够,无法代表确定性。
有足够已结算数据时,可以从简单的 Sigmoid 映射开始,和更灵活的非参数方法比较。但无论选哪一种,校准器都不能拿拟合自己的同一段未来结果来自证。
scikit-learn 的概率校准指南介绍了 reliability diagram:对真正的概率预测,比较分桶平均预测概率与实际正标签比例。它也提醒,Brier Score 等评分规则同时受到校准性和区分能力的影响,不能仅凭一个数就声称“校准改善了交易表现”。
金融数据还多了一道时间边界。t 日出现信号,要等 t+5 日才能知道标签。如果校准器在训练期末尾用了包含验证期价格变动的 forward return,就算“训练日期”早于验证日期,也可能已经偷看未来。TimeSeriesSplit 官方文档说明了按时间顺序划分与 gap 参数,但实际需要按 horizon、交易日历、标签成熟时间和相关样本来确定间隔,不是机械写一个 gap=5 就万事大吉。
最后,我会把两种验收结果分开,而不是都叫“评分准确率”:
| 想回答的问题 | 需要的证据 |
|---|---|
| 高分是否更容易把未来较好的标的排到前面? | 分来源、分时间段的排序统计,Top vs Rest 的收益分布 |
| 估计事件概率 60% 的样本,在未来是否约有 60% 成功? | 时间外 reliability 分箱、样本数、校准误差、分组稳定性 |
一个规则分数可能很擅长排序,却根本不是概率;一个估计概率接近平均事件率的模型,也可能无法有效区分标的。Google 的 prediction bias 说明提供一个粗粒度的检查方法:看平均预测和真实标签均值的偏差。但整体均值对得上,并不意味着来源或分桶都校准好了。
更重要的是,概率不是收益。就算“5 日正收益概率”校准得很好,也没有说明赢家能赚多少、输家会亏多少、滑点有多大、回撤如何,以及实际组合是否有正的收益期望。高胜率完全可能搭配糟糕的盈亏比。
把经过校准的正收益概率直接变成“推荐买入分数”,可能只是把第二次误解包装成一张更精致的图表。
现在我不会急着给所有来源重新排一个大榜单
回到那两只 80 分的股票。
在项目工程上,给来源设计各自合理的评分剖面是有必要的。综合信号可以依赖技术共振,筛选器则应该按自己的规则拿分。这并不违反公平。真正缺失的一步,是在来源内部评分之后、跨来源统一推荐之前,说明这些分数有多大的可比较性。
下一轮如果有权限检查真实 outcome,我会先只读核对新的正常生产批次,排除部署验证和手动批次,确认每条评分对应的 model_version 与来源;固定市场、session 和 horizon,按已结算程度筛选,再分 score bin 和 regime 检查事件率、收益中位数、极端亏损、重复标的及样本覆盖。最后才轮到概率映射的时间外评估。
如果发现样本不足、来源混合偏差很大或模型已换版本,最实际的决定可能不是马上校准,而是继续保留 raw_score,并让来源、统计窗口与样本覆盖一起显示出来。
这也接上博客此前的两段研究:Bucket Spread 与 Rank IC 多周期验证讲单个因子有没有排序信息;Regime、Shadow 与权重漂移讲有了证据以后权重该不该上线。这次补上的,是夹在中间的问题:两套评分规则说的“强”,究竟是不是一种能比较的强?
真正要决定哪个来源排在前面,仍然要等同口径、已结算、时间外的真实 outcome。那之前,保留来源标签与原始评分,比给所有股票换算一个看似精确的统一胜率更稳妥。
我更希望读者从这篇带走的,不是“某个分数应该折算成几成胜率”,而是遇见统一评分列表时,知道先问一句:这个数字的来源、事件定义和样本分布,真的足以让它和旁边的数字放在一起比吗?