卡帕西疯魔记:243行纯Python手搓GPT全流程拆


卡帕西用最原始的Python代码,从自动求导引擎开始,手写一个完整的GPT模型,实现训练、优化、推理与文本生成全过程,结构极简,逻辑透明,是理解大模型底层机制的绝佳范本。

这一整份代码干了一件非常燃的事:用最朴素的Python语法,从零开始,手工造一个可以训练、可以生成文本的GPT模型。没有深度学习框架,没有加速库,没有花哨依赖。只有基础数学、列表、循环,还有一套自己写的自动求导引擎。

安德烈·卡帕西(Andrej Karpathy)在斯坦福读博时就是李飞飞(Fei-Fei Li)的学生,搞计算机视觉出身,后来成了OpenAI的创始成员之一,再后来被马斯克挖去特斯拉当AI总监,主导了自动驾驶的视觉系统。2022年他离开特斯拉,回去做教育内容,在YouTube上开课讲神经网络,从微积分基础讲到GPT原理,吸粉无数。

这人的特点是能把复杂的东西讲得连你奶奶都能听懂,而且代码风格极其洁癖,追求极简和优雅。

这个项目就是他的典型风格:去掉一切花里胡哨,只留核心算法,让你看清楚大模型到底是怎么学会说话的。

代码开头那段注释说得明明白白:这个文件包含训练GPT所需的全部算法,其他的一切都只是效率优化。这句话太重要了。现在的AI框架比如PyTorch和JAX,做了海量优化,CUDA加速、分布式训练、内存管理,但这些是工程细节,不是智能的本质。

卡帕西想展示的是,哪怕用最朴素的Python,只要算法对了,AI就能学习。这就像一个顶级厨师用一把菜刀也能做出米其林大餐,而新手即使有全套德国厨具也只会煮泡面。

这个项目证明了,大语言模型的核心其实非常简洁:嵌入、注意力、前馈网络、残差连接,就这么几板斧。理解了这个,再看那些几百层的GPT-4,你也不会觉得神秘,只是规模问题而已。

超参数设置:AI大脑的基因配置


代码开头用argparse定义了一堆超参数,这些数字决定了模型的智商上限。

parser.add_argument('--n-embd', type=int, default=16)
parser.add_argument('--n-layer', type=int, default=1)
parser.add_argument('--block-size', type=int, default=8)
parser.add_argument('--num-steps', type=int, default=500)
parser.add_argument('--n-head', type=int, default=4)
parser.add_argument('--learning-rate', type=float, default=1e-2)

参数意义:

  • n_embd是16,意思是每个词用16维向量表示,这就像给每个字发了一个16格的身份证。控制向量维度,也就是每个字变成多少维数字。
  • n_layer是1,表示只有一层Transformer,这是极简配置,正式GPT有上百层。控制层数,决定网络深度。
  • block_size是8,表示模型一次能记住8个词,超过就忘了,像金鱼一样的记忆力。决定一次最多看多少个字。
  • num_steps是500步训练
  • n_head是4个注意力头,是注意力头数量。
  • learning_rate学习率0.01。是学习速度。

这些数字都很小,因为是在CPU上跑的演示版本。卡帕西故意设得这么小,就是为了让普通电脑也能跑起来,让你亲眼看着loss值一点点下降,看着AI从乱码变成能拼出名字。这种可触摸的学习体验,比看论文要爽一百倍。

数据准备:从名字开始教AI认字


# Dataset example: the names dataset (one name per line). rest of the code just assumes docs: list[str]
if not os.path.exists('input.txt'):
    import urllib.request
    urllib.request.urlretrieve('https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt', 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()] # list[str] of documents
random.shuffle(docs)

这段代码聪明得很——它先检查当前目录有没有一个叫 input.txt 的文件。如果没有?它当场用urllib.request.urlretrieve去GitHub上扒一份“人名列表”下来! 代码把每一行当作文档(doc),去掉空行和前后空格,存成一个列表 docs。然后还调皮地 random.shuffle(docs) 打乱顺序,防止模型死记硬背。

代码自动下载了一个名字数据集,就是一堆英文人名,每行一个。这个名单来自卡帕西自己的项目 makemore,里面全是英文人名,比如“Emma”、“Liam”、“Olivia”这种。简单到不能再简单,但足够展示原理。

为啥用人名?因为短、干净、有规律,适合教学。名字有固定模式,比如英文名字常以元音结尾,有常见的前缀后缀,AI能学到这些规律,但又不会像莎士比亚文本那么复杂。

这就像是教小孩认字先从"爸爸""妈妈"开始,而不是直接扔一本《战争与和平》。数据预处理用了最简单的字符级分词,每个字母是一个token,加上一个特殊的BOS标记表示开头。词汇表大小就是不同字符的数量,通常几十个而已。这种极简分词让代码更清晰,不用处理BPE或者SentencePiece那些复杂玩意。

