banner
Nagi-ovo

Nagi-ovo

Breezing
github

LLM演进史(一):Bigram的简洁之道

本节的源代码仓库地址

前面我们通过实现micrograd,弄明白了梯度的意义和如何优化。现在我们可以进入到语言模型的学习阶段,了解初级阶段的语言模型是如何设计、建模的。

语言模型发展#

本篇文章中,我们将把注意力放在 Bigram 上,这个在如今看来十分简单的模型架构。它基于马尔可夫链的假设,即下一个单词的出现仅依赖于前一个单词。

数据集介绍#

包含了 32033 个英文姓名,长度范围在 2-15 之间。

Screenshot 2024-01-22 at 15.40.42

一个名字中的信息#

以数据集中的第 4 个数据‘isabella’为例,这个词包含了很多例子,如:

  • 字符 i 是一个很有可能出现在名字第一个位置的字符
  • s 很有可能跟在 i 后面出现;
  • a 很有可能跟在 is 后面...... 以此类推;
  • 在‘isabella’出现后,这个词很可能就结束了,这是一个很重要的信息。

Bigram#

这个模型中,我们总是一次只处理 2 个字符,也就是只看一个给定的字符来预测序列中的下一个字符,只在这种局部结构上建模,是一个非常简单且 weak 的模型。

for w in words[:3]:
    chs = ['<S>'] + list(w) + ['<E>']
    for ch1, ch2 in zip(chs, chs[1:]):
        print(ch1, ch2)

Screenshot 2024-01-22 at 17.39.31

bigram 最简单的实现方式是 “计数”,所以我们基本上只需要计算这些组合在训练集中出现的频率。

可以用一个字典来维护每一个 bigram 的计数:

Screenshot 2024-01-22 at 17.51.42

创建字符组合的映射关系:

N = torch.zeros((28,28), dtype=torch.int32)

chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0 # 用.替换标志符,避免出现<S>为第二个字母等不可能情况
itos = {i:s for s,i in stoi.items()}
itos

复制并修改前面的计数统计循环:

for w in words:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        N[ix1, ix2] += 1

为了使输出更美观,使用matplotlib库:

import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize=(16,16))
plt.imshow(N, cmap='Blues')
for i in range(27):
    for j in range(27):
        chstr = itos[i] + itos[j]
        plt.text(j, i, chstr, ha='center', va='bottom', color='gray')
        plt.text(j, i, N[i, j].item(), ha='center', va='top', color='gray') # 这里N[i,j]是torch.Tensor,使用.item()来获取其数值
plt.axis('off')

Pasted image 20240122183831

我们现在已经基本完成了 Bigram 模型所需的采样,之后要做的就是根据这些计数(概率)从模型中抽样:

  • 对名字的第一个字符采样:看第一行

Screenshot 2024-01-22 at 18.41.31

第一行的计数告诉了我们所有字符作为第一个字符的频率

现在我们可以使用torch.multinomial (一个用于从给定的多项式分布中进行随机抽样的函数)从这个分布中抽样。具体来说,它根据输入张量(Tensor)中的权重随机抽取样本,这些权重定义了每个元素被抽中的概率。

为了使实验具有可复现性,使用torch.Generator

Screenshot 2024-01-22 at 19.03.47

含义是:一半多都是 0,小部分是 1,2 只有很少的数量

p = N[0].float()
p = p / p.sum()

Screenshot 2024-01-22 at 19.11.23

第一个字符预测为 c ,那接下来就看 c 开头的行...... 按照这个逻辑构建循环:

Screenshot 2024-01-22 at 19.19.14

循环 20 次:

for i in range(20):
    out = []
    ix = 0
    while True:
        p = N[ix].float()
        p = p / p.sum()
        ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
        out.append(itos[ix])
        if ix == 0:
            break
    print(''.join(out))

Screenshot 2024-01-22 at 20.56.09

结果有些奇怪,但其实代码没有什么问题,因为当前的模型设计能得到的结果就是如此。

