1. 为什么机器学习的门槛会有一块"线性代数"
我在准备写机器学习系列的时候,最先要补的不是某个模型,而是 NumPy 线性代数那一层。原因很简单:不管你是去读别人的训练代码,还是想自己从零复现一个回归模型,你遇到的第一处门槛几乎都是矩阵运算——一组数据要作为矩阵参与计算,权重是矩阵,损失函数对权重的求导结果也是矩阵。这篇博文就准备把机器学习里最常用、最容易绕晕的一批线性代数操作,用 NumPy 一条一条讲明白,尽量让每个操作都能拿到编辑器里直接跑一遍验证。
这系列会从纯新手的视角出发,默认你只写过 Python 基础语法,可能见过列表和字典,但没系统碰过 NumPy。我会把每个函数为什么这样用、矩阵形状怎么对齐、哪一步容易报错都讲透。等你把这篇文章啃完,再去看后面的梯度下降、神经网络、PCA 这些章节,会发现很多公式的"手感"已经通了。
1.1 一张表就是最朴素的一个矩阵
先建立一个直觉:机器学习处理的数据,在计算机里本质上就是一张矩形表。比如房价预测,你收集到 100 套房子的数据,每套房子有面积、房龄、卧室数量、地段评分、最终成交价五个字段。把价格单独拎出来当标签,剩下的四列就是 100 行 4 列的数字,这就是一个矩阵,在 NumPy 里叫 ndarray,形状记作(100, 4)。
矩阵的行通常表示样本,列通常表示特征。这个约定贯穿几乎所有机器学习代码。比如一张 28×28 的灰度图可以拉直成 784 个像素值,那么 32 张图就拼成一个(32, 784)的矩阵;一批评论经词频统计变成 1000 个词向量,整个语料库就是(5000, 1000)。图像、文本、表格,最后都要落到这种规则矩形上才能喂给数值计算库。
为什么要强调"矩阵"而不是"二维列表"?因为矩阵背后有一套完整的运算体系:矩阵乘、转置、求逆、特征分解。这些运算不是 Python 的 list 能直接承担的,它们决定了线性代数这门工具能在机器学习里发挥多大作用。你可以把(样本数, 特征数)这个形状当成描述数据的第一句话——拿到任何一个数据集,先打印.shape,再谈其他。
1.2 把 for 循环换成一整套矩阵运算
同一个计算任务,用三重 for 循环写和用矩阵乘法写,结果一样,但性能天差地别。因为 NumPy 的矩阵乘法底层调用的是经过高度优化的 BLAS 线性代数库,这些库用 C、Fortran 甚至汇编实现,循环展开、缓存友好、并行化全都比你手写的 Python 循环强。
更重要的是,矩阵写法让公式变得极其简洁。线性回归的目标是找到一组权重 w,使得预测值接近真实值 y:单个样本是y = w1*x1 + w2*x2 + w3*x3 + b,写成的话每个样本都要写一遍。可如果你把所有样本叠成矩阵 X,那预测就是一行X @ w + b。整个训练过程绕不开的公式w = (X^T X)^{-1} X^T y,本质上是矩阵乘法和求逆的组合;如果坚持用循环表达,这个公式几乎没法读,也没法往更深的方向推。
这就是为什么我建议你从线性代数入手学机器学习:它不是数学考试,而是代码里的日常语法。后面神经网络里的前向传播、反向传播,以及各种正则化、降维、特征变换,全部建立在这套矩阵语言上。把 NumPy 的线性代数基础打牢,等于提前给后面所有模型装好了语法引擎。
2. 一上来就把 ndarray 的形状捏明白
2.1 shape、ndim、dtype、axis 到底在说啥
在 NumPy 里,所有数据都被包装成 ndarray 对象。最需要先记住的是四个属性:shape是各维度长度组成的元组,ndim是维度个数,dtype是元素类型,size是元素总个数。你在调试时经常要做的第一件事就是打印它们的值,尤其是 shape。
axis这个概念最容易让新手懵。对于二维数组,可以靠"掐括号"来记:axis=0就是把最外层括号去掉,剩下的元素按列操作;axis=1就是把第二层括号去掉,剩下的元素按行操作。举个例子,对二维数组执行arr.sum(axis=0),得到的是每一列求和结果组成的一维数组;执行arr.sum(axis=1),得到的是每一行求和结果。对于三维数组,axis=2对应最内层,逻辑完全一样。
import numpy as np arr = np.array([[1, 2, 3], [4, 5, 6]]) print(arr.shape) # (2, 3) print(arr.ndim) # 2 print(arr.sum(axis=0)) # [5 7 9] 每列之和 print(arr.sum(axis=1)) # [6 15] 每行之和我见过很多人在做数据预处理时把 axis 搞反,得到的结果形状完全对不上。这里给一个最笨但最稳的检查方法:先在心里想清楚你期望的输出是多少个数,再反推 axis。比如求每个特征的均值,特征在横轴(axis=1),样本在纵轴(axis=0),那结果自然是一个长度为特征数的向量,对应axis=0方向上的聚合。
2.2 创建、reshape 与转置的底层视角
NumPy 提供了四个最常用的构造器:np.zeros建全零矩阵,np.ones建全一矩阵,np.eye建单位矩阵,np.arange生成等差数列。后面两个特别常用:单位矩阵在线性代数里相当于数字 1,做矩阵变换时经常要初始化它;np.arange则能快速生成测试数据,配合reshape使用。
A = np.zeros((3, 4)) B = np.eye(4) C = np.arange(12).reshape(3, 4)重点说reshape。NumPy 在变形时默认按 C 顺序读取元素,也就是最后一个维度变化最快:np.arange(12).reshape(3, 4)得到的是[[0,1,2,3],[4,5,6,7],[8,9,10,11]],而不是按列填充。如果元素个数能整除,你还可以写-1让 NumPy 自动推断某一维,比如np.arange(12).reshape(-1, 4)会自动算出共有 3 行。这个写法在不知道样本数时非常省心。
转置A.T会交换两个轴。对于二维矩阵,A.T就是把(m, n)变成(n, m)。对于更高维的数据,要用np.transpose(A, (1, 0, 2))这种方式显式指定轴的顺序。举个例子:一批时间序列数据的形状是(样本数, 时间步, 特征数),你想把时间步和特征数互换,直接写A.transpose(0, 2, 1)就好。这一步在后续处理多通道输入、循环神经网络的数据时经常出现。
2.3 大部分维度报错都出在索引和变形上
新手写 NumPy 代码,报错十有八九来自维度不匹配,但也有很多来自切片和索引带来的"意外降维"。比如arr[1]取的是第二行,结果是带一维数组;arr[:, 1]取的是第二列,结果也是一维数组。索引只取一个维度时,那个维度就被"掐掉"了。如果你不希望维度被掐掉,需要改用arr[1:2, :]或arr[:, 1:2],这样会保留二维结构。
布尔索引也是高频操作:arr[arr > 2]会把所有大于 2 的元素筛出来,返回一维数组。这个能力很适合做异常值过滤,比如把概率矩阵里小于 0.5 的项全部清零。但注意,布尔索引的结果一定是复制品,不是视图,修改它不会影响原数组。
还有一个容易出问题的细节:切片和reshape返回的通常是视图而不是副本。视图意味着新旧数组共享内存,改一个,另一个跟着变。这点后面我会单独拿出一节讲,因为它真的是生产环境下最容易踩的隐匿坑。提前记住一个习惯:当你不能确定某个操作是否安全时,显式调用.copy()是最省心的做法。
3. 矩阵乘法与广播:机器学习的引擎
3.1 np.dot、@、np.matmul 之间的界限
矩阵乘法是线性代数在机器学习里的重头戏。NumPy 里至少有三个东西能做这件事:np.dot、np.matmul、@运算符。从功能上来说,@就是np.matmul的运算符写法,推荐日常使用;np.dot则更古老且行为更复杂。
np.dot对一维数组执行内积,对二维数组执行矩阵乘,但对于更高维数组,它的行为会变成"在数组的最后两个轴上进行乘法",容易让你忘记维度规则。np.matmul的规则更统一:除了向量特殊情况外,它强制要求参与运算的两个数组维度至少为二维,并且遵守"内层维度相同"这个条件。实际项目中我几乎只用@,因为它读起来最接近数学写法,也最直观。
x = np.array([1., 2., 3.]) W = np.arange(12, dtype=float).reshape(3, 4) z = x @ W # (3,) 与 (3, 4) 相乘,结果是 (4,)矩阵乘法的维度规则一句话:两个矩阵A(m, k)和B(k, n)相乘,得到C(m, n)。中间的k必须相等。这就像工厂流水线:A 的输出规格必须和 B 的输入规格一致,否则整条生产线卡住。写完乘法后,我通常会立刻把结果的 shape 算一遍,心里默念 "最后一个维度和倒数第二个维度对齐了没有"。
3.2 一个 32 张图片小批次的前向传播
拿神经网络最基础的全连接层举个例子。假设你有一批 32 张 28×28 的灰度图,每张图拉平后变成 784 个像素值,这批数据的形状就是(32, 784)。你要把这批数据映射到一个 64 维的隐藏层,就需要一个权重矩阵W(784, 64),以及一个长度为 64 的偏置向量b。
前向传播的写法干净利落:
X = np.random.randn(32, 784) # 32 个样本,每个 784 维 W = np.random.randn(784, 64) # 把 784 映射到 64 b = np.random.randn(64) # 64 个神经元各自的偏置 Z = X @ W + b # 结果 shape: (32, 64)X @ W是(32, 64),这一点好理解;神奇的是+ b,b的形状是(64,),却能和(32, 64)相加。这就是广播机制在工作:NumPy 自动把b沿着样本维度扩展开,等价于每一行都加上同一个偏置向量。换句话说,线性层的基础运算就是"一次矩阵乘法加一次广播加法",神经网络堆得再深,底层核心运算单元就是这一套。
3.3 广播机制:形状不一致时怎么算的
广播的规则其实只有两条:从尾部维度开始依次比较;两个维度要么相等,要么其中一个等于 1,然后这两个维度会扩展到二者中较大的那个。如果两个维度既不相等也不为 1,就报错。
举例:A的形状是(3, 1),B的形状是(1, 4),相加时3与1对齐后扩展成3,1与4对齐后扩展成4,结果就是(3, 4)。这正是做特征交叉、外积时的常用技巧:想让两个向量做"外积"而不是内积,先分别 reshape 成(n, 1)和(1, m)即可。
a = np.array([1, 2, 3]) # 形状 (3,) b = np.array([4, 5]) # 形状 (2,) outer = a[:, None] + b[None, :] # 形状 (3, 2)广播最容易被忽略的坑是"隐式复制"的误解:广播并不是真的把所有数据复制一遍,大多数情况下 NumPy 是零拷贝地按需读取,所以不用担心内存暴涨,但你要担心的是形状是否符合预期。如果你想要两个(3,)向量得到(3, 3)的交叉结果,必须先 reshape。许多人写a + b以为自己得到的是元素级相加,实际上得到长度还是3的向量。这个区别在实现特征工程时非常重要。
4. 内积、范数与迹:让公式不再飘在空中
4.1 内积与余弦相似度
向量内积是线性代数里最朴素的运算:对应位置相乘再求和。在 NumPy 里可以用np.dot(x, y)、x @ y或者np.sum(x * y)实现。内积的大小受向量长度影响,两个方向相同但长度差很大的向量,内积也会很大,所以做相似度时通常要归一化成余弦相似度:
def cos_sim(x, y): return (x @ y) / (np.linalg.norm(x) * np.linalg.norm(y))余弦相似度在文本向量、推荐系统里几乎无处不在。词向量也好、用户行为向量也罢,比较两个对象像不像,先归一化方向再算内积,得到的是一个[-1, 1]的数,越接近 1 代表方向越一致。这个操作看似简单,却是很多检索模型的基础组件。
4.2 norm:给向量和矩阵一把尺子
np.linalg.norm是 NumPy 的范数函数。默认计算的是 L2 范数,也就是欧几里得长度,等于所有元素平方和再开根号。ord=1时计算绝对值之和,ord=np.inf时取最大绝对值。在高维空间里,不同范数定义出不同的"尺度",也会带来不同的机器学习效果。
L2 正则化很直观:在损失函数后面加一项λ * ||w||_2^2,它的梯度是2λ w,所以每次更新权重时都会多减去一个与 w 成正比的量,让权重整体不会太大,这就是权重衰减的由来。L1 正则化加的是λ * ||w||_1,梯度带有方向性,会让一部分权重被直接压到 0,从而带来稀疏解。规范性地讲:L2 防止过拟合靠缩小规模,L1 靠筛选特征。这两个正则项在 NumPy 里的实现,基点都是 norm。
| 范数 | 公式 | 典型用途 |
|---|---|---|
| L2 | 平方和后开根 | 欧氏距离、权重衰减 |
| L1 | 绝对值求和 | 稀疏化、特征选择 |
| 无穷 | 取最大绝对值 | 稳定性分析 |
| Frobenius | 矩阵所有元素平方和后开根 | 矩阵重建误差 |
4.3 迹和矩阵内积:损失函数推导的捷径
矩阵的迹是主对角线元素之和,np.trace(A)一行搞定。单独看它似乎没什么用,但它和矩阵内积绑在一起时极其强大。两个同尺寸矩阵的内积可以写成sum(A * B),也可以写成trace(A.T @ B)。Frobenius 范数也能用迹重新表达:||A||_F = sqrt(trace(A.T @ A))。
这个等价关系在推导最小二乘时是绝佳工具。比如目标函数||XW - Y||_F^2,用迹展开后可以得到:
trace((XW - Y)^T (XW - Y))
展开后对 W 求导,最终结果是2 X^T (XW - Y),这正好是线性回归里最常出现的梯度形式。如果你直接对矩阵求导觉得玄,用迹展开能绕开很多"张量求导"的复杂规则。理解迹的引用,能让你在推导不少损失函数梯度时顺畅很多。
5. 特征值分解与 SVD:找到数据的主轴
5.1 从协方差矩阵到 PCA
当你面临高维数据时,第一步往往不是直接训练模型,而是先看哪些方向上的信息量最大。协方差矩阵是这个问题的主角:对于已经中心化(每列减去均值)的数据矩阵 X,它的协方差矩阵近似等于X.T @ X / m。协方差矩阵是对称矩阵,对称矩阵可以被正交对角化,特征值越大,对应的特征向量方向上的数据方差也越大。
PCA 的操作路径就清晰了:先算协方差矩阵,再用np.linalg.eigh做特征分解,取前 k 个最大的特征值对应的特征向量,把原始数据投影到这些方向上。注意这里我用的是eigh而不是eig,因为eigh专门处理对称矩阵,数值更稳定、速度更快。
X = np.random.randn(100, 4) # 100 个样本,4 个特征 Xc = X - X.mean(axis=0) # 中心化 C = Xc.T @ Xc / (Xc.shape[0] - 1) # 协方差矩阵 vals, vecs = np.linalg.eigh(C) idx = np.argsort(vals)[::-1] # 按特征值从大到小排序 top_k = vecs[:, idx[:2]] # 取前两个主成分方向 X_pca = Xc @ top_k # 投影到二维这段代码相当于把四维数据压到二维,同时尽量保留方差信息。特征值排序这一步别漏,因为eigh默认返回的特征值是从小到大排列的,不[::-1]一下会取到方差最小的方向。
5.2 用 SVD 做一轮图像压缩实验
SVD 是特征分解的"大统一"版本,任意矩阵 X 都能分解成U S V^T的形式,其中 U 和 V 是正交矩阵,S 是由奇异值组成的对角矩阵。NumPy 用np.linalg.svd(X, full_matrices=False)返回三个量:左奇异向量 U、奇异值数组 s、右奇异矩阵 V 的转置 Vt。注意 s 返回的是一维数组,不是对角矩阵。
SVD 一个经典应用是低秩近似。保留前 r 个奇异值,可以得到原矩阵的近似:U[:, :r] @ diag(s[:r]) @ Vt[:r, :]。对图像来说,这意味着用 256×r + 256×r + r 个数代替 256×256 个数,压缩率相当可观。
img = np.random.rand(256, 256) * 255 # 模拟一张灰度图 U, s, Vt = np.linalg.svd(img, full_matrices=False) r = 20 approx = (U[:, :r] * s[:r]) @ Vt[:r, :] # 低秩重建 # 原图存储量 256*256,重建存储量 256*20 + 20*256 + 20实际图片的奇异值往往衰减很快,所以只保留前 20 个奇异值就能恢复出大致的轮廓,这就是图像压缩的朴素原理。如果你遇到的是大稀疏矩阵,直接调np.linalg.svd会非常慢,内存也可能撑不住,这时候应该换用迭代式 SVD 实现,或者先对数据做有损降维。
5.3 特征值、奇异值与"信息量"的关系
特征值和奇异值本质上都在回答同一个问题:沿着某个方向,数据的变化幅度到底有多大?变化的幅度大,说明这个方向承载的信息多;幅度小甚至接近零,说明这个方向基本是冗余的。所以降维不是随便丢掉几个特征,而是丢掉"变化幅度小"的方向。这也是为什么 PCA 前几个主成分往往能解释大部分方差。
在推荐系统里,用户-物品评分矩阵极度稀疏且庞大,常用的协同过滤算法也建立在低秩近似思想上:把整个矩阵拆成用户矩阵和物品矩阵的乘积,本质上就是在找一组低维的"隐特征方向"。理解 SVD,你在看到这类模型时就不会只觉得它们是个黑盒。
6. 解方程和最小二乘:线性回归的最后一公里
6.1 solve、lstsq、pinv 怎么选
求解线性方程组Ax = b是线性代数最古老的问题。在 NumPy 里,如果 A 是方阵且满秩,最推荐np.linalg.solve(A, b),它使用 LU 分解,速度快、数值稳。千万别图省事写np.linalg.inv(A) @ b,求逆的代价更高,数值误差也更大。如果 A 不是方阵,或者 A 是方阵但奇异,问题就变成最小二乘问题,用np.linalg.lstsq(A, b, rcond=None)或者np.linalg.pinv(A) @ b。
lstsq返回四个值:解向量、残差、矩阵的秩、奇异值数组。rcond=None表示让 NumPy 根据奇异值自动决定截断阈值,把接近零的奇异值置零,避免小奇异值把噪声无限放大。这个细节对病态问题几乎有救命意义。
6.2 亲手拟合一条直线:带截距的最小二乘
我们来跑一个完整的线性回归例子。先造一批带噪声的数据,真实关系是y = 2x + 1,然后故意让np.linalg.lstsq去恢复出这两个系数。注意一点:要拟合带截距的直线,必须在 X 矩阵里加一列 1,否则拟合结果根本没有"截距"这个位置。
x = np.linspace(0, 10, 100) true_w, true_b = 2.0, 1.0 y = true_w * x + true_b + np.random.normal(0, 0.8, size=x.shape) X = np.column_stack([np.ones_like(x), x]) # 第一列固定为1 theta, residuals, rank, s = np.linalg.lstsq(X, y, rcond=None) print(theta) # 大约 [1.0x, 2.0x],x 代表受噪声影响用正规方程的写法也能得到w = (X.T @ X)^-1 @ X.T @ y,但这就用到了求逆,数值稳定性不如lstsq。真实项目里我基本不直接写正规方程,而是把数据准备好后扔给lstsq,让底层用 QR 分解或 SVD 去处理。这样做的理由不是"写法优雅",而是当特征之间高度相关时,直接求逆会把共线性问题瞬间放大,得到极不稳定的权重。
6.3 条件数:同一个方程为什么有人能解有人不能
遇到数值不稳定时,先检查一件事:矩阵的条件数。np.linalg.cond(A)返回最大奇异值与最小奇异值的比值。条件数越大,矩阵越接近奇异,方程对输入扰动越敏感,计算结果越不可信。条件数达到1e8以上,基本可以认为这个矩阵不适合直接求逆。
实际场景里,特征之间如果高度相关,比如"房屋面积"和"房间数量"几乎没有独立变化,协方差矩阵X.T @ X就会病态。解决思路有两条:一是加正则化,岭回归在X.T @ X上加λI,让最小奇异值变大;二是先做标准化或者 PCA 去相关。做线性模型时,我会先用np.linalg.cond估算一下,心里有个底,再决定是用普通最小二乘还是加正则。
7. NumPy 线性代数最容易藏坑的地方
7.1 视图与副本:改一个变量居然牵连另一个
这是我认为新手最容易踩、也最难排查的坑。NumPy 的很多操作默认返回视图,而不是副本:切片、reshape、.T转置大多如此。视图意味着新旧数组共享同一块内存,修改其中一个,另一个会跟着变。这在预处理流程里经常引发诡异 bug。
A = np.zeros((3, 3)) B = A.T # B 是视图 B[0, 0] = 1 print(A) # A[0, 0] 也会变成 1所以当你要保存一份"中间结果"并且之后可能会修改它时,立刻.copy()一下。另外一个好习惯是:任何 Ops 结果进入下一步之前,先确认一下是否需要隔离原数组。我吃过几次亏以后,现在只要不确定,就默认 copy,绝不赌。
7.2 dtype 与精度:图像归一化怎么突然全黑了
dtype 是另一个隐形杀手。默认np.zeros(3)是 float64,np.arange(5)是 int64,两者混算时会触发类型提升,但有些规则并不直观。最经典的例子是图像数据:读进来的像素常常是uint8,范围0~255。如果你直接做img / 255,整数除以整数得到的是整数,结果几乎全变成 0,图像直接黑掉。正确做法是先转成浮点:
img = np.array([[200, 100]], dtype=np.uint8) print(img / 255.0) # 正确,得到浮点结果 print(img.astype(np.float64) / 255) # 更明确精度也值得注意。深度学习里为了省显存,很多人会把模型参数降到 float32,这在大部分情况下没问题。但如果你在优化过程中用累积求和、累加梯度,或者矩阵条件数本来就大,float32 的误差会被持续放大。我的建议是:离线计算和调试阶段一律用 float64,只有明确压性能瓶颈时才切 float32。
7.3 遇到超大矩阵先别急着 full 分解
np.linalg.solve、np.linalg.svd、np.linalg.eigh这些函数背后的实现都假设矩阵是稠密的,内存需求随维度平方增长。假如你的数据是 10 万行、10 万列但绝大多数位置是 0,直接扔给np.linalg.svd基本等于自杀。这种情况下应该换用适合稀疏数据的存储格式和迭代求解方式,或者先做特征筛选、降维,把问题的维度降下来。
另一个相关经验是:"线性代数函数不关心你的数据业务含义,它只认 shape 和 dtype。" 所以在调用任何np.linalg函数之前,我的常规动作是先打印两行:print(X.shape, X.dtype),顺便看一下矩阵是否稀疏。这样一个习惯能帮你拦截掉绝大多数莫名其妙的运行时报错。
最后说点个人体会。我一直觉得 NumPy 的线性代数部分不值得死记硬背,关键是要建立起"先看 shape,再写运算"的条件反射。你看别人写的模型源码时,不妨把所有@、np.linalg出现的行标出来,在旁边手动标出每个参与运算矩阵的形状,一旦发现有对不上的地方,bug 其实就藏在那儿。这种读代码的习惯,比你逐个查函数文档高效得多。