保真度关卡:由粗到细的回测会更快地愚弄你——除非廉价代理的排序方式与昂贵评估一致
本文属于"无幻觉回测"系列文章之一。
每一次认真的参数搜索都会撞上同一堵墙:参数空间极其庞大,而每一次诚实的评估都很昂贵。一个包含十几个阈值和三个指标周期的多时间框架策略,在多折 walk-forward 划分下评估,每个配置可能要花费数秒。一万个配置就是数小时。于是你会寻找捷径,而捷径永远是同一个想法:**先廉价评估,只把存活者提升到昂贵的测试中。**在一折数据上筛选一万个配置,保留排名前几百的,再让它们跑完所有折;在小时级K线上筛选,只对最终入围者下钻到1秒级别。
这就是由粗到细的搜索——下钻、多保真度、逐次减半、Hyperband、ASHA——它是优化领域里真正好用的想法之一,能把一天的算力开销压缩成一小时。我们自己就在用它。但它隐藏着一个极其隐蔽的陷阱,隐蔽到大多数使用它的人从未去检验过,这个陷阱是:**整套方法都假设你的廉价代理对配置的排序方式,和昂贵评估的排序方式是一致的。**如果不一致,你并不是在更快地搜索,而是在第一道关卡就把未来的胜者扔掉,转而提拔噪声——你只是在更快地自欺欺人。
本文要讲的是唯一一项能告诉你,你的下钻搜索究竟是真实有效还是自我欺骗的测量。我们在自己的多时间框架搜索上做了这项测量,结果令人不安:在最廉价的保真度下,我们的代理对配置的排序几乎是随机的(Spearman ρ ≈ 0.03)。如果我们在这一保真度下就大力剪枝——正如每个开箱即用的 ASHA 默认设置都在诱使你去做的那样——我们就会在第一轮就把最终的胜者淘汰掉。解决办法是在搜索开始之前运行一道强制性关卡:先测量排序相关性,并且拒绝在排序尚不可信的保真度以下进行剪枝。
多保真度搜索究竟是什么

这一整个方法家族都遵循同一种形态。你需要定义一个保真度(也叫资源或预算):一个从廉价到昂贵的旋钮,当它调到最大值时,给出真实的目标值。然后你在低保真度下运行各个配置,对它们排序,只保留最优的一部分,再只对这些存活者在更高保真度下重新运行——如此反复,直到少数几个最终入围者在完整保真度下完成评估。
逐次减半(Jamieson & Talwalkar, 2016)是其核心:以最小资源启动 N 个配置,保留排名前 1/η 的部分,将资源乘以 η,然后重复。Hyperband(Li et al., 2018)把逐次减半包裹在一个外层循环里,在不同的起始资源之间进行对冲,这样你就不用去猜该以多激进的力度剪枝。ASHA(Li et al., 2020)是异步、对并行友好的版本。淘汰率 η 是它们共有的唯一旋钮;标准的默认值是 η = 3——每一档保留前三分之一,把预算变为三倍。
保真度本身几乎可以是任何一种从廉价到昂贵、最终收敛于真值的量度:
- **walk-forward 的折数。**先在1折上评估,再2折、3折,直到K折。这是我们下文要研究的保真度,因为它对于带有滚动样本外划分的回测来说是最自然的一种。
- **K线分辨率。**在1小时K线上筛选,提升到1分钟级别,只对最终入围者下钻到1秒级别或原始成交数据。这是把自适应下钻的思路应用于搜索,而不是应用于成交撮合仿真。
- 历史长度、训练轮数(epoch)、数据集子采样——这些是经典的机器学习保真度。
在回测中,折数和分辨率是最重要的两种保真度,而——这正是整篇文章的核心——**它们并不是同等安全的。**其中一种存在偏差,无论你把关卡设置得多么谨慎,它都会悄悄地选出错误的参数。
一切都依赖的那一个假设

