200行Python代码实战:从Bigram模型到大型语言模型(LLM)的构建之旅!

前言

上一篇文章 《从零开始200行python代码实现LLM》,实现了一个“诗词生成器”,从一个基于“概率统计”的实现开始,最后使用pytorch,实现了一个经典的Bigram模型。

在Bigram模型里,每一个字只和前一个字有关,尽管是这样,我们的babygpt_v1.py 也输出了“渐觉是路,故园春衫。”这种看起来比较通顺的语句。

本文继续从 babygpt_v1.py 出发,逐渐加入self-attention机制、position嵌入等机制,直到实现一个完整的GPT。

本文适用范围及目标:

  •  ✅看过前文,会写python和已经硬背下了基本机器学习代码结构;

  •  ✅尝试实现完整的语言模型;

  •  ❌不解释数学、机器学习原理性的知识,只做到“能用”为止(因为我也不懂);

  •  ❌不依赖抽象层次高的框架,用到的部分也会做解释;

最终效果

运行方法:

$ git clone https://github.com/simpx/buildyourownllm.git$ cd buildyourownllm$ pip install -r requirements.txt$ python babygpt_v11_hyper_params.py

结果:

step 0: train loss 8.0529, val loss 8.0512, speed: 55304.00 tokens/sec, time: 0m 0.3sstep 50: train loss 5.9337, val loss 6.0072, speed: 102707.49 tokens/sec, time: 0m 8.1s...step 4950: train loss 2.8944, val loss 3.9963, speed: 104340.95 tokens/sec, time: 12m 57.4s春江水似流。
临江仙 陈允平红柳依然蝴蝶乱,汝江桥。剪轻鸥荐日飞来。叠叠匀酥相半掩,霏霏残梦归郎乱注疏篱。属车忽到谢仙归。毕竟归来如梦去,会容觞咏再来期。
一翦梅 陈允平芍药闲来草碧初。
----------往事,恨功名淡泪眼垂些。幅酒难禁。无缘一点恩光万红成。而今宁许我堪歌更擘划,无计是愁人。
临江仙 魏了翁思深契阔隐驹重,却倾不惜伤牵。尊频劝客莫徘徊。舣舟方把柂,更须取易相随。无风吹我鬓毛----------

在我的4090上,需要运行12分57秒完成训练,损失值变化如下图,而在我的mac(16GB内存 m1 pro)上,相同代码耗时4个小时才完成了训练。

对比之前使用Bigram模型输出的词。​​​​​​​

春江月 张先生疑被。
倦旅。清歌声月边、莼鲈清唱,尽一卮酒红蕖花月,彩笼里繁蕊珠玑。只今古。浣溪月上宾鸿相照。乞团,烟渚澜翻覆古1半吐,还在蓬瀛烟沼。木兰花露弓刀,更任东南楼缥缈。

我们使用Transformer输出的词明显看起来更像真的。​​​​​​​

临江仙 陈允平红柳依然蝴蝶乱,汝江桥。剪轻鸥荐日飞来。叠叠匀酥相半掩,霏霏残梦归郎乱注疏篱。属车忽到谢仙归。毕竟归来如梦去,会容觞咏再来期。

经过训练,我们的模型的输出已经能让人看得出“词牌名”、“人名”了,这句“红柳依然蝴蝶乱”,简直能以假乱真了😬。

Transformer模型结构

先来看下图,来自"Attention Is All Your Need"论文中的"Model Architecture"章节。这张图已经被引用滥了,我们会一步步实现图中的线和框。

Transformer是由这篇论文提出,最初是用来实现翻译任务的模型。图中的左边为encoder ,它的Inputs 是“翻译的原文”,经过计算后,变成向量。而图右侧为decoder ,它是一种自回归的结构。它的输入,即图中的Outputs(shifted right) 是自己上一轮的输出。

​​​​​​​

在翻译任务刚开始的时候,“翻译的原文”输入encoder ,转换为向量,而<sos> (start of sequence)这样的“特殊token”会被当做decoder 的初始输入,每一轮decoder 的计算,都会把encoder的输出结果加入到某个隐藏层里去。decoder 的输出会当做下一轮decoder 的输入,反复这个过程,直到decoder 输出了<eos> (end of sequence)这样的特殊"token",表示结束。

这样的结构,实现了“翻译的原文”和“目标文”的关联,也让“目标文”本身之间有语义关系。

