简介:这份资源提供完整的自组织映射(SOM)算法Python实现,面向机器学习初学者及需要聚类、降维与可视化实践的开发者。SOM作为经典无监督神经网络,可将高维数据映射到二维网格并保持拓扑结构,代码覆盖网络初始化、BMU查找、权重更新等核心流程,并支持高斯、墨西哥帽、气泡三种邻域函数与指数、线性两种衰减策略。压缩包共31个文件,约3.55MB,以9个py源码模块为主,辅以10张png可视化结果、5份csv示例数据集及md说明文档,结构清晰便于按模块阅读。示例涵盖螺旋数据聚类、鸢尾花降维、RGB颜色聚类与参数比较,并输出权重网格、U-Matrix、激活图及训练历史等图像,同时提供量化误差、拓扑误差、邻域保持度等评估指标。已有84人学习,适合希望理解SOM原理并快速上手实验的读者。
1. 自组织映射SOM算法:为什么它能在无标签数据里画出拓扑地图
手上有一批客户行为数据,几十个维度,没有标签,K-Means 跑出来的簇每次随机种子一变就换一批成员,PCA 降维散点图挤成一团看不出结构。这种场景我遇到过好几次,最后能同时给出「聚类结果 + 二维可视化 + 拓扑保持」的方案,自组织映射 SOM 是性价比最高的一个。SOM 属于无监督神经网络,和普通反向传播网络不同,它用的是竞争学习:输出层每个神经元代表一个原型向量,输入样本进来后找最相似的那个神经元(BMU,最佳匹配单元),然后不只更新它,还按邻域函数把周围的神经元一起往样本方向拉。训练结束后,高维空间里相近的样本会落到输出网格上相邻的位置,这张网格本身就是一张可解释的拓扑地图。用 Python 实现 SOM 不需要深度学习框架,NumPy 加 Matplotlib 就能跑通,适合做数据分析与可视化的从业者快速验证想法。这一篇从算法原理讲到可复现代码,再到参数调优和踩坑记录,目标是让你照着敲完就能在自己的数据上跑出结果。
2. SOM 的竞争学习机制:权重更新、邻域函数与网格拓扑
2.1 从 BMU 到邻域衰减:一次迭代到底发生了什么
SOM 的训练循环比反向传播简单得多,但每一步的几何含义需要先理清楚。假设输出层是一个 10×10 的网格,共 100 个神经元,每个神经元持有一个和输入同维度的权重向量。一个样本 x 进入后,计算它到所有 100 个权重向量的距离,距离最小的那个就是 BMU。接下来更新公式是:
w_i(t+1) = w_i(t) + α(t) · h(i, bmu, t) · (x - w_i(t))
这里 α(t) 是学习率,随迭代衰减;h(i, bmu, t) 是邻域函数,通常用高斯核,形式为 exp(-d²/(2σ(t)²)),d 是神经元 i 到 BMU 在网格上的距离,σ(t) 是邻域半径,同样随迭代收缩。这个公式的含义是:BMU 自己更新幅度最大,网格上离 BMU 越远的神经元更新幅度越小,超过一定半径基本不动。随着 σ 从初始的大值(比如网格边长的一半)衰减到接近 0,训练从「粗调全局拓扑」过渡到「精调局部原型」。
理解这一点很关键,因为后面调参时你会发现,σ 的衰减策略比学习率更影响最终拓扑质量。我一般把 σ 初始值设为 max(网格宽, 网格高)/2,最终值设到 0.5 左右,保证最后阶段只有 BMU 自己在微调。
2.2 网格形状与拓扑类型:选矩形还是六边形
输出层网格有两种常见拓扑:矩形(rectangular)和六边形(hexagonal)。矩形网格每个神经元有上下左右四个直接邻居,六边形网格每个神经元有六个邻居,邻域更均匀,不会出现矩形网格里对角线方向邻居被低估的问题。如果你的可视化目的是让簇边界更清晰,六边形网格通常效果更好;如果只是快速验证,矩形网格实现更简单。
网格尺寸的选择有个经验公式:神经元数量约为样本数量的 5 到 20 倍再开平方。比如 1000 个样本,取 5 倍是 5000,开平方约 70,那网格可以取 8×8 到 10×10。网格太小会导致多个真实簇被压缩到同一个神经元,网格太大则训练慢且容易过拟合噪声。我一般先用 10×10 跑一版看量化误差和拓扑误差,再决定要不要调整。
2.3 量化误差与拓扑误差:两个必须监控的指标
量化误差(Quantization Error)是所有样本到其 BMU 权重向量的平均距离,衡量网格对数据的拟合程度,越小说明原型向量越贴近样本。拓扑误差(Topographic Error)是找每个样本的 BMU 和次优 BMU,看它们在网格上是否相邻,不相邻的比例就是拓扑误差,衡量拓扑保持的好坏。这两个指标要一起看:量化误差低但拓扑误差高,说明网格把相似样本映射到了不相邻的位置,可视化就不可信了。
训练过程中我会每 100 轮记录一次这两个值,正常情况两者都应该下降并趋于平稳。如果量化误差还在降但拓扑误差开始上升,通常是邻域半径衰减太快,需要放慢 σ 的衰减速度。
3. 用 NumPy 从零实现 SOM:初始化、训练循环与可视化
3.1 数据标准化与权重初始化
SOM 对输入尺度敏感,因为距离计算是欧氏的,量纲大的特征会主导 BMU 选择。所以第一步必须做标准化,常用 Z-score 或 Min-Max。权重初始化有两种做法:随机从样本中选,或者用 PCA 前两个主成分方向做线性初始化。后者能让训练更快收敛,因为初始权重已经大致铺在数据的主要变化方向上。
import numpy as np from sklearn.preprocessing import StandardScaler # 假设 X 是原始数据,shape = (n_samples, n_features) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 网格参数 grid_w, grid_h = 10, 10 n_neurons = grid_w * grid_h n_features = X_scaled.shape[1] # PCA 线性初始化:让初始权重沿前两个主成分方向铺开 from sklearn.decomposition import PCA pca = PCA(n_components=2) pcs = pca.fit_transform(X_scaled) # 把前两个主成分的 min-max 范围映射到网格坐标 pc1_min, pc1_max = pcs[:, 0].min(), pcs[:, 0].max() pc2_min, pc2_max = pcs[:, 1].min(), pcs[:, 1].max() weights = np.zeros((grid_h, grid_w, n_features)) for i in range(grid_h): for j in range(grid_w): # 网格坐标归一化后映射到主成分空间,再逆变换回原始特征空间 ratio1 = i / (grid_h - 1) if grid_h > 1 else 0.5 ratio2 = j / (grid_w - 1) if grid_w > 1 else 0.5 pc1 = pc1_min + ratio1 * (pc1_max - pc1_min) pc2 = pc2_min + ratio2 * (pc2_max - pc2_min) weights[i, j] = pca.inverse_transform(np.array([[pc1, pc2]]))[0]这段代码的关键在最后一行:pca.inverse_transform把二维主成分坐标还原回标准化后的特征空间,这样每个神经元的初始权重就是一个合理的「数据原型」。相比纯随机初始化,PCA 初始化能让拓扑误差在训练早期就降得更低。参数上grid_w和grid_h控制网格分辨率,n_features必须和X_scaled.shape[1]一致,否则距离计算会广播出错。
3.2 训练循环:学习率和邻域半径的衰减实现
训练循环的核心是三重逻辑:找 BMU、算邻域、更新权重。下面是一个完整可运行的版本,包含学习率和 σ 的指数衰减。
def train_som(X, weights, n_epochs=1000, lr_start=0.5, lr_end=0.01, sigma_start=None, sigma_end=0.5, seed=42): rng = np.random.default_rng(seed) grid_h, grid_w, n_features = weights.shape if sigma_start is None: sigma_start = max(grid_h, grid_w) / 2.0 # 预计算网格上每个神经元的坐标,用于邻域距离 grid_coords = np.array([[i, j] for i in range(grid_h) for j in range(grid_w)]) n_samples = X.shape[0] for epoch in range(n_epochs): # 指数衰减:当前学习率和 sigma progress = epoch / n_epochs lr = lr_start * (lr_end / lr_start) ** progress sigma = sigma_start * (sigma_end / sigma_start) ** progress # 每个 epoch 打乱样本顺序 indices = rng.permutation(n_samples) for idx in indices: x = X[idx] # 1. 找 BMU:计算到所有神经元权重的距离 diff = weights.reshape(-1, n_features) - x # (n_neurons, n_features) dists = np.linalg.norm(diff, axis=1) bmu_flat = np.argmin(dists) bmu_i, bmu_j = divmod(bmu_flat, grid_w) # 2. 算邻域权重:高斯核 d2 = ((grid_coords[:, 0] - bmu_i) ** 2 + (grid_coords[:, 1] - bmu_j) ** 2) h = np.exp(-d2 / (2 * sigma ** 2)) # 3. 更新权重 h = h.reshape(grid_h, grid_w, 1) weights += lr * h * (x - weights) return weights逻辑说明:diff那一步把三维权重展平成二维,一次性算出所有神经元的距离,比循环快很多。divmod把展平索引还原成网格坐标。邻域权重h用高斯核,sigma越小,h越集中在 BMU 附近。更新时h被 reshape 成(grid_h, grid_w, 1),利用 NumPy 广播直接对整个权重张量做更新,避免逐神经元循环。
参数说明:n_epochs一般取样本数的 10 到 50 倍,1000 到 5000 是常见范围;lr_start取 0.5 左右,lr_end取 0.01;sigma_start默认取网格最大边长的一半,sigma_end取 0.5。如果你的数据簇很密集,可以把sigma_end调到 0.3 让局部更精细。
3.3 用 Matplotlib 画出 U-Matrix 和命中图
训练完之后,最直观的可视化是 U-Matrix(统一距离矩阵):每个神经元的值是它到邻居权重的平均距离,距离大的地方就是簇边界,画成热力图后深色区域自然分隔开不同簇。另一个是命中图(Hit Map),统计每个神经元被多少个样本选为 BMU,反映数据在网格上的分布密度。
import matplotlib.pyplot as plt def plot_u_matrix(weights): grid_h, grid_w, _ = weights.shape u_matrix = np.zeros((grid_h, grid_w)) for i in range(grid_h): for j in range(grid_w): neighbors = [] for di, dj in [(-1,0),(1,0),(0,-1),(0,1)]: ni, nj = i + di, j + dj if 0 <= ni < grid_h and 0 <= nj < grid_w: neighbors.append(np.linalg.norm(weights[i,j] - weights[ni,nj])) u_matrix[i,j] = np.mean(neighbors) if neighbors else 0 plt.figure(figsize=(6,5)) plt.imshow(u_matrix, cmap='bone_r') plt.colorbar(label='avg distance to neighbors') plt.title('U-Matrix') plt.show() def plot_hit_map(X, weights): grid_h, grid_w, n_features = weights.shape flat_w = weights.reshape(-1, n_features) hit = np.zeros(grid_h * grid_w) for x in X: dists = np.linalg.norm(flat_w - x, axis=1) hit[np.argmin(dists)] += 1 plt.figure(figsize=(6,5)) plt.imshow(hit.reshape(grid_h, grid_w), cmap='viridis') plt.colorbar(label='hit count') plt.title('Hit Map') plt.show()U-Matrix 里亮色带就是簇之间的「山脊」,命中图里高亮区域对应数据密集区。两张图叠着看,如果命中图的高密度区被 U-Matrix 的亮带清晰隔开,说明聚类结构可信。如果命中图一片均匀,可能是网格太大或数据本身没有明显簇结构。
4. 参数调优与聚类结果量化:学习率、网格尺寸、轮廓系数
4.1 学习率和邻域半径的三种衰减策略对比
衰减策略直接影响收敛质量。常见的有线性衰减、指数衰减和反比例衰减。线性衰减实现最简单,但后期学习率下降太慢,容易在最优解附近震荡;指数衰减前期下降快、后期平缓,是我最常用的;反比例衰减形式是 lr = lr_start / (1 + epoch * decay),适合训练轮数很多的情况。
| 衰减策略 | 公式 | 适用场景 | 注意点 |
|---|---|---|---|
| 线性 | lr = lr_start - (lr_start-lr_end)*t/T | 轮数少、快速验证 | 后期震荡 |
| 指数 | lr = lr_start * (lr_end/lr_start)^(t/T) | 通用首选 | 前期下降过快需调 lr_start |
| 反比例 | lr = lr_start / (1 + decay*t) | 轮数多、精细调优 | decay 需手动试 |
σ 的衰减建议比学习率更慢一些,因为拓扑结构一旦被破坏很难恢复。我一般让 σ 在前 30% 的轮数里从初始值降到 1.0,后 70% 从 1.0 缓慢降到 0.5。
4.2 网格尺寸怎么定:从量化误差曲线找拐点
网格尺寸不是越大越好。我通常跑一组实验:网格从 5×5 到 20×20,每个尺寸训练完后记录量化误差和拓扑误差,画成曲线。量化误差会随网格增大单调下降,但下降速度会在某个点明显变缓,那个拐点就是性价比最高的尺寸。拓扑误差则可能先降后升,因为网格太大时神经元之间距离拉远,邻域更新覆盖不到,拓扑保持反而变差。
实际操作中,如果样本量在 500 到 2000 之间,8×8 到 12×12 基本够用。样本超过 5000,可以考虑 15×15 以上,但训练时间会明显增加,因为每个样本都要和所有神经元算距离。
4.3 用轮廓系数验证 SOM 聚类质量
SOM 本身不直接输出簇标签,但可以用两种方式得到聚类:一是对权重向量再做一次 K-Means,二是用 U-Matrix 的亮带做分割。得到标签后,用轮廓系数(Silhouette Score)评估。轮廓系数范围是 -1 到 1,越接近 1 说明簇内紧凑、簇间分离。
from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score # 对训练好的权重向量做 K-Means flat_weights = weights.reshape(-1, n_features) kmeans = KMeans(n_clusters=4, random_state=42, n_init=10) neuron_labels = kmeans.fit_predict(flat_weights) # 把每个样本映射到 BMU,再映射到簇标签 flat_w = weights.reshape(-1, n_features) sample_labels = [] for x in X_scaled: bmu = np.argmin(np.linalg.norm(flat_w - x, axis=1)) sample_labels.append(neuron_labels[bmu]) sample_labels = np.array(sample_labels) score = silhouette_score(X_scaled, sample_labels) print(f'Silhouette Score: {score:.3f}')这里n_clusters需要根据 U-Matrix 的亮带数量或业务先验来定。轮廓系数高于 0.5 说明结构清晰,0.3 到 0.5 之间算合理,低于 0.3 就要怀疑数据本身是否适合聚类,或者网格参数需要调整。注意轮廓系数是在标准化后的数据上算的,和原始量纲无关。
5. SOM 实战避坑:从初始化到可视化的 5 个翻车现场
5.1 现象:所有样本落到同一个神经元,命中图只有一个亮点
原因:学习率太大或权重初始化全部相同,导致 BMU 始终是同一个,其他神经元没有机会被拉向数据。解决:检查权重初始化是否有随机性,PCA 初始化时确认主成分范围映射正确;把lr_start降到 0.3 以下,并确认 σ 初始值足够大,让邻域更新能覆盖到其他神经元。
5.2 现象:U-Matrix 全是均匀灰色,看不出簇边界
原因:网格太大而数据簇太少,或者 σ 衰减太快导致邻域更新不足,权重向量之间差异被抹平。解决:减小网格尺寸,把sigma_end从 0.5 调到 1.0 以上,让训练后期仍有适度邻域平滑。另外检查数据标准化是否做了,未标准化的数据会让距离计算失真。
5.3 现象:训练时间随样本量线性增长,10000 条数据跑半小时
原因:每个样本都要和所有神经元算距离,复杂度是 O(n_samples × n_neurons × n_features)。解决:用矩阵运算替代循环,把weights.reshape(-1, n_features)预计算好,每次只做一次矩阵减法;或者用 Mini-batch SOM,每次取一批样本更新,但要注意 batch 太大会退化成批梯度下降,失去竞争学习的局部性。
5.4 现象:拓扑误差先降后升,训练越久反而越差
原因:σ 衰减过快,后期邻域半径接近 0,BMU 附近的神经元不再被拉向同一方向,拓扑结构被破坏。解决:放慢 σ 衰减,让sigma_end不低于 0.5;或者采用「粗调 + 精调」两阶段训练,前 70% 轮数用较大 σ,后 30% 用固定小 σ 只更新 BMU 及其直接邻居。
5.5 现象:轮廓系数很高但可视化上簇混在一起
原因:轮廓系数是在原始高维空间算的,而 SOM 可视化是二维网格,两者不一致说明拓扑保持不好。解决:优先看拓扑误差,如果拓扑误差高,即使轮廓系数好看也不能信。可以尝试六边形网格或增大 σ 初始值,让拓扑结构在训练早期就被正确建立。
6. 把 SOM 用到新数据上:增量映射与批量映射的取舍
训练好的 SOM 模型要应用到新数据,有两种方式。批量映射是把新数据和原始训练数据合并后重新训练,适合数据分布发生明显漂移的场景,但计算成本高。增量映射是固定权重,只对新样本找 BMU 并映射到对应簇,适合在线场景,但要求新数据分布和训练数据一致。
我一般会先做增量映射,同时监控新样本到 BMU 的平均距离。如果这个距离比训练集的量化误差高出 50% 以上,说明新数据分布已经漂移,这时候再触发批量重训练。下面是一个增量映射的封装:
class SOMMapper: def __init__(self, weights, scaler, neuron_labels): self.weights = weights self.scaler = scaler self.neuron_labels = neuron_labels self.flat_w = weights.reshape(-1, weights.shape[-1]) def predict(self, X_new): X_scaled = self.scaler.transform(X_new) labels = [] dists_all = [] for x in X_scaled: d = np.linalg.norm(self.flat_w - x, axis=1) bmu = np.argmin(d) labels.append(self.neuron_labels[bmu]) dists_all.append(d[bmu]) return np.array(labels), np.array(dists_all)scaler必须用训练时 fit 的那个,不能重新 fit,否则标准化尺度不一致。neuron_labels是之前对权重做 K-Means 得到的神经元簇标签。返回的dists_all就是每个新样本到其 BMU 的距离,用来判断分布漂移。
一个我踩过的坑:增量映射时如果新数据里出现了训练集没有的极端值,BMU 可能落到网格边缘的神经元,那个神经元的权重其实并不代表任何真实簇,映射结果就不可信。所以我会额外设一个阈值,距离超过训练集量化误差 3 倍的样本标记为「未知」,不强行归类。
最后说个习惯:每次跑完 SOM,我都会把权重矩阵、scaler、网格参数和量化误差一起存成 npz 文件,下次做增量映射直接加载,不用重新训练。这个后悔药在调试阶段救过我好几次,尤其是当业务方突然要换一批数据验证时,能省掉大量重复训练时间。希望帮到你。
本文还有配套的精品资源,点击获取