简介:这份资源是面向机器学习初学者与数据科学学习者的SOM自组织映射神经网络Python实现项目,聚焦无监督学习中的数据降维、聚类与可视化需求。SOM通过竞争学习将高维输入映射到低维拓扑空间,是理解神经网络竞争机制的经典入门算法,适合具备Python基础、希望动手实践算法原理的读者。压缩包共3个文件,以2个py源码文件和1个md说明文档为主,源码承载网络构建与训练逻辑,文档用于交代项目背景与使用方式,整体约3KB,体量轻便,便于快速通读与调试。目前已有610人学习下载。读者可从中获得完整的SOM实现流程参考,包括权重初始化、最佳匹配单元查找、学习率与邻域半径调整以及结果可视化思路,也可基于源码修改参数、替换数据集,加深对自组织映射训练过程的理解,是入门无监督神经网络的一份实用练手材料。
1. 自组织映射:为什么一张二维网格能“记住”高维数据的形状
把一堆 784 维的手写数字向量丢给一个只有几十个节点的二维网格,训练完之后,相邻节点竟然对应着相似的数字——这件事第一次看到会觉得有点玄学。更反直觉的是:整个训练过程没有任何标签,没人告诉网络“这是 3、那是 7”,它却自己把拓扑结构长了出来。这就是 Self-Organizing Map(SOM,自组织映射)最核心的魔法,也是som-master这个 Python 项目想让你亲手复现的东西。
SOM 解决的是一个很朴素的问题:高维数据没法直接看。你可以算 PCA、算 t-SNE,但它们要么是线性投影,要么只关心局部邻域。SOM 走的是另一条路——它不降维,而是把高维空间“铺”到一张低维网格上,让网格的拓扑关系去逼近数据流形的拓扑关系。搞懂它,你就理解了竞争学习、邻域衰减、拓扑保持这一整套无监督机制是怎么协同工作的。而som-master用main.py+som.py两个文件把这条链路完整摊开,没有黑盒库调用,正好适合拿来拆机制。
2. 竞争学习机制:BMU 是怎么被“选”出来的
2.1 权重向量与输入向量的距离度量
SOM 的每个神经元本质上就是一个和输入同维度的权重向量。假设输入是x ∈ R^n,网格上第i个神经元的权重是w_i ∈ R^n,那么“谁最像这个输入”就退化成了一次最近邻搜索。最常见的度量是欧氏距离:
d_i = ||x - w_i||² = Σ_j (x_j - w_ij)²som.py里通常会用 NumPy 的广播机制一次性算出所有神经元的距离,而不是写双重循环。这是第一个值得注意的实现细节:向量化不是优化,而是让代码结构直接对应数学结构。下面是一段典型的 BMU 查找逻辑:
import numpy as np def find_bmu(self, x): # x: (n_features,) # self.weights: (n_neurons, n_features) diff = self.weights - x # 广播:(n_neurons, n_features) dist_sq = np.sum(diff ** 2, axis=1) # (n_neurons,) return np.argmin(dist_sq) # 返回一维索引diff = self.weights - x这一步利用了 NumPy 的广播:x被自动扩展成(n_neurons, n_features),每一行减去同一个输入向量。axis=1的求和把每个神经元的距离压成一个标量。argmin返回的就是 BMU 的扁平索引。如果你把网格存成二维数组,还需要np.unravel_index把扁平索引还原成(row, col),因为后面算邻域要用到坐标。
提示:距离平方和距离本身在比较时等价,开根号只会增加计算量。很多实现里直接比较平方距离,这是有意的取舍。
2.2 邻域函数:为什么“邻居”也要跟着改
只更新 BMU 是不够的。如果每次只动一个神经元,那 SOM 就退化成了在线 k-means,网格的拓扑结构根本不会形成。SOM 的关键在于:BMU 周围的神经元也按距离远近被不同程度地拉动。这个“程度”由邻域函数决定,最常见的是高斯核:
h_i(t) = exp( -||r_i - r_bmu||² / (2σ(t)²) )其中r_i和r_bmu是神经元在网格上的坐标,σ(t)是随时间衰减的邻域半径。som.py里一般会预计算一张网格坐标矩阵,然后用广播算出每个神经元到 BMU 的网格距离。这里有个容易翻车的点:网格距离和输入空间距离是两回事。邻域函数只看网格坐标,不看权重向量的相似度。这是 SOM 和一般聚类算法最本质的区别——它强制把“空间上的近”和“特征上的近”绑定在一起。
def neighborhood(self, bmu_idx, sigma): # self.grid_coords: (n_neurons, 2) d = np.linalg.norm(self.grid_coords - self.grid_coords[bmu_idx], axis=1) return np.exp(-(d ** 2) / (2 * sigma ** 2))self.grid_coords[bmu_idx]取出 BMU 的二维坐标,np.linalg.norm(..., axis=1)算出所有神经元到它的欧氏距离。返回的h是一个长度等于神经元总数的向量,值域在(0, 1],BMU 自己对应 1。这个向量后面会直接乘到权重更新量上。
2.3 权重更新:学习率和邻域半径的双重衰减
权重更新公式本身很短:
w_i(t+1) = w_i(t) + η(t) · h_i(t) · (x - w_i(t))但真正决定 SOM 能不能收敛的是η(t)和σ(t)的衰减策略。常见做法是线性衰减或指数衰减:
eta = eta0 * (1 - t / max_iter) sigma = sigma0 * np.exp(-t / tau)eta0通常取 0.1 到 0.5,sigma0取网格尺寸的一半左右。衰减的意义在于:训练初期网络需要大范围调整,邻域半径大、学习率高,让整个网格快速铺开;训练后期只做微调,邻域收缩到 BMU 附近,避免已经成型的拓扑被破坏。这个“先粗后细”的节奏是 SOM 能稳定的核心,也是很多人自己实现时最容易忽略的地方——如果sigma不衰减,网格会一直抖动,最后根本收敛不了。
3. 从som.py看工程实现:数据结构与训练循环
3.1 权重矩阵的形状选择
som.py里最关键的工程决策是权重矩阵怎么存。有两种常见方案:存成(rows, cols, n_features)的三维数组,或者存成(n_neurons, n_features)的二维数组。前者直观,后者计算方便。som-master这类项目通常选后者,因为 BMU 查找和邻域计算都能用一维索引直接搞定,只有在可视化的时候才 reshape 回二维。
class SOM: def __init__(self, rows, cols, n_features, lr=0.5, sigma=None): self.rows = rows self.cols = cols self.n_neurons = rows * cols self.weights = np.random.randn(self.n_neurons, n_features) self.grid_coords = np.array( [(i // cols, i % cols) for i in range(self.n_neurons)] ) self.lr = lr self.sigma = sigma or max(rows, cols) / 2grid_coords用整除和取模把一维索引映射回二维坐标,顺序是行优先。这个顺序必须和后面可视化时的 reshape 顺序一致,否则画出来的图会上下颠倒或者左右镜像。我一般会在这里加一行断言,确认grid_coords[bmu_idx]和np.unravel_index(bmu_idx, (rows, cols))的结果一致。
3.2 训练循环的完整时序
把前面几块拼起来,训练循环的骨架是这样的:
def train(self, data, epochs): n_samples = data.shape[0] for epoch in range(epochs): # 每个 epoch 打乱样本顺序,避免顺序偏置 indices = np.random.permutation(n_samples) for t, idx in enumerate(indices): x = data[idx] bmu = self.find_bmu(x) h = self.neighborhood(bmu, self.current_sigma(epoch, epochs)) # 权重更新:h 是 (n_neurons,),x - weights 是 (n_neurons, n_features) self.weights += self.current_lr(epoch, epochs) * h[:, None] * (x - self.weights)h[:, None]把(n_neurons,)变成(n_neurons, 1),这样才能和(n_neurons, n_features)的差值逐行相乘。这是 NumPy 广播里最常用的一个技巧,少写一层循环,也让更新公式和数学表达式一一对应。np.random.permutation打乱样本顺序不是可选项——如果数据本身有排序(比如按类别排列),不打乱会让网络在训练早期被某一类主导,最后拓扑结构会偏。
3.3 可视化:把权重“画”出来
训练完之后,main.py一般会做两件事:一是画 U-Matrix(统一距离矩阵),二是把样本映射到 BMU 上做聚类着色。U-Matrix 的思路是:对每个神经元,算它和所有直接邻居的权重距离之和,距离大的地方就是“边界”,距离小的地方就是“簇内部”。
def u_matrix(self): umat = np.zeros((self.rows, self.cols)) w = self.weights.reshape(self.rows, self.cols, -1) for r in range(self.rows): for c in range(self.cols): neighbors = [] if r > 0: neighbors.append(w[r-1, c]) if r < self.rows - 1: neighbors.append(w[r+1, c]) if c > 0: neighbors.append(w[r, c-1]) if c < self.cols - 1: neighbors.append(w[r, c+1]) umat[r, c] = np.mean([np.linalg.norm(w[r, c] - n) for n in neighbors]) return umat这段代码里w = self.weights.reshape(self.rows, self.cols, -1)把一维权重还原成网格,-1让 NumPy 自动推断特征维度。U-Matrix 的值越大,说明这个神经元和邻居的差异越大,通常对应簇与簇之间的分界线。把 U-Matrix 用热力图画出,再叠加 BMU 命中次数的散点,就能直观看到数据在网格上的分布。
4. 避坑与排查:SOM 训练中最容易翻车的几个点
4.1 网格死活不收敛,权重一直在抖
现象:训练几百轮之后,U-Matrix 还是花的,相邻神经元的权重差异没有明显规律。
原因:sigma没有衰减,或者衰减太慢。邻域半径一直很大,等于每次都在全局拉扯权重,网络永远进入不了微调阶段。
解决:确认sigma从max(rows, cols) / 2左右开始,在训练周期内衰减到 1 以下。指数衰减的tau一般取epochs / 3到epochs / 2。可以在训练循环里打印每轮的sigma值,确认它确实在降。
4.2 所有样本都映射到同一个 BMU
现象:训练完之后,大部分样本的 BMU 都是同一个神经元,网格上只有一个热点。
原因:学习率太高且没有衰减,或者输入数据没有归一化。如果某一维特征的量纲远大于其他维,距离计算会被这一维主导,BMU 永远落在同一个区域。
解决:先对输入做标准化(减均值除标准差)或归一化到[0, 1]。然后确认lr从 0.5 左右开始线性降到接近 0。如果数据本身簇结构很弱,可以适当增大网格尺寸,给网络更多“空间”去区分。
4.3 拓扑结构扭曲,相邻节点对应的类别跳变
现象:可视化出来,网格上相邻的神经元对应的样本类别完全不相关,拓扑保持失败。
原因:训练轮数不够,或者样本顺序没有打乱。SOM 的拓扑保持是靠邻域更新一点点“拉”出来的,如果训练不充分,网格还没来得及铺开就停了。
解决:增加epochs,一般至少要让每个样本被送入网络几十次。同时确认每个 epoch 都做了np.random.permutation。如果数据量很小,可以重复采样。
4.4 距离计算溢出或精度丢失
现象:find_bmu返回的索引看起来不对,或者距离值出现inf/nan。
原因:输入数据没有做数值范围控制,平方之后超出浮点精度。或者权重初始化用了过大的方差。
解决:权重初始化用np.random.randn之后乘一个小的缩放因子,比如0.1。输入数据先做标准化。如果特征维度很高,考虑用np.float64而不是默认的float32。
4.5 可视化时网格方向反了
现象:U-Matrix 画出来和 BMU 散点图对不上,或者和预期上下颠倒。
原因:reshape的顺序和grid_coords的生成顺序不一致。grid_coords用的是(i // cols, i % cols),对应行优先;如果reshape时用了order='F',就会列优先,导致坐标错位。
解决:统一用行优先。self.weights.reshape(self.rows, self.cols, -1)默认就是行优先,和grid_coords一致。如果用了其他顺序,在grid_coords生成时也要相应调整。
5. 设计取舍:为什么 SOM 用欧氏距离而不是余弦相似度
5.1 距离度量的选择背后是拓扑假设
SOM 的整个机制建立在“网格上的近邻对应输入空间的近邻”这个假设上。欧氏距离对应的是各向同性的球形邻域,它假设各个特征维度同等重要、且数据在空间中均匀分布。如果换成余弦相似度,邻域就变成了方向性的锥形,只关心向量夹角,不关心模长。这在文本数据里可能更合理,但会破坏 SOM 的拓扑保持性质——因为余弦相似度不满足三角不等式,网格上的邻域更新会变得不可控。
常见的实现思路是:如果数据已经做了 L2 归一化,欧氏距离和余弦相似度是等价的(因为||x - w||² = 2 - 2·cos)。所以与其改距离函数,不如在预处理阶段做归一化,保持som.py里的距离计算简单且可预测。
5.2 批量更新 vs 在线更新
som.py用的是在线更新:每来一个样本就更新一次权重。另一种做法是批量更新:攒一批样本,算出每个神经元的平均更新量,再统一更新。批量更新更稳定,但收敛慢;在线更新对样本顺序敏感,但通常几十轮就能看到结构。som-master选在线更新,是为了让训练循环更短、更容易观察每一步的变化。如果你要处理大规模数据,可以把train里的内层循环改成累积梯度,每batch_size个样本更新一次。
5.3 网格尺寸怎么定
网格太大,每个神经元分到的样本太少,拓扑结构会碎;网格太小,不同簇会被挤在一起,U-Matrix 分不开。经验法则是:神经元数量大约是样本数的平方根到五分之一之间。比如 1000 个样本,用 10×10 到 20×20 的网格比较合适。main.py里一般会把rows和cols暴露成命令行参数,方便你直接试不同尺寸。
5.4 验证 SOM 有没有训好
最直接的验证方法是量化误差(QE)和拓扑误差(TE)。QE 是所有样本到其 BMU 的平均距离,越小说明网络拟合得越好。TE 是样本的 BMU 和次优 BMU 在网格上不相邻的比例,越低说明拓扑保持越好。这两个指标不需要标签,可以在训练过程中每轮算一次,画成曲线。如果 QE 还在降但 TE 开始升,说明网络开始过拟合,该停下来了。
我自己的习惯是:每次跑完 SOM,先把 U-Matrix 和 BMU 命中图叠在一起看一遍,再算 QE 和 TE 的最终值。如果 U-Matrix 上能看到清晰的“山谷”和“山脊”,BMU 命中图上的热点和山谷对应,基本就稳了。从那以后我每次调参都强制走一遍这个流程,比只看损失曲线靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取