拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

样本内外划分:量化策略防过拟合的第一道防线

样本内外划分:量化策略防过拟合的第一道防线

1. 为什么样本内外划分是防止过拟合的第一道防线

一个量化策略在回测里跑出漂亮的净值曲线,夏普比高得吓人,偏偏一上模拟盘就原形毕露。这种经历,做量化的人多少都遇到过。问题大概率不在策略逻辑本身有多离谱,而在于你没有做样本内外划分,或者划分得不够干净。

样本内外划分的本质,就是把数据强行切成"训练题"和"考试题"两个集合。训练题用来开发策略、确定参数,考试题用来检验策略在没见过的数据上到底行不行。这个思路听着简单,但大部分策略失效事故,恰恰是因为训练题和考试题混在了一起,或者干脆没给自己留考试题。

1.1 样本内样本外的本质区别

样本内数据(In-Sample,IS)是策略开发过程中看到的全部历史行情,参数优化、入场规则筛选、出场逻辑设计,全都基于这段数据完成。样本外数据(Out-of-Sample,OOS)是策略从头到尾没有参与计算、没有参与调参的数据区间,它模拟的是"策略上线之后的未来"。

两者的关系很像学生备考和模拟考试。样本内就是反复刷题的过程,题刷得再多,只能说明把历年真题背熟了;样本外则是一套从来没见过的新题目,考出来的分数才反映真实水平。量化策略如果只在样本内表现好,样本外一塌糊涂,那就是典型的背题背多了——模型把历史行情里的噪声当成规律记住了,真实市场不给你这种好事。

所以做策略评估时,我心里始终有一根弦:所有收益指标、回撤指标,我都以样本外的为准。样本内数字再好看,也只当参考,因为它天然是乐观的。

1.2 过拟合是怎么被调参喂出来的

很多人以为过拟合是模型太复杂才导致的,其实在量化策略开发里,过拟合更常见的来源是"参数搜索次数太多"。

假设你在同一段数据上尝试了50组均线参数组合,每组都跑一遍回测,最后留下表现最好的那组。这50组里总有一组恰好踩中这一段行情的节奏,收益自然会非常高。但这个高收益里有相当大部分是运气成分,而不是策略本身有优势。尝试的次数越多,找到"幸运参数"的概率就越大。学术上管这个叫多重检验问题(Multiple Testing Bias),通俗说就是:猴子在键盘上敲得足够久,也能敲出一段像样的旋律。

更隐蔽的是,很多人在调参过程中还会不断修改策略规则:看到回撤大就加一条过滤条件,看到某年亏损就针对那一年补一个逻辑。每改一次,其实都是对同一段数据做了一次新的拟合。改到最后,策略在历史上处处合理、无懈可击,但那段历史已经不再代表真实规律,因为你的规则本身就是从这段历史里"反推"出来的。

样本内外划分恰恰能挡住这种慢性污染。因为样本外是你反复修改规则的这段时间里一直碰不到的数据,等策略完全定型了再拿出来检验,如果还能保持稳定盈利,那才是真有东西。

1.3 不划分或者划分不清的代价

直接后果就是策略实盘表现和回测表现之间出现巨大剪刀差。回测里年化30%、最大回撤5%,实盘变成年化5%、最大回撤30%,这种事情放在任何资金体量上都是灾难。轻则白费半年开发时间,重则让交易者对自己的系统彻底失去信心,在一波正常回撤里砍掉仓位,反而错过后续的利润。

我见过不少新手把全部数据拿来回测,然后在同一条曲线上自我感动,完全忽略了一个基本事实:那条净值曲线的每一段,都已经被你的策略参数"看见"过了。真实交易的时候,市场永远给你出新题,可你的策略只在旧题上练过。

2. 主流的样本内外划分方案与取舍

划分方案不是随便切一刀就完事,不同方案对应不同的策略开发阶段和检验目的。我自己常用的有三种方案,每种后面都有它的适用场景。

2.1 按时间顺序做一次性留出划分

这是最朴素也最容易理解的方法:把完整时间序列按先后顺序,前面一段作为样本内,后面一段作为样本外。行情数据天然有序,绝对不能随机打乱再切分,否则样本外区间里的"历史信息"会被无意中泄露到训练过程里。

比例上常见的做法是七三开或者八二开,但这只是习惯,不是铁律。两个更重要的原则:一是样本外区间必须足够长,长到能覆盖至少一轮完整的市场周期,比如包含牛熊转换、震荡整理、趋势行情,否则检验结果会有较大偶然性;另一个是样本外的起点之前要留有"预热区"(burn-in period),因为很多指标在计算初期需要足够的历史数据才能稳定。

