ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从 4192 个参数看懂 GPT:拆解 Andrej Karpathy 的 microGPT

从 4192 个参数看懂 GPT:拆解 Andrej Karpathy 的 microGPT 真实的 GPT-4、ChatGPT 有上千亿参数堆着几十上百层 Transformer训练数据是整个互联网。这样的规模让理解 GPT 到底在做什么变成一件望而生畏的事——但如果把它缩小到 4192 个参数、1 层 Transformer、纯 Python 写、不依赖任何深度学习框架呢这正是 Andrej KarpathyOpenAI 创始成员之一前 Tesla AI 总监写的 microGPT一份约 200 行的纯 Python 脚本从零手写自动求导引擎到完整的 Transformer 架构到训练循环再到推理采样——一个字都不需要 PyTorch。它的开篇注释写得很直白The most atomic way to train and run inference for a GPT in pure, dependency-free Python. This file is the complete algorithm. Everything else is just efficiency.也就是说这份代码就是 GPT 的完整算法本身ChatGPT 和它相比多出来的只是规模和工程效率核心逻辑一字未改。这篇文章按代码的自然结构把 microGPT 拆成几个部分逐一讲清楚。全貌一个只做一件事的模型microGPT 只干一件事给一串字符预测下一个字符是什么。整个文件按逻辑分成六块数据集32,033 个英文名字一行一个比如 emma、olivia、ava分词器字符级每个字符对应一个整数 id自动求导引擎手写的Value类和反向传播GPT 架构多头注意力 MLP模仿 GPT-2训练循环交叉熵损失 Adam 优化器推理循环采样生成新名字模型只有 4192 个参数——GPT-4 级别的模型是它的几千万倍——但支撑它运转的算法和真正的 ChatGPT 完全一致。1. 数据与分词器一切从切成整数开始数据很朴素3.2 万个英文名字。神经网络不认识字符串第一步永远是把文本变成整数序列——这就是分词器tokenizer的工作。microGPT 用的是最简单的字符级分词把数据集里出现过的所有字符去重排序每个字符分配一个 id这批数据只有小写字母排序后a0, b1, ..., z25。关键的一个设计是BOSBeginning of Sequencetoken一个专门的特殊 id被插在每个名字的开头和结尾。比如emma会变成[BOS, e, m, m, a, BOS]。开头的 BOS 好理解是新名字开始了的信号容易被忽略的是结尾为什么也要放一个 BOS——这其实是整个训练目标的关键训练时模型会在每个位置学习预测下一个字符是什么当序列走到最后一个真实字符比如a时“下一个字符这道题的标准答案就是 BOS。模型因此学会了判断这个名字该结束了”到了推理阶段模型自己生成到 BOS就知道该停手了不然它不知道什么时候该收尾。词表大小 26 个字母 1 个 BOS 27。2. 自动求导引擎没有 PyTorch怎么手写反向传播真实的深度学习框架把自动求导这件事藏得很深但 microGPT 把它完整暴露了出来——这是全篇最值得细读的部分。核心思路把每个数字变成一个会记账的数字。classValue:def__init__(self,data,children(),local_grads()):self.datadata# 前向传播算出来的值self.grad0# loss 对这个节点的导数self._childrenchildren# 我是由哪几个 Value 计算出来的self._local_gradslocal_grads# 我对每个 child 的局部导数每次对Value做加、乘、幂、log、exp、relu 这些运算代码不只是算出结果还顺带把求导公式记了下来。以乘法为例def__mul__(self,other):returnValue(self.data*other.data,(self,other),(other.data,self.data))c a * b时c记住了∂c/∂a b.data、∂c/∂b a.data——这是乘积法则最朴素的样子。每做一次运算就在计算图里连一条边前向传播的过程其实是在悄悄搭建这张图。反向传播backward()做两件事先用深度优先搜索对计算图做拓扑排序保证每个节点都排在它所有父节点之后被处理然后把这个顺序倒过来从 loss 出发依次把梯度往下传forvinreversed(topo):forchild,local_gradinzip(v._children,v._local_grads):child.gradlocal_grad*v.grad这一行就是链式法则的全部实现。为什么一定要先排序再倒序处理因为一个节点常常被多处使用比如同一个参数在算 Q 和 K 时都被用到它的最终梯度必须等所有用到它的地方都把贡献传过来才算完整顺序错了会拿一个还没累加完、过早读取的梯度继续往下传整个反向传播就是错的。这也解释了为什么代码里全是而不是——梯度要把所有来源加总。这套引擎还有个精巧的设计减法、除法完全没有单独写求导规则而是靠加法和乘法拼出来的a - b被翻译成a b*(-1)。之所以能这样拼是因为加法以及乘一个常数的局部导数永远是固定值跟操作数的实际取值无关而一般乘法a*b两个变量的局部导数依赖对方的实时值乘积法则这是加法规则推不出来的全新行为。所以整个引擎只需要手写 * ** log exp relu六个求导规则互不可推导的原语其余运算都是组合出来的——这是自动求导系统少即是多的经典设计。3. 模型架构总览参数到底是些什么先看架构的几个超参数这些是设计选择不是训练出来的n_layer1# Transformer 层数这个玩具模型只有 1 层n_embd16# 每个 token 用一个 16 维向量表示block_size16# 最大上下文长度n_head4# 4 个注意力头每头 4 维真正的可训练参数用一个简单的工厂函数生成——matrix(nout, nin)造一个nout×nin的随机初始化Value矩阵本质上就是手写版的nn.Linear权重。全局参数表里最重要的两张查找表是wte词嵌入表27×16每个字符 id 查出一个 16 维向量代表这个字符的语义wpe位置嵌入表16×16每个位置 0~15 查出一个 16 维向量代表我在序列里排第几为什么需要单独一张位置表因为注意力机制本身天然不知道谁在前谁在后下一节会看到它本质是在做加权平均必须靠wpe把顺序信息显式注入进去。输入向量就是两者相加而不是拼接x tok_emb pos_emb。之所以能直接相加是因为这两张表都是训练出来的——梯度下降会调整它们让相加后的向量同时保留身份和位置信息只要维度够、后面的层够灵活模型自己会学会怎么读出这两部分相加也比拼接更省参数不会让维度翻倍。除了这两张表每层还有 attention 用的四个 16×16 矩阵wq/wk/wv/wo以及 MLP 用的两个矩阵mlp_fc116→64升维和mlp_fc264→16降维。这种先胀 4 倍再压缩的设计是给 ReLU 非线性更大的空间做复杂的分段线性组合——如果直接用一个 16→16 的矩阵整个 MLP 就退化成一次线性变换 一层 ReLU表达能力大打折扣。所有矩阵拍平成一个列表就是 Adam 优化器要更新的全部对象4192 个Value。4. 注意力机制全篇的灵魂Transformer 的核心问题只有一句话每个位置该去看过去的哪些位置看多少权重看到什么内容。qlinear(x,wq)# Query我在找什么klinear(x,wk)# Key我能提供什么信息用来被匹配vlinear(x,wv)# Value如果被选中我实际贡献的内容直觉类比Query 像提问Key 像每个历史 token 举的关键词牌子Value 像牌子后面真正的货。当前的 Query 和所有历史的 Key 做点积比对越匹配权重越高softmax 归一化后按权重把大家的 Value 加权混合就是这个位置该看到的信息。keys[li]、values[li]这两个列表就是KV cache——真实 LLM 推理加速用的同一套技巧。因为 microGPT 是逐位置跑gpt()而不是一次性处理整句话每处理一个新位置才append一次历史的 K/V避免重复计算已经算过的部分。多头注意力把 16 维切成 4 份每份独立算一遍注意力再拼回去。意义在于与其让一组 Q/K/V 塞下所有种类的关系不如切成几个小组每组专注学一种模式比如一个头学上一个字符另一个头学元音字符拼起来信息更丰富。有个数值细节值得展开点积之后要除以√head_dim再做 softmaxscaled dot-product attention。原因是点积本质上是head_dim个随机数乘随机数求和根据统计规律这个和的标准差会随维度按√d增长——维度越高点积数值越膨胀。不缩放的话softmax 会因为输入的绝对值过大而变得过度尖锐几乎所有权重压在一个位置进而在饱和区梯度趋近于零训练很难推动。除以√head_dim把点积数值范围重新拉回到一个与维度无关的稳定区间。还有一个容易被忽略但很精妙的地方因果性模型看不到未来是免费获得的。keys[li]严格按处理顺序增量append处理位置 t 的时候t1、t2… 的 key 根本还没被算出来压根不在列表里——因果约束是时间顺序的自然副产品不需要额外写一个遮罩矩阵。但这依赖严格按序、增量计算这个前提如果反过来一次性把整条序列的 K/V 都提前算好放进列表这正是真实 Transformer 训练时为了并行效率而做的事因果性就不会自动成立必须额外加限制——要么每个位置只看列表的前pos_id1个要么把未来位置的注意力得分强制设为负无穷。这就是标准 Transformer 里显式加因果遮罩矩阵的来历。5. RMSNorm 与残差连接让深层网络训练得动的两个关键RMSNorm做的事很简单算出向量的均方值ms mean(x_i²)用1/√(ms1e-5)去缩放整个向量。这相当于给每一层重新校准音量把不管多大多小的输入统一拉回稳定范围避免数值随着层数增多越滚越大或越滚越小。那个很小的1e-5是防止x恰好接近全零时除以 0。相比更常见的 LayerNormRMSNorm 省略了减均值这一步更简单但效果接近。残差连接的结构是先备份原始x归一化后的副本送进 attention/MLP 去计算算完的结果再和最初备份的x相加而不是替换掉它。这里有个值得展开算一遍的地方——残差连接为什么能让很深的网络依然训练得动。把多层残差结构展开写出来x_1 x_0 f_1(x_0) x_2 x_0 f_1(x_0) f_2(x_1) x_L x_0 f_1(x_0) f_2(x_1) ... f_L(x_{L-1})关键在于x_0作为一个独立的加数原封不动地出现在最终的x_L里它从来没有被送进任何一次乘法或非线性变换。反向传播求∂x_L/∂x_0时这个和式里必然有一项精确等于1跟层数L完全无关——这正是加法局部导数恒为常数 1 的直接体现。其余经过f_i分支传回的梯度可能会被压缩、甚至消失但这条跳过一切计算的恒等路径永远保底、不随深度衰减。这也是为什么残差连接必须用加法如果写成乘法局部导数就要依赖对方的具体数值一旦某个分量接近 0梯度照样会被压没恒为 1、不随深度衰减的保证就没了。6. MLP block另一半分工MLP 块的结构和 attention 块完全同构pre-norm 残差只是中间换成一个两层小网络linear16→64→ relu → linear64→16。真正值得强调的是它和 attention 的分工差异attention 是唯一让不同位置互相通信的地方——当前位置去看其他位置的信息MLP 只对单个位置自己的向量做独立变换不会混合任何其他位置的信息它依然知道自己的位置因为位置编码已经在向量里只是没有跨位置的信息流动。如果去掉 attention 只堆 MLP模型将无法完成根据前几个字符预测下一个字符这类任务——因为没有任何路径能把历史信息带到当前位置模型会退化成孤立猜测。一个 Transformer block 的套路正是先用 attention 让每个位置看看别人再用 MLP 对收集到的信息做一次消化加工。7. 完整 forward 过程串起来看把前面几节拼成一条主干流程查表取tok_embwtepos_embwpe相加融合身份位置信息rmsnorm归一化进入 Transformer blockattention吸收历史信息残差保底→ MLP消化提炼残差保底lm_head把最终 16 维向量投影回 27 维logits每个字符一个打分还不是概率要再过 softmax有个容易被忽视的关键点gpt()函数本身根本不知道下一个字符是什么——它只接收当前的token_id和pos_id吐出logits。“logits 代表下一个字符的打分这件事完全是训练循环人为规定的一种错位配对”token_id,target_idtokens[pos_id],tokens[pos_id1]# 喂当前的拿下一个当答案这个规定之所以能训练出预测下一个词的能力靠的正是第 4 节讲的因果性模型在算某个位置时看不到序列里更靠后的内容所以拿真正的下一个字符去给它的输出打分、反向传播是一个没有作弊的诚实训练信号——模型才能被梯度下降一步步调教成输出的打分越来越像真正的下一个字符。8. 训练循环交叉熵损失与 Adam损失函数的核心是交叉熵probssoftmax(logits)loss_t-probs[target_id].log()模型对正确答案越自信概率接近 1损失趋近 0越不自信、错得越离谱概率接近 0损失趋向无穷大。为什么用-log(p)而不是更朴素的1-p关键在于惩罚力度的性质1-p最多罚到 1 就封顶就算模型给正确答案打 0 分也不会罚得更重且导数恒为-1推力不随错误程度变化-log(p)在p→0时惩罚趋向无穷大导数是-1/pp越小梯度推力越大——恰好在模型最离谱的时候给出最强的纠正力度。反向传播这里正好呼应第 2 节一个名字有多个位置但所有位置共享同一套参数wte、wq……同一个参数在多次前向传播里被反复使用梯度靠把所有位置的贡献累加起来。参数更新用的是Adam优化器不是简单的减去梯度乘学习率而是维护每个参数的一阶矩m梯度的滑动平均类似动量和二阶矩v梯度平方的滑动平均代表典型波动幅度用偏差修正后的m_hat/√v_hat去更新参数——梯度一直很小很稳定的参数步子相对更大梯度大且波动的参数步子变小每个参数有自己独立、自适应的步长。别忘了每次更新完要把p.grad手动清零因为梯度是靠累加的不清零下一步的新梯度会叠加在旧梯度上训练直接崩溃。9. 推理与采样让模型开口编名字生成时每个样本从空的 KV cache、token_idBOS出发呼应第 1 节BOS 既是开始也是结束信号。关键的一个旋钮是temperatureprobssoftmax([l/temperatureforlinlogits])temperature 1比如 0.5会放大 logits 之间的差距softmax 分布更尖锐生成结果更保守、更贴近训练数据里的常见模式temperature 1则相反分布更平生成更随机、更天马行空。极端情况下temperature→0趋近于每次都选分数最高的贪心/argmaxtemperature→∞趋近于纯随机瞎选。最后是按概率抽样而不是贪心选最大值——如果用贪心同一个起点加同一套参数每次生成结果都会一模一样正是靠random.choices按概率抽样引入的随机性20 次生成才能得到 20 个不同的名字。生成会一直进行到模型自己输出 BOS 为止或者到达block_size的安全上限。10. microGPT 与真实 ChatGPT 差多远讲到这里整个算法已经完整了——embedding、注意力、MLP、残差连接、反向传播、Adam、采样这就是 GPT 的全部。真实的 ChatGPT 和它相比核心算法一字未改差距全部来自规模和多加的一个阶段数据3.2 万个名字对比几千亿到几万亿 token 的全网文本网页、书籍、代码、对话分词器字符级、词表 27对比 BPEByte Pair Encoding子词分词、词表 5 万到 10 万以上——BPE 把常见词或词根打包成一个 token同样内容编码出的序列更短同样的上下文窗口能装下更多实际内容规模1 层、16 维、4192 个参数对比 GPT-3 的 96 层、12288 维、1750 亿参数——大约四千万倍的差距。层数越多信息被逐层通信加工的轮次就越多后训练microGPT 完全没有的一个阶段microGPT 训练的唯一目标是预测下一个字符这叫预训练pretraining。ChatGPT 在预训练之后还要经过监督微调SFT用人工写的问答示例和基于人类反馈的强化学习RLHF专门调教该怎么回应人类的指令。这一步和预测下一个词完全是两码事——只是单纯放大预训练的规模更多数据、更多参数、更好的分词器并不会自动让模型学会听指令、拒绝有害请求对齐这件事必须靠额外的后训练阶段才能获得。写在最后microGPT 最打动人的地方不是它多精巧而是它证明了一件事理解 GPT 不需要理解一千七百五十亿个参数只需要理解 4192 个参数背后的那套逻辑——它们是完全一样的东西只是被放大了几千万倍又在后面多接了一道人类反馈的调教工序。如果你想真正把这套逻辑吃透最好的办法就是像本文这样把这两百行代码逐块拆开一步步跟着推一遍。源码karpathy/microgpt.py
返回列表