目錄
語言模型 是一種用於預測文字序列中下一個單字或字元的機率分佈的模型。它可以捕獲語言結構的某些方面,如語法、句式和上下文資訊。傳統的語言模型通常使用 N-gram 方法或隱藏馬可夫模型,但這些模型往往無法捕捉到長距離依賴和複雜的語義資訊。本文小編就來聊聊什麼是語言模型(language model)。
語言模型是什麼
簡單來說 語言模型 評價一句話是否 “合理” 或 “是人話”,數學上講 P(今天天氣不錯) > P(今錯不天天氣),語言模型用於計算文字的成句機率。
語言模型的主要用途
語言模型 - 語音識別
- 語音辨識:聲音 -> 文字
- 聲音本質是一種波
- 將波按時間段切分許多幀,如25ms一段
- 之後進行聲學特徵提取,將每一幀轉換成向量
- 以聲學特徵擷取後的向量為輸入,經過聲學模型,預測得到音素
- 音素與拼音類似,但要考慮聲調
- 音素序列對應多個文字序列,由語言模型挑選出成句機率最高的序列
- 使用beam search或維特比的方式解碼
- 語音辨識示意圖
語言模型 - 手寫識別
辨識模型將圖片中文字轉換為候選漢字(一般分定位與辨識兩步驟),再有語言模型挑選出成句機率最高的序列
語言模型 - 輸入法
輸入即為拼音、注音序列,每個拼音自然的有多個候選漢字,根據語言模型挑選高機率序列。
輸入法是一個細節繁多的任務,在語言模型這個基礎演算法上,需要考慮常見的打字手誤,常見誤讀,拼音縮略,中英混雜,輸出符號,用戶習慣等能力手寫輸入法,語音輸入法同理。
語言模型的分類
1. 基於統計語言模型
- 對於一份語料進行詞頻、詞序、詞共現的統計
- 計算相關機率得到語言模型
- 代表:N-gram語言模型
2. 基於神經網路的語言模型
- 根據設定好的網路結構使用語料進行模型訓練
- 代表:LSTM語言模型、BERT等
3. 自動回歸(auto regressive)語言模型
- 在訓練時由上文預測下文(或反過來)
- 單向模型,僅使用單側序列資訊
- 代表:N-gram、ELMO
4. 自動編碼(auto encoding)語言模型
- 在訓練時預測序列中任意位置的字符
- 雙向模型,吸收情境資訊
- 代表:BERT
N-gram語言模型
N-gram語言模型是一種基礎的語言模型,用於預測下一個單字或字元出現的機率,基於前N-1個字或字元。該模型將文本視為單字或字符的有序序列,並假設第n個字僅與前N-1個字相關。
例如,在一個 bigram(2-gram)模型中,每個字的出現只依賴它前面的一個字。例如,”我吃”之後是 “蘋果” 的機率可以表示為 P(蘋果|我吃)。
優點:
- 計算簡單:只需要統計詞頻和條件詞頻。
- 實作容易:不需要複雜的演算法。
缺點:
- 稀疏性問題:隨著N的增加,模型所需的儲存空間急劇增加,而大多數N-gram組合在實際資料中可能並不存在。
- 上下文限制:只能捕捉N-1個字的上下文資訊。
儘管有這些局限性,N-gram 模型由於其簡單和高效仍然在許多應用場景中被廣泛使用,如拼字檢查、語音識別和機器翻譯等。
S = w1w2w3w4w5…wn
P(S) = P(w1,w2,w3,w4,w5…wn)
成句機率 -> 詞W1~Wn依序出現的機率
P(w1,w2,w3,…,wn) = P(w1)P(w2|w1)P(w3|w1,w2)…P(wn|w1,…,wn-1)
P(天氣|今天) = Count(今天 天氣) / Count(今天)
P(不錯|今天 天氣) = Count(今天 天氣 不錯) / Count(今天 天氣)
二元組:今天 天氣 2 gram
三元組:今天 天氣 不錯 3 gram
假設第n個字出現的機率,僅受其前面有限個字影響
P(今天天氣不錯) = P(今)*P(天|今) *P(天|今天) *P(氣|天天) *P(不|天氣) *P(錯|氣不)
long distance dependency
例:我讀過關於馬爾科夫的生平的書
我看過關於馬爾科夫的生平的電影
我聽過關於馬爾科夫的生平的故事
2. 影響第n個字的因素可能出現在其後面
3. 影響第n個字的因素可能並不在文中
但是,馬爾科夫假設下依然可以得到非常有效的模型
corpus:
今天 天氣 不錯 P(今天) = 3 / 12 = 1/4
明天 天氣 不錯 P(天氣|今天) = 2 / 3
今天 天氣 不行 P(不錯|今天 天氣) = 1 / 2
今天 是 晴天 P(不錯|天氣) = 2/3
3 gram模型
P(今天 天氣 不錯) = P(今天)*P(天氣|今天) *P(不錯|今天 天氣) = 1 / 12
2 gram模型
P(今天 天氣 不錯) = P(今天)*P(天氣|今天) *P(不錯|天氣) = 1 / 9
corpus:
今天 天氣 不錯 P(今天) = 3 / 12 = 1/4
明天 天氣 不錯 P(天氣|今天) = 2 / 3
今天 天氣 不行 P(不錯|今天 天氣) = 1 / 2
今天 是 晴天 P(不錯|天氣) = 2/3
問題:如何給出 corpus 中沒出現過的字或 ngram 機率?
P(今天 天氣 糟糕) = P(今天)*P(天氣|今天) *P(糟糕|天氣)
- 平滑問題(smoothing)
- 理論上說,任意的字組合成的句子,機率都不應該當為零
- 如何給沒見過的字或ngram分配機率即為平滑問題
- 也稱折扣問題(discounting)
N-gram 語言模型 - 平滑方法
回退(backoff)
當三元組 a b c 不存在時,退而尋找 b c 二元組的機率
P(c | a b) = P(c | b) * Bow(ab)
Bow(ab) 稱為二元組 a b 的回退機率
回退機率有很多計算方式,甚至可以設定為常數
回退可以迭代進行,如序列 a b c d
P(d | a b c) = P(d | b c) * Bow(abc)
P(d | bc) = P(d | c) * Bow(bc)
P(d | c ) = P(d) * Bow©
P(word) 不存在如何處理
加 1 平滑 add-one smooth
對於 1gram機率 P(word) = Count(word)+1/Count(total_word)+V
V 為詞表大小
對於高階機率同樣可以
將低頻詞替換為
預測中遇到的未見過的詞,也用代替
一語成讖 -> 一語成
P(|一 語 成)
這是一種 nlp 處理未登入詞(OOV)的常見方法
插值
受到回退平滑的啟發,在計算高階 ngram 機率是同時考慮低階的 ngram 機率值,以插值給出最終結果
實踐證明,這種方式效果有提升
λ 可以在驗證集上調整參數確定
ngram 程式碼
import math
from collections import defaultdict
class NgramLanguageModel:
def __init__(self, corpus=None, n=3):
self.n = n
self.sep = "_" # 用來分割兩個詞,沒有實際意義,只要是字典裡不存在的符號都可以
self.sos = "" #start of sentence,句子開始的識別符號
self.eos = "" #end of sentence,句子結束的識別符號
self.unk_prob = 1e-5 #給 unk 分配一個比較小的機率值,避免集外詞機率為 0
self.fix_backoff_prob = 0.4 #使用固定的回退概率
self.ngram_count_dict = dict((x + 1, defaultdict(int)) for x in range(n))
self.ngram_count_prob_dict = dict((x + 1, defaultdict(int)) for x in range(n))
self.ngram_count(corpus)
self.calc_ngram_prob()
#將文字切分成詞或字或 token
def sentence_segment(self, sentence):
return sentence.split()
#return jieba.lcut(sentence)
#統計 ngram 的數量
def ngram_count(self, corpus):
for sentence in corpus:
word_lists = self.sentence_segment(sentence)
word_lists = [self.sos] + word_lists + [self.eos] #前後補充開始符號和結尾符號
for window_size in range(1, self.n + 1): #按不同窗長掃描文字
for index, word in enumerate(word_lists):
#取到結尾時視窗長度會小於指定的 gram,跳過那幾個
if len(word_lists[index:index + window_size]) != window_size:
continue
#用分隔符號連接 word 形成一個 ngram 用於儲存
ngram = self.sep.join(word_lists[index:index + window_size])
self.ngram_count_dict[window_size][ngram] += 1
#計算總詞數,後續用於計算一階 ngram 機率
self.ngram_count_dict[0] = sum(self.ngram_count_dict[1].values())
return
#計算 ngram 機率
def calc_ngram_prob(self):
for window_size in range(1, self.n + 1):
for ngram, count in self.ngram_count_dict[window_size].items():
if window_size > 1:
ngram_splits = ngram.split(self.sep) #ngram :a b c
ngram_prefix = self.sep.join(ngram_splits[:-1]) #ngram_prefix :a b
ngram_prefix_count = self.ngram_count_dict[window_size - 1][ngram_prefix] #Count(a,b)
else:
ngram_prefix_count = self.ngram_count_dict[0] #count(total word)
# word = ngram_splits[-1]
# self.ngram_count_prob_dict[word + "|" + ngram_prefix] = count / ngram_prefix_count
self.ngram_count_prob_dict[window_size][ngram] = count / ngram_prefix_count
return
#取得 ngram 機率,其中用到了回退平滑,回退機率採取固定值
def get_ngram_prob(self, ngram):
n = len(ngram.split(self.sep))
if ngram in self.ngram_count_prob_dict[n]:
#尝试直接取出概率
return self.ngram_count_prob_dict[n][ngram]
elif n == 1:
#一階 gram 查找不到,說明是集外詞,不做回退
return self.unk_prob
else:
#高於一階的可以回退
ngram = self.sep.join(ngram.split(self.sep)[1:])
return self.fix_backoff_prob * self.get_ngram_prob(ngram)
#回退法預測句子機率
def calc_sentence_ppl(self, sentence):
word_list = self.sentence_segment(sentence)
word_list = [self.sos] + word_list + [self.eos]
sentence_prob = 0
for index, word in enumerate(word_list):
ngram = self.sep.join(word_list[max(0, index - self.n + 1):index + 1])
prob = self.get_ngram_prob(ngram)
# print(ngram, prob)
sentence_prob += math.log(prob)
return 2 ** (sentence_prob * (-1 / len(word_list)))
if __name__ == "__main__":
corpus = open("sample.txt", encoding="utf8").readlines()
lm = NgramLanguageModel(corpus, 3)
print("詞總數:", lm.ngram_count_dict[0])
print(lm.ngram_count_prob_dict)
print(lm.calc_sentence_ppl("e f g b d"))
語言模型的評估指標
困惑度 perplexity
PPL 值與成句機率成反比
一般使用合理的目標文字來計算 PPL,若 PPL 值低,則說明成句機率高,也就說明由此語言模型來判斷,該句子的合理性高,這樣是一個好的語言模型。
另一種 PPL,用對數求和代替小數乘積
- 本質是相同的,與成句機率呈反比
- 思考:PPL越小,語言模型效果越好,這結論是否正確?
- 成句機率是個相對值!
兩類語言模型的對比
N-gram 語言模型 | NN 語言模型 | |
---|---|---|
解碼速度 | 快 | 慢 |
消耗記憶體 | 大 | 小 |
是否需要調整參數 | 不需要(很少) | 需要 |
模型大小 | 大 | 小 |
長距離依賴 | 無法處理 | 相對有效 |
詞義關係 | 無 | 有
|
泛化能力
| 較弱
| 較強
|
神經網路語言模型
- Bengio et al. 2003
- 與ngram模型相似使用前n個字預測下一個詞
- 輸出在字表上的機率分佈
- 得到了詞向量這一副產品
- 隨著相關研究的發展,隱含層模型結構的複雜度不斷提升
- DNN -> CNN/RNN -> LSTM/GRU -> transformer
- Devlin et al. 2018 BERT 誕生
- 主要特點:不再使用預測下一個字的方式訓練語言模型,轉而預測文字中被隨機遮蓋的某個字
- 這種方式稱為 MLM(masked language model)
- 實際上這種方式被提出的時間非常早,並非 bert 原創
程式碼
#coding:utf8
import torch
import torch.nn as nn
import numpy as np
import math
import random
import os
import re
import matplotlib.pyplot as plt
"""
基於 pytorch 的 rnn 語言模型
"""
class LanguageModel(nn.Module):
def __init__(self, input_dim, vocab):
super(LanguageModel, self).__init__()
self.embedding = nn.Embedding(len(vocab) + 1, input_dim)
self.layer = nn.RNN(input_dim, input_dim, num_layers=2, batch_first=True)
self.classify = nn.Linear(input_dim, len(vocab) + 1)
self.dropout = nn.Dropout(0.1)
self.loss = nn.functional.cross_entropy
#當輸入真實標籤,返回 loss 值;無真實標籤,返回預測值
def forward(self, x, y=None):
x = self.embedding(x) #output shape:(batch_size, sen_len, input_dim)
x, _ = self.layer(x) #output shape:(batch_size, sen_len, input_dim)
x = x[:, -1, :] #output shape:(batch_size, input_dim)
x = self.dropout(x)
y_pred = self.classify(x) #output shape:(batch_size, input_dim)
if y is not None:
return self.loss(y_pred, y)
else:
return torch.softmax(y_pred, dim=-1)
#讀取 corpus 獲得字符集
#輸出一份
def build_vocab_from_corpus(path):
vocab = set()
with open(path, encoding="utf8") as f:
for index, char in enumerate(f.read()):
vocab.add(char)
vocab.add("") #增加一個 unk token 來處理未登入詞
writer = open("vocab.txt", "w", encoding="utf8")
for char in sorted(vocab):
writer.write(char + "\n")
return vocab
#加載字表
def build_vocab(vocab_path):
vocab = {}
with open(vocab_path, encoding="utf8") as f:
for index, line in enumerate(f):
char = line[:-1] #去掉結尾換行符
vocab[char] = index + 1 #留出0位给pad token
vocab["\n"] = 1
return vocab
#加載 corpus
def load_corpus(path):
return open(path, encoding="utf8").read()
#隨機產生一個樣本
#從文字中截取隨機窗口,前n個字作為輸入,最後一個字作為輸出
def build_sample(vocab, window_size, corpus):
start = random.randint(0, len(corpus) - 1 - window_size)
end = start + window_size
window = corpus[start:end]
target = corpus[end]
# print(window, target)
x = [vocab.get(word, vocab[""]) for word in window] #將字轉換成序號
y = vocab[target]
return x, y
#建立資料集
#sample_length 輸入所需的樣本數量。需要多少生成多少
#vocab 詞表
#window_size 樣本長度
#corpus 字符串
def build_dataset(sample_length, vocab, window_size, corpus):
dataset_x = []
dataset_y = []
for i in range(sample_length):
x, y = build_sample(vocab, window_size, corpus)
dataset_x.append(x)
dataset_y.append(y)
return torch.LongTensor(dataset_x), torch.LongTensor(dataset_y)
#建立模型
def build_model(vocab, char_dim):
model = LanguageModel(char_dim, vocab)
return model
#計算文字ppl
def calc_perplexity(sentence, model, vocab, window_size):
prob = 0
model.eval()
with torch.no_grad():
for i in range(1, len(sentence)):
start = max(0, i - window_size)
window = sentence[start:i]
x = [vocab.get(char, vocab[""]) for char in window]
x = torch.LongTensor([x])
target = sentence[i]
target_index = vocab.get(target, vocab[""])
if torch.cuda.is_available():
x = x.cuda()
pred_prob_distribute = model(x)[0]
target_prob = pred_prob_distribute[target_index]
prob += math.log(target_prob, 10)
return 2 ** (prob * ( -1 / len(sentence)))
def train(corpus_path, save_weight=True):
epoch_num = 10 #訓練輪數
batch_size = 128 #每次訓練樣本個數
train_sample = 10000 #每輪訓練總共訓練的樣本總數
char_dim = 128 #每個字的維度
window_size = 6 #樣本文字長度
vocab = build_vocab("vocab.txt") #建立字表
corpus = load_corpus(corpus_path) #載入corpus
model = build_model(vocab, char_dim) #建立模型
if torch.cuda.is_available():
model = model.cuda()
optim = torch.optim.Adam(model.parameters(), lr=0.001) #建立優化器
for epoch in range(epoch_num):
model.train()
watch_loss = []
for batch in range(int(train_sample / batch_size)):
x, y = build_dataset(batch_size, vocab, window_size, corpus) #建立一組訓練樣本
if torch.cuda.is_available():
x, y = x.cuda(), y.cuda()
optim.zero_grad() #梯度歸零
loss = model(x, y) #計算loss
watch_loss.append(loss.item())
loss.backward() #計算梯度
optim.step() #更新權重
print("=========\n第%d輪平均loss:%f" % (epoch + 1, np.mean(watch_loss)))
if not save_weight:
return
else:
base_name = os.path.basename(corpus_path).replace("txt", "pth")
model_path = os.path.join("model", base_name)
torch.save(model.state_dict(), model_path)
return
#訓練corpus資料夾下的所有corpus,依照檔案名稱將訓練後的模型放到莫得了資料夾
def train_all():
for path in os.listdir("corpus"):
corpus_path = os.path.join("corpus", path)
train(corpus_path)
if __name__ == "__main__":
# build_vocab_from_corpus("corpus/all.txt")
# train("corpus.txt", True)
train_all()
語言模型的應用
語言模式的應用 - 話者分離
根據說話內容判斷說話人,常用於語言辨識系統中,判斷錄音對話中角色。如客服對話錄音,判斷坐席或客戶,根據不同腔調判斷說話人。
- 翻譯口音: 這倒楣的房子裡竟然有蟑螂,你可以想像嗎?這真是太可怕了!
- 台灣口音:你這個人怎麼可以這個樣子
- 中國口音: 我不稀得說你那些事兒就拉倒了
本質上為文字分類任務
- 對於每個類別,使用類別 corpus 訓練語言模型
- 對於一個新輸入的文字,用所有語言模型計算成句機率
- 選取機率最高的類別為預測類別
相較於一般文本分類模型,如貝葉斯,rf,神經網路等
優勢:
- 每個類別模型互相獨立,樣本不均衡或樣本有錯誤對其他模型沒有影響
- 可以隨時增加新的類別,而不影響舊的類別的效果
- 效果上:一般不會有顯著優勢
- 效率上:一般會低於統一的分類模型
語言模型的應用 - 文字糾錯
修正文字中的錯誤
例如:
我今天去了台南庵平古堡看熱藍遮城
我今天去了台南安平古堡看熱蘭遮城
錯誤可能是同音字或形近字等,對每一個字建立一個混淆字集合。計算整句話成句機率,用混淆字集合中的字取代原句中的字,重新計算機率
選出一個得分最高的候選句子,如果這個句子比原句的得分成長超過一定的門檻然後對下一個字重複步驟3-4,直到句子末尾。
這種方式有一些缺陷:
- 無法解決多字少字問題
- 閾值的設定非常難把握,如果設定過大,達不到糾錯效果;如果設定太小,造成大量替換,有可能改變句子的原意
- 混淆字字表難以完備
- 語言模型的領域性會影響修改結果
- 連續的錯字會大幅提升糾錯難度
一般工業做法:
- 限定一個修改白名單,只判斷特定的字詞是否要修改
- 如限定只對所有發音為shang wu的片段,計算是否修改為“商務”,其餘一概不做處理
- 對於深度學習模型而言,錯字是可以容忍的,所以糾錯本身的重要性正在下降,一般只針對展示類任務
語言模型的應用 - 數位歸一化
我們將一個文字中的數字部分轉換成對讀者友善的樣式,常見於語言辨識系統後,展示文字時使用。
例如:
- 史上第二慘!台股暴跌七百五十二點收二萬二千一百一十九點
- 史上第2慘!台股暴跌 752 點收 22119 點
- 找出數字形式符合規範的文字作為原始語料
- 用正規表示式找出數字部分(任意形式)
- 將數字部分依其格式替換為 < 阿拉伯數字 >< 中文數字 >< 中文連讀>等 token
- 使用帶有 token 文字訓練語言模型
- 對於新輸入的文字,同樣使用正規表示式找到數字部分,之後分別帶入各個 token,使用語言模型計算機率
- 選取機率最高的 token 最為最終數字格式,依照規則轉換後填入原文字
例句:
- 今天面臨補跌壓力,盤中一度重摔將近千點,終場挫跌七百五十二點,以二萬二千一百一十九點作收,創下史上單日第2大跌點紀錄,僅次於今年四月一十九日的七百七十四點
- 今天面臨補跌壓力,盤中一度重摔將近 <中文數字> 點,終場挫跌 <阿拉伯數字> 點,以 <阿拉伯數字> 點作收,創下史上單日第 <阿拉伯數字> 大跌點紀錄,僅次於今年 <阿拉伯數字> 月 <阿拉伯數字 >日的 <阿拉伯數字> 點
訓練時,將 <token> 當成一個字訓練語言模型
預測時,今天面臨補跌壓力,盤中一度重摔將近千點 <–原句
今天面臨補跌壓力,盤中一度重摔將近 <中文數字> 點
今天面臨補跌壓力,盤中一度重摔將近 <阿拉伯數字> 點 語言模型判斷最高概率
今天面臨補跌壓力,盤中一度重摔將近 <中文字連續> 點
若需要轉化格式則通過規則完成,模型只用到判斷作用
語言模型的應用 - 文字打標
給文字添加標點或是語氣停頓
這可以理解微一種粗粒度的分詞,常用於一種語音合成任務中,輔助做出發音的停頓,我們需要有標註數據所以在停頓處中加入 token:<s>
例如:
我最近<s>抽了一點時間<s>讀了一本<s>關於馬爾可夫生平<s>的書
帶 token 訓練語言模型。
預測過程:
選定一個窗口長度,首先預測第一次停頓位置。
- 我<s>最近抽了一點時間 ppl:10
- 我最<s>近抽了一點時間 ppl:20
- 我最近<s>抽了一點時間 ppl:5 <- 選擇此處作為第一次停頓
- …
- 之後從”抽了一點時間”開始向後重複此過程
質為序列標註任務可以依照類似方式,處理分詞、文字加標點、文字段落切分等任務,分詞或切分段落只需要一種 token;打標點時,可以用多種分隔 token,代表不同標點。
總結
語言模型的核心能力是計算成句機率,依賴這項能力,可以完成大量不同類型的 NLP 任務。
基於統計的語言模型和基於神經網路的語言模型各有使用的場景,大體上講,基於統計的模型優勢在於解碼速度,而神經網路的模型通常效果更好。
單純透過 PPL 評估語言模型是有限制的,透過下游任務效果進行整體評估較好。
深入的理解一種演算法,有助於發現更多的應用方式。
看似簡單(甚至錯誤)的假設,也能帶來有意義的結果,事實上,這是簡化問題的常見方式。