理解这个结构很关键,因为今天的大语言模型,就是基于Transformer架构的decoder 部分实现的(所以经常被称为"Decoder-only"架构),实际上GPT不知道何为“输入”、“输出”,甚至不知道哪部分是“人类提问“、哪部分是“AI回答”,在“Decoder-only”的Transformer架构里,每个token都是平等的,AI只是在不断自回归的“续写”文字而已,就像最终效果展示的一样。

看起来模型输出了词牌名“临江仙”和人名“陈允平”,但实际上模型只是依据概率,在“临江仙”后面加了一个空格,让读者以为它真的知道什么叫“词牌名”了。

对AI来说,人和AI的“对话”只是一堆以空行、回车隔开的token而已,而只有人在“阅读”这段文字的时候,才会觉得“似乎人和AI在对话” —— “意义”实际上是由读者赋予的。

实现Positional Encoding

以前在系统领域发论文的时候,配的架构图通常是“示意图”,和真实的结构还是有差距的。第一次细读机器学习领域的论文,发现这张图居然就是“代码”,下面我们看看如何实现图中的Positional Encoding。

在"Attention"原文中,采用了一种“正弦余弦位置编码”的方式实现,但实际上GPT采用的是一种更简单的“learned position embeddings”的方式,如"Improving Language Understanding by Generative Pre-Training"论文中的描述。

We used learned position embeddings instead of the sinusoidal version proposed in the original work. We use the ftfy library2 to clean the raw text in BooksCorpus, standardize some punctuation and whitespace, and use the spaCy tokenizer.3

可以暂时不懂生僻的“learned position embeddings”到底是什么意思,直接来看代码实现(以下是在babygpt_v1.py 基础上的diff),只增加了9行而已,完整代码见babygpt_v2_position.py[1]。​​​​​​​

class BabyGPT(nn.Module):
-    def __init__(self, vocab_size: int, n_embd: int):+    def __init__(self, vocab_size: int, block_size: int, n_embd: int):         super().__init__()+        self.block_size = block_size         self.token_embedding_table = nn.Embedding(vocab_size, n_embd)+        self.postion_embedding_table = nn.Embedding(block_size, n_embed) # 建设一个“位置”映射关系         self.lm_head = nn.Linear(n_embd, vocab_size)
     def forward(self, idx, targets=None):+        B, T = idx.shape # B是batch size,T是block size+        T = min(T, self.block_size)+        idx = idx[:, -T:] # 不管输入的序列有多长,我们只取最后的block_size个token         tok_emb = self.token_embedding_table(idx) # 获得token的嵌入表示 (B,T,n_embd)-        logits = self.lm_head(tok_emb)+        pos_emb = self.postion_embedding_table(torch.arange(T, device=idx.device)) # 获得位置的嵌入表示 (T,n_embd)+        x = tok_emb + pos_emb # 给token的嵌入表示加上位置的嵌入表示,x有了“位置”信息!+        logits = self.lm_head(x) # 通过线性层,把embedding结果重新映射回vocab_size维空间 (B,T,vocab_size)

It just works!

可以看到,区区5~6行有效代码,就在我们的神经网络上加了一层,让我们的神经网络在训练的时候,不仅考虑到token本身,还考虑了token所在的位置,就是这么神奇。

毕竟是第一次“加层”,我们仔细解读一下这里的代码。

  • Line 8:如openai描述的,我们这里用了一个“可学习的位置嵌入矩阵”。说白了,就是我们增加了很多参数,当block_size 长度的token被输入的时候,除了用token_embedding_table获得它们的向量外,还把它们的“位置”也转换成向量。而这个转换的table,也会在神经网络中被训练。

  • Line 12 ~ 14:由于我们的postion_embedding_table 是一个key只有block_size 大小的“表”,因此无论多长的序列输入进来,我们必须截断,保留最后的block_size 个token。

  • Line 17:获得token的“位置”的向量,torch.arange(T) 实际上就是返回[0,1,2,...,T-1] ,这就是每个token的“位置”(说白了是不是发现很土?)。

  • Line 18:把原来的token向量和它的位置向量加起来,形成新的向量。此时回头看看Transformer架构图中连接🔗"Output Embedding"和"Positional Encoding"的节点上,有一个明显的加号,原来不是为了美观,而是真的“加法”😄。