当前模型还会生成出单字符的名字,比如 ‘h’ 。原因是它只知道这个 ‘h’下一个字符的可能,但不知道它是不是第一个字符,只能捕捉到非常局部的关系,不过也比完全没有训练过的模型效果要好,起码部分结果看着有点名字的样子。

现在每次循环都会计算一次概率,为了提升效率和练习 torch API 的 Tensor 操作,我们使用如下方式:

Screenshot 2024-01-22 at 22.10.53

dimkeepdim=True使得可以指定维度索引,这里我们希望的是分别计算 27 行各自的总和,在二维张量里就是dim=1了。

除此之外,还有一个重点是 PyTorch 中的broadcasting semantics(广播语义)

简而言之,如果PyTorch操作支持广播,则其张量参数可以自动扩展为相同大小(不需要复制数据)。

如果张量满足以下条件,那么就可以广播:

  • 每个张量至少有一个维度。
  • 在遍历维度大小时,从尾部维度开始遍历,并且二者维度必须相等,它们其中一个要么是1要么不存在。

Screenshot 2024-01-22 at 22.19.51

当前情况符合要求,是可传播的,即 (27x27) / (27x1) ,因此相当于把 (27x1) 复制了 27 次,分别求商,对每一行都进行了归一化。

P = N.float()
P /= P.sum(dim=1, keepdim=True)
# 使用“就地操作”(in-place operation),避免创建一个新张量

for i in range(50):
    out = []
    ix = 0
    while True:
        p = P[ix] # 避免重复计算
        ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
        out.append(itos[ix])
        if ix == 0:
            break
    print(''.join(out))

最终形式

那么问题来了,怎么设定损失函数来判断这个模型在预测训练集的效果呢?

Screenshot 2024-01-23 at 18.38.45

打印出每个组合的概率,可以发现比起随机预测(1274%\frac{1}{27}\approx4\%),有些组合的高达39%39\%,这显然是个好事。特别是当你的模型性能非常出色时,这些期望应该会接近 1,这意味着模型在训练集中正确预测了接下来发生的事情。

负对数似然损失#

那么我们如何将这些概率总结成一个衡量模型质量的数字呢?

当你研究最大似然估计和统计建模等文献时,你会发现这里通常使用的是一种叫做 “似然(likelihood)” 的东西,这个模型中所有概率的乘积就是似然,这个乘积应该越高越好。当前模型的概率都是一个 0-1 之间的比较小的数字,整体的乘积也会是一个很小的值。

为了方便起见,人们通常使用的是 “对数似然(log likelihood)”,这里我们只需要取概率的对数就能得到的对数似然:

Screenshot 2024-01-23 at 19.31.22

这里把 prob 的类型换回 Tensor,以便调用torch.log(),可以看出概率越大,对数似然就越接近0

Screenshot 2024-01-23 at 19.35.26

plot of log(x),x(0,1)log(x) , x\in(0,1)

根据基本的数学知识:log(abc)=log(a)+log(b)+log(c)log(a*b*c)=log(a)+log(b)+log(c)
可知,对数似然就是各个概率的对数之和。

Screenshot 2024-01-23 at 23.35.41

目前的定义下,模型越好,对数似然值就越接近 0,误差就越小。然而 “损失函数” 的语义是 “越低越好”,也就是 “减小损失”,因此我们需要的是目前表达式的相反数,这就得到了负对数似然(negative log likelihood):

log_likelihood = 0.0
n = 0 # 计数,用于取平均
for w in words[:3]:
    chs = ['.'] + list(w) + ['.']
    for ch1, ch2 in zip(chs, chs[1:]):
        ix1 = stoi[ch1]
        ix2 = stoi[ch2]
        prob = P[ix1, ix2]
        logprob = torch.log(prob)
        log_likelihood += logprob
        n += 1
        print(f'{ch1}{ch2}:{prob:.4f} {logprob:.4f}')

nll = -log_likelihood 
print(f'{nll / n}') # normalize

