基于MQL5和Python的自优化EA(第六部分):利用深度双重下降算法(基础篇)
📘

基于MQL5和Python的自优化EA(第六部分):利用深度双重下降算法(基础篇)

第 1/3 篇

「过拟合的尽头可能是双重下降」

机器学习里过拟合最常见的形态,是模型把训练数据里的噪声也当规律学进去,结果一碰到样本外行情就失灵。对刚接触 AI 的交易者来说,传统正则化、 dropout 那些抑制过拟合的手段,落地门槛不低。

  • 年 6 月 25 日发布的这组研究中提到,哈佛背景团队的一篇论文指出:在特定任务上,随着模型容量继续放大,测试误差会先升后降,出现“双重下降”现象,过拟合反而被容量本身消化掉。

这对做 MT5 自优化 EA 的人是个提醒——外汇和贵金属杠杆高、跳空频繁,样本外失效风险始终存在;与其死磕小模型防过拟合,不如验证下大模型在扩大参数规模后是否进入下降区间。

把过拟合图表从单谷重新看成双谷

做 AI 模型时,老办法是盯着训练误差和测试误差两条曲线:开头一起往下走算健康,继续训下去训练误差还降、测试误差却抬头,就判定过拟合,于是用提前停止在验证误差恶化前砍掉训练、回滚最佳权重。

  • 年那篇《深度双重下降》把这套常识砸了。它没给解释,只描述了现象:某些任务上测试误差先降、再升、然后又二次陡降创出新低,最终才收敛,形成双谷而不是单谷。

现象可写成两个变量的函数——模型参数量、最大训练迭代次数。同数据集上不断换更大的模型,测试误差走的就是先降后升、再二次探底。但算力不允许无限堆参数,所以我们这次只把它当“最大迭代次数”的函数来玩:允许模型训更久,验证误差总会先冲高、再掉到新低,冲高到回落的时长取决于数据噪声和模型类型。 关键反直觉点在插值阈值。简单网络欠拟合,加复杂度能降本;等复杂到完美拟合数据,训练误差贴近 0 不再降——这和“更复杂必更低”的传统说法第一次冲突。过了阈值还加复杂度,测试精度会明显掉,但再往后训下去误差又能落进新低并稳住。 提前停止这类防过拟合手段,恰恰总在二次下降前就叫停,等于把双谷后半截掐了。下面我们用 MT5 环境自己复现一次,独立看清楚这条曲线。

◍ 用网格搜索给线性模型找最佳预测窗口