一次性划分的优点是简单、快、可复现,适合在策略开发早期用来做粗筛。缺点是它只检验了一次"考试",万一样本外那段时间恰好顺风,就容易高估策略的真实水平。

2.2 Walk-Forward滚动式划分

Walk-Forward又叫滚动样本内外检验,逻辑是模拟实盘的连续决策过程。具体做法是设定一个训练窗口和一个测试窗口,比如用前36个月训练,测试未来6个月,测试完把训练窗口整体向后滑动6个月,再训练再测试,直到覆盖完整个数据区间。

这种方法比一次性划分更贴近真实交易,因为实盘就是在不断用最近的数据训练参数、然后用于未来交易。最终把每一段样本外的交易结果拼接成一条完整曲线,这条曲线代表的是策略在"未知未来"上连续运行的表现,可信度远高于一次性划分。

代价是计算量成倍增加:每滚动一次,就要对全部候选参数重新做一轮优化。如果策略参数多、数据频率高(比如分钟级),跑下来可能要几个小时。但我觉得这笔时间是值得的,尤其是进入策略中期验证阶段,Walk-Forward结果几乎是我决定一个策略是否继续推进的核心依据。

这里面还有个细节:训练窗口用固定长度还是扩展长度?固定窗口适合市场结构会漂移的品种,比如商品期货,太久远的数据参考意义下降;扩展窗口则让训练样本越来越多,适合相对稳定的标的。没有标准答案,取决于你在什么市场交易。

2.3 K-Fold在时序数据上的坑与替代方案

很多人把机器学习里的K折交叉验证直接搬到时序数据上,这是高风险操作。标准的K-Fold是随机把样本打乱后分成K份,依次取一份做验证、其余做训练。但行情数据不是独立同分布的,相邻两天的价格高度相关,前一天的价格直接影响后一天。一旦随机打乱,就会发生"用未来信息训练、验证过去数据"的穿越,得到的验证分数虚高,而且完全无法反映真实交易的时间顺序。

针对时序数据,业界也发展出了专门的交叉验证方式,比如按时间顺序划分的TimeSeriesSplit,以及解决标签重叠问题的Purged K-Fold。Purged的思想是:训练集和验证集之间要留出一段"禁运区",不让训练集样本的标签信息通过重叠的时间窗口"漏"到验证集。涉及机器学习模型的策略,尤其是预测第二天涨跌这类任务时,这种划分非常关键。不过对大多数用规则、指标做信号的策略,Walk-Forward已经是最合适的折中方案了。

划分方案优点缺点主要适用场景
一次性留出划分简单、快速、易复现只检验一次、偶然性高策略早期粗筛、快速试错
Walk-Forward贴近实盘、可信度高计算量大、实现复杂策略中期验证、参数稳定性检查
Purged K-Fold适合机器学习模型、防标签泄漏实现门槛高、不适用于规则策略预测型模型、特征工程验证

3. Python实操:从划分数据到完整样本外评估

光讲理论不落地等于白说。下面我把一套完整的样本内外划分流程写成代码,全部用Python实现,环境只需要pandas、numpy和matplotlib,非常轻量。

3.1 数据准备与基准划分函数

我习惯把数据统一整理成DataFrame,索引是时间,列至少包含open、high、low、close、volume。第一步就是按时间排序,防止数据源本身乱序,然后写一个按日期切分的函数。

import pandas as pd import numpy as np from datetime import datetime def split_by_time(df: pd.DataFrame, split_ratio: float = 0.7): """ 按时间顺序划分样本内和样本外 df: 按时间升序排列的行情数据 split_ratio: 样本内占比 """ df = df.sort_index() split_idx = int(len(df) * split_ratio) is_df = df.iloc[:split_idx] oos_df = df.iloc[split_idx:] return is_df, oos_df

注意,划分之前一定要确认数据里没有未来函数。比如计算均线指标时,我的做法是保证每个K线对应的指标值都只用这个K线之前的数据计算,这是最基本的底线。发生过一个真实案例:我把一个信号生成代码里写成了shift(-1),等于把下一根K线的收盘价拿来计算当前信号,回测成绩异常好,好到我自己都不敢信,一查才发现是这种低级泄漏。这类问题在样本内外划分之前就必须排查干净,否则后面所有工作都是空中楼阁。

3.2 实现Walk-Forward回测

一次性划分代码太简单,重点说Walk-Forward的实现思路。整体框架分四步:切片训练窗口、优化参数、在测试窗口生成信号、拼接样本外收益。