其他训练、推理代码都不变,我们就完成了“positional embedding”,通过运行python babygpt_v2_position.py 可以看到结果,实际上并没有比之前的版本有什么太明显的提升。

当我们改模型的时候,我们到底改了什么?

至此,我们成功往模型中加了一层, 简单到不可思议,我们到底做了什么?

由于本系列是“零基础”,只求能使用和简单修改模型,因此,只尝试“品”一“品”我们做了什么。

当我们在nn.Module 类的构造函数中(如BabyGPT ),通过变量赋值的方式,赋值了另一个同样继承了nn.Module 的类(如nn.Embedding ),pytorch就会偷偷把信息都记录下来,当我们后面通过:

optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate) 这样的优化器训练模型的时候,优化器就知道要修改哪些层次的参数 —— 因此,我们刚刚加入的position embedding 的参数就这样被神奇的“训练”了。

当我们第一次执行token嵌入(上面代码的Line 15),我们获得了一个形状为(B, T, n_embd)的张量,其中B是batch size,T是block size,而每一个token都有n_embd个“参数”,这些参数是一些浮点数。我们执行position嵌入(上面代码的Line 17)后,我们获得了一个形状为(T, n_embd)的张量,简单的可以认为是T * n_embd浮点数。

当两个张量相加后,我们获得了一个(B, T, n_embd)的张量,只不过每个浮点数都略微“增加”了一点,那一点就是位置信息的数字表示。

作为没有机器学习背景的人来说,冥冥中感受到机器学习的本质 —— 似乎就是把我们人为认为需要的信息,通过各种方式,编码到一些可训练的参数里去。至于“为什么这里用加法?”、“为什么position和token的向量可以相加,单位是什么?”这些都不是很重要,关键点在于“做完加法后,信息没有丢失”、“参数可训练”,即可。

始终记住,今天我们看到的“模型”都是人为定义出来的,机器学习和我们平时写代码,在“精准表达”上有非常大的不同。在机器学习里,真正正确的只有“迭代并寻找loss值下降的办法”这个方法,而不是“模型”或者“表达事物关系”的方法😄。

在这里解释这么多,是为了避免像我一样写系统代码出身的同学,会太纠结这里面的原理。

实现Single Head Self-Attention

终于要实现Attention机制了。🥹

Attention到底是什么?很多文章比喻说,人看照片的时候,只关注图片中的突出部分,就是注意力 —— 但是,机器为什么会有“注意力”?

我自己尝试理解注意力机制的时候,被很多“比喻”搞得一头雾水,有人说q代表了“当前查询”、kv代表了其他信息,我这里尝试展示具体的过程来解释计算attention的过程,对于读者来说,最关键的是时刻回想一下张量代表的“信息”和“形状”。

从前面positional encoding里,学到最重要的知识就是 —— “两个数加起来,它们就产生了关系”,用它们相加之和去做后续的计算,就是把它们各自代表的“信息”给传递了下去。

上一篇文章里的Bigram模型认为,每一个token只和“上一个”token有关,而注意力机制认为,每个token出现的概率,取决于这个token之前的所有token的信息。

最简单的“注意力”,加权求和

假设我们有一个基本的“token - 特征”矩阵v ,形状是(3, 2),如下:​​​​​​​

