拓扑数据分析深度实战:从单纯复形、Vietoris–Rips 过滤到持久同调与贝蒂数条形码的完整工程链路

拓扑数据分析(Topological Data Analysis,TDA)关心一件事:数据除了有"位置",还有"形状"。一组散点可能围成一圈、串成一条链、或者卷成一个球面——这些连通性、孔洞、空腔是度量坐标变换下几乎不变的不变量,传统统计量(均值、方差、PCA 主方向)看不见,而它们往往正是数据最本质的结构信号。本文从第一性原理出发,不依赖任何 TDA 库(GUDHI / Ripser / Dionysus),用纯 NumPy 从零实现 Vietoris–Rips 复形与标准 Z₂ 持久同调算法,并在圆上采样点云、双圆环、路径、离群点四类实战中,展示持久同调如何把"形状"变成可计算的条形码(barcode)。

一、为什么是拓扑,而不是另一堆统计量

假设你在做异常检测,拿到一坨点云。PCA 告诉你主方向;k-means 告诉你有几团;但如果你想知道"这坨点是不是围成一个环",上述工具都会哑火——环和盘在欧氏距离上可以任意接近,却拓扑不同(一个有一维孔,一个没有)。

拓扑不变量(贝蒂数 βₖ)恰好刻画这件事:

  • β₀:连通分量数(有多少个"孤岛");
  • β₁:一维孔洞数(圆环有 1 个,球体表面有 0 个);
  • β₂:二维空腔数(充气球面有 1 个,圆环有 0 个)。

更妙的是,TDA 用"滤波尺度 ε"把数据扫描一遍,记录每个拓扑特征从哪个尺度诞生、在哪个尺度消亡,得到持久同调(persistent homology)的条形码。真正的结构特征是"活得久"的长条,采样噪声只会制造"朝生暮死"的短条——这就是 TDA 天然抗噪的来源。

二、第一性原理:单纯形、复形、同调

单纯形(simplex):0-单纯形是点,1-单纯形是边,2-单纯形是三角形,3-单纯形是四面体……k-单纯形由 k+1 个顶点张成。

单纯复形(simplicial complex):单纯形的集合,要求"面封闭"——若某个单纯形在集合里,它的所有面也必须在。复形上的链群 Cₖ 以 k-单纯形为基;边界算子 ∂ₖ: Cₖ → Cₖ₋₁ 把每个单纯形映射到它的有向面之和。

同调群定义为:

Hₖ = ker(∂ₖ) / im(∂ₖ₊₁) = 闭链(cycles)/ 边界(boundaries)

直观地说:βₖ = dim Hₖ = (k 维闭链数) − (k 维边界数)。一条一维闭链若能被某个二维面"填实",它就不是真孔洞;只有填不满的闭链才贡献 β₁。

对点云,我们不直接堆砌三角形,而是用 Vietoris–Rips(VR)复形 自动生长。

三、Vietoris–Rips 复形:用距离尺度长出一个复形

给定一个距离尺度 ε,VR 复形这样定义:若一组点两两距离 ≤ ε,则它们张成一个单纯形。等价地,每个单纯形的"诞生尺度"取它顶点两两最大距离——于是单纯形天然带有一个 filtration 值,尺度从小到大扫描就得到一族嵌套复形。


import numpy as np, math

def pairwise(X):
    """两两欧氏距离矩阵。"""
    n = len(X); D = np.zeros((n, n))
    for i in range(n):
        d = X[i] - X
        D[i] = np.sqrt((d * d).sum(1))
    return D

