拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

神经气体网络与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 从“神经气体”到“成长型神经气体”

**GNG(Growing 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_1's;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后来还有各种变体,比如实时版本RTGNG(Real-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) ]).T

3.2 NG网络最小可运行实现

我直接在代码里把NG的更新逻辑完整实现了,供参考:

class NeuralGas: def __init__(self, n_nodes=20, lr=0.2, lr_lambda=0.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, epochs=40, lambda_init=10.0, lambda_final=0.01, verbose=True): n_features = X.shape[1] # 初始化权值:从数据中随机采样节点,比随机均匀初始化收敛更快 idx = np.random.choice(len(X), self.n_nodes, replace=False) 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, axis=1) # 排名:距离最小的排第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(f"epoch {epoch+1}/{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_nodes=50, alpha_b=0.2, alpha_n=0.006, age_max=50, lambda_insert=200, alpha_max_error=None): 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, key=lambda 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, reverse=True): 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更平滑。
  • 数据本身就是高维人工特征,还非要签到一个网络里:先降维,再选择NG,GNG的拓扑学习在高维可视性差且难以验证。

5.5 和近年热门模型的简单关系

近些年图神经网络(GNN)火起来之后,很多人回头才发现,GNG其实很早就给出了“用节点+边表示数据流形”的明确方案。如果把GNG训练出来的图结构当作输入喂给GNN,甚至可以在点云分类、场景理解里形成一个非常有意思的复合管线。可惜这类做法对工程要求高,参数也要仔细调,暂时还没有进入主流框架。如果你们在做图学习方向的课程设计,完全可以考虑把GNG当作一种有效的图构建策略从点云数据里生成图,这是个自带创新点的题目。

最后再分享一个我实际操作中的体会

跑了这么多年聚类和拓扑学习,我最大的感受是:神经气体网络和GNG都不是那种“越新越流行”的算法,但它们解决的是其他算法很难替代的特定问题。如果你只看机器学习十大算法列表,它们大概率排不进去;但一旦你遇到环形簇、管道状点云、动态拓扑这类数据,你会发现主流聚类几乎全军覆没,而NG和GNG稳稳地给出了结果。

我个人在实际操作中的建议很简单:第一次接触时不要纠结数学推导里的每个细节,先拿二维圆环数据把NG跑通、可视化出来,感受节点如何在空间里均匀铺开;然后再给GNG加上边和插入机制,去看它如何自动画出拓扑。把这套流程走完,你基本就掌握它们的精髓了。之后换到真实高维数据,只需要额外做好预处理和距离度量设计,顺手得很。

如果后续想再深挖,可以试着把GNG与图神经网络(GNN)结合起来做点云分类,或者给GNG加一个遗忘机制去应对非平稳数据流。这些方向都还有不少可玩的空间,也期待看到有人把它玩出更多花来。

返回列表