然后构建字符级词表:

# Tokenizer: simple character-level tokenization with a BOS token delimiter
chars = ['<BOS>'] + sorted(set(''.join(docs)))
vocab_size = len(chars)
stoi = { ch:i for i, ch in enumerate(chars) } # string to integer
itos = { i:ch for i, ch in enumerate(chars) } # integer to string
BOS = stoi['<BOS>']
print(f"vocab size: {vocab_size}, num docs: {len(docs)}")

是句子开头标记。每个字符都会映射成一个数字。这叫做最原始的分词方式:字符级分词。好处很直接:实现简单,逻辑清晰;代价也很真实:模型要自己学拼写。

它用的是字符级分词——每个字母就是一个token!再加上一个特殊的(BOS:Beginning Of Sequence,序列开始符),整个词表就这么建起来了。
比如“Ada”会被切成 [, 'A', 'd', 'a', ]。
你看,连tokenizer都不用现成的,自己搓一个,三行搞定!
这就像你做菜不用买酱油,自己种黄豆发酵——虽然慢,但香得纯粹!

自动求导引擎:一切魔法的根基

自动求导引擎:用加减乘除实现反向传播,CPU直呼内行!
自动求导?不存在的!我自己造个“梯度计算器”!
一个从零实现的自动微分引擎:Value类

这是整段代码最疯魔的地方。别人用PyTorch,一句loss.backward()背后是数千行C++、CUDA核函数、自动微分图优化。
卡帕西自己写了个Value类,里面存一个数字和它的梯度,然后重载加减乘除、幂函数、对数、指数、ReLU。每一次运算,他都手动把链式法则写在_backward函数里。

卡帕西定义了一个Value类,每个实例包装一个数字,同时记录这个数字是怎么算出来的,以及怎么求梯度。

每个 Value 保存两个东西:data当前数值和grad 梯度

所有运算都被重载:加法、乘法、幂运算、对数、指数、ReLU,每做一次运算,就构建一个计算图节点。

add方法定义加法,
mul定义乘法,
pow定义幂运算,
还有log和exp。

每个操作都实现了_backward函数,这是反向传播的核心:

  def backward(self):
        # topological order all of the children in the graph
        topo =
        visited = set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)
        # go one variable at a time and apply the chain rule to get its gradient
        self.grad = 1
        for v in reversed(topo):
            v._backward()

backward 函数会按拓扑排序,从输出往回传播梯度。这就是自动求导。深度学习框架干的事情,本质就是这个。这里只是你亲眼看见它的骨架。
当你调用loss.backward()时,它先拓扑排序计算图,确定依赖关系,然后从终点往回走,用链式法则把梯度传回去。
这就像是给神经网络装了一个后悔药系统,每次预测错了,它都能精确算出每个参数该负多大责任,然后调整。

深度学习最玄乎的就是“反向传播”——模型怎么知道自己哪里算错了?通常我们靠PyTorch的autograd自动算梯度。
但这里?Value类把每个数字都变成会记账的小精灵! 看看这个Value:它不仅存data(数值),还存grad(梯度),还有个_backward函数,记录“如果我错了,该怎么通知我的上游”。

比如两个Value相加,结果out的_backward就会告诉左边:“你的梯度加上我的梯度”,右边也一样。乘法更狠:“你的梯度加上对方的值乘我的梯度”。连log、exp、relu都有对应的反向规则。

