☰
TensorFlow 2线性代数实战:从矩阵运算到特征分解与SVD
2026/10/9 8:50:29 网站建设 项目流程

我最早学机器学习那阵,心里一直有个疙瘩:线性代数是考完就忘了,可一到真正写模型、调参、推公式的时候,矩阵又无处不在。直到我老老实实用TensorFlow 2把一套线性回归从零实现了一遍,才彻底想通——机器学习的一次前向传播、一次梯度更新,本质上就是一次次矩阵运算。而像PCA降维、推荐系统、岭回归解析解这些经典操作,背后全是线性代数里那些耳熟能详的概念,只不过在TensorFlow 2里被封装成了几行干净的API。

这篇文章我打算从实操角度把TensorFlow 2的线性代数能力完整过一遍,从张量创建、矩阵乘法、求逆,到特征值分解、奇异值分解、求解线性方程组,再到梯度计算和einsum进阶玩法。核心代码都会给出,你可以直接复制到自己的环境里跑通。本文适合正在学机器学习的朋友,也适合已经在用TensorFlow写模型但想加深底层理解的开发者;如果你正在准备期末复习或项目实践,这里的公式到代码的对照也能帮上不少忙。我的运行环境是Python 3.10 + TensorFlow 2.13 CPU版,代码在GPU环境下完全兼容。

1. 先搞清楚TensorFlow 2里的“线性代数”到底指什么

1.1 机器学习与线性代数:不只是计算公式

很多人把线性代数理解为“计算课”,这其实低估了它。在机器学习里,线性代数是整套系统的基本表达语言。一个最简单的神经网络隐藏层,计算过程就是h = relu(W @ x + b),其中W是权重矩阵,x是输入向量,b是偏置向量。你看,前向传播的每一步都是矩阵-向量乘法,多层网络本质上是矩阵变换的复合。

再往后看,反向传播计算梯度时,链式法则本质上是一连串雅可比矩阵的连乘;线性回归有解析解θ = (XᵀX)⁻¹Xᵀy,这里面同时用到了转置、矩阵乘法、求逆三样东西;PCA降维要对协方差矩阵做特征值分解;推荐系统里的矩阵分解也是靠SVD这类工具。所以我不是吓唬你,线性代数没吃透,机器学习学起来确实像盖楼没打地基。

我还想强调一点:学线性代数不是为了考试时手算三阶行列式,而是为了看懂模型、排查问题、优化性能。比如你训练一个模型发现loss不降,先检查特征矩阵是不是存在严重共线性;你写自定义层时维度对不上,脑子里有矩阵形状的敏感度,很快就能定位问题。这就是我把话放在前头的原因:理解矩阵运算背后的几何意义,比记住某条公式更重要。

1.2 tf.linalg:一个被低估的宝藏模块

TensorFlow 2把大量线性代数运算集中在了tf.linalg模块里。别小看这个模块,它是除了keras.layers之外我觉得最值回票价的API集合。常用的包括:tf.linalg.inv求逆、tf.linalg.pinv求伪逆、tf.linalg.det行列式、tf.linalg.eigh特征值分解、tf.linalg.svd奇异值分解、tf.linalg.solve解线性方程组、tf.linalg.lstsq最小二乘、tf.linalg.norm范数、tf.linalg.trace迹,以及可以统一很多运算的tf.einsum。

和NumPy的numpy.linalg对比,最大的区别在于三点:第一,输出的都是Tensor对象,可以无缝接入模型训练流程;第二,底层能跑在GPU/TPU上,对大矩阵的运算速度远超CPU;第三,整个计算图支持自动微分,你可以对一个包含矩阵分解操作的函数求梯度,这在传统线性代数工具里是做不到的。当然,如果只是做原型验证,用NumPy也很顺手;但一旦要放到训练循环里,或者要对某个自定义层里的矩阵运算求导,就得用TensorFlow了。

我自己的习惯是:数据处理和快速验证用NumPy,模型内部的线性代数运算一律用tf.linalg。两者配合起来,既保证了开发效率,又兼顾了性能和可微性。下面就开始进入实战环节。

2. TensorFlow 2线性代数核心操作实战

2.1 创建张量:从一维到多维

