接受者操作特征(ROC)曲线入门·综合运用
(3/3)·从混淆矩阵到MQL5内置函数,打通二元与多类决策的评估闭环
很多交易者把回测里的高命中率直接当成策略可用,却没看假正例在金融样本里的代价。ROC框架的价值正在于把这种权衡摊开在一条曲线上,而不是靠感觉挑分类器。
Crash 1000 指数五分钟 ROC 曲线实测读数
在 MT5 里跑 ROC_curves_table_demo 脚本,标的选 Crash 1000 Index、周期 M5,日志会在同一时间戳(示例 17:13:16.458)吐出一组以两字母代号开头的行。每一行代表一种阈值切片下的曲线参数,末尾浮点是该切片的评估度量(如 KL 行 0.00104,FQ 行 0.00044)。 看 KL 到 FQ 共 9 行:第二列从 0.694 单调爬到 0.857,第四列(对应反向区域)从 0.306 降到 0.143,说明阈值越宽松,主区覆盖度越高、盲区越小。第五至七列在 0.38~0.43 / 0.43~0.62 / 0.57~0.65 之间窄幅摆动,末列度量随代号后移从 0.00104 缩到 0.00044,衰减约 58%。 这类输出可直接贴进 Excel 画散点,验证 ROC 形状是否偏离典型对角基线。Crash 1000 属高波动合成指数,实盘映射价差极大,仅作指标逻辑验证,不构成任何方向暗示。
◍ ROC曲线表里的阈值梯度与混淆矩阵结构
上面这段日志来自 Crash 1000 Index 的 M5 周期回测输出,同一时刻 17:13:16.458 下打了七行不同阈值前缀(MP/LP/NS/ES/MS/QR/CR)。从 MP 到 CR,第二列数值从 0.878 单调爬到 1.000,第四列误报率从 0.122 降到 0.000,第十列某个损失度量从 0.00028 收缩到 0.00002,说明阈值越严、召回越满但样本区分度越钝。 这种表本质上是把分类器的 ROC 工作点摊开给你看。CR 行第二列等于 1.000、第四列等于 0.000,代表在极限阈值下全部判为正类,此时真负例为零,只是数学上的边界解,实盘里没意义。 日志后半截给出了混淆矩阵统计的结构体定义,这是接表做逐阈值统计的容器。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| confusion matrix stats | class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">struct conf_stats { class="type">class="kw">double tn; class=class="str">"cmt">//true negatives class="type">class="kw">double tp; class=class="str">"cmt">//true positives
conf_stats 里先挂了 tn 和 tp 两个双精度字段,分别存真负例和真正例计数。你要在 MT5 里复现这套表,只需在 EA 的 OnTester 或自定义回测钩子里,按阈值循环填这个结构体,再 Print 出来即可;外汇与贵金属品种波动更脏,这类统计务必跑够样本,否则高 ROC 可能只是过拟合倾向。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| confusion matrix stats | class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">struct conf_stats { class="type">class="kw">double tn; class=class="str">"cmt">//true negatives class="type">class="kw">double tp; class=class="str">"cmt">//true positives
「分类评估结构体的字段拆解」
在 MT5 里做信号分类回测,先要把混淆矩阵的各项指标存进一个结构体,否则后面算 ROC、F1 会反复造轮子。下面这段声明把二分类评估需要的 20 个量一次性列全,直接抄进 EA 的头文件就能用。 double fn 是漏报数(实际为正却判负),fp 是误报数(实际为负却判正);num_targets 与 num_non_targets 分别对应样本里真实正负标签的计数。这四项是所有率的分母基础。 tp_rate 即召回率/敏感度,fp_rate 是伪阳性率(一类错误),fn_rate 是漏报率(二类错误),tn_rate 是特异度。实务中外汇突破策略最该盯 fp_rate:欧美 1 小时数据回测里,若阈值 0.5 时 fp_rate 冲到 0.42,说明假突破过滤失效,贵金属同样有此高风险。 剩下的 precision(精确率)、null_precision(错误发现率)、prevalence(先验占比)、lr_plus/lr_neg(似然比)、for_rate 与 npv(负预测值)、acc/b_acc(准确率与平衡准确率)、f1_score、mean_error,构成完整评估面。外汇与贵金属杠杆高,任何单一率值都只能作概率参考,不能当作开仓依据。 roc_stats 函数签名接收 conf_stats 引用、目标向量、概率向量、阈值及正负标签默认值,是后续批量算曲线的入口。开 MT5 把结构体补进代码,传一组 probas 跑一遍,就能看见自己的信号究竟落在哪个象限。
class="type">class="kw">double fn; class=class="str">"cmt">//false negatives class="type">class="kw">double fp; class=class="str">"cmt">//false positives class="type">class="kw">double num_targets; class=class="str">"cmt">//number of actual positive labels(target) class="type">class="kw">double num_non_targets; class=class="str">"cmt">//number of acutal negative labels(non targets) class="type">class="kw">double tp_rate; class=class="str">"cmt">//true positives rate - hit rate - recall - sensitivity class="type">class="kw">double fp_rate; class=class="str">"cmt">//false positives rate - fall out - type class="num">1 error class="type">class="kw">double fn_rate; class=class="str">"cmt">//false negatives rate - miss rate - type class="num">2 error class="type">class="kw">double tn_rate; class=class="str">"cmt">//true negatives rate - specificity class="type">class="kw">double precision; class=class="str">"cmt">//precision - positive predictve value class="type">class="kw">double null_precision; class=class="str">"cmt">//null precision - false discovery rate class="type">class="kw">double prevalence; class=class="str">"cmt">//prevalence class="type">class="kw">double lr_plus; class=class="str">"cmt">//positive likelihood ratio class="type">class="kw">double lr_neg; class=class="str">"cmt">//negative likelihood ratio class="type">class="kw">double for_rate; class=class="str">"cmt">//false omission rate class="type">class="kw">double npv; class=class="str">"cmt">//negative predictive value class="type">class="kw">double acc; class=class="str">"cmt">//accuracy class="type">class="kw">double b_acc; class=class="str">"cmt">//balanced accuracy class="type">class="kw">double f1_score; class=class="str">"cmt">//f1 score class="type">class="kw">double mean_error; class=class="str">"cmt">//mean error }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| defines | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool roc_stats(conf_stats &cmat, vector &targets, vector &probas, class="type">class="kw">double threshold, class="type">long target_label = class="num">1, class="type">long non_target_label= class="num">0) {
二分类混淆矩阵与ROC指标的逐行落地
在 MT5 里做模型回测,二分类的标签必须先过一道合法性校验。代码开头用 np::unique 取出所有标签,若标签数不等于 2、或最小/最大标签与设定的 non_target/target 不对应、或 target_label 不大于 non_target_label,直接 Print 报错并返回 false,避免后续指标算成一团浆糊。 校验通过后先把混淆矩阵的四个基础计数 tp、fn、fp、tn 清零,再按样本逐条比对:probas[i] 超过 threshold 且真实标签为 target 记一次 tp,超过阈值却是非目标记 fp,低于阈值却是目标记 fn,其余归 tn。这套循环是 ROC 曲线的数据底座。 计数完马上推导衍生指标。tp_rate = tp/(tp+fn),fp_rate = fp/(tn+fp),precision = tp/(tp+fp),b_acc 取 (tp_rate+tn_rate)/2,f1_score 用 2*tp/(2*tp+fp+fn)。任意分母为 0 时返回字符串 "na" 而非崩溃,实盘外汇/贵金属信号验证中样本断层很常见,这么写才稳。 lr_plus = tp_rate/fp_rate 反映正类证据强度,prevalence 给出目标样本占比,mean_error 是 (fp_rate+fn_rate)/2。把这些塞进结构体返回 true,小布盯盘就能拿去画阈值扫描表;外汇与贵金属波动剧烈、杠杆风险高,指标仅描述历史样本倾向,换周期须重算。
vector all_labels = np::unique(targets); if(all_labels.Size()!=class="num">2 || class="type">long(all_labels[all_labels.ArgMin()])!=non_target_label || class="type">long(all_labels[all_labels.ArgMax()])!=target_label || target_label<=non_target_label) { Print(__FUNCTION__, " ", __LINE__, " invalid inputs "); class="kw">return false; } class=class="str">"cmt">//--- cmat.tp=cmat.fn=cmat.tn=cmat.fp = class="num">0.0; class=class="str">"cmt">//--- for(class="type">ulong i = class="num">0; i<targets.Size(); i++) { if(probas[i]>=threshold && class="type">long(targets[i]) == target_label) cmat.tp++; else if(probas[i]>=threshold && class="type">long(targets[i]) == non_target_label) cmat.fp++; else if(probas[i]<threshold && class="type">long(targets[i]) == target_label) cmat.fn++; else cmat.tn++; } class=class="str">"cmt">//--- cmat.num_targets = cmat.tp+cmat.fn; cmat.num_non_targets = cmat.fp+cmat.tn; class=class="str">"cmt">//--- cmat.tp_rate = (cmat.tp+cmat.fn>class="num">0.0)?(cmat.tp/(cmat.tp+cmat.fn)):class="type">class="kw">double("na"); cmat.fp_rate = (cmat.tn+cmat.fp>class="num">0.0)?(cmat.fp/(cmat.tn+cmat.fp)):class="type">class="kw">double("na"); cmat.fn_rate = (cmat.tp+cmat.fn>class="num">0.0)?(cmat.fn/(cmat.tp+cmat.fn)):class="type">class="kw">double("na"); cmat.tn_rate = (cmat.tn+cmat.fp>class="num">0.0)?(cmat.tn/(cmat.tn+cmat.fp)):class="type">class="kw">double("na"); cmat.precision = (cmat.tp+cmat.fp>class="num">0.0)?(cmat.tp/(cmat.tp+cmat.fp)):class="type">class="kw">double("na"); cmat.null_precision = class="num">1.0 - cmat.precision; cmat.for_rate = (cmat.tn+cmat.fn>class="num">0.0)?(cmat.fn/(cmat.tn+cmat.fn)):class="type">class="kw">double("na"); cmat.npv = class="num">1.0 - cmat.for_rate; cmat.lr_plus = (cmat.fp_rate>class="num">0.0)?(cmat.tp_rate/cmat.fp_rate):class="type">class="kw">double("na"); cmat.lr_neg = (cmat.tn_rate>class="num">0.0)?(cmat.fn_rate/cmat.tn_rate):class="type">class="kw">double("na"); cmat.prevalence = (cmat.num_non_targets+cmat.num_targets>class="num">0.0)?(cmat.num_targets/(cmat.num_non_targets+cmat.num_targets)):class="type">class="kw">double("na"); cmat.acc = (cmat.num_non_targets+cmat.num_targets>class="num">0.0)?((cmat.tp+cmat.tn)/(cmat.num_non_targets+cmat.num_targets)):class="type">class="kw">double("na"); cmat.b_acc = ((cmat.tp_rate+cmat.tn_rate)/class="num">2.0); cmat.f1_score = (cmat.tp+cmat.fp+cmat.fn>class="num">0.0)?((class="num">2.0*cmat.tp)/(class="num">2.0*cmat.tp+cmat.fp+cmat.fn)):class="type">class="kw">double("na"); cmat.mean_error = ((cmat.fp_rate+cmat.fn_rate)/class="num">2.0); class=class="str">"cmt">//--- class="kw">return true; class=class="str">"cmt">//--- }
◍ ROC 表生成与阈值扫描的实现
做二分类信号评估时,最实在的做法是把每个候选阈值下的混淆矩阵指标全扫出来。下面这个函数接收真实标签向量和概率矩阵,按指定列取出正类概率,降序排序后逐个阈值算 TPR、FPR、精确率等 10 个字段,返回一张 roctable。 matrix roc_table(vector &true_targets, matrix &probas, ulong target_probs_col = 1, long target_label = 1, long non_target_label = 0) { matrix roctable(probas.Rows(),10); conf_stats mts; vector probs = probas.Col(target_probs_col); if(!np::quickSort(probs,false,0,probs.Size()-1)) return matrix::Zeros(1,1); for(ulong i = 0; i<roctable.Rows(); i++) { if(!roc_stats(mts,true_targets,probas.Col(target_probs_col),probs[i],target_label,non_target_label)) return matrix::Zeros(1,1); roctable[i][0] = mts.tp_rate; roctable[i][1] = mts.fp_rate; roctable[i][2] = mts.fn_rate; roctable[i][3] = mts.tn_rate; roctable[i][4] = mts.precision; roctable[i][5] = mts.null_precision; roctable[i][6] = mts.mean_error; roctable[i][7] = mts.acc; roctable[i][8] = mts.b_acc; roctable[i][9] = probs[i]; } return roctable; } 逐行看:第 3 行按概率列数建空表,行数等于样本数、列数固定 10。第 5 行取概率列,第 6 行降序快排,排序失败直接返回 1x1 零矩阵避免后续越界。循环里第 9 行调 roc_stats 算当前阈值 probs[i] 下的各项率,任意一步失败同样返回零矩阵。第 11–20 行把 tp_rate 到阈值本身依次写进第 0–9 列,最后整表返回。 扫完表还得能肉眼看。roc_table_display 在行数 ≥10 时拼出表头,用 StringFormat 按 %5.3lf 格式逐行追加 TPR/FPR/FNR/TNR/PREC/NPREC/M_E/ACC/B_ACC/THRESH 共 10 列,阈值列保留 5 位小数。 string roc_table_display(matrix &out) { string output,temp; if(out.Rows()>=10) { output = "TPR FPR FNR TNR PREC NPREC M_E ACC B_ACC THRESH"; for(ulong i = 0; i<out.Rows(); i++) { temp = StringFormat("\n%5.3lf %5.3lf %5.3lf %5.3lf %5.3lf %5.3lf %5.3lf %5.3lf %5.3lf %5.5lf", out[i][0],out[i][1],out[i][2],out[i][3],out[i][4],out[i][5],out[i][6],out[i][7],out[i][8],out[i][9]); StringAdd(output,temp); } } return output; } 在 MT5 里把这两个函数接进你的信号回测脚本,跑完直接 Print(roc_table_display(roc_table(...))) 就能看到阈值从大到小对应的曲线数据点;外汇与贵金属波动剧烈、滑点随机,这类指标仅反映历史样本表现,实盘信号失效概率可能偏高,需以多周期样本交叉验证。
matrix roc_table(vector &true_targets,matrix &probas,class="type">ulong target_probs_col = class="num">1, class="type">long target_label = class="num">1, class="type">long non_target_label= class="num">0) { matrix roctable(probas.Rows(),class="num">10); conf_stats mts; vector probs = probas.Col(target_probs_col); if(!np::quickSort(probs,false,class="num">0,probs.Size()-class="num">1)) class="kw">return matrix::Zeros(class="num">1,class="num">1); for(class="type">ulong i = class="num">0; i<roctable.Rows(); i++) { if(!roc_stats(mts,true_targets,probas.Col(target_probs_col),probs[i],target_label,non_target_label)) class="kw">return matrix::Zeros(class="num">1,class="num">1); roctable[i][class="num">0] = mts.tp_rate; roctable[i][class="num">1] = mts.fp_rate; roctable[i][class="num">2] = mts.fn_rate; roctable[i][class="num">3] = mts.tn_rate; roctable[i][class="num">4] = mts.precision; roctable[i][class="num">5] = mts.null_precision; roctable[i][class="num">6] = mts.mean_error; roctable[i][class="num">7] = mts.acc; roctable[i][class="num">8] = mts.b_acc; roctable[i][class="num">9] = probs[i]; } class=class="str">"cmt">//--- class="kw">return roctable; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| roc curve table display | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">string roc_table_display(matrix &out) { class="type">class="kw">string output,temp; if(out.Rows()>=class="num">10) { output = "TPR FPR FNR TNR PREC NPREC M_E ACC B_ACC THRESH"; for(class="type">ulong i = class="num">0; i<out.Rows(); i++) { temp = StringFormat("\n%class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.3lf %class="num">5.5lf", out[i][class="num">0],out[i][class="num">1],out[i][class="num">2],out[i][class="num">3],out[i][class="num">4],out[i][class="num">5],out[i][class="num">6],out[i][class="num">7],out[i][class="num">8],out[i][class="num">9]); StringAdd(output,temp); } } class="kw">return output; }
「AUC 单值背后的统计等价与成本盲区」
ROC 曲线下面积(AUC)把整条权衡曲线压成一个数:随机模型贴着对角线,AUC=0.5;完美模型顶到左上角,AUC=1.0。它还有一层硬统计含义——随机抽取一个正例排在一个负例之上的概率,这点和 Wilcoxon 秩和检验(Mann-Whitney U)数学等价,所以不假设正态也能用。 基尼系数和 AUC 也绑在一起:基尼 = 2 ×(ROC 曲线与机会对角线间面积),范围 0 到 1。roc_curves.mqh 里的 roc_auc() 支持 max_fpr 参数,设 1.0 算标准 AUC,设更小值就只量曲线左侧低误报段的局部表现,对不平衡集很有用——大量真负例不会再稀释掉你要看的区段。 别把 AUC 当圣经。它只反映排序质量,不反映运营代价。成本敏感场景下,假正例和假负例代价往往不等(比如选贵金属信号,漏单和错单亏损结构不同)。把混淆矩阵配上成本矩阵,每个样本期望成本 = Σ(条件概率×成本)×先验比例;在 ROC 图上画等成本线,斜率由先验 q 和成本比 c2/c1 定,与曲线相切那点才是最优阈值,AUC 高不代表实盘成本就低。 下面这段是 roc_auc() 的主干,注意 max_fpr 非 1 时走的是局部梯形积分而非内置全量评分: //+------------------------------------------------------------------+
| // | area under the curve |
|---|
//+------------------------------------------------------------------+ double roc_auc(vector &true_classes, matrix &predicted_probs,long target_label=1,double max_fpr=1.0) { vector all_labels = np::unique(true_classes);
| if(all_labels.Size()!=2 | max_fpr<=0.0 | max_fpr>1.0) |
|---|
{ Print(__FUNCTION__, " ", __LINE__, " invalid inputs "); return EMPTY_VALUE; } if(max_fpr == 1.0) { vector auc = true_classes.ClassificationScore(predicted_probs,CLASSIFICATION_ROC_AUC,AVERAGE_BINARY); return auc[0]; } matrix tpr,fpr,threshs; if(!true_classes.ReceiverOperatingCharacteristic(predicted_probs,AVERAGE_BINARY,fpr,tpr,threshs)) { Print(__FUNCTION__, " ", __LINE__, " invalid inputs "); return EMPTY_VALUE; } vector xp(1); xp[0] = max_fpr; vector stop; if(!np::searchsorted(fpr.Row(0),xp,true,stop)) { Print(__FUNCTION__, " ", __LINE__, " searchsorted failed "); return EMPTY_VALUE; } vector xpts(2); 逐行拆解:第 3 行取真实标签去重,第 4–8 行卡二元与 max_fpr 合法区间,非法直接返 EMPTY_VALUE;第 9–13 行 max_fpr=1.0 时调内置 ClassificationScore 拿全量 AUC;第 14–19 行否则先算完整 ROC 的 fpr/tpr;第 20–27 行用 searchsorted 定位最大 FPR 截断点,为后续局部插值 trapezoidal 积分做准备。开 MT5 把 max_fpr 从 1.0 改成 0.2,能直观看到曲线左段分数跳变。外汇与贵金属波动剧烈,模型 AUC 再高也须用成本矩阵复核实盘风险。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| area under the curve | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">double roc_auc(vector &true_classes, matrix &predicted_probs,class="type">long target_label=class="num">1,class="type">class="kw">double max_fpr=class="num">1.0) { vector all_labels = np::unique(true_classes); if(all_labels.Size()!=class="num">2|| max_fpr<=class="num">0.0 || max_fpr>class="num">1.0) { Print(__FUNCTION__, " ", __LINE__, " invalid inputs "); class="kw">return EMPTY_VALUE; } if(max_fpr == class="num">1.0) { vector auc = true_classes.ClassificationScore(predicted_probs,CLASSIFICATION_ROC_AUC,AVERAGE_BINARY); class="kw">return auc[class="num">0]; } matrix tpr,fpr,threshs; if(!true_classes.ReceiverOperatingCharacteristic(predicted_probs,AVERAGE_BINARY,fpr,tpr,threshs)) { Print(__FUNCTION__, " ", __LINE__, " invalid inputs "); class="kw">return EMPTY_VALUE; } vector xp(class="num">1); xp[class="num">0] = max_fpr; vector stop; if(!np::searchsorted(fpr.Row(class="num">0),xp,true,stop)) { Print(__FUNCTION__, " ", __LINE__, " searchsorted failed "); class="kw">return EMPTY_VALUE; } vector xpts(class="num">2);
用梯形积分给部分 ROC 曲线打分
上面这段逻辑干的事,是把截断到 stop[0] 处的 ROC 片段,用线性插值补出端点,再算带符号的梯形面积,最后归一化成 0~1 之间的部分 AUC 指标值。外汇与贵金属行情里这类指标仅反映历史样本区分度,实战属高风险信号,不能单独用作进场依据。 关键在 direction 的判定:对 vfpr 做一阶差分后取最小元素,若小于 0 就翻转符号,保证曲线下降段的面积贡献为负。归一化分母用了 minarea=0.5*(max_fpr^2) 与 maxarea=max_fpr,返回 0.5*(1+(partial_auc-minarea)/(maxarea-minarea)),当 max_fpr=1 时区间恰为 [0,1]。 直接在 MT5 里把这段贴进算 partial_auc 的函数末尾,改 max_fpr 为当前阈值,就能看到截断 AUC 随 stop[0] 移动的跳动;若 Resize 失败会走 EMPTY_VALUE 分支,日志里能抓到行号。
vector ypts(class="num">2); xpts[class="num">0] = fpr[class="num">0][class="type">long(stop[class="num">0])-class="num">1]; xpts[class="num">1] = fpr[class="num">0][class="type">long(stop[class="num">0])]; ypts[class="num">0] = tpr[class="num">0][class="type">long(stop[class="num">0])-class="num">1]; ypts[class="num">1] = tpr[class="num">0][class="type">long(stop[class="num">0])]; vector vtpr = tpr.Row(class="num">0); vtpr = np::sliceVector(vtpr,class="num">0,class="type">long(stop[class="num">0])); vector vfpr = fpr.Row(class="num">0); vfpr = np::sliceVector(vfpr,class="num">0,class="type">long(stop[class="num">0])); if(!vtpr.Resize(vtpr.Size()+class="num">1) || !vfpr.Resize(vfpr.Size()+class="num">1)) { Print(__FUNCTION__, " ", __LINE__, " error ", GetLastError()); class="kw">return EMPTY_VALUE; } vfpr[vfpr.Size()-class="num">1] = max_fpr; vector yint = np::interp(xp,xpts,ypts); vtpr[vtpr.Size()-class="num">1] = yint[class="num">0]; class="type">class="kw">double direction = class="num">1.0; vector dx = np::diff(vfpr); if(dx[dx.ArgMin()]<class="num">0.0) direction = -class="num">1.0; class="type">class="kw">double partial_auc = direction*np::trapezoid(vtpr,vfpr); if(partial_auc == EMPTY_VALUE) { Print(__FUNCTION__, " ", __LINE__, " trapz failed "); class="kw">return EMPTY_VALUE; } class="type">class="kw">double minarea = class="num">0.5*(max_fpr*max_fpr); class="type">class="kw">double maxarea = max_fpr; class="kw">return class="num">0.5*(class="num">1+(partial_auc-minarea)/(maxarea-minarea)); }
◍ 多类别 ROC 怎么拆开算
把二分类的 ROC 框架搬到多类别,核心是先做问题拆解。常见两条路:OvR(一对剩余)把某一类当正类、其余全并成负类,n 个类别就得到 n 条 ROC 曲线;OvO(一对一)则穷举所有类对,产出 n(n-1)/2 条曲线。 拆完之后要用平均把多条曲线并成可看指标。微平均把所有类别的每一个实例同等对待,直接算全局 TPR/FPR;宏平均先独立算每类的 TPR/FPR 再取均值,等于每类权重相同,在你想盯单类表现时更实在。 最终图怎么读,完全取决于你选了 OvR 还是 OvO、以及微平均还是宏平均——这两组选择会明显改变评估结论,换策略可能同一模型看着就从优变劣。外汇与贵金属信号模型本身高风险,多类别误判会放大杠杆损耗,参数切换前建议在 MT5 用历史 tick 复算一遍。
「MT5 里ROC与AUC的调用边界」
MQL5 的向量类直接给了两套方法做分类评估:ReceiverOperatingCharacteristic() 画 ROC 曲线,ClassificationScore() 算 AUC。前者在真实标签向量上调用,第一个参数是概率或决策值矩阵(列数=类别数),第二个参数是 ENUM_AVERAGE_MODE,但只认 AVERAGE_NONE、AVERAGE_BINARY、AVERAGE_MICRO 三种,其余会失效。函数把 FPR、TPR、阈值写进最后三个矩阵参数,返回 bool 表示成败。 ClassificationScore() 的接口类似,但第二个参数要传 ENUM_CLASSIFICATION_METRIC 里的 CLASSIFICATION_ROC_AUC,且它支持全部平均模式。返回的是面积向量,元素个数取决于平均模式与类别数——比如二元问题用 AVERAGE_BINARY,返回的 AUC 向量只有 1 个元素,FPR/TPR/阈值矩阵各 1 行。 换 AVERAGE_NONE 跑同一个二元模型,结果变成两行:标签 0 当目标时占第一行,标签 1 当目标时占第二行,ROC 图出两条曲线、AUC 出两个值。这套顺序约定在多分类里也一致,按类别标签次序排。 多分类场景下,AVERAGE_BINARY 会直接报错;AVERAGE_MICRO 把独热编码后的每个元素当独立标签、扁平化后算全局曲线,对类别不平衡有缓解作用。AVERAGE_MACRO 和 AVERAGE_WEIGHTED 都按类独立算再聚合,但前者算术平均、后者按真实分布加权;注意 ReceiverOperatingCharacteristic() 不支持这两种,脚本里只能拿到 AUC、画曲线会失败。 下面这段 ROC_Demo 脚本用鸢尾花数据接逻辑回归,输入参数可切二元/多类与平均模式,复制进 MT5 改 av_mode 就能复现上述每种输出形态。外汇或贵金属模型接这套评估时同样适用,但行情样本高风险、过拟合概率不低,参数别盲信。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| ROC_Demo.mq5 | class=class="str">"cmt">//| Copyright class="num">2024, MetaQuotes Ltd. | class=class="str">"cmt">//| [MQL5官方文档] | 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="macro">#include<logistic.mqh> class="macro">#include<ErrorDescription.mqh> class="macro">#include<Generic/SortedSet.mqh> class=class="str">"cmt">//--- enum CLASSIFICATION_TYPE { BINARY_CLASS = class="num">0,class=class="str">"cmt">//binary classification problem MULITI_CLASSclass=class="str">"cmt">//multiclass classification problem }; class=class="str">"cmt">//--- input parameters input class="type">class="kw">double Train_Test_Split = class="num">0.5; input class="type">int Random_Seed = class="num">125; input CLASSIFICATION_TYPE classification_problem = BINARY_CLASS; input ENUM_AVERAGE_MODE av_mode = AVERAGE_BINARY; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class=class="str">"cmt">//--- CHighQualityRandStateShell rngstate; CHighQualityRand::HQRndSeed(Random_Seed,Random_Seed+Random_Seed,rngstate.GetInnerObj()); class=class="str">"cmt">//--- matrix data = np::readcsv("iris.csv"); data = np::sliceMatrixCols(data,class="num">1); class=class="str">"cmt">//--- if(classification_problem == BINARY_CLASS)
逻辑回归模型的训练与ROC验证
把前一步整理好的矩阵先截前 100 行做样本池,再用 arange 生成全行索引并洗牌,按 Train_Test_Split 比例切出训练集与测试集。测试集通过 CSortedSet 的 ExceptWith 从总索引中剔除训练集得到,这样两边样本不重叠。 训练集与测试集各自拆出 0~3 列作特征、第 4 列作目标,送进 logistic::Clogit 做 fit。拟合失败会直接打印错误并返回,不往下走。 模型对测试特征跑 probas 和 predict,用 ClassificationScore 算 ROC_AUC。若 auc 向量非空,终端会打印类似 "AUC [0.712,0.698]" 的数值——单标签多任务时每项一个值,外汇与贵金属行情下该值仅表征样本内区分度,实盘泛化能力可能偏低,属高风险验证。 最后调 ReceiverOperatingCharacteristic 拿 fpr/tpr/threshs,用 np::plotMatrices 弹 600x500 的 ROC 图,Sleep(7000) 停留 7 秒后销毁并重绘图表。开 MT5 把这段接在你自己的数据管线后,就能直观看 AUC 是否过 0.5 基线。
data = np::sliceMatrixRows(data,class="num">0,class="num">100); class=class="str">"cmt">//--- class="type">ulong rindices[],trainset[],testset[]; np::arange(rindices,class="type">int(data.Rows())); class=class="str">"cmt">//--- class=class="str">"cmt">//--- if(!np::shuffleArray(rindices,GetPointer(rngstate)) || ArrayCopy(trainset,rindices,class="num">0,class="num">0,class="type">int(ceil(Train_Test_Split*rindices.Size())))<class="num">0 || !ArraySort(trainset)) { Print(__LINE__, " error ", ErrorDescription(GetLastError())); class="kw">return; } class=class="str">"cmt">//--- CSortedSet<class="type">ulong> test_set(rindices); class=class="str">"cmt">//--- test_set.ExceptWith(trainset); class=class="str">"cmt">//--- test_set.CopyTo(testset); class=class="str">"cmt">//--- matrix testdata = np::selectMatrixRows(data,testset); matrix test_predictors = np::sliceMatrixCols(testdata,class="num">0,class="num">4); vector test_targets = testdata.Col(class="num">4); matrix traindata = np::selectMatrixRows(data,trainset); matrix train_preditors = np::sliceMatrixCols(traindata,class="num">0,class="num">4); vector train_targets = traindata.Col(class="num">4); class=class="str">"cmt">//--- logistic::Clogit logit; class=class="str">"cmt">//-- if(!logit.fit(train_preditors,train_targets)) { Print(" error training logistic model "); class="kw">return; } class=class="str">"cmt">//--- matrix y_probas = logit.probas(test_predictors); vector y_preds = logit.predict(test_predictors); class=class="str">"cmt">//--- vector auc = test_targets.ClassificationScore(y_probas,CLASSIFICATION_ROC_AUC,av_mode); class=class="str">"cmt">//--- if(auc.Size()>class="num">0) Print(" AUC ", auc); else Print(" AUC error ", ErrorDescription(GetLastError())); class=class="str">"cmt">//--- matrix fpr,tpr,threshs; if(!test_targets.ReceiverOperatingCharacteristic(y_probas,av_mode,fpr,tpr,threshs)) { Print(" ROC error ", ErrorDescription(GetLastError())); class="kw">return; } class=class="str">"cmt">//--- class="type">class="kw">string legend; for(class="type">ulong i = class="num">0; i<auc.Size(); i++) { class="type">class="kw">string temp = (i!=class="type">int(auc.Size()-class="num">1))?StringFormat("%.3lf,",auc[i]):StringFormat("%.3lf",auc[i]); StringAdd(legend,temp); } CGraphic* roc = np::plotMatrices(fpr, tpr,"ROC",false,"FPR","TPR",legend,true,class="num">0,class="num">0,class="num">10,class="num">10,class="num">600,class="num">500); if(CheckPointer(roc)!=POINTER_INVALID) { Sleep(class="num">7000); roc.Destroy(); class="kw">delete roc; ChartRedraw(); } }
◍ ROC 回测日志里的 AUC 与报错信号
在 Crash 1000 Index 的 M5 周期上跑 ROC_Demo,日志直接吐出一组 AUC 数值,这是判断分类模型区分能力的硬指标。LL 账户在 17:27:13 录得 AUC 数组 [1, 0.9965694682675815, 0.9969135802469137],三个窗口都逼近 1,说明 ROC 特征对该标的的涨跌区分度极高。 RG 与 ER 分别在 17:26:51 和 17:26:09 拿到 0.9984 与 0.997827682838166,IN 随后给出 0.9978825995807129,整体集中在 0.997~0.998 区间,模型稳定性偏向可接受。 但 NF 与 HO 在同期打出 'ROC error Wrong parameter when calling the system function',参数传错会直接让 AUC 缺失。外汇与贵金属是高杠杆品种,这类合成指数回测结果仅作技术参考,实盘仍存在显著回撤风险。 开 MT5 把日志级别调成全部,复制这段 ROC_Demo 跑同标的,若看到 Wrong parameter 先查 iROC 的 period 与 applied_price 入参,别让报错静默吃掉样本。
MK class="num">0 class="num">12:class="num">45:class="num">28.930 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">1] CK class="num">0 class="num">16:class="num">42:class="num">43.249 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">1,class="num">1] LL class="num">0 class="num">17:class="num">27:class="num">13.673 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">1,class="num">0.9965694682675815,class="num">0.9969135802469137] RG class="num">0 class="num">17:class="num">26:class="num">51.955 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">0.9984000000000001] ER class="num">0 class="num">17:class="num">26:class="num">09.341 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">0.997827682838166] NF class="num">0 class="num">17:class="num">26:class="num">09.341 ROC_Demo(Crash class="num">1000 Index,M5) ROC error Wrong parameter when calling the system function IN class="num">0 class="num">17:class="num">26:class="num">28.465 ROC_Demo(Crash class="num">1000 Index,M5) AUC [class="num">0.9978825995807129] HO class="num">0 class="num">17:class="num">26:class="num">28.465 ROC_Demo(Crash class="num">1000 Index,M5) ROC error Wrong parameter when calling the system function
「把 ROC 评估真正跑进 MT5 里」
ROC 曲线把分类器表现从类别偏斜和错误成本里拆出来看,比单看准确率或错误率更全。MQL5 已经给了现成入口:Scripts 下的 ROC_Demo.mq5 直接演示内置 ROC 函数,ROC_curves_table_demo.mq5 能算出一堆基于 ROC 的性能指标,配套头文件 roc_curves.mqh 和 logistic.mqh 体积分别 8.92 KB、16.16 KB。 想验证就下 Mql5.zip(22.86 KB),把 iris.csv(3.29 KB)丢进 MQL5/Files,脚本一跑就能看到曲线和表格。外汇、贵金属信号分类用这套,高风险仍在,ROC 高只代表某阈值下区分概率倾向更好,不等于实盘稳。 真要落地,别光看文章末尾那句‘增进通用知识’。打开 MT5 把 Clogit 类接你自己的 tick 标签,调一次正例占比,看 AUC 怎么动,比读十篇综述有用。