总结:现在的损失函数非常好,因为它的最小值是 0,值越高说明预测效果越差,我们的训练任务就是找到使负对数似然损失最小化的参数。最大化似然等价于最大化对数似然,因为 log 是单调函数,相当于对原似然函数起了scaling的效果,而最大化对数似然相当于最小化负对数似然,实践中又变成了最小化平均负对数似然。

模型平滑#

以一个包含‘jq’的名字为例,此时得到结果为:损失为无穷。原因是训练集中完全没有‘jq’的组合,发生概率为 0,因此导致了这样的情况:

Screenshot 2024-01-23 at 23.54.40

为了解决这个问题,一个很简单的方法就是模型平滑(model smoothing),可以对每一个组合的数量都增加 1:

P = (N + 1).float()

Screenshot 2024-01-23 at 23.59.42

这里的平滑加的越多,得到的模型就越平缓;加的越少,模型的就会越更peaked。1 就是一个很好的值,因为他可以保证概率矩阵 P 中没有 0 出现,解决了无穷损失的问题。

使用神经网络框架#

在神经网络框架中,处理问题的方法会略有不同,但都会在非常相似的地方结束。将使用基于梯度的优化来调整这个网络的参数,调整权重以让神经网络能够正确预测下一个字符。

第一件事是编译这个神经网络的训练集:

Screenshot 2024-01-24 at 00.20.51

这里的意思是,输入字符序列号为 0 的时候,正确预测的标签应该是 5,以此类推。

tensor 和 Tensor 的区别#

Screenshot 2024-01-24 at 00.16.49

区别在于,torch.Tensor默认类型为float32,而torch.tensor它会根据提供的数据自动推断数据类型。

所以推荐使用小写的tensor,我们这里要的也是int类型整数,所以使用小写的。

one-hot 编码#

这里不能直接简单带入,因为现在的样本都是整数,提供的是字符的索引,而让一个输入神经元取你输入的整数的索引再乘以权重是没有意义的。

一种常用的整数编码方法叫独热编码(one-hot encoding):

以 13 为例,在独热编码中,我们取这样的整数,然后创建一个除了第 13 维是 1 其它都是 0 的向量,这个向量可以输入神经网络。

import torch.nn.functional as F

xenc = F.one_hot(xs, num_classes=27)

Screenshot 2024-01-24 at 18.09.56

需要指定维度,否则可能会猜测一共只有 13 维

但现在,xenc.dtype显示其类型为int64,torch 也不支持指定ont_hot的输出类型,因此需要强制转换:

xenc = F.one_hot(xs, num_classes=27).float()

Screenshot 2024-01-24 at 18.13.24

现在的结果是一个float32浮点数,可以输入神经网络。

构建神经元#

正如 micrograd 中介绍过,神经元执行的功能基本来说就就是对输入值 xxwx+bw\cdot x+b ,其中运算是dot product

W = torch.randn((27,27))
# (5,27) @ (27,27) = (5,27)
xenc @ W

Screenshot 2024-01-24 at 21.37.22

现在我们把 27 维输入输入到了一个有 27 个神经元的神经网络的第一层。

那怎么解释神经网络的输出呢?

我们当前的输出有正有负,而前面的计数和概率都是正数,所以神经网络要输出的是log counts(对数计数,记为 logits),为了得到正计数,我们要对 log 计数然后取指数:

Screenshot 2024-01-24 at 21.51.47

Softmax#

# randomly initialize 27 neurons' weights, each neuron receives 27 input
g = torch.Generator().manual_seed(2147483647)
W = torch.randn((27,27), generator=g)

# Forward pass

xenc = F.one_hot(xs, num_classes=27).float()
logits =  xenc @ W # predict log-count
counts = logits.exp() # counts, equivalent to N
probs = counts / counts.sum(dim=1, keepdim=True) # probabilities for next character

btw: the last 2 lines here are together called softmax

Softmax 是神经网络中经常使用的一层,它取对数logits,指数化再标准化,是一种获取神经网络层输出的方法,输出的结果总是等于 1 且都是正数,就像概率分布一样。你可以把它放在神经网络中任何线性层的上面。