在TensorFlow里,张量(Tensor)就是多维数组。标量是0维,向量是1维,矩阵是2维,再往上是批量张量或多维特征张量。你可以把它想象成一套快递盒:0维是一个没有标签的球,1维是一排球,2维是一张球组成的网格,3维就是一沓这样的网格摞在一起。维度越高,嵌套越深,但每个元素依然是数字。

创建张量的方式很灵活。最常用的是tf.constant和tf.Variable,前者是常量不可变,后者是可训练变量,会自动累积梯度。举例:

import tensorflow as tf # 向量和矩阵 vec_a = tf.constant([1.0, 2.0, 3.0]) mat_a = tf.constant([[1.0, 2.0], [3.0, 4.0]], dtype=tf.float32) # 全零、全一、单位矩阵 zeros_mat = tf.zeros([3, 3]) ones_vec = tf.ones([4]) identity = tf.eye(3, dtype=tf.int32) # 随机张量,常用作权重初始化 random_mat = tf.random.normal(shape=[2, 4], mean=0.0, stddev=0.1) # 可训练变量 w = tf.Variable(tf.random.normal([3, 1], stddev=0.01))

这里有个新手容易忽略的点:dtype。TensorFlow里的线性代数运算对dtype很敏感,float32和float64混用会直接报错。默认情况下tf.constant([1, 2, 3])是int32,但矩阵求逆、特征分解这类运算要求浮点类型,所以我在创建矩阵时会习惯性地加上dtype=tf.float32或tf.float64。

还有一点,tf.Variable和tf.Tensor完全不同。变量可以被赋值更新,常量不行。在很多教程代码里,tf.Variable被用来存放模型权重,而线性代数推导时用tf.constant更安全,因为不会有意外修改的风险。调试阶段我经常用print(x.shape)和print(x.dtype)来确认每一步的输入都符合预期。

需要理解矩阵的基本印象:一个[m, n]的矩阵,是m行n列。mat_a.shape返回(2, 2),表示2行2列。后续所有维度错误排查,都依赖你到底有没有搞清楚自己手里是个什么形状。

2.2 矩阵乘法:前向传播的地基

矩阵乘法是整个深度学习里出现频率最高的运算,没有之一。TensorFlow里用tf.matmul(a, b),也可以直接写成a @ b,后者更简洁。

矩阵乘法的维度规则是:A的形状是(m, k),B的形状是(k, n),结果形状是(m, n)。内层维度必须相等,也就是A的列数必须等于B的行数。用生活场景类比,就像流水线上两道工序:第一道工序有k种零件,第二道工序刚好接收这k种零件,才能继续往下走。

a = tf.constant([[1.0, 2.0], [3.0, 4.0]]) # (2, 2) b = tf.constant([[5.0, 6.0], [7.0, 8.0]]) # (2, 2) c = a @ b # (2, 2) print(c.numpy()) # [[19. 22.] # [43. 50.]]

这里要特别注意,a * b是逐元素相乘(Hadamard积),a @ b才是矩阵乘法。刚上手时特别容易搞混,尤其是从NumPy转过来的同学,因为NumPy里*对二维数组也是逐元素乘。神经网络里的前向传播y = W @ x + b用的就是矩阵乘法,如果误用了逐元素乘,整个模型就算废了。

批量矩阵乘法也经常用到。比如一批数据X的形状是(batch_size, n_features),权重矩阵W是(n_features, n_classes),直接X @ W得到(batch_size, n_classes),这就是标准批量前向传播。如果是三维以上的张量,比如注意力机制里的Q、K、V矩阵,形状是(batch_size, heads, seq_len, dim),你也可以直接q @ k,只要最后两维满足维度匹配规则,前面的批量维度会自动对齐。

我在实际项目中踩过的坑是:写自定义层时,某个矩阵需要先做转置再乘,结果忘了转置,直接a @ b,维度倒是能对上,但计算完全错误。所以每次写矩阵乘法前,我都会在草稿纸上写出两个形状,或者干脆加一行assert a.shape[-1] == b.shape[-2],能节省大量排查时间。

2.3 转置、求逆与伪逆:让矩阵“可解”

