做无监督学习的人大概都碰过这样的场景:聚类算法跑完,输出一串簇标签和中心向量,你把轮廓系数、簇内距离整理成报告发过去,对方回一句“这些簇之间是什么关系,能不能画出来看看”。k-means 回答不了这个问题,它只告诉你谁和谁归到一类,不告诉你类与类之间的相对位置,更看不到两个簇中间还有没有过渡样本。PCA 能画,可它是线性投影,遇到弯曲的流形会把原本相距很远的点叠在一起。t-SNE 图很漂亮,但布局每次重跑都变,新样本也没法稳稳地放进已有的图里。自组织映射(Self-Organizing Map,SOM)恰好活在这个夹缝里——它把高维样本压到一张二维网格上,同时尽量保留原来样本之间的拓扑邻近关系,是无监督学习里少见的“既能聚类、又能可视化、还能给新样本定位”的技术。
这篇内容不打算写成教科书,而是按我自己的使用路径来:先讲清楚它解决什么问题、和常见降维方法差在哪,再把训练机制拆开,然后给一份能直接跑的完整实现,接着聊 U-Matrix、命中图、分量平面这几张图怎么画怎么读,最后是参数调优、踩过的坑和几个落地的场景。适合的人群很明确——有一点 Python 和 numpy 基础,知道欧氏距离、标准差是什么,想把聚类结果做得更可解释,或者已经在用 t-SNE 但苦于不能增量映射的读者。不需要你有神经网络背景,全文的数学只到指数函数和向量减法。
1. 为什么可视化这件事,值得单独看看自组织映射
1.1 一次聚类汇报引出的问题
我印象很深的一次,是帮一个做会员运营的团队分析用户行为数据。样本量不大,六万多条,每条记录十几个特征:近三个月下单次数、客单价、浏览深度、领券使用率、退货率等等。标准化之后跑 k-means,用肘部法和轮廓系数选了 7 个簇,指标看着挺健康,轮廓系数 0.42。报告发出去,对面的运营负责人问了三个问题:这 7 类人里,哪两类最像?有没有那种“正在从 A 类往 B 类迁移”的用户?新进来的用户我可以马上知道他落在哪一类吗?
k-means 一个都答不上来。它输出的簇是离散标签,簇与簇之间除了中心距离没有别的结构信息;迁移中的用户会被硬塞进某一类,看不出中间态;新用户要定位必须重新算距离,虽然技术上可行,但你没法把新用户“画”进原来那张图里。PCA 降到二维之后,前两个主成分解释了 43% 的方差,散点图糊成一团,几个簇重叠得厉害,因为线性投影保不住局部结构。换成 t-SNE 之后簇分得漂亮了,但每次改随机种子图就变一个样,而且新用户没法定位。
后来我改用 SOM,把 12 维特征映射到 14×14 的网格上,情况立刻不一样了。网格本身就是一张地图,相邻格子代表相似的用户状态,运营可以指着某个区域说“这块是高频低客单,边上那块是高客单但复购慢,中间这一小片就是正在迁移的人”。新用户来了,算一下他离哪个神经元的权重最近,直接在地图上打个点就行,不用重训。那次之后我基本形成了一个习惯:只要需求里出现“解释结构”“看关系”“增量定位”这几个词,先考虑 SOM,再考虑其他降维。
1.2 SOM 到底在解决什么问题:保拓扑而不是保距离
很多人第一次接触 SOM,会把它当成“另一种降维方法”,这个理解不算错,但容易抓错重点。PCA 优化的是投影后的方差保留率,t-SNE 优化的是高维和低维之间样本对概率分布的 KL 散度,它们的评价标准都建立在“距离”上。SOM 优化的东西不一样,它要的是拓扑保真——高维空间里离得近的样本,映射到网格上也应该落在相近的位置;反过来,网格上相邻的神经元,其权重向量在原始空间里也应该接近。
这个差别带来的实际后果是:SOM 的网格坐标不携带绝对距离信息,两个格子挨着不代表它们对应的样本距离一定是某个具体数值,只代表“比隔得远的格子更相似”。所以你不能拿 SOM 的网格坐标去做回归,也不能算出“样本 A 到样本 B 在网格上差 3.7 个单位所以它们相似度是多少”。但你可以非常放心地做这几件事:把网格切成连通区域当簇、用网格上的位置解释样本的相对关系、把新样本映射进去看它落在哪个区域。
再补一句它和 k-means 的本质区别。k-means 的每个簇只有一个中心,簇内所有样本共享同一个“代表”;SOM 的每个神经元都是一个原型,一张 14×14 的网格有 196 个原型,它能表达出连续渐变的结构。用生活化的比喻:k-means 是把一座城市分成 7 个行政区,SOM 是在城市上铺一张网格,每个格点记录当地的平均气候,你能看到温度从南到北怎么渐变。
1.3 和 PCA、t-SNE、UMAP 摆在一起比一比
选型这件事不能靠感觉,我把这几个方法在几个关键维度上列了个表,这张表是我自己在做方案评审时常用的版本:
| 维度 | PCA | t-SNE | UMAP | SOM |
|---|---|---|---|---|
| 线性与否 | 线性 | 非线性 | 非线性 | 非线性(自适应) |
| 全局结构 | 保留较好 | 基本丢失 | 部分保留 | 拓扑结构保留 |
| 局部结构 | 一般 | 很强 | 强 | 较强,取决于网格密度 |
| 新样本映射 | 直接投影 | 需额外训练映射 | 近似 transform | 算 BMU 即可,天然支持 |
| 输出形式 | 连续坐标 | 连续坐标 | 连续坐标 | 离散二维网格 |
| 布局稳定性 | 稳定 | 每次不同 | 略有波动 | 固定种子后稳定 |
| 计算成本 | 很低 | 中等偏高 | 中等 | 中等,可增量 |
| 典型用途 | 快速降维、去噪 | 探索性可视化 | 通用降维 | 聚类加可视化、在线分群 |
表格背后的取舍逻辑值得展开说几句。如果你的目标只是把维度压下来喂给下游模型,PCA 的性价比最高,它快、稳定、可解释,前几个主成分的载荷还能告诉你哪些特征在起作用。如果你纯粹是为了做一次探索性分析、给人看一张漂亮的图,t-SNE 依然是首选,它对局部结构的还原能力很难被替代,代价是图不能复用。UMAP 介于两者之间,速度快、能用 transform 近似映射新样本,近两年在很多场景里替代了 t-SNE。
SOM 的位置比较特殊,它的强项不是“降维质量”这项单一指标,而是把一个连续的高维空间离散化成一张可复用的地图。地图一旦训练好,你可以往上贴任意多的信息层:样本密度、簇标签、某个特征的取值、异常分数。举个例子,同样是那张用户地图,我今天可以叠“近 30 天活跃度”看哪些区域在降温,明天可以叠“优惠券核销率”看哪些区域的用户对促销敏感,地图本身不用重训。这种“一次训练、多层复用”的特性,是 PCA 和 t-SNE 给不了的。
提示:如果你的数据维度低于 5 维,SOM 的优势不明显,直接画散点图或者用 PCA 更省事。SOM 真正的价值区间在 8 维以上、样本量几千到几十万、且需要持续解释结构的场景。
2. 训练机制拆解:竞争、合作、适应
2.1 一张网格和一堆权重向量
SOM 的结构简单到可以用一句话说完:一张 m×n 的二维网格,每个格点(神经元)挂一个和输入同维度的权重向量。网格的尺寸是你定的超参数,权重的数值是训练出来的。输入是 d 维,权重也是 d 维,你可以把每个神经元想成一个“原型样本”,训练的过程就是让这些原型慢慢铺开、覆盖住数据分布的形状。
这里有个容易混淆的点必须先说清楚:网格是固定的,权重才是可变的。很多人第一次看示意图,以为 SOM 是在训练过程中把神经元“拉”到数据点旁边,实际上是网格坐标不动,改的是每个格点上的权重向量。网格只是一套索引系统,真正承载信息的是权重。
还有一个细节:网格的拓扑结构(矩形还是六边形)在训练前就要确定,训练中不改。矩形网格每个格点有 4 个邻居,六边形有 6 个,后者的邻域更均匀、不会出现对角线方向的偏差,代价是可视化和索引稍微麻烦一点。我自己的习惯是先用矩形网格,如果发现 U-Matrix 上有明显的横竖条纹伪影,再换六边形。
2.2 竞争:找到最佳匹配单元
对一个输入样本 x,算它到所有神经元权重的距离,距离最近的那个神经元叫最佳匹配单元,简称 BMU。这一步是纯粹的最近邻搜索,没有任何玄机:
d²(x, w_ij) = Σ_k (x_k - w_ijk)² BMU = argmin_{i,j} d²(x, w_ij)距离度量默认用欧氏距离,但不是唯一选择。文本 embedding、推荐系统的向量这类方向比长度更重要的数据,用余弦距离效果更好,因为它忽略了向量模长带来的干扰。我做过一个商品标题聚类的项目,用欧氏距离时高频词多的标题会主导匹配,换成余弦之后效果好了一大截。
如果你的数据里存在缺失维度,也可以在距离计算时只对非缺失维度求和,再做归一化。这是 SOM 相对很多降维方法的一个隐性优势——它天然能处理部分缺失,而 PCA 和 t-SNE 必须先做插补。
2.3 合作:邻域内的神经元一起动
找到 BMU 之后,不能只更新它一个。如果只更新 BMU,最后得到的就是一张稀疏的、没有连续性的量化表,邻居之间毫无关系。所以要让 BMU 周围的神经元也跟着更新,更新的幅度按距离衰减,这就是“合作”。
衰减函数通常用高斯核:
h(d_grid) = exp(-d_grid² / (2σ²))d_grid 是网格上的坐标距离,比如 BMU 在 (5,5),某个神经元在 (5,6),那 d_grid 就是 1;在 (7,8) 就是 √13 ≈ 3.6。σ 是邻域半径,控制“合作”的范围。σ 大的时候,BMU 周围一大片都会被拉动;σ 小的时候,基本只有紧邻的几个受一点影响。
这一步是整个算法保拓扑的关键。因为相邻的神经元被同一个样本往同一个方向拉,长期下来它们的权重就会变得相似,映射关系因此是平滑的。你可以反过来理解:如果每个样本只更新 BMU,神经网络之间就没有任何耦合,网格上相邻的格点可能对应完全不同的数据区域,那就退化成了一张哈希表。
2.4 适应:把权重往样本方向拽
更新公式很朴素:
w_new = w_old + η · h · (x - w_old)η 是学习率,h 是上一步的邻域权重。这个式子的含义是:把神经元权重向样本方向移动一段距离,移动的比例由学习率和邻域强度共同决定。BMU 移动最多,远处的神经元几乎不动。
把 2.2 到 2.4 串起来,一个样本的完整更新流程就是:算距离找 BMU → 按网格距离算邻域权重 → 所有神经元按权重向样本靠拢。遍历完所有样本算一轮,重复几十轮,网格就收敛了。
2.5 两个必须随迭代衰减的量
学习率 η 和邻域半径 σ 都必须随训练进程衰减,这一点没有例外。常用的指数衰减:
η(t) = η0 · exp(-t / τ) σ(t) = σ0 · exp(-t / τ)τ 一般取总迭代轮数的四分之一到三分之一。参数的量级参考:η0 取 0.3 到 0.5,σ0 取 max(网格行数, 网格列数) 的一半,末期 σ 不低于 0.5 到 1。
为什么必须衰减,我用两个阶段来解释。训练早期,网格权重是随机初始化的,布局完全乱,这时候需要大的 σ 让整张网格一起做粗调,大的 η 让权重快速移动到位;训练后期,大局已定,需要小的 σ 只做局部精修,小的 η 防止权重在最优解附近反复横跳。如果不衰减,σ 一直是网格一半,那么每来一个样本,半张网格都会被拉一下,最后网格会整体抖动,永远收敛不到稳定结构,U-Matrix 上全是噪声。
工业上更常见的做法是分两阶段训练:第一阶段 η 从 0.5 线性降到 0.05、σ 从网格一半降到 1,跑总轮数的 90%;第二阶段 η 固定 0.02、σ 固定 1,跑剩下的 10% 做细化。第二阶段的作用是让网格边界更清晰,簇与簇之间的过渡带更窄。我在做设备状态监测时对比过,加第二阶段之后拓扑误差能降 3 到 5 个百分点。
注意:σ 的末期值不要设成 0。σ 接近 0 时高斯核退化成只有 BMU 自己被更新,网格上会出现锯齿状的孤立格点,U-Matrix 看起来像撒了盐。最小保持 0.5 到 1 之间比较稳。
2.6 为什么这套机制能保住拓扑
原理层面再往深挖一层。SOM 的更新实际上是在做一个带邻域约束的向量量化:每个样本都在把附近的神经元往自己这边拉,而邻近神经元被拉的力度相近,所以它们的相对位置不容易被破坏。用物理类比,网格是一张有弹性的网,每个样本都在网上按一个局部凹陷,凹陷的深度由学习率决定,宽度由 σ 决定。样本多了,网上就形成了和数据分布对应的起伏。
这个类比能解释不少现象。为什么死神经元会出现?因为某些区域从来没被任何样本拉起过凹陷,权重停在初始值。为什么大 σ 阶段结束后网格整体形状就定了?因为那时候整张网被拉出了大致轮廓。为什么小 σ 阶段能细化边界?因为凹陷变窄了,不同样本之间的竞争更激烈,边界自然收紧。
3. 从零写一个能跑的 SOM
3.1 数据准备与标准化
先造一份可控的数据。四个簇,5 维空间,每簇 150 个样本,加高斯噪声,这样训练完能直观看出 SOM 有没有把四个簇分开:
import numpy as np rng = np.random.default_rng(7) centers = np.array([ [0.0, 0.0, 0.0, 0.0, 0.0], [6.0, 6.0, 0.0, 0.0, 0.0], [0.0, 6.0, 6.0, 0.0, 0.0], [6.0, 0.0, 0.0, 6.0, 0.0], ]) X = np.vstack([c + rng.normal(0, 0.9, size=(150, 5)) for c in centers]) # z-score 标准化,这一步不能省 X = (X - X.mean(axis=0)) / X.std(axis=0) print(X.shape) # (600, 5)标准化必须做,而且要在训练前做,不能在训练中做。原因很直白:SOM 的距离是各维度平方和,量纲大的维度会直接主导匹配结果。我见过一个案例,特征里有一个“年收入”字段取值在几万到几十万,其他字段都在 0 到 1 之间,没标准化直接训练,最后整张网格只按收入一个维度排列,其他特征完全没体现。用 z-score 之后每个维度均值 0、方差 1,权重才均衡。
如果数据里有明显的长尾或者离群点,z-score 之后依然会被拉偏,这时候可以先做对数变换或者分位数裁剪(比如把超过 99 分位的值截断),再做标准化。这一步做完,后面所有参数的意义才是一致的。
3.2 初始化:随机采样还是 PCA 初始化
最常见的是随机初始化,但随机从高斯分布里取数效果一般,因为初始权重可能离数据分布很远,导致大量死神经元。更稳的做法是从训练样本里随机抽一批做初始权重:
init_idx = rng.choice(len(X), size=gh * gw, replace=False) W = X[init_idx].reshape(gh, gw, -1).copy()这样每个神经元的初始权重都落在数据分布内部,收敛快、死神经元少。要求是样本数大于神经元数,一般都能满足。
还有一种更讲究的做法是 PCA 初始化:对数据做 PCA,取前两个主成分,把网格坐标归一化到 [-1, 1],然后用主成分方向线性铺开初始权重。这种做法能把初始网格直接铺成数据的主要形状,收敛轮数能减少三分之一左右。代价是引入了一点线性偏置,在数据流形弯曲得厉害的时候,小 σ 阶段需要更多轮才能把弯曲结构修正回来。我自己的选择是:数据量小、要快速出结果时用 PCA 初始化;数据分布复杂、追求拓扑质量时用样本采样初始化。
3.3 核心训练循环
下面这份实现不到 50 行,依赖只有 numpy,可以直接跑:
import numpy as np class MiniSOM: def __init__(self, gh=10, gw=10, lr0=0.5, sigma0=None, seed=0): self.gh, self.gw = gh, gw self.lr0 = lr0 self.sigma0 = sigma0 if sigma0 is not None else max(gh, gw) / 2.0 self.rng = np.random.default_rng(seed) ys, xs = np.meshgrid(np.arange(gh), np.arange(gw), indexing='ij') self.coords = np.stack([ys, xs], axis=-1).astype(float) self.W = None def fit(self, X, epochs=60): n, d = X.shape idx = self.rng.choice(n, size=self.gh * self.gw, replace=False) self.W = X[idx].reshape(self.gh, self.gw, d).copy() tau = epochs / 4.0 for ep in range(epochs): lr = self.lr0 * np.exp(-ep / tau) sigma = self.sigma0 * np.exp(-ep / tau) + 0.5 for i in self.rng.permutation(n): x = X[i] d2 = ((self.W - x) ** 2).sum(axis=2) bi, bj = np.unravel_index(np.argmin(d2), (self.gh, self.gw)) gd2 = ((self.coords - np.array([bi, bj])) ** 2).sum(axis=2) h = np.exp(-gd2 / (2 * sigma ** 2)) self.W += lr * h[..., None] * (x - self.W) return self def bmu(self, x): d2 = ((self.W - x) ** 2).sum(axis=2) return np.unravel_index(np.argmin(d2), (self.gh, self.gw))几个实现细节值得单独说。coords是预计算好的网格坐标,形状 (gh, gw, 2),每次算邻域时直接和 BMU 坐标做差,避免了循环。h[..., None]把 (gh, gw) 的邻域权重扩成 (gh, gw, 1),这样就能和 (gh, gw, d) 的权重张量做广播运算,一行代码更新全部神经元。self.W += ...是原地更新,省内存。
性能上,600 个样本、60 轮、10×10 网格,纯 Python 循环在这个实现下大概几秒到十几秒。如果样本量上了十万,内层循环得改成 mini-batch 版本:一次取 64 或 128 个样本,各自算 BMU,对同一个神经元的更新量做累加后再统一施加。这样能把 Python 层的循环次数降两个数量级,代价是收敛所需的轮数略增。
3.4 参数怎么算:网格尺寸、初始半径、迭代轮数
这几个参数不是拍脑袋定的,有一套经验公式可以起步。
网格尺寸的经验公式是神经元总数约等于 5 倍的样本数平方根,再开方得到边长:
神经元总数 ≈ 5 · √n 边长 ≈ √(5 · √n)n = 600 时,√600 ≈ 24.5,乘 5 得 122,开方得 11,所以取 10×10 或 11×11 都合理。这个系数的来源是经验平衡:神经元太少,簇会被挤在一起分不开;神经元太多,大量神经元没有样本匹配,死神经元比例飙升,U-Matrix 上会看到大片的平坦区域。
初始半径 σ0 取网格长边的一半,10×10 的网格就是 5。这个值的含义是训练初期几乎整张网格都参与更新,保证大局的连续性。
迭代轮数我一般取 50 到 100,样本量越大取的轮数越少。判断收敛的标准不是轮数,而是量化误差曲线是否走平。我习惯每 5 轮记录一次量化误差,如果在连续 15 轮里变化小于 1%,就提前停。
学习率 η0 取 0.3 到 0.5 之间。0.5 收敛快但末期容易震荡,0.3 稳一些但需要更多轮。超过 0.7 我基本不用,网格会出现明显的扭曲。
| 参数 | 经验取值 | 主要影响 | 调错的症状 |
|---|---|---|---|
| 网格边长 | √(5·√n) | 分辨率与死神经元比例 | 太小簇粘连,太大网格稀疏 |
| σ0 | 长边的一半 | 全局连续性与收敛速度 | 太小布局碎裂,太大边界模糊 |
| σ 末期 | 0.5 到 1 | 边界清晰度 | 为 0 时出现锯齿孤点 |
| η0 | 0.3 到 0.5 | 收敛速度与稳定性 | 太大震荡,太小收敛慢 |
| 迭代轮数 | 50 到 100 | 收敛质量 | 太少未收敛,太多过拟合噪声 |
3.5 训练完先做三个自检
训练结束不要急着画图,先算三个指标,这三个指标能挡住大部分“看起来能跑其实没学好”的情况。
量化误差(Quantization Error)是每个样本到其 BMU 权重的平均距离,衡量网格对数据的拟合程度。这个值没有绝对标准,要和数据的整体尺度比,一般小于数据标准差的 0.5 倍算健康。
拓扑误差(Topology Error)是检查拓扑保真度的关键指标,算法是:对每个样本,找 BMU 和次近邻 BMU,看这两个神经元在网格上是否相邻。如果相邻说明拓扑保持得好,如果不相邻说明网格在这里发生了折叠。拓扑误差就是不相邻样本的比例,低于 0.1 算优秀,0.1 到 0.2 可接受,超过 0.3 说明网格太小或者 σ 衰减太快。
死神经元比例是从未成为任何样本 BMU 的神经元占比。低于 20% 正常,20% 到 30% 需要关注,超过 30% 就要调参数了,通常是网格太大或者初始化不好。
def evaluate(som, X): qe, te, dead = 0.0, 0, np.zeros((som.gh, som.gw), dtype=bool) for x in X: d2 = ((som.W - x) ** 2).sum(axis=2) flat = d2.ravel() order = np.argsort(flat) b1 = np.unravel_index(order[0], (som.gh, som.gw)) b2 = np.unravel_index(order[1], (som.gh, som.gw)) qe += np.sqrt(flat[order[0]]) dead[b1] = True if max(abs(b1[0] - b2[0]), abs(b1[1] - b2[1])) > 1: te += 1 return qe / len(X), te / len(X), 1 - dead.mean()4. 可视化:把网格变成能看懂的结构图
4.1 U-Matrix:最常用的那张图
U-Matrix 是 SOM 可视化的主力图,逻辑很简单:对网格上的每个神经元,算它和上下左右邻居的权重距离,取平均,把这个平均值画成热力图。距离大说明这个位置是不同簇的交界,距离小说明处在簇的内部。
def u_matrix(som): W, gh, gw = som.W, som.gh, som.gw U = np.zeros((gh, gw)) for i in range(gh): for j in range(gw): ns = [] if i > 0: ns.append(W[i - 1, j]) if i < gh - 1: ns.append(W[i + 1, j]) if j > 0: ns.append(W[i, j - 1]) if j < gw - 1: ns.append(W[i, j + 1]) U[i, j] = np.mean([np.linalg.norm(W[i, j] - v) for v in ns]) return U读图的方法我总结成一句话:亮的是山脊,暗的是山谷,山脊就是簇边界。如果你看到几条连续的亮线把整张图分割成几个暗区,那就说明数据里确实有几团;如果整张图亮度均匀,要么数据本身没结构,要么网格太小把所有东西压平了。
边界神经元的邻居数量少,这里用的是可用邻居的平均值,比补零或者只算内部更稳。还有一种变体是取邻居距离的最小值而不是平均值,得到的图边界更锐利,适合簇之间间隔明显的场景;取最大值则会突出噪声点,一般不用。我自己 90% 的情况用平均。
4.2 命中图:每个神经元接了多少样本
命中图统计每个神经元作为 BMU 的次数,反映样本在网格上的密度分布:
def hit_map(som, X): H = np.zeros((som.gh, som.gw), dtype=int) for x in X: i, j = som.bmu(x) H[i, j] += 1 return H单独看命中图信息量有限,它要和 U-Matrix 叠着看才有意思。我的组合读法是:U-Matrix 亮且命中高,说明这里是簇边界的密集过渡带,往往是最值得关注的地方,因为处于状态迁移中的样本都堆在这里;U-Matrix 暗且命中高,说明是簇的核心区域,特征最典型的样本在这;U-Matrix 暗且命中为 0,是死神经元或者数据稀疏区,可以直接忽略。
命中图还有一个实用场景是异常检测。异常样本的 BMU 往往落在网格边缘或者死神经元附近,因为它们的特征组合在训练数据里罕见。把命中数为 0 或 1 的格点标出来,再回查落在这里的样本,经常能挖出一些脏数据或者真正的异常。
4.3 分量平面:每个特征在网格上的分布
分量平面是解释性最强的一类图。对输入的每一维,把网格上每个神经元的这一维权重画成热力图,就得到一张分量平面。d 维特征就有 d 张图,拼成一个矩阵看。
这张图的价值在于建立“空间位置”和“业务含义”的对应关系。举个虚构的例子,如果网格横向从左到右“客单价”这一维的权重单调上升,纵向从上到下“下单频次”单调上升,那你就能说:网格的右下角是高客单高频次的核心用户,左上角是低客单低频次的边缘用户,对角线方向就是从边缘到核心的过渡路径。这种解释业务方一听就懂,比给一堆簇号有用得多。
import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 3, figsize=(13, 8)) axes = axes.ravel() for k in range(X.shape[1]): im = axes[k].imshow(som.W[..., k], cmap='coolwarm') axes[k].set_title(f'Component {k}') plt.colorbar(im, ax=axes[k]) axes[-1].axis('off') plt.tight_layout() plt.savefig('component_planes.png', dpi=150)提示:matplotlib 默认字体不带中文,图标题用英文能避免出现方框乱码。如果一定要中文,得先设置字体族,不同系统上的字体名不一样,跨机器复现时容易出问题,我一般直接用英文标题。
4.4 把簇标签贴到网格上
到这一步,网格已经有结构了,但还没有“簇”这个概念。补上这一步的做法是在网格上做二次聚类:把每个神经元的权重向量当作新样本,跑一遍层次聚类(ward 连接),或者直接在 U-Matrix 上做阈值切分得到连通区域。
层次聚类的做法更稳,因为神经元数量不多(一两百个),计算量很小,而且可以通过树状图决定切几刀。切完之后每个神经元有了簇标签,把标签画回网格,再叠加命中图,就是你给业务方看的那张“地图行政区划图”。
from scipy.cluster.hierarchy import linkage, fcluster flat_W = som.W.reshape(-1, X.shape[1]) Z = linkage(flat_W, method='ward') labels = fcluster(Z, t=4, criterion='maxclust').reshape(som.gh, som.gw)阈值 t 怎么定,我的经验是结合 U-Matrix 来看:先在 U-Matrix 上数出明显的暗区个数,把它作为簇数的初值,再微调。如果切出来的簇在网格上是零散的碎片,说明切多了;如果两个视觉上明显分开的暗区被合并成一个,说明切少了。
5. 参数调优与问题排查实录
5.1 常见问题速查表
SOM 的调试问题比较集中,我把遇到过的情况整理成表,方便对照排查:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| U-Matrix 整张图亮度均匀 | 网格太小或数据本身无结构 | 先增大网格,再检查数据是否有可分性 |
| 大量神经元从未被激活 | 初始化离数据太远、σ 太小 | 改用样本采样初始化,增大 σ0 |
| 每次训练结果差别很大 | 随机初始化 + 网格偏小 | 固定 seed,或改用 PCA 初始化 |
| 网格出现明显扭曲折叠 | 学习率过大或 σ 衰减过快 | η0 降到 0.3,τ 放大到总轮数的 1/3 |
| 簇边界模糊分不开 | σ 末期值太大 | 加第二阶段训练,σ 固定为 1 |
| 某个特征主导了布局 | 没做标准化 | 逐维 z-score,必要时先做对数变换 |
| 量化误差下降后又上升 | 后期学习率仍偏大 | 检查衰减曲线,末期 η 应低于 0.02 |
| 新样本全部映射到同一角落 | 训练数据与线上数据分布不一致 | 检查特征口径,做 PSI 对比 |
5.2 三个踩过的坑
第一个坑是贪大网格。刚开始用 SOM 的时候总觉得网格越大分辨率越高,上来就给 30×30,结果样本才两千个,900 个神经元里有一大半从没被匹配过,U-Matrix 上大片大片的平坦区域,看着像一块补丁布。后来按 5·√n 的公式算,两千样本对应 5·44.7 ≈ 223 个神经元,15×15 就够了,死神经元比例从 40% 多降到 12%,图的细节反而更清楚了。网格不是越大越好,它和样本量是配套的。
第二个坑是忘了给新样本走同一套预处理。训练时做了 z-score,上线时新样本直接用原始值算距离,结果全部映射到网格边缘。这个问题的根源是把预处理当成了一次性的操作,没有固化成流水线。后来我把均值、标准差、裁剪阈值和模型权重一起序列化保存,新样本进来先过同一套变换再算 BMU,问题就没了。凡是距离类的算法,预处理一致性都是必查项。
第三个坑是把 SOM 当分类器用。早期有个需求是做设备故障分类,我图省事直接拿 SOM 的簇标签当预测结果,准确率一直上不去。后来想明白了,SOM 是无监督的,它的簇反映的是数据分布的密度结构,不是类别边界。正确做法是把 SOM 输出的网格坐标或者 BMU 索引当作特征,再喂给一个有监督的分类器,或者用带标签的样本给每个神经元投票。改完之后准确率提了十几个点。这个认知很关键:SOM 是特征工程和可视化工具,不是分类器。
注意:如果你需要给神经元打标签,用“每个神经元上落入样本的多数类”这种方式投票,样本量少的神经元标签不可信,建议设置最小样本阈值(比如少于 5 个样本的神经元不参与投票),否则噪声会污染整张地图。
6. 落地场景与工程化注意事项
6.1 场景清单与适配度
SOM 在实际业务里的适用面比很多人想的宽,我按自己的经验排了个序:
用户分群与生命周期地图是最典型的场景。把用户的多维行为特征映射到网格上,网格的连续性能呈现出“活跃度从高到低的渐变”和“新用户到老用户的迁移路径”,这是 k-means 做不到的。某电商团队用这套方法做会员运营,把网格划分成六个区域对应六种运营策略,新用户进来算 BMU 直接落区,不需要每天重跑聚类。
设备状态监测与异常检测也合适。工业设备的传感器数据维度高(振动、温度、电流、转速等),正常状态在特征空间里是一个连续的区域,用 SOM 映射之后会形成一片密集的暗区,故障样本往往落在边缘或者死神经元附近。命中图和 U-Matrix 叠加能直观看到偏离程度,新采集的数据算 BMU 之后看落在哪个区域,就能判断状态是否正常。
文本与 embedding 的主题地图是近几年用得多的方向。把文档向量或者句向量映射到网格上,相邻区域的主题通常相近,形成一张“主题地形图”,很适合做内容库的探索性分析。这里距离度量建议换成余弦,效果比欧氏距离好。
图像颜色量化是 SOM 的经典应用。把一张图的像素颜色当作三维样本训练 SOM,网格上每个神经元的权重就是一个调色板颜色,用网格颜色替换原像素就完成了减色,而且因为是按拓扑排列的,视觉上过渡比 k-means 量化更自然。
还有基因表达分析、工业质检图像分块、网络流量模式识别这些,思路都类似:高维、需要看结构、需要持续映射新样本。
6.2 上线时的几个工程决策
模型持久化只需要存两样东西:权重张量 W 和网格坐标 coords。加上预处理用的均值、标准差、阈值,一个 .npz 文件就够,体积几十 KB,加载几十毫秒,比很多模型轻得多。
新样本映射是 SOM 最大的工程优势。线上服务收到一个样本,做同样的预处理,算它到所有神经元权重的距离,取最近的那个,返回网格坐标。这个过程的复杂度是 O(神经元数量 × 维度),10×10 网格 5 维数据只有 500 次乘加,单次几微秒,完全不需要 GPU,也不需要维护复杂的推理框架。
数据漂移是需要持续关注的点。SOM 训练完之后网格是固定的,如果线上数据分布慢慢偏了,很多样本会映射到网格边缘或者原本的稀疏区。我一般监控两个指标:映射到边缘区域的样本比例,以及平均量化误差。前者超过 15% 或者后者比训练时上涨 30% 以上,就该考虑重训了。重训频率看业务变化速度,用户行为类的一个季度一次比较稳妥。
增量更新是另一种选择。不做全量重训,而是拿新数据以很小的学习率(比如 0.01)、固定的小邻域(σ=1)继续训练几轮,让网格缓慢适应新分布。这种方式适合数据缓慢漂移的场景,但要注意别让网格被新数据带偏太狠,我一般会混入一部分历史数据一起训,比例控制在 7 比 3。
最后提一个容易被忽略的点:SOM 的结果可复现性。同一个数据集、同样的参数,不同的随机种子训出来的网格布局会不一样,虽然拓扑结构相似,但网格的具体朝向可能旋转或者翻转。这在做汇报时会造成困扰,因为上一次的图和这一次对不上。解决办法是固定种子,并且在每次重训后检查网格的朝向是否和上次一致,如果不一致,可以通过翻转权重张量来对齐。这一步不做,业务方会觉得你的模型“每次结果都不一样”,信任度会打折。
我个人在实际操作中的体会是,SOM 这套方法的价值不在于它的降维质量能打赢谁,而在于它能给业务方一张可以反复指着讨论的地图。t-SNE 出的图是一次性的,看完就扔;SOM 训出来的网格是个可以持续叠加信息、持续映射新样本的载体。真正把它的价值发挥出来的场景,往往不是算法本身多先进,而是围绕这张地图建立起来的一套解释习惯——哪个区域代表什么、边界在哪、新样本落在哪、地图的哪个部分最近在变大。这套习惯一旦建立,后面的分析效率会比每次重跑聚类高很多。