数据科学与机器学习(第 09 部分):K-最近邻算法(KNN)·进阶篇
📐

数据科学与机器学习(第 09 部分):K-最近邻算法(KNN)·进阶篇

(2/3)· 不是所有邻近点都该信:k 值奇数陷阱与混淆矩阵如何暴露模型盲区

偏理论进阶 第 2/3 篇
把 k 随手设成 4 的交易者不在少数,结果两类票数打平,模型直接哑火。KNN 不假设数据分布,却最怕你用偶数邻居和未清洗的行情样本去逼它投票。

◍ 从日志看KNN向量筛选的实跑输出

上面这段 CS 日志是脚本在 19:33:48.862 同一毫秒内吐出的 9 组向量,格式为 [x, y, label, distance],例如 [51,167,1,6.7082] 表示坐标 (51,167)、类别 1、到查询点的欧氏距离约 6.71。其中 label 为 1 的只有两组([51,167,1,6.7082] 与 [56,174,1,4.12311]),其余 7 组 label 为 0,说明样本在类别上并不均衡。 下面的 MQL5 片段干的事很直接:先把 m_target 和 euc_dist 两个向量拷进普通数组 tarArr 与 eucArray,再用一个循环不断删掉 eucArray 里的最大值及其在 track 中对应位置,直到剩下 k 个最近邻。注意判断条件是 ArraySize(track) > (int)k,意味着若 k 大于等于样本数,循环体一次都不会进。 把代码贴进 MT5 的脚本里跑一遍,改一下 k 的值(比如 3 或 5),看 Print 出来的 NN 和 Track 数组是否真只剩距离最小的几项。外汇与贵金属行情受杠杆与跳空影响,这类距离筛选只反映历史向量相似度,实盘信号概率性成立,高风险。

MQL5 / C++
  class="type">int size = (class="type">int)m_target.Size();
  class="type">class="kw">double tarArr[];
  ArrayResize(tarArr, size);
  class="type">class="kw">double eucArray[];
  ArrayResize(eucArray, size);
  for(class="type">class="kw">ulong i=class="num">0; i<m_target.Size(); i++)  class=class="str">"cmt">//convert the vectors to array
    {
    tarArr[i] = m_target[i];
    eucArray[i] = euc_dist[i];
    }
  class="type">class="kw">double track[], NN[];
  ArrayCopy(track, tarArr);
  class="type">int max;
  for(class="type">int i=class="num">0; i<(class="type">int)m_target.Size(); i++)
    {
    if(ArraySize(track) > (class="type">int)k)
      {
      max = ArrayMaximum(eucArray);
      ArrayRemove(eucArray, max, class="num">1);
      ArrayRemove(track, max, class="num">1);
      }
    }
  ArrayCopy(NN, eucArray);
  Print("NN ");
  ArrayPrint(NN);
  Print("Track ");
  ArrayPrint(track);

KNN 投票计数怎么在 MT5 里落地

上面这段日志来自 MT5 脚本跑 K 近邻分类时的真实输出。05:40:33 那一轮,Track 数组是 [1.0,1.0,0.0,0.0,0.0],classesVector 为 [1,0],统计完 votes 得到 [0,3]——也就是说 3 个近邻都指向类别 1,类别 0 一票没有。 投票逻辑本身不复杂:先建一个和类别数等长的 vector votes,外层遍历每个已知类别,内层拿 track 里的预测标签逐个比对,命中就 count++,最后把计数写进 votes[i]。当 votes 求和等于 k(本例 k=5)时说明所有近邻都已归票,直接 break 跳出。 06:43:30 那轮更直观,Track 变成 [1.0,1.0,0.0,0.0,0.0] 配合 Neighbors [1,0],votes 算出 [2,3];用 votes.ArgMax() 取最大下标,样本 [57,170] 被判定 belongs to class 0。外汇与贵金属行情噪声大,这类近邻归票在小周期上假信号概率偏高,实盘前务必用历史数据复跑。 把下面这段代码直接丢进 MT5 的脚本里,开 isdebug 就能在终端看到每一轮的 votes 数组和最终归类,比看文档快得多。