转置可能是我用得最多的操作之一。tf.transpose(a)会把矩阵的维度对调,对二维矩阵来说就是行列互换。如果你处理的是向量,一维向量转置后看起来没变化,因为它只有一维;真正需要转置的通常是二维矩阵或更多维张量。还有一个常用的perm参数可以指定维度交换顺序,比如把(batch, seq, dim)换成(batch, dim, seq),这在注意力机制里非常常见。

求逆是线性代数的经典操作。tf.linalg.inv直接给出方阵的逆矩阵。但我要提醒你:工程上直接求逆不是最优解,尤其是矩阵规模较大或者接近奇异时,求逆既慢又不稳定。最典型的例子是线性回归的解析解:θ = (XᵀX)⁻¹Xᵀy,教科书会教这个公式,但实际工程里更推荐用tf.linalg.solve或tf.linalg.lstsq来解。原因在于求逆的计算复杂度是O(n³),而且数值误差会被放大。

如果矩阵是奇异矩阵(行列式为0、不可逆),或者根本不是方阵,就需要用到伪逆tf.linalg.pinv,也就是摩尔-彭罗斯广义逆。伪逆在最小二乘问题、欠定方程组里非常有用。比如你有一个(m, n)的矩阵,m < n(方程数小于未知数),正规的逆矩阵不存在,但pinv能给出一个最小范数解。

A = tf.constant([[2.0, 0.0], [0.0, 4.0]]) inv_A = tf.linalg.inv(A) print(inv_A.numpy()) # [[0.5 0. ] # [0. 0.25]] # 伪逆用在长方形矩阵上 B = tf.constant([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) # (3, 2) pinv_B = tf.linalg.pinv(B) print(pinv_B.shape) # (2, 3)

理解求逆和伪逆的区别,最简单的方式是看解方程的角度。一个可逆方阵表示方程组有唯一解,求逆相当于一口气算出所有变量;而超定方程组(样本多于特征)通常没有精确解,只能求最小二乘意义下的最优解,这正是伪逆的用武之地。

3. 特征值分解和奇异值分解的实践应用

3.1 特征值分解:从PCA到矩阵对角化

特征值和特征向量的定义很简短:对矩阵A,如果存在非零向量v和标量λ,使得A v = λ v,那么v是特征向量,λ是特征值。几何意义就是:矩阵A作用于向量v后,只会让v伸缩,而不会改变方向。你可以想象有一根弹簧,顺着它的轴线拉,方向不变,只是长度变了;特征向量就是那根轴线方向,特征值就是拉长或压缩的比例。

深度学习里最常见的特征值分解应用是PCA降维。PCA的本质是找到一个正交变换,把数据映射到方差最大的方向上。具体步骤是:先对数据做中心化,计算协方差矩阵C, 然后对C做特征值分解,取特征值最大的前k个特征向量作为主方向。在TensorFlow 2里,这一步非常简洁:

# 假设 X 是 (n_samples, n_features),先中心化 X = tf.random.normal([100, 5]) X_mean = tf.reduce_mean(X, axis=0) X_centered = X - X_mean # 协方差矩阵 C = tf.matmul(X_centered, X_centered, transpose_a=True) / tf.cast(tf.shape(X)[0], tf.float32) # 特征值分解,返回特征值和特征向量 eigenvalues, eigenvectors = tf.linalg.eigh(C) # 按特征值降序排序 idx = tf.argsort(eigenvalues, direction='DESCENDING') eigenvectors = tf.gather(eigenvectors, idx, axis=1) # 取前2个主成分 principal_components = X_centered @ eigenvectors[:, :2]

我想提醒你一个细节:tf.linalg.eigh专门针对实对称矩阵或复Hermitian矩阵,因为协方差矩阵天然是实对称的。eigh内部使用了更高效的算法,并且保证特征值有序(默认升序)。如果你用通用的tf.linalg.eig去分解对称矩阵,也能得到结果,但速度更慢、数值稳定性更差。所以在PCA这类场景里,请使用eigh。

如果你手头有经典的线性代数教材作参考,比如很多人用过的基础教材,特征值那一章会花不少篇幅教人手算特征多项式、求行列式。工程上我们完全不需要手算,数值方法才是主流,但我依然建议你把公式推导过一遍,这样才明白eigh到底在算什么。

