从NumPy到Transformer:程序员学LLM必须补的数学工具与Shape思维
1. 从一行 NumPy 说起:为什么程序员学 LLM 要回头补数学工具
很多人第一次接触大模型,脑子里想的都是 Transformer、注意力机制、Token、微调这些词,恨不得当天就把一个开源模型跑起来。但真到动手的时候,卡住你的往往不是模型本身,而是最底层的一行代码——import numpy as np报了个ModuleNotFoundError: No module named 'numpy',或者装上了却提示numpy版本不匹配,再或者你压根没搞明白shape到底在描述什么。我自己就是从这条路走过来的,所以这篇笔记不打算一上来就讲注意力公式,而是老老实实从 NumPy 这个"地基"讲起,一路铺到 Transformer 和大模型的整体认知。
这篇内容适合三类人:一是刚学 Python、想往 AI 方向走的初学者;二是会写业务代码、但对大模型内部机制一知半解的工程师;三是想系统梳理一遍"从数组到模型"这条链路、把零散知识串起来的人。核心关键词会贯穿始终:NumPy、LLM、Transformer、Tensor、Shape。我会把每个环节"为什么这么做"讲清楚,而不是只丢给你一段能跑的代码。
先说清楚一个定位:NumPy 不是"过时的老古董",恰恰相反,它是整个 Python 科学计算和数据科学生态的底座。你后面要用的 PyTorch、TensorFlow、JAX,它们的张量(Tensor)概念和 NumPy 的 ndarray 是一脉相承的。理解了 NumPy 的数组、广播、形状变换,你再看 Transformer 里的张量运算,会有一种"原来就是换个名字"的顿悟感。这也是我坚持从 NumPy 讲起的原因——跳过它直接学 LLM,等于盖楼不打地基。
2. NumPy 到底解决了什么问题:从 list 到 ndarray 的跨越
2.1 NumPy 和 list 比快在哪
新手最常问的一个问题就是:Python 自带的 list 不也能存一堆数吗,为什么还要 NumPy?这个问题问得特别好,答案藏在"内存布局"和"运算方式"两个层面。
list 是一个"指针数组",里面存的不是数字本身,而是指向各个 Python 对象的指针。每个 Python 的 int 或 float 都是一个完整的对象,带着类型信息、引用计数等一堆额外开销。你存一百万个浮点数,list 里就有一百万个独立的 Python 对象,内存占用大、访问还绕。
NumPy 的 ndarray 则是一块连续的、同类型的内存。它只存数据本身,类型统一(比如全是 float32),外加一个描述形状和步长的头部信息。这样一来,CPU 可以一次性把连续内存读进缓存,做向量化运算,速度差距往往是几十倍甚至上百倍。
我实测过一个简单的例子,对一千万个数做逐元素平方再求和:
import numpy as np import time n = 10_000_000 py_list = list(range(n)) np_arr = np.arange(n, dtype=np.float64) # 纯 Python t0 = time.time() s1 = sum(x * x for x in py_list) t1 = time.time() # NumPy t2 = time.time() s2 = np.sum(np_arr * np_arr) t3 = time.time() print(f"Python list: {t1 - t0:.3f}s") print(f"NumPy: {t3 - t2:.3f}s")在我这台普通笔记本上,list 版本大概要 1.5 秒,NumPy 版本 0.02 秒左右,差了将近两个数量级。原因就是 NumPy 把循环下沉到了 C 语言层面,还利用了 SIMD 指令并行处理。这就是"向量化思维"的起点——能用数组整体运算,就别写 for 循环。
2.2 安装 NumPy 的正确姿势与版本坑
numpy安装看着简单,坑却不少。最稳的方式是用 pip:
pip install numpy但如果你用的是 conda 环境,建议:
conda install numpy因为 conda 会帮你处理底层的 BLAS/LAPACK 数学库依赖,矩阵运算性能更好。纯 pip 装的话,有时候会拉到一个没链接优化数学库的版本,做矩阵乘法会慢一截。
关于numpy版本不匹配,这是新手最容易踩的坑。典型场景是:你装了 PyTorch,它依赖某个 NumPy 版本,你又手动升级了 NumPy,结果两者 ABI 不兼容,一 import 就报错。我的经验是:
- 先装框架(PyTorch/TensorFlow),让它们自己决定 NumPy 版本,别手动去动。
- 如果非要升级,用
pip install "numpy<2.0"这种带约束的方式,别裸升。 - 遇到
ModuleNotFoundError: no module named 'numpy',先确认你当前用的 Python 解释器和 pip 是不是同一个环境,虚拟环境里最容易出这个问题。
提示:用
python -c "import numpy; print(numpy.__version__)"快速确认当前环境实际生效的 NumPy 版本,比看 pip list 更准。
2.3 Shape 是理解一切张量运算的钥匙
Shape这个词,是贯穿 NumPy 和 Transformer 的核心概念。你可以把 shape 理解成"这个数组在每个维度上有多少个元素"。
a = np.array([1, 2, 3]) # shape (3,) 一维 b = np.array([[1, 2], [3, 4]]) # shape (2, 2) 二维 c = np.zeros((2, 3, 4)) # shape (2, 3, 4) 三维为什么 shape 这么重要?因为几乎所有张量运算的报错,本质都是 shape 对不上。矩阵乘法要求前一个的最后一维等于后一个的倒数第二维;广播(broadcasting)要求维度要么相等,要么其中一个是 1。你在 Transformer 里看到的一堆reshape、transpose、unsqueeze,全都是在跟 shape 打交道。
我个人的习惯是:写任何张量代码前,先在纸上或注释里把每一步的 shape 标出来。这个习惯帮我省下了无数调试时间。比如一句话经过 tokenizer 后是(batch, seq_len),过 embedding 变成(batch, seq_len, d_model),进多头注意力又要拆成(batch, num_heads, seq_len, head_dim)。shape 想清楚了,代码基本就对了。
3. 从 NumPy 到 Tensor:概念是怎么平滑过渡的
3.1 Tensor 不是新东西,是 NumPy 的"进化版"
很多人被Tensor这个词吓到,觉得是什么高深概念。其实你可以直接理解成:Tensor 就是能放到 GPU 上、能自动求导的 NumPy 数组。它的 shape、索引、切片、广播规则,跟 ndarray 几乎一模一样。
import torch # NumPy 风格 x = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) print(x.shape) # torch.Size([2, 2]) # 和 NumPy 互转 import numpy as np arr = np.array([[1.0, 2.0], [3.0, 4.0]]) t = torch.from_numpy(arr) # NumPy -> Tensor back = t.numpy() # Tensor -> NumPy关键差异有三个:一是 Tensor 可以搬到 GPU(.to('cuda'));二是 Tensor 支持自动微分(requires_grad=True);三是 Tensor 有专门的tensor core加速,在支持矩阵乘的硬件上能跑出极高吞吐。理解了这三点,你就明白为什么深度学习框架不直接用 NumPy 了——NumPy 缺的正是"求导"和"硬件加速"这两块。
3.2 广播机制:让 shape 不同的数组也能运算
广播是 NumPy 里最优雅也最容易搞混的机制。规则很简单:从右往左对齐维度,每个维度要么相等,要么其中一个是 1,要么其中一个不存在。
a = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3) b = np.array([10, 20, 30]) # shape (3,) print(a + b) # [[11, 22, 33], # [14, 25, 36]]这里b被自动"广播"成了(2, 3),每一行都加上[10, 20, 30]。在 Transformer 里,位置编码加到词嵌入上、注意力分数加 mask,用的都是广播。掌握广播,你就能少写很多显式的循环和复制。
但广播也是 bug 温床。比如你本想按行归一化,结果写成了按列,shape 恰好能广播过去,程序不报错但结果是错的。我的建议是:运算后立刻检查 shape 是否符合预期,别等 loss 不下降才回头找。
3.3 矩阵运算:从求逆到行列式
numpy如何求解矩阵的逆和python行列式计算不使用numpy这类问题,说明很多人卡在线性代数上。NumPy 提供了完整的线性代数模块:
import numpy as np A = np.array([[4.0, 7.0], [2.0, 6.0]]) # 求逆 A_inv = np.linalg.inv(A) # 行列式 det = np.linalg.det(A) # 解线性方程组 Ax = b b = np.array([1.0, 2.0]) x = np.linalg.solve(A, b)这里有个实操心得:能用solve就别用inv。因为求逆再相乘在数值上不稳定,计算量也更大。np.linalg.solve内部用的是更稳定的分解方法。这个原则在深度学习里同样适用——很多看似要"求逆"的操作,实际都有更稳的等价写法。
至于"不使用 NumPy 算行列式",纯 Python 可以用递归展开或高斯消元实现,但性能和数值稳定性都远不如 NumPy。除非是教学目的,否则没必要自己造轮子。
4. Transformer 拆解:从 Shape 视角看懂注意力机制
4.1 一句话讲清 Transformer 在干什么
transformer模型详解的文章满天飞,但很多一上来就是公式。我用一句话概括:Transformer 就是让序列里的每个位置,去"看"其他所有位置,然后根据相关性加权汇总信息。这个"看"的过程,就是注意力(Attention)。
而llm的token三个点key我是谁、query我在找什么、value我能提供什么这个热词,把 QKV 讲得特别形象。我顺着这个说法展开:
- Query(我在找什么):当前位置发出的"查询请求"。
- Key(我是谁):每个位置对外展示的"身份标签"。
- Value(我能提供什么):每个位置实际携带的"内容信息"。
注意力做的事就是:用我的 Query 去和所有位置的 Key 做匹配,算出相似度,再用相似度对所有 Value 加权求和。匹配度高,说明这个位置对我重要,它的 Value 就多拿一点。
4.2 用 NumPy 手写一遍注意力,彻底搞懂 shape
光看公式容易飘,我建议你用 NumPy 手写一遍单头注意力。下面这段代码是我自己学习时写的,每一步都标了 shape:
import numpy as np def softmax(x, axis=-1): x = x - np.max(x, axis=axis, keepdims=True) # 数值稳定 exp = np.exp(x) return exp / np.sum(exp, axis=axis, keepdims=True) def self_attention(X, Wq, Wk, Wv): # X: (seq_len, d_model) Q = X @ Wq # (seq_len, d_k) K = X @ Wk # (seq_len, d_k) V = X @ Wv # (seq_len, d_v) scores = Q @ K.T # (seq_len, seq_len) scores = scores / np.sqrt(Q.shape[-1]) # 缩放 weights = softmax(scores, axis=-1) # (seq_len, seq_len) out = weights @ V # (seq_len, d_v) return out, weights seq_len, d_model, d_k = 4, 8, 8 X = np.random.randn(seq_len, d_model) Wq = np.random.randn(d_model, d_k) Wk = np.random.randn(d_model, d_k) Wv = np.random.randn(d_model, d_k) out, weights = self_attention(X, Wq, Wk, Wv) print("输出 shape:", out.shape) # (4, 8) print("注意力权重 shape:", weights.shape) # (4, 4)跑一遍你就明白了:scores是(seq_len, seq_len),表示每个位置对其他位置的关注程度;weights每行加起来是 1;out的 shape 和输入一致。这就是 Transformer 的核心,剩下的多头、残差、LayerNorm 都是在这个骨架上加料。
4.3 为什么要缩放、为什么要多头
先说缩放。scores / sqrt(d_k)这一步不是可有可无的。当d_k很大时,Q 和 K 的点积方差会随维度增大,导致 softmax 的输入数值很大,输出接近 one-hot,梯度几乎消失。除以sqrt(d_k)把方差拉回 1 附近,训练才稳定。这是"为什么"层面的知识,比记住公式重要得多。
再说多头。单头注意力只能学到一种"关注模式",多头就是并行跑多组 QKV,每组关注不同的关系——有的头关注语法、有的关注指代、有的关注位置。最后把多头的输出拼接再投影回d_model。从 shape 看:
# 输入 (batch, seq_len, d_model) # 拆成 (batch, num_heads, seq_len, head_dim) # 其中 head_dim = d_model // num_headstransformer架构模型参数计算里,注意力部分的参数量主要来自四个投影矩阵 Wq、Wk、Wv、Wo,每个都是d_model × d_model,所以是4 × d_model²。这个数字在估算模型大小时很有用。
4.4 从 Transformer 到 LLM:架构没变,规模变了
大模型llm和transformer的关系,一句话:主流 LLM 就是"解码器版 Transformer"堆很多层、喂很多数据训出来的。GPT 系列用的是 decoder-only 结构,只保留带因果 mask 的自注意力(每个位置只能看前面的,不能看后面),加上前馈网络和残差。
transformer架构本身没变,变的是规模:层数从 6 层涨到几十上百层,d_model从 512 涨到几千上万,参数量从几千万涨到千亿级。vision transformer则是把图像切成 patch 当 token,套用同一套架构做图像分类,swin transformer进一步引入层级和窗口注意力来降低计算量。同一套思想,换个输入形式,就能跨模态。
5. 实操路线:一个程序员该怎么系统学下去
5.1 分阶段的学习路径
我把自己的学习路线整理成四个阶段,供你参考:
| 阶段 | 目标 | 关键内容 | 检验标准 |
|---|---|---|---|
| 第一阶段 | 打牢 NumPy | 数组、shape、广播、矩阵运算 | 能手写 softmax、矩阵乘法 |
| 第二阶段 | 理解 Transformer | QKV、多头、位置编码、残差 | 能用 NumPy 手写单头注意力 |
| 第三阶段 | 上手框架 | PyTorch 张量、自动求导、nn.Module | 能跑通一个小 Transformer |
| 第四阶段 | 接触 LLM | Tokenizer、加载预训练模型、推理 | 能本地跑通一个开源小模型 |
这个顺序不能乱。我见过太多人跳过前两步直接冲第四步,结果连报错都看不懂。基础不牢,调参调到怀疑人生。
5.2 手写一个最小 Transformer 块
在 NumPy 版注意力之后,我建议用 PyTorch 写一个最小的 Transformer block,把前面学的 shape 知识用起来:
import torch import torch.nn as nn class MiniTransformerBlock(nn.Module): def __init__(self, d_model=64, num_heads=4, ff_dim=256): super().__init__() self.attn = nn.MultiheadAttention(d_model, num_heads, batch_first=True) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.ff = nn.Sequential( nn.Linear(d_model, ff_dim), nn.ReLU(), nn.Linear(ff_dim, d_model), ) def forward(self, x): # x: (batch, seq_len, d_model) attn_out, _ = self.attn(x, x, x) x = self.norm1(x + attn_out) # 残差 + LayerNorm x = self.norm2(x + self.ff(x)) # 残差 + LayerNorm return x block = MiniTransformerBlock() x = torch.randn(2, 10, 64) # (batch, seq_len, d_model) print(block(x).shape) # (2, 10, 64)跑通这段,你就理解了 Transformer block 的"输入输出 shape 不变"这个关键特性——正因为不变,才能一层层往上堆。这也是为什么 Transformer 能堆得那么深。
5.3 关于 LLM 知识库和工具链的补充
热词里出现了llm wiki知识库、llm wiki项目、karpathy llm wiki这些,说明很多人想找一个系统整理 LLM 知识的入口。我的建议是:与其收藏一堆链接,不如自己动手建一个笔记库,把每个概念用自己的话写一遍,配上能跑的代码。能讲清楚,才是真懂。
工具链方面,onnx部署llm模型、llm 网关、llm框架这些方向,属于工程落地层面。等你把模型原理搞懂了,再去看这些会轻松很多。至于open llm leaderboard这类公开榜单,可以用来选型参考,但别唯榜单论——榜单分数高不等于适合你的场景。
6. 常见问题与避坑实录
6.1 环境与版本类问题速查
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
ModuleNotFoundError: no module named 'numpy' | 解释器和 pip 不在同一环境 | 用python -m pip install numpy |
numpy版本不匹配 | 框架与 NumPy ABI 冲突 | 先装框架,别手动升 NumPy |
| 矩阵乘法报 shape 错 | 维度不满足乘法规则 | 打印两边 shape,检查对齐 |
| 广播结果不对 | 维度恰好能广播但语义错 | 运算后立即校验 shape |
| GPU 上运算报错 | 张量还在 CPU | 检查.to(device)是否一致 |
6.2 几个我踩过的坑
第一个坑是盲目升级 NumPy。有次为了用某个新函数把 NumPy 升到最新,结果 PyTorch 直接 import 失败,回退版本才恢复。教训是:在框架环境里,NumPy 版本交给框架管。
第二个坑是忽略 dtype。NumPy 默认 float64,PyTorch 默认 float32。混用的时候要么报错要么悄悄降精度。我现在的习惯是显式指定dtype=np.float32,和框架保持一致。
第三个坑是softmax 不减去最大值。手写 softmax 时如果直接np.exp(x),遇到大数值会溢出成 inf。减去max是标准做法,别省这一步。
第四个坑是把 shape 记在脑子里。多维张量一多,人脑根本记不住。我的做法是每写一步就print(x.shape),或者用注释标出来。看起来笨,但省时间。
6.3 关于学习节奏的一点个人体会
学 LLM 最容易犯的错是"贪多求快"。今天看 Transformer,明天想微调,后天又想搞 Agent。我的建议是一次只啃一个点,啃到能给别人讲明白为止。NumPy 的广播、注意力的 shape 变换、残差为什么能缓解梯度消失,这些点一个个吃透,比囫囵吞枣看十篇综述强得多。
另外,transformer手写这件事真的值得做一遍。不用写得多完整,哪怕只是一个单头注意力加一个前馈层,亲手敲一遍,你对 shape 和矩阵运算的理解会完全不一样。我当初就是靠手写这一遍,才真正把"QKV 到底在算什么"想清楚的。
最后分享一个小技巧:遇到看不懂的架构图,别硬看,直接去找对应的代码,把每一行的 shape 标出来。图会骗人,shape 不会。当你能从 shape 的角度读懂一个模型,你就已经跨过了从"调包"到"理解"的那道坎。