MQL5 / C++
class=class="str">"cmt">//--- Voting process
  vector votes(m_classesVector.Size());
  for(class="type">class="kw">ulong i=class="num">0; i<votes.Size(); i++)
   {
     class="type">int count = class="num">0;
     for(class="type">class="kw">ulong j=class="num">0; j<track.Size(); j++)
      {
       if(m_classesVector[i] == track[j])
         count++;
      }
     votes[i] = (class="type">class="kw">double)count;
     if(votes.Sum() == k)  class=class="str">"cmt">//all members have voted
       break;
   }
  Print("votes ", votes);

  if(isdebug)
     Print(vector_, " belongs to class ", (class="type">int)m_classesVector[votes.ArgMax()]);

  class="type">int                KNNAlgorithm(vector &vector_);

「切分数据验证近邻模型」

监督式机器学习里,模型搭好后必须拿没见过的数据去测,否则无从判断它在不同样本上的泛化表现。近邻算法本身不提炼数据里的形态,训练阶段只是把样本原样存下来,测试时再拿出来比对,这和逻辑回归或 SVM 的拟合逻辑完全不同。 默认按 70% 训练、30% 测试切分。下面这段函数把矩阵按行拆成两块,训练块用 MathCeil 向上取整、测试块用 MathFloor 向下取整,避免行数除不尽时越界。 不衡量准确度的话,跑测试纯粹浪费算力。日志里能看到训练矩阵前几行形如 [51,167,1]、[62,182,0],说明特征列加标签列已经正确分离,外汇与贵金属行情建模时这类切分偏差会直接放大样本外风险,需手动复核边界行。

MQL5 / C++
class="type">class="kw">float TrainTest(class="type">class="kw">double train_size=class="num">0.7)
class=class="str">"cmt">//--- Split the matrix

  matrix default_Matrix = Matrix;

  class="type">int train = (class="type">int)MathCeil(m_rows*train_size),
      test  = (class="type">int)MathFloor(m_rows*(class="num">1-train_size));

  if (isdebug) printf("Train %d test %d",train,test);
  matrix TrainMatrix(train,m_cols), TestMatrix(test,m_cols);
  class="type">int train_index = class="num">0, test_index =class="num">0;
class=class="str">"cmt">//---

  for (class="type">class="kw">ulong r=class="num">0; r<Matrix.Rows(); r++)
    {
      if ((class="type">int)r < train)
        {
          TrainMatrix.Row(Matrix.Row(r),train_index);
          train_index++;
        }
      else
        {
          TestMatrix.Row(Matrix.Row(r),test_index);
          test_index++;
        }      
    }
  if (isdebug) Print("TrainMatrix\n",TrainMatrix,"\nTestMatrix\n",TestMatrix);

◍ 用 KNN 跑通测试集预测

训练矩阵复制完成后,真正要验证的是算法在测试集上的泛化表现。下面这段循环把测试集逐行喂给 KNN,去掉因变量后取预测值,并打印输入与输出。 日志里两次调用都返回 out 0.0:输入 [57,173] 和 [55,170] 的预测结果均为 0.0,说明在当前训练样本下,这两个特征向量的最近邻标签倾向为 0 类,但这只是极小样本下的现象,不能外推为稳定规律。 外汇与贵金属市场高风险,KNN 这类惰性学习对特征尺度极其敏感,实盘前务必在 MT5 用更大样本重跑,观察 out 分布是否随 k 值与距离权重变化。

MQL5 / C++
  Matrix.Copy(TrainMatrix); class=class="str">"cmt">//That&class="macro">#x27;s it ???
class=class="str">"cmt">//--- Testing the Algorithm
  
  vector TestPred(TestMatrix.Rows());
  vector v_in = {};
  
  for (class="type">class="kw">ulong i=class="num">0; i<TestMatrix.Rows(); i++)
   {
     v_in = TestMatrix.Row(i);
     v_in.Resize(v_in.Size()-class="num">1); class=class="str">"cmt">//Remove independent variable
     
     TestPred[i] = KNNAlgorithm(v_in);
     
     Print("v_in ",v_in," out ",TestPred[i]);
   }  
CS    class="num">0    class="num">09:class="num">51:class="num">45.136  TestScript    v_in [class="num">57,class="num">173] out class="num">0.0
CS    class="num">0    class="num">09:class="num">51:class="num">45.136  TestScript    v_in [class="num">55,class="num">170] out class="num">0.0

用混淆矩阵看清 KNN 的真实命中

