當前位置:
首頁 > 新聞 > 從字元級的語言建模開始,了解語言模型與序列建模的基本概念

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

你有沒有想過 Gmail
自動回復是如何進行的?或者手機在你輸入文本時如何對下一個詞提出建議?生成文本序列的通常方式是訓練模型在給定所有先前詞/字元的條件下預測下一個詞/字元出現的概率。此類模型叫作統計語言模型,這種模型會嘗試捕捉訓練文本的統計結構,本文從字元級語言模型和名字預測出發向讀者介紹了語言建模的核心概念。

循環神經網路(RNN)模型常用於訓練這種語言模型,因為它們使用高維隱藏狀態單元處理信息的能力非常強大,建模長期依賴關係的能力也非常強。任意語言模型的主要目的都是學習訓練文本中字元/單詞序列的聯合概率分布,即嘗試學習聯合概率函數。例如,如果我們試圖預測一個包含
T 個詞的單詞序列,那麼我們試圖獲取令聯合概率 P(w_1, w_2, …, w_T) 最大的詞序列,等價於所有時間步 (t)
上條件概率的累乘:

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

本文描述了字元級的語言模型,其中幾乎所有概念都適用於其它語言模型,如單詞級的語言模型等。字元級語言模型的主要任務是根據之前的所有字元預測下一個字元,即逐個字元地生成文本內容。更正式地來說,給出訓練序列
(x^1,…,x^T),RNN 使用輸出向量序列 (o^1,…,o^T) 來獲取預測分布 P(x^t|x^t?1)=softmax(o^t)。

下面我用我的姓氏(imad)為例介紹字元級語言模型的運行過程(該示例的詳情見圖 2)。

1.
我們首先用語料庫中所有名字的字母(去掉重複的字母)作為關鍵詞構建一個辭彙詞典,每個字母的索引從 0 開始(因為 Python
的索引也是從零開始),按升序排列。以 imad 為例,辭彙詞典應該是:{「a」: 0,「d」: 1,「i」: 2,「m」: 3}。因此,imad
就變成整數列表:[2, 3, 0, 1]。

2. 使用辭彙詞典將輸入和輸出字元轉換成整型數列。本文中,我們假設所有示例中

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

。因此,y=「imad」,

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

。換言之,x^t+1=y^t,y=[2,3,0,1],

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

3. 對於輸入中的每一個字元:

  • 將輸入字元轉換成 one-hot 向量。注意第一個字元

    從字元級的語言建模開始,了解語言模型與序列建模的基本概念

    的轉換過程。

  • 計算隱藏狀態層。

  • 計算輸出層,然後將計算結果傳入 softmax 層,獲得的結果就是概率。

  • 把時間步 (t) 的目標字元作為時間步 (t+1) 的輸入字元。

  • 返回步驟 a,重複該過程,直到結束名字中的所有字母。

模型的目標是使概率分布層中的綠色數值儘可能大,紅色數值儘可能小。原因在於概率趨近於
1
時,真正的索引具備最高的概率。我們可以使用交叉熵來評估損失,然後計算損失函數關於所有參數損失的梯度,並根據與梯度相反的方向更新參數。不斷重複該過程并迭代地調整參數,這樣模型就能夠使用訓練集中的所有名字,根據之前的字元預測後一個字元。注意:隱藏狀態
h^4 具備所有之前字元的信息。

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

圖 2:使用 RNN 的字元級語言模型圖示。

注意:為簡潔起見,我刪除了所有 Python 函數的文檔注釋,也沒有包含一些無益於理解主要概念的函數。

notebook
和 script
地址:https://nbviewer.jupyter.org/github/ImadDabbura/blog-posts/blob/master/notebooks/Character-LeveL-Language-Model.ipynb

https://github.com/ImadDabbura/blog-posts/blob/master/scripts/character_level_language_model.py

訓練

我們使用的數據集有
5163 個名字:4275 個男性名字,以及 1219 個女性名字,其中有 331 個名字是中性的。我們將使用多對多的 RNN
架構來訓練字元級語言模型,其中輸入(T_x)的時間步等於輸出(T_y)的時間步。換句話說,輸入和輸出的序列是同步的(詳見圖 3)。

