Python 参数优化与过拟合防范

前面的章节有说回测三大陷阱,其中过拟合最隐蔽:它不报错、不警告,还附赠一份漂亮的回测报告。

本篇在真实数据上亲手制造一次过拟合,再用样本内外分离和 Walk-Forward 两道防线拆穿它。

所有数字来自双均线策略在茅台 2019-2024 的实算结果。


网格搜索:把所有参数试一遍

双均线有快慢两个参数。对每个组合回测一遍,得到"参数-收益"的完整地图:

实例

import yfinance as yf
import pandas as pd

close = yf.download("600519.SS", start="2019-01-01", end="2024-12-31",
                    progress=False, multi_level_index=False)["Close"]
ret = close.pct_change()

fast_list = [5, 10, 15, 20, 25, 30]
slow_list = [40, 60, 80, 100, 120]
grid = pd.DataFrame(index=slow_list, columns=fast_list, dtype=float)

for f_ in fast_list:
    for s_ in slow_list:
        ma_f = close.rolling(f_).mean()
        ma_s = close.rolling(s_).mean()
        sig = (ma_f > ma_s).astype(int)              # 1 持有,0 空仓
        r = (sig.shift(1) * ret).dropna()            # 信号次日执行
        cum = (1 + r).cumprod()
        grid.loc[s_, f_] = cum.iloc[-1] ** (252 / len(r)) - 1

print((grid * 100).round(1).to_string())
print(f"最优参数: fast={grid.stack().idxmax()[1]}, "
      f"slow={grid.stack().idxmax()[0]}, "
      f"年化 {grid.stack().max():.2%}")

输出结果如下所示(单位:%):

        5     10    15    20    25    30
40   11.1   4.2   4.8   8.7  10.7   7.7
60    9.8   9.1  10.2  10.9  12.6  14.4
80    6.5   8.4  11.9  11.4  10.8  12.5
100   6.2   7.4   9.3   8.8   8.7   7.4
120   1.9   2.6   3.6   3.7   1.2   2.4

最优参数: fast=30, slow=60, 年化 14.44%

把网格画成热力图,参数地形的"平原"与"洼地"一目了然:

实例

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 6))
im = ax.imshow(grid.values.astype(float), cmap="RdYlGn", aspect="auto")
ax.set_xticks(range(len(fast_list)), [str(x) for x in fast_list])
ax.set_yticks(range(len(slow_list)), [str(x) for x in slow_list])
ax.set_xlabel("Fast MA (days)")
ax.set_ylabel("Slow MA (days)")
ax.set_title("Dual MA Grid Search: Annualized Return (%)")
for i in range(len(slow_list)):
    for j in range(len(fast_list)):
        ax.text(j, i, f"{grid.values[i, j]*100:.0f}",
                ha="center", va="center", fontsize=10)
fig.colorbar(im, ax=ax, label="Annualized Return")
fig.tight_layout()
plt.show()

执行以上代码,输出的图表如下:

双均线参数网格热力图

选参数看地形,不只是看最高点:

地形特征 解读 应对
孤立尖峰 参数的过拟合典型症状,换个参数就崩 直接放弃
平缓高原 策略逻辑稳健,参数不敏感 优先选择,取区域中心值

本例中快线 15-30、慢线 60-80 的区域连片给出 10%-14% 的年化收益,属于健康的高原地形;而慢线 120 一行整体塌陷,说明信号太迟钝。最优的 30/60 恰好位于高原边缘——稳妥的选择是高原中心(如 20/60 或 25/60),而不是边界上的最大值。


样本内外分离:第一道防线

网格用 2019-2024 全区间数据选出"最优参数",问题在于:这些参数见过全部考题。

正确的做法是把数据切成两段:样本内(IS,2019-2023)调参数,样本外(OOS,2024)考试:

实例

is_mask = close.index < "2024-01-01"      # 样本内:2019-2023
oos_mask = close.index >= "2024-01-01"    # 样本外:2024

# 只在样本内选参数
best_f, best_s = 30, 60                   # 由样本内网格得出
ma_f = close.rolling(best_f).mean()
ma_s = close.rolling(best_s).mean()
sig = (ma_f > ma_s).astype(int)

r_is = (sig.shift(1) * ret)[is_mask].dropna()
r_oos = (sig.shift(1) * ret)[oos_mask].dropna()

cum_is = (1 + r_is).cumprod()
cum_oos = (1 + r_oos).cumprod()
print(f"样本内最优: 30/60, IS 年化 {cum_is.iloc[-1] ** (252/len(r_is)) - 1:.2%}")
print(f"该参数 2024 样本外: 年化 {cum_oos.iloc[-1] ** (252/len(r_oos)) - 1:.2%}")
print(f"2024 买入持有: {close[oos_mask].iloc[-1]/close[oos_mask].iloc[0]-1:.2%}")

