Let's build GPT: from scratch, in code, spelled out (1)

Posted by Zhang Jian on January 20, 2023

最近Karpathy写的这篇关于gpt的教程通俗易懂,引起了非常大的反响。这里,我参考了这篇教程进行了简单翻译、总结。

简介

ChatGPT, Transformers, nanoGPT, Shakespeare

ChatGPT是一个革命性的语言模型,在人工智能界引起了轰动,它是一个基于文本让你和AI互动的系统。例如,可以要求ChatGPT给我们写一首俳句,说明人们了解AI的重要性以及如何利用它来改善世界并使其更加繁荣。ChatGPT是一个概率性系统,因此对于任何一个提示(prompt),它都可以给出多个答案。

一个例子,请写一篇关于树叶从树上掉下来的突发新闻:

a shocking turn of events a leaf has fallen from a treat in the local park Witnesses report that the leaf which was previously attached to a branch of a tree detached itself and fell to the ground very dramatic

你可以看到,这是一个相当了不起的系统,它模拟了单词,字符或标记的序列,它知道英语中的单词是如何相互关联的。给它一个序列的开头,ChatGPT完成了序列,在这个意义上它是一个语言模型。

接下来,我们将研究ChatGPT背后的技术原理。那么,在背后模拟单词序列的神经网络是什么呢?其来自于一篇名为《Attention Is All You Need》的论文,发表于2017年。这是一篇具有里程碑意义的论文,提出了Transformer架构。GPT是通用预训练Transformer的缩写(Generative Pre-Training)。在2017年的这篇论文中,Transformer架构是为机器翻译而设计的,但实际上它在人工智能领域取得了巨大成功,成为了许多应用的核心技术,包括ChatGPT。

现在,让我们来建立一个类似于ChatGPT的系统(我们无法直接复现ChatGPT,这是一个非常严肃的生产级系统,它在大量的互联网上训练过,并经过了大量的预训练和微调,因此非常复杂)。我们重点关注的是:如何训练一个基于Transformer的语言模型。在这里,我们将实现一个字符级的语言模型。这非常的有教育意义,它可以帮助我们了解这些系统的工作原理,同时也不需要训练整个互联网,只需要一个较小的数据集。我们使用了一个小数据集,名为Tiny Shakespeare。它是所有莎士比亚作品的结合体,整个文件大约1MB。我们将使用这个数据集来训练Transformer,让它能够产生类似于莎士比亚作品的字符序列。

在训练完成后,我们可以生成无限量的莎士比亚作品,虽然它是假的,但看起来很像莎士比亚的作品。这是通过Transformer实现的,它的工作方式类似于ChatGPT,不同的是它是逐个字符进行预测的,而ChatGPT是逐个词级别的。Karpathy已经写好了训练这些Transformer的所有代码,并且可以在的GitHub上找到名为Nano GPT的repo。这是一个很好的学习资源,可以帮助你了解如何训练这种类型的模型。

代码

现在我们从零开始构建Transformer模型。我们将在Tiny Shakespeare数据集上进行训练,并看看如何生成无限的莎士比亚作品。重要的是:这个过程可以应用于任意的文本数据集。你需要掌握Python编程,并对微积分和统计学有基本的了解。google colab

baseline language modeling, code setup

reading and exploring the data

首先下载数据集:

1
2
# We always start with a dataset to train on. Let's download the tiny shakespeare dataset
!wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

读取数据,并查看

1
2
3
4
5
6
7
8
# read it in to inspect it
with open('input.txt', 'r', encoding='utf-8') as f:
    text = f.read()

print("length of dataset in characters: ", len(text))

# let's look at the first 1000 characters
print(text[:1000])

提取本文中的所有字符

1
2
3
4
5
# here are all the unique characters that occur in this text
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print(vocab_size)

输出

1
2
!$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
65

tokenization, train/val split

将原始的文本转化为整数序列的过程,称为tokenization。这里实现了一种简单的基于字符的tokenization:遍历所有的字符,并建立一个字符到整数的映射表和反向映射表(encoder和decoder)。这样就可以将任意字符串编码为整数序列,也可以将其解码回原来的字符串。除此以外,也可以使用google/sentencepieceopenai/tiktoken

1
2
3
4
5
6
7
8
# create a mapping from characters to integers
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
encode = lambda s: [stoi[c] for c in s] # encoder: take a string, output a list of integers
decode = lambda l: ''.join([itos[i] for i in l]) # decoder: take a list of integers, output a string

print(encode("hii there"))
print(decode(encode("hii there")))

在下面这段代码中,我们使用tensor存储数据,并且划分训练集与测试集