>>> v = torch.randint(0, 10, (3, 2)).float()tensor([[5., 0.], # Token1 的特征:[特征A=5, 特征B=0]        [8., 1.], # Token2 的特征:[特征A=8, 特征B=1]        [6., 1.]]) # Token3 的特征:[特征A=6, 特征B=1]

按照babygpt_v2_position.py里的实现,我们也可以把v 当做token被embedding后的结果,把v看做一个batch_size 为1,block_size 为3,n_embed (每个token的“特征”)为2的张量。

如何能计算出一个新的“token - 特征”矩阵v2 ,让它形状依旧是(3, 2),但是它的内容,是对应位置token的特征值,加上这个token之前所有token特征值之和的平均数呢?

遍历求和再求平均值 —— 当然可以实现,不过我们用一个更加“机器学习”的办法。

先算出一个“权重矩阵”wei:​​​​​​​

>>> tril = torch.tril(torch.ones(3, 3))tensor([[1., 0., 0.],        [1., 1., 0.],        [1., 1., 1.]])>>> wei = tril / torch.sum(tril, 1, keepdim=True)tensor([[1.0000, 0.0000, 0.0000],        [0.5000, 0.5000, 0.0000],        [0.3333, 0.3333, 0.3333]])

tril是一个形状为(3, 3)的“下三角”矩阵,功能类似于做掩码mask。这个矩阵除以每一行的总和后就得到了wei 权重矩阵,用来表示“多大权重的关注其他token”,实际上就是我们说的“注意力矩阵” —— 这里的数值,就是token之间的“注意力”大小了。

如下更直观的解释:​​​​​​​

[[1.0, 0.0, 0.0],   # Token1 只关注自己 [0.5, 0.5, 0.0],   # Token2 平均关注 Token1 和 Token2 [1/3,1/3,1/3]]     # Token3 平均关注所有 Token

最后做一个简单的矩阵乘法,我们就得到了我们的v2。​​​​​​​

>>> v2 = wei @ vtensor([[5.0000, 0.0000],        [6.5000, 0.5000],        [6.3333, 0.6667]])

我们能看到结果的每一行,是v  的列的加权求和,权重由 wei  的对应行决定,如下解释。​​​​​​​

[[5.0, 0.0],   # Token1 的新特征:直接保留自己的特征(权重[1,0,0]) [6.5, 0.5],   # Token2 的新特征:Token1和Token2的平均(权重[0.5,0.5,0]) [6.333,0.666] # Token3 的新特征:所有Token的平均(权重[1/3,1/3,1/3])]

v2 里的每一行,就是对应token 经过“注意力”机制计算的新值,可用于后续预测对应token 的下一个值,就和Bigram模型的输出一样。

特别的,机器学习中还有更优雅的获得wei 的方式,篇幅原因不展开,眼熟一下即可。

另一种计算wei的方式:​​​​​​​

>>> tril = torch.tril(torch.ones(3, 3))>>> wei = torch.zeros((3, 3))>>> wei = wei.masked_fill(tril == 0, float('-inf'))>>> wei = F.softmax(wei, dim=-1)tensor([[1.0000, 0.0000, 0.0000],        [0.5000, 0.5000, 0.0000],        [0.3333, 0.3333, 0.3333]])

真正的注意力计算,QKV!

在“Attention”论文中,注意力计算需要用到3个矩阵,我们不深究原因,直接按照算法实现即可。

我们以batch_size 为8,n_embed 16为例,展示计算过程和结果。

这里我们用到了head 概念,以我个人理解,head 可以当做注意力机制的一个“量词”,一个head 就是一组QKV计算。

第1步,初始化输入​​​​​​​

>>> head_size = 16 # 人为定义的注意力维度>>> x = torch.randn(1, 8, 16)  # 单批次 (B=1), 8个token, 每个token 16维特征>>> B, T, C = x.shape  # B=1, T=8, C=16

此时的x:​​​​​​​

          x (1,8,16)        ┌───────────────────┐        │ token1 (16维)      │        │ token2            │        │ ...               │        │ token8           │        └───────────────────┘

第2步:计算 Key/Query/Value​​​​​​​

>>> key = nn.Linear(C, head_size, bias=False)>>> query = nn.Linear(C, head_size, bias=False)>>> value = nn.Linear(C, head_size, bias=False)
>>> k = key(x)   # (1,8,16)>>> q = query(x) # (1,8,16)>>> v = value(x) # (1,8,16)

key/query/value,本质上就是3个没有偏置的线性层,也就是说,里面各只有一个w 参数,非常简单。

三个矩阵计算结果形状都一样.​。

        q/k/v (1,8,16)        ┌───────────────────┐        │ 新特征1 (16维)     │        │ 新特征2           │        │ ...               │        │ 新特征8           │        └───────────────────┘

第3步:计算注意力分数 (Q·Kᵀ) 并缩放

k.transpose(-2, -1) 是对K做转置(改变了形状),以便能和q 做矩阵乘法。通过缩放,我们可以将结果缩放到一个合理的范围,避免梯度消失问题。​​​​​​​

>>> d_k = k.size(-1)  # Key 的维度 (16)>>> wei = q @ k.transpose(-2, -1)  # (1,8,8)>>> wei = wei / (d_k ** 0.5) # (1,8,8)

这就是我们的“注意力矩阵”!😎还记得上一个版本的wei 是简单的下三角矩阵,这里复杂的多。​​​​​​​

        q (8,16)           kᵀ (16,8)        ┌─────────┐        ┌─────────┐        │ ...     │        │ ...     │        └─────────┘        └─────────┘              ↓                ↓              [[q1·k1, q1·k2, ..., q1·k8],               [q2·k1, q2·k2, ..., q2·k8],               ...               [q8·k1, q8·k2, ..., q8·k8]]              ↓        wei (8,8) 每个元素是点积分数

第4步:应用掩码

tril = torch.tril(torch.ones(T, T))  # 下三角矩阵wei = wei.masked_fill(tril == 0, float('-inf'))  # 只保留当前token及之前的信息

掩码效果:

原始 wei:[[ q1·k1, q1·k2, q1·k3, ..., q1·k8 ] [ q2·k1, q2·k2, q2·k3, ..., q2·k8 ] ... [ q8·k1, q8·k2, q8·k3, ..., q8·k8 ]]
应用下三角掩码后:[[ q1·k1, -inf,   -inf, ..., -inf  ] [ q2·k1, q2·k2, -inf, ..., -inf  ] ... [ q8·k1, q8·k2, q8·k3, ..., q8·k8 ]]

第5步:Softmax 归一化

wei = F.softmax(wei, dim=-1)  # 按最后维度归一化

效果示例(数值为示意):

[[1.0, 0.0, 0.0, ..., 0.0]   # token1只能看到自己 [0.3, 0.7, 0.0, ..., 0.0]   # token2看到token1和2 ... [0.1, 0.1, 0.1, ..., 0.6]]  # token8看到所有历史

第6步:聚合 Value

out = wei @ v  # (1,8,16)

在out里,每个 token 的输出是 v  向量的加权和,权重来自wei 表示的注意力,例如:

out[2] = 0.3*v1 + 0.7*v2 + 0*v3 + ... + 0*v8

最终输出结果:

        out (1,8,16)        ┌───────────────────┐        │ 新token1 (16维)    │  # 只包含token 1信息        │ 新token2          │  # 包含token 1、2的信息        │ ...               │  # ...        │ 新token8          │   # 包含token 1 ~ 8信息        └───────────────────┘

总结 & kvcache

以上就是注意力的计算方法,现在再来看论文中的公式就没那么陌生了:

其中:

  • Q是查询矩阵,维度为

    ,n是查询数量;

  • K是键矩阵,维度为

    ,m是键的数量;

  • V是值矩阵,维度为

    ,m是值的数量(与键的数量相同);

  • 是键和查询的维度;

  • 是缩放因子,用于防止点积过大导致梯度消失;

我们实现了完整的公式,根据得到的out 张量,我们可以预测“下一个token”,比如out[1] 是用token1 ~ 2的信息计算而来,用来预测token3 。如果我们是在做训练,那么我们会计算每一个token的out 值,如果我们只是在做推理,那么我们只需要增量计算最后一个out 值即可。

此外,通过观察“第2步”计算注意力分数的结果wei 矩阵:

       q (8,16)           kᵀ (16,8)        ┌─────────┐        ┌─────────┐        │ ...     │        │ ...     │        └─────────┘        └─────────┘              ↓                ↓              [[q1·k1, q1·k2, ..., q1·k8],               [q2·k1, q2·k2, ..., q2·k8],               ...               [q8·k1, q8·k2, ..., q8·k8]]              ↓        wei (8,8) 每个元素是点积分数

我们能看到,最后一行的结果[q8·k1, q8·k2, ..., q8·k8] 只和q8 有关,但和k1 ~ k8 都有关系,因此我们也能得出,当推理的时候,我们实际上只需要计算当前token的q 即可,但我们还是要计算所有token的k 和v 的值 —— 这就是为什么我们可以用“kvcache”提升推理性能,而没有“q-cache”的说法。

注意力代码实现

实际上代码量也不多,完整见babygpt_v3_self_attention.py ,下面是注意力部分代码:

class Head(nn.Module):    def __init__(self, head_size):        super().__init__()        self.key = nn.Linear(n_embed, head_size, bias=False)        self.query = nn.Linear(n_embed, head_size, bias=False)        self.value = nn.Linear(n_embed, head_size, bias=False)        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
    def forward(self, x):        B, T, C = x.shape # (batch_size, block_size, n_embed)        k = self.key(x)   # (B, T, head_size)        q = self.query(x) # (B, T, head_size)        v = self.value(x) # (B, T, head_size)        wei = q @ k.transpose(-2, -1) / (k.size(-1) ** 0.5)         wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))         wei = F.softmax(wei, dim=-1) # (B, T, T)        out = wei @ v # (B, T, T) @ (B, T, head_size) = (B, T, head_size)        return out
@@ -52,6 +73,7 @@ class BabyGPT(nn.Module):         tok_emb = self.token_embedding_table(idx) # 获得token的嵌入表示 (B,T,n_embd)         pos_emb = self.postion_embedding_table(torch.arange(T, device=idx.device)) # 获得位置的嵌入表示 (T,n_embd)         x = tok_emb + pos_emb # 给token的嵌入表示加上位置的嵌入表示,x有了“位置”信息!+        x = self.sa_head(x) # self-attention    

可以看到,和上面解释的代码实现是一样的,这么简单,就又给我们的模型加了一层。运行后会发现,依旧没有什么突飞猛进的提升 😅。

实现剩下的层

以上,我们明白了往神经网络里加layer,实际上就是简单的“新增一个nn.Module ”、“在forward中应用新layer”的过程。

并且,我们一起实现了Transformer最重要的Attention机制,而其他实现都是神经网络中的常见机制,因此这里不再详细解释后面每一层的原理,只大概介绍用途,并且通过babygpt_vN_xx.py 文件,看到添加的整个过程,通过本地运行diff 命令查看两个相邻文件之间的差异,就可以明白添加了什么。

虽然这么做有下图的嫌疑,但谁让我们是一个“零基础”教程呢😅。

1. Multi-Head Self-Attention

代码见babygpt_v4_multihead_attention.py

实际上multi-head只是对多个head的concat 操作,如下:

+class MultiHeadAttention(nn.Module):+    def __init__(self, num_heads, head_size):+        super().__init__()+        self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])+    def forward(self, x):+        return torch.cat([h(x) for h in self.heads], dim=-1)+@@ -63,7 +72,7 @@ class BabyGPT(nn.Module):-        self.sa_head = Head(n_embed) # self-attention 头+        self.sa_heads = MultiHeadAttention(n_head, n_embed//n_head) # 从单头变多heads的注意力,但每个heads size变小了

2. FeedForward

代码见babygpt_v5_feedforward.py

FFN通过ReLU激活引入非线性,使模型能拟合更复杂的函数。

实际上代码很简单,主要是为了模型的表现力更强。

+class FeedFoward(nn.Module):+    def __init__(self, n_embed):+        super().__init__()+        self.net = nn.Sequential(+            nn.Linear(n_embed, n_embed),+            nn.ReLU(), # 把负值变为0,正直不变的激活函数+        )+    def forward(self, x):+        return self.net(x)

3. Block

代码见babygpt_v6_block.py

Block实际上指的就是模型结构图中的灰色部分。

封装成一个Block类,这样我们就能顺序连接多个Block,即图中的“Nx”的含义。

有时候我们说一个Transformer结构有N层,值得也是这里的Block数量。

+n_layer = 3# block的数量
+class Block(nn.Module):+    def __init__(self, n_embed, n_head):+        super().__init__()+        head_size = n_embed // n_head+        self.sa = MultiHeadAttention(n_head, head_size)+        self.ffwd = FeedFoward(n_embed)++    def forward(self, x):+        x = self.sa(x)+        x = self.ffwd(x)+        return x

4. 残差神经网络(Residual Connection)

代码见babygpt_v7_residual_connection.py

是一种非常简单的结构,直接把输入x跨层传递到后续层,解决训练中出现的梯度消失问题,代码如下,非常直白(神经网络中越牛逼的词汇,对应代码量就越少😅,你没看错,就这么两行)。

     def forward(self, x):-        x = self.sa(x)-        x = self.ffwd(x)+        x = x + self.sa(x) # 使用了残差连接,保留原来的x信息,避免梯度消失+        x = x + self.ffwd(x)         return x

5. 投影(Projection)

代码见babygpt_v8_projection.py

投影(Projection) 是一种将输入数据从当前向量空间映射到另一个向量空间的操作,在我们的实现中,就是简单的一个线性层。用途也是为了提升模型的表现能力。

  • @@ -54,8 +54,9 @@ class FeedFoward(nn.Module):
         super().__init__()         self.net = nn.Sequential(-            nn.Linear(n_embed, n_embed),+            nn.Linear(n_embed, n_embed * 4),             nn.ReLU(), # 把负值变为0,正直不变的激活函数+            nn.Linear(n_embed * 4, n_embed),         )     def forward(self, x):         return self.net(x)@@ -64,9 +65,11 @@ class MultiHeadAttention(nn.Module):     def __init__(self, num_heads, head_size):         super().__init__()         self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])+        self.proj = nn.Linear(n_embed, n_embed) # 投影层,把多头注意力的输出映射回n_embed维度
     def forward(self, x):-        return torch.cat([h(x) for h in self.heads], dim=-1)+        out = torch.cat([h(x) for h in self.heads], dim=-1)+        return self.proj(out)

如上面的代码,我们在两个位置增加了线性层,对于整个模型实际上除了增加了参数外,没有别的差别。

6. 层归一化(Layer Normalization, LayerNorm) 

代码见babygpt_v9_layer_norm.py

神经网络中,层归一化(Layer Normalization, LayerNorm) 是一种用于加速训练、提升模型稳定性的重要技术,LayerNorm 对单个样本的所有特征进行归一化,使其均值为0、方差为1,再通过可学习的参数调整分布。

如下所示:

>>> ln = torch.nn.LayerNorm(3)>>> x = torch.tensor.randint(0, 10, (2,3)).float()tensor([[9., 2., 4.],        [5., 6., 9.]])>>> ln(x)tensor([[ 1.3587, -1.0190, -0.3397],        [-0.9806, -0.3922,  1.3728]], grad_fn=<NativeLayerNormBackward0>)

实现也非常简单,在一些输出的位置使用了torch.nn.LayerNorm 做了归一化,这里不再贴代码。

注意,原Transformer论文中采用 Post-LayerNorm,每个子层(自注意力/前馈网络)的输出经过残差连接后再进行LayerNorm,而GPT系列采用 Pre-LayerNorm,先对输入进行LayerNorm,再进入子层计算,最后通过残差连接,这种结构使训练更稳定,成为后续模型的标配(如GPT-2/3、LLAMA等)。

7. Dropout

代码见babygpt_v10_dropout.py

在机器学习中,Dropout 是一种广泛使用的正则化技术,旨在防止神经网络过拟合。它通过随机丢掉部分神经元的结果,来迫使模型学习更鲁棒的特征表示,如下:

>>> x = torch.randint(0, 10, (2,3)).float()>>> dp = torch.nn.Dropout(0.5)>>> xtensor([[5., 9., 7.],        [8., 3., 1.]])>>> dp(x)tensor([[10.,  0.,  0.],        [ 0.,  6.,  0.]])

当dropout比例为0.5时,执行dropout会随机丢掉输入的50%特征。

具体的,我们在Head、Multi-Head、FeedForward的输出上都加了dropout。

调整参数,开始训练

至此我们实现了完整“GPT-like”的Transformer结构代码,我们再调整一下代码文件头部的超参数。

最终完整代码见babygpt_v11_hyper_params.py[2],删了一些注释后,代码刚好200行整 😄(实际上有大量代码可以精简,处于简单目的而保留)。

-batch_size = 32 # 每个批次的大小-block_size = 8 # 每个序列的最大长度-learning_rate = 1e-2 # 学习率-n_embed = 32 # 嵌入层的维度-n_head = 4 # 多头注意力的头数-n_layer = 3# block的数量+batch_size = 64 # 每个批次的大小+block_size = 256 # 每个序列的最大长度+learning_rate = 3e-4 # 学习率+n_embed = 384 # 嵌入层的维度+n_head = 6 # 多头注意力的头数+n_layer = 6# block的数量

调整前,是一个3层、4头、32维度,参数量为437,764的“K级超小模型”。

调整后,是一个6层、6头、384维度,参数量为15,466,756的“15M模型”,至少也能用B做单位了,是一个“0.0155B”的模型😄。

训练和推理效果见文章开头的“最终效果”。

 一、大模型风口已至:月薪30K+的AI岗正在批量诞生

2025年大模型应用呈现爆发式增长,根据工信部最新数据:

国内大模型相关岗位缺口达47万

初级工程师平均薪资28K

70%企业存在"能用模型不会调优"的痛点

真实案例:某二本机械专业学员,通过4个月系统学习,成功拿到某AI医疗公司大模型优化岗offer,薪资直接翻3倍!

二、如何学习大模型 AI ?


🔥AI取代的不是人类,而是不会用AI的人!麦肯锡最新报告显示:掌握AI工具的从业者生产效率提升47%,薪资溢价达34%!🚀

由于新岗位的生产效率,要优于被取代岗位的生产效率,所以实际上整个社会的生产效率是提升的。

但是具体到个人,只能说是:

“最先掌握AI的人,将会比较晚掌握AI的人有竞争优势”。

这句话,放在计算机、互联网、移动互联网的开局时期,都是一样的道理。

我在一线互联网企业工作十余年里,指导过不少同行后辈。帮助很多人得到了学习和成长。

我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在人工智能学习中的很多困惑,所以在工作繁忙的情况下还是坚持各种整理和分享。但苦于知识传播途径有限,很多互联网行业朋友无法获得正确的资料得到学习提升,故此将并将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

1️⃣ 提示词工程:把ChatGPT从玩具变成生产工具
2️⃣ RAG系统:让大模型精准输出行业知识
3️⃣ 智能体开发:用AutoGPT打造24小时数字员工

📦熬了三个大夜整理的《AI进化工具包》送你:
✔️ 大厂内部LLM落地手册(含58个真实案例)
✔️ 提示词设计模板库(覆盖12大应用场景)
✔️ 私藏学习路径图(0基础到项目实战仅需90天)

 

第一阶段(10天):初阶应用

该阶段让大家对大模型 AI有一个最前沿的认识,对大模型 AI 的理解超过 95% 的人,可以在相关讨论时发表高级、不跟风、又接地气的见解,别人只会和 AI 聊天,而你能调教 AI,并能用代码将大模型和业务衔接。

*   大模型 AI 能干什么?
*   大模型是怎样获得「智能」的?
*   用好 AI 的核心心法
*   大模型应用业务架构
*   大模型应用技术架构
*   代码示例:向 GPT-3.5 灌入新知识
*   提示工程的意义和核心思想
*   Prompt 典型构成
*   指令调优方法论
*   思维链和思维树
*   Prompt 攻击和防范
*   …

第二阶段(30天):高阶应用

该阶段我们正式进入大模型 AI 进阶实战学习,学会构造私有知识库,扩展 AI 的能力。快速开发一个完整的基于 agent 对话机器人。掌握功能最强的大模型开发框架,抓住最新的技术进展,适合 Python 和 JavaScript 程序员。

*   为什么要做 RAG
*   搭建一个简单的 ChatPDF
*   检索的基础概念
*   什么是向量表示(Embeddings)
*   向量数据库与向量检索
*   基于向量检索的 RAG
*   搭建 RAG 系统的扩展知识
*   混合检索与 RAG-Fusion 简介
*   向量模型本地部署
*   …

第三阶段(30天):模型训练

恭喜你,如果学到这里,你基本可以找到一份大模型 AI相关的工作,自己也能训练 GPT 了!通过微调,训练自己的垂直大模型,能独立训练开源多模态大模型,掌握更多技术方案。

到此为止,大概2个月的时间。你已经成为了一名“AI小子”。那么你还想往下探索吗?

*   为什么要做 RAG
*   什么是模型
*   什么是模型训练
*   求解器 & 损失函数简介
*   小实验2:手写一个简单的神经网络并训练它
*   什么是训练/预训练/微调/轻量化微调
*   Transformer结构简介
*   轻量化微调
*   实验数据集的构建
*   …

第四阶段(20天):商业闭环

对全球大模型从性能、吞吐量、成本等方面有一定的认知,可以在云端和本地等多种环境下部署大模型,找到适合自己的项目/创业方向,做一名被 AI 武装的产品经理。

*   硬件选型
*   带你了解全球大模型
*   使用国产大模型服务
*   搭建 OpenAI 代理
*   热身:基于阿里云 PAI 部署 Stable Diffusion
*   在本地计算机运行大模型
*   大模型的私有化部署
*   基于 vLLM 部署大模型
*   案例:如何优雅地在阿里云私有部署开源大模型
*   部署一套开源 LLM 项目
*   内容安全
*   互联网信息服务算法备案
*   …

学习是一个过程,只要学习就会有挑战。天道酬勤,你越努力,就会成为越优秀的自己。

如果你能在15天内完成所有的任务,那你堪称天才。然而,如果你能完成 60-70% 的内容,你就已经开始具备成为一名大模型 AI 的正确特征了。

这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值