banner
Nagi-ovo

Nagi-ovo

Breezing
github

LLM演進史(二):詞嵌入——多層感知器與語言的深層連接

本節的源代碼倉庫地址

《A Neural Probabilistic Language Model》#

本文算是訓練語言模型的經典之作,Bengio 將神經網絡引入語言模型的訓練中,並得到了詞嵌入這個副產品。詞嵌入對後面深度學習在自然語言處理方面有很大的貢獻,也是獲取詞的語義特徵的有效方法。

論文的提出源於解決原詞向量(one-hot 表示)會造成維數災難的問題,作者建議通過學習詞的分佈式表示來解決這個問題。作者基於 n-gram 模型,通過使用語料對神經網絡進行訓練,最大化上文的 n 個詞語對當前詞語的預測。該模型同時學到了每個單詞的分佈式表示單詞序列的概率分佈函數

w2v

經典公示

該模型學習到的詞彙表示,與傳統的 one-hot 表示不同,它可通過詞嵌入之間的距離(歐幾里得距離、餘弦距離等),表示詞彙間的相似程度。如在: The cat is walking in the bedroom A dog was running in a room 中,cat 和 dog 有著相似的語義,你可以通過嵌入空間,傳遞知識,將其推廣到新的場景中。

Pasted image 20240125170634

index(0,16999)index\in(0,16999)
共享 look-up 表
隱藏層的大小是一個超參數
輸出層有 17000 個神經元,與隱藏層中的神經元完全相連,即 logits

在神經網絡中,術語 "logits" 通常指的是最後一個線性層(即未經激活函數處理的)的輸出。在分類任務中,這個線性層的輸出被輸入到 softmax 函數中以生成概率分佈。logits 本質上是模型對每個類別未歸一化的預測分數,它們可以被視為反映模型對每個類別的信心水平。

為什麼叫 "logits"?#

這個術語來自邏輯回歸,其中 “logit” 函數是邏輯函數的反函數。在二分類的邏輯回歸中,輸出概率 pp 與 logit LL 之間的關係可以表示為:

L=log(p1p)L = \log\left(\frac{p}{1 - p}\right)
p/1p{p}/{1-p} 表示事件發生與不發生的概率之比,稱為odds,即幾率。相較於概率,幾率的優勢有:讓輸出範圍拓展到了整個實數範圍(,+)(-\infty,+\infty),特徵與輸出間是線性關係,似然函數簡潔)

這裡,LL 就是 logit。在神經網絡中,儘管沒有直接使用 logit 函數,術語 "logits" 仍然被用來描述網絡的原始輸出,因為這些原始輸出在通過 softmax 函數之前與邏輯回歸中的 logits 相似。

在多分類問題中,網絡的 logits 通常是一個向量,其中每個元素對應一個類別的 logit。例如,如果一個模型在處理手寫數字識別(如 MNIST 數據集),則輸出 logits 將是一個具有 10 個元素的向量,每個元素對應一個數字類別(0 到 9)的預測分數。

softmax 函數將 logits 轉換為概率分佈:

softmax(logits)i=elogitsijelogitsj\text{softmax}(\text{logits})_i = \frac{e^{\text{logits}_i}}{\sum_{j} e^{\text{logits}_j}}

這裡,elogitsie^{\text{logits}_i} 是將第 ii 個 logit 指數化,使其為正數,並且通過除以所有指數化 logits 的總和來歸一化,從而得到一個有效的概率分佈。

總結:在神經網絡的上下文中,logits 是模型對每個類別的原始預測輸出,通常在應用 softmax 函數之前。這些原始分數反映了模型的預測信心,並且在訓練過程中用於計算損失,特別是交叉熵損失,這是許多分類任務中常用的損失函數。

構建 NPLM#

創建數據集#

# build the dataset

block_size = 3 # context length: how many characters do we take to predict the next one
X, Y = [], []

for w in words:
    print(w)
    context = [0] * block_size
    for ch in w + '.':
        ix = stoi[ch]
        X.append(context)
        Y.append(ix)
        print(''.join(itos[i] for i in context), '--->', itos[ix])
        context = context[1:] + [ix] # crop and append
    
X = torch.tensor(X)
Y = torch.tensor(Y)

Screenshot 2024-02-14 at 21.38.58

現在我們來搭建神經網絡,用 X 來預測 Y。

Lookup Table#

Screenshot 2024-02-14 at 21.41.36

我們要把可能出現的 27 個字符嵌入到低維空間中(原論文是將 17000 個詞嵌入到 30 維空間)。

C = torch.randn((27,2))

