一. 前言
自然語言處理(NLP)是機器學習,人工智能中的一個重要領(lǐng)域。文本表達是 NLP中的基礎(chǔ)技術(shù),文本分類則是 NLP 的重要應(yīng)用。在 2016 年, Facebook Research 開源了名為 fasttext[1] 的文本表達和分類的計算庫。 fasttext 是基于文章 [2], [3], [4] 所提出算法的實現(xiàn),針對變形詞匯表達,線性分類優(yōu)化提供了優(yōu)秀的解決方案。 本文試圖梳理 FastText 在文本表達和文本分類方面的工作,并進行實踐。
二. 詞嵌入
1. 背景介紹
詞表達是 NLP 處理中的關(guān)鍵技術(shù)。常見的方式如 one-hot,使用了高維稀疏向量表示詞匯,這樣的特征可以反映詞出現(xiàn)的頻率,卻不能反映詞之間的關(guān)系。與此對應(yīng),詞嵌入技術(shù)將詞匯的上下文關(guān)系嵌入一個低維空間,其中比較有代表性的方法如 word2vec [5], GloVe[6]。本段落將分析 word2vec 的原理。
2. word2vec
word2vec 將詞的上下文關(guān)系嵌入到低維空間。更具體而言,word2vec 將詞的上下文關(guān)系轉(zhuǎn)換為分類關(guān)系,并以此同時訓練詞嵌入向量和 logistic regression 分類器。
1. 邏輯回歸
logistic regression 是經(jīng)典的線性分類模型。廣義上而言,線性模型由三個部分組成, 1. 輸入向量 2. 線性系數(shù) 3. 偏移(bias),而 bias 可以進一步表示成線性系數(shù)。所以,二分類的線性分類問題可以表示為輸入 $x$ 和系數(shù) $w$ 的內(nèi)積結(jié)果 $w^Tx$,結(jié)果的正負決定了數(shù)據(jù)的類別。分類器參數(shù)通過最小化損失函數(shù) $l(y, w^Tx)$來完成,不同的損失函數(shù)定義了不同的分類模型。
下面列舉了svm, lr 的損失函數(shù),其中 $y\in \{-1, 1\}$
logit : $\Sigma_{i=1}^N ln(1 + e^{-y_i w^Tx_i})$
svm : $\Sigma_{i=1}^N max(0, 1- y _iw^Tx_i) + \lambda||w||^2$
logistic regression 也廣泛地應(yīng)用在多分類問題中,通過 softmax 函數(shù)計算數(shù)據(jù)屬于每個類別的概率完成分類。因為分類神經(jīng)網(wǎng)絡(luò)的輸出層通常也設(shè)定為 softmax 函數(shù),所以多分類 lr 也可以表示為淺層神經(jīng)網(wǎng)絡(luò)。
下面我們分析 word2vec 如何將詞的上下文關(guān)系轉(zhuǎn)化為分類任務(wù)。
2. skip-gram
我們將詞的上下文定義為以詞 $w_i$ 為中心,窗口為 $k$ 前后范圍內(nèi)的詞 $C_i = {w_{i-k}, w_{i-k+1}, ..., w_{i-1}, w_{i+1}, ... w_{i+k}}$。
skip-gram 將詞之間的關(guān)系變成了 $|V|$ 多分類問題,其中 $|V|$ 是詞庫大小。每個詞有兩個變量 $x_i, w_i$,前者為詞嵌入向量,后者是線性分類器的系數(shù),在相關(guān)文章中又稱為上下文向量。
skip-gram 用中心詞匯來預(yù)測其上下文,如下圖:
3. cbow
Continuous bag of words(cbow) 是與 skip-gram 相對應(yīng)的另一種將上下文轉(zhuǎn)化為分類任務(wù)的方式。
上圖顯示 cbow 用上下文詞向量的加和結(jié)果來預(yù)測其中心詞匯,其它的方式還有拼接。需要注意的是拼接的方式會導致上下文向量 $c_i$ 維度增大 $2 k$ 倍數(shù), $k$ 為上下文窗口。
4. 針對多分類的計算優(yōu)化
word2vec 將上下文關(guān)系轉(zhuǎn)化為多分類任務(wù),進而訓練邏輯回歸模型,這里的類別數(shù)量是 $|V|$ 詞庫大小。通常的文本數(shù)據(jù)中,詞庫少則數(shù)萬,多則百萬,在訓練中直接訓練多分類邏輯回歸并不現(xiàn)實。
word2vec [5] 中提供了兩種針對大規(guī)模多分類問題的優(yōu)化手段, negative sampling 和 hierarchical softmax。以 skip-gram 為例,中心詞對上下文的詞類是正面例子,對所有其它的詞則是負面例子。在優(yōu)化中,negative sampling 只更新少量負面類,從而減輕了計算量。hierarchical softmax 將詞庫表示成前綴樹,從樹根到葉子的路徑可以表示為一系列二分類器,一次多分類計算的復(fù)雜度從 $|V|$ 降低到了樹的高度。
3. 總結(jié)
詞嵌入技術(shù)將詞的上下文關(guān)系嵌入到低維空間。word2vec 將詞的局部上下文轉(zhuǎn)化為了多分類任務(wù),從而訓練邏輯回歸模型,并將邏輯回歸模型中的輸入部分作為詞嵌入輸出。
三. FastText 子詞嵌入
1. 背景介紹
詞嵌入技術(shù)在 NLP 領(lǐng)域發(fā)揮了越來越大的作用,相較于 one-hot 特征,詞嵌入具有維度低,體現(xiàn)詞間關(guān)系等特點。但傳統(tǒng)的詞嵌入技術(shù)忽略了詞的微變形特性,如英語中動詞的第三人稱,進行時或過去時變形。針對這個問題, [2] 提出使用子詞來學習詞的表達,每個詞由內(nèi)部的 n-gram 字母串組成。在中文處理中, [7] 提出了類似的算法。
2. FastText 的實現(xiàn)
FastText 的子詞嵌入在 word2vec 的基礎(chǔ)上,引入了子詞這個因素,從而使得詞的微變形關(guān)系也能映射到嵌入空間中。
1. 子詞
在 fasttext 中,每個詞被看做是 n-gram 字母串包。為了區(qū)分前后綴情況,"<", ">" 符號被加到了詞的前后端。除了詞的子串外,詞本身也被包含進了 n-gram 字母串包。以 where 為例,$n=3$ 的情況下,其子串分別為
<wh, whe, her, ere, re>,以及其本身 <where>
注意,這里的 her 與單詞 <her> 是不同的。
現(xiàn)在我們看看 fasttext 具體是怎么計算子串的。
void Dictionary::initNgrams() {
for (size_t i = 0; i < size_; i++) {
// 加入前后綴符號 <, >
std::string word = BOW + words_[i].word + EOW;
// 詞本身作為特殊子串加入包中
words_[i].subwords.push_back(i);
// 計算并將詞子串加入包中
computeNgrams(word, words_[i].subwords);
}
}
// 計算詞子串
void Dictionary::computeNgrams(const std::string& word,
std::vector<int32_t>& ngrams,
std::vector<std::string>& substrings) const {
for (size_t i = 0; i < word.size(); i++) {
std::string ngram;
if ((word[i] & 0xC0) == 0x80) continue;
for (size_t j = i, n = 1; j < word.size() && n <= args_->maxn; n++) {
ngram.push_back(word[j++]);
// 將 utf-8 編碼數(shù)據(jù)部分推入 ngram
while (j < word.size() && (word[j] & 0xC0) == 0x80) {
ngram.push_back(word[j++]);
}
if (n >= args_->minn && !(n == 1 && (i == 0 || j == word.size()))) {
// fasttext 使用 open hash 進行 string->int 轉(zhuǎn)換
int32_t h = hash(ngram) % args_->bucket;
ngrams.push_back(nwords_ + h);
substrings.push_back(ngram);
}
}
}
}
在 fasttext 中,minn 和 maxn 參數(shù)控制了子串的長度,其默認值分別為 3, 6。再以 where 為例,在默認子串參數(shù)情況下,其子串包內(nèi)容為:
<wh, <whe, <wher, <where, whe, wher, where, where>, her, here, here>, ere, ere>, re> 以及自身 <where>
下面我們看 fasttext 是如何用子串來構(gòu)造詞嵌入的,這里注意每個子串也有自身的嵌入表示。
void Model::computeHidden(const std::vector<int32_t>& input, Vector& hidden) const {
assert(hidden.size() == hsz_);
hidden.zero();
for (auto it = input.cbegin(); it != input.cend(); ++it) {
if(quant_) {
// product quantization 優(yōu)化
// 子串加和
hidden.addRow(*qwi_, *it);
} else {
// 子串加和
hidden.addRow(*wi_, *it);
}
}
// 平均
hidden.mul(1.0 / input.size());
}
代碼非常簡明直接,每個詞是其子串嵌入表示的加和平均。
fasttext 的另一個優(yōu)勢,是學習未知詞的表達。以 working 為例,如果這個詞在學習文本中沒有出現(xiàn)過,但文本包含了 work, 和 ing,那么 working 的詞嵌入向量也能夠合理地計算出來。
2. skip-gram
一個詞的嵌入是其子串嵌入的加和平均。當這個嵌入表示計算出來后,后續(xù)步驟與 word2vec 的 skip-gram 相同。下面是 fasttext 的 skip-gram 實現(xiàn)代碼:
void FastText::skipgram(Model& model, real lr,
const std::vector<int32_t>& line) {
std::uniform_int_distribution<> uniform(1, args_->ws);
for (int32_t w = 0; w < line.size(); w++) {
// 窗口大小
int32_t boundary = uniform(model.rng);
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w]);
for (int32_t c = -boundary; c <= boundary; c++) {
if (c != 0 && w + c >= 0 && w + c < line.size()) {
// 通過 ngrams 計算 line[w] 的表示,通過邏輯回歸優(yōu)化 ngrams 的嵌入表示
model.update(ngrams, line[w + c], lr);
}
}
}
}
我們來詳細看看 Model::update 函數(shù)是怎么用邏輯回歸來優(yōu)化 ngrams 的嵌入表示
void Model::update(const std::vector<int32_t>& input, int32_t target, real lr) {
assert(target >= 0);
assert(target < osz_);
if (input.size() == 0) return;
// 子串加權(quán)平均得到詞嵌入表示
computeHidden(input, hidden_);
// 針對分類問題的 3 種處理
// 1. negative sampling 優(yōu)化
if (args_->loss == loss_name::ns) {
loss_ += negativeSampling(target, lr);
}
// 2. hierarchical softmax 優(yōu)化
else if (args_->loss == loss_name::hs) {
loss_ += hierarchicalSoftmax(target, lr);
}
// 3. 直接計算多分類邏輯回歸
// 在文本分類模式下使用
else {
loss_ += softmax(target, lr);
}
nexamples_ += 1;
// 邏輯回歸產(chǎn)生了導數(shù),在文本分類情況下導數(shù)向量需要除以子串數(shù)量
if (args_->model == model_name::sup) {
grad_.mul(1.0 / input.size());
}
// 將導數(shù)直接加到子串向量中
for (auto it = input.cbegin(); it != input.cend(); ++it) {
wi_->addRow(grad_, *it, 1.0);
}
}
在第二段,我們簡單介紹了針對大量多分類的優(yōu)化策略, negative sampling 和 hierarchical sampling。這里我們看看 fasttext 是怎樣實現(xiàn)這些策略。
// 根據(jù)詞頻構(gòu)構(gòu)建采樣表
void Model::initTableNegatives(const std::vector<int64_t>& counts) {
real z = 0.0;
for (size_t i = 0; i < counts.size(); i++) {
z += pow(counts[i], 0.5);
}
// 每個詞類在采樣表中數(shù)量與其詞頻開方成比例
for (size_t i = 0; i < counts.size(); i++) {
real c = pow(counts[i], 0.5);
for (size_t j = 0; j < c * NEGATIVE_TABLE_SIZE / z; j++) {
negatives.push_back(i);
}
}
std::shuffle(negatives.begin(), negatives.end(), rng);
}
// 負采樣
real Model::negativeSampling(int32_t target, real lr) {
real loss = 0.0;
grad_.zero();
// 負采樣 args_->neg 個類別
for (int32_t n = 0; n <= args_->neg; n++) {
if (n == 0) {
// 將當前詞作為正面例子對 target 類進行二分邏輯回歸訓練
loss += binaryLogistic(target, true, lr);
} else {
// 將當前詞作為負面例子對負采樣類進行二分邏輯回歸訓練
loss += binaryLogistic(getNegative(target), false, lr);
}
}
return loss;
}
// 負采樣一個類
int32_t Model::getNegative(int32_t target) {
int32_t negative;
// 輪詢采樣表
do {
negative = negatives[negpos];
negpos = (negpos + 1) % negatives.size();
} while (target == negative);
return negative;
}
下面我們看看 hierarchical softmax 的實現(xiàn)
// 根據(jù)詞頻構(gòu)造前綴樹,因為樹的內(nèi)部節(jié)點數(shù)量為 |V| - 1 所以可以用 |V| * 2 的數(shù)組存儲樹結(jié)構(gòu)
void Model::buildTree(const std::vector<int64_t>& counts) {
tree.resize(2 * osz_ - 1);
for (int32_t i = 0; i < 2 * osz_ - 1; i++) {
tree[i].parent = -1;
tree[i].left = -1;
tree[i].right = -1;
tree[i].count = 1e15;
tree[i].binary = false;
}
for (int32_t i = 0; i < osz_; i++) {
tree[i].count = counts[i];
}
int32_t leaf = osz_ - 1;
int32_t node = osz_;
for (int32_t i = osz_; i < 2 * osz_ - 1; i++) {
int32_t mini[2];
for (int32_t j = 0; j < 2; j++) {
if (leaf >= 0 && tree[leaf].count < tree[node].count) {
mini[j] = leaf--;
} else {
mini[j] = node++;
}
}
tree[i].left = mini[0];
tree[i].right = mini[1];
tree[i].count = tree[mini[0]].count + tree[mini[1]].count;
tree[mini[0]].parent = i;
tree[mini[1]].parent = i;
tree[mini[1]].binary = true;
}
for (int32_t i = 0; i < osz_; i++) {
std::vector<int32_t> path;
std::vector<bool> code;
int32_t j = i;
while (tree[j].parent != -1) {
path.push_back(tree[j].parent - osz_);
code.push_back(tree[j].binary);
j = tree[j].parent;
}
paths.push_back(path);
codes.push_back(code);
}
}
// 用 hierarchical softmax 進行多分類計算
real Model::hierarchicalSoftmax(int32_t target, real lr) {
real loss = 0.0;
grad_.zero();
// 詞的 0,1 表示
const std::vector<bool>& binaryCode = codes[target];
// 詞到樹根的路徑
const std::vector<int32_t>& pathToRoot = paths[target];
for (int32_t i = 0; i < pathToRoot.size(); i++) {
// 路徑上的節(jié)點對應(yīng)二分類, 0,1 編碼決定分類
// 用二分邏輯回歸進行訓練
loss += binaryLogistic(pathToRoot[i], binaryCode[i], lr);
}
return loss;
}
最后我們看一下二分邏輯回歸的實現(xiàn)。
// 用一個正/負面例子更新二分邏輯回歸模型
real Model::binaryLogistic(int32_t target, bool label, real lr) {
real score = sigmoid(wo_->dotRow(hidden_, target));
real alpha = lr * (real(label) - score);
grad_.addRow(*wo_, target, alpha);
wo_->addRow(hidden_, target, alpha);
if (label) {
return -log(score);
} else {
return -log(1.0 - score);
}
}
3. cbow
第二段講解了 cbow 的原理是用中心詞的上下文來預(yù)測中心詞, 這里我們看看 fasttext 是如何實現(xiàn) cbow 的。
void FastText::cbow(Model& model, real lr,
const std::vector<int32_t>& line) {
std::vector<int32_t> bow;
std::uniform_int_distribution<> uniform(1, args_->ws);
for (int32_t w = 0; w < line.size(); w++) {
int32_t boundary = uniform(model.rng);
bow.clear();
for (int32_t c = -boundary; c <= boundary; c++) {
// 將上下文的子字符串加入包中
if (c != 0 && w + c >= 0 && w + c < line.size()) {
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w + c]);
bow.insert(bow.end(), ngrams.cbegin(), ngrams.cend());
}
}
// 用所有子字符串來預(yù)測中心詞,從而更新參數(shù)
model.update(bow, line[w], lr);
}
}
fasttext 的處理非常簡潔,將上下文的子串全部加和平均作為輸入去預(yù)測中心詞。
3. 總結(jié)
fasttext 利用子詞改良了詞嵌入的質(zhì)量,在嵌入學習中考慮了詞的內(nèi)部結(jié)構(gòu)。在具體實現(xiàn)中, fasttext 用子詞向量的加和平均表示詞向量, 提供了 skip-gram 和 cbow 模式訓練詞嵌入表達。
四. FastText 線性文本分類優(yōu)化
1. 背景介紹
對于文本分類而言,線性分類器往往能夠達到非常優(yōu)秀,媲美深度模型的效果。通常,線性模型的參數(shù)數(shù)量與詞庫大小相關(guān),導致模型規(guī)模巨大。 fasttext 針對線性分類器模型進行了諸多優(yōu)化,在不顯著損失分類器精度的情況下,減少了內(nèi)存使用和計算時間。
2. fasttext 分類模型架構(gòu)
fasttext 的詞嵌入是通過分類學習完成的,所以詞嵌入和文本分類模型可以用下圖統(tǒng)一表示。
其中 $x_1, ..., x_N$ 表示一個文本中的 ngram 向量,一個文本的表示是所有 ngram 的加和平均。這和前文中提到的 cbow 相似,cbow 用上下文的 ngram 去預(yù)測中心詞,而此處用全部的 ngram 去預(yù)測指定類別。
與前文詞嵌入模型一樣, fasttext 模型在進行文本分類監(jiān)督訓練時,既學習詞嵌入表達,也學習分類器線性系數(shù)。
3. 優(yōu)化
1. 子空間量化
product quantization [8] 是一種保存數(shù)據(jù)間距離的壓縮技術(shù)。PQ 用一個碼本來近似數(shù)據(jù),與傳統(tǒng)的 keams 訓練碼本不同的是, PQ 將數(shù)據(jù)空間劃分為 k 個子空間,并分別用 kmeans 學習子空間碼本。數(shù)據(jù)的近似和重建均在子空間完成,最終拼接成結(jié)果。
在 fasttext 中,子空間碼本大小為 256,可以用 1 byte 表示。子空間的數(shù)量在 [2, d/2] 間取值。
除了用 PQ 對數(shù)據(jù)進行量化壓縮,fasttext 還提供了對分類系數(shù)的 PQ 量化選項。
PQ 的優(yōu)化能夠在不影響分類其表現(xiàn)的情況下,將分類模型壓縮為原大小的 $\frac{1}{10}$。
2. 裁剪字典內(nèi)容
fasttext 提供了一個誘導式裁剪字典的算法,保證裁剪后的字典內(nèi)容覆蓋了所有的文章。具體而言,fasttext 存有一個保留字典,并在線處理文章,如果新的文章沒有被保留字典涵蓋,則從該文章中提取一個 norm 最大的詞和其子串加入字典中。
字典裁剪能夠有效將模型的數(shù)量減少,甚至到原有的 $\frac{1}{100}$。
4. 總結(jié)
fasttext 利用 Product Quantization 對字典中的 詞嵌入向量進行了壓縮,并使用誘導式字典方法,構(gòu)造涵蓋全部文本的字典。兩者結(jié)合,能夠在不明顯損害分類算法表現(xiàn)的情況下,將分類模型大小減小數(shù)百倍 。
五. 實踐
本段我們對詞嵌入進行實踐。詞嵌入的比較有兩種方式,直接比較是驗證詞表達保存了人為標注的詞間關(guān)系,間接比較則是通過使用嵌入表達向量進行進一步學習,比如情緒預(yù)測[5],通過模型的表現(xiàn)判斷詞嵌入的質(zhì)量。
我們用中文維基數(shù)據(jù) [9] 進行了訓練,fasttext 的參數(shù)使用默認值, epoch 設(shè)置為50。此外 facebook 還提供了訓練好的多國語言表達 [10].
以下我們分別用 nn(最近鄰) 對中文維基訓練結(jié)果進行實踐:
// 乒乓球的近似詞匯
Query word? 乒乓球
壁球 0.837808
曲棍球 0.792717
網(wǎng)球 0.792332
排球 0.789665
手球 0.780589
田徑 0.780279
桌球 0.778775
舉重 0.776161
沙灘排球 0.775708
乒乓球隊 0.772797
// "男乒乓球" 并不存在, fasttext 仍然可以得到合理結(jié)果
Query word? 男乒乓球
乒乓球 0.767142
中國男子乒乓球隊 0.725763
兵乓球 0.691391
體操 0.688922
張怡寧 0.688578
陳若琳 0.681761
跳水隊 0.678217
打乒乓球 0.677547
王楠 0.671578
吳敏霞 0.669105
六. 總結(jié)
fasttext 是 facebook 開源的關(guān)于文本表達和文本分類的計算庫。fasttext 結(jié)合詞的子串信息計算詞表達,提高了對微變形詞匯的學習。針對文本分類模型, fasttext 使用了子空間量化和字典裁剪的策略,在不損失模型精度的情況下,將模型大小縮減數(shù)百倍。
在 fasttext 之上,可以做進一步優(yōu)化,一個方向是在文本分類模型中,文本表達用類似 tf-idf 的方式對詞進行加權(quán)平均。在字典裁剪算法上,一個涵蓋所有文本的詞并不一定有區(qū)分能力,比如 "the" 這個單詞,可以嘗試從保留區(qū)分能力的視角來保留字典。
七. 引用
[1] https://github.com/facebookresearch/fastText
[2] Bojanowski, Piotr and Grave, Edouard and Joulin, Armand and Mikolov, Tomas. "Enriching Word Vectors with Subword Information".
[3] Joulin, Armand and Grave, Edouard and Bojanowski, Piotr and Mikolov, Tomas. "Bag of Tricks for Efficient Text Classification".
[4]Joulin, Armand and Grave, Edouard and Bojanowski, Piotr and Douze, Matthijs and Jegou, Herve and Mikolov, Tomas. "FastText.zip: Compressing text classification models".
[5] Mikolov, Tomas, et al. "Efficient Estimation of Word Representations in Vector Space".
[6] Jeffrey Pennington, Richard Socher, and Christopher D. Manning. 2014. "GloVe: Global Vectors for Word Representation".
[7] Xinxiong Chen, Lei Xu, Zhiyuan Liu, Maosong Sun, and Huanbo Luan. 2015. Joint learning of character and word embeddings. In Proc. IJCAI.
[8] Product quantization for nearest neighbor search. H Jegou, M Douze, C Schmid. IEEE Transactions on Pattern Analysis and Machine Intelligence
[9] https://dumps.wikimedia.org/zhwiki/
[10] https://github.com/facebookresearch/fastText/blob/master/pretrained-vectors.md