數據集地址:http://deron.meranda.us/data/census-derived-all-first.txt

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

圖 3:多對多的 RNN 架構。

該字元級語言模型將在名字數據集上訓練,然後我們可以使用該模型生成一些有趣的名字。

在這一節中,我們將介紹 4 個主要內容:

1 前向傳播

2 反向傳播

3 採樣

4 擬合模型

前向傳播

我們將使用隨機梯度下降(SGD),其中每個 Batch 只包含一個樣本。也就是說,RNN 模型將從每個樣本(名字)中分別進行學習,即在每個樣本上運行前向和反向傳播,並據此更新參數。以下是前向傳播所需步驟:

  • 使用全部小寫字母(無重複)構建辭彙詞典:

  • 創建不同字元的索引詞典,使每個字元以升序對應索引。例如,a
    的索引是 1(因為 Python 的索引是從 0 開始,我們將把 0 索引保存為 EOS「
    」),z 的索引是
    26。我們將使用該詞典將名字轉換成整數列表,其中的每個字母都用 one-hot 向量來表示。

  • 創建一個字元詞典的索引,使索引映射至字元。該詞典將用於將 RNN 模型的輸出轉換為字元,然後再翻譯成名字。

  • 初始化參數:將權重初始化為從標準正態分布中採樣的較小隨機數值,以打破對稱性,確保不同的隱藏單元學習不同的事物。另外,偏置項也要初始化為 0。

  • W_hh:權重矩陣,連接前一個隱藏狀態 h^t?1 和當前的隱藏狀態 h^t。

  • W_xh:權重矩陣,連接輸入 x^t 和隱藏狀態 h^t。

  • b:隱藏狀態偏置項向量。

  • W_hy:權重矩陣,連接隱藏狀態 h^t 與輸出 o^t。

  • c:輸出偏置項向量。

  • 將輸入 x^t 和輸出 y^t 分別轉換成 one-hot 向量:one-hot 向量的維度是 vocab_size x 1,除了在字元處的索引是 1,其他都是 0。在我們的案例中,x^t 和 y^t 一樣需要向左移一步

    從字元級的語言建模開始,了解語言模型與序列建模的基本概念

    。例如,如果我們使用「imad」作為輸入,那麼 y=[3,4,1,2,0],

    從字元級的語言建模開始,了解語言模型與序列建模的基本概念

    。注意:

    從字元級的語言建模開始,了解語言模型與序列建模的基本概念

    ,索引不是 0。此外,我們還使用「
    」作為每個名字的 EOS(句子/名字末尾),這樣 RNN 可以將「
    」學習為任意其它字元。這會幫助網路學習什麼時候停止生成字元。因此,所有名字的最後一個目標字元都將是表示名字末尾的「
    」。

  • 使用以下公式計算隱藏狀態:

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

注意我們使用雙曲正切

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

作為非線性函數。主要優勢是雙曲正切函數在一定範圍內近似於恆等函數。

  • 用以下公式計算輸出層:

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

  • 將輸出傳輸至 softmax 層,以歸一化輸出,這樣我們可以將它表達為概率,即所有輸出都在 0 和 1 之間,總和為 1。以下是 softmax 公式:

    從字元級的語言建模開始,了解語言模型與序列建模的基本概念

softmax 層和輸出層的維度相同,都是 vocab_size x 1。因此,y^t[i] 表示時間步 (t) 下索引 i 對應字元為預測字元的概率。

  • 如前所述,字元級語言模型的目標是最小化訓練序列的負對數似然。因此,時間步 (t) 的損失函數和所有時間步的總損失為:

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

由於我們使用 SGD,因此損失函數的一階導作為下降方向會帶有雜訊,且會存在振蕩現象,因此使用指數加權平均法消除雜訊是一個不錯的方法。

  • 將目標字元 y^t 作為下一個輸入 x^t+1,直到完成該序列。