def walk_forward_backtest(data: pd.DataFrame, train_days: int = 720, test_days: int = 120, step_days: int = 30, param_grid=None): """ 简化的Walk-Forward框架 train_days: 训练窗口长度(K线根数) test_days: 测试窗口长度 step_days: 滚动步长 param_grid: 候选参数网格 """ results = [] start = 0 total_len = len(data) while start + train_days + test_days < total_len: train = data.iloc[start:start + train_days] test = data.iloc[start + train_days:start + train_days + test_days] # 在训练窗口内寻找最优参数 best_params, best_score = optimize_on_in_sample(train, param_grid) # 用最优参数在测试窗口生成信号并计算收益 oos_return = run_strategy(test, best_params) results.append({ 'train_start': train.index[0], 'test_end': test.index[-1], 'params': best_params, 'oos_return': oos_return }) # 窗口滚动 start += step_days return pd.DataFrame(results)

这个框架的精髓在嵌套循环的外层只做一件事:切窗口、调参、测试,然后滚动。关键是best_params的选择仅限于训练窗口内部,测试窗口的数据在参数优化阶段不可见,一次都不可见。

实际跑的时候,step_days这个参数我建议设成和test_days一致,也就是把测试窗口向后完整滑动,每一步测试区间不重叠。如果步长小于测试窗口,那么相邻两段样本外会有重叠,统计上会引入一定相关性,但不是致命问题。步长大于测试窗口则中间会有空档,覆盖不够完整。

3.3 样本外拼接与绩效评估

Walk-Forward跑完之后,把每一段样本外的日收益序列拼接成一条完整的样本外资金曲线。这里有个容易踩的坑:每一段测试窗口虽然不重叠,但拼接时要把各段内的收益率按复利方式连接,而不是简单相加。

def concat_oos_returns(segment_returns: list): """把各段样本外收益序列按照时间顺序拼接,并折算为净值曲线""" # 先按时间排序 all_returns = pd.concat(segment_returns).sort_index() # 计算累计净值 equity_curve = (1 + all_returns).cumprod() return equity_curve, all_returns

拿到样本外净值曲线之后,再算年化收益率、夏普比率、最大回撤、卡玛比率这些指标。但记住,这些指标只对样本外区间有意义,别再把样本内的绩效一起混进来算总账。我通常只报告样本外绩效,因为那才是"如果当初上线了,实际会赚多少钱"。

还要注意一点:参数网格不能太密。我在训练窗口里做参数寻优时,网格粒度如果过细,即使划分再严格,也同样会在训练窗口内过拟合。网格的意义是选出一个稳定可行的参数区域,不是选出唯一的最优点。所以候选参数我一般控制在几十组以内,而不是上万组。

4. 判断策略是否过拟合的几项硬指标

划分数据只是手段,最终目的是判断策略有没有过拟合。光看样本外赚不赚钱还不够,我需要一组更细的指标来辅助判断。

4.1 样本外衰减比

样本外衰减比的定义很简单:样本外年化收益率除以样本内年化收益率。这个比值越接近1,说明策略在训练集和测试集上的表现越一致,泛化能力越强。如果比值低于0.5,说明样本外表现大幅衰减,策略大概率过拟合了。

我自己常用的阈值是:0.8以上可以放心推进;0.5到0.8属于需要进一步研究的状态,可以调整一下参数区间或者规则,但要警惕调整过程中又把样本外信息用进去;0.5以下基本判死刑,除非有极强的逻辑支撑,否则不建议在实盘上投入。

这里强调一句,衰减比只反映收益率变化,还要结合回撤一起看。有时候样本外收益没降多少,但最大回撤翻了一倍,这种策略能承受的风险和回测时预估的完全不是一回事。

4.2 参数高原与参数敏感性

过拟合策略的典型特征是参数敏感:参数稍微偏离最优值,绩效就断崖式下降。而稳定策略的特征是存在"参数高原",也就是在一个连续的参数区间内,策略表现都比较接近,即使最优参数不是唯一的,随便取一个邻近值也不会差太多。

实操上,我会把最优参数附近邻域的绩效做成热力图来看。横轴是参数A,纵轴是参数B,颜色表示绩效。如果图中的高绩效区域是一块连续的、面积不小的区域,说明策略有参数高原,可靠;如果只有一个孤立亮点,周围全是深色,说明这策略是靠单点运气撑起来的。这个习惯帮我否定过好几个看似完美的策略。