3.2 奇异值分解:更稳定的矩阵分解工具

奇异值分解(SVD)是比特征值分解更通用的工具。任意一个实矩阵都可以分解为A = U Σ Vᵀ,其中U和V是正交矩阵,Σ是对角矩阵,对角元素叫奇异值。SVD不仅适用于方阵,也适用于任意形状的矩阵,这一点让它成了无数算法的主力。

TensorFlow里的tf.linalg.svd返回三个结果:奇异值s、左奇异向量u、右奇异向量v。重建公式是A = u @ diag(s) @ transpose(v),注意这里的v是右奇异向量矩阵本身,不是转置结果,和NumPy里返回Vh的习惯并不一样,重构时一定要记得手动转置。这样设计的好处是你可以拿到v的原始定义,方便做特征向量计算。

A = tf.constant([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) # (3, 2) s, u, v = tf.linalg.svd(A, full_matrices=False) # 重建验证 sigma = tf.linalg.diag(s) A_hat = u @ sigma @ tf.transpose(v) print(tf.reduce_max(tf.abs(A - A_hat)).numpy()) # 接近0

SVD最有代表性的应用有三个。第一个是低秩近似和图像压缩:只保留最大的若干个奇异值,丢弃小的,就能用更低维度近似原矩阵,存储量大幅下降。第二个是推荐系统里的矩阵分解:把用户-物品评分矩阵分解成用户隐向量、奇异值、物品隐向量的乘积,再做召回和排序。第三个是矩阵秩的数值判断:奇异值中非零的个数就是矩阵的秩,但浮点运算下更实用的做法是统计大于某阈值的奇异值数量。

SVD比特征值分解稳定的原因在于,SVD不需要矩阵是方阵,也不要求矩阵满秩,它直接从矩阵本身的结构出发,数值算法更稳健。所以在面对病态矩阵、非方阵、共线性严重的场景时,SVD通常是更安全的选择。

4. 求解线性方程组与最小二乘法

4.1 用tf.linalg.solve解线性方程组

解线性方程组Ax = b是线性代数最基础的场景之一。机器学习里的解析解、控制理论里的状态估计,都离不开它。TensorFlow提供了tf.linalg.solve,它的用法非常直观:传入系数矩阵A和右侧向量b,返回解向量x。

A = tf.constant([[2.0, 1.0], [1.0, 3.0]]) b = tf.constant([1.0, 2.0]) x = tf.linalg.solve(A, b) print(x.numpy()) # [0.2 0.6]

你可能会问:既然x = inv(A) @ b也能解,为什么非要用solve?答案是效率和稳定性。求逆需要完整算出A⁻¹,再乘一次向量,计算量是O(n³)还要额外做一次矩阵乘向量;而solve底层通常使用LU分解或Cholesky分解,直接把b带入求解过程,避免显式求逆,既快又稳。我的比喻是:求逆相当于为了去10楼,先把整栋楼的电梯都修一遍;solve则是直接找到一条能到10楼的路线,后者显然更省事。

请注意,solve要求A必须是方阵且可逆。如果矩阵奇异,会直接抛出InvalidArgumentError,提示矩阵不可解。在实际项目中,我发现很多新人拿到一个长方形矩阵就去solve,结果报错。此时要先判断问题属于“方程组数量等于未知数”还是“样本多于特征”,后者通常要用lstsq。

4.2 最小二乘与tf.linalg.lstsq

现实世界的机器学习问题几乎都是超定方程组:样本数量远大于特征数量,也就是方程个数大于未知数个数。此时Ax = b往往没有精确解,只能找让残差平方和||Ax - b||²最小的近似解,这就是最小二乘。

TensorFlow的tf.linalg.lstsq正是为此准备的。用法同样简单:

# 构造一组线性数据 y = 2*x + 3 + 噪声 t = tf.linspace(-1.0, 1.0, 50) X = tf.stack([t, tf.ones_like(t)], axis=1) # (50, 2) y = 2.0 * t + 3.0 + tf.random.normal([50], stddev=0.1) # 最小二乘求解系数 coeffs = tf.linalg.lstsq(X, y) print(coeffs.numpy()) # 近似 [2.0, 3.0]

底层实现通常走QR分解或SVD路线,数值稳定性比教科书里的正规方程θ = (XᵀX)⁻¹Xᵀy好很多。这里我踩过一个非常经典的坑:用正规方程时,如果XᵀX接近奇异(特征之间有强相关性),求逆结果会有很大的数值误差,甚至完全跑偏;但换成lstsq就稳多了,因为它不会直接构造XᵀX这个病态矩阵。

lstsq接的输入,第一个参数是设计矩阵,第二个参数是观测值。如果观测值是多维的,它也会自动按列求解,非常方便。我做线性拟合时,经常把数据组织成(n_samples, n_features)的矩阵,最后一列加个1作为偏置项,然后交给lstsq一把推出所有系数。这个方法虽然古老,但用来做基线模型、验证数据质量、检查特征间的共线性,依然特别好使。

5. 进阶玩法:梯度、einsum与自动微分

5.1 GradientTape与雅可比矩阵

现代深度学习框架的杀手锏是自动微分,而自动微分底层和线性代数密不可分。tf.GradientTape会把前向计算过程中涉及到的操作记录下来,然后通过反向传播计算梯度。反向传播的每一步,都是雅可比矩阵与梯度的连乘。

举个例子,计算一个简单函数的导数:

x = tf.Variable(3.0) with tf.GradientTape() as tape: y = x ** 3 dy_dx = tape.gradient(y, x) print(dy_dx.numpy()) # 27.0,因为 3 * 3^2 = 27

如果要算雅可比矩阵,也就是输出向量对输入向量的偏导数矩阵,可以用tape.jacobian:

x = tf.Variable([1.0, 2.0, 3.0], dtype=tf.float32) with tf.GradientTape() as tape: y = x * x # 逐元素平方 jac = tape.jacobian(y, x) print(jac.numpy()) # 对角矩阵,每个元素是 2x_i

很多人写自定义模型时不理解为什么可以随便改中间的矩阵运算,只要保证可微就行。其实,只要你的运算里没有不可导的跳变(比如除以零、tf.clip_by_value激进截断),GradientTape都能正确地把梯度算出来。这就是用Linear algebra组件的好处:矩阵乘法、求逆、SVD在TensorFlow里都是可导操作,你甚至可以写一个损失函数,里面包含SVD,照样反向传播。我在实验里试过对tf.linalg.svd的结果求梯度,确实能跑通,这是传统线性代数库绝对给不了的。

5.2 einsum:用爱因斯坦求和记号优雅地消灭循环

如果说tf.linalg是线性代数工具库,那么tf.einsum就是一套通用张量运算语言。爱因斯坦求和记号的核心思想是:用字符串描述一个运算,比如'ij,jk->ik'表示矩阵乘法。TensorFlow会解析这个表达式并优化执行,你根本不用写循环。

用途极广,下面几个例子覆盖了最常见的场景:

# 矩阵乘法 x = tf.random.normal([2, 3]) y = tf.random.normal([3, 4]) z = tf.einsum('ij,jk->ik', x, y) # 转置后相乘:ij,jk -> ki 相当于 A.T @ B # 批量矩阵乘法:bij,bjk->bik # 对角线求和:ii->(相当于 trace) # 向量点积 a = tf.constant([1.0, 2.0, 3.0]) b = tf.constant([4.0, 5.0, 6.0]) dot = tf.einsum('i,i->', a, b)

在我看来,einsum最大的价值是让代码意图一目了然。你写tf.einsum('bij,bjk->bik', a, b),别人一看就知道是批量矩阵乘法;而如果写一堆tf.expand_dims、tf.transpose、tf.matmul,读起来就痛苦很多。对性能敏感的场景,einsum还会自动做计算调度,减少中间变量的内存占用。

不过有个小坑:einsum的表达式写错后报错信息有时比较隐晦,比如InvalidArgumentError: einsum does not support repeated indices。我的建议是先用小尺寸样例验证表达式是否符合预期,再把真实数据套进去。我就吃过这个亏,在多头注意力里用einsum实现QK^T乘积时,下标顺序写反,跑了几步才发现梯度完全不对。

6. 常见问题与排查技巧实录

6.1 维度不匹配报错实战排查

矩阵维度错误是TensorFlow新手遇到最多的报错。典型的错误信息长这样:InvalidArgumentError: Matrix size-incompatible: In[0]: [2,3], In[1]: [4,5]。这句话直接告诉你,左边矩阵形状是(2,3),右边是(4,5),中间维度3和4对不上。

遇到这个问题,我的排查顺序是:先打印所有涉及的张量形状,确认到底谁和谁相乘;然后检查是否需要先tf.transpose;再检查批量维度是否一致。特别容易忽略的是,(batch, seq, dim)和(dim, hidden)相乘时没有问题,但设计成(batch, heads, seq, dim)后,里面某一个维度就要小心对齐。建议在关键位置加一个断言:

assert a.shape[-1] == b.shape[-2], f"维度不匹配: {a.shape} vs {b.shape}"

调试时多用print(tensor.shape)比看报错信息更直观。我一般在自定义层的第一行就打印输入形状,跑一次小样,确认没问题再取消打印。这个方法看似笨,但能省下大量在报错堆栈里翻来翻去的时间。

6.2 数值问题:NaN、奇异矩阵、float32精度

数值问题是我在TensorFlow线性代数实战中遇到的最隐性的一类坑。第一种是NaN。NaN的来源很多:数据里本身存在无穷值或NaN、梯度爆炸、求逆时遇到奇异矩阵。我遇到最典型的场景是:特征矩阵存在完全共线的两列,导致XᵀX不可逆,这时候用tf.linalg.inv大概率得到NaN或无穷值。解决方案是改用tf.linalg.pinv或tf.linalg.lstsq,并检查特征相关性。

第二种是float32精度问题。TensorFlow默认使用float32,在大多数模型训练场景没问题,但做特征值分解、SVD时,如果矩阵条件数很大,float32的精度可能不够,导致特征向量结果有明显误差。我的做法是:复现数学推导、验证算法正确性时,把dtype换成float64;正式训练模型时再切回float32,因为训练的前向反向传播对精度宽容得多。

第三种是奇异矩阵的处理。你在实验里可能会碰到行列式为0、solve直接报错的情况。一个很实用的技巧是给矩阵加一个小的对角噪声,比如A = A + 1e-6 * tf.eye(n),叫做Tikhonov正则化,能有效缓解奇异性。这个思路在岭回归里用得很多,底层逻辑都一样。

我把常见问题整理成了一张速查表,方便你直接对照:

现象可能原因排查方向常用解决方案
Matrix size-incompatible矩阵乘法维度不匹配打印两矩阵shape,检查内层维度转置、reshape、修正下标
NaN出现在loss/权重数据含NaN、梯度爆炸、奇异矩阵检查输入数据、梯度范数、矩阵条件数清洗数据、梯度裁剪、改用pinv/lstsq
solve报错矩阵不可解矩阵奇异或非方阵检查矩阵秩和形状加对角噪声、改用lstsq/pinv
特征向量结果异常float32精度不足对比float64结果临时切float64验证
einsum报错下标表达式书写错误检查字符串里的维度符号小规模数据先行验证

最后再分享一个我自己的实践习惯

学TensorFlow线性代数最舒服的姿势,是“带着公式来,带着代码走”。我会在纸上把线性回归解析解、PCA推导手推一遍,然后立刻用tf.linalg把每一步翻译成代码。手推公式能让我理解为什么要有转置和求逆,写代码能让我快速验证自己的理解对不对。书上的公式和实际API对不上的时候,多半是维度约定不同,这时不要硬套,先查API文档再手算一次。

另外一个我自己很受用的技巧是:把tf.linalg和GradientTape组合起来做“数学实验”。比如想验证某个矩阵分解性质,直接建一个小张量,前向计算后求梯度,看梯度是否符合理论值。这种方法比用纸笔推导快得多,而且能锻炼对张量形状的直觉。

如果你想把这一块练扎实,建议从这三件事开始:把线性回归的解析解手推一遍再用代码实现;对一组高维数据做PCA并对比sklearn的结果;用一个简单的用户-物品评分矩阵跑SVD分解,理解推荐系统里隐向量的含义。做完这三步,你再看TensorFlow里的很多模型代码,会突然觉得顺眼许多——因为底下跑的那些东西,无非就是一座巨大的线性代数运算场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询