# Load packages
import os
import numpy as np
os.chdir("../scripts/")
from character_level_language_model import (initialize_parameters,
initialize_rmsprop,
softmax,
smooth_loss,
update_parameters_with_rmsprop)
def rnn_forward(x, y, h_prev, parameters):
"""Implement one Forward pass on one name."""
# Retrieve parameters
Wxh, Whh, b = parameters["Wxh"], parameters["Whh"], parameters["b"]
Why, c = parameters["Why"], parameters["c"]
# Initialize inputs, hidden state, output, and probabilities dictionaries
xs, hs, os, probs = {}, {}, {}, {}
# Initialize x0 to zero vector
xs[0] = np.zeros((vocab_size, 1))
# Initialize loss and assigns h_prev to last hidden state in hs
loss = 0
hs[-1] = np.copy(h_prev)
# Forward pass: loop over all characters of the name
for t in range(len(x)):
# Convert to one-hot vector
if t > 0:
xs[t] = np.zeros((vocab_size, 1))
xs[t][x[t]] = 1
# Hidden state
hs[t] = np.tanh(np.dot(Wxh, xs[t]) + np.dot(Whh, hs[t - 1]) + b)
# Logits
os[t] = np.dot(Why, hs[t]) + c
# Probs
probs[t] = softmax(os[t])
# Loss
loss -= np.log(probs[t][y[t], 0])
cache = (xs, hs, probs)
return loss, cache

反向傳播

在基於 RNN 的模型上使用的基於梯度的技術被稱為隨時間的反向傳播(Backpropagation Through Time,BPTT)。我們從最後的時間步 T 開始,計算關於全部時間步的所有參數的反向傳播梯度,並將它們都加起來(如圖 4 所示)。

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

圖 4:隨時間的反向傳播(BPTT)。

此外,由於已知
RNN 有很陡峭的梯度變化,梯度可能會突然變得非常大然後使原來訓練得到的進展功虧一簣,即使使用了適應性學習方法如
RMSProp。其原因是梯度是損失函數的線性近似,可能無法捕捉在評估的點之外的其它信息,例如損失曲面的曲率。因此,通常在實踐中會將梯度限制在
[-maxValue, maxValue] 區間內。在這裡,我們將把梯度限制在 [-5,5] 上。這意味著如果梯度小於-5 或者大於
5,它將分別被截斷為-5 和 5。以下是所有時間步上用於計算損失函數對所有參數的梯度所需的公式。

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

注意,在最後的時間步
T,我們將初始化 dh_next 為 0,因為其無法在未來得到任何更新值。由於 SGD
可能存在很多振蕩,為了在每個時間步穩定更新過程,我們將使用其中一種適應性學習率的優化方法。具體來說,我們將使用
RMSProp,該方法能夠獲得可接受的性能。

def clip_gradients(gradients, max_value):
"""
Implements gradient clipping element-wise on gradients to be between the
interval [-max_value, max_value].
"""
for grad in gradients.keys():
np.clip(gradients[grad], -max_value, max_value, out=gradients[grad])
return gradients
def rnn_backward(y, parameters, cache):
"""
Implements Backpropagation on one name.
"""
# Retrieve xs, hs, and probs
xs, hs, probs = cache
# Initialize all gradients to zero
dh_next = np.zeros_like(hs[0])
parameters_names = ["Whh", "Wxh", "b", "Why", "c"]
grads = {}
for param_name in parameters_names:
grads["d" + param_name] = np.zeros_like(parameters[param_name])
# Iterate over all time steps in reverse order starting from Tx
for t in reversed(range(len(xs))):
dy = np.copy(probs[t])
dy[y[t]] -= 1
grads["dWhy"] += np.dot(dy, hs[t].T)
grads["dc"] += dy
dh = np.dot(parameters["Why"].T, dy) + dh_next
dhraw = (1 - hs[t] ** 2) * dh
grads["dWhh"] += np.dot(dhraw, hs[t - 1].T)
grads["dWxh"] += np.dot(dhraw, xs[t].T)
grads["db"] += dhraw
dh_next = np.dot(parameters["Whh"].T, dhraw)
# Clip the gradients using [-5, 5] as the interval
grads = clip_gradients(grads, 5)
# Get the last hidden state
h_prev = hs[len(xs) - 1]
return grads, h_prev

