刚做完一个挺有意思的练手项目不调现成的机器学习库自己手写K-means聚类算法拿去对一份真实的天气观测数据做分类。整个过程比我想象中更有价值踩了不少坑也把聚类的很多细节真正吃透了。这篇文章就完整记录一下这个项目从头到尾是怎么做的包括算法实现、数据哪来的、为什么选这些特征、K值怎么定、结果怎么解释还有我个人实测下来的教训。想自己动手写聚类算法、或者想学无监督学习的朋友这份记录应该能帮你省不少时间。1. 项目背景与核心思路拆解1.1 为什么用K-means处理天气数据天气数据本质上是一个非常适合做聚类分析的对象。每天的天气状况可以从温度、湿度、气压、风速、降水量这些维度来描述而不同天气类型晴朗、阴雨、大风、寒潮在数值特征上天然有区分度。聚类算法正好能在不告诉算法哪条数据是晴天的情况下仅凭特征之间的相似性把数据自动分组——这就是无监督学习的典型应用场景。我选K-means而不是其他聚类算法主要基于三点考虑数据量不大几千条K-means的计算开销完全可控天气特征大多是连续数值和欧氏距离的假设契合K-means的结果是硬分配每个样本明确归一类便于后续给每个簇做画像和解释当然K-means也有明显的短板比如对初始中心点敏感、需要预先指定K值、对球形簇更友好。但这些恰恰是值得亲手去实现的理由——调库的话这些细节会完全被封装掉手写一遍才能真切感受到这些局限是怎么在代码里体现的。1.2 特征选择与问题定义原始天气数据里不是所有字段都适合直接丢进算法。比如日期、城市编号这类索引型字段参与距离计算只会引入噪声。我最终保留了以下数值特征特征名单位物理含义平均温度℃当日气温均值温度极差℃当日最高温与最低温的差值相对湿度%空气相对湿度均值海平面气压hPa修正到海平面后的大气压力平均风速m/s当日风速均值降水量mm当日累计降水这个组合覆盖了冷暖、湿度、晴雨、风、气压几个相互独立的天气维度足够区分典型的天气状态。我在确定特征后专门想过一个问题有没有必要加入季节这个字段后来放弃的原因是——聚类算法的目标就是让数据自己发现季节性模式如果人为加入春、夏、秋、冬标签那就变成半监督了丧失了聚类的意义。项目目标也很明确用自写的K-means算法把这几千条逐日天气记录划分成若干簇使得簇内样本的天气特征高度相似簇间差异显著然后尝试解读每个簇对应的天气类型。这既是对算法的验证也是对数据的一次探索性分析。2. 自定义K-means算法的实现细节2.1 算法流程与数据结构设计我决定不用scikit-learn自带的KMeans类从零手写一个核心目的是彻底理解聚类的每一步到底发生了什么。算法流程本身不复杂就四步随机选K个样本作为初始中心点计算每个样本到K个中心点的距离把它归到最近的中心重新计算每个簇的质心簇内所有样本在每个维度上的均值重复2-3步直到中心点变化量小于阈值或达到最大迭代次数但代码实现上有一个非常关键的细节数据结构怎么组织。我用的是纯Python的列表和字典简单直观不引入额外的科学计算栈之外的依赖。每个样本用一个浮点数列表表示整个数据集就是一个二维列表。中心点也用同样结构存储这样求距离的函数就可以复用。这里要给刚开始写算法的人提个醒尽量不要一开始就在数据结构里引入类封装先把裸代码跑通再考虑优化。我第一版就是过度设计把样本定义成WeatherRecord类还写了各种method后来发现增量得太早调试起来异常痛苦。全部换成list之后代码量直接砍掉一半。2.2 距离度量与初始化方式的选择标准K-means用的是欧氏距离也就是L2范数。对连续型特征来说这是最自然的选择。公式很简单对两个维度相同的向量逐维度求差值的平方和再开根号。由于之后我会对特征做标准化这节后面详细说所以欧氏距离的尺度问题能够被有效规避。初始化方式我实现了两种纯随机初始化和K-means初始化。纯随机就是随机从数据里挑K个样本当初始中心。问题在于如果随机点的位置不好可能直接导致收敛到局部最优解。K-means的思路是让初始中心点尽可能分散——先把第一个中心点随机选出来然后对每个样本计算它到已有最近中心点的距离距离越大的样本有更高概率被选为下一个中心点。实测下来K-means配合多轮随机重启能显著降低坏聚类出现的概率。我建议实现时把初始化阶段做成参数可选这样就能在同一份数据上用不同的初始化方式跑多次直接比较结果的差异——这是一种很直观的感受算法缺陷的方式。2.3 手写算法核心代码贴一下核心代码不算长但每一步都对应了上面说的算法逻辑。我还给关键步骤加了注释方便看懂运行轨迹。import math import random from collections import defaultdict def euclidean_distance(a, b): 计算两个等长向量之间的欧氏距离 if len(a) ! len(b): raise ValueError(向量维度不一致) return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) def kmeans_plus_plus_init(data, k, seed42): K-means 初始化策略尽量让初始中心点分散开 rng random.Random(seed) # 先随机选第一个中心点 centers [data[rng.randrange(len(data))]] while len(centers) k: # 对每个样本计算它到现有最近中心点的距离 distances [] for point in data: min_dist min(euclidean_distance(point, c) for c in centers) distances.append(min_dist * min_dist) total sum(distances) # 按距离平方的权重随机选下一个中心点 target rng.uniform(0, total) running 0 for idx, d in enumerate(distances): running d if running target: centers.append(data[idx]) break return centers def kmeans(data, k, max_iter200, tol1e-4, init_methodkmeans, seed42): 自定义K-means主函数 if init_method kmeans: centers kmeans_plus_plus_init(data, k, seed) else: rng random.Random(seed) centers [data[i] for i in rng.sample(range(len(data)), k)] for iteration in range(max_iter): # 第一步分配到最近中心点 clusters defaultdict(list) for point in data: distances [euclidean_distance(point, c) for c in centers] cluster_id distances.index(min(distances)) clusters[cluster_id].append(point) # 第二步重新计算质心 new_centers [] for cluster_id in range(k): if clusters[cluster_id]: # 避免空簇 cluster_points clusters[cluster_id] dimension len(cluster_points[0]) new_center [ sum(p[d] for p in cluster_points) / len(cluster_points) for d in range(dimension) ] new_centers.append(new_center) else: # 空簇处理保留原有中心等待下一轮 new_centers.append(centers[cluster_id]) # 检查收敛中心点位移程度是否低于阈值 total_movement sum( euclidean_distance(new_centers[i], centers[i]) for i in range(k) ) centers new_centers if total_movement tol: print(f第{iteration 1}轮收敛中心点总位移 {total_movement:.6f}) break else: print(f达到最大迭代次数 {max_iter}停止迭代) # 最终分配 final_clusters defaultdict(list) for idx, point in enumerate(data): distances [euclidean_distance(point, c) for c in centers] cluster_id distances.index(min(distances)) final_clusters[cluster_id].append(idx) return centers, final_clusters这里有个值得注意的地方收敛判断不是看样本不再改变所属簇而是看中心点位移量够不够小。两者其实等价但中心点位移是连续值更容易设阈值。tol1e-4表示所有中心点移动距离的总和小于0.0001时认为已经稳定这样做的好处是可以在真正的完美收敛前提前终止节省计算时间。我在实现中发现迭代中的空簇处理非常关键。如果某个簇在分配阶段没有分到任何样本new_centers的长度就会比k少后续循环直接报索引错误。很多新手会在这里卡住我选择保守处理遇到空簇就保留上一轮的中心点下一轮再参与竞争。3. 数据准备与预处理3.1 天气数据集的获取与清洗数据来自开源气象站点的历史观测记录我用的是一年期的逐日数据包含温度、湿度、气压、风速、降水量等字段共365条记录。这份数据没有直接用原始文件而是做了一些预处理。第一步是缺失值处理。观测站点偶尔会有设备故障具体表现为某个字段为空或者填了9999这类占位符。我逐行检查后发现大概有17条记录存在不同程度缺失。由于缺失记录占比不高直接删掉是最稳妥的方案不会对整体数据分布造成明显影响。如果缺失比例超过20%就需要考虑均值填充或者插值了但那会引入人为偏差。第二步是异常值过滤。有一次风速直接写了89.3m/s明显不符合常理这已经是超强台风级别了对照周边日期也找不到合理解释判定为录入错误直接剔掉。类似的问题我还碰到过温度极差为负的情况属于明显的逻辑错误——最高温不可能低于最低温这类数据也必须清理。清洗后的数据集最终保留了341条完整有效的逐日记录。在数据量不大、质量可控的前提下删除优于填充。3.2 特征标准化——这步千万不能省这是整个项目里我体会最深的一个坑。K-means基于距离计算而不同特征的数值量纲差异极大温度在-10到38之间波动气压在985到1040之间波动降水量则多数是0、偶尔几十毫米。如果不做标准化距离会被气压这种量纲大的特征主导温度、降水几乎没有话语权聚类结果就变成根据气压高中低分三组了完全失去天气分类的意义。我采用Z-score标准化也叫标准差标准化公式是标准化值 (原始值 - 均值) / 标准差转换之后每个特征的均值为0标准差为1数值没有单位差异可以公平地参与距离计算。代码实现上也不复杂先算出每个特征的均值和标准差再逐条变换。需要注意归一化必须是先拟合后变换也就是说均值和标准差是聚类前在整个数据集上算出来的统计算量不是逐条单独算的。def zscore_standardize(data): 对二维列表数据做Z-score标准化 if not data: return data n_features len(data[0]) means [] stds [] for d in range(n_features): col_values [row[d] for row in data] mean sum(col_values) / len(col_values) variance sum((v - mean) ** 2 for v in col_values) / len(col_values) std math.sqrt(variance) means.append(mean) stds.append(std) scaled_data [] for row in data: scaled_row [ (row[d] - means[d]) / stds[d] if stds[d] ! 0 else 0.0 for d in range(n_features) ] scaled_data.append(scaled_row) return scaled_data, means, stds为什么要在这里单独强调因为我见过很多人在做聚类分析时拿原始数据直接扔进算法然后得出一个自己都解释不了的聚类结果。最终归因到K-means效果不好。其实很多时候不是算法的问题是特征工程的问题。3.3 特征相关性检查在确定六个特征之前我还做了一步相关性检查目的很明确避免冗余维度影响聚类。比如平均温度和温度极差本身有一定的正向相关性但这种相关性不是绝对的——春季温差大但平均温度不一定高海边城市即使在夏天温差也小。所以两个特征可以提供互补信息不构成冗余。真正有隐患的是温度极差和平均气温在极端天气下的耦合效应比如寒潮天通常白天最高温低夜间最低温更低极差反而大。这种耦合关系如果太强等效于在特征空间里重复强调同一个维度会让聚类结果过度偏重这个因素。我算了一下各特征间的皮尔逊相关系数最大的相关性出现在平均温度和低温值之间约0.86对聚类的影响在可接受范围内。特征选择的小建议宁可少几个特征也不要让特征之间高度共线。对天气数据来说温度湿度气压风速降水的组合已经能覆盖绝大多数天气分型需求。特征过多不仅计算量大还可能引入噪声维度。4. 聚类过程的调优与K值选择4.1 肘部法则的具体操作K-means需要预先指定K值这就面临第一个问题K取多少才合理理论上没有一个绝对正确的答案聚类的目标是在簇内紧密、簇间远离之间找平衡。我用的是最经典的肘部法则Elbow Method。思路很简单对不同的K值分别跑一遍聚类计算每个K值下的簇内误差平方和WCSS即每个样本到它所属簇质心的距离平方的总和。K增大时WCSS必然降低因为簇多了每个样本离中心点更近但降低的速度会从某个点开始明显放缓——转折点就像手臂的肘部对应的K就是推荐值。我跑K从2到10每次用K-means初始化并固定随机种子保证可复现。结果如下K值WCSS2382132864423125204561893718028174191696101665可以看到K从3到4降幅比较明显从4到5降幅趋缓5之后基本是缓慢下降。结合轮廓系数下面详说最终K4是比较稳的选择。WCSS这段代码我之前在K-means实现里没有展示实际上需要单独写一个计算函数用训练好的中心点和簇分配结果把所有样本的欧氏距离平方累加。这里复用euclidean_distance效率虽然不算高但足够用。4.2 轮廓系数评估聚类质量肘部法则给出的K值只是一个参考方向还需要另一个独立指标交叉验证。轮廓系数Silhouette Coefficient就是干这个的。对每个样本i定义两个量a(i)样本i到同簇其他样本的平均距离衡量簇内凝聚度b(i)样本i到最近其他簇所有样本的平均距离衡量簇间分离度样本i的轮廓系数 (b(i) - a(i)) / max(a(i), b(i))范围在-1到1之间。接近1表示样本距离邻簇很远、离自己簇很近聚类效果好接近-1表示样本被分错了簇。我对K2到10分别计算了平均轮廓系数K4时得分约0.52K5时约0.47K6时约0.39。K4在轮廓系数和肘部法则两方面都占优最终就定了4类。轮廓系数的计算有一定代价要算所有样本两两之间的距离复杂度是O(n²)。对于项目里的341条样本时间完全可以接受。如果是大数据集就需要抽样计算了。4.3 收敛判断与中心点初始化陷阱这个部分值得单独说一下因为我在调试时花了整整一个晚上在排查一个诡异现象每次跑K-means结果都不一样有时候聚类明显很差却还收敛了。原因就是随机初始化。K-means的迭代过程是贪心的它只会往当前位置的局部最优收敛不保证全局最优。如果初始中心点选得不巧比如两个初始点落在同一个密集区域内另一个密集区域完全没有初始覆盖就可能收敛到极差的局部解。处理办法有三个层次用K-means初始化我已经实现了大幅降低坏初始化的概率多跑几次比如20次每次用不同的随机种子取WCSS最小的那一次作为最终结果对结果的稳定性做验证用两个不同种子跑出来的结果如果簇分配差异很大说明聚类不稳定数据本身可能没有明显的簇结构我最终的做法是K-means跑10次从结果里挑WCSS最低的一次。这样既能保持结果稳定又不至于浪费太多计算时间。还有一个细节迭代次数上限。如果数据集分布特别平坦也就是没有明显的簇隔断中心点会在一个很大区域内来回飘动很难收敛。设置max_iter200可以强制终止防止死循环。我实测正常天气数据在20轮以内就能收敛所以200次的上限是很宽松的。5. 聚类结果的解释与呈现5.1 结果可视化二维降维展示聚类结果要在高维空间里看其实是做不到的六个特征的分布没法直接画图。常规做法是降维后可视化我用的是主成分分析PCA把六维特征压缩到二维平面画散点图。PCA在这里只是可视化工具它不会参与聚类本身的计算所以不会影响聚类结果。实现上不需要自己写矩阵运算用常用的数据处理库里的PCA类就行。具体做法是把标准化后的数据喂给PCA取前两个主成分然后把每个样本的聚类标签映射到散点颜色上。画出来的图有比较明显的四个颜色块一类在左下方集中一类在右上方集中另两类在中间交错。虽然二维投影会有一定信息损失但簇的分离程度肉眼可见。如果你在图上看到四个颜色混在一起分不开基本可以回头质疑聚类参数了。5.2 四个簇的典型天气画像聚类完成后我把每个簇的原始数据反标准化后拉出来统计各维度的均值做成了一张画像表簇标签平均温度温度极差相对湿度气压风速降水量簇内样本数簇024.2℃8.1℃78%1008 hPa2.1 m/s8.5 mm96簇111.5℃10.6℃55%1021 hPa3.2 m/s0.2 mm87簇218.9℃13.2℃62%1014 hPa2.8 m/s1.1 mm102簇34.6℃9.4℃48%1026 hPa4.5 m/s0.0 mm56对照气象常识可以给每个簇一个通俗的天气命名簇0湿热的雷雨性天气平均湿度高、降水最多、温度偏高且温差小簇1秋高气爽型天气湿度低、气压高、温差适中、几乎无降水簇2温和的过渡性天气温差偏大、风和日丽介于冷暖之间簇3干冷的寒潮天气温度最低、风速最大、气压极高、湿度极低看到这个结果还挺惊喜的4个簇对应的天气类型非常符合生活感知说明特征选择和算法实现是有效的。你不需要预先告诉算法什么叫雷雨天它自己从数据里发现了这种模式——这就是无监督学习的价值。5.3 聚类结果的实际延伸应用聚类完成之后我在想这个结果除了挺有意思之外还能做什么。后来整理了三个能落地的方向出行决策参考如果把聚类标签和历史日期对应起来可以告诉用户今天大概率属于湿热雷雨类天气降雨概率高建议携带雨具。本质上是在做天气分型的自动标注农业气象分析不同天气簇对应不同的温湿度组合可以分析各农作物品种在不同天气簇下的生长表现找出最匹配的种植窗口期新能源发电预测风电和光伏发电受天气状态影响显著。用K-means分型后的历史数据做关联分析比直接用连续特征建模更简单直观当然这些都是后话项目本身做到聚类加结果解释已经达到了练手的核心目标。6. 常见问题与排查技巧实录6.1 聚类前忘标准化的后果这个坑我前面反复提示过这里还是想单独放在问题排查里说一次因为它的影响是隐蔽的——不报错不出异常但聚类结果完全跑偏。我当时做了一组对照实验一组用标准化数据跑一组用原始数据跑K值都取4。原始数据那组的结果几乎是按气压从低到高硬切成四份温度、湿度、降水量在簇之间的区分度远低于标准化组。从WCSS数值上看原始数据组甚至更低但那是假象——因为气压这个维度的量纲大距离的绝对值也大气压的微小差异就能让距离产生巨大变化WCSS的绝对数值没有跨数据组的可比性。判断标准化是否必要的粗暴标准只要特征之间的单位或量级不同就必须标准化否则聚类结果就没有实际意义。6.2 空簇与算法崩溃手写K-means遇到的另一个高频报错就是空簇。当K值取太大、数据本身没有那么多天然分组时大概率会有某个簇在一次迭代后分不到任何样本。如果不处理下一步new_centers会缺项程序直接IndexError。我采用的方案是保留旧中心点让它在下一轮继续竞争。还有一种做法是把空簇的样本重新随机初始化然后继续迭代。要我推荐的话小数据集用保留旧中心点更稳定大数据集用重新初始化更容易跳出局部最优。项目里我自己跑出来的经验簇的数量最好不要超过样本总数的5%在这个范围内空簇问题很少出现。6.3 局部最优导致的结果不一致如果你发现自己每次跑K-means出来的结果都微妙地不同而且某些簇之间的差别在业务上难以解释多半是落入了局部最优。我建议的排查流程是先固定随机种子跑三次对比簇中心是否一致或者接近。如果每次差异很大说明聚类结果不可靠。解决办法前面说过增加重启次数、用K-means、或者对你的数据重新审视——也许它真的没有k个簇。某些情况下轮廓系数很低低于0.25说明数据本身并没有清晰的簇结构这时候不要硬调参去创造聚类可以考虑换其他算法比如DBSCAN或者重新评估特征选择。6.4 结果可解释性验证聚类算法输出一堆数字标签很容易但这些标签有没有业务含义才是关键。我给自己的项目定了一个验证明流程统计每个簇在所有特征上的均值形成画像用气象常识判断画像描述是否符合真实可解释的类型随机抽几簇的样本原始记录人工检查是否真的像同一类天气如果某个簇没有实际含义需要考虑K值太大或者特征选择不当我跑完第四簇干冷寒潮型平均气温4.6℃、湿度48%、风速4.5m/s随手翻了几个样本对应的真实日期——都是12月到次年2月的记录完全吻合。根据我个人做这个项目的感觉手写K-means的价值是调库完全替代不了的。它逼着你把标准化、初始化、收敛条件、空簇处理这些细节全部想一遍而每一个细节都对应着实际数据的某一种情况。如果你也在学习聚类算法强烈建议找一份真实数据先自己写一遍算法再用成熟的库做一次对比差距在哪里会立刻就清楚。另外天气数据只是载体这套流程换成用户分群、商品分类、传感器异常检测逻辑完全一样。