从 MT5 拽数据只是前戏。真正要干的,是给 GBPUSD 日线 OHLC 定两个旋钮:返回值按什么周期算、往前预测多少步。默认大家爱用「周期1、预测1步」,但这未必是最优解,让数据自己说话。 我们对这两个参数做网格搜索,x 轴 y 轴拉平后生成二维网格,再写函数按每组参数清洗数据、交叉验证、把误差写回同形数组。简单线性回归跑完,等高线图里最深的蓝区白点就是最佳设置——图表显示,线性模型在日线周期与预测范围都落在 20 到 40 之间时误差最低。 把误差摊成 3D 曲面能看见:随预测 horizon 拉远,误差先降后升,有个明确最优谷。也就是说,这个线性 AI 模型在 20~40 日窗口上的表现,可能轻松盖过市场上那些死守「周期1预测1步」的经典价格模型。外汇与贵金属属高风险品种,回测优越不等于实盘稳赢。 换深度神经网络进同一参数空间,曲面图乱得多:蓝区夹着突兀红区,相邻组合误差天差地别。这源于神经网络优化算法的随机性——重训一次,最佳点就跑位。传统视角会判线性模型胜出(误差图平滑=有技能),但按双重下降理论,现在说神经网络过拟合还太早,它更可能是没收敛。 下面这段脚本负责从当前品种拉最多 10 万根数据落盘成 CSV,是后面所有实验的取数底座。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                                  ProjectName |
class=class="str">"cmt">//|                                                 Copyright class="num">2020, CompanyName |
class=class="str">"cmt">//|                                       http://www.companyname.net |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Gamuchirai Zororo Ndawana"
class="macro">#class="kw">property link      "[MQL5官方文档]
class="macro">#class="kw">property version   "class="num">1.00"
class="macro">#class="kw">property script_show_inputs
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Script Inputs                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">input class="type">int size = class="num">100000; class=class="str">"cmt">//How much data should we fetch?
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Global variables                                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| On start function                                                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnStart()
  {
class=class="str">"cmt">//--- File name
   class="type">class="kw">string file_name = "Market Data " + Symbol()+  ".csv";
class=class="str">"cmt">//--- Write to file
   class="type">int file_handle=FileOpen(file_name,FILE_WRITE|FILE_ANSI|FILE_CSV,",");
   for(class="type">int i= size;i>=class="num">0;i--)
     {
     if(i == size)
       {

「把MT5导出的K线落盘并喂给Python建模」

MT5里写文件要先区分表头与数据行:首次建文件时写列名,之后每个Bar用 iTime / iOpen / iHigh / iLow / iClose 按当前品种与周期抓取,循环结束务必 FileClose,否则句柄泄漏会让后续写盘失败。

MQL5 / C++
FileWrite(file_handle,"Time","Open","High","Low","Close");
}
else
  {
   FileWrite(file_handle,iTime(Symbol(),PERIOD_CURRENT,i),
                             iOpen(Symbol(),PERIOD_CURRENT,i),
                             iHigh(Symbol(),PERIOD_CURRENT,i),
                             iLow(Symbol(),PERIOD_CURRENT,i),
                             iClose(Symbol(),PERIOD_CURRENT,i)
                             );
  }
 }
class=class="str">"cmt">//--- Close the file
 FileClose(file_handle);
上面这段逻辑:首次进入时 file_handle 为空分支写表头;else 分支对每个 i 写一行OHLC,Symbol() 取当前图表品种,PERIOD_CURRENT 取当前周期,最后统一关文件。 落地到分析侧,用 pandas 读入以制表符分隔的 CSV,把 <OPEN> 等标签重命名为 Open/High/Low/Close,并丢掉 DATE/VOL/SPREAD/TICKVOL 这些列。样本用的是 GBPUSD_Daily_20160103_20240131.csv,覆盖 2016-01-03 到 2024-01-31 共约 8 年日线。 建模前先框定搜索网格:look_ahead 与 period 都取 2~100,步长 4 做均匀采样,用 np.meshgrid 生成二维参数面,后面才能用 sklearn 的 TimeSeriesSplit 做时序交叉验证而非随机切分。外汇与贵金属杠杆高,回测参数面好看不代表实盘概率占优,开 MT5 导出自己品种的 CSV 跑一遍最踏实。

MQL5 / C++
FileWrite(file_handle,"Time","Open","High","Low","Close");
}
else
  {
   FileWrite(file_handle,iTime(Symbol(),PERIOD_CURRENT,i),
                             iOpen(Symbol(),PERIOD_CURRENT,i),
                             iHigh(Symbol(),PERIOD_CURRENT,i),
                             iLow(Symbol(),PERIOD_CURRENT,i),
                             iClose(Symbol(),PERIOD_CURRENT,i)
                             );
  }
 }
class=class="str">"cmt">//--- Close the file
 FileClose(file_handle);

用差分周期和预测步长给英镑美元日线建模

这段处理把原始 K 线字段重命名后直接丢掉日期与成交量类列,只留 OHLC 四列做特征。目标变量用 Close 向前平移 look_ahead 根 K 线得到,也就是用当下差分后的开高低收去猜若干根之后的收盘价,属于典型的多步向前回归设定。 差分环节对 Open/High/Low/Close 统一做 period 阶滞后相减,再把含空值的首行删掉、索引重置。这样序列从价格水平变成价格变动,线性模型才不容易被趋势带偏。 评估函数里用了 TimeSeriesSplit(n_splits=5, gap=look_ahead),5 折且折间隔离预测步长,避免未来信息泄漏。cross_val_score 对线性回归取平均 R² 作为该 (look_ahead, period) 组合的得分,objective 再双重循环铺满参数网格。 图上白点标的是绝对值误差最小的组合:x 轴是 Forecast Horizon(预测步长),y 轴是 Differencing Period(差分周期)。英镑美元日线在这个网格下,线性与神经网络两张等高线图都指向相近的低谷区,说明差分周期选错可能比模型选错更伤精度。外汇与贵金属为高杠杆品种,此类回测仅反映历史样本,实盘信号失效概率不低。 把这段代码丢进本地 Python,换 x/y 网格密度重跑一次,你能直接看到自己机器上 GBPUSD 日线的最优差分周期落在哪。