採樣

正是採樣過程使得用 RNN
在每個時間步生成的文本變得有趣和有創造性。在每個時間步 (t),給定所有的已有字元,RNN 可輸出下一個字元的條件概率分布,即
P(c_t|c_1,c_2,…,c_t?1)。假設我們在時間步 t=3,並嘗試預測第三個字元,其條件概率分布為
P(c_3/c_1,c_2)=(0.2,0.3,0.4,0.1)。其中有兩種極端情況:

  • 最大熵:字元會使用均勻概率分布進行選取;這意味著辭彙表中的所有字元都是同等概率的。因此,我們最終將在選取下一個字元的過程中達到最大隨機性,而生成的文本也不會有意義。

  • 最小熵:在每個時間步,擁有最高條件概率的字元將會被選取。這意味著下一個字元的選取將基於訓練中的文本和已學習的參數。因此,生成的命名將是有意義的和有真實性的。

隨著隨機性的增大,文本將逐漸失去局部結構;然而,隨著隨機性的減小,生成的文本將變得更具真實性,並逐漸開始保留其局部結構。在這裡,我們將從模型生成的分布中採樣,該分布可被視為具有最大熵和最小熵之間的中等級別的隨機性(如圖
5 所示)。在上述分布中使用這種採樣策略,索引 0 有 20% 的概率被選取,而索引 2 有 40% 的概率被選取。

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

圖 5:採樣:使用字元級語言建模預測下一個字元的圖示。因此,採樣過程將在測試時用於一個接一個地生成字元。

def sample(parameters, idx_to_chars, chars_to_idx, n): """ Implements sampling of a squence of n characters characters length. The sampling will be based on the probability distribution output of RNN. """ # Retrienve parameters, shapes, and vocab size Whh, Wxh, b = parameters["Whh"], parameters["Wxh"], parameters["b"] Why, c = parameters["Why"], parameters["c"] n_h, n_x = Wxh.shape vocab_size = c.shape[0] # Initialize a0 and x1 to zero vectors h_prev = np.zeros((n_h, 1)) x = np.zeros((n_x, 1)) # Initialize empty sequence indices = [] idx = -1 counter = 0 while (counter <= n and idx != chars_to_idx["
"]): # Fwd propagation h = np.tanh(np.dot(Whh, h_prev) + np.dot(Wxh, x) + b) o = np.dot(Why, h) + c probs = softmax(o) # Sample the index of the character using generated probs distribution idx = np.random.choice(vocab_size, p=probs.ravel()) # Get the character of the sampled index char = idx_to_chars[idx] # Add the char to the sequence indices.append(idx) # Update a_prev and x h_prev = np.copy(h) x = np.zeros((n_x, 1)) x[idx] = 1 counter += 1 sequence = "".join([idx_to_chars[idx] for idx in indices if idx != 0]) return sequence

擬合模型

在介紹了字元級語言建模背後的所有概念/直覺思想之後,接下來我們開始擬合模型。我么將使用
RMSProp 的默認超參數設置,并迭代地運行模型 100
次。在每次迭代中,我們將輸出一個採樣的命名,並平滑損失函數,以觀察生成的命名如何(隨著迭代數的增加和梯度的下降)變得越來越有趣。當模型擬合完成後,我們將畫出損失函數並生成一些命名。