def param_sensitivity_map(is_data, param_a_range, param_b_range, metric_func): """计算参数邻域绩效矩阵,用于观察是否存在参数高原""" heat_matrix = np.zeros((len(param_a_range), len(param_b_range))) for i, a in enumerate(param_a_range): for j, b in enumerate(param_b_range): heat_matrix[i, j] = metric_func(is_data, a, b) return heat_matrix

4.3 蒙特卡洛置换检验

还有一种更严格的方法,用随机性做对照:把策略产生的交易信号在时间轴上随机打乱很多次,每次重新计算绩效,得到一组"纯随机运气"下的绩效分布。如果真实策略的样本外绩效显著优于这个随机分布的95%分位,说明策略确实有正期望;如果真实绩效落在随机分布中间,那基本可以断定当前收益只是运气,趁早放弃。

思想很朴素,但代码实现要注意随机数种子固定,保证结果可复现。我还习惯做1000次置换,次数太少了分布不够稳定,次数太多了计算压力大。

信号打乱的时候要按"交易方向序列"打乱,而不是把日收益率直接打乱。因为日收益率之间的自相关性会干扰检验结果,打乱信号再按原始行情映射,才能近似生成一组"独立同分布假设下的随机策略"。

4.4 样本内外的夏普比率对比

单独讲一下夏普比率,因为它是很多人评估策略的第一指标。样本内夏普2.5,样本外夏普0.8,这中间差了1.7,已经足够说明问题。我一般要求样本外夏普至少要在样本内夏普的60%以上,才考虑上实盘。

还有一个实战小技巧:把样本外区间细分成两段,分别计算夏普比率。如果前段很高、后段很低,说明策略的有效性可能正在衰减,或者样本外区间里恰好包含了一段与该策略适配的行情。与其纠结是哪种情况,不如直接再拉长样本外数据做二次确认。

5. 实操中防过拟合的典型坑与排查技巧

前面讲的都是正面做法,这节专门讲我在实践里踩过或见过的坑。每一条都是真金白银换来的教训。

5.1 前视偏差的三种常见形式

最常见的一种,是特征计算时用到了未来数据。比如计算某根K线的"当日动量",代码里却包含了当日收盘价与次日开盘价的差。检查方法很简单:把指标计算函数的输入全部shift(1)处理后再跑一遍回测,如果绩效发生明显变化,说明原代码里很可能存在未来函数。

第二种,是数据预处理阶段用全样本统计量归一化。比如用整个数据区间的均值、标准差去归一化样本内外的特征,这会让样本外的特征值包含未来信息。正确做法是只用样本内数据算统计量,然后把这个统计量应用到样本外。

第三种,是逐根K线实时计算指标时,指标本身使用了"未来窗口"的滚动值。金融数据处理里常见的是用rolling(window, center=True),加了center参数等于偷看了未来数据。我习惯写一个自查清单:所有指标计算完成后,在某个随机日期上手动推演一遍信号,看是否和程序输出一致。

5.2 幸存者偏差与退市数据

做股票策略时,样本数据里如果只包含当前还在上市的股票,就会产生幸存者偏差。那些已经退市、暴跌的股票没有进入历史回测区间,策略的历史表现自然会虚高。这是样本内外划分上看不出来的问题,因为划分针对的是时间维度,而幸存者偏差是标的维度上的系统性遗漏。

期货策略相对好一点,但也有类似问题:某个合约退市后,如果策略在旧合约上交易,历史数据是否完整、复权方式是否合理,都会影响绩效。解决问题的唯一方法是选用包含退市标的的全量历史数据,或者至少在文档里明确标注这一限制。

5.3 在样本外反复调参等于没有样本外

这个坑特别隐蔽。有些开发者做了样本外划分,但第一次样本外测试结果不理想,就开始动手改参数、改规则,再跑一次样本外,直到结果满意为止。这个循环跑上十几轮之后,样本外其实已经被污染了——它不再是一套没见过的"新题",而是另一套被背过的旧题。

为了避免这种情况,我给自己立了两条规矩。第一,样本外数据只允许跑一次,跑完无论结果好坏,这个区间都不能再参与任何调整。第二,如果第一次结果不好,改策略时必须使用更早的样本内数据重新做交叉验证,绝不能回头动样本外。这样一来,一次策略开发周期至少要准备两条独立的样本外区间:一条在开发阶段检验用(即使只用一次),一条留着上线前最终验收用,这条最终验收的数据谁都不能碰,包括我自己。

5.4 工具侧与实盘侧的适配问题