MQL5 / C++
temp.rename(columns={&class="macro">#x27;<OPEN>&class="macro">#x27;:&class="macro">#x27;Open&class="macro">#x27;,&class="macro">#x27;<HIGH>&class="macro">#x27;:&class="macro">#x27;High&class="macro">#x27;,&class="macro">#x27;<LOW>&class="macro">#x27;:&class="macro">#x27;Low&class="macro">#x27;,&class="macro">#x27;<CLOSE>&class="macro">#x27;:&class="macro">#x27;Close&class="macro">#x27;},inplace=True)
temp = temp.drop([&class="macro">#x27;<DATE>&class="macro">#x27;,&class="macro">#x27;<VOL>&class="macro">#x27;,&class="macro">#x27;<SPREAD>&class="macro">#x27;,&class="macro">#x27;<TICKVOL>&class="macro">#x27;],axis=class="num">1)
class="macro">#Define our target
temp["Target"] = temp["Close"].shift(-look_ahead)
class="macro">#Apply the differencing
temp["Close"] = temp["Close"].diff(period)
temp["Open"] = temp["Open"].diff(period)
temp["High"] = temp["High"].diff(period)
temp["Low"] = temp["Low"].diff(period)
temp = temp.dropna()
temp = temp.reset_index(drop=True)
class="kw">return(temp)
class="macro">#Evaluate the objective function
def evaluate(look_ahead,period):
	class="macro">#Define the model
	model = LinearRegression()
	class="macro">#Define our time series split
	tscv = TimeSeriesSplit(n_splits=class="num">5,gap=look_ahead)
	temp = clean_data(look_ahead,period)
	score = np.mean(cross_val_score(model,temp.loc[:,["Open","High","Low","Close"]],temp["Target"],cv=tscv))
	class="kw">return(score)
class="macro">#Define the objective
def objective(x,y):
	class="macro">#Define the output matrix
	results = np.zeros([x.shape[class="num">0],y.shape[class="num">0]])
	class="macro">#Fill in the output matrix
	for i in np.arange(class="num">0,x.shape[class="num">0]):
		class="macro">#Select the rows
		look_ahead = x[i]
		period     = y[i]
		for j in np.arange(class="num">0,y.shape[class="num">0]):
			results[i,j] = evaluate(look_ahead[j],period[j])
	class="kw">return(results)
linear_reg_res = objective(x,y)
linear_reg_res = np.abs(linear_reg_res)
plt.contourf(x,y,linear_reg_res,class="num">100,cmap="jet")
plt.plot(x_axis[linear_reg_res.min(axis=class="num">0).argmin()],y_axis[linear_reg_res.min(axis=class="num">1).argmin()],&class="macro">#x27;.&class="macro">#x27;,class="type">color=&class="macro">#x27;white&class="macro">#x27;)
plt.ylabel("Differencing Period")
plt.xlabel("Forecast Horizon")
plt.title("Linear Regression Accuracy Forecasting GBPUSD Daily Close")
class="macro">#Create a surface plot
fig , ax = plt.subplots(subplot_kw={"projection":"3d"})
fig.set_size_inches(class="num">8,class="num">8)
ax.plot_surface(x,y,linear_reg_res,cmap="jet")
res = objective(x,y)
res = np.abs(res)
plt.contourf(x,y,res,class="num">100,cmap="jet")
plt.plot(x_axis[res.min(axis=class="num">0).argmin()],y_axis[res.min(axis=class="num">1).argmin()],&class="macro">#x27;.&class="macro">#x27;,class="type">color=&class="macro">#x27;white&class="macro">#x27;)
plt.ylabel("Differencing Period")
plt.xlabel("Forecast Horizon")
plt.title("Neural Network Accuracy Forecasting GBPUSD Daily Close")
class="macro">#Create a surface plot
fig , ax = plt.subplots(subplot_kw={"projection":"3d"})
fig.set_size_inches(class="num">8,class="num">8)
ax.plot_surface(x,y,res,cmap="jet")

常见问题

传统认知里过拟合越重越差,但模型复杂度再往上可能进入双重下降区,测试误差反而回落。建议把误差曲线从单谷改成双谷视角,别一看到过拟合就全砍特征。
用网格搜索遍历不同预测窗口,对比样本外误差选最低点对应的窗口。实操中可先以 20/50/100/200 根日线为候选,再细化到相邻值。
可以。把EA回测误差和小布盯盘里的AIGC诊断对照,它能标出双重下降区间,提醒你别在单谷底部盲目停手。
按日期和品种分文件保存收盘价与成交量,统一时区避免错位。喂给模型前先做缺失值填充和标准化,不然差分周期会算偏。
先从一阶差分配 1~5 根预测步长试起,看样本外均方误差。英镑美元噪音大,步长超过 10 根通常衰减明显,建议回测确认再定。