接受者操作特征(ROC)曲线入门·综合运用
📊

接受者操作特征(ROC)曲线入门·综合运用

(3/3)·从混淆矩阵到MQL5内置函数,打通二元与多类决策的评估闭环

偏理论 第 3/3 篇

很多交易者把回测里的高命中率直接当成策略可用,却没看假正例在金融样本里的代价。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,代表在极限阈值下全部判为正类,此时真负例为零,只是数学上的边界解,实盘里没意义。 日志后半截给出了混淆矩阵统计的结构体定义,这是接表做逐阈值统计的容器。

MQL5 / C++
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 里先挂了 tntp 两个双精度字段,分别存真负例和真正例计数。你要在 MT5 里复现这套表,只需在 EA 的 OnTester 或自定义回测钩子里,按阈值循环填这个结构体,再 Print 出来即可;外汇与贵金属品种波动更脏,这类统计务必跑够样本,否则高 ROC 可能只是过拟合倾向。

MQL5 / C++
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 跑一遍,就能看见自己的信号究竟落在哪个象限。

MQL5 / C++
  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,小布盯盘就能拿去画阈值扫描表;外汇与贵金属波动剧烈、杠杆风险高,指标仅描述历史样本倾向,换周期须重算。

MQL5 / C++
  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(...))) 就能看到阈值从大到小对应的曲线数据点;外汇与贵金属波动剧烈、滑点随机,这类指标仅反映历史样本表现,实盘信号失效概率可能偏高,需以多周期样本交叉验证。

MQL5 / C++
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()!=2max_fpr<=0.0max_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 再高也须用成本矩阵复核实盘风险。

MQL5 / C++
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 分支,日志里能抓到行号。

MQL5 / C++
  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 就能复现上述每种输出形态。外汇或贵金属模型接这套评估时同样适用,但行情样本高风险、过拟合概率不低,参数别盲信。

MQL5 / C++
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 基线。

MQL5 / C++
  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 入参,别让报错静默吃掉样本。

MQL5 / C++
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 怎么动,比读十篇综述有用。

把曲线诊断交给小布
这些 ROC 与 AUC 的诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到分类器在不同阈值下的权衡,你只管看信号是否值得跟。

常见问题

类别极度不平衡时PR曲线对假正例更敏感;ROC更稳但可能乐观。实盘前两者都扫一遍更稳妥,倾向用PR复核稀缺信号。
可以,小布盯盘内置了MQL5的ROC与AUC诊断模块,导入分类信号后自动绘曲线,省去手算混淆矩阵。
常见做法是One-vs-Rest画多条ROC再平均,或看宏观/微观AUC;注意每类样本量差异会扭曲整体评估。
可能只是标签反了或特征符号错;先查数据方向,反转后或能上0.5。外汇贵金属高风险,单看AUC不够。