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),為了得到正計數,我們要對 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。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。