F.one_hot(torch.tensor(5), num_classes=27).float() @ C
# (1, 27) @ (27, 2) = (1, 2)

相當於只保留了 C 的第五行

也就是說,降低運算量並不是因為詞向量的出現,而是因為把 one-hot 的矩陣運算簡化為了查表操作。

Screenshot 2024-02-14 at 22.03.32

Screenshot 2024-02-16 at 16.37.53

隱藏層#

Screenshot 2024-02-16 at 17.36.43

W1 = torch.randn((6, 100)) # 輸入個數:3 x 2 = 6,100個神經元
b1 = torch.randn(100)

emb @ W1 + b1 # 我們希望得到這樣的形式

但是由於 emb 的形狀是[228146, 3, 2],怎麼把 3 和 2 組合起來變成 6 呢?

  • torch.cat(tensors, dim, )

Screenshot 2024-02-16 at 16.51.39

因為 block_size 可以改變,我們要避免硬編碼形式,就換用 torch.unbind(tensors, dim, ) 獲取切片元組:

Screenshot 2024-02-16 at 16.55.57

上面的方式會創建新的內存,有沒有更高效的方式呢?

  • tensor.view()
a = torch.arange(18)
 
a.storage() # 0 1 2 3 4 ... 17

Screenshot 2024-02-16 at 16.59.47

這樣的做法是高效的。因為每個 tensor 都有底層存儲形式,也就是存儲的數字本身,永遠都是一個一維向量。對其調用的view()的時候,我們只是改變了這個序列的解釋方式屬性,這個過程中沒有發生內存的改變、複製、移動或者創建,也就是兩者之間的存儲是相同的。

Screenshot 2024-02-16 at 17.04.47

所以最後的樣子是如下:

emb.view(emb.shape[0], 6) @ W1 + b1
# or 
emb.view(-1, 6) @ W1 + b1

加入非線性變換:

h = torch.tanh(emb.view(-1, 6) @ W1 + b1)

輸出層#

Screenshot 2024-02-16 at 17.36.43

# 27個可能輸出的字符
W2 = torch.randn((100, 27)) 
b2 = torch.randn(27)

logits = h @ W2 + b2

上一節中一樣,得到 counts 和概率:

counts = logits.exp()
prob = counts / counts.sum(1, keepdim=True)

Negative log likelihood loss:

loss = -prob[torch.arange(Y.shape[0]), Y].log().mean()

Screenshot 2024-02-17 at 14.05.39

當前損失是 19 多,是我們的訓練優化起點。

重新整理一下我們的神經網絡:

g = torch.Generator().manual_seed(2147483647)
C = torch.randn((27,2), generator=g)
W1 = torch.randn((6,100), generator=g)
b1 = torch.randn(100, generator=g)
W2 = torch.randn((100,27), generator=g)
b2 = torch.randn(27, generator=g)
parameters = [C, W1, b1, W2, b2]

sum(p.nelement() for p in parameters) # 3481

# forward pass
emb = C[X] # (228146, 3, 2)
h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (228146, 100)
logits = h @ W2 + b2 # (228146, 27)

這裡我們可以使用 torch 的交叉熵損失函數來替換之前的代碼:

# counts = logits.exp()
# prob = counts / counts.sum(1, keepdim=True) 
# loss = -prob[torch.arange(Y.shape[0]), Y].log().mean()
loss = F.cross_entropy(logits, Y)

Screenshot 2024-02-17 at 14.08.03

可以看到結果完全一樣

實踐中都會使用 PyTorch 的實現方式,因為它讓所有操作在一個fused kernel中進行,不會創建額外的存儲張量的中間內存,並且表達式更簡單,forward、backward pass 的效率更高。除此之外,教學的實現方式中,如果有一個 count 很大,經過 exp 後就會溢出變為 nan。

PyTorch 的實現方式是如何解決這個問題的:
舉例:對於 logits = torch.tensor([1,2,3,4])logits = torch.tensor([1,2,3,4]) - 4,雖然兩者的絕對值不同,但它們的相對差距保持不變。Softmax 函數是關於輸入的相對差距敏感的,而不是其絕對值。

softmax(logits)i=elogitsijelogitsj\text{softmax}(\text{logits})_i = \frac{e^{\text{logits}_i}}{\sum_{j} e^{\text{logits}_j}}

當你從 logits 中減去一個常數,指數函數的性質使得每個 logit 的指數減小了相同的倍數。但是,由於這個常數是從每個 logit 中都減去的,它會在分子和分母中抵消掉,因此不會影響最終的概率分佈。也就是說,對於任何 logits 向量和常數 C:

elogitsiCjelogitsjC=elogitsi/eCjelogitsj/eC=elogitsijelogitsj\frac{e^{\text{logits}_i - C}}{\sum_{j} e^{\text{logits}_j - C}} = \frac{e^{\text{logits}_i} / e^C}{\sum_{j} e^{\text{logits}_j} / e^C} = \frac{e^{\text{logits}_i}}{\sum_{j} e^{\text{logits}_j}}

PyTorch 通過在內部計算 logits 中的最大值,然後減去這個值,防止了在計算 e^logits 時發生數值上的溢出。

訓練#

for p in parameters:
    p.requires_grad_()
    
for _ in range(10): # 數據量大,先測試一下優化是否成功
    # forward pass
    emb = C[X] # (228146, 3, 2)
    h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (228146, 100)
    logits = h @ W2 + b2 # (228146, 27)
    loss = F.cross_entropy(logits, Y)

    # backward pass
    for p in parameters:
        p.grad = None
    loss.backward()

    # update
    for p in parameters:
        p.data += -0.1 * p.grad 

print(loss.item())

Screenshot 2024-02-17 at 14.58.56

由於是在整個數據集上訓練的,損失只能達到一個相對較小的值,可以看到輸出的結果和正確結果之間有一定的相似度,如果只用一個 batch 來訓練的話,能夠達到過擬合的效果,預測結果基本和正確結果一樣。從根本上來說,損失也不會非常接近 0,因為...也是需要預測的,很多字母都有可能,不可能完全過擬合。

mini-batch#

由於每次要回溯這 22 萬個數據,每次迭代的速度較慢,運算量十分龐大。實踐中,人們常用的是在前向和反向傳遞中在很多小批的數據上更新並衡量表現。我們要做的就是隨機選擇數據集的一部分,這就是mini-batch,然後在這些小批次數據上進行迭代。

for _ in range(1000):
    # mini-batch
    ix = torch.randint(0,X.shape[0],(32,))

    # forward pass
    emb = C[X[ix]] # (32, 3, 2)
    h = torch.tanh(emb.view(-1, 6) @ W1 + b1) # (32, 100)
    logits = h @ W2 + b2 # (32, 27)
    loss = F.cross_entropy(logits, Y[ix])

現在迭代速度變得飛快。

這樣做每次優化的並不是真正的梯度和正確的方向,是在一個近似的梯度上多走幾步,在實踐中上是很有效的。

Screenshot 2024-02-17 at 16.32.09

learning rate#

lre = torch.linspace(-3, 0, 1000)
lrs = 10**lre # (0.001 - 1)

lri = []
lossi = []

for i in range(1000):
	'''
	mini-batch, forward and backward pass code 
	'''
	# update
    lr = lrs[i]
    for p in parameters:
        p.data += -lr * p.grad 
	# tracks stats
	lri.append(lre[i])
	lossi.append(loss.item())

plt.plot(lri, lossi)

Screenshot 2024-02-17 at 16.46.32

如圖,可以看到 lre 在 - 1.0 附近損失達到最小附近,此時的學習率就是 $10^{-1}=0.1$

現在我們有了對選擇學習率的信心。

	lr = 0.1
    for p in parameters:
        p.data += -lr * p.grad 

在運行了幾次 1 萬步的迭代後,損失穩定在 2.4 附近,這時可以降低學習率(learning rate decay),如降低十倍至 0.01 訓練幾輪,這樣就的到了個大概訓練好的網絡。

Screenshot 2024-02-17 at 16.55.37

比之前的 bigram model 損失要低很多,那可以說這個模型比之前的要好嗎?

其實這個說法並不對,如果我們把參數量提高,這個模型的損失甚至能達到非常接近 0 的地步,但你對其抽樣只能得到訓練集中完全一樣的例子,在面對沒見過的詞語上損失可能會很大,所以這並不是一個好模型。

這就引出了這個領域的標準做法:將數據集分開,spliting 為 3 段,即 training split, validation(dev) split, test split,我們耳熟能詳的訓練集、驗證集、測試集。

  1. 訓練集(Training Split)

    • 用途:用來訓練模型的參數,即模型中的權重和偏差。
    • 過程:在訓練過程中,模型嘗試學習數據的特徵和模式,並通過反向傳播和梯度下降等優化算法調整其參數以最小化損失函數。
  2. 驗證集(Dev/Validation Split)

    • 用途:用來訓練(調整)模型的超參數,例如學習率、網絡層數、層的大小等。
    • 過程:在模型的訓練過程中,我們不斷地在驗證集上評估模型的性能,以便調整和選擇最佳的超參數。驗證集幫助我們在不接觸測試集的情況下評估模型的泛化能力,避免模型的過擬合。
  3. 測試集(Test Split)

    • 用途:用來評估模型的最終性能,即在實際應用中模型可能的表現。
    • 過程:在模型開發階段,測試集是完全不被接觸的。只有在模型經過訓練,並且在驗證集上調整好所有超參數後,才使用測試集來測試。這樣可以提供一個未見過數據的公正評估,給出模型在處理新數據時的真實表現。