def vr_complex(X, max_dim=2, max_edge=None):
    """Vietoris–Rips 复形:单纯形的 filtration 值 = 其顶点两两最大距离。
    max_edge 用于截断过远的边,控制计算量与语义(只关心局部邻域)。"""
    n = len(X); D = pairwise(X); simplices = []
    for i in range(n):
        simplices.append(((i,), 0.0))                 # 0-单纯形(顶点)
    for i in range(n):
        for j in range(i + 1, n):
            w = D[i, j]
            if max_edge is not None and w > max_edge: continue
            simplices.append(((i, j), w))             # 1-单纯形(边)
    if max_dim >= 2:
        for i in range(n):
            for j in range(i + 1, n):
                if max_edge is not None and D[i, j] > max_edge: continue
                for k in range(j + 1, n):
                    w = max(D[i, j], D[i, k], D[j, k])
                    if max_edge is not None and w > max_edge: continue
                    simplices.append(((i, j, k), w))  # 2-单纯形(三角形)
    return simplices

对一个 40 点圆,这段代码会生成 40 个顶点、780 条边、9880 个三角形——复形规模随点数立方增长,这正是高维 TDA 的计算瓶颈(后文"工程边界"详述)。

四、持久同调核心:标准 Z₂ 化简算法

持久同调的输入是带 filtration 值的单纯形序列,输出是一组同调区间 (维度, 诞生尺度, 消亡尺度)。我们用 Zomorodian–Adcock 的标准算法,在 GF(2)(系数模 2)上对每个单纯形的边界做高斯消元,维护每个最简列的最低位(low)来配对"诞生"与"消亡"。


def persistence(simplices):
    """标准 Z₂ 持久同调(Zomorodian–Adcock 化简)。返回区间列表 (dim, birth, death)。"""
    simp = sorted(simplices, key=lambda s: (s[1], len(s[0])))  # 按 filtration 升序
    idx = {s[0]: i for i, s in enumerate(simp)}
    n = len(simp)
    # 预计算每个单纯形的边界(其面在 idx 中的集合, GF(2) 下即异或)
    cols = []
    for verts, val in simp:
        faces = set()
        for i in range(len(verts)):
            f = verts[:i] + verts[i + 1:]
            if f:
                faces.add(idx[f])
        cols.append(faces)
    low = {}                       # 行(单纯形) -> 以它为最低位的列
    for j in range(n):
        while cols[j]:
            l = max(cols[j])       # 当前列的最低位(最大索引的面)
            if l in low:
                cols[j] ^= cols[low[l]]   # 用已配对的列消元
            else:
                break
        if cols[j]:
            l = max(cols[j]); low[l] = j  # 该列杀死诞生于 l 的同调类
    # 空列 = 诞生(正单纯形);low 配对 = 消亡
    births = {j: (len(simp[j][0]) - 1, simp[j][1]) for j in range(n) if not cols[j]}
    intervals = []
    for l, j in low.items():
        d = len(simp[l][0]) - 1
        intervals.append((d, simp[l][1], simp[j][1]))
    for j, (d, bval) in births.items():
        if j not in low:
            intervals.append((d, bval, math.inf))    # 永不消亡 -> 无限长条
    return intervals

def betti_at(intervals, eps):
    """给定尺度 eps 下仍"存活"的同调类数,即贝蒂数 βₖ(ε)。"""
    b = {}
    for d, b0, d1 in intervals:
        if b0 <= eps and (d1 == math.inf or eps < d1):
            b[d] = b.get(d, 0) + 1
    return b

算法复杂度主要由边界矩阵的列消元决定,朴素实现约 O(n³);max_edge 截断与限制 max_dim 是工程上控制规模的关键手段。

五、实战一:圆上采样点云 → 一个一维孔

在半径 1 的圆上均匀采样 40 个点,加少量高斯噪声;运行持久同调。


rng = np.random.default_rng(7)
N = 40
theta = rng.uniform(0, 2 * math.pi, N)
X = np.column_stack([np.cos(theta), np.sin(theta)]) + rng.normal(0, 0.03, (N, 2))

sim = vr_complex(X, max_dim=2)
ints = persistence(sim)

h1 = sorted([(d - b, round(b, 3), round(d, 3))
             for dim, b, d in ints if dim == 1 and d != math.inf], reverse=True)
print("H1 最长持久条 (persistence, birth, death):", h1[0])
print("持久度 > 0.3 的 H1 条数 (即显著一维孔数):",
      sum(1 for p, _, _ in h1 if p > 0.3))