现在不少量化终端和平台都提供了策略测评功能,一键就能出回测报告,非常方便。但要注意,平台生成的回测曲线如果不告诉你它是基于样本内还是样本外,默认就是全样本。全样本的结果参考价值有限,只能当作初步评估。

用Python写好本地策略,再映射到无限易这类量化终端上实盘执行时,我习惯把策略评估和策略执行两部分拆开:评估部分只负责参数与绩效检验,使用严格的样本内外流程;执行部分负责落地到实盘交易,完全复用评估部分确认过的参数。凡是涉及参数变化,一律回到评估流程里先验证,不允许在终端里凭感觉改参数。这样才能保证实盘交易参数和回测验证参数的一致,不至于"回测一套,实盘另一套"。

6. 样本内外划分的进阶组合技巧

基础框架熟练之后,我发现有几个进阶操作能进一步提升划分的有效性,这里一并分享出来。

6.1 不同市场环境下分别测样本外

单一时间段的样本外覆盖不了市场全部状态。我的做法是选择两到三个特征鲜明的历史区间做次级样本外,比如一段趋势明显的行情和一段剧烈震荡的行情。策略在两类区间中都能保持正收益,比整体样本外均值高更说明问题。

这个方法可以和Walk-Forward结合:在滚动的每一段测试窗口里,再按波动率或者趋势强度切分子区间。不过要注意,子区间长度太短的话绩效统计意义不够,所以分组别贪多,两到三组足够。

6.2 参数稳健性检验里的Paired Testing

所谓配对检验,是固定其他因素、只改变一个参数,观察绩效变化是否显著。比如固定均线长度,把止损参数从1倍ATR调到3倍ATR,逐点测试绩效。如果整个区间绩效变化平缓,说明策略对止损不敏感;如果绩效在某个值附近剧烈波动,说明策略依赖这个止损参数的精确取值,这本身就是过拟合信号。

操作上我习惯把每个参数单独做一次敏感性曲线图,然后像体检报告一样归档。样本外衰减比解决"整体过拟合吗"的问题,敏感性曲线解决"具体是哪个参数导致过拟合"的问题,两者视角不同、互相补充。

6.3 指标组合的降维与精简

过拟合的另一个来源是规则太多。每多一条过滤规则,就多一层对历史数据的定制化。我经常在策略开发后期做减法:把每个规则依次去掉,测试绩效下降幅度。如果去掉某条规则后绩效几乎没变,说明这条规则本就是多余的噪声,果断删除。这个过程很像给代码做重构——逻辑越简单,越容易在未知样本上保持稳定。

减完之后重新做一遍完整的样本外验证,通常会发现策略的样本外稳定性会比精简前更好。因为少了一些只在特定历史时期有效的"巧合规则",策略的期望收益才能体现出来。

6.4 多周期数据的交叉验证

如果策略横跨分钟级和日线级,最好在两种周期上都做一遍样本外检验。有些策略在分钟级样本外表现很好,但日线级样本外糟糕,这种策略的交易频率高、容量小,对交易成本极其敏感;反之,日线样本外好而分钟级样本外差,策略可能过于迟钝。两种周期交叉验证,才能对策略的运行环境有一个完整的判断。

7. 我从这些实战中学到的东西

样本内外划分这件事,技术难度不算高,真正难的是守住纪律。数据怎么切、切多大比例,代码很快就写好了,但"样本外只许测一次"这条纪律,我花了很长时间才真正做到。每次看到新想法,总忍不住想拿那块留存的样本外数据验证一下,但每次都硬生生忍住了。因为我很清楚,那块数据一旦被消耗掉,之后所有的验证都失去了解释力。

实际操作中,我还发现一个出乎意料的好处:严格执行样本外划分之后,策略开发速度反而变快了。过去花大量时间在参数微调上,现在知道那些微调大多是针对噪声的自嗨,于是把精力更多放在策略逻辑本身。真正有优势的策略,参数即使不精确,样本外也能给出不错的结果;需要精确到小数点后两位才能赚钱的策略,我建议直接放弃,因为那是巧合不是能力。

对一个量化新人,我的建议是别急着追求复杂模型,先用一套简单的均线系统,把一个品种五年日线数据按70/30划分,认真跑一遍Walk-Forward,再对比一下样本内外的绩效差异。这个过程做完,你对过拟合的直觉会比读十本理论书都强。量化的很多问题,说到底不是模型不够先进,而是评估流程不够严格。样本内外划分就是那个最简单的矫正器,它不保证你赚钱,但能保证你亏钱之前先看清自己的策略到底几斤几两。

返回列表