突破机器学习的局限(第一部分):缺乏可互操作的度量指标·进阶篇
◍ 历史均值模型为何长期占优
在覆盖约四年的样本测试中,控制模型(始终预测过去 10 天平均收益率)在 91.6% 的市场情形下误差低于对照模型。也就是说,一个只会输出历史均值的最简模型,在绝大多数时间里比复杂方案更接近真实走势。 即便换用更深的神经网络,误差改善也微乎其微——这提醒我们,在外汇与贵金属这类高波动、高噪声市场里,堆参数并不等于提精度,杠杆与滑点会放大错误,属高风险博弈。 从业者容易掉进机器学习的‘最优实践’陷阱:选误差最低的模型。但误差最低可能只是因为它永远猜均值,而非真捕捉了价格行为。 对新手而言,这不代表机器学习无用,而是定义‘好表现’要极谨慎:若评估目标设错,模型会因‘预测均值’被奖励,而你以为自己在做方向交易。
「换掉统计视角的评估尺」
RMSE、MSE、MAE 这些指标来自统计、医学等自然科学,原本用来衡量平均预测误差。但算法交易里,模型若只追求预测平均收益率,实盘表现可能比不用 AI 更差,某些情形下不碰 AI 反而更安全。 缺理解的 AI 永远是高风险的源头。我们需要一套能奖励“抓盈亏”而非“统计配合度”的评估框架,并在训练协议里惩罚“拥抱平均值”的模型行为,让模型学的是交易现实而不是平滑曲线。 这套思路正是本系列要拆的问题:它不来自市场本身,而来自我们手里的工具,社区讨论和统计教材里都很少提。从业者若不清醒认知这些局限,日常很容易复读危险的统计实践,把回测好看当实盘稳妥。
跑一遍全市场,才知道均值模型多难缠
把标准库加载进 MT5,设定好预测用的 K 线周期(原文用 HORIZON=10 根 D1 柱),初始化终端拉出全部可交易品种名,再写一个时间序列交叉验证拆分器,分别测「永远猜均值」和「直接预测收益率」两类误差。这套流程不挑品种,外汇、贵金属、差价合约都能原样丢进去跑。 在 119 个可获取市场中,最终只有约 35.7% 完成了评估,其余因历史数据不足被剔除(比如 GBPUSD 的 RSI 下行样本不够直接报错)。完成评估的市场里,用类 R² 思路算「直接预测误差 / 均值预测误差」,小于 1 才算赢。结果只有约 8%(精确值 0.08403)的市场胜出,91.6% 跑不赢无脑猜均值。 胜出的少数派里,澳元兑加元得分 0.8145、欧元兑加元 0.7316、新西兰元兑加元 0.7905、美元兑人民币 0.8983,都是四年间 D1 数据测出来的。外汇与贵金属属高风险品种,样本不足或模型误用都可能放大实盘亏损,上述比例仅反映历史回测、不预示未来。 有人会觉得「换个深度神经网络总能碾压均值吧」。原文拿 EURUSD 试了:用 MQL5 抓四个价格档位的涨幅做特征,喂进调参后的神经网络,RMSE 上看确实略赢均值模型;但把预测分布和市场真实收益率分布叠图一看,模型根本没捕捉到尾部那些最大的盈亏区间。光盯 RMSE 选模型,在实盘里可能是灾难。建议先在自己券商 MT5 上复跑下面的脚本再谈部署。
class="kw">import pandas as pd class="kw">import numpy as np class="kw">import matplotlib.pyplot as plt class="kw">import MetaTrader5 as mt5 class="kw">import multiprocessing as mp HORIZON = class="num">10 if(not mt5.initialize()): print("Failed to load MT5") symbols = mt5.symbols_get() symbols[class="num">0].name symbol_names = [] for i in range(len(symbols)): symbol_names.append(symbols[i].name) from sklearn.linear_model class="kw">import Ridge from sklearn.model_selection class="kw">import TimeSeriesSplit,cross_val_score tscv = TimeSeriesSplit(n_splits=class="num">5,gap=HORIZON) def null_test(name): data_amount = class="num">365 * class="num">4 f_data = pd.DataFrame(mt5.copy_rates_from_pos(name,mt5.TIMEFRAME_D1,class="num">0,data_amount)) if(f_data.shape[class="num">0] < data_amount): print(f"{symbol_names[i]} did not have enough data!") class="kw">return(None) f_data[&class="macro">#x27;time&class="macro">#x27;] =pd.to_datetime(f_data[&class="macro">#x27;time&class="macro">#x27;],unit=&class="macro">#x27;s&class="macro">#x27;) f_data[&class="macro">#x27;target&class="macro">#x27;] = f_data[&class="macro">#x27;close&class="macro">#x27;].shift(-HORIZON) - f_data[&class="macro">#x27;close&class="macro">#x27;] f_data.dropna(inplace=True) model = Ridge() res = [] res.append(np.mean(np.abs(cross_val_score(model,f_data.loc[:,[‘open’]] * class="num">0,f_data[‘target’],cv=tscv)))) res.append(np.mean(np.abs(cross_val_score(model,f_data.loc[:,[‘open’,‘high’,‘low’,‘close’]],f_data[‘target’],cv=tscv)))) class="kw">return(res) res = pd.DataFrame(columns=[‘Mean Forecast’,‘Direct Forecast’],index=symbol_names) for i in range(len(symbol_names)): test_score = null_test(symbol_names[i]) if(test_score is None): print(f"{symbol_names[i]} does not have enough data!")
◍ 零模型打分与MT5脚本骨架
前面那段 Python 把每个品种跑完零模型后,把 test_score 的两个分量写进 res:第 0 列是训练侧基准,第 1 列是样本外得分;循环里用 i/len(symbol_names) 打印进度,最后算 Score = 样本外 / 样本内,导出 Deriv Null Model Test.csv。回读 Null Model Test.csv 能看到「(dropna 行数 / 总行数)*100% 的 markets were evaluated」,这一步直接暴露了有多少品种因数据不足被跳过。 Score 小于 1 代表该品种样本外表现不及样本内随机基准,大于 1 才值得继续盯;用 res.loc[res['Score']<1].shape[0]/res.shape[0] 能算出全样本里「跑输基准」的比例,外汇与贵金属品种在此类检验中波动大、高风险,该比例仅作过滤参考而非交易信号。 下面的 MQL5 脚本头是后续实盘抓 K 线形态的骨架:file_name 用 Symbol() 拼接「IID Candlestick Recognition.csv」,input 里 size=3000 控制取棒数、HORIZON=10 控制向前看几根,OnStart 里先留了写文件的坑。开 MT5 新建脚本把这段头填上,就能把多品种形态识别的本地 csv 命名规范先定下来。
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 "Copyright class="num">2024, MetaQuotes Ltd." 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">//| System constants | class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| File name | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string file_name = Symbol() + " IID Candlestick Recognition.csv"; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| User inputs | class=class="str">"cmt">//+------------------------------------------------------------------+ input class="type">int size = class="num">3000; input class="type">int HORIZON = class="num">10; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Our script execution | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//---Write to file
「把K线差分直接落盘成CSV」
做价格行为量化时,光在内存里算差值不够,落地成 CSV 才能用 Python 或 Excel 做后续分布检验。下面这段 MT5 逻辑把当前图表最近 size 根 K 线的绝对价和跨期差分一次性写进逗号分隔文件。
| 文件句柄用 FileOpen 以 FILE_WRITE | FILE_ANSI | FILE_CSV 打开,分隔符指定为英文逗号,保证 Excel 双击可直接读。循环从 i=size 倒序到 1,首行先写表头,之后每行写入具体数值。 |
|---|
差分列有两层含义:一类是相对 HORIZON 根之前的同向价格差(如 Delta Open = 当前 Open 减 i+HORIZON 的 Open),用于捕捉中期位移;另一类是单根 K 线内部的结构差(O-H、H-L 等),反映当根博弈。外汇与贵金属杠杆高,差分序列仅描述历史波动结构,不预示未来方向。 开 MT5 把这段塞进脚本的 OnStart,先设 HORIZON=1、size=500,跑完去数据文件夹翻 CSV,看 Delta Close 列是否多为同号连续——那是趋势段,混号多则是震荡段。
class="type">int file_handle=FileOpen(file_name,FILE_WRITE|FILE_ANSI|FILE_CSV,","); for(class="type">int i=size;i>=class="num">1;i--) { if(i == size) { FileWrite(file_handle,"Time","Open","High","Low","Close","Delta Open","Delta High","Delta Low","Delta Close","O - H","O - L","O - C","H - L","H - C","L - C"); } 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), iOpen(_Symbol,PERIOD_CURRENT,i) - iOpen(_Symbol,PERIOD_CURRENT,i + HORIZON), iHigh(_Symbol,PERIOD_CURRENT,i) - iHigh(_Symbol,PERIOD_CURRENT,i + HORIZON), iLow(_Symbol,PERIOD_CURRENT,i) - iLow(_Symbol,PERIOD_CURRENT,i + HORIZON), iClose(_Symbol,PERIOD_CURRENT,i) - iClose(_Symbol,PERIOD_CURRENT,i + HORIZON), iOpen(_Symbol,PERIOD_CURRENT,i) - iHigh(_Symbol,PERIOD_CURRENT,i), iOpen(_Symbol,PERIOD_CURRENT,i) - iLow(_Symbol,PERIOD_CURRENT,i), iOpen(_Symbol,PERIOD_CURRENT,i) - iClose(_Symbol,PERIOD_CURRENT,i), iHigh(_Symbol,PERIOD_CURRENT,i) - iLow(_Symbol,PERIOD_CURRENT,i), iHigh(_Symbol,PERIOD_CURRENT,i) - iClose(_Symbol,PERIOD_CURRENT,i),
用神经网络跑赢惰性基准线
上面这段把 MT5 导出的 K 线识别 CSV 接进 Python,先以 HORIZON=10 根柱子的收盘价位移做标签,再丢掉末尾 10 行防止未来函数泄漏。 基准模型只用一列常数特征喂 Ridge,在时间序列 5 折交叉验证下算出平均绝对误差 tss;神经网络用三层结构 (len(X),200,50)、logistic 激活、lbfgs 求解,同样 cv 算出 rss。 打印语句直接比 rss<tss 与 rss/tss:若比值小于 1,说明模型在 EURUSD 样本上可能比「闭眼猜均值」稍强,但外汇高杠杆下这仅是历史样本倾向,不等于实盘概率。 最后三张 seaborn 图把预测分布和测试分布叠在训练均值参考线旁,肉眼能看出模型是否把尾巴压窄;开 MT5 导出你自己的 CSV 替换路径,就能复跑这套对照。
HORIZON = class="num">10 data = pd.read_csv(&class="macro">#x27;EURUSD IID Candlestick Recognition.csv&class="macro">#x27;) class="macro">#Label the data data[&class="macro">#x27;Null&class="macro">#x27;] = class="num">0 data[&class="macro">#x27;Target&class="macro">#x27;] = data[&class="macro">#x27;Close&class="macro">#x27;].shift(-HORIZON) - data[&class="macro">#x27;Close&class="macro">#x27;] class="macro">#Drop the last HORIZON rows of data data = data.iloc[:-HORIZON,:] data class="macro">#Load our models from sklearn.neural_network class="kw">import MLPRegressor from sklearn.linear_model class="kw">import Ridge from sklearn.model_selection class="kw">import TimeSeriesSplit,train_test_split,cross_val_score class="macro">#Split the data into half train,test = train_test_split(data,test_size=class="num">0.5,shuffle=False) class="macro">#Create a time series validation object tscv = TimeSeriesSplit(n_splits=class="num">5,gap=HORIZON) class="macro">#Fit the model predicting the mean on the train set null_model = Ridge() null_model.fit(train[[&class="macro">#x27;Null&class="macro">#x27;]],train[&class="macro">#x27;Target&class="macro">#x27;]) tss = np.mean(np.abs(cross_val_score(null_model,test[[&class="macro">#x27;Null&class="macro">#x27;]],test[&class="macro">#x27;Target&class="macro">#x27;],cv=tscv,scoring=&class="macro">#x27;neg_mean_absolute_error&class="macro">#x27;))) tss X = data.iloc[:,class="num">4:-class="num">2].columns y = &class="macro">#x27;Target&class="macro">#x27; class="macro">#Let us now try to outperform the null model model = MLPRegressor(activation=&class="macro">#x27;logistic&class="macro">#x27;,solver=&class="macro">#x27;lbfgs&class="macro">#x27;,random_state=class="num">0,shuffle=False,hidden_layer_sizes=(len(X),class="num">200,class="num">50),max_iter=class="num">1000,early_stopping=False) model.fit(train.loc[:,X],train[&class="macro">#x27;Target&class="macro">#x27;]) rss = np.mean(np.abs(cross_val_score(model,test.loc[:,X],test[&class="macro">#x27;Target&class="macro">#x27;],cv=tscv,scoring=&class="macro">#x27;neg_mean_absolute_error&class="macro">#x27;))) print(f"Test Passed: {rss < tss} || New R-Squared Metric {rss/tss}") res = [] res.append(rss) res.append(tss) sns.barplot(res,class="type">color=&class="macro">#x27;black&class="macro">#x27;) plt.axhline(tss,class="type">color=&class="macro">#x27;red&class="macro">#x27;,linestyle=&class="macro">#x27;:&class="macro">#x27;) plt.grid() plt.ylabel("RMSE") plt.xticks([class="num">0,class="num">1],[&class="macro">#x27;Deep Neural Network&class="macro">#x27;,&class="macro">#x27;Null Model&class="macro">#x27;]) plt.title("Our Deep Neural Network vs The Null Model") plt.show() predictions = model.predict(test.loc[:,X]) plt.title("Visualizing Our Improvements") plt.plot() plt.grid() plt.xlabel("Return") plt.axvline(train[&class="macro">#x27;Target&class="macro">#x27;].mean(),class="type">color=&class="macro">#x27;red&class="macro">#x27;,linestyle=&class="macro">#x27;:&class="macro">#x27;) legend = [&class="macro">#x27;Train Mean&class="macro">#x27;] plt.legend(legend) plt.title("Visualizing Our Improvements") plt.plot() plt.grid() plt.xlabel("Return") plt.axvline(train[&class="macro">#x27;Target&class="macro">#x27;].mean(),class="type">color=&class="macro">#x27;red&class="macro">#x27;,linestyle=&class="macro">#x27;:&class="macro">#x27;) sns.histplot(predictions,class="type">color=&class="macro">#x27;blue&class="macro">#x27;) legend = [&class="macro">#x27;Train Mean&class="macro">#x27;,&class="macro">#x27;Model Predictions&class="macro">#x27;] plt.legend(legend) plt.title("Visualizing Our Improvements") plt.plot() plt.grid() plt.xlabel("Return") plt.axvline(train[&class="macro">#x27;Target&class="macro">#x27;].mean(),class="type">color=&class="macro">#x27;red&class="macro">#x27;,linestyle=&class="macro">#x27;:&class="macro">#x27;) sns.histplot(test[&class="macro">#x27;Target&class="macro">#x27;],class="type">color=&class="macro">#x27;black&class="macro">#x27;) sns.histplot(predictions,class="type">color=&class="macro">#x27;blue&class="macro">#x27;) legend = [&class="macro">#x27;Train Mean&class="macro">#x27;,&class="macro">#x27;Test Distribution&class="macro">#x27;,&class="macro">#x27;Model Prediction&class="macro">#x27;] plt.legend(legend)