让我们明确写下逐次减半要想正确运作,究竟需要什么。它并不需要廉价保真度给出正确的目标数值——没有人在乎一折报告的夏普比率和六折不一样。它需要的是一种更弱、但也更具体的东西:廉价保真度必须以和昂贵保真度相同的方式对配置进行排序。如果配置A在完整保真度下胜过配置B,那么A在低保真度下也应该倾向于胜过B。仅此而已。但这也就是全部。
从形式上说,决定下钻搜索是否有效的量,是在整个配置空间上、低保真度目标与高保真度目标之间的排序相关性。如果这个相关性很高,廉价轮次的存活者就是本应在昂贵轮次中获胜的那些配置,你就白白省下了算力。如果它很低,廉价轮次就是一个随机过滤器:它会淘汰好的配置、提拔差的配置,而你则是把预算花在了加速奔向一个更差答案的路上。没有任何中间的 η 设置能救你——一个糟糕的代理,即便温和地剪枝也仍会漏掉胜者;剧烈剪枝的话,则会让胜者大量流失。
这种失败会以一贯的方式隐而不现。搜索照常完成,报告出一个冠军,冠军在样本内看起来也不错。没有任何东西会抛出错误。你只有在冠军在样本外崩溃时,才会发现原来代理一直在撒谎——而到那时,你早已把失败归咎于策略本身,而不是选出这个策略的搜索过程。因此,这里的准则和本系列一再得出的准则是同一个:**在信任一个方法之前,先测量该方法所假设的那件事。**对于下钻搜索而言,需要测量的就是排序相关性,而这项测量的成本很低。
测量它:保真度关卡

这道关卡是一个小实验,你在正式搜索之前,在你即将搜索的同一个空间上运行一次。步骤如下:
- 从参数空间中随机抽取几百个配置(我们使用约200个——足以给出稳定的Spearman估计,成本也足够低,负担得起)。
- 在每一个保真度档位上评估每个配置:1折、2折……直到完整的K折。对于以折数计的保真度而言,这几乎是免费的,因为你在廉价档位上计算出的折,会在昂贵档位中被复用——这是一种累积平均。
- 对每个档位
r,在这约200个配置上,计算 r折排序与完整K折排序之间的Spearman 排序相关系数。 - ρ 首次跨过阈值(我们使用 ρ ≥ 0.5)的那个档位,就是你被允许开始剪枝的最浅保真度。低于这个档位,排序的噪声太大,不可信任;你绝不能在那里淘汰配置。
整个实现只有二十来行代码。其核心部分如下:
def fidelity_check(cache, n_probe, seed=7):
"""Spearman ρ: cumulative mean over the first r folds (in FOLD_ORDER)
vs the full K-fold objective, on n_probe random configs."""
rng = np.random.default_rng(seed)
k = len(FOLDS)
per_fold = np.empty((k, n_probe))
order_win = np.array([list(FOLDS[fi]) for fi in FOLD_ORDER], np.int64)
for j in range(n_probe):
p = _unit_to_params(rng.random(len(PNAMES))) # random config
scores, *_ = eval_group(cache, p, _sp_of(p)[None, :], order_win, False)
per_fold[:, j] = scores[0] # score on each fold
cums = np.cumsum(per_fold, axis=0) / np.arange(1, k + 1)[:, None] # r-fold mean
rhos = []
for r in range(1, k):
rho = spearmanr(cums[r - 1], cums[-1]).statistic # r folds vs all K
rhos.append(0.0 if math.isnan(rho) else rho)
return rhos
注意一个刻意设计的细节:这些折是按照 FOLD_ORDER 的顺序取用的,这是一种交错顺序,在日历上早期和晚期的切片之间交替(先是折0,然后是一个中间的折,再是折1,然后是一个更晚的折……)。这一点极其重要,也是后文一节要讨论的主题:它意味着"1折"是横跨历史中段的一个切片,而"2折"是一个早期加一个晚期的切片——绝不是一段连续的近期窗口。廉价保真度之所以更便宜,是因为使用了更少的折,而不是更近期的折。
结果:单折的排序几乎是随机的

