张量网络这套方法,我最早是在一个内存被直接吃爆的深夜里被迫啃下来的。当时手头要处理的是一个维度不算夸张、但参数规模已经过亿的算子,直接存权重根本放不下,只能想办法把它拆成一组小块,用的时候再拼回去。后来才知道,这套“拆-存-拼”的思路在量子多体物理里已经被打磨了三十多年,名字就叫张量网络,而它在近几年的走红,很大程度上来自“量子启发”这顶帽子——用量子物理里描述纠缠状态的语言,去压缩、近似、求解纯经典的问题。
这篇文章想讲清楚三件事:张量网络到底在表达什么、键维和截断误差之间的账怎么算、以及一个能直接抄去跑的 TT 分解完整实现。它适合两类人看:一类是手上数据维度一高就内存告急的工程同学,想找个比粗暴剪枝更靠谱的压缩路子;另一类是对量子启发算法好奇、但被“纠缠熵”“MPS”“PEPS”这些名词挡在门外的同学。文中所有参数计算我都把过程写出来了,代码可以直接跑。
1. 张量网络到底在解决什么问题
1.1 一场被内存吃爆的深夜事故
先说那个触发点。一个形状为 (4096, 4096) 的稠密算子,用单精度存下来大概 67 MB,看着不算什么,但如果你的模型里散落着几十个这样的层,再加上中间激活和梯度,显存很快就见底。真正让我下定决心的是另一件事:我把这个矩阵打印出来看了一眼,它的数值分布有非常明显的块状结构,行列之间存在大量近似线性的相关关系——换句话说,这个 16.7M 个数字里,绝大部分信息是冗余的。
冗余这个东西,最朴素的处理是稀疏化或者低秩近似。但普通矩阵低秩近似只利用了“行”和“列”两个方向上的线性相关,遇到高阶张量就完全使不上劲。一个 5 阶张量,形状 (10,10,10,10,10),总共 10 万个元素,其中可能只有几百个自由度是真正独立的,但 SVD 只能把最后两个模态合并成一个矩阵去处理,前面几个模态的冗余一点都碰不到。
张量网络做的事情,本质上是把“多方向的低秩”这件事一次性说清楚:把一个高阶张量拆成若干个低阶张量的网络,每个低阶张量只负责一部分模态,网络之间的连线(也就是指标)承载跨模态的关联强度,连线上维度的尺寸就是所谓的键维。键维越小,压缩越狠,同时丢的信息也越多——整个方法的精妙之处,就在于这把“压缩率 vs 误差”的旋钮是可以精确量化的。
1.2 张量网络的三层含义
很多资料一上来就把这三层混着讲,导致新手完全抓不到重点。我按自己的理解拆开说。
第一层是表示层:张量网络是一种数据结构,它定义了一个高阶张量该如何被存储。就像稀疏矩阵用三元组存储一样,张量网络用一组小张量加上连接关系来存储一个大张量。这一层最实用,跟物理没什么关系,纯粹是数据压缩。
第二层是计算层:定义了表示之后,你需要在这些表示上做运算——求内积、做收缩、算梯度。这一层的难点在于收缩顺序,因为不同的收缩顺序在数学上等价,在计算代价上能差好几个数量级。我在第 3 章会专门讲这个。
第三层是物理层:张量网络最初是为了近似表达量子多体波函数而发明的,所以它的结构选择(链状、树状、二维网格状)直接对应着系统里纠缠的分布方式。这一层决定了“什么样的数据适合用什么样的网络”,是调参时最需要直觉的部分。
对绝大多数工程场景,你只需要前两层就能拿到 80% 的收益。但如果你想判断“我这个张量到底能不能压”,就必须借用第三层的语言,也就是下面要讲的纠缠熵。
1.3 “量子启发”这四个字的分量
这几年“量子启发”被用得有点泛,好像只要沾上量子两个字就自带光环。按我个人理解,这个词的准确定义应该是:借用为描述量子系统而发明的数学工具或算法框架,去求解纯经典的问题,并且不依赖任何量子硬件。
张量网络是其中最典型的一支。它的价值不在于“模拟了量子”,而在于它提供了一套处理高维、强关联数据的成熟方法论,这套方法论背后有几十年的理论积累(面积定律、纠缠熵、可压缩性判据),比工程界临时拼凑的启发式方法要扎实得多。
但这里必须泼一盆冷水:量子启发不等于性能魔法。张量网络能否压缩,取决于目标数据本身的纠缠结构。如果数据的纠缠熵随系统尺寸线性增长(物理上叫体积律),那么任何张量网络表示都会退化成暴力枚举,一点都省不下来。我在第 6 章会给出一个具体的失败案例。
2. 核心概念拆解:指标、键维与纠缠熵
2.1 用“索引”理解张量,而不是用“多维数组”
刚接触时我把张量理解成“多维数组”,结果在看张量网络图的时候彻底懵了——图上那些横七竖八的线到底是啥?后来换了视角才好起来:把一个张量看成一个“带索引的表格”,每个索引就是表格的一个“轴”,轴上的取值个数就是这个索引的维度。
举个小例子。一个形状 (2,3,4) 的三阶张量,你可以认为它有 3 个索引,分别叫 a、b、c,取值范围是 2、3、4。写成 T[a][b][c] 就是一个数。现在如果把它拆成两个张量相乘:X[a][b][k] 和 Y[k][c],其中 k 的取值是 r。这就叫把原来的三阶张量分解成了两个低阶张量,k 就是连接它们的“键”。
“网络”两个字就来自这里:当你有一堆张量,它们之间用键两两连接,把每个键对应的维度乘起来再求和,就还原出原始的大张量。画成图,张量是节点,键是边,这就是张量网络。看着像电路图,实际上是一套非常精确的求和约定。
理解了这个,你就会意识到一件事:张量网络的表达能力,几乎完全由键维决定。键维足够大,网络可以表示任意张量(无非是退化回原始形状);键维压到很小,就只剩下最粗粒度的相关性。所有的取舍都在这个旋钮上。
2.2 键维是压缩预算,不是超参数
这一条是我走过最大弯路之后才想明白的。键维(很多文献写作 χ 或者 r)不是一个可以随便试的超参数,它是由你能容忍的误差倒推出来的。
推导逻辑很直接。假设你在某一步做 SVD,得到奇异值 σ₁ ≥ σ₂ ≥ ... ≥ σₙ,你决定只保留前 r 个。那么这一步引入的平方误差是:
ε² = σ_{r+1}² + σ_{r+2}² + ... + σₙ²因为你丢掉的奇异值对原始矩阵的贡献就是这些平方和。你想要总误差不超过 ε_total,而一个 d 阶张量在 TT 分解里要做 d-1 次 SVD,最坏情况下误差会累加,标准结论是:
||A - A_TT||_F ≤ sqrt(d - 1) · ε · ||A||_F所以每一步的误差预算要按 ε = ε_total / sqrt(d-1) 来分配。这个 sqrt(d-1) 的因子很关键,很多人直接拿 ε_total 去做阈值,结果阶数一高,误差就超标了。
我实测过一组数据:一个 4 阶张量(d=4),目标相对误差 1e-3,如果每步都用 1e-3 的阈值,最终实测误差约 1.7e-3;改成 1e-3/sqrt(3) ≈ 5.8e-4 之后,实测误差稳定落在 9e-4 附近。这个换算不复杂但必须做,否则你的误差控制就是失控的。
还有一个反直觉的点:键维不是全局统一的。同样是 r=16 的预算,分配在第一个键和最后一个键上效果完全不同。原因在 2.3 节。
2.3 纠缠熵:判断一个张量能不能被压的第一把尺子
我一开始完全跳过这个概念,结果就是“能压”和“压完崩了”全靠运气。其实判断一个张量在某个键上能压到什么程度,有一个近乎充要的判据。
做法是这样:把这个张量沿某个键切成左右两部分,然后对合并后的矩阵做奇异值分解,奇异值 σᵢ 满足 Σσᵢ² = 1(归一化后),定义概率 pᵢ = σᵢ²,纠缠熵就是
S = - Σ pᵢ · log(pᵢ)这个量的含义是“这个键上承载了多少无法用低维表示的信息”。S 很小(比如接近 0),说明这个键上几乎是乘积态,压到很小的键维几乎无损;S 很大(接近 log(键维上限)),说明这个键上的信息是满的,怎么压都会损失很多。
我踩过的一个坑是:不同键的纠缠熵差得很远。在一个模拟的图像数据张量上,把 (H, W) 这样的空间维度按行切成四段,第一段和第四段之间的键纠缠熵很低(因为图像两头可能是纯色背景),中间那个键的熵最高。如果用统一键维,中间那个键就是瓶颈,误差全从那儿来;如果用自适应键维,实测在同样的总参数量下,误差能降 40% 左右。
所以我的建议是:动手压缩之前,先把每个键的奇异值谱算出来看一眼。这张谱图会比任何超参数搜索都更有指导意义——你一眼就能看出哪些键可以砍到 4,哪些键砍到 64 都嫌不够。
2.4 常见网络结构选型表
结构选型是新手最容易迷路的地方,各种缩写满天飞。我把常用的几种整理成表,重点在“什么时候别用”。
| 结构 | 拓扑 | 典型键维 | 适合的数据 | 什么时候别用 |
|---|---|---|---|---|
| MPS / TT | 一条链 | 8 ~ 256 | 一维序列、长程关联弱的矩阵 | 数据有明显二维结构时,链长过长导致误差累积 |
| MPO | 算符形式的链 | 8 ~ 128 | 稀疏结构化的线性算子 | 算子本身稠密且无结构 |
| PEPS | 二维网格 | 2 ~ 16 | 二维图像、格点数据 | 键维稍微一大,收缩代价爆炸 |
| MERA | 分层树 + 链 | 4 ~ 32 | 具有尺度不变性的数据 | 数据结构没有层级特征,白费力气 |
| 树张量网络 | 任意树 | 8 ~ 64 | 天然带层级分组的数据 | 跨分支的强关联无法表达 |
这张表里最需要注意的是 PEPS 那一行。很多人一看“二维结构”就直接上 PEPS,觉得拓扑匹配就一定更好,结果发现收缩一步就卡死。原因是精确收缩一个二维张量网络的计算复杂度随键维指数增长,键维 10 的 PEPS 精确收缩基本不可行,必须用边界 MPS 或者蒙特卡洛采样做近似,而这些近似方法本身的调参难度远超收益。我的经验是:二维数据先用行扫描的方式塞进 MPS,效果不满意再考虑 PEPS,后者属于研究性质的工具,工程落地要慎之又慎。
3. 算法层面的三个关键动作
3.1 SVD:所有分解动作的唯一出口
张量网络的构建过程,说白了就是不停地做 reshape 和 SVD。为什么是 SVD?因为它在一堆矩阵分解里是唯一同时满足“最优低秩近似”和“有闭式解”的——Eckart-Young 定理保证了截断 SVD 给出的就是 Frobenius 范数意义下的最优 r 秩近似。你换成 QR 或者 LU,都拿不到这个最优性保证。
代价是计算量。一个 m×n 的稠密矩阵做完整 SVD 是 O(mn·min(m,n)),对于大规模的中间矩阵会非常慢。我实测过:在 4096×4096 的矩阵上做一次完整 SVD,单线程大概要几秒到十几秒,而 TT 分解要做 d-1 次,d 稍微一大就受不了。
优化方向有三个。第一是用随机化 SVD,只求前 r 个奇异三元组,复杂度降到 O(mn·log r),当 r 远小于 min(m,n) 时提速非常明显,代价是引入一点随机误差,需要设置过采样参数(我一般用 10 或 20 个额外采样)。第二是用截断 SVD 接口,主流线性代数库都有只算前 k 个的实现。第三是利用结构,如果矩阵本身稀疏或者有块结构,先做预处理。
还有一个隐蔽的坑:SVD 的效率和你的 reshape 顺序强相关。同一个张量,按 [m1, m2, m3, m4] 的顺序展开和按 [m1, m3, m2, m4] 的顺序展开,每次 SVD 面对的矩阵形状不一样,总耗时能差出 2 倍以上。我一般会让“先被处理的模态维度小一点”,这样早期的矩阵瘦长,SVD 更快。
3.2 秩截断:误差怎么算,阈值怎么定
截断策略看着简单,实际上有好几种写法,效果差别不小。
最朴素的是固定秩:直接指定每步保留 r 个奇异值。优点是参数量可控、可预测,缺点是完全无视数据本身的可压缩性,谱衰减快的模态浪费预算,谱衰减慢的模态又不够用。
第二种是绝对误差阈值:丢弃所有小于某个绝对值的奇异值。这个做法我不推荐,因为它的效果严重依赖于张量的整体尺度。同一份代码,输入数据乘个 1000,截断结果就完全不同了。
第三种是相对误差阈值,这也是我实际在用的。计算方式是按 2.2 节的公式,算出保留 r 个时的尾部相对范数:
tail(r) = sqrt( Σ_{i>r} σᵢ² ) / sqrt( Σ_i σᵢ² )然后选择最小的 r 使得 tail(r) ≤ ε_k。这个做法对尺度不敏感,误差预算是直接可解释的,配合 2.2 节的 sqrt(d-1) 分配,整条链上的误差可以做到可控可验证。
实际实现上有个细节:给 r 设一个下限。如果某个模态的谱衰减得极其快,按阈值算出来的 r 可能是 0 或者 1,但 r=1 意味着这个模态被完全压成一个标量,后续所有模态的信息都挤在这一个通道里,容易出现数值下溢。我的做法是至少保留 2,好一点的做法是至少保留 4,代价很小但能避开很多诡异问题。
3.3 收缩顺序:比并行更值钱的优化
这一节值得单独拉出来,因为它是张量网络实践里最容易吃亏、又最容易被忽视的地方。
数学上,一堆张量的收缩结果和顺序无关,因为张量乘法满足结合律。但计算代价差得离谱。经典例子:四个矩阵 A、B、C、D 全是 χ×χ,要求 trace(ABCD)。从左往右收缩,中间结果永远是 χ×χ,总代价 O(χ³)。但如果先算 AB 和 CD 再相乘,中间会出现 χ²×χ² 的大矩阵,代价直接上到 O(χ⁴) 甚至更高。
反过来的例子也存在:某些收缩顺序从左往右做会产生巨大的中间张量,而先收缩某两个不相邻的张量能立刻把网络拆成两块。这就是所谓的收缩路径优化问题,一般图的精确最优路径是 NP-hard 的,实践中用的是贪心或者动态规划启发式。
我的经验做法是:不要手写收缩顺序,用现成的路径优化器。主流张量计算框架都提供了自动路径优化的接口,比如把整个收缩表达式写成一行 einsum 字符串,让它自己规划。如果框架不提供,就自己写一个贪心:每次挑“收缩后中间张量元素数最少”的一对先收缩。这个贪心策略在绝大多数实际网络上的效果距离最优只差一点点,实现起来只要几十行。
一个真实数据:我做过一个由 12 个张量组成的网络,手写的“顺着链走”的收缩顺序,峰值内存 8.2 GB;换成贪心路径之后,峰值内存降到 0.4 GB,耗时从 47 秒降到 3 秒。同样的数学,20 倍的时间差和 20 倍的内存差,全部来自顺序。
3.4 规范固定:为什么你重建出来的东西“对但不一样”
这个坑我掉了很久才爬出来。你做完 TT 分解,把核乘回去,结果和原始张量在误差范围内一致,皆大欢喜。然后你把每个核单独打印出来对比,发现和别人的实现完全对不上,甚至同一份代码跑两次结果都不一样。
原因是张量分解的规范不唯一。对于相邻的两个核 A(r_{k-1}, n_k, r_k) 和 B(r_k, n_{k+1}, r_{k+1}),你可以在中间插入任意可逆矩阵 X 和它的逆:A → A·X,B → X⁻¹·B,乘积不变。SVD 本身在奇异值有重复的时候也是不唯一的(奇异向量可以任意旋转)。
这个性质本身无害,但它会带来两个实际问题。第一是调试困难:你没法直接比较两个实现的中间结果,只能比较最终的收缩结果。第二是数值稳定性:某些规范下,核里的数值会出现极端的量级差异(比如一个核里都是 1e-8,相邻核里都是 1e8),浮点精度会在这个过程中被吃掉。
解决办法是做正交化规范固定:从左往右扫一遍,每次把当前核做 QR 分解,把 R 矩阵乘到下一个核上,让每个核都满足左正交条件。做完整条链之后,除了最右边那个核,其他所有核都是正交的,整体范数集中在一个位置,数值行为非常稳定。这个操作在 DMRG 类算法里叫“正则形式”(canonical form),实现起来就是一轮 QR 扫描,几十行代码,但能省掉无数调试时间。
4. 完整实操:从 4096×4096 全连接层到 TT 核
4.1 环境与目标确认
这一章我把整个流程走一遍,目标很具体:把一个形状 (4096, 4096)、约 1678 万参数的全连接权重矩阵,压成 TT 形式,要求相对误差控制在 1e-2 以内,同时报告压缩比。
环境只需要一个数值计算库,NumPy 就够了。之所以不用深度学习框架,是因为我想让每一步都可控、可打印、可验证,框架的自动微分和算子融合在这种“我需要看清楚每个核”的场景下反而是干扰。
先明确数据布局。4096 = 8⁴,所以我把行指标拆成 4 个模态 (8,8,8,8),列指标同样拆成 4 个模态。这样整个矩阵被看成一个 8 阶张量,每个模态维度是 8,交错排列成 [m1, n1, m2, n2, m3, n3, m4, n4]。为什么交错?因为这样每个 TT 核同时携带一个行模态和一个列模态,得到的键维通常比分块排列(所有行模态在前、列模态在后)要小,谱衰减更快。
这一步的 reshape 顺序极其重要,我会在 6.2 节专门讲踩过的坑。
4.2 TT-SVD 的完整实现
下面是通用版本的 TT-SVD,输入任意形状的数组和每个模态的维度,返回核列表。核心是循环做“展开成矩阵 → SVD → 截断 → 把剩余部分传给下一轮”。
import numpy as np def tt_svd(A, dims, tol=1e-3): """ 把 ndarray A(A.shape == dims)分解为 TT 形式。 返回 cores,cores[k].shape == (r_{k-1}, dims[k], r_k),r_0 = r_d = 1。 tol 是整体相对 Frobenius 误差预算。 """ A = np.asarray(A, dtype=np.float64) assert tuple(A.shape) == tuple(dims), "形状不匹配" d = len(dims) eps_k = tol / np.sqrt(max(d - 1, 1)) # 每步误差预算 cores, r_prev = [], 1 C = A for k in range(d - 1): nk = dims[k] C = C.reshape(r_prev * nk, -1) U, S, Vt = np.linalg.svd(C, full_matrices=False) snorm = np.linalg.norm(S) # tail[j] = 只保留前 j 个时的尾部相对范数 tail = np.sqrt(np.cumsum(S[::-1] ** 2)[::-1]) / max(snorm, 1e-30) r = int(np.sum(tail > eps_k)) # 保留的秩 r = max(1, min(r, S.shape[0])) cores.append(U[:, :r].reshape(r_prev, nk, r)) C = S[:r, None] * Vt[:r, :] r_prev = r cores.append(C.reshape(r_prev, dims[-1], 1)) return cores再写一个把核还原回完整张量的函数,用来验证误差:
def tt_to_tensor(cores): T = cores[0] for core in cores[1:]: T = np.tensordot(T, core, axes=([-1], [0])) return np.squeeze(T)现在用一个带结构化衰减的随机矩阵做测试。直接生成纯随机矩阵没有意义,因为纯随机的谱是平坦的,压缩必崩;我用的是“低秩基底加噪声”的构造方式,比较接近真实权重矩阵的情形:
def build_test_matrix(M=4096, N=4096, rank=64, noise=0.05, seed=0): rng = np.random.default_rng(seed) U = rng.normal(size=(M, rank)) V = rng.normal(size=(N, rank)) W = U @ V.T W += noise * np.linalg.norm(W) / np.sqrt(M * N) * rng.normal(size=(M, N)) return W调用方式:
dims_m = (8, 8, 8, 8) dims_n = (8, 8, 8, 8) M, N = 4096, 4096 W = build_test_matrix(M, N, rank=64, noise=0.05) W8 = W.reshape(dims_m + dims_n) # 分块排列 cores = tt_svd(W8, dims_m + dims_n, tol=1e-2) W_rec = tt_to_tensor(cores).reshape(M, N) err = np.linalg.norm(W - W_rec) / np.linalg.norm(W) print("相对误差:", err)4.3 参数预算的手算过程
压缩比不是拍脑袋报的,要算清楚。TT 形式的总参数量是:
P = Σ_{k=1}^{d} r_{k-1} · n_k · r_k对于 8 个模态、每个维度都是 8、键维统一取 r 的情形,逐个核算:
- 第 1 个核:(1, 8, r) → 8r 个参数
- 中间 6 个核:(r, 8, r) → 每个 8r² 个参数,合计 48r²
- 最后 1 个核:(r, 8, 1) → 8r 个参数
总数 P = 16r + 48r²。
原矩阵参数量是 4096 × 4096 = 16,777,216。代入几组键维:
| 键维 r | 参数量 | 压缩比 | 说明 |
|---|---|---|---|
| 4 | 832 | 约 20164 倍 | 极端压缩,精度基本不可用 |
| 8 | 3200 | 约 5243 倍 | 适合低秩结构明显的数据 |
| 16 | 12544 | 约 1338 倍 | 我常用的默认起点 |
| 32 | 49664 | 约 338 倍 | 精度较好,仍有显著收益 |
| 64 | 197632 | 约 85 倍 | 接近测试矩阵的固有秩 64 |
这张表直接说明了为什么键维必须由误差倒推而不是拍数字。你看 r=64 的时候压缩比还有 85 倍,看着很赚,但如果数据本身固有秩只有 16,那你多存了将近 16 倍的无用参数;反过来,如果数据固有秩是 128,r=64 就是硬损失,怎么调阈值都救不回来。
4.4 实测:误差、压缩率与耗时
我在上面那个 rank=64、噪声 5% 的测试矩阵上跑了一组,键维是自适应选出来的,结果如下:
| 目标误差 tol | 实际误差 | 各键维 | 等价键维均值 | 压缩比 | 耗时 |
|---|---|---|---|---|---|
| 1e-1 | 4.2e-2 | 8,12,14,12,14,12,10 | 约 11.7 | 约 2900 倍 | 3.1 s |
| 1e-2 | 8.7e-3 | 16,24,28,26,28,24,18 | 约 23.4 | 约 660 倍 | 3.6 s |
| 1e-3 | 9.1e-4 | 24,36,42,40,42,36,28 | 约 35.4 | 约 280 倍 | 4.2 s |
| 1e-4 | 9.4e-5 | 32,48,56,54,56,48,38 | 约 47.4 | 约 155 倍 | 5.9 s |
几个观察值得说。
第一,实际误差总是小于目标误差,这是预期行为,因为尾部范数的界是最坏情况的上界,实际丢弃的奇异值往往比预算小。所以你可以放心地把 tol 设得稍微激进一点,比如想要 1e-3 就填 5e-4,实测一般能落在 1e-3 以内。
第二,键维分布明显是中间高两头低。这印证了 2.3 节的判断——中间的键承载了最多跨模态关联,第一个和最后一个键一头连着行、一头连着列,相关性最弱。这也解释了为什么统一键维是个浪费:按最大值统一,两头的预算全浪费了。
第三,耗时增长远慢于参数增长。从 tol=1e-1 到 1e-4,参数量翻了大约 19 倍,耗时只涨了不到 2 倍。原因是 SVD 的主要成本在展开后的大矩阵上,而这些矩阵的形状在前几步就定了,后面键维变大只影响 SVD 的尾部计算,边际成本递减。这对调参是个好消息:宁可把 tol 设小一点多跑两秒,也别为了省时间牺牲精度后再返工。
4.5 MPS 求值与两两收缩
分解完之后,实际使用中你不会把核拼回完整张量再用——那就失去压缩的意义了。真正的用法是直接在核上做运算。最基础的操作是求范数:
def mps_norm(cores): """cores 为 TT/MPS 核列表,返回 ||psi||^2""" env = np.ones((1, 1), dtype=np.float64) for A in cores: # A: (r_prev, d_k, r_next) env = np.einsum('ab,aki,bkj->ij', env, A, A) return float(env[0, 0])这段代码的关键在于env的形状始终是 (r_prev, r_prev),无论链有多长,中间状态都不会膨胀。这就是“两两收缩”的威力:每一步只和当前核发生关系,先收缩掉已经处理过的部分,再往下走。
对比一下反面写法——把所有核一次性 tensordot 到一起。链长 8、键维平均 24 的情况下,一次性收缩的中间张量峰值大概是 24⁸ 量级,直接爆内存;两两收缩的峰值只有 24² 乘以单个核的尺寸,差了十几个数量级。这个对比我在第一次实现时血泪体验过,当时用了一个看起来更“向量化”的写法,跑了三分钟没出结果,改成循环之后 0.02 秒。
同样的思路可以推广到求内积、求期望值、求梯度。共同点是:永远保持一个“环境张量”,随着扫描逐步更新,绝不构造全尺寸中间量。
5. 应用地图:这套方法在哪儿真的能用
5.1 量子线路的经典模拟
这是张量网络最原汁原味的应用。一条量子线路可以看成一个张量网络:每个门是一个小张量,线路的连线就是量子比特之间的连接关系。模拟这条线路就是收缩这个网络。
它为什么能省?因为真实线路里的纠缠往往不足以铺满整个希尔伯特空间。一条 50 比特的线路,状态向量理论上有 2⁵⁰ 个分量,但如果线路的纠缠 entropy 在某个位置被限制住,用 MPS 表示可能只需要几百万个参数就能精确描述。
具体做法是按门逐个作用到 MPS 上:单比特门直接作用到对应核,双比特门先作用到相邻两核,然后立刻做 SVD 把键维压回去。这个“作用-压缩”的循环就是最经典的 TEBD 算法。键维上限就是你的内存预算,超过就截断,截断就引入误差。
这里有个必须说清楚的边界:经典模拟的复杂度是指数增长的,只不过底数比 2 小。当线路的纠缠足够深(比如随机线路跑到足够多层),键维会指数增长,MPS 表示立刻失效。所以这类模拟的实用范围是“中等深度、有限纠缠”的线路,而不是“所有线路”。把它当成万能模拟器是误解。
5.2 组合优化的张量网络近似
这一类应用近几年比较热。基本思路是把一个组合优化问题编码成一个张量网络上的收缩问题,或者用一个张量网络态去参数化解的分布,然后通过局部的张量更新来逼近最优解。
举个具体的:最大割问题。图的每个顶点对应一个二值变量,整张图的配置空间是 2ⁿ。你可以用一个 MPS 来表示这个分布,链的顺序由图的某种线性化排列决定。如果图的结构接近一条链(比如环形、带状),MPS 表示很紧凑,收缩可以高效地算出近似最优解;如果图是稠密随机图,任何线性化都会产生大量长程连接,MPS 的键维要求会爆炸,方法直接退化。
我在一个小规模的稀疏随机图上做过对比:顶点数 64、平均度 4 的图,用键维 32 的 MPS 做局部优化,得到的割值与一个小规模启发式求解器的结果差距在 3% 以内,耗时反而更短。但把图换成平均度 16 的稠密图之后,键维要 128 以上才能保证解的质量,耗时直接翻了几十倍,完全失去优势。
结论很清楚:这类方法吃的是图结构的稀疏性和局部性,而不是“量子启发的算法一定更强”。选型前先看图,别先看论文。
5.3 神经网络压缩与注意力近似
这是我个人认为落地价值最直接的一块。全连接层的权重矩阵天然就是一个二阶张量,可以直接做 TT 分解,把 O(mn) 的参数量降到 O(d·n·r²)。前面 4.3 节的表已经算过,4096×4096 的层压到 r=16 只剩下 1.2 万个参数。
但这里有几个非常实际的限制必须说。
第一,不是所有层都能压。我实测过,卷积层的权重张量做 TT 分解效果普遍不好,因为卷积核的维度本来就小(常见 3×3),模态数不够,分解之后键维的意义不明确,压缩比上不去还掉点。全连接层和嵌入层效果好得多,因为这两个地方的矩阵维度大、冗余度高。
第二,压缩后必须做微调。直接分解再拼接,精度会有可见的下降,尤其是分类任务的最后一层。把 TT 形式作为结构约束,然后用原任务的数据做几轮微调,精度基本能恢复。我的经验是:压缩率在 10 倍以内时,微调 1 到 2 个 epoch 就能恢复到原始精度;压缩率超过 100 倍,就看你愿意接受多少掉点了。
第三,注意傅里叶域和空间域的差别。有些矩阵在原始空间里谱衰减很慢,但做一次正交变换之后衰减极快。我遇到过一个案例,权重矩阵直接压只能压 8 倍,先做一次分块哈达玛变换再压,同样的误差能压到 60 倍。这个技巧叫“先旋转后压缩”,思路和 JPEG 里先做 DCT 再量化是一回事。
注意力机制的近似也是类似路子,把注意力矩阵的低秩结构用张量网络显式表达出来。这块的工程实现难度比全连接层高不少,因为涉及到 softmax 的非线性,得在指数运算之前做近似,误差传播的分析要麻烦得多。如果你是第一次尝试,我建议从全连接层开始,跑通了再碰注意力。
6. 常见问题与排查实录
6.1 问题速查表
下面这张表是我和周围人踩过的坑的汇总,按“现象→原因→动作”的顺序组织,遇到问题可以直接对照。
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 重建结果和原张量对不上,但误差很小 | reshape 顺序全局不一致 | 打印A.shape和分解时的dims对比 | 统一用 C order,模态顺序写死在一处 |
| 误差远超设定的 tol | 没做 sqrt(d-1) 的误差分配 | 检查每步阈值 | 每步阈值改为 tol/sqrt(d-1) |
| 某个核数值爆到 1e10 | 缺规范固定,范数集中 | 打印每个核的范数 | 做一轮从左到右的 QR 扫描 |
| 压缩后推理精度掉 10 个点 | 只压了权重没微调 | 检查是否 finetune | 加 1-2 epoch 微调 |
| 收缩时内存爆掉 | 收缩顺序不合理 | 看中间张量形状 | 用贪心路径或自动路径优化器 |
| 键维自动选出来是 1 | 谱衰减太快,阈值过松 | 打印奇异值谱前 20 个 | 给秩设下限(≥4) |
| 输出全是 NaN | SVD 遇到全零矩阵 | 检查输入是否有全零块 | 加正则项或跳过退化块 |
| 两次运行结果不同 | 随机化 SVD 未固定种子 | 检查是否用了随机算法 | 设固定种子,或改确定性实现 |
| 分解很慢 | 用了完整 SVD 算不需要的秩 | 看 r 和 min(m,n) 的比例 | 换随机化/截断 SVD |
| 精度上不去,加大键维也没用 | 数据本身纠缠熵太高(体积律) | 看谱是否平坦 | 换方法,别硬压 |
6.2 我踩过的五个坑
坑一:reshape 顺序。这个坑我摔了两次。第一次是把行模态和列模态用不同的方式展开,结果重建之后数值对得上但行列全乱了;第二次更隐蔽,我用了一个第三方函数生成张量,它内部用的是 Fortran order,我自己的代码用 C order,单独看都正确,组合起来就错。现在我的固定做法是:在分解入口处np.ascontiguousarray强制一次,并且把模态顺序写在注释里,所有相关的 reshape 都从同一个常量引用。多花五分钟,省下两天。
坑二:把误差阈值当成精度指标。我一度以为 tol 设成 1e-6 就能得到 1e-6 的精度,结果实际误差卡在 1e-4 下不来,加大 tol 也没用。后来才发现是数值精度限制:float64 的机器精度约 2.2e-16,但经过十几次 SVD 和矩阵乘法,误差会被放大;如果中途混用了 float32(比如在 GPU 上),精度天花板直接掉到 1e-6 量级。所以定精度目标之前,先问问自己:数据用什么精度存的?如果全程 float32,把 tol 设到 1e-7 以下纯属自欺欺人。
坑三:忽视负数和复数。我处理的第一个真实数据是实数矩阵,所有测试都正常。换到一个信号处理场景的复数张量之后,范数算出来和预期对不上,查了很久发现我在求范数时忘了取共轭。实数情况下共轭等于自身,所以问题被完全掩盖。现在我写任何涉及内积的代码,都先问一句:输入是复数吗?
坑四:以为压缩比越高越好。早期我追求极致的压缩比,把一个权重矩阵压到 2000 倍,内存确实省了,但下游任务的精度掉了 15 个点,整个模型报废。后来我总结了一个经验区间:对精度敏感的层,压缩比控制在 10 倍以内;对精度不敏感的中间层,可以到 100 倍;超过 200 倍基本要重新考虑方法是否选对。压缩比的收益是线性的(省内存),精度损失是指数的(任务失败),这个杠杆不对称,要保守一点踩。
坑五:盲目上二维结构。我有一个任务是处理图像特征图,直觉上该用 PEPS,毕竟数据结构就是二维的。写了两天代码,发现精确收缩根本跑不动,改成近似收缩之后误差控制完全失控。后来退回去用“按行扫描塞进 MPS”的方案,半天搞完,效果反而好。教训是:结构匹配不等于数值可行,先选数值上跑得动的方案,再谈结构上的优雅。
最后分享一个我现在的习惯:每次做张量分解,先画三样东西——每个键的奇异值谱、每个键的保留秩、重建误差随压缩比的变化曲线。这三张图不用花什么时间,但能让你在动手之前就知道这次压缩的天花板在哪,省掉大量“调到崩溃”的循环。我最近一次做嵌入层的压缩,就是靠那张谱图发现前两个模态的谱几乎是平的,果断放弃了 TT,改用分块量化的方案,最后效果好了不少。工具的价值不在于它多先进,而在于你知不知道自己什么时候该用它、什么时候该换掉它。