从零手写K-Means:理解聚类算法的初始化、分配与收敛原理

📅 2026/6/16 15:32:46 👤 管理员 👁 次浏览
从零手写K-Means:理解聚类算法的初始化、分配与收敛原理
1. 项目概述从零手写K-Means不是为了造轮子而是为了看清聚类的“呼吸节奏”你有没有在调用sklearn.cluster.KMeans时盯着控制台里那一行行跳动的Iteration 12, inertia: 1428.391发过呆明明只写了三行代码模型却像有生命一样自己迭代、收敛、分组——它到底在“想”什么K-Means常被称作聚类算法里的“Hello World”但恰恰因为太常用反而成了最被黑箱化的那一个。我带过十几期数据科学训练营每次讲到K-Means总有人问“初始化那几个质心真的是随机选的吗万一选歪了怎么办”“距离算欧氏距离可如果我的特征单位差十倍是不是就全乱套了”“inertia下降变慢了是该停还是该继续”这些问题文档不答教程略过只有当你亲手把每一步拆开、写死、调试、打断点才能真正听懂算法内部的每一次计算心跳。这篇内容就是一次彻底的“解剖式复现”不依赖任何机器学习库仅用NumPy和基础Python从零实现一个功能完整、逻辑透明、可调试、可观察的K-Means版本。它能完成标准K-Means全部核心流程——质心初始化、样本归属分配、质心位置更新、收敛判断并支持手动步进、中间状态可视化、不同初始化策略对比。它不追求性能极致但追求每一行代码都可解释、每一个变量都有意义、每一次循环都可追踪。适合三类人刚学完公式但不敢信公式的初学者想搞懂n_init和max_iter底层作用的中级实践者以及需要定制化逻辑比如加约束、换距离、嵌入业务规则的算法工程师。这不是教你怎么用API而是带你站在算法引擎舱里亲手拧紧每一颗螺丝。2. 整体设计思路为什么必须“从零手写”而不是“调包改参数”2.1 核心矛盾黑箱调用 vs 白盒理解很多教程教K-Means直接甩出一段fit_predict代码再画个二维散点图说“看它自动分好三类了”。这就像教人开车只让你踩油门看车跑却不告诉你变速箱怎么换挡、ABS怎么介入、ECU怎么读取轮速传感器。问题来了当你的业务数据出现异常——比如某类样本天然稀疏、某维特征存在强偏态、或者聚类结果明显不符合业务直觉——你拿什么去诊断查inertia曲线看轮廓系数这些指标本身也是黑箱输出。真正的根因往往藏在算法最基础的决策环节里质心怎么选的距离怎么算的归属怎么判的更新怎么做的这些环节任何一个微小偏差在高维、非球形、量纲混杂的数据上都会被指数级放大。所以我坚持“从零手写”的第一理由是建立因果链的完整性。比如sklearn默认用k-means初始化但它的具体实现细节如概率加权采样、累积分布函数构建在文档里只有一句话。而我们手写时会逐行写出D(x)^2怎么算、怎么累加、怎么二分查找这样当你发现某次初始化总把质心卡在边缘就能立刻定位到是np.random.rand()的均匀性问题还是累积和计算时的浮点误差溢出。2.2 架构选择函数式模块化而非面向对象封装你可能会疑惑为什么不做成一个class KMeans这样更“工程化”。但我的经验是对理解型复现函数式拆解比类封装更能暴露数据流本质。K-Means本质就是一个循环迭代过程输入数据→初始化质心→分配标签→更新质心→判断收敛→重复。这个链条里每个环节的输入输出都是明确的数组X,centroids,labels,distances没有隐藏状态没有生命周期管理。用独立函数实现比如initialize_centroids(X, k),assign_labels(X, centroids),update_centroids(X, labels, k)好处非常明显调试友好你可以单独调用assign_labels传入任意X和centroids立刻看到所有样本的归属结果不用启动整个训练流程组合灵活想试试用余弦距离代替欧氏距离只需替换assign_labels里的距离计算部分其他函数完全不动教学清晰每个函数名就是它的数学定义assign_labels对应E-step期望步update_centroids对应M-step最大化步学生一眼就能对应到EM算法框架。当然最后我会提供一个顶层kmeans_from_scratch函数来串联流程但它只是胶水不是核心。真正的“魔法”就藏在那四个不到20行的核心函数里。2.3 关键设计取舍精度、可读性与教学价值的三角平衡手写实现必然面临取舍。比如sklearn用Cython加速我们的纯Python版本肯定慢。但这不是缺陷反而是优势——慢才能让你看清每一步耗时在哪。我在实测一个1000×10的数据集时手写版单次迭代约0.15秒其中assign_labels占72%update_centroids占18%剩下是收敛判断。这个比例本身就在说话距离计算是瓶颈所以后续优化比如用KD-Tree、向量化广播才有明确方向。另一个取舍是数值稳定性。sklearn在更新质心时会对空簇做特殊处理如重采样我们初期版本先不做——因为空簇本身就是重要诊断信号。如果你的算法频繁产生空簇说明k值过大或数据分布不适合K-Means这比自动修复更有价值。等你完全理解了空簇成因再加修复逻辑才不会变成“知其然不知其所以然”。最后是可视化。很多教程用matplotlib画最终聚类图但我们额外加入迭代过程快照每轮结束后保存当前质心坐标、样本标签、inertia值。这样你可以用plotly做一个滑动条拖动查看质心如何一步步“游向”数据密集区。这种动态视角是静态最终图永远给不了的直觉。3. 核心细节解析四步拆解每一步都藏着关键原理3.1 初始化随机不是真随机k-means才是“聪明的懒”K-Means的第一步看似简单“随机选k个点当质心”。但这个“随机”决定了整个算法的命运。我做过一个实验用纯随机初始化np.random.choice跑100次结果inertia标准差高达±15%而用k-means标准差压到±1.2%。差距在哪在于初始质心的分布质量。纯随机初始化的问题是它完全无视数据结构。想象一个香蕉形数据集随机选两个点大概率一个在香蕉头、一个在香蕉尾中间大片区域空着。后续迭代中质心会被拉向两端导致分割线横切香蕉把同一簇撕成两半。k-means的精妙在于引入距离感知的贪心策略随机选第一个质心这步无法避免计算所有样本到已选质心的最小距离平方D(x)^2按D(x)^2作为权重概率采样下一个质心——离现有质心越远的点被选中的概率越大重复2-3步直到选满k个。这个设计的数学直觉是D(x)^2大的区域是当前质心“覆盖不到”的空白地带把新质心放在这里能最大化初始覆盖范围。它不保证全局最优但极大降低了陷入局部极小的概率。手写实现时关键在第三步的加权随机采样。np.random.choice支持p参数但要求权重和为1。所以我们得先算D_sq np.min(distances_to_existing_centroids, axis1) ** 2再归一化p D_sq / D_sq.sum()。这里有个易错点distances_to_existing_centroids是(n_samples, n_existing_centroids)矩阵np.min(..., axis1)取每行最小值得到(n_samples,)向量这才是每个样本的D(x)。我第一次写错成axis0结果选出来的质心全挤在数据中心debug了半小时才意识到维度搞反了。提示k-means的“”不是噱头是经过理论证明的近似比保证O(log k)。它让K-Means从“运气算法”升级为“有保障的启发式算法”。3.2 分配标签欧氏距离的物理意义与量纲陷阱第二步把每个样本分配给最近的质心。核心就是计算欧氏距离distance sqrt(sum((x_i - c_j)^2))。但这里藏着一个常被忽略的致命陷阱量纲不一致。假设你的数据有两列身高单位米范围1.5~2.0、年收入单位元范围50000~200000。直接算欧氏距离收入的平方项1e10量级会完全淹没身高的平方项1e0量级导致聚类结果几乎只由收入决定身高信息被彻底抹杀。这就是为什么sklearn文档反复强调“务必标准化”。手写时我们必须显式处理这个。方案有两个预处理标准化在调用K-Means前用StandardScaler或手动计算(X - X.mean()) / X.std()。这是最正统的做法。距离加权在距离公式里给每维特征乘一个权重w_i使各维贡献均衡。但权重怎么定又回到标准化问题。我选择第一种并在代码里强制检查如果X.std(axis0).min() 1e-8就报错提醒“检测到近似常数特征请检查数据”。这个检查是sklearn默认不做的但实际项目中常有ID列、时间戳列被误入特征矩阵导致距离计算失效。另一个细节是数值稳定性。直接算sqrt(sum((x-c)**2))当x和c很大时(x-c)**2可能溢出。更稳的做法是用np.linalg.norm(x - c, ord2)它内部做了缩放处理。我在测试中故意用X * 1e5纯手工sqrt(sum)版本报RuntimeWarning: overflow encountered in square而linalg.norm安然无恙。这个坑不手写根本看不到。3.3 更新质心均值的本质是“最小化平方误差”的解第三步对每个簇用该簇所有样本的均值更新质心。这步看似最简单但它的数学根基最深刻质心取均值是因为均值是使簇内平方误差inertia最小的点。推导很简单对固定簇C_jinertia sum_{x in C_j} ||x - c_j||^2。对c_j求导并令导数为0得c_j (1/|C_j|) * sum_{x in C_j} x。也就是说“取均值”不是约定俗成而是优化目标的自然解。如果你换一个目标比如最小化绝对误差质心就得取中位数。手写实现时关键在按标签分组求均值。常见错误是用Python循环遍历每个标签j再用布尔索引X[labels j]。这在小数据上没问题但大数据时布尔索引会创建临时副本内存爆炸。更高效的是用np.bincount和np.add.at# labels是长度为n_samples的整数数组值域[0, k) # X是(n_samples, n_features)矩阵 new_centroids np.zeros((k, n_features)) # 统计每个簇的样本数 counts np.bincount(labels, minlengthk) # (k,) # 对每个特征维度累加该簇所有样本值 for d in range(n_features): np.add.at(new_centroids[:, d], labels, X[:, d]) # 除以计数得均值 new_centroids new_centroids / counts[:, None]np.add.at是原地累加不创建副本比布尔索引快3倍以上。这个技巧是我在优化一个日志聚类脚本时从NumPy文档犄角旮旯里挖出来的。注意当某个簇为空counts[j] 0时new_centroids[j]会是nan。这就是前面说的空簇是重要信号——它意味着k值过大或数据分布有严重偏斜。我们不自动修复而是抛出ValueError(Empty cluster detected at iteration {i})逼你直面问题。3.4 收敛判断inertia下降不是唯一标准还得看质心漂移最后一步判断是否停止迭代。sklearn默认用inertia簇内平方和的相对变化小于tol1e-4。但inertia是标量它下降了不代表质心真的稳定了。我遇到过一个案例数据有轻微旋转对称性inertia在最后几轮波动极小1e-5但质心坐标还在缓慢漂移导致聚类标签来回切换。这时只看inertia会误判收敛。因此我们增加双轨收敛判断inertia轨道abs(inertia_old - inertia_new) / inertia_old tol_inertia质心轨道np.max(np.sqrt(np.sum((centroids_old - centroids_new)**2, axis1))) tol_centroid第二个条件计算所有质心在本轮移动的最大欧氏距离。只要有一个质心移动超过阈值就不收敛。tol_centroid默认设为1e-6比tol_inertia更严苛。这个设计让算法对“伪收敛”更鲁棒。还有一点max_iter不是摆设。有些病态数据如所有点几乎共线inertia下降极慢不设上限会无限循环。我们严格遵循max_iter并在达到时抛出ConvergenceWarning同时返回当前最佳结果。这个警告是调试时最重要的线索之一——它告诉你“数据有问题别怪算法”。4. 实操过程从空文件到可运行的完整代码附关键注释4.1 环境准备与数据生成用合成数据验证逻辑正确性开始编码前先搭好环境。我们只依赖两个包numpy用于数值计算matplotlib用于可视化。版本要求不高numpy1.19即可支持np.add.at。pip install numpy matplotlib然后生成一个经典的“半月形”数据集它能直观暴露K-Means的局限性球形假设也方便我们验证手写代码是否真能工作import numpy as np import matplotlib.pyplot as plt # 生成两个半月形簇K-Means天然难分 from sklearn.datasets import make_moons X, y_true make_moons(n_samples300, noise0.05, random_state42) # 标准化必须做否则距离计算失效 X (X - X.mean(axis0)) / X.std(axis0) # 可视化原始数据 plt.figure(figsize(8, 6)) plt.scatter(X[:, 0], X[:, 1], cgray, alpha0.6, s20, labelData points) plt.title(Original Data: Two Moons) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.legend() plt.grid(True, alpha0.3) plt.show()这段代码生成300个点组成两个月牙。注意noise0.05控制扰动random_state42保证可复现。标准化那行至关重要——它把两维特征都拉到均值0、标准差1的尺度让欧氏距离公平。4.2 核心函数实现四步每步一行关键注释现在逐个实现四个核心函数。我会在每行关键操作后加# -- 这里是XX原理注释解释为什么这么写。def initialize_centroids_kpp(X, k, random_state42): k-means 初始化确保初始质心分散 n_samples, n_features X.shape np.random.seed(random_state) # 固定随机种子便于调试 # 步骤1随机选第一个质心 centroids np.zeros((k, n_features)) first_idx np.random.randint(0, n_samples) centroids[0] X[first_idx] # 步骤2-4贪心选剩余k-1个 for i in range(1, k): # 计算所有点到已选质心的最小距离平方 D(x)^2 # dists.shape (n_samples, i), 每列是到第j个质心的距离 dists np.sqrt(((X - centroids[:i]) ** 2).sum(axis2)) # -- 广播机制X(300,2) - centroids[:i](i,2) - (300,i,2) D_sq np.min(dists, axis1) ** 2 # -- axis1取每行最小得(300,)向量 # 步骤3按D_sq加权概率采样下一个质心 probs D_sq / D_sq.sum() # -- 归一化为概率分布 cum_probs np.cumsum(probs) # -- 构建累积分布函数 r np.random.rand() # -- 生成[0,1)随机数 next_idx np.searchsorted(cum_probs, r) # -- 二分查找等价于np.random.choice centroids[i] X[next_idx] return centroids def assign_labels(X, centroids): 分配每个样本到最近质心返回标签数组 n_samples X.shape[0] k centroids.shape[0] # 向量化计算所有样本到所有质心的距离 # 利用广播X(300,2) - (300,1,2), centroids(k,2) - (1,k,2) # dists.shape (300, k)每个元素d[i,j]是样本i到质心j的距离 dists np.sqrt(((X[:, np.newaxis, :] - centroids[np.newaxis, :, :]) ** 2).sum(axis2)) # argmin(axis1)对每行即每个样本找最小距离的质心索引 labels np.argmin(dists, axis1) # -- 核心E-step分配归属 return labels, dists def update_centroids(X, labels, k): 根据新标签更新每个质心为对应簇的均值 n_samples, n_features X.shape new_centroids np.zeros((k, n_features)) counts np.bincount(labels, minlengthk) # -- 统计每个簇的样本数 # 使用np.add.at进行高效分组累加避免布尔索引内存爆炸 for d in range(n_features): np.add.at(new_centroids[:, d], labels, X[:, d]) # -- 原地累加内存友好 # 处理空簇如果counts[j]0则new_centroids[j]为0除以0得inf/nan # 我们不掩盖而是让后续收敛判断暴露它 new_centroids new_centroids / counts[:, np.newaxis] # -- 广播除法 return new_centroids, counts def kmeans_from_scratch(X, k, max_iter100, tol_inertia1e-4, tol_centroid1e-6, init_methodkpp, random_state42): 主函数执行完整K-Means迭代 n_samples, n_features X.shape if k n_samples: raise ValueError(fk ({k}) cannot be greater than n_samples ({n_samples})) # 初始化质心 if init_method kpp: centroids initialize_centroids_kpp(X, k, random_state) else: # random idx np.random.choice(n_samples, k, replaceFalse) centroids X[idx].copy() # 存储历史记录用于可视化 history {centroids: [centroids.copy()], labels: [], inertias: []} for i in range(max_iter): # E-step分配标签 labels, dists assign_labels(X, centroids) history[labels].append(labels.copy()) # 计算当前inertia所有样本到其归属质心的距离平方和 inertia np.sum(np.min(dists, axis1) ** 2) # -- axis1取每行最小即每个样本的最小距离 history[inertias].append(inertia) # M-step更新质心 new_centroids, counts update_centroids(X, labels, k) # 检查空簇 if np.any(counts 0): raise ValueError(fEmpty cluster detected at iteration {i}. Try smaller k or different init.) # 收敛判断双轨 centroid_shift np.max(np.sqrt(np.sum((centroids - new_centroids) ** 2, axis1))) inertia_change abs(history[inertias][-2] - inertia) / history[inertias][-2] if i 0 else np.inf # 保存本轮质心 history[centroids].append(new_centroids.copy()) # 判断收敛 if (centroid_shift tol_centroid) and (inertia_change tol_inertia): print(fConverged at iteration {i1}) break centroids new_centroids else: print(fReached max_iter{max_iter}, did not converge.) return labels, centroids, history这段代码就是全部核心。没有花哨的装饰器没有抽象基类只有最直白的数学映射。assign_labels里那个X[:, np.newaxis, :]是NumPy广播的精髓——它把二维数组升维让距离计算向量化速度比Python循环快百倍。update_centroids里np.add.at的使用是内存优化的关键。每一行都对应一个明确的数学步骤或工程考量。4.3 运行与可视化看见算法的“呼吸”现在调用它看看魔法如何发生# 运行手写K-Meansk2 labels, final_centroids, history kmeans_from_scratch( X, k2, max_iter50, tol_inertia1e-5, tol_centroid1e-7, init_methodkpp, random_state42 ) # 可视化迭代过程 fig, axes plt.subplots(2, 3, figsize(15, 10)) axes axes.flatten() # 绘制前6轮包括初始的质心和分配 for i, ax in enumerate(axes): if i len(history[centroids]): break centroids_i history[centroids][i] if i len(history[labels]): labels_i history[labels][i] # 画样本点按标签着色 scatter ax.scatter(X[:, 0], X[:, 1], clabels_i, cmapviridis, alpha0.6, s20) # 画质心 ax.scatter(centroids_i[:, 0], centroids_i[:, 1], cred, markerx, s200, linewidths3, labelCentroids) else: # 初始状态无标签只画数据和初始质心 ax.scatter(X[:, 0], X[:, 1], cgray, alpha0.6, s20, labelData) ax.scatter(centroids_i[:, 0], centroids_i[:, 1], cred, markerx, s200, linewidths3, labelInitial Centroids) ax.set_title(fIteration {i}) ax.set_xlabel(Feature 1) ax.set_ylabel(Feature 2) ax.grid(True, alpha0.3) plt.tight_layout() plt.show() # 绘制inertia下降曲线 plt.figure(figsize(10, 5)) plt.plot(history[inertias], bo-, labelInertia) plt.xlabel(Iteration) plt.ylabel(Inertia (Sum of Squared Distances)) plt.title(Inertia Convergence Curve) plt.legend() plt.grid(True, alpha0.3) plt.show()运行后你会看到两张图第一张是6个子图展示质心如何从随机位置左上角一步步“游动”最终稳定在两个月牙的几何中心第二张是inertia曲线它像心电图一样快速下跌后趋于平缓。这个动态过程是任何静态API调用都无法提供的认知深度。5. 常见问题与排查技巧实录那些文档里不会写的“血泪教训”5.1 问题速查表高频故障与现场诊断问题现象可能原因快速诊断命令解决方案ValueError: Empty cluster detectedk值过大或数据分布极度不均print(np.bincount(labels))查看各簇计数减小k或用kmeans初始化或检查数据是否有异常离群点RuntimeWarning: invalid value encountered in sqrt距离计算中出现负数浮点误差print(np.min(dists))在assign_labels后加改用np.linalg.norm或在开方前加np.clip(dists, 0, None)迭代50次都不收敛max_iterreached数据非球形如环形、流形或tol设得太小print(fCentroid shift: {centroid_shift:.2e})检查数据形态增大tol_centroid或换算法如DBSCANinertia曲线震荡不降特征未标准化量纲差异大print(X.std(axis0))强制标准化X (X - X.mean(axis0)) / (X.std(axis0) 1e-8)手写版比sklearn慢10倍以上距离计算未向量化用了Python循环%%timeit测试assign_labels函数确保使用X[:, np.newaxis, :]广播避免for i in range(n): for j in range(k):这张表是我过去三年在客户现场救火时从几十个真实case里提炼出来的。它不讲理论只给“看到什么做什么”的即时反馈。5.2 实操心得五个必须知道的“潜规则”心得1random_state不是可选项是必选项很多人觉得“随机初始化设不设种子无所谓”。错。在调试时如果你不固定random_state每次运行结果都不同你就永远无法确定是算法bug还是随机性导致。我养成的习惯是所有涉及随机的操作random_state参数必须显式传入且在项目开始时统一定义一个SEED 42。这能让你的调试过程可复现、可追溯。心得2n_init的本质是“用计算换确定性”sklearn的n_init10意思是跑10次不同初始化选inertia最小的那次。手写时你可以轻松实现best_labels, best_centroids, best_inertia None, None, float(inf)然后循环调用kmeans_from_scratch更新最佳结果。这比盲目调大max_iter有效得多。记住K-Means的稳定性不靠迭代次数靠初始化多样性。心得3inertia不能跨k值比较新手常犯的错误是用inertia值直接比较k2和k3的好坏。这是错的因为k越大inertia天然越小更多质心拟合更细。正确做法是画肘部法则图Elbow Plotx轴是ky轴是inertia找下降趋势明显变缓的那个“肘部”。或者用轮廓系数Silhouette Score它衡量簇内紧密度与簇间分离度的比值值域[-1,1]越高越好。心得4二维可视化是你的第一道防线无论数据多高维动手前先用PCA或t-SNE降到2D画出来。我处理过一个电商用户行为数据集100维inertia曲线很美但2D可视化一看所有点挤成一团根本没法聚。原来特征工程出了问题——大量0-1编码的稀疏特征主导了距离。图形不会说谎它是最诚实的调试器。心得5业务理解永远先于算法调优最后也是最重要的一点K-Means是一个工具不是答案。我曾帮一家物流公司做网点聚类算法给出k7inertia很低。但业务方说“我们只有5个区域经理最多管5个片区。”这时强行用k7结果再“数学完美”也毫无落地价值。算法的终点是业务的起点。先问“我们要解决什么问题”再选“哪个k值能让业务方拍板”最后才调“怎么让它算得更快更准”。6. 进阶扩展从手写到生产还有哪些路可以走手写K-Means的价值不仅在于理解更在于它是一块跳板。基于这个干净、透明的基座你可以安全地做各种定制化扩展而不用担心破坏核心逻辑。6.1 距离度量的替换从欧氏到业务语义欧氏距离假设各维特征同等重要且独立。但现实中业务距离往往有语义。比如用户相似度地理距离公里用欧氏或Haversine消费金额元可能需要对数变换避免高消费用户主导购买品类one-hot用Jaccard相似度。手写架构的优势就体现出来了只需修改assign_labels函数里距离计算的部分。例如加入一个metric参数def assign_labels(X, centroids, metriceuclidean): if metric euclidean: dists np.sqrt(((X[:, np.newaxis, :] - centroids[np.newaxis, :, :]) ** 2).sum(axis2)) elif metric manhattan: dists np.sum(np.abs(X[:, np.newaxis, :] - centroids[np.newaxis, :, :]), axis2) elif metric cosine: # 余弦距离 1 - 余弦相似度 X_norm np.linalg.norm(X, axis1, keepdimsTrue) c_norm np.linalg.norm(centroids, axis1, keepdimsTrue) dot_product X centroids.T cos_sim dot_product / (X_norm c_norm.T) dists 1 - cos_sim return np.argmin(dists, axis1), dists这样你就可以用同一套框架探索不同距离下的聚类效果找到最贴合业务直觉的那个。6.2 约束聚类加入业务规则的硬性边界标准K-Means不考虑业务约束。但现实中约束无处不在一个销售区域不能跨省一个服务器集群的节点必须在同一机房一个推荐列表的物品必须覆盖至少3个品类。这时你需要约束聚类Constrained Clustering。手写实现可以在assign_labels后加一层校验。例如假设你有一个constraints数组constraints[i] j表示样本i必须属于簇j-1表示无约束def assign_labels_with_constraints(X, centroids, constraints): labels, dists assign_labels(X, centroids) # 先按距离分 # 强制满足约束 for i, must_be_in in enumerate(constraints): if must_be_in ! -1: labels[i] must_be_in return labels, dists更复杂的约束如“每个簇至少10个样本”、“簇间最小距离”可以通过在update_centroids后添加修复逻辑来实现。手写代码的灵活性让你能精准控制每一步的干预点。6.3 在线学习应对数据流的实时聚类sklearn的K-Means是批处理的数据全量加载。但在IoT设备监控、实时推荐场景数据是源源不断的流。这时你需要在线K-MeansStreaming K-Means它用一个衰减因子lambda让新样本对质心的影响随时间衰减def update_centroids_online(X_new, labels_new, centroids, lambda_decay0.9): 在线更新新样本按权重影响旧质心 k centroids.shape[0] for j in range(k): mask (labels_new j) if np.any(mask): # 新样本均值 X_new_j X_new[mask] new_mean X_new_j.mean(axis0) # 加权更新新均值权重lambda旧质心权重(1-lambda) centroids[j] (1 - lambda_decay) * centroids[j] lambda_decay * new_mean return centroids这个公式就是著名的指数移动平均EMA它让模型能适应数据分布的缓慢漂移而不会被突发