KNN 函数库跑通后,光看准确率不够,得拆开看模型把哪些类判对了、哪些类判混了。混淆矩阵就是干这个的:行是真实标签,列是预测标签,对角线才是真命中。 下面这段是 ConfusionMatrix 方法的实现,输入真实向量 A 和预测向量 P,按两类(m_classesVector[0] 与 [1])统计 tp/tn/fp/fn,再填进 size×size 的矩阵返回。 matrix CKNNNearestNeighbors::ConfusionMatrix(vector &A,vector &P) { ulong size = m_classesVector.Size(); matrix mat_(size,size); // 取类别数,建同维方阵 if (A.Size() != P.Size()) Print("Cant create confusion matrix | A and P not having the same size "); // 长度不一致直接报错退出 else { int tn = 0,fn =0,fp =0, tp=0; // 四类计数器初始化 for (ulong i = 0;i<A.Size(); i++) // 遍历每个样本 { if (A[i]== P[i] && P[i]==m_classesVector[0]) tp++; // 真实与预测同为类0,记真正 if (A[i]== P[i] && P[i]==m_classesVector[1]) tn++; // 真实与预测同为类1,记真负 if (P[i]==m_classesVector[0] && A[i]==m_classesVector[1]) fp++; // 预测类0实际类1,假正 if (P[i]==m_classesVector[1] && A[i]==m_classesVector[0]) fn++; // 预测类1实际类0,假负 } mat_[0][0] = tn; mat_[0][1] = fp; // 左上真负,右上假正 mat_[1][0] = fn; mat_[1][1] = tp; // 左下假负,右下真正 } return(mat_); } matrix cf_m = ConfusionMatrix(TargetPred,TestPred); // 拿测试集真实与预测算矩阵 vector diag = cf_m.Diag(); // 取对角线元素 float acc = (float)(diag.Sum()/cf_m.Sum())*100; // 对角和除以总和非零项,得准确率百分比 Print("Confusion Matrix\n",cf_m,"\nAccuracy ------> ",acc,"%"); return(acc); 日志里那次测试只给了 2 个测试点,且都落进 zero 类(正常类),矩阵打出 [[2,0][0,0]],准确率 100%。这种样本量没有泛化意义,外汇与贵金属行情里小样本过拟合概率很高,真要验证得换几十根以上 K 线。 别把百分百当信号 那次 100% 只是因为测试集全员同类,模型啥也没学出来也能满分。上 MT5 把 TestPred 换成跨品种 EURUSD 与 XAUUSD 的各 50 根收盘标签,重跑 ConfusionMatrix,看 fn 是不是比 fp 更刺眼,才知哪边更易漏判。

MQL5 / C++
matrix CKNNNearestNeighbors::ConfusionMatrix(vector &A,vector &P)
 {
   class="type">class="kw">ulong size = m_classesVector.Size();
   matrix mat_(size,size);
   
   if (A.Size() != P.Size()) 
      Print("Cant create confusion matrix | A and P not having the same size ");
   else
     {
       
       class="type">int tn = class="num">0,fn =class="num">0,fp =class="num">0, tp=class="num">0;
       for (class="type">class="kw">ulong i = class="num">0;i<A.Size(); i++)
         {               
           if (A[i]== P[i] && P[i]==m_classesVector[class="num">0])
             tp++; 
           if (A[i]== P[i] && P[i]==m_classesVector[class="num">1])
             tn++;
           if (P[i]==m_classesVector[class="num">0] && A[i]==m_classesVector[class="num">1])
             fp++;
           if (P[i]==m_classesVector[class="num">1] && A[i]==m_classesVector[class="num">0])
             fn++;
         }
           
       mat_[class="num">0][class="num">0] = tn; mat_[class="num">0][class="num">1] = fp;
       mat_[class="num">1][class="num">0] = fn; mat_[class="num">1][class="num">1] = tp;
     }
   
   class="kw">return(mat_);   
 }
  matrix cf_m = ConfusionMatrix(TargetPred,TestPred);
  vector diag = cf_m.Diag();
  class="type">class="kw">float acc = (class="type">class="kw">float)(diag.Sum()/cf_m.Sum())*class="num">100;
  
  Print("Confusion Matrix\n",cf_m,"\nAccuracy ------> ",acc,"%");
  
  class="kw">return(acc);

「给KNN喂日线标签的实操坑」

监督学习绕不开人工打标:模型得先知道目标是什么,才能学出自变量和目标变量的映射。这里自变量取 ATR 读数和成交量指标,目标变量按日线蜡烛收盘大于开盘标 1(看涨),否则标 0(看跌),后续回测里 1、0 直接转成买卖信号。 日线一根柱内含 24 小时价格波动,若拿去做剥头皮或短线,这套标签逻辑可能偏粗。开盘等于收盘的情况在日线里较少出现,所以高时间帧下把等于开盘视作 0 类,算是给模型留了点容错。外汇和贵金属本身高杠杆高风险,这套标法不构成任何交易建议。 未调优 k 值时,模型在样本上准确度约 43.33%;循环试不同 k 后,峰值性能落在 k 接近 40 附近,虽不是交叉验证的最优解,但能省一步。下面是在 EA 初始化里组装数据集的 MT5 代码,可直接拷去 OnInit 验证: int OnInit() { //--- Preparing the dataset atr_handle = iATR(Symbol(),timeframe,period); volume_handle = iVolumes(Symbol(),timeframe,applied_vol); CopyBuffer(atr_handle,0,1,bars,atr_buffer); CopyBuffer(volume_handle,0,1,bars,volume_buffer); Matrix.Col(atr_buffer,0); //Independent var 1 Matrix.Col(volume_buffer,1); //Independent var 2 //--- Target variables vector Target_vector(bars); MqlRates rates[]; ArraySetAsSeries(rates,true); CopyRates(Symbol(),PERIOD_D1,1,bars,rates); for (ulong i=0; i<Target_vector.Size(); i++) //putting the labels { if (rates[i].close > rates[i].open) Target_vector[i] = 1; //bullish else Target_vector[i] = 0; } Matrix.Col(Target_vector,2); } 逐行拆解:iATR / iVolumes 拿到指标句柄;CopyBuffer 从 1 号偏移取 bars 根数据进缓冲区;Matrix.Col 把 ATR、成交量分别塞为第 0、1 列自变量;CopyRates 取日线序列后,按 close>open 写 1/0 进第 2 列目标向量。日志里打印的最后几行如 [0.01097857142857144,13762,1],前三列就是 ATR、成交量、标签。 EURUSD 在 2022.06.01–2022.11.03 的每次跳价回测里,EA 已能依此开平仓。k 取 40 附近时信号倾向更稳,但实盘前你最好在策略测试器换品种重跑一遍矩阵。

MQL5 / C++
class="type">int OnInit()
  {
class=class="str">"cmt">//--- Preparing the dataset 
   atr_handle = iATR(Symbol(),timeframe,period);
   volume_handle = iVolumes(Symbol(),timeframe,applied_vol);
   
   CopyBuffer(atr_handle,class="num">0,class="num">1,bars,atr_buffer);
   CopyBuffer(volume_handle,class="num">0,class="num">1,bars,volume_buffer);
   
   Matrix.Col(atr_buffer,class="num">0); class=class="str">"cmt">//Independent var class="num">1
   Matrix.Col(volume_buffer,class="num">1); class=class="str">"cmt">//Independent var class="num">2
   
class=class="str">"cmt">//--- Target variables
   vector Target_vector(bars);
   
   class="type">MqlRates rates[];
   ArraySetAsSeries(rates,true);
   CopyRates(Symbol(),PERIOD_D1,class="num">1,bars,rates);
   
   for (class="type">class="kw">ulong i=class="num">0; i<Target_vector.Size(); i++) class=class="str">"cmt">//putting the labels
    {
      if (rates[i].close > rates[i].open)
          Target_vector[i] = class="num">1; class=class="str">"cmt">//bullish
      else
          Target_vector[i] = class="num">0;
    }
   Matrix.Col(Target_vector,class="num">2);
  }
交给小布盯盘看特征分布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到样本聚类与邻近度热力,把重复劳动交给小布,你专注决策。

常见问题

会。价格数值往往比波动率百分比大几个数量级,实际运用前需要对特征做标准化或归一化,否则距离计算会被绝对数值绑架。
奇数只保证二分类不僵局;当类别数大于等于三,k 个邻居的投票仍可能均分,这时要靠加权距离或打破平局规则处理。
它能拆出假突破被误判为趋势的比例,帮助识别模型在震荡市和趋势市的召回率差异,进而调整样本权重。
可以,小布盯盘的 AIGC 模块内置了邻近度诊断,能基于历史片段对当前行情做状态归类,省去你自己写距离函数的过程。
可以混,但必须对齐时间戳并标注周期来源,否则邻居计算会跨周期错位,令分类意义失真。