酷播亮新聞
最棒的知識補給站

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

摘要:你有沒有想過Gmail
自動回復是如何進行的? 或者手機在你輸入文本時如何對下一個詞提出建議? 生成文本序列的通常方式是訓練模型在給定所有先前詞/字符的條件下預測下一個詞/字符出現的概率。 此類模型叫作統計

你有沒有想過 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. 對於輸入中的每一個字符:

  • 的轉換過程。

  • 計算隱藏狀態層。

  • 計算輸出層,然後將計算結果傳入 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「n」),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。 此外,我們還使用「n」作為每個名字的 EOS(句子/名字末尾),這樣 RNN 可以將「n」學習為任意其它字符。 這會幫助網絡學習什麼時候停止生成字符。 因此,所有名字的最後一個目標字符都將是表示名字末尾的「n」。

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

注意我們使用雙曲正切

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

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

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

# 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”]

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 # 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”\033[1m\033[94mEpoch {epoch}”)
print(f”\033[1m\033[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}\n”)
# 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[“\n”]]
# Fwd pass
loss, cache = rnn_forward(x, y, h_prev, parameters)
# Compute smooth loss
smoothed_loss = smooth_loss(smoothed_loss, loss)

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 names
data = open(“../data/names.txt”, “r”).read()
# Convert characters to lower case
data = 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 size
data_size = len(data)
vocab_size = len(chars_to_idx)
print(f”There are {data_size} characters and {vocab_size} unique characters.”)
# Fitting the model
parameters, loss = model(“../data/names.txt”, chars_to_idx, idx_to_chars, 100, vocab_size, 100, 0.01)
# Plotting the loss
plt.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 個字符的序列。

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

版權聲明

本文僅代表作者觀點,不代表百度立場。
本文係作者授權百度百家發表,未經許可,不得轉載。

如有侵權請來信告知:酷播亮新聞 » 從字符級的語言建模開始,了解語言模型與序列建模的基本概念