def <strong>add</strong>(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad += out.grad
            other.grad += out.grad
        out._backward = _backward
        return out

    def <strong>mul</strong>(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

你算a + b,反向传播时a的梯度加上输出梯度,b的梯度也加上输出梯度。
你算a * b,反向传播时a的梯度加上b的值乘以输出梯度,b的梯度加上a的值乘以输出梯度。
你算a的平方,反向传播时梯度加上2a乘以输出梯度。

然后他调用build_topo把整个计算图的节点拓扑排序,从输出开始倒着走一遍,逐个调用_backward。这就是反向传播。

backward()方法:它先做拓扑排序,把所有计算节点按依赖关系排好队,然后从最后的结果开始,一路往前喊:“喂!你错多少?快改!” 这整个过程就像一群小学生传纸条——最后一个同学发现答案错了,就挨个往前传:“张三,你抄错啦!李四,你算漏啦!” 最后全班一起改错。

这就是微积分第一课!这就是最原始的自动微分引擎,没有魔法,只有逻辑链! 而且你看它的addmul这些方法,还贴心地处理了int/float和Value混用的情况,简直是为懒人量身定制。有了这个小精灵系统,后面所有参数更新都能自动追踪梯度,完全不用手动推公式!

这个微分引擎只有几十行,但功能完整,支持所有需要的运算。

卡帕西甚至实现了ReLU激活和它的导数,用了一个巧妙的技巧:out.data > 0作为掩码,正数梯度为1,负数梯度为0。这种从零造轮子的做法,让你彻底理解PyTorch的autograd背后发生了什么。

参数初始化:矩阵就是权重

matrix = lambda nout, nin,std=0.02: [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]

这里就是取个高斯噪声,均值0,标准差0.02,填满矩阵。
标准差的0.02怎么来的?GPT-2论文里提过一句,卡帕西直接照搬。他不是追求最优初始化,他是追求最小可行初始化。

每个权重都是一个 Value。每个矩阵都是二维列表。


然后他手动造state_dict:词嵌入矩阵、位置嵌入矩阵、输出投影矩阵。每一层的注意力QKV矩阵、输出矩阵、MLP两层全连接。你数数这个字典里嵌套了多少层列表推导式。每一对中括号就是一层内存开销。


然后params = [p for mat in state_dict.values() for row in mat for p in row]。
所有参数被拉平到 params 列表里。三层循环拍扁所有参数,得到一个光秃秃的Value列表。这样做的意义非常直接:优化器可以统一更新。

注意attn_wo和mlp_fc2用了std=0,这是卡帕西的小技巧,让残差连接刚开始时更接近恒等映射,训练更稳定。

模型架构:Transformer的极简实现

函数 gpt(token_id, pos_id, keys, values)

gpt函数实现了完整的Transformer前向传播。
输入参数是token_id(当前字符的编号)、pos_id(位置)、keys和values(缓存的键值对,用于注意力)。

首先查表拿到token嵌入和位置嵌入,相加后做RMSNorm归一化。
RMSNorm比LayerNorm更简单,只除以一个缩放因子,不用减均值,计算更快效果差不多。
然后进入Transformer层循环,每层有两个子层:多头注意力块和MLP块。
注意力块先保存残差连接,然后归一化,计算Q、K、V三个矩阵。
keys和values被缓存起来,因为生成文本时要复用之前的计算。

然后分n_head个头并行计算注意力,每个头看head_dim维,Q和K点积除以根号d_k,softmax得到权重,加权求和V。

所有头的输出拼接后,用wo投影回去,加上残差。MLP块也是先保存残差,归一化后过两个全连接层,中间用平方ReLU激活(x^2 if x>0 else 0),这是和GPT-2的一个区别,更简单粗暴。

最后再过一层归一化和输出层,得到logits。

训练开始了!每个step拿一个名字(比如“Alice”),前后加变成[, 'A','l','i','c','e', ]。然后从左到右,一个字一个字地猜下一个是什么。比如当前位置是'A',模型要预测'l';位置是'l',预测'i'……直到倒数第二个。每次预测都走完整GPT流程:查wte+wpe得到输入向量,过n_layer层,输出logits,softmax变概率,取目标字符的概率取负log——这就是交叉熵损失!整个序列的损失取平均。

然后loss.backward()一声令下,所有Value小精灵开始反向传梯度。接着上Adam优化器:维护一阶矩m和二阶矩v,做偏差修正,最后用学习率lr_t(还带线性衰减!)更新每个参数。整个过程就像老师教小孩写字:写错一笔,就轻轻打手心(梯度),然后调整握笔姿势(参数),下次争取写对!

 而且你看,keys和values是跨时间步共享的——同一个样本的不同位置,后面的注意力能看到前面所有K和V,这才是真正的自回归!训练500步后,打印最近50步的平均loss,如果降到2以下,说明模型已经开始记住人名套路了!

流程如下:

  • 词嵌入 + 位置嵌入
  • 做一次均方根归一化
  • 进入多层循环

每一层包括:

  • 多头注意力
  • 残差连接
  • 前馈网络
  • 残差连接

注意力部分:

  • q = linear(x, attn_wq)
  • k = linear(x, attn_wk)
  • v = linear(x, attn_wv)

按头切分
算点积
除以维度开方
softmax
加权求和

这一段就是自注意力的全部,核心逻辑非常清楚:

  • 当前词看过去所有词
  • 根据相关性打分
  • 加权汇总信息

这就是上下文理解能力的来源。

所谓理解世界,本质就是对历史信息做加权求和。

注意力机制:AI的聚光灯系统

注意力机制是整个Transformer的核心创新,卡帕西的代码把它展现得淋漓尽致。

想象一下,你在读一句话时,眼睛会自动聚焦在关键信息上,注意力就是让AI拥有这种能力。Q(查询)是当前词的"问题",K(键)是所有词的"标签",V(值)是实际内容。计算Q和K的相似度,softmax变成概率分布,再乘V得到加权平均。

代码里keys和values用列表缓存,因为生成文本是逐个字符进行的,每次只需要计算新的Q,K和V可以复用之前的,这叫做KV缓存,是推理加速的关键。

多头注意力就是把维度分成几份,每份学不同的关注模式,有的看语法,有的看语义,有的看长距离依赖。卡帕西用简单的列表切片实现这个,q[hs:hs+head_dim]就是取第h个头的部分,清晰易懂。

Softmax和归一化:概率的炼金术

softmax函数把任意实数变成概率分布,代码里先减最大值防止指数爆炸,这是数值稳定的经典技巧。
RMSNorm则是另一种炼金术,它把向量长度标准化,让训练更稳定。

卡帕西用(ms + 1e-5) ** -0.5计算缩放因子,1e-5防止除零。这些操作看似简单,但没有它们神经网络要么梯度爆炸,要么梯度消失,根本训不动。

平方ReLU作为激活函数,正数部分平方,负数部分归零,比GeLU计算简单,效果在小型模型上差不多。

这些细节体现了卡帕西的工程品味:在保证效果的前提下,能简单就简单,让读者抓住主要矛盾。

Adam优化器:参数的自我修正系统

训练部分用了Adam优化器,这是深度学习的事实标准。它维护两个动量:m是一阶矩(梯度的指数移动平均),v是二阶矩(梯度平方的移动平均)。代码里beta1=0.9,beta2=0.95,eps=1e-8,学习率随时间衰减。

每次更新时,先算修正后的m_hat和v_hat,然后用m_hat / (sqrt(v_hat) + eps)作为步长,这相当于给每个参数自适应的学习率,梯度大的参数步子小,梯度小的参数步子大。

卡帕西手动实现了这一切,没有调用任何库,让你看到优化器到底是怎么改变参数的。训练循环里,p.grad = 0这一步很关键,清空梯度防止累积,这是新手常踩的坑。

推理生成:文本随机生成 

训练完当然要看看它能生成啥!代码进入inference模式:从开始,一步步预测下一个字符,直到再遇到或达到最大长度。

关键来了——它用了temperature=0.5!temperature参数控制随机性,0.5是保守值,越大越随机。

这是啥意思?就是在softmax之前,先把logits除以0.5(相当于放大差异),这样高概率的选项更可能被选中,低概率的几乎没机会。如果temperature=1.0,就是完全按模型原始概率采样;如果接近0,就变成贪心搜索(永远选最高分)。0.5是个甜点——既有创造性,又不至于乱码。比如模型可能生成“Emmalyn”、“Jaxon”这种像人名但不存在的词,说明它学会了“E开头常接m,J后面爱跟a”这种模式。

而且你看,每次生成都要重置keys和values缓存,确保上下文干净。20个样本打印出来,如果大部分像人名,恭喜!你的纯Python GPT成功了!如果全是乱码?别急,可能是n_embd太小或训练步数不够——毕竟默认配置只是玩具,但原理绝对正宗!

你会看到模型一开始输出乱码,训练久了开始输出类似真实名字的结构,比如以元音结尾,有常见的前缀。

这就是涌现,简单规则的叠加产生了看似智能的行为。

总结

训练一个GPT需要的算法元素,其实并不多。

  • 嵌入
  • 注意力
  • 前馈
  • 归一化
  • 残差
  • 损失
  • 反向传播
  • 优化器

所有现代大模型的复杂度,主要来自规模和效率优化。

命令行怎么玩?几个参数调一调,秒变超参数炼丹师!

这代码可不是写死的,它用argparse搞了一套命令行接口,让你随便调参:

python gpt.py --n-embd 32 --n-layer 2 --block-size 16 --num-steps 1000 --n-head 8 --learning-rate 0.01
  • --n-embd:每个token的向量有多宽?默认16,加大能学更复杂模式。
  • --n-layer:叠几层Transformer?默认1层,加到2-3层效果飙升。
  • --block-size:最多看多长的序列?默认8,人名一般够用。
  • --num-steps:训练多少步?默认500,加大到2000更稳。
  • --n-head:注意力分几个头?默认4,必须整除n_embd!
  • --learning-rate:学习速度?默认0.01,太高会震荡,太低学不动。

你就是炼丹师,这些参数就是你的药材! 比如把n_embd=64, n_layer=3, num_steps=2000,loss很可能降到1.0以下,生成的名字更逼真。但小心——参数越大,训练越慢,纯Python可没GPU加速!这就像用算盘解微积分,精神可嘉,效率感人。但卡帕西要的不是效率,是透明——让你看清每一滴梯度怎么流,每一个权重怎么变。这才是教育的本质!

核心思想就在这几百行里。它像一座微缩景观:麻雀虽小,五脏俱全。你可以逐行调试,看Value.grad怎么变,看attention weights怎么聚焦,看loss怎么下降。这种掌控感,是黑箱框架永远给不了的!

当你跑通它,看着终端吐出“Emmalie”、“Zayden”这种名字时,你会笑出声——原来AI,不过如此!