這種劃分方法幫助研究人員和開發人員避免數據洩露(data leakage)和過擬合(overfitting),這兩種情況都會導致模型在訓練集上表現良好,但在看不見的新數據上表現不佳。通過這種方式,我們可以更加自信地預測模型在實際世界中的表現。

我們到前面構建數據集的位置,將其封裝為一個函數,然後進行三部分的劃分:

def build_dataset(words):
	'''
	previous code
	'''
	return X, Y

import random

random.seed(42)
random.shuffle(words)
n1 = int(0.8 * len(words))
n2 = int(0.9 * len(words))

Xtr, Ytr = build_dataset(words[:n1])     # 80% train
Xdev, Ydev = build_dataset(words[n1:n2]) # 10% validation
Xte, Yte = build_dataset(words[n2:])     # 10% test

三部分的數據量

修改神經網絡訓練部分:

ix = torch.randint(0,Xtr.shape[0],(32,))
loss = F.cross_entropy(logits, Ytr[ix])

Screenshot 2024-02-17 at 17.19.38

三部分的數據量

修改神經網絡訓練部分:

ix = torch.randint(0,Xtr.shape[0],(32,))
loss = F.cross_entropy(logits, Ytr[ix])

Screenshot 2024-02-17 at 17.25.07

可以看到,訓練集和驗證集的損失相近,所以我們的模型沒有強大到過擬合,這樣的狀態被稱為欠擬合(underfitting),這通常意味著我們的網絡參數量太小了。

最簡單的方式就是把隱藏層的神經元數量增加:

Screenshot 2024-02-17 at 17.29.12

現在有 1 萬多個參數,相較於原來的 3000 多個網絡變大了很多

Screenshot 2024-02-17 at 17.34.49

可以看到損失函數優化後面的過程 “很厚”,是因為在 mini-batch 上訓練會產生一些噪聲(noise

現在的網絡效果仍然欠佳,影響性能瓶頸的原因有:

  • mini-batch size 太小,噪聲太大
  • 嵌入方式有問題,將過多的字符放到了二維空間,神經網絡並不能很好的利用

可視化當前的嵌入:

plt.figure(figsize=(8, 8))
plt.scatter(C[:,0].data, C[:,1].data, s=200)
for i in range(C.shape[0]):
    plt.text(C[i,0].item(), C[i,1].item(), itos[i], ha="center", va="center", color="white")
plt.grid('minor')

Screenshot 2024-02-17 at 17.49.38

可以看到是有訓練成果的,gqp還有.都被視作了特殊的向量,而xhb等則被視作相近的,可互相替換的向量。

嵌入向量很可能影響了網絡的瓶頸。

C = torch.randn((27,10), generator=g)
W1 = torch.randn((30,200), generator=g)
b1 = torch.randn(200, generator=g)
W2 = torch.randn((200,27), generator=g)

Screenshot 2024-02-17 at 17.57.25

Screenshot 2024-02-17 at 18.05.37

損失比之前小了,看来嵌入向量確實有比較大的影響。

進一步的優化策略:

  • 嵌入向量維度
  • 上下文長度
  • 隱藏層神經元數量
  • 學習率
  • 訓練實踐
  • ......

采樣#

最後,采樣看一下模型當前效果:

g = torch.Generator().manual_seed(2147483647 + 10)

for _ in range(20):
    out = []
    context = [0] * block_size # initialize with all ...
    while True:
        emb = C[torch.tensor([context])]  # (1, block_size, D)
        h = torch.tanh(emb.view(1, -1) @ W1 + b1)
        logits = h @ W2 + b2
        probs = F.softmax(logits, dim=1)
        ix = torch.multinomial(probs, num_samples=1, generator=g).item()
        context = context[1:] + [ix]
        out.append(ix)
        if ix == 0:
            break
    print(''.join(itos[i] for i in out))

Screenshot 2024-02-17 at 18.10.40

初具人形,可見還是有提高的。

接下來,我們就會進入現代的模型介紹,如 CNN、GRU 和 Transformers。

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