def model(
file_path, chars_to_idx, idx_to_chars, hidden_layer_size, vocab_size,
num_epochs=10, learning_rate=0.01):
"""Implements RNN to generate characters."""
# Get the data
with open(file_path) as f:
data = f.readlines()
examples = [x.lower().strip() for x in data]
# Initialize parameters
parameters = initialize_parameters(vocab_size, hidden_layer_size)
# Initialize Adam parameters
s = initialize_rmsprop(parameters)
# Initialize loss
smoothed_loss = -np.log(1 / vocab_size) * 7
# Initialize hidden state h0 and overall loss
h_prev = np.zeros((hidden_layer_size, 1))
overall_loss = []
# Iterate over number of epochs
for epoch in range(num_epochs):
print(f"33[1m33[94mEpoch {epoch}")
print(f"33[1m33[92m=======")
# Sample one name
print(f"""Sampled name: {sample(parameters, idx_to_chars, chars_to_idx,
10).capitalize()}""")
print(f"Smoothed loss: {smoothed_loss:.4f}
")
# Shuffle examples
np.random.shuffle(examples)
# Iterate over all examples (SGD)
for example in examples:
x = [None] + [chars_to_idx[char] for char in example]
y = x[1:] + [chars_to_idx["
"]]
# Fwd pass
loss, cache = rnn_forward(x, y, h_prev, parameters)
# Compute smooth loss
smoothed_loss = smooth_loss(smoothed_loss, loss)
# Bwd pass
grads, h_prev = rnn_backward(y, parameters, cache)
# Update parameters
parameters, s = update_parameters_with_rmsprop(
parameters, grads, s)
overall_loss.append(smoothed_loss)
return parameters, overall_loss

# Load namesdata = open("../data/names.txt", "r").read()# Convert characters to lower casedata = data.lower()# Construct vocabulary using unique characters, sort it in ascending order,# then construct two dictionaries that maps character to index and index to# characters.chars = list(sorted(set(data)))chars_to_idx = {ch:i for i, ch in enumerate(chars)}idx_to_chars = {i:ch for ch, i in chars_to_idx.items()}# Get the size of the data and vocab sizedata_size = len(data)vocab_size = len(chars_to_idx)print(f"There are {data_size} characters and {vocab_size} unique characters.")# Fitting the modelparameters, loss = model("../data/names.txt", chars_to_idx, idx_to_chars, 100, vocab_size, 100, 0.01)# Plotting the lossplt.plot(range(len(loss)), loss)plt.xlabel("Epochs")plt.ylabel("Smoothed loss");There are 36121 characters and 27 unique characters.

Epoch 0
=======
Sampled name: Nijqikkgzst
Smoothed loss: 23.0709

Epoch 10
=======
Sampled name: Milton
Smoothed loss: 14.7446

Epoch 30
=======
Sampled name: Dangelyn
Smoothed loss: 13.8179

Epoch 70
=======
Sampled name: Lacira
Smoothed loss: 13.3782

Epoch 99
=======
Sampled name: Cathranda
Smoothed loss: 13.3380

從字元級的語言建模開始,了解語言模型與序列建模的基本概念

圖 6:平滑化的損失函數

經過 15 個 epoch 之後,生成的命名開始變得有意義。在這裡,為簡單起見,我並沒有展示所有 epoch 的結果;然而,你可以在我的 notebook 里查看完整的結果。其中一個有趣的命名是「Yasira」,這是一個阿拉伯名字。

結論

統計語言模型在 NLP 中非常重要,例如語音識別和機器翻譯。我們在此文章中展示了字元級語言模型背後的主要概念。該模型的主要任務是使用一般數據中的命名按字元生成預測命名,該數據集包含 5136 個名字。以下是主要思考:

如果我們有更多數據、更大模型、更長的訓練時間,我們可能會得到更有趣的結果。然而,為了得到更好的結果,我們應該使用更深層的
LSTM。有人曾使用 3 層帶有 dropout 的 LSTM,應用到莎士比亞詩上獲得了很好的結果。LSTM
模型因其獲取更長依存關係的能力,性能上比簡單的 RNN 更強。

在此文章中,我們使用每個名字作為一個序列。然而,如果我們增加 Batch 的大小,可能會加速學習速度且得到更好的結果。比如從一個名字增加到 50 個字元的序列。

我們可以使用採樣策略控制隨機性。在這篇文章中,我們在模型考慮的正確字元與隨機性之間做了權衡。

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 機器之心 的精彩文章:

阿里巴巴論文:一種基於 Stacking 集成模型的 KKBOX 用戶流失
FAIR何愷明等人提出組歸一化:替代批歸一化,不受批量大小限制

TAG:機器之心 |