输出结果如下所示:

样本内最优: 30/60, IS 年化 19.62%
该参数 2024 样本外: 年化 -0.40%
2024 买入持有: -6.18%

样本内年化 19.62%,样本外直接跌到 -0.40%——缩水 20 个百分点。

这就是过拟合的真实嘴脸:参数完美适配了 2019-2023 的行情结构,而 2024 的市场变了。公允地说,样本外跑赢了买入持有 6 个百分点,策略并非一无是处,但对"19.6% 年化"抱有期待就会失望。

纪律:样本外数据只能用一次。反复"偷偷"看样本外结果再回去调参,样本外就变成了更大的样本内,防线形同虚设。


Walk-Forward:让时间检验参数

样本内外只切了一次刀,结论容易受切法影响。Walk-Forward(滚动前推验证)每年重演一次"用历史选参数、在未来执行"的完整流程:

2019 2020 2021 2022 2023 2024 第 1 轮 训练 测试 2020 第 2 轮 训练 2019-2020 测试 2021 第 3 轮 训练 2019-2021 测试 2022 第 4 轮 训练 2019-2022 测试 2023 第 5 轮 训练 2019-2023 测试 训练窗口(选参数) 测试窗口(真实执行)

实现只需要在年份上套一层循环:

实例

for year in range(2020, 2025):
    tr_mask = close.index < f"{year}-01-01"                       # 训练:该年之前全部
    te_mask = (close.index >= f"{year}-01-01") & \
              (close.index < f"{year + 1}-01-01")                 # 测试:该年

    # 第一步:在训练集上找出年化收益最高的参数
    best_p, best_v = None, -9e9
    for f_ in fast_list:
        for s_ in slow_list:
            ma_f, ma_s = close.rolling(f_).mean(), close.rolling(s_).mean()
            sig = (ma_f > ma_s).astype(int)
            r_tr = (sig.shift(1) * ret)[tr_mask].dropna()
            v = ((1 + r_tr).cumprod().iloc[-1] ** (252 / len(r_tr)) - 1)
            if v > best_v:
                best_v, best_p = v, (f_, s_)

    # 第二步:用选中参数在测试年执行
    ma_f, ma_s = close.rolling(best_p[0]).mean(), close.rolling(best_p[1]).mean()
    sig = (ma_f > ma_s).astype(int)
    r_te = (sig.shift(1) * ret)[te_mask].dropna()
    v_te = (1 + r_te).cumprod().iloc[-1] ** (252 / len(r_te)) - 1
    print(f"{year} 年: 选中 {best_p} | 训练期表现 {best_v:.2%} | 样本外 {v_te:.2%}")

输出结果如下所示:

2020 年: 选中 (25, 60) | 训练期表现 41.58% | 样本外 68.12%
2021 年: 选中 (30, 60) | 训练期表现 58.09% | 样本外 3.01%
2022 年: 选中 (30, 60) | 训练期表现 37.00% | 样本外 -5.58%
2023 年: 选中 (30, 60) | 训练期表现 24.84% | 样本外 0.81%
2024 年: 选中 (30, 60) | 训练期表现 19.62% | 样本外 -8.40%

这张表是全篇最重要的一张表:

观察 含义
五年中四年选中同一组参数(30, 60) 参数有一定稳定性,不是纯噪音
训练期表现从 41.58% 一路降到 19.62% 参数的"历史战绩"会自然衰减,不要线性外推
样本外时好时坏(68% 到 -8%) 参数解决不了市场环境切换,2022 年后的茅台已不是 2019 年的茅台

Walk-Forward 的价值不在于选出更好的参数,而在于给出对参数衰减的无偏预期:连年重优化后的平均样本外表现,才是这个策略在这个市场上可信的水平。


蒙特卡洛置换:这真是策略的功劳吗

最后一种防线回答一个哲学问题:好成绩会不会只是运气?做法简单粗暴——把策略信号随机打乱几百次,看"瞎蒙策略"的收益分布。

如果真策略的收益只排在随机分布的前 30%,它与掷硬币没有区别;排进前 5%,才有信心说信号里有真实信息。

检验 做法 回答的问题
网格地形 看参数-收益曲面是否平缓 参数稳健吗
样本内外 留一段数据只考一次 成绩能外推吗
Walk-Forward 逐年滚动重演全流程 长期可信水平是多少
蒙特卡洛置换 打乱信号对比随机分布 是不是纯运气