简介:这份资源面向机器学习初学者与需要做数据可视化、聚类分析的开发者,提供完整的自组织映射(SOM)算法Python实现。SOM是一种无监督神经网络方法,可将高维数据映射到二维网格并保持拓扑结构,常用于降维、聚类与可视化探索。代码包含网络初始化、BMU查找、权重更新等核心流程,并支持高斯、墨西哥帽、气泡三种邻域函数,以及指数与线性两种衰减策略。资源包共31个文件,以9个py源码、10张png可视化图、5个csv数据集为主,另有md说明与txt依赖清单,压缩包约3.55MB。示例覆盖螺旋数据聚类、鸢尾花降维、RGB颜色聚类及参数对比,输出权重网格、U-Matrix、激活图与训练历史等图像,并给出量化误差、拓扑误差、邻域保持度等评估指标。已有84人学习,适合希望理解SOM原理并快速上手实验的读者。
1. 自组织映射 SOM 算法:无监督神经网络聚类与降维可视化到底在做什么
如果你手上有一批没有标签的高维数据,比如几十维的客户行为特征、上百维的文本向量、上千维的传感器采样,直接扔给 KMeans 往往效果一般,因为欧氏距离在高维空间会变得稀疏而失去区分度。自组织映射 SOM(Self-Organizing Map)就是为这种场景准备的:它是一种无监督的神经网络,能把高维输入映射到一个低维(通常是二维)的规则网格上,同时保留原始数据的拓扑关系——相近的样本会落到相邻的神经元上。这意味着你既拿到了聚类结果,又拿到了一张可以直接看的“数据地图”,降维可视化一步到位。
SOM 和 PCA、t-SNE 的区别在于:PCA 是线性投影,t-SNE 擅长局部结构但全局拓扑容易失真,而 SOM 通过竞争学习让整个网格自适应地铺展在数据流形上,既能看全局分布,又能放大局部密度差异。它适合谁?适合做探索性数据分析的工程师、需要给业务方展示“数据长什么样”的算法同学,以及想用 Python 快速跑通神经网络聚类和降维可视化的入门者。下面从原理到代码,把这条路走通。
2. SOM 的竞争学习机制与 Python 实现选型
2.1 权重向量、竞争与邻域更新:三个核心动作
SOM 的网络结构很简单:一个二维的神经元网格,每个神经元持有一个和输入维度相同的权重向量。训练时对每个输入样本做三件事。第一,竞争:计算样本与所有神经元权重的距离,距离最小的那个神经元获胜,称为 BMU(Best Matching Unit)。第二,协作:不只是更新 BMU,还更新它周围邻域内的神经元,邻域半径随训练轮次衰减。第三,自适应:更新公式为w(t+1) = w(t) + α(t) * h(t) * (x - w(t)),其中 α 是学习率,h 是邻域函数(通常是高斯核),两者都随迭代衰减。
这个机制的关键在于邻域函数:早期邻域很大,整个网格一起向数据靠拢,形成粗略的全局拓扑;后期邻域收缩到几乎只有 BMU 自己,权重向量精细地贴合局部样本分布。学习率和邻域半径的衰减策略直接决定最终地图质量,这也是后面调参的重点。
2.2 用 MiniSom 还是手写 NumPy:选型对比
Python 生态里做 SOM 有几个选择。MiniSom 是最轻量的库,纯 NumPy 实现,安装无依赖负担,API 简洁,适合快速验证和中小规模数据。SOMPY 功能更全但维护活跃度一般。sklearn 没有内置 SOM,所以不要指望from sklearn.som import。如果你要深入理解算法或做定制化邻域函数,手写 NumPy 版本反而更可控。
| 方案 | 安装方式 | 适合场景 | 自定义难度 |
|---|---|---|---|
| MiniSom | pip install minisom | 快速验证、中小数据、教学 | 低,可继承重写 |
| 手写 NumPy | 无需安装 | 理解原理、定制邻域/衰减 | 高,完全可控 |
| SOMPY | pip install sompy | 需要内置可视化 | 中 |
我一般先用 MiniSom 跑通看效果,如果邻域策略需要改或者要嵌入到已有训练管线里,再换成手写版本。下面两节分别给出可复现的代码。
2.3 手写 NumPy 版 SOM:最小可运行代码
import numpy as np def som_train(X, grid_h=10, grid_w=10, epochs=100, lr0=0.5, sigma0=None): """ X: (n_samples, n_features) 输入数据 grid_h, grid_w: 神经元网格尺寸 epochs: 训练轮数 lr0: 初始学习率 sigma0: 初始邻域半径,默认取网格较大边的一半 """ n_samples, n_features = X.shape # 初始化权重:从数据分布中随机采样,比全零或纯随机更稳 idx = np.random.choice(n_samples, grid_h * grid_w, replace=False) weights = X[idx].reshape(grid_h, grid_w, n_features).copy() if sigma0 is None: sigma0 = max(grid_h, grid_w) / 2.0 # 预计算神经元坐标,用于邻域距离 coords = np.array([[i, j] for i in range(grid_h) for j in range(grid_w)]) for epoch in range(epochs): # 学习率和邻域半径指数衰减 lr = lr0 * np.exp(-epoch / epochs) sigma = sigma0 * np.exp(-epoch / epochs) # 每个 epoch 打乱样本顺序 for x in X[np.random.permutation(n_samples)]: # 竞争:找 BMU diff = weights.reshape(-1, n_features) - x # (grid_h*grid_w, n_features) dist = np.linalg.norm(diff, axis=1) bmu_idx = np.argmin(dist) bmu_coord = coords[bmu_idx] # 协作:高斯邻域 d2 = np.sum((coords - bmu_coord) ** 2, axis=1) h = np.exp(-d2 / (2 * sigma ** 2)) # 自适应:更新所有权重 h = h.reshape(grid_h, grid_w, 1) weights += lr * h * (x - weights) return weights # 生成测试数据:三个高斯簇 np.random.seed(42) c1 = np.random.randn(200, 2) + np.array([0, 0]) c2 = np.random.randn(200, 2) + np.array([5, 5]) c3 = np.random.randn(200, 2) + np.array([0, 5]) X = np.vstack([c1, c2, c3]) weights = som_train(X, grid_h=10, grid_w=10, epochs=100) print("训练完成,权重形状:", weights.shape)这段代码把竞争、协作、自适应三个动作完整实现了一遍。几个参数需要说明:lr0=0.5是初始学习率,数据标准化后这个值通常够用,如果数据尺度大可以降到 0.1;sigma0默认取网格较大边的一半,保证早期邻域覆盖足够广;epochs=100对几百到几千样本够用,样本上万时建议加到 200 以上。注意权重初始化从数据中随机采样,这比全零初始化收敛更快,也避免了所有神经元初始状态相同导致的对称性问题。
2.4 MiniSom 版:十行代码跑通聚类与可视化
from minisom import MiniSom import numpy as np import matplotlib.pyplot as plt # 沿用上面的 X som = MiniSom(x=10, y=10, input_len=2, sigma=1.5, learning_rate=0.5) som.random_weights_init(X) som.train_random(X, num_iteration=5000) # 绘制 U-Matrix(距离矩阵),颜色越深表示神经元间距离越大 plt.figure(figsize=(8, 6)) plt.pcolor(som.distance_map().T, cmap='bone_r') plt.colorbar() # 把每个样本映射到 BMU 并标在图上 markers = ['o', 's', 'D'] colors = ['r', 'g', 'b'] for i, x in enumerate(X): w = som.winner(x) plt.plot(w[0] + 0.5, w[1] + 0.5, markers[i // 200], markerfacecolor='None', markeredgecolor=colors[i // 200], markersize=8, markeredgewidth=1.5) plt.title('SOM U-Matrix with Sample Mapping') plt.show()MiniSom 的distance_map()返回的是每个神经元到其邻居的平均距离,这就是 U-Matrix。颜色深的区域是簇之间的边界,颜色浅的区域是簇内部。样本映射用som.winner(x)拿到 BMU 坐标,叠加在 U-Matrix 上就能直观看到三个高斯簇被分到了地图的不同区域。sigma=1.5和learning_rate=0.5是 MiniSom 的推荐起点,num_iteration=5000对 600 个样本足够,样本量增大时按比例增加。
3. 把 SOM 用到真实数据:标准化、网格尺寸与量化误差
3.1 数据标准化:不做这一步后面全是玄学
SOM 基于欧氏距离,如果某个特征的量纲是另一个的几百倍,距离计算会被它主导,其他特征等于没参与。常见做法是 Z-score 标准化,让每个特征均值为 0、方差为 1。对于有偏分布的特征,可以先做对数变换再标准化。注意:标准化参数必须从训练集计算,然后应用到验证集和测试集,否则会引入数据泄漏。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 如果特征有偏,先做 log1p 再标准化 X_log = np.log1p(np.abs(X)) * np.sign(X) X_log_scaled = StandardScaler().fit_transform(X_log)fit_transform在训练集上同时计算均值和方差并转换,验证集只能用transform。这一步看起来简单,但我见过太多人直接把原始数据扔进 SOM,然后抱怨“聚类结果不稳定”——十有八九是量纲问题。
3.2 网格尺寸怎么定:经验公式与量化误差曲线
网格太小,多个簇会被挤到同一个神经元;网格太大,训练慢且地图稀疏。经验规则是神经元数量约为样本量的平方根到五倍平方根之间。比如 600 个样本,sqrt(600)≈24,网格可以取 5x5 到 10x10。更靠谱的做法是画量化误差(Quantization Error)曲线:量化误差是每个样本到其 BMU 权重的平均距离,网格越大误差越小,但边际收益递减,拐点就是合适的尺寸。
def quantization_error(som, X): """计算平均量化误差""" qe = 0.0 for x in X: w = som.winner(x) qe += np.linalg.norm(x - som.get_weights()[w[0], w[1]]) return qe / len(X) # 测试不同网格尺寸 for size in [5, 8, 10, 15, 20]: som = MiniSom(size, size, input_len=X_scaled.shape[1], sigma=1.5, learning_rate=0.5) som.random_weights_init(X_scaled) som.train_random(X_scaled, num_iteration=5000) qe = quantization_error(som, X_scaled) print(f"网格 {size}x{size}, 量化误差: {qe:.4f}")量化误差随网格增大而下降,但当它下降变缓时,再增大网格只是增加计算量而不提升表达能力。我一般选误差下降曲线拐点前一到两档的尺寸,兼顾效果和速度。
3.3 拓扑误差:比量化误差更能反映地图质量
量化误差只看样本到 BMU 的距离,不关心 BMU 之间是否相邻。拓扑误差(Topographic Error)衡量的是:样本的第一 BMU 和第二 BMU 在网格上是否相邻。如果不相邻,说明地图的拓扑保持得不好。计算方式是统计不相邻的比例,越低越好。
def topographic_error(som, X): """计算拓扑误差:第一和第二 BMU 不相邻的样本比例""" weights = som.get_weights() grid_h, grid_w = weights.shape[:2] te = 0 for x in X: # 计算到所有神经元的距离 dists = np.linalg.norm( weights.reshape(-1, weights.shape[-1]) - x, axis=1) sorted_idx = np.argsort(dists) bmu1 = np.unravel_index(sorted_idx[0], (grid_h, grid_w)) bmu2 = np.unravel_index(sorted_idx[1], (grid_h, grid_w)) # 判断是否相邻(曼哈顿距离为 1) if abs(bmu1[0] - bmu2[0]) + abs(bmu1[1] - bmu2[1]) > 1: te += 1 return te / len(X)拓扑误差低于 0.1 通常说明地图质量不错。如果偏高,可以增大初始邻域半径或增加训练轮数,让邻域收缩更平滑。
4. 踩坑记录:SOM 训练中五个高频翻车现场
4.1 所有样本映射到同一个神经元
现象:训练完发现大部分样本的 BMU 是同一个,地图上只有一个区域有映射。原因通常是学习率太大或邻域半径衰减太快,导致权重向量还没来得及铺开就锁死了。解决:把lr0降到 0.1 到 0.3,sigma0设为网格较大边的一半以上,并确保epochs足够让衰减走完。另外检查数据是否已经标准化。
4.2 地图出现“死神经元”
现象:某些神经元从未成为任何样本的 BMU,权重一直停在初始值。原因是初始化时这些神经元恰好落在数据稀疏区,或者邻域更新时被“遗忘”。解决:改用从数据中采样的初始化方式,或者训练结束后统计每个神经元的命中次数,对死神经元做二次初始化再补训几轮。MiniSom 的random_weights_init就是从数据中采样,比纯随机好很多。
4.3 聚类结果每次跑都不一样
现象:同样的数据和参数,两次运行得到的聚类边界差异明显。SOM 本身是随机算法,初始化不同结果会有波动,但如果波动大到影响结论,说明参数设置有问题。解决:固定随机种子,增加训练轮数让收敛更充分,或者用多次运行取量化误差最小的那次。如果数据本身簇结构不明显,SOM 的结果本来就会不稳定,这时候要考虑是不是不该用聚类。
4.4 高维数据下 U-Matrix 看不出结构
现象:输入维度上百维时,U-Matrix 一片模糊,看不出明显的簇边界。原因是高维空间中距离集中效应导致神经元间距离差异被压缩。解决:先用 PCA 降到 10 到 30 维再喂给 SOM,既保留了主要方差又缓解了距离集中。注意 PCA 要在标准化之后做,且降维后的维度不要低于 5,否则会丢失太多信息。
4.5 把 SOM 当分类器用
现象:有人拿 SOM 的 BMU 标签直接当预测结果,发现准确率很低。SOM 是无监督聚类,它不优化分类边界,BMU 只代表“最相似的神经元”,不代表类别。解决:如果要做分类,用 SOM 做特征提取或降维,把 BMU 坐标或权重向量作为新特征喂给有监督分类器;或者用学习向量量化(LVQ)在 SOM 基础上做有监督微调。
5. 进阶技巧:用 SOM 命中矩阵做聚类归组与可视化增强
5.1 命中矩阵与聚类归组
训练完 SOM 后,每个神经元有一个命中次数(多少样本以它为 BMU)。命中矩阵本身就是一个降维后的密度图,可以直接用来做聚类:对命中矩阵做层次聚类或 KMeans,把相邻的高密度神经元归为一组,就得到了样本的簇标签。这比直接在原始高维空间聚类更稳,因为 SOM 已经做了拓扑平滑。
from scipy.cluster.hierarchy import linkage, fcluster # 构建命中矩阵 hits = np.zeros((10, 10)) for x in X_scaled: w = som.winner(x) hits[w] += 1 # 对命中矩阵做层次聚类 # 把 10x10 展平成 100 个点,每个点的特征是其命中次数 flat_hits = hits.reshape(-1, 1) Z = linkage(flat_hits, method='ward') clusters = fcluster(Z, t=3, criterion='maxclust') cluster_map = clusters.reshape(10, 10) # 可视化聚类结果 plt.figure(figsize=(8, 6)) plt.pcolor(cluster_map, cmap='Set3') plt.colorbar() plt.title('SOM Hit-Matrix Clustering') plt.show()这里用层次聚类对命中矩阵分组,t=3表示期望分成 3 簇。实际使用时可以用轮廓系数或肘部法确定簇数。归组后,每个样本的簇标签就是其 BMU 所在神经元的簇标签。这个方法的好处是簇的形状不受限于球形,SOM 的拓扑保持让任意形状的簇都能被捕捉到。
5.2 用 BMU 坐标做二维散点可视化
除了 U-Matrix,另一种直观的可视化是把每个样本的 BMU 坐标(网格上的 x、y)当作二维嵌入,用散点图画出来,颜色用真实标签或聚类标签。这样得到的图和 t-SNE 类似,但计算量小得多,而且新样本可以直接通过som.winner()映射进来,不需要重新训练。
# 用 BMU 坐标做散点图 bmu_coords = np.array([som.winner(x) for x in X_scaled]) plt.figure(figsize=(8, 6)) plt.scatter(bmu_coords[:, 0] + np.random.randn(len(X)) * 0.1, bmu_coords[:, 1] + np.random.randn(len(X)) * 0.1, c=np.array([0]*200 + [1]*200 + [2]*200), cmap='viridis', alpha=0.6, s=30) plt.title('SOM BMU Coordinates as 2D Embedding') plt.xlabel('Grid X') plt.ylabel('Grid Y') plt.colorbar(label='True Cluster') plt.show()加一点随机抖动是为了避免同一点上多个样本完全重叠。这张图能直接看出三个簇在网格上的分布,如果簇之间有明显的空白带,说明 SOM 成功分开了它们。
5.3 增量映射与新样本处理
SOM 训练好后,新样本不需要重新训练整个网络,直接调用som.winner(new_x)就能拿到 BMU 坐标,进而得到簇标签或二维嵌入。这是 SOM 相比 t-SNE 的一个实用优势:t-SNE 没有自然的 out-of-sample 扩展,而 SOM 有。注意新样本必须用训练时的标准化参数做同样的预处理,否则距离计算会偏。
我自己的习惯是:每次做完 SOM 训练,先把量化误差和拓扑误差记下来,作为后续调参的基线;然后把命中矩阵和 BMU 散点图各存一份,方便和业务方沟通时直接展示。这套流程在客户分群、异常检测、文本主题探索里都跑通过,最深的教训是——标准化和网格尺寸这两件事没做对,后面所有可视化都是自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取