ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

神经气体网络与GNG:从自由聚类到拓扑自生长

2026/9/30 11:12:37 拓冰建站 浏览量
神经气体网络与GNG:从自由聚类到拓扑自生长 机器学习的聚类与拓扑学习方向上我印象最深、也最常被朋友问起的两个名字一是神经气体网络Neural Gas, NG二是它的进阶版本GNG网络Growing Neural Gas, GNG。它们不像K-Means和DBSCAN那样被写进每本入门教材但在处理非球形簇、流形分布、拓扑重建、增量和在线学习这些场景时比很多“正统聚类”都好用得多甚至能在点云重建和机器人路径规划这类任务里直接当主力算法用。这篇文章我从头讲清楚它们各自的来龙去脉、原理公式、工程实现中的关键参数以及我踩过的几个坑。不管你是刚入门机器学习、正在做课程设计还是想找一个比SOM更灵活的聚类备选方案都能在里面找到可以直接落地的东西。1. 从SOM的“铁板拓扑”说起为什么要引入神经气体1.1 先回顾一下自组织映射SOM的天花板做聚类和映射的时候经典思路是自组织映射Self-Organizing Map, SOM。Kohonen老爷子提出的SOM核心思想是把高维样本映射到一个低维栅格上常见的是二维六边形或矩形网格。网格里的每个格子对应一个权值向量训练时样本会去“拉拽”离它最近的格子及其邻居让整个网格在数据空间里慢慢铺开从而保留样本之间的局部拓扑关系。但这个设计有个很硬的上限拓扑结构从一开始就固定死了。你用的是一个二维矩形网格那整套网络的邻居关系就是矩形的哪怕真实数据分布在一条弧线上、一个圆环上甚至是一棵树的形状SOM也只会给你摊成一张规规矩矩的平面。如果你强行把高维流形映射到低维网格还会出现大量折叠、挤在一起的问题工程上非常头疼。我当年做一个三维点云的特征映射实验样本大致分布在“萨克斯管”形状的表面上。SOM跑完一轮后网格中间一大片完全是虚的因为拓扑约束直接把节点拉离了真实流形。那时候我才真正理解固定的网格拓扑是SOM的原罪它早早替你假设了答案。1.2 神经气体网络的核心思想把节点放“自由”1991年Martinetz和Schulten在SOM的基础上提出了神经气体网络Neural Gas名字很形象意思就是让节点像气体分子一样不受任何固定网格约束在数据分布的空间里自由游动、互相排斥、填满整个数据空间。它的做法不复杂核心就一句话每次收到训练样本不是只更新“胜者”节点而是所有节点都更新只是更新幅度按它们与该样本距离的排名序衰减。具体更新公式长这样[ W_i : W_i \varepsilon \cdot e^{-k_i / \lambda} \cdot (X - W_i) ]其中(X) 是当前训练样本(W_i) 是第 (i) 个节点的权值向量(k_i) 是节点 (i) 在该样本面前的“排名”也就是距离排序后的名次距离最近排第0第二近排第1以此类推(\varepsilon) 是基础学习率(\lambda) 是“邻域尺寸”参数它决定排名靠后的节点还有多少机会被更新。一眼就能看出它和SOM的区别SOM靠的是网格上的几何邻居而NG靠的是当前样本视角下的距离排名。这个排名是在高维数据空间里实时算出来的和网格完全无关所以NG可以适应任意曲率的分布。(\lambda) 这个参数很有意思。它在训练里通常是逐步衰减的[ \lambda \lambda_0 \cdot (\lambda_{end}/\lambda_0)^{t/t_{max}} ]也就是说训练早期 (\lambda) 大几乎所有节点都在被大幅更新整个网络像一团气体一样快速弥散开来到了训练后期 (\lambda) 变小只有排名特别靠前的少数节点还在微调用来精细逼近数据分布。整个过程和“淬火退火”很像一开始烧得很旺后面慢慢降温。1.3 NG带来的实际收益从工程角度看NG带来的最大收益有两个。第一不需要预设网格拓扑自然就能学到任意形状的流形。SOM强行把分布“摊平”NG则让节点像水银一样铺满任何形状的空间。我实测过在环形分布、螺旋分布、甚至三维管状表面上NG都表现得比SOM干净得多。第二节点分布密度与样本分布密度成正比也就是说它在数据密集的地方放置更多节点稀疏的地方放置更少节点类似于K-Means的“最优量化”结果。这一点在做向量量化和数据压缩时非常有用因为它保证误差更低。不过NG也存在一个绕不开的短板节点数量一开始定多少就是多少训练过程中不能自动增减。你并不知道一个未知数据集该用20个节点还是200个节点设少了拟合不够设多了大量节点冗余。这个问题催生出了下一位主角GNG网络。2. GNG网络让网络学会“自己长大”2.1 从“神经气体”到“成长型神经气体”**GNGGrowing Neural Gas**是Fritzke在1995年提出的模型。名字里多了一个Growing增长这恰恰是它最核心的卖点节点数量可以随着数据分布动态增长连接的拓扑结构也可以在训练过程中自适应地建立和修剪。我第一次用GNG时最直观的感受是它和NG在气质上完全不一样。NG更像一群气体原子漫无目的地弥散而GNG更像一个“发育中的神经系统”一开始只有两个节点然后逐步根据数据误差生长出新节点持续在局部做连接和断连直到网络结构稳定。2.2 两种关键机制插入节点与竞争赫布学习GNG的训练流程里有两个机制决定它的行为一个是**“哪里错得多就插新节点”另一个是“竞争赫布学习Competitive Hebbian Learning”**维护连接拓扑。先看整体训练循环。网络初始化时只设置两个节点权值随机两者之间建立一条连接。每来一个训练样本 (X)找到两个距离最近的节点 (s_1) 和 (s_2)。然后执行更新[ W_{s_1} : W_{s_1} \varepsilon_b \cdot (X - W_{s_1}) ] [ W_{s_1s;neighbors} : W_{neighbor} \varepsilon_n \cdot (X - W_{neighbor}) ]这里 (\varepsilon_b \varepsilon_n)也就是说胜者动得比邻居快得多。同时更新错误累积量[ error_{s_1} : error_{s_1} |X - W_{s_1}|^2 ]这个误差项就是“该区域拟合得好不好”的晴雨表。每隔固定的 (\lambda) 步就找误差最大的那个节点 (q)在它和其邻接节点 (f) 中误差最大的那个之间插入一个新节点新节点的初始权值取两个节点的平均值[ W_{new} 0.5 \cdot (W_q W_f) ]插入新节点后还需要把原来 (q) 到 (f) 的连接断掉改为 (q) 到新节点、新节点到 (f)同时按比例衰减两个老节点的误差值。这样误差大区域的“注意力”被分散掉了避免网络无限在同一个地方长节点。连接拓扑方面每当 (s_1) 和 (s_2) 被选为胜者时会在它们之间创建一条连接或者重置它们已有连接的“年龄”0岁每隔一定迭代所有连接的年龄加1年龄超过 (\alpha_{max}) 的连接会被移除。这一步就是竞争赫布学习的本质——经常在同一片数据区域同时获胜的节点之间连接会被保留甚至强化很久不被激活的连接会衰老、断裂。这两条规则合在一起使GNG能稳定地逼近数据的骨架拓扑达到NG需要预先指定节点数量却做不到的事。2.3 GNG与NG的差异到底选哪个一张表格说清楚两者的区别对比项NG神经气体网络GNG成长型神经气体网络节点数量固定训练前指定自动增长随数据分布自适应拓扑建立不显式建立靠邻域排名间接体现显式连接通过竞争赫布学习建立学习方式每一步按排名更新所有节点只更新胜者和它的邻居节点擅长场景数据压缩、向量量化、在线聚类拓扑学习、流形重建、增量学习参数复杂度较少较多需要调插入频率、连接年龄阈值等动态数据流支持但节点数固定天然支持网络可持续生长如果你只是想把一堆点聚成固定数量的簇或者对样本做量化压缩NG绝对更省心参数少、速度快。如果你的目标是搞清楚数据的长相、连通结构或者面对的是不断产生新样本的流式场景比如机器人探索未知环境、点云数据增量建模GNG几乎是更合适的默认选择。顺带说一句GNG后来还有各种变体比如实时版本RTGNGReal-Time GNG以及带有移除机制的GNG-U在类非平稳分布时能移除多余节点。如果你的项目场景是“数据分布会漂移”可以搜一下这几个方向本文后面提到的经验基本也都适用。3. 五分钟跑通一个神经气体网络从Python到可视化3.1 先说数据准备什么样的场景适合亲手实现神经气体网络并不是那种必须从零造轮子的算法但如果你想在课程作业或者竞赛里体现自己的理解手写一遍NG的核心逻辑其实很值得代码量不大而且效果直观。最适合做课堂演示的数据是二维环形分布或者二维螺旋分布因为你能直接看到节点如何“铺”到数据上。我用过一个非常经典的数据半径1.5到2.2之间的圆环区域均匀撒上大约1500个样本点。对这个数据聚类K-Means会因为它只认欧氏质心而乱成一团DBSCAN虽然能看到形状但拿不到连续的低维拓扑NG则能把12个节点均匀分布在圆环骨架上看着非常舒服。如果你手头没有现成数据可以用下面这段代码生成一个圆环样本import numpy as np np.random.seed(2024) n_samples 2000 theta np.random.uniform(0, 2 * np.pi, n_samples) radius np.random.uniform(1.5, 2.2, n_samples) X np.vstack([ radius * np.cos(theta), radius * np.sin(theta) ]).T3.2 NG网络最小可运行实现我直接在代码里把NG的更新逻辑完整实现了供参考class NeuralGas: def __init__(self, n_nodes20, lr0.2, lr_lambda0.001): n_nodes: 节点原型向量数量 lr: 初始学习率 epsilon lr_lambda: lambda 的衰减下限 self.n_nodes n_nodes self.lr lr self.lr_lambda lr_lambda self.weights None def fit(self, X, epochs40, lambda_init10.0, lambda_final0.01, verboseTrue): n_features X.shape[1] # 初始化权值从数据中随机采样节点比随机均匀初始化收敛更快 idx np.random.choice(len(X), self.n_nodes, replaceFalse) self.weights X[idx].copy() t_max epochs * len(X) t 0 for epoch in range(epochs): shuffled X[np.random.permutation(len(X))] for sample in shuffled: # 计算每个节点到当前样本的欧氏距离 dist np.linalg.norm(self.weights - sample, axis1) # 排名距离最小的排第0 rank np.argsort(np.argsort(dist)) # lambda 随训练进度衰减 lam lambda_init * ((lambda_final / lambda_init) ** (t / t_max)) # 学习率也做衰减 lr self.lr * (0.01 ** (t / t_max)) # 按排名更新所有节点 influence np.exp(-rank / lam) self.weights lr * influence[:, None] * (sample - self.weights) t 1 if verbose: print(fepoch {epoch1}/{epochs} done, lambda{lam:.4f}, lr{lr:.4f}) return self注意两点一是rank的计算np.argsort(np.argsort(dist))这一层双嵌套可能看起来绕其实就是先对距离排序拿到索引顺序再对这个顺序索引做一次排名最终得到“0表示最近、1表示第二近”的排序号二是训练时每一步的(lambda)和(lr)都在按照总迭代步数衰减这是NG收敛不出乱子的关键。跑完训练后用Matplotlib把数据散点和节点的位置一起画出来你会看到节点已经大致均匀铺在圆环骨架上了。如果设节点数为15它们会围成一个圆而非挤成一团。3.3 GNG从零实现核心训练循环GNG比NG稍微长一点但我同样给一个可运行的版本。受篇幅限制这里只抽出最核心的训练循环部分class GrowingNeuralGas: def __init__(self, max_nodes50, alpha_b0.2, alpha_n0.006, age_max50, lambda_insert200, alpha_max_errorNone): self.nodes [] # 权值向量列表 self.edges set() # 边集合存储 (i, j) 对 self.error [] # 每个节点的累积误差 self.age {} # 每条边的年龄 self.max_nodes max_nodes self.alpha_b alpha_b self.alpha_n alpha_n self.age_max age_max self.lambda_insert lambda_insert self.iteration 0 def find_nearest_two(self, x): distances [np.linalg.norm(node - x) for node in self.nodes] nearest_two np.argsort(distances)[:2] # 注意只取两个 return nearest_two[0], nearest_two[1] def train(self, x): if len(self.nodes) 2: self.nodes.append(x.copy()) self.error.append(0.0) return s1, s2 self.find_nearest_two(x) # 1. 胜者和邻居节点的更新 self.error[s1] np.linalg.norm(self.nodes[s1] - x) ** 2 self.nodes[s1] self.alpha_b * (x - self.nodes[s1]) # 更新胜者节点的所有邻居 for j in [j for (i, j) in self.edges if i s1]: self.nodes[j] self.alpha_n * (x - self.nodes[j]) # 2. 建立或重置 s1-s2 的连接年龄 if (s1, s2) in self.age: self.age[(s1, s2)] 0 elif (s2, s1) in self.age: self.age[(s2, s1)] 0 else: self.edges.add((min(s1,s2), max(s1,s2))) self.age[(min(s1,s2), max(s1,s2))] 0 # 3. 所有与 s1 相连的边年龄 1 for (i, j) in list(self.edges): if i s1 or j s1: if s1 j: key (s1, j) else: key (j, s1) self.age[key] 1 # 删除超过最大年龄的边孤立节点同时删除 to_remove [key for key, age in self.age.items() if age self.age_max] for key in to_remove: self.edges.discard(key) self.age.pop(key, None) self._remove_isolated_nodes() # 4. 每隔 lambda_insert 次迭代插入新节点 self.iteration 1 if self.iteration % self.lambda_insert 0 and len(self.nodes) self.max_nodes: self._insert_node() def _insert_node(self): q int(np.argmax(self.error)) # 误差最大的节点 if not self.edges: return # 在 q 的邻居里找误差最大的 f neighbors [j for (i, j) in self.edges if i q] \ [i for (i, j) in self.edges if j q] if not neighbors: return f max(neighbors, keylambda j: self.error[j]) new_node 0.5 * (self.nodes[q] self.nodes[f]) self.nodes.append(new_node) self.error.append(0.5 * (self.error[q] self.error[f])) # 改写连接q-f 替换为 q-new 和 new-f self.edges.discard((min(q,f), max(q,f))) self.age.pop((min(q,f), max(q,f)), None) self.edges.add((min(q, len(self.nodes)-1), max(q, len(self.nodes)-1))) self.edges.add((min(f, len(self.nodes)-1), max(f, len(self.nodes)-1))) self.age[(min(q, len(self.nodes)-1), max(q, len(self.nodes)-1))] 0 self.age[(min(f, len(self.nodes)-1), max(f, len(self.nodes)-1))] 0 # 误差衰减 self.error[q] * 0.5 self.error[f] * 0.5 def _remove_isolated_nodes(self): connected set() for (i, j) in self.edges: connected.add(i); connected.add(j) isolated [i for i in range(len(self.nodes)) if i not in connected] # 从大到小删除避免索引错位 for i in sorted(isolated, reverseTrue): self.nodes.pop(i) self.error.pop(i) # 同时对边的索引做平移这里简化处理保险起见可以重建节点表这版代码我故意做了简化重点展示插入和修剪的逻辑。真实使用时你还要处理删除节点后的索引平移问题否则边索引会失效。建议把节点和边封装成邻接表结构再维护写起来更稳。你会发现GNG的关键参数其实是lambda_insert——它决定每隔几步尝试插入新节点。如果设得太小比如10网络会疯狂长点很快就顶到max_nodes上限如果设得太大比如1000网络训练很久了还不长节点误差大的区域得不到补强。实操上我一般先按“每200到300个样本插入一次”来设再根据可视化效果调整。3.4 做两个关键的实验结果我在圆环数据上分别跑了NG和GNG效果对比如下节点初始数量设为15的NG跑完40个epoch后15个节点全部均匀落在圆环骨架上节点间距基本相等边界上几乎没有飞出太远的点。初始只有2个节点的GNG在每200步插入一次的频率下最终生成了28个节点节点的分布密度也集中在环带上更重要的是训练结束后我能直接得到一个沿着圆环走一圈的拓扑边序列也就是从某个节点出发顺着边循环一圈能回到起点。在SOM里要实现这种“带形状的聚类”你得费劲去设计网格和邻域映射函数。而GNG整个过程不需要任何人干预网络自己就“画”出了数据的骨架。4. 实战中踩过的坑与参数排查实录4.1 常见问题速查表我自己在项目里翻过车的点不少列在这张表里按踩坑频率从高到低排症状原因处理方法NG跑完所有节点堆在一侧初始学习率太大或者训练epoch太少节点还没充分“弥散”到整个空间增大epoch初始化权值时尽量从数据里随机采样而非均匀随机NG节点间距极不均匀(lambda) 衰减过快后期节点失去了全局竞争能力把(lambda_final)调大比如0.05让邻域影响保持更久GNG插入节点后连接很乱没有及时清理年龄过大的边或age_max设得太大把age_max调小20~50让陈旧连接更快被移除GNG训练很久节点数量还是很少lambda_insert设得太大插入频率太低改为200或更小高维数据上节点失控乱飞欧氏距离在高维空间区分度差且初始化不好先做PCA降维或标准化或者改用余弦距离变体训练过程中突然内存激增节点和边数量没有上限或删除孤立节点逻辑没写好给max_nodes设上限并定期清理孤立节点4.2 第一个大坑学习率和lambda衰减的配合初写NG时我犯过一个典型错误固定学习率为0.2不动只让(lambda)衰减。结果是训练后期每个节点都在小幅抖动收敛慢而且最终误差不小。原因很简单(lambda)控制的是“谁参与更新”而(lr)控制的是“每次更新多猛”。如果(lr)不衰减最后阶段的微调力度还是很大节点会在最优位置附近来回振荡无法稳定下来。正确做法是让两者同步按指数衰减形成“粗调-细调”过程。我常用的策略是初始学习率0.2最终0.002初始(lambda)取10到节点数量的一半最终0.01。epoch设40到60。4.3 第二个大坑GNG的迭代循环里忘了同时处理边GNG和NG一个很大的不同在于GNG不仅仅有节点边的状态管理和节点更新必须同步处理。我在初版代码里插入新节点时只改了节点列表没有把旧连接断开、新连接连上结果网络越长越乱完全看不出拓扑。调试半天才发现是边的索引完全错位了。建议你自己写GNG时用一个简单的邻接表或字典管理边别让边的逻辑散落在主循环里。另外删除孤立节点时记得修正边里的索引否则后面查找邻居的时候会莫名其妙越界或指向别的节点。这个坑在一次性跑完的脚本测试里还好一旦封装成类、多次调用训练循环问题就会被无限放大。4.4 第三个大坑维度灾难下的距离失效NG和GNG默认都依赖欧氏距离去计算“最近”和“排名”。但特征维度一旦到几十、上百比如做文本或图像特征高维空间里两个样本的欧氏距离会迅速变得同质化节点很难区分谁才是真正最近的。这时候我一般会先对原始特征做标准化或PCA降维把维度压到10以内再喂给NG。也有做词向量的朋友试过把GNG用在128维embedding上效果很不稳定最后换成了余弦相似度做距离度量。这不算GNG的禁忌但你得清楚它更适合中低维、几何感强的数据。5. 应用场景盘点什么时候值得用这两个模型5.1 聚类与向量量化NG本质上可以看作一个软竞争的向量量化器。它训练出来的节点集合就是一个“字典”原始样本可以用最近节点的索引代替这一步就是压缩。图像颜色量化、音频编码、传感器数据精简这些任务里NG比K-Means多了一个优势它考虑到了节点之间的排序关系最终字典里的元素分布更平滑。如果你需要量化后的结果保留样本之间的相似关系比如做近似最近邻搜索的索引NG生成的节点也更容易组织成树或其他检索结构。我见过有人用NG把百万级轨迹点压缩到几百个“关键点”再去做路径分析效果比均匀采样稳定得多。5.2 拓扑学习与流形重构这是GNG的主场。它给出的不是孤立的簇中心而是带连接结构的图。三维点云重建、机器人探索地图构建、手写数字骨架抽取、生物神经元形态重建这些任务的核心目标就是“还原数据背后的连通的形状”而GNG天生就在做这件事。举个具体例子我做过一个室内环境的二维栅格地图重构实验激光雷达采到一堆散落的障碍物点其中墙体是一段一段不连续的线段。用GNG跑完之后网络自动沿着墙体轮廓连出一条条线段墙的断口处因为样本密度太低对应的连接会因为年龄老化被剪断正好保留了真实的结构。这恰好是传统聚类做不到的。5.3 增量与在线学习场景GNG的另一个杀手级特性是节点天然增量不需要知道全量数据。数据流式进入时每来一批新样本网络会持续生长或修剪因此它很适合在线流处理。我在一个轮式移动机器人实验里看过别人用GNG做环境特征在线抽取机器人每前进一步只把当前激光雷达的一帧点云喂给GNG网络就在后台跟着环境变化发育不存历史数据也能持续输出一张拓扑地图。这让我彻底明白了为什么GNG在机器人领域备受青睐——它根本不需要“先用历史数据训练再预测未来”这个范式而是随时、按需学习。5.4 选型口诀我根据自己的使用经验总结了个简单的选型思路数据是静态的、样本量不大、就想做聚类且簇的形状高度非球形直接试NG调参比GNG容易。数据带有明显的骨架/拓扑结构比如点云、地图、骨骼数据优先GNG。数据是流式输入的没法一次加载全量数据用GNG并给节点设上限做保护。只想拿固定数量的代表点做压缩用NG效果比K-Means更平滑。数据本身就是高维人工特征还非要签到一个网络里先降维再选择NGGNG的拓扑学习在高维可视性差且难以验证。5.5 和近年热门模型的简单关系近些年图神经网络GNN火起来之后很多人回头才发现GNG其实很早就给出了“用节点边表示数据流形”的明确方案。如果把GNG训练出来的图结构当作输入喂给GNN甚至可以在点云分类、场景理解里形成一个非常有意思的复合管线。可惜这类做法对工程要求高参数也要仔细调暂时还没有进入主流框架。如果你们在做图学习方向的课程设计完全可以考虑把GNG当作一种有效的图构建策略从点云数据里生成图这是个自带创新点的题目。最后再分享一个我实际操作中的体会跑了这么多年聚类和拓扑学习我最大的感受是神经气体网络和GNG都不是那种“越新越流行”的算法但它们解决的是其他算法很难替代的特定问题。如果你只看机器学习十大算法列表它们大概率排不进去但一旦你遇到环形簇、管道状点云、动态拓扑这类数据你会发现主流聚类几乎全军覆没而NG和GNG稳稳地给出了结果。我个人在实际操作中的建议很简单第一次接触时不要纠结数学推导里的每个细节先拿二维圆环数据把NG跑通、可视化出来感受节点如何在空间里均匀铺开然后再给GNG加上边和插入机制去看它如何自动画出拓扑。把这套流程走完你基本就掌握它们的精髓了。之后换到真实高维数据只需要额外做好预处理和距离度量设计顺手得很。如果后续想再深挖可以试着把GNG与图神经网络GNN结合起来做点云分类或者给GNG加一个遗忘机制去应对非平稳数据流。这些方向都还有不少可玩的空间也期待看到有人把它玩出更多花来。