print("β₁(ε) 扫描:", {e: betti_at(ints, e).get(1, 0) for e in [0.3, 0.5, 0.8, 1.0, 1.5]})
print("β₀(∞) 连通分量数:", sum(1 for dim, b, d in ints if dim == 0 and d == math.inf))

输出(固定随机种子,可复现):


H1 最长持久条 (persistence, birth, death): (1.053, 0.7, 1.754)
持久度 > 0.3 的 H1 条数 (即显著一维孔数): 1
β₁(ε) 扫描: {0.3: 0, 0.5: 0, 0.8: 1, 1.0: 1, 1.5: 1}
β₀(∞) 连通分量数: 1

注意两件事:第一,最长的一条 H1 持久条跨越 ε∈[0.7, 1.754]、持久度 1.053,在所有 H1 条里鹤立鸡群;而其余 H1 条的持久度全部 ≈ 0(采样噪声制造的"朝生暮死"假孔)。第二,β₁(ε) 在 ε≈0.8 之后稳定为 1,并在 ε 很大(复形被完全填满、圆被"撑破")时回落到 0——这正是圆的拓扑指纹:一个一维孔。

六、实战二:形状分类 —— 环 vs 路径 vs 双环

把"β₁ 是否、在何时达到某个峰值"作为拓扑指纹,可以区分几种基本形状。路径(完全共线、零噪声)的 VR 复形始终是树状,β₁ 恒为 0;圆有一个孔;两个分离的圆环有两个孔。


def beta1_plateau(X, es, max_edge=None):
    sim = vr_complex(X, max_dim=2, max_edge=max_edge)
    ints = persistence(sim)
    return {e: betti_at(ints, e).get(1, 0) for e in es}

# 路径:40 个点严格排在线段上,零噪声
Xline = np.column_stack([np.linspace(0, 1, 40), np.zeros(40)])
print("路径  β₁:", beta1_plateau(Xline, [0.1, 0.5, 1.0, 1.5]))

# 圆(复用实战一的 X)
print("圆    β₁:", beta1_plateau(X, [0.1, 0.5, 0.8, 1.0]))

# 双分离圆环:半径 0.7,圆心分别在 (0,0) 与 (3,0),截断局部邻域
N2 = 30
c1 = np.column_stack([0.7 * np.cos(theta[:N2]), 0.7 * np.sin(theta[:N2])]) + rng.normal(0, 0.02, (N2, 2))
c2 = np.column_stack([3 + 0.7 * np.cos(theta[:N2]), 0.7 * np.sin(theta[:N2])]) + rng.normal(0, 0.02, (N2, 2))
Xt = np.vstack([c1, c2])
print("双圆  β₁:", beta1_plateau(Xt, [0.1, 0.5, 0.8, 1.0, 1.5], max_edge=1.5))

可复现输出:


路径  β₁: {0.1: 0, 0.5: 0, 1.0: 0, 1.5: 0}
圆    β₁: {0.1: 0, 0.5: 0, 0.8: 1, 1.0: 1}
双圆  β₁: {0.1: 0, 0.5: 0, 0.8: 2, 1.0: 2, 1.5: 0}

汇成一张拓扑指纹表:

形状 β₁ 峰值 显著一维孔数 拓扑指纹含义
路径(线段) 0 0 树状,无孔
圆环 1 1 一个一维洞
双分离圆环 2 2 两个一维洞

双圆环的最长两条 H1 持久条分别为 0.615(ε∈[0.606, 1.221])与 0.588(ε∈[0.631, 1.219]),与单圆环的 1.053 同量级但数量翻倍——TDA 用"孔的数量"而非"点的位置"完成了分类,对坐标平移、旋转、甚至适度的度量畸变都稳健。

七、实战三:用 H₀ 做离群点检测

拓扑不只看孔,也看连通性。把一个远离主体的离群点投进圆点云,max_edge 截断局部邻域后,离群点无法与主体连通,于是 β₀ 从 1 变成 2——一个干净的"异常分量"信号。