1
2
3
4
5
6
7
8
9
10
# let's now encode the entire text dataset and store it into a torch.Tensor
import torch # we use PyTorch: https://pytorch.org
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape, data.dtype)
print(data[:1000]) # the 1000 characters we looked at earier will to the GPT look like this

# Let's now split up the data into train and validation sets
n = int(0.9*len(data)) # first 90% will be train, rest val
train_data = data[:n]
val_data = data[n:]

data loader: batches of chunks of data

由于我们不可能一次性将文本全部输入到Transformer中,这样计算代价太高。因此,当我们在大量数据集上训练Transformer时,只使用数据集的小块。这些小块都有一定长度,最大长度是block size。在这里,我们设置block size的值为8。因此,最小的训练单元包含9位数据,前8位是模型输入$x$,后8位则是标签值$y$

1
2
block_size = 8
train_data[:block_size+1]

在这9位数据中,可以获得8个训练单元,我们将这8个训练单元打印出来

1
2
3
4
5
6
x = train_data[:block_size]
y = train_data[1:block_size+1]
for t in range(block_size):
    context = x[:t+1]
    target = y[t]
    print(f"when input is {context} the target: {target}")

打印结果如下:

1
2
3
4
5
6
7
8
when input is tensor([18]) the target: 47
when input is tensor([18, 47]) the target: 56
when input is tensor([18, 47, 56]) the target: 57
when input is tensor([18, 47, 56, 57]) the target: 58
when input is tensor([18, 47, 56, 57, 58]) the target: 1
when input is tensor([18, 47, 56, 57, 58,  1]) the target: 15
when input is tensor([18, 47, 56, 57, 58,  1, 15]) the target: 47
when input is tensor([18, 47, 56, 57, 58,  1, 15, 47]) the target: 58

使用存在长度限制的$(input, target)$来训练transformer,既可以提升效率,也能让transformer在推理时适应不同的文字输入长度,甚至可以只使用1个字符作为输入进行预测。 我们还会设置batch size,这样可以同时处理多个数据。而batch的获取, 会随机的从训练数据中进行采样。

1
2
3
4
5
6
7
8
9
10
11
torch.manual_seed(1337)
batch_size = 4 # how many independent sequences will we process in parallel?
block_size = 8 # what is the maximum context length for predictions?

def get_batch(split):
    # generate a small batch of data of inputs x and targets y
    data = train_data if split == 'train' else val_data
    ix = torch.randint(len(data) - block_size, (batch_size,))
    x = torch.stack([data[i:i+block_size] for i in ix])
    y = torch.stack([data[i+1:i+block_size+1] for i in ix])
    return x, y

我们可以将采样得到的数据打印出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xb, yb = get_batch('train')
print('inputs:')
print(xb.shape)
print(xb)
print('targets:')
print(yb.shape)
print(yb)

print('----')

for b in range(batch_size): # batch dimension
    for t in range(block_size): # time dimension
        context = xb[b, :t+1]
        target = yb[b,t]
        print(f"when input is {context.tolist()} the target: {target}")

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inputs:
torch.Size([4, 8])
tensor([[24, 43, 58,  5, 57,  1, 46, 43],
        [44, 53, 56,  1, 58, 46, 39, 58],
        [52, 58,  1, 58, 46, 39, 58,  1],
        [25, 17, 27, 10,  0, 21,  1, 54]])
targets:
torch.Size([4, 8])
tensor([[43, 58,  5, 57,  1, 46, 43, 39],
        [53, 56,  1, 58, 46, 39, 58,  1],
        [58,  1, 58, 46, 39, 58,  1, 46],
        [17, 27, 10,  0, 21,  1, 54, 39]])
----
when input is [24] the target: 43
when input is [24, 43] the target: 58
···

simplest baseline: bigram language model, loss, generation

让我们从一个最简单的神经网络语言模型开始,即bigram language model。在__init__中,我们使用nn.Embedding定义了token_embedding_table,其是一个大小为vocab_size*vocab_size的矩阵,当输入一个字符的idx:self.token_embedding_table(idx),便会从这个矩阵中得到对应的一行向量,例如我们输入24,就会得到第24行的数据。

在训练过程中,forward函数中的idx,其形状是batch_size*time,经过embedding,我们会得到形状为batch_size*time*channel的张量logits,其为网络输出的原始预测值,是未经过归一化的。继续看forward函数,当存在targets(为训练过程)时,首先将logits、targets进行reshape,再使用交叉熵函数计算loss。