下面是这道关卡在我们两套多时间框架配置上运行后所报告的结果。表中数字是在约200个随机配置上、r折排序与完整K折排序之间的 Spearman ρ——衡量廉价保真度排序对完整保真度排序的预测有多忠实。
折数 r |
多时间框架运行 | 更困难的行情 |
|---|---|---|
| 1 | +0.43 | +0.03 |
| 2 | +0.67 | +0.43 |
| 3 | +0.78 | +0.78 |
| 4 | +0.82 | — |
| 5 | +0.91 | +0.91 |
先看"更困难的行情"这一列,因为它才是令人警觉的那个。**在一折时,ρ = 0.03。**这不是一个微弱的相关性;这根本没有相关性——两百个配置在一折上的排序,在统计上和把它们随机打乱是无法区分的。一个(如大多数默认设置那样)配置为从 min_resource = 1 开始剪枝的逐次减半运行,在这种行情下,会在第一轮、也是最激进的一轮剪枝中,凭抛硬币来决定去留。那些最终会在完整保真度下获胜的配置,在最初这一轮中存活下来的概率只有五五开。等你到达排序真正有意义的保真度(三折时 ρ = 0.78)时,其中大多数早已被淘汰。
多时间框架这一列的情况温和一些,但从另一个角度说明了同一个道理。即便在那里,一折也只给出 ρ = 0.43——低于我们设定的 0.5 关卡。它看起来像是一个还不错的相关性,而这恰恰就是危险所在:0.43 高到足以让你放松警惕,又低到足以泄漏掉你最好的配置。只有到两折(ρ = 0.67)时,排序才变得可信。
从中可以归纳出两点。第一,一折时的 ρ 依赖于具体行情,且不可靠——我们在不同配置上测出的值从 0.03 到 0.43 不等,且没有一个案例中单独一折能达标。第二,ρ 单调且快速地上升:到三折时,两套配置都达到了 0.78,到五折时,二者都收敛到 0.91。信号确实存在;你只需要投入足够的保真度就能看见它。这道关卡的作用,就是找到"足够"开始出现的那个确切档位——并禁止在此以下进行剪枝。
为什么单折会如此嘈杂
这种噪声并不是我们折构造方式中的一个缺陷;它是内在固有的,理解这一点能让你不会用错误的方式去"修复"它。单个 walk-forward 折是一个很短的窗口——只是某一种市场行情下的几周时间。策略在这个窗口上的得分,主要由其参数恰好多大程度上贴合了那种行情所主导,而这和参数的泛化能力只有松散的关系。两个质量真正不同的配置,可能仅仅因为其中一个恰好抓住了那一折恰好包含的某个趋势,就在单折上互换名次。单折上的目标值,是你真正关心的那个目标值的一个高方差估计量,而排序相关性恰恰是高方差最先摧毁的东西——你可以让一个估计量的均值保持不变,同时它的排序却纯属噪声。
增加折数会把这种方差平均掉。每一折都是对市场状况的一次部分独立的抽样;r折均值是一个方差更低的估计量,其排序也会向完整保真度的排序收敛。这正是 ρ = 0.03 → 0.43 → 0.78 → 0.91 这一路攀升的原因:不是目标值本身在变化,而是随着行情特有的运气被平均掉,其排序估计逐渐趋于稳定。由此得到的教训是,对于回测而言,保真度从根本上关乎你采样到了多少个独立的行情——而单独一个行情几乎从来都不足以用来排序。
这也解释了为什么"更困难的行情"从 0.03 起步,而多时间框架运行则从 0.43 起步。在更困难的行情中,单折更具行情特异性——各配置在单折上的得分更多是由运气驱动,而较少由持久的优势驱动,因此它们的排序更接近随机。这道关卡会自动读出这种差异,并以要求更多折数才允许剪枝作为回应。你不必事先知道自己身处哪种行情——去测量就好。
代码中的关卡:自动提升最低保真度
这道关卡的输出是一个单一整数:min_resource,即 ASHA 被允许开始剪枝的最浅保真度。规则是机械式的——遍历各个档位,取第一个 ρ 越过阈值的档位:
RHO_GATE = 0.5
min_res = len(FOLDS) # default: pruning OFF (full fidelity)
rhos = fidelity_check(cache, n_probe=200) # [ρ@1, ρ@2, …, ρ@(K-1)]
passing = [r for r, rho in enumerate(rhos, 1) if rho >= RHO_GATE]
if passing:
min_res = passing[0] # first rung that clears the gate
pruner = SuccessiveHalvingPruner(min_resource=min_res, reduction_factor=3)
让我们把这两次运行代入其中追踪一遍。在多时间框架配置中,ρ@1 = 0.43 未能通过关卡,但 ρ@2 = 0.67 通过了,因此 min_resource 被自动提升为 2:ASHA 会让每个配置至少跑完两折之后,才被允许淘汰任何配置,此后再照常剪枝。在更困难的行情中,ρ@1 = 0.03 和 ρ@2 = 0.43 都未通过;ρ@3 = 0.78 是第一个通过的,因此 min_resource 变为 3。还有一个至关重要的兜底机制:如果没有任何档位达到 0.5,passing 就是空的,min_resource 就会保持在 K——剪枝被彻底关闭,搜索会优雅地退化为一次纯粹的完整保真度搜索,而不是一次快速却错误的搜索。一次无法证明自己代理有效的下钻搜索,就干脆拒绝剪枝。
这一整套理念,浓缩在一行控制流里就说完了。每个逐次减半库的默认做法都是"从 min_resource = 1 开始剪枝,信任那个廉价档位"。这道关卡把它替换成了"从数据证明可信的第一个档位开始剪枝,如果没有一个档位可信,就不剪枝"。它的代价是预先做一次约200个配置的探测,而它把下钻搜索从一种信仰行为,转变为一个经过测量的决策。淘汰率 η = 3 保持不变;这道关卡不会触碰你剪枝的力度,只触碰你被允许多早开始剪枝。
上面代码里有一条诚实的注脚:提高 min_resource 会侵蚀掉一部分加速效果。以3折而不是1折开始剪枝,意味着每一个配置——包括那些最终会被淘汰的——都要付出三折的计算代价。这就是正确性的代价,而这是一笔划算的交易:一个能找到真正胜者、只有2倍加速的搜索,胜过一个把胜者都淘汰掉、有6倍加速的搜索。这道关卡把这笔交易明明白白地摆出来,而不是把它藏起来。
错误的廉价轴:缩短历史长度是一个陷阱
有一种廉价保真度既诱人又显而易见,但却是有偏的,它值得被点名指出,因为每个人都会第一个想到它:**缩短历史长度。**在最近一个月的数据上筛选配置,把存活者提升到完整两年的数据上。这种做法便宜得不费吹灰之力,感觉上和减少折数是同一个想法。但其实并不是。
减少折数和缩短历史长度之间,存在一个决定性的差异。一个折数保真度,如果构造得当,依然横跨整个日历——它只是采样得更粗略而已。而一个缩短历史长度的保真度,是对日历的某个子区间进行密集采样。而市场历史的一个子区间,就是一种特定的行情。当你在最近一个月的数据上给配置排序时,你得到的并不是它们全周期排序的一个有噪声、但无偏的估计;你得到的是一个有偏的估计,它系统性地偏袒那些为最近一个月的行情调好参的配置。无论你把样本量提到多大——在更多配置、更多试验上取平均——偏差都不会缩小,因为它根本不是方差问题。你会提拔那些最贴合筛选窗口的配置,而这些配置恰恰最可能是对短暂行情的过拟合。
这就是为什么我们的保真度是按交错顺序、而不是按时间顺序遍历各折的。当K折分布在整段历史上时,"1折"是靠近中段的单个切片,"2折"是一个早期切片加一个晚期切片,"3折"则铺开早期/中期/晚期——每一个保真度级别,即便是最廉价的那个,也横跨整个日历采样。廉价代理是对整段周期的一种粗略视图,而绝不是对其中某一段切片的清晰视图。正是这种交错,使得折数保真度只是有噪声的(这道关卡可以治愈),而不是有偏的(任何关卡都无法修复这一点——针对一个有偏目标测出高 ρ,只能证明你会稳定地选中那个贴合特定行情的配置)。**如果你让一个保真度变廉价的唯一办法,是让它变得更接近当下,那你手里根本没有一个有效的保真度。**应该让它变得更粗略,而不是更近期。
分辨率——诚实的廉价轴
另一条廉价的轴则完全避开了这种偏差,它是我们在成交撮合仿真中所描述的自适应下钻的天然搭档:**K线分辨率。**在1小时K线上对整个空间进行筛选,把存活者提升到1分钟级别,只对少数几个最终入围者下钻到1秒级别或原始成交数据。在相同的完整历史上使用更粗的K线,评估成本更低——K线数量更少、指标计算更快、仿真更快——而且,和缩短窗口不同,对整个日历的粗略视图是无偏的:它能看到每一种行情,只是K线内部的细节更少。
分辨率和折数是两条互补的保真度轴,而保真度关卡对两者都适用。在信任1小时筛选之前,运行同样的探测:取约200个配置,分别在1小时和1分钟级别上打分,测量排序相关性。如果 ρ(1小时, 1分钟) 很高,那么按小时筛选是安全的,你诚实地换来了一次可观的加速。如果它很低——这种情况发生在策略的优势存在于K线内部结构、而小时级K线把这些结构抹平的时候——那么小时级筛选对这个策略而言就是一个随机过滤器,关卡会告诉你需要从更精细的粒度开始。规则永远不变:只有在你测量出某个保真度的排序与真值一致之后,你才被允许在该保真度上剪枝。
这两条轴的失效方式也恰好相反,这一点很有用。粗略的分辨率会丢失K线内部的信息;较少的折数会丢失跨行情的信息。一个日线级别的动量策略,可能在小时级K线上排序完美,却需要很多折才能把行情运气平均掉;一个剥头皮策略,可能在很少的折数上排序就很好,但一旦分辨率粗于1秒级别就会崩溃。按轴分别运行这道关卡,会告诉你对于这个策略,你在哪条轴上可以放心地节省成本——而不是假定一个恰好方便你的答案。
节省的算力究竟从何而来
有一条注意事项能让下钻搜索保持诚实:**一个保真度只能在成本实际所在的地方节省算力。**在我们的多时间框架引擎中,昂贵的部分是预先计算指标——多时间框架的 HMA 和分离信号——这部分成本是每个配置只支付一次,在任何折运行之前就已经付出。而基于缓存信号的逐折仿真则相对便宜。所以按折数剪枝,节省的只是仿真成本,而不是占主导地位的指标计算成本;折数保真度是真实有效的,但它的上限要比原始的折数比例所暗示的要低。
相比之下,分辨率轴直接冲着占主导地位的成本下手:更粗的K线意味着计算指标所依据的K线数量更少,于是昂贵的预计算和廉价的仿真会一起缩小。这不是一个细枝末节——它决定了哪种下钻值得去构建。在投入构建多保真度搜索之前,先问问你的时间都花在了哪里。如果90%都是各折共享的指标预计算,那么折数保真度带来的收益很有限,而分辨率保真度则能带来巨大的收益。先做性能剖析;正确的廉价轴,是那条能真正去除你实际拥有的成本的轴,而且它仍然必须通过这道关卡。
与其他内容的联系
保真度关卡在本系列关于回测卫生的链条中,处于一个特定的位置:
- 它处于**过拟合控制的上游。**一个依据糟糕代理进行剪枝的下钻搜索,是一种新的过拟合方式——你是在让一个充满噪声的早期档位去挑选你的最终入围者。冠军仍然必须经受住缩减夏普比率和PBO的检验,而且输入这些关卡的试验计数,必须包含每一个被剪掉的试验,而不仅仅是存活者——剪枝并不能让一次试验从多重检验的账本中被免除。
- 它和平台期分析有着共同的敌人:一个只在某一折或某一个筛选窗口中获胜、别处都不行的配置,正是这两种工具都存在的目的所在——拒绝这种贴合特定行情的产物。这道关卡拒绝在这样的一折上进行选择;平台期分析则拒绝信任一个仅仅立足于这样一折的冠军。
- 它以底层诚实的walk-forward 划分为前提——折横跨整个日历,样本外部分被单独留出——而它是我们为成交撮合仿真所构建的下钻搜索,在搜索阶段的对应物:同样的由粗到细原则,只是应用于评估哪些配置,而不是应用于以多精确的方式去撮合它们。
- 而且它完全依赖于每一个档位的保真度都不存在泄漏。如果廉价档位存在昂贵档位所没有的前视偏差,那么 ρ 衡量的就是与一个被污染的目标之间的一致性,而这道关卡也就在为一次泄漏背书。是的,要测量排序相关性——但必须建立在一个诚实的目标之上。
再一次强调那个贯穿全文、本系列会不断重复的核心理念:回测是一场统计实验,而其中的每一个捷径,都是一个你有义务去检验的假设。下钻搜索的假设是"廉价排序 ≈ 昂贵排序"。这个假设用约200个配置就能检验。去检验它。
要点总结
- **由粗到细的搜索建立在一个假设之上:廉价代理对配置的排序方式和昂贵评估一样。**不是数值相同——而是排序相同。如果排序相关性很低,激进的剪枝就会淘汰掉你未来的胜者,转而提拔噪声。你只是在更快地朝着一个更差的答案搜索。
- **在信任它之前先测量它。**抽取约200个随机配置,在每个保真度档位上打分,计算每个廉价档位与完整保真度排序之间的 Spearman ρ。这只需要二十来行代码和一次低成本的探测。
- **单折的排序几乎是随机的。**根据行情不同,我们测得 ρ@1 从 0.03(相当于抛硬币)到 0.43(仍低于可信线)不等;随着折数累积,它会攀升到 0.67、0.78、0.82 和 0.91。对回测而言,每个开箱即用的 ASHA 所默认的
min_resource = 1,通常都是错的。 - **将最低保真度自动提升到 ρ ≥ 0.5 的第一个档位,如果没有一个档位达标,就干脆完全不剪枝。**这道关卡在一套配置中变成了
min_resource = 2,在另一套更困难的配置中变成了3;兜底机制会优雅地退化为完整保真度搜索。正确性是要付出一些加速代价的——付就是了。 - **选择廉价轴要看它是否有偏,而不是看它是否便宜。**缩短历史长度是有偏的——它会选出贴合特定行情的参数,而任何样本量都无法修复这一点。应该使用横跨整个日历的更少折数(交错,而不是连续),或者在完整周期上使用更粗的分辨率。并且,要把廉价轴用在你实际成本所在的地方。
下钻搜索是回测中最好的加速手段之一,也是最容易变成一种更快自欺方式的手段之一。这两者之间的差别,就是一个你可以在搜索开始之前测出来的数字。如果你的代理无法证明它的排序方式和真值一致,那它就不是一个代理——它只是一个带着看似合理成本的随机数生成器。
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.