Xo = np.vstack([X, [[5.0, 5.0]]])                 # 在圆点云里塞一个远处的离群点
sim = vr_complex(Xo, max_dim=2, max_edge=2.0)
ints = persistence(sim)
ncomp = sum(1 for dim, b, d in ints if dim == 0 and d == math.inf)
print("含离群点的连通分量数:", ncomp)             # 输出 2

输出:含离群点的连通分量数: 2。相比基于密度/距离的离群检测,这种拓扑视角对"主体形状复杂、异常只是孤立点"的场景格外鲁棒。

八、与 AI / 深度学习的映射

TDA 不是孤立的数学玩具,它和当代 ML 有深刻同构:

AI / DL 概念 对应的拓扑视角
流形假设(数据低维流形嵌入高维空间) 用 H₁/H₂ 直接测量流形的孔洞与空腔结构
损失地貌(loss landscape 的极小值连通性) 对权重空间采样点云做 TDA,量化"谷"之间是否有隧道相连
表示学习 / embedding 把样本 embedding 当作点云,用持久同调刻画簇的拓扑分离度
对抗样本 干净样本与对抗样本在特征空间是否落在同一拓扑连通分量
GNN / 图学习 图本身就是 1-复形,节点聚类对应 β₀,环结构对应 β₁
神经架构搜索 / 权重空间 权重向量的"拓扑指纹"可作为可解释、可比较的架构特征
拓扑正则化 在损失中惩罚表示空间里不期望出现的孔洞,引导更简洁的流形

实践中,持久同调的条形码会被向量化后喂给下游模型:持久景观(persistence landscape)、持久图像(persistence image)、或简单的统计量(各维长条数、总持久度、最大持久度)。这些特征对采样噪声有理论上的稳定性保证(bottleneck distance 上的 1-Lipschitz 连续),是 TDA 能进 ML 流水线的关键。

九、工程边界与局限

  • 拓扑噪声地板:有限采样必然产生大量短命假孔。只看"最长条"会漏掉微弱结构,全看又会被噪声淹没。工业做法是构造 bootstrap 置信集,只对统计显著的条做结论。
  • 计算复杂度:朴素 VR + 标准算法约 O(n³),点数上千就吃力。生产用 Ripser(基于稀疏化简与清除 clear 技术,可到百万点量级)、或改用 α-复形 / 带权 VR。
  • 维度诅咒:VR 复形规模随维数指数膨胀,高维点云(>50 维)直接做 TDA 不现实——通常先在表示空间降维,再对低维 embedding 做 TDA。
  • 对距离度量敏感:filtration 完全由距离定义,度量选错(欧氏 vs 余弦 vs 图距离)结论会截然不同,需要领域知识设定。
  • max_dim / max_edge 是语义旋钮:限制 max_dim=1 只能看连通性与孔,限制 max_edge 只看局部邻域;它们不是单纯性能优化,而是"你想问什么问题"的表达。
  • 推断而非因果:TDA 告诉你"数据有环",但不解释"为什么有环"——它应与领域知识、统计检验配合使用。

十、小结

本文从单纯形与边界算子的第一性原理出发,用纯 NumPy 实现了 VR 复形与标准 Z₂ 持久同调算法,并在四类实战中验证:

  1. 圆点云产出一条持久度 1.053 的主导 H1 长条,β₁(ε) 在 ε≈0.8 后稳定在 1,干净地识别出"一个一维孔";
  2. 路径的 β₁ 恒为 0、双圆环的 β₁ 达到 2,证明 TDA 能用"孔的数量"完成形状分类;
  3. 离群点使 β₀ 从 1 跳到 2,展示拓扑视角的异常检测能力。

持久同调的本质,是把"数据的形状"编译成一组可计算、可比较、对噪声稳健的条形码。当传统统计量在"环还是盘""连通还是离散"这类问题上失语时,TDA 恰好补上了这一环——这也是它能在流形学习、表示分析、架构比较等 AI 前沿场景中持续发光的原因。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部