在训练完成后,可以使用训练好的模型生成连续的字符,也就是函数generate所做的事。首先,最大的字符生成长度为max_new_tokens,循环开始,向forward函数中输入当前的idx,得到logits,取时间维度上的最后一维(字符串的最后一个字符),经过softmax归一化得到概率,再根据概率得到下一个字符的idx,最后将当前的预测结果idx_next与之前的idx进行合并,这样便可以得到当前的最新字符序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import torch
import torch.nn as nn
from torch.nn import functional as F
torch.manual_seed(1337)

class BigramLanguageModel(nn.Module):

    def __init__(self, vocab_size):
        super().__init__()
        # each token directly reads off the logits for the next token from a lookup table
        self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)

    def forward(self, idx, targets=None):

        # idx and targets are both (B,T) tensor of integers
        logits = self.token_embedding_table(idx) # (B,T,C)
        
        if targets is None:
            loss = None
        else:
            B, T, C = logits.shape
            logits = logits.view(B*T, C)
            targets = targets.view(B*T)
            loss = F.cross_entropy(logits, targets)

        return logits, loss
    
    def generate(self, idx, max_new_tokens):
        # idx is (B, T) array of indices in the current context
        for _ in range(max_new_tokens):
            # get the predictions
            logits, loss = self(idx)
            # focus only on the last time step
            logits = logits[:, -1, :] # becomes (B, C)
            # apply softmax to get probabilities
            probs = F.softmax(logits, dim=-1) # (B, C)
            # sample from the distribution
            idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
            # append sampled index to the running sequence
            idx = torch.cat((idx, idx_next), dim=1) # (B, T+1)
        return idx

基于这个模型,我们可以生成一个长度为100的字符串序列

1
2
3
4
5
6
m = BigramLanguageModel(vocab_size)
logits, loss = m(xb, yb)
print(logits.shape)
print(loss)

print(decode(m.generate(idx = torch.zeros((1, 1), dtype=torch.long), max_new_tokens=100)[0].tolist()))

打印结果如下:

1
2
3
4
5
torch.Size([32, 65])
tensor(4.8786, grad_fn=<NllLossBackward0>)

SKIcLT;AcELMoTbvZv C?nq-QE33:CJqkOKH-q;:la!oiywkHjgChzbQ?u!3bLIgwevmyFJGUGp
wnYWmnxKWWev-tDqXErVKLgJ

training the bigram model

接下来,让我们开始训练模型,首先加入AdamW优化器,然后设置100次循环迭代,一次循环的步骤如下:

  1. 获取batch数据
  2. 模型预测得到loss
  3. 清空优化器之前的梯度信息
  4. loss反传得到梯度
  5. optimizer基于梯度信息更新模型参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# create a PyTorch optimizer
optimizer = torch.optim.AdamW(m.parameters(), lr=1e-3)

batch_size = 32
for steps in range(100): # increase number of steps for good results... 
    
    # sample a batch of data
    xb, yb = get_batch('train')

    # evaluate the loss
    logits, loss = m(xb, yb)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

print(loss.item())

最终这个模型的训练效果并不好,迭代100次之后,loss为4.66,打印预测的字符序列:

1
print(decode(m.generate(idx = torch.zeros((1, 1), dtype=torch.long), max_new_tokens=500)[0].tolist()))

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
oTo.JUZ!!zqe!
xBP qbs$Gy'AcOmrLwwt
p$x;Seh-onQbfM?OjKbn'NwUAW -Np3fkz$FVwAUEa-wzWC -wQo-R!v -Mj?,SPiTyZ;o-opr$mOiPJEYD-CfigkzD3p3?zvS;ADz;.y?o,ivCuC'zqHxcVT cHA
rT'Fd,SBMZyOslg!NXeF$sBe,juUzLq?w-wzP-h
ERjjxlgJzPbHxf$ q,q,KCDCU fqBOQT
SV&CW:xSVwZv'DG'NSPypDhKStKzC -$hslxIVzoivnp ,ethA:NCCGoi
tN!ljjP3fwJMwNelgUzzPGJlgihJ!d?q.d
pSPYgCuCJrIFtb
jQXg
pA.P LP,SPJi
DBcuBM:CixjJ$Jzkq,OLf3KLQLMGph$O 3DfiPHnXKuHMlyjxEiyZib3FaHV-oJa!zoc'XSP :CKGUhd?lgCOF$;;DTHZMlvvcmZAm;:iv'MMgO&Ywbc;BLCUd&vZINLIzkuTGZa
D.?

结论

至此,我们搭建了一个最简单的语言模型,虽然目前的结果并不好,但是接下来的文章中,我们将引入self attention以及transformer,这将大大提升模型的效果,敬请期待:)

参考

videoGoogle colab karpathy/nanoGPTkarpathy/ng-video-lecture