nlls = torch.zeros(5)
for i in range(5):
    # i-th bigram
    x = xs[i].item()
    y = ys[i].item()
    print('---------')
    print(f'bigram example {i+1}: {itos[x]}{itos[y]} (indexes {x},{y})') 
    print('inputs to neural net:', x)
    print('outputs probabilities from the neural net:', probs[i])
    print('label (actual next character):', y)
    p = probs[i, y]
    print('probability assigned by the net to the correct character:', p.item())
    logp = torch.log(p)
    print(f'logp=')
    nll = -logp
    print('negative log-likelihood:', nll.item())
    nlls[i] = nll

print('===========')
print('average negative log-likelihood, i.e. loss:', nlls.mean().item())

现在的损失只由可微(differantiable)操作构成,我们可以根据这些WW的权重矩阵的损失梯度,调整WW来最小化损失。

对于第一个例子,我们感兴趣的概率是这些:

Screenshot 2024-01-24 at 22.36.15

为了提升访问这些概率的效率,而不是像这样把它们存在元组中,在torch中可以像下面这么做:

Screenshot 2024-01-24 at 22.37.39

运行反向传播,得到了每一个权重的梯度信息:

Screenshot 2024-01-24 at 23.00.34

比如 [0,0]=0.0121,就是说如果这个权重增加,它的梯度是正的,所以损失也会增加

可以根据这些信息来更新神经网络的权重,类比前面 micrograd 中的实现:

W.data += -0.1 * W.grad

再重新计算 forward pass,预计得到更低的损失:

Screenshot 2024-01-24 at 23.18.14

果然比之前的 3.76 要小

# gradient descent
for k in range(1000):

    # forward pass
    xenc = F.one_hot(xs, num_classes=27).float()
    logits = xenc @ W # predict log-count
    counts = logits.exp() # counts, equivalent to N
    probs = counts / counts.sum(dim=1, keepdim=True) # probabilities for next character
    loss = -probs[torch.arange(5), ys].log().mean() + 0.01 * (W**2).mean() # L2 regularization
    print(loss.item())

    # backward pass
    W.grad = None
    loss.backward()

    # update
    W.data += -50 * W.grad

在后续一路将神经网络复杂化,一直到使用 Transformer 的时候,传入神经网络的结构不会从根本上改变,唯一改变的是我们做前向传递的方式,

将 bigram 扩展到更多输入字符的情况显然不现实,因为字符组合会有太多,即这个方法不可扩展。而神经网络方法的可拓展性明显更强,可以不断改进,这也是本系列课程的研究方向。

正则化#

事实证明,基于梯度的框架与平滑是等价的:
如果WW的所有元素都相等,或者说特别是 0 时,那么logits就会变成 0,再取这些对数求幂变成 1,那么概率就会完全一致。

所以,试图激励 W 接近 0 基本上相当于标签平滑,在损失函数中激励得越多,得到的分布就越平滑,这就引出了 “正则化” ,我们实际上可以给损失函数增加一个小的分量,我们称之为 “正则化损失”。

loss = -probs[torch.arange(5), ys].log().mean() + 0.01 * (W**2).mean() # L2 regularization

现在不仅试图让所有的概率正常工作,除此之外还同时试图让所有的WW都为 0,权重接近 0 使得模型输出对输入的变化不那么敏感,这有助于防止过拟合。在分类任务中,这可能导致模型对每个类别的预测概率分布更加均衡,减少对某个类别的过度自信。

效果相同的模型#

Screenshot 2024-01-25 at 01.36.30

我们发现两个模型损失相近,采样结果和使用概率矩阵的模型一样,可见它们其实是相同的模型,不过后面的方式以神经网络这种非常不同的解释得到了同样的答案。

接下来我们会将更多这类的角色输入神经网络并得到相同的结果,神经网络输出 logits,它们仍然以相同方式归一化,所有损失和基于梯度的框架中的其它东西都是相同的,只不过神经网络会一直变复杂直至 Transformer。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。