ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从NumPy到LLM:程序员如何用矩阵运算理解大语言模型核心原理

从NumPy到LLM:程序员如何用矩阵运算理解大语言模型核心原理 1. 从一行 NumPy 说起为什么程序员该懂点 LLM1.1 一个真实的学习起点我最早接触 NumPy 的时候纯粹是为了处理一批传感器采集的数据。那时候的想法很简单Python 的 list 用着挺顺手为什么还要学一个新库直到我用 list 做了一次 1000×1000 的矩阵乘法跑了将近 8 秒换成 NumPy 之后直接降到 0.02 秒。这个差距让我第一次意识到底层数据结构的选择直接决定了上层能做什么。后来我开始接触大语言模型LLM发现一个很有意思的现象几乎所有 LLM 的底层运算归根结底都是 NumPy 那套东西的延伸——张量Tensor运算、矩阵乘法、广播机制、维度变换。Transformer 架构看起来很复杂但拆开来看每一层都是矩阵运算的堆叠。你如果理解了 NumPy 里的shape、broadcast、einsum再看 Transformer 的代码会发现很多“魔法”其实没那么神秘。这篇笔记就是记录我从一行 NumPy 代码出发一步步走到理解 LLM 核心原理的过程。适合谁看如果你是有一定 Python 基础、想搞明白大模型到底怎么运转的程序员或者你已经在用 LLM 的 API 但想往下挖一层那这些内容应该对你有用。我不会堆砌公式而是尽量用代码和类比把关键概念讲清楚。1.2 从 NumPy 到 Tensor 的自然延伸NumPy 的ndarray和深度学习框架里的Tensor本质上描述的是同一件事多维数组加上一套运算规则。区别在于Tensor 多了两个能力——自动求导和硬件加速GPU/TPU。但如果你把这两个能力暂时放一边Tensor 的很多操作逻辑和 NumPy 几乎一模一样。举个例子你在 NumPy 里写a b做矩阵乘法在 PyTorch 里写a b做的是同样的事。你在 NumPy 里用a.reshape(2, 3, 4)改变数组形状在 PyTorch 里也是a.reshape(2, 3, 4)。甚至广播机制broadcasting的规则都高度一致。这意味着你花时间学 NumPy 的每一分钟在学 LLM 的时候都会变成已有的知识储备。我见过不少新手一上来就想跑 Transformer 模型结果卡在shape不匹配的报错上一调就是半天。其实这类问题的根源往往不在 Transformer 本身而在对多维数组运算的理解不够扎实。所以我的建议是别急着看 Transformer 的论文先把 NumPy 里的矩阵运算、广播、轴变换这几个概念吃透后面会省很多时间。2. NumPy 核心操作LLM 的地基怎么打2.1 矩阵乘法从手算到 einsum矩阵乘法是 LLM 里出现频率最高的运算。Transformer 里的注意力机制、前馈网络、投影层全都是矩阵乘法。NumPy 提供了几种写法import numpy as np # 方式一np.dot a np.random.randn(4, 8) b np.random.randn(8, 16) c np.dot(a, b) # shape: (4, 16) # 方式二 运算符推荐 c a b # 同上 # 方式三np.matmul支持批量 a_batch np.random.randn(2, 4, 8) b_batch np.random.randn(2, 8, 16) c_batch np.matmul(a_batch, b_batch) # shape: (2, 4, 16)np.dot和在二维情况下等价但到了三维及以上行为就不一样了。np.dot做的是张量缩并而np.matmul也就是做的是批量矩阵乘法。在 LLM 的代码里你几乎总是需要批量矩阵乘法因为输入是一个 batch 的数据。所以养成用的习惯能避免很多维度上的困惑。再来说np.einsum这个函数看起来吓人但用熟了之后非常优雅。注意力机制里的Q K^T可以用 einsum 写成# Q: (batch, seq_len, d_k) # K: (batch, seq_len, d_k) # 计算注意力分数 scores np.einsum(bqd,bkd-bqk, Q, K)这行代码的意思是对 Q 和 K 的最后一个维度做点积保留 batch 和序列长度维度。用写就是Q K.transpose(0, 2, 1)效果一样但 einsum 的写法更直观地表达了“我在做什么运算”。当然einsum 在性能上不一定总是最优实际工程中还是用居多但理解 einsum 对读懂论文里的公式很有帮助。2.2 广播机制让 shape 不再打架广播broadcasting是 NumPy 里最容易被低估的特性。它的规则很简单从右往左对齐维度要么相等要么其中一个是 1要么其中一个不存在。但就是这个简单规则在 LLM 里到处都在用。比如在 Transformer 的位置编码里你需要把一个(seq_len, d_model)的编码矩阵加到(batch, seq_len, d_model)的输入上。NumPy 会自动把编码矩阵广播到 batch 维度# positional_encoding: (seq_len, d_model) # x: (batch, seq_len, d_model) x x positional_encoding # 自动广播再比如层归一化LayerNorm里你需要对每个样本的最后一维做归一化然后乘以一个可学习的缩放参数gammashape 为(d_model,)。广播机制让(batch, seq_len, d_model)和(d_model,)能直接相乘不需要手动扩展维度。我踩过的一个坑是广播虽然方便但容易掩盖维度错误。比如你本意是想让两个(batch, 1)的向量做外积得到(batch, batch)结果因为广播规则得到了一个(batch, batch)的矩阵但语义完全不对。所以我的经验是在关键运算前养成打印 shape 的习惯。一行print(x.shape)能帮你省下大量调试时间。2.3 Shape 变换view、reshape、transpose 的区别在 LLM 代码里shape 变换无处不在。多头注意力机制就是一个典型的例子你需要把(batch, seq_len, d_model)的输入拆成(batch, num_heads, seq_len, d_head)其中d_model num_heads * d_head。batch, seq_len, d_model x.shape num_heads 8 d_head d_model // num_heads # 拆分多头 x x.reshape(batch, seq_len, num_heads, d_head) x x.transpose(0, 2, 1, 3) # (batch, num_heads, seq_len, d_head)这里reshape和transpose配合使用。注意transpose之后数组在内存里不再连续如果后续要做view操作会报错。这时候需要先.contiguous()PyTorch或.copy()NumPy。这个细节在实际写代码时经常遇到尤其是调试 shape 报错的时候。还有一个容易混淆的点reshape和view的区别。在 NumPy 里只有reshape它会尽量返回视图如果不行就复制。在 PyTorch 里view要求内存连续reshape更灵活。新手建议统一用reshape除非你明确知道内存布局是连续的并且追求极致性能。3. Transformer 拆解从 NumPy 视角看懂注意力机制3.1 注意力的三个关键Query、Key、ValueTransformer 的核心是自注意力机制Self-Attention。很多教程一上来就抛公式但我觉得用一句话就能说清楚Query 是“我在找什么”Key 是“我有什么”Value 是“我能提供什么”。具体来说每个 token 会生成三个向量QueryQ、KeyK、ValueV。注意力分数的计算过程是用我的 Query 去和所有 token 的 Key 做点积得到一组权重然后用这些权重对所有 token 的 Value 做加权求和。用 NumPy 写出来就是# x: (batch, seq_len, d_model) # W_q, W_k, W_v: (d_model, d_k) Q x W_q # (batch, seq_len, d_k) K x W_k # (batch, seq_len, d_k) V x W_v # (batch, seq_len, d_v) # 计算注意力分数 scores Q K.transpose(0, 2, 1) # (batch, seq_len, seq_len) scores scores / np.sqrt(d_k) # 缩放 # Softmax 归一化 weights np.exp(scores - scores.max(axis-1, keepdimsTrue)) weights weights / weights.sum(axis-1, keepdimsTrue) # 加权求和 output weights V # (batch, seq_len, d_v)这段代码就是自注意力的全部核心。看起来不复杂对吧但有几个细节值得展开。为什么要除以sqrt(d_k)因为当d_k很大时Q 和 K 的点积会变得很大导致 softmax 的梯度极小训练不动。除以sqrt(d_k)相当于把方差拉回到 1 附近让 softmax 的输出更平滑。这个技巧叫“缩放点积注意力”Scaled Dot-Product Attention。Softmax 为什么要减去最大值这是数值稳定性的考虑。exp函数在输入很大时会溢出减去最大值后最大的输入变成 0exp(0)1不会溢出。这个技巧在 NumPy 里手动实现 softmax 时一定要加上。3.2 多头注意力并行的智慧单头注意力只能捕捉一种关系模式。多头注意力Multi-Head Attention的思路是用多组不同的 Q、K、V 投影让模型同时关注不同的特征子空间。实现上就是把d_model拆成num_heads份每份独立做注意力最后拼接起来再投影一次。用 NumPy 写# 假设 d_model 512, num_heads 8, d_head 64 # x: (batch, seq_len, 512) # 一次性计算所有头的 Q、K、V Q x W_q # (batch, seq_len, 512) K x W_k V x W_v # 拆分成多头 Q Q.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) K K.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) V V.reshape(batch, seq_len, num_heads, d_head).transpose(0, 2, 1, 3) # 每个头独立做注意力 scores Q K.transpose(0, 1, 3, 2) # (batch, num_heads, seq_len, seq_len) weights softmax(scores / np.sqrt(d_head)) output weights V # (batch, num_heads, seq_len, d_head) # 拼接多头 output output.transpose(0, 2, 1, 3).reshape(batch, seq_len, d_model) output output W_o # 最终投影这里的关键操作是transpose和reshape的配合。transpose(0, 2, 1, 3)把num_heads维度换到前面让每个头的数据在内存里连续方便后续的批量矩阵乘法。最后再换回来用reshape把多头的结果拼回d_model。我一开始看这段代码的时候被 transpose 的参数绕晕了。后来我的方法是在纸上画出每个维度的含义然后一步步跟踪 shape 的变化。比如(batch, seq_len, num_heads, d_head)经过transpose(0, 2, 1, 3)变成(batch, num_heads, seq_len, d_head)这样就能清楚地知道每个维度代表什么。3.3 位置编码给模型一个“顺序感”自注意力机制本身是位置无关的——把输入序列打乱注意力的输出不变。但语言是有顺序的所以需要位置编码Positional Encoding来注入位置信息。原始 Transformer 用的是正弦位置编码def positional_encoding(seq_len, d_model): pos np.arange(seq_len)[:, np.newaxis] # (seq_len, 1) i np.arange(d_model)[np.newaxis, :] # (1, d_model) angle pos / np.power(10000, (2 * (i // 2)) / d_model) encoding np.zeros((seq_len, d_model)) encoding[:, 0::2] np.sin(angle[:, 0::2]) encoding[:, 1::2] np.cos(angle[:, 1::2]) return encoding这段代码用到了 NumPy 的广播和切片赋值。pos是(seq_len, 1)i是(1, d_model)相除之后广播成(seq_len, d_model)。然后偶数维度用 sin奇数维度用 cos。现在主流的大模型如 LLaMA、GPT 系列更多用旋转位置编码RoPE或可学习的位置编码。但不管哪种核心思路是一样的给每个位置一个独特的向量表示让模型能区分不同位置的 token。4. 从 NumPy 到 LLM 框架工具链的演进4.1 为什么需要专门的框架用 NumPy 手写一个 Transformer 的前向传播对于理解原理非常有帮助。但如果你真的要用它来训练模型很快就会遇到瓶颈没有自动求导你需要手动推导每个操作的梯度工作量巨大且容易出错。没有 GPU 加速NumPy 默认在 CPU 上运行训练大模型的速度无法接受。没有优化器SGD、Adam 等优化器需要自己实现。所以实际工程中我们会用 PyTorch、TensorFlow、JAX 等框架。这些框架提供了自动求导、GPU 加速、分布式训练等能力。但它们的底层运算逻辑和 NumPy 是一脉相承的。以 PyTorch 为例把上面的 NumPy 代码翻译过去几乎只需要把np换成torch再加上requires_gradTrueimport torch Q x W_q # 自动求导 scores Q K.transpose(-2, -1) / torch.sqrt(torch.tensor(d_k)) weights torch.softmax(scores, dim-1) output weights VPyTorch 的softmax已经内置了数值稳定性处理不需要手动减最大值。transpose(-2, -1)也比 NumPy 的transpose(0, 2, 1)更直观——用负数索引表示“最后两个维度互换”。4.2 张量并行与显存优化当你试图训练一个真正的 LLM 时会发现显存不够用。一个 7B 参数的模型光是参数就占 28GBFP32加上梯度、优化器状态、激活值轻松超过 100GB。这时候就需要各种优化技术混合精度训练用 FP16 或 BF16 存储参数和计算显存减半。梯度累积用小 batch 多次前向传播累积梯度后再更新模拟大 batch。张量并行把矩阵乘法拆到多张卡上比如把W_q按列切分。ZeRO 优化把优化器状态、梯度、参数分片到多张卡上。这些技术的底层仍然是对张量 shape 的操作。比如张量并行里的列切分就是把(d_model, d_model)的权重矩阵切成(d_model, d_model/num_gpus)每张卡算一部分最后用all-reduce汇总。如果你理解 NumPy 的切片和拼接这些概念就不难理解。4.3 推理部署ONNX 与量化训练完之后模型要部署到生产环境。这时候关注的重点从“训练速度”变成了“推理延迟”和“吞吐量”。常见的优化手段包括ONNX 导出把 PyTorch 模型转成 ONNX 格式然后用 ONNX Runtime 推理跨平台兼容性好。量化把 FP16 转成 INT8模型体积缩小一半推理速度提升精度损失通常在可接受范围内。KV Cache自回归生成时缓存之前 token 的 Key 和 Value避免重复计算。KV Cache 是一个很好的例子它本质上是一个 shape 操作把之前计算的 K 和 V 存起来每次只计算新 token 的 K 和 V然后拼接。用 NumPy 的话说就是np.concatenate([cache, new_k], axis1)。理解了这个再看推理框架的代码就不会觉得陌生。5. 常见问题与排查技巧实录5.1 Shape 不匹配最常见的报错Shape 不匹配是 LLM 开发中最常见的错误。我整理了一个速查表报错信息常见原因解决方法matmul: Input operand 1 has a mismatch矩阵乘法维度不对检查a.shape[-1]是否等于b.shape[-2]could not broadcast广播规则不满足从右往左对齐确保维度相等或为 1view size is not compatiblereshape 时元素总数不对计算np.prod(shape)是否一致expected 4D input卷积层输入维度不对确认输入是(batch, channel, height, width)我的经验是在写代码之前先在纸上画出每个张量的 shape。比如输入是(batch, seq_len, d_model)经过 Q 投影后是(batch, seq_len, d_k)经过注意力后是(batch, seq_len, d_v)。把这条链路画清楚写代码时就不容易出错。5.2 NumPy 版本不匹配ModuleNotFoundError: No module named numpy或者版本冲突是新手经常遇到的问题。我的建议是用虚拟环境python -m venv myenv然后source myenv/bin/activateLinux/Mac或myenv\Scripts\activateWindows。固定版本在requirements.txt里写numpy1.24.0避免自动升级到不兼容的版本。检查依赖pip list | grep numpy看看实际安装的版本。如果遇到numpy和pandas、scipy的版本冲突可以用pip install --upgrade --force-reinstall强制重装或者用conda管理环境它的依赖解析更靠谱。5.3 梯度消失与梯度爆炸训练 Transformer 时梯度问题很常见。表现是 loss 不下降或者突然变成 NaN。排查思路检查学习率太大容易爆炸太小容易消失。可以试试 warmup cosine decay。检查初始化权重初始化太小会导致梯度消失太大容易爆炸。常用 Xavier 或 Kaiming 初始化。检查 LayerNormLayerNorm 能稳定训练但如果位置放错比如放在残差连接之后效果会打折扣。梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)防止梯度爆炸。我踩过的一个坑是忘记加 warmup。Transformer 训练初期梯度很大直接上大学习率容易发散。加上 warmup 之后前几千步学习率从 0 线性增加到目标值训练就稳定多了。5.4 显存不足的排查与优化显存不足OOM是训练大模型时的家常便饭。排查步骤打印显存占用torch.cuda.memory_allocated()看看当前用了多少。减小 batch size最直接的方法但可能影响训练效果可以用梯度累积补偿。检查中间变量有些中间变量没有及时释放可以用del删除然后torch.cuda.empty_cache()。用混合精度torch.cuda.amp自动混合精度显存减半速度提升。检查模型并行如果模型太大单卡放不下需要用张量并行或流水线并行。我的经验是先用小模型和小数据跑通流程再逐步放大。一上来就上大模型遇到 OOM 都不知道是哪里出了问题。小规模跑通之后每一步的显存占用心里有数放大时就知道该在哪里优化。6. 学习路径与资源推荐6.1 从 NumPy 到 Transformer 的路线图如果你是从零开始我建议按这个顺序走NumPy 基础数组创建、索引切片、广播、矩阵乘法、axis 操作。推荐官方教程和《Python 科学计算与数据科学应用》。手写注意力机制用 NumPy 实现单头和多头注意力理解 Q、K、V 的含义。手写完整 Transformer包括位置编码、前馈网络、残差连接、LayerNorm。迁移到 PyTorch把 NumPy 代码翻译成 PyTorch加上自动求导和 GPU 支持。训练一个小模型比如字符级语言模型数据集用莎士比亚文本感受完整的训练流程。阅读开源代码看 HuggingFace Transformers、nanoGPT 等项目的实现学习工程技巧。这个路线的好处是每一步都有可运行的代码每一步都能看到结果。不会出现“看了一堆论文但还是不会写代码”的情况。6.2 值得反复看的资料The Illustrated Transformer用图解的方式讲注意力机制非常适合入门。nanoGPTKarpathy 写的极简 GPT 实现代码量少适合精读。HuggingFace Transformers工业级实现代码量大但注释详细适合查阅。LLM Wiki社区维护的知识库涵盖 LLM 的各个方面适合系统学习。我个人的习惯是先看图解建立直觉再看代码验证细节最后自己动手写一遍。光看不动手很容易产生“我懂了”的错觉一写代码就卡壳。6.3 一些实用的调试技巧打印 shape在关键步骤后加print(x.shape)确认维度符合预期。用小数据用batch2, seq_len4, d_model8这样的小数据调试出错时容易定位。单元测试对每个模块写测试比如注意力的输出 shape 是否正确softmax 的权重和是否为 1。可视化用 matplotlib 画出注意力权重直观感受模型在关注什么。对比实现自己写的和 PyTorch 内置的对比确保数值一致。我在实际使用中发现调试 LLM 代码最有效的方法是把大问题拆成小问题。比如注意力机制不工作先单独测试 Q、K、V 的投影再测试分数计算再测试 softmax一步步缩小范围。不要一上来就怀疑整个模型有问题。最后再分享一个小技巧如果你在学 Transformer 的时候觉得公式太难不妨先把公式放一边用 NumPy 把代码写出来跑通之后再回头看公式会发现很多符号其实就是在描述你写过的那些操作。代码是具体的公式是抽象的从具体到抽象理解起来会顺畅很多。
返回列表