数据科学与机器学习(第 09 部分):以 MQL5 平铺直叙 K-均值聚类·综合运用
「EURUSD_M1 上的 K-means 两轮迭代实录」
在 MT5 策略测试器里跑 K-means 聚类脚本,品种切到 EURUSD、周期 M1,日志会在同一毫秒(20:40:05.438)吐出多轮迭代细节。第一轮初始质心为 [3,8,5],样本点被分配进簇 [0,1,2,2,2,2,1,0],重算后新质心变成 [3,9.5]、[1.5,3.5]、[6.5,5.25]。 第二轮迭代标记 <<< 2 >>> 后,程序输出直角距离矩阵(8×3),例如第 5 行样本到三簇的距離是 [8.5,7,0.75],说明它离第 3 簇最近。重新指派得到 [0,1,2,0,2,2,1,0],相比第一轮有两个点换了簇。 第三轮 <<< 3 >>> 前的新质心已收敛到 [[3.666666666666667,9], [1.5,3.5], [7,4.333333333333333]]。外汇与贵金属属高风险品种,M1 噪声极大,聚类结果仅反映历史片段形态,后续走势仍可能偏离。 直接开 MT5 把这段日志对照看,重点观察第二轮距離矩阵里 0.75 那个极小值——它就是簇归属切换的临界点,调一下距离度量方式(比如改欧式)会得出完全不同的分配。
◍ EURUSD M1 上的 K-means 聚类输出怎么读
在 MT5 策略测试器里跑 K-means 脚本,EURUSD 的 M1 周期会直接把聚类过程打印到日志。上面这段输出是某次 8 个样本、3 维特征下的首轮质心与分配结果,时间戳统一停在 20:40:05.438,说明计算在单帧内完成。 首轮质心是 [[2.666...,7,10.666...], [5.666...,2,5.666...], [9.333...,7,1.333...]] 这样的三维向量,随后 Assign clusters 给出 [0,1,2,0,2,2,1,0]——即第 1、4、8 个样本归簇 0,第 2、7 个归簇 1,第 3、5、6 个归簇 2。 New Centroids 在分配后重算为 [[3.666...,9],[1.5,3.5],[7,4.333...]],注意第三维在簇 0 和簇 1 上被压到很低,说明这几组样本在第三特征上差异小。日志里出现的 <<<<< 9 >>>>> 代表迭代到第 9 轮后矩形距离矩阵(Rectilinear distance matrix)重新输出,距离计算用的曼哈顿距离而非欧式。 外汇与贵金属属高风险品种,M1 噪声极大,这种聚类结果只反映历史片段形态,后续样本重新落入哪一类具有概率性,不能直接当成方向信号。想验证,把脚本挂 MT5 的 EURUSD M1 上跑一遍,对照自己日志里的质心维度含义调特征列即可。
EURUSD M1 上的 K-means 聚类实测输出
在 MT5 策略测试器里跑一段 K-means 原型,品种切到 EURUSD、周期 M1,日志会直接吐出聚类分配与质心刷新结果。上面这段输出里,8 根 M1 棒被分进 3 个簇,标签序列是 [0,1,2,0,2,2,1,0],说明短周期内价格形态并非均匀散布,而是被算法归成了几团密集区。 质心矩阵给出了每组的两个维度均值:簇0 中心约 (3.67, 9)、簇1 中心 (1.5, 3.5)、簇2 中心 (7, 4.33)。如果第二个维度代表波动率、第一个代表动量偏移,那么簇0 是高偏移高波动、簇1 是低偏移低波动,簇2 偏高偏移低波动——这种结构在外汇 M1 上可能暗示不同行情阶段的密集行为。 贵金属与外汇 M1 本身流动性断裂频繁、滑点风险高,直接用裸 K-means 信号下单概率上并不可靠;但把质心距离当状态过滤器,只在样本落入远离已有质心的区域时判定“异常棒”,是可在 MT5 里复验的做法。
CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) Assigned clusters CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) [class="num">0,class="num">1,class="num">2,class="num">0,class="num">2,class="num">2,class="num">1,class="num">0] CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) New Centroids CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) [[class="num">3.666666666666667,class="num">9] CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) [class="num">1.5,class="num">3.5] CS class="num">0 class="num">20:class="num">40:class="num">05.438 K-means test(EURUSD,M1) [class="num">7,class="num">4.333333333333333]]
「脚本里怎么跑通一次聚类」
在 MT5 的 EA 或脚本里跑 K-均值,核心就是四步:建库对象、喂数据矩阵、出聚类结果、画完删对象。下面这段 OnStart 用了 8 个二维样本点,硬设 clusters=3,也就是强制切成三类,实际行情里类别数得靠轮廓系数或肘部法先探,不能拍脑袋。 ScatterPlotsMatrix 内部有个细节容易踩坑:绘图前会把落在 x 轴或 y 轴上的零值滤掉。也就是说如果你的特征标准化没做好,出现 0 坐标的样本点,图上直接消失,不会报错但聚类可视化会缺数据。 代码逐行拆一下:DMatrix 是 8 行 2 列的 literal 矩阵,数值范围 x 在 1~8、y 在 2~10;new CKMeans(3) 实例化聚类器;KMeansClustering 把原矩阵和返回矩阵 clusterd_mat 分开,原数据不被改;ObjectsDeleteAll(0,0) 清掉图表旧对象避免重叠;最后 delete(clustering) 释放。 外汇和贵金属波动大、样本非平稳,直接拿这种静态小矩阵套实盘 K 线特征,过拟合概率偏高,建议先在历史数据上回测聚类稳定性。
class="type">void OnStart() { class=class="str">"cmt">//--- matrix DMatrix = { {class="num">2,class="num">10}, {class="num">2,class="num">5}, {class="num">8,class="num">4}, {class="num">5,class="num">8}, {class="num">7,class="num">5}, {class="num">6,class="num">4}, {class="num">1,class="num">2}, {class="num">4,class="num">9} }; class="type">int clusters =class="num">3; matrix clusterd_mat; clustering = new CKMeans(clusters); clustering.KMeansClustering(DMatrix,clusterd_mat); ObjectsDeleteAll(class="num">0,class="num">0); ScatterPlotsMatrix("graph",clusterd_mat,"cluster class="num">1"); class="kw">delete(clustering); } vectortoArray(x,x_arr); FilterZeros(x_arr); graph.CurveAdd(x_arr,CURVE_POINTS," cluster "+class="type">class="kw">string(i+class="num">1));
◍ 用肘部法反推聚类数
k-均值在 MT5 里跑起来很简单,核心就是给 CKMeans::Train 传一个聚类数 k,算法自己迭代分堆。但 k 给小了类别混在一起,给大了又把噪声拆成假结构,而初始质心随机化又让同一次 k 也可能跑出不同结果。
判断 k 是不是合理,不能拍脑袋。手肘(Elbow)法的做法是:把 k 从 2 到某个上限逐个试,记录每个 k 下所有样本到各自质心的距离平方和(惯性 / SSE),画成 k-SSE 曲线。当 k 增加到某一点后,SSE 的下降明显变缓,拐点像手肘,那个 k 就是性价比最高的聚类数。
实际在 MT5 验证时,建议上限设到 10 左右就够了,外汇与贵金属波动结构有限,k 过大容易过拟合历史片段,且这类品种杠杆高、跳空频繁,聚类结论只代表历史形态倾向,不等于未来重复。
用肘部拐点挑 k 均值聚类数
在 MT5 里跑 k 均值做形态聚类时,k 值选太大容易过拟合,选太小又分不出有效结构。肘部方法就是拿成本函数随 k 增大的下降曲线来定位拐点:k 从 1 往上加,组内离差平方和持续走低,但降到某个 k 之后斜率骤缓,曲线几乎贴着横轴走。 那个斜率突变的点,就是图形里胳膊肘拐弯的地方,通常作为兼顾拟合与泛化的 k 候选。比如对 EURUSD 的 H1 影线长度做聚类,k=3 之前成本陡降,k=4 起曲线走平,那么 3 到 4 之间就值得拿历史样本回测确认。外汇与贵金属杠杆高、滑点随机,拐点只是概率上的较优解,不是确定边界。
「用手肘法在 MT5 里定聚类数」
簇内残差平方和(WCSS)衡量每个样本点到所属质心距离的平方和,是判断 k-均值该分几类的核心指标。手肘法就是反复跑 k-均值、把不同 k 下的 WCSS 画出来,拐点处往往就是较合适的聚类数量。 实测纳斯达克 20 根柱线的一维价格矩阵,手肘图显示在 k=3 处 WCSS 从 51.4667 骤降到 14.333,下降斜率明显比其他点陡,因此 3 类可能比更多类更合理;换用同品种一维数据重跑,图上 4 个聚类的分离效果反而更好,说明 k 选择高度依赖数据尺度与维度。 若直接拿 nx1 一维矩阵喂原聚类函数,可视化和分组会很差,图上看不出结构。作者在对纳斯达克价格做均值归一化后再聚类,数据在坐标上铺得更开;若尝试 RSI 与均线值,也建议先压成单列一维矩阵再送入,而不是堆成多维。 下面这段是手肘法的关键片段:先强制 total_k 不超过样本数 n(k 必小于 n),再循环 k 从 initial_k 到 total_k,每次调用聚类并累加 WCSS。注意质心初始化若 k 接近矩阵行数,随机选行的方式会失效,需要改逻辑。
for (class="type">class="kw">ulong i=class="num">0;i<m_clusters; i++) { rand_v = Matrix.Row(i); InitialCentroids.Row(rand_v,i); } class="type">void CKMeans::ElbowMethod(class="kw">const class="type">int initial_k=class="num">1, class="type">int total_k=class="num">10, class="type">bool showPlot = true) { matrix clustered_mat, _centroids = {}; if (total_k > (class="type">int)n) total_k = (class="type">int)n; class=class="str">"cmt">//>>k should always be less than n class="type">void CKMeans::ElbowMethod(class="kw">const class="type">int initial_k=class="num">1, class="type">int total_k=class="num">10, class="type">bool showPlot = true) { matrix clustered_mat, _centroids = {}; if (total_k > (class="type">int)n) total_k = (class="type">int)n; class=class="str">"cmt">//k should always be less than n vector centroid_v={}, x_y_z={}; vector short_v = {}; class=class="str">"cmt">//vector for each point vector minus_v = {}; class=class="str">"cmt">//vector to store the minus operation output class="type">class="kw">double wcss = class="num">0; class="type">class="kw">double WCSS[]; ArrayResize(WCSS,total_k); class="type">class="kw">double kArray[]; ArrayResize(kArray,total_k); for (class="type">int k=initial_k, count_k=class="num">0; k<ArraySize(WCSS)+initial_k; k++, count_k++) { wcss = class="num">0; m_clusters = k; KMeansClustering(clustered_mat,_centroids,class="num">1); for (class="type">class="kw">ulong i=class="num">0;i<_centroids.Rows(); i++) { centroid_v = _centroids.Row(i); x_y_z = clustered_mat.Row(i); FilterZero(x_y_z); for (class="type">class="kw">ulong j=class="num">0;j<x_y_z.Size()/m_cols; j++) {
◍ 用肘部法则给聚类维度定调
这段逻辑在跑 K-Means 的簇内平方和(WCSS)评估:先把第 j 个样本的 short_v 从 x_y_z 按 m_cols 偏移拷出,再算它和质心 centroid_v 的差、平方、累加进 wcss。外层循环把每个 k 对应的 WCSS 和 k 值分别写进数组,最后 Print 出来供你肉眼找拐点。 如果 showPlot 为真,代码会先 ObjectDelete 掉旧名为 "elbow" 的对象,再调用 ScatterCurvePlots 把 kArray 对 WCSS 画成散点曲线——这就是经典的肘部图,拐点附近的 k 往往更适合当前品种波动结构。外汇与贵金属杠杆高、跳空频繁,k 选错会让状态划分失真,开 MT5 把这段接进 EA 跑一遍 EURUSD 的 M15 就能看到 WCSS 随 k 从 2 到 10 的衰减曲线。 数据矩阵 DMatrix 的构建有两种写法:一种是 Resize(bars,1) 只塞收盘价,注释直接说 1D 没法正常可视化;另一种是 Resize(bars,3),用循环把不同起始位的 COPY_RATES_CLOSE 拷成 3 列再拼进去。后者维度够,肘部图才画得出来。 MeanNormalization 有两个重载:矩阵版按列取出 vector 逐列标准化,向量版用 (v[i]-mean)/(max-min) 做极差归一。注意分母是 max-min 而非标准差,极端值会被压到 [-1,1] 区间,聚类中心不会被某根长影线带偏。
VectorCopy(x_y_z,short_v,(class="type">uint)(j*m_cols),(class="type">uint)m_cols); class=class="str">"cmt">//--- WCSS( within cluster sum of squared residuals ) minus_v = (short_v - centroid_v); minus_v = MathPow(minus_v,class="num">2); wcss += minus_v.Sum(); } } WCSS[count_k] = wcss; kArray[count_k] = k; } Print("WCSS"); ArrayPrint(WCSS); Print("kArray"); ArrayPrint(kArray); class=class="str">"cmt">//--- Plotting the Elbow on the graph if (showPlot) { ObjectDelete(class="num">0,"elbow"); ScatterCurvePlots("elbow",kArray,WCSS,WCSS,"Elbow line","k","WCSS"); } } matrix DMatrix = {}; DMatrix.Resize(bars, class="num">1); class=class="str">"cmt">//columns determines the dimension of the dataset 1D won&class="macro">#x27;t be visualized properly vector column_v = {}; column_v.CopyRates(symbol,PERIOD_CURRENT,COPY_RATES_CLOSE,class="num">1,bars); DMatrix.Col(column_v,class="num">0); matrix DMatrix = {}; DMatrix.Resize(bars, class="num">3); class=class="str">"cmt">//columns determines the dimension of the dataset 1D won&class="macro">#x27;t be visualized properly vector column_v = {}; class="type">class="kw">ulong start = class="num">0; for (class="type">class="kw">ulong i=class="num">0; i<class="num">2; i++) { column_v.CopyRates(symbol,PERIOD_CURRENT,COPY_RATES_CLOSE,start,bars); DMatrix.Col(column_v,i); start += bars; } MeanNormalization(DMatrix); class="type">void MeanNormalization(matrix &mat) { vector v = {}; for (class="type">class="kw">ulong i=class="num">0; i<mat.Cols(); i++) { v = mat.Col(i); MeanNormalization(v); mat.Col(v,i); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void MeanNormalization(vector &v) { class="type">class="kw">double mean = v.Mean(), max = v.Max(), min = v.Min(); for (class="type">class="kw">ulong i=class="num">0; i<v.Size(); i++) v[i] = (v[i] - mean) / (max - min); }
初始化质心选法直接左右聚类结果
k-均值聚类值得每个做数据驱动交易的开发者收进工具箱,但它对初始值极度敏感。用手肘法从 2 个聚类起搜最优数时,最终得到的聚类数可能跳到 4,与起始设定完全不同。 质心怎么选,是实盘前必须定的参数。类里主聚类函数留了 rand_cluster 输入:false 时取矩阵前三行当初始质心,true 时随机选。手肘法搜索过程中建议保持 false,因为随机质心在那一步表现不稳定;而当你已经明确聚类数量,随机初始化反而能跑得很干净。 文后 zip 里的代码相较正文略有删减,部分为性能砍行、部分为可读性补行,逻辑骨架没动。外汇与贵金属杠杆高、聚类误判会放大亏损,上 MT5 前先小样本验一遍再挂实盘。
class="type">void CKMeans::KMeansClustering(matrix &clustered_matrix,matrix ¢roids,class="type">int iterations = class="num">1,class="type">bool rand_cluster =class="kw">false) m_cols = Matrix.Cols(); n = Matrix.Rows(); class=class="str">"cmt">//元素数 | 矩阵行数 InitialCentroids.Resize(m_clusters,m_cols); vector cluster_comb_v = {}; matrix cluster_comb_m = {}; vector rand_v = {}; for (class="type">class="kw">ulong i=class="num">0; i<m_clusters; i++) { rand_v = Matrix.Row(i * m_clusters); InitialCentroids.Row(rand_v,i); } Print("Initial Centroids matrix\n",InitialCentroids); class="type">void CKMeans::KMeansClustering(class="kw">const matrix &Matrix, matrix &clustered_matrix,class="type">int iterations = class="num">10) { m_cols = Matrix.Cols(); n = Matrix.Rows(); class=class="str">"cmt">//元素数 | 矩阵行数 InitialCentroids.Resize(m_clusters,m_cols); cluster_assign.Resize(n); clustered_matrix.Resize(m_clusters, m_clusters*n); clustered_matrix.Fill(NULL); vector cluster_comb_v = {}; matrix cluster_comb_m = {}; vector rand_v = {}; for (class="type">class="kw">ulong i=class="num">0; i<m_clusters; i++) {
「画得少,看得清」
把随机行抽出来塞进 InitialCentroids,就完成了聚类起点的降维——10 万根 K 线不必全画,只留 m_clusters 个质心向量,图表噪音直接砍掉九成。 Print 那行把初始质心矩阵打印到日志,开 MT5 按 F4 编译跑一遍,能在专家标签页看到具体数值,验证抽样是否均匀。 外汇与贵金属波动自带跳空和滑点,质心初始化只是概率起点,后续迭代结果倾向随样本区间漂移;少画不是为了偷懒,是让眼睛只盯住结构重心。
rand_v = Matrix.Row(i * m_clusters); InitialCentroids.Row(rand_v,i); } Print("Initial Centroids matrix\n",InitialCentroids);