数据科学与机器学习(第 09 部分):K-最近邻算法(KNN)·进阶篇
(2/3)· 不是所有邻近点都该信:k 值奇数陷阱与混淆矩阵如何暴露模型盲区
◍ 从日志看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 数组是否真只剩距离最小的几项。外汇与贵金属行情受杠杆与跳空影响,这类距离筛选只反映历史向量相似度,实盘信号概率性成立,高风险。
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 数组和最终归类,比看文档快得多。
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],说明特征列加标签列已经正确分离,外汇与贵金属行情建模时这类切分偏差会直接放大样本外风险,需手动复核边界行。
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 值与距离权重变化。
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 更刺眼,才知哪边更易漏判。
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 附近时信号倾向更稳,但实盘前你最好在策略测试器换品种重跑一遍矩阵。
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); }