文字分類單層網路就夠了。非線性的問題用多層的。
fasttext有一個有監督的模式,但是模型等同於cbow,只是target變成了label而不是word。
fastText有兩個可說的地方:1 在word2vec的基礎上, 把Ngrams也當做詞訓練word2vec模型, 最終每個詞的vector將由這個詞的Ngrams得出. 這個改進能提升模型對morphology的效果, 即"字面上"相似的詞語distance也會小一些. 有人在question-words資料集上跑過fastText和gensim-word2vec的對比, 結果在 Jupyter Notebook Viewer .可以看出fastText在"adjective-to-adverb", "opposite"之類的資料集上效果還是相當好的. 不過像"family"這樣的字面上不一樣的資料集, fastText效果反而不如gensim-word2vec.推廣到中文上, 結果也類似. "字面上"相似對vector的影響非常大. 一個簡單的例子是, gensim訓練的模型中與"交易"最相似的是"買賣", 而fastText的結果是"交易法".2 用CBOW的思路來做分類, 親測下來訓練速度和準確率都挺不錯(也許是我的資料比較適合). 尤其是訓練速度, 快得嚇人.
在比賽中用了fasttext,發現速度驚人,而且記憶體優化比較好,用tensorflow搭建3層模型,記憶體超52g了,但是fasttext訓練卻非常快,文字分類準確率都還可以,只是為什麼loss這麼高
分完詞,使用facebook開源工具fasttext試試,效果超讚。如果你自己做的話,tfidf其實對於兩三句話的短評可效果還是可以的。
要是資料量不夠的話 可以直接嵌入一些規則來做,這裡是我總結的一篇基於規則的情感分析;短文字情感分析 - Forever-守望 - 部落格頻道 - CSDN.NET要是資料量很大的話,可以參考word2vec的思路,使用更復雜的分類器,我用卷積神經網路實現了一個基於大規模短文字的分類問題CNN在中文文字分類的應用 - Forever-守望 - 部落格頻道 - CSDN.NET
不久前為某諮詢公司針對某行業做過一個在twitter上的情感分析專案。題主的資料比較好的一點是評論已經按維度劃分好,免去了自建分類器來劃分維度的步驟,而這一點對為客戶創造價值往往相當重要。情感分析一般是個分類或者預測問題,首先需要定義情感的scale,通常的做法是polarity,直接可以使用把問題簡化為分類模型,如果題主的資料不是簡單的兩極,而是類似於1-5分的評分模式,則可以考慮把問題建模成預測模型以儲存不同level之間的邏輯關係。分類模型需一定量的標註資料進行訓練,如果題主資料量比較小的話,像肖凱提到的,可以去尋找類似的標註好的文字資料,當然最好是酒店和汽車行業的。如果沒有現成標註,在預算之內可以使用像AMT這樣的服務進行標註。接著是特徵的抽取,對於短文字特徵確實比較少,可以參考像微博這種短文字的分析,用什麼方法提取特徵比較好呢? - 文字挖掘 劉知遠老師的回答,使用主題模型擴充特徵選擇。不過對於一個諮詢專案來講,情感分析的結論是對於某一維度評論集合的情感分析,本身已經很多工作要做,根據80/20原則,我覺得沒有必要花費大量時間熟悉並應用主題模型。可以考慮的特徵有1. 詞袋模型,固定使用詞典或者高頻詞加人工選擇一些作為特徵;2. 文字長度;3. 正面詞佔比;4. 負面詞佔比;5. 表強調或疑問語氣的標點等等,題主可以多閱讀一些評論,從中找到一些其他特徵。在選取完特徵之後,使用主成分分析重新選取出新的特徵組合,最好不要超過15個防止過擬合或者curse of dimensionality。在選取模型時,考慮使用對過擬合抵抗性強的模型,經驗來講,linear SVR或Random Forest Regression效果會好一些,但是題主可以把所有常用的預測模型都跑一邊看哪個模型比較好。以上是在假定只有文字資料的情況下的一個可行的方案,如果資料是社交網路資料,可以考慮使用網路模型中心度等對不同評論的重要性加權。結果的展示方面,最好能夠展示出正負情感的佔比,作為平均情感分數的補充。同時,按照不同維度顯示情感,並且顯示情感隨時間的變化也比較重要。
fastText 方法包含三部分:模型架構、Softmax 和 N-gram 特徵。下面我們一一介紹。
fastText 模型架構和 Word2Vec 中的 CBOW 模型很類似。不同之處在於,fastText 預測標籤,而 CBOW 模型預測中間詞。
Softmax建立在哈弗曼編碼的基礎上,對標籤進行編碼,能夠極大地縮小模型預測目標的數量。
常用的特徵是詞袋模型。但詞袋模型不能考慮詞之間的順序,因此 fastText 還加入了 N-gram 特徵。“我 愛 她” 這句話中的詞袋模型特徵是 “我”,“愛”, “她”。這些特徵和句子 “她 愛 我” 的特徵是一樣的。如果加入 2-Ngram,第一句話的特徵還有 “我-愛” 和 “愛-她”,這兩句話 “我 愛 她” 和 “她 愛 我” 就能區別開來了。當然啦,為了提高效率,我們需要過濾掉低頻的 N-gram。
fastText 的詞嵌入學習能夠考慮 english-born 和 british-born 之間有相同的字尾,但 word2vec 卻不能。
fastText還能在五分鐘內將50萬個句子分成超過30萬個類別。
支援多語言表達:利用其語言形態結構,fastText能夠被設計用來支援包括英語、德語、西班牙語、法語以及捷克語等多種語言。
FastText的效能要比時下流行的word2vec工具明顯好上不少,也比其他目前最先進的詞態詞彙表徵要好。
FastText= word2vec中 cbow + h-softmax的靈活使用
靈活體現在兩個方面:
1. 模型的輸出層:word2vec的輸出層,對應的是每一個term,計算某term的概率最大;而fasttext的輸出層對應的是 分類的label。不過不管輸出層對應的是什麼內容,起對應的vector都不會被保留和使用;
2. 模型的輸入層:word2vec的輸出層,是 context window 內的term;而fasttext 對應的整個sentence的內容,包括term,也包括 n-gram的內容;
兩者本質的不同,體現在 h-softmax的使用。
Wordvec的目的是得到詞向量,該詞向量 最終是在輸入層得到,輸出層對應的 h-softmax 也會生成一系列的向量,但最終都被拋棄,不會使用。
fasttext則充分利用了h-softmax的分類功能,遍歷分類樹的所有葉節點,找到概率最大的label(一個或者N個)
facebook公開了90種語言的Pre-trained word vectors
https://github.com/facebookresearch/fastText/blob/master/pretrained-vectors.md
可怕的facebook,用fasttext進行訓練,使用預設引數,300維度
與word2vec的區別
這個模型與word2vec有很多相似的地方,也有很多不相似的地方。相似地方讓這兩種演算法不同的地方讓這兩
相似的地方:
圖模型結構很像,都是採用embedding向量的形式,得到word的隱向量表達。
都採用很多相似的優化方法,比如使用Hierarchical softmax優化訓練和預測中的打分速度。
不同的地方:
word2vec是一個無監督演算法,而fasttext是一個有監督演算法。word2vec的學習目標是skip的word,而fasttext的學習目標是人工標註的分類結果。
word2vec要求訓練樣本帶有“序”的屬性,而fasttext使用的是bag of words的思想,使用的是n-gram的無序屬性。
fasttext只有1層神經網路,屬於所謂的shallow learning,但是fasttext的效果並不差,而且具備學習和預測速度快的優勢,在工業界這點非常重要。比一般的神經網路模型的精確度還要高。
Please cite 1 if using this code for learning word representations or 2 if using for text classification.
1. Enriching Word Vectors with Subword Information
2. Bag of Tricks for Efficient Text Classification
FastText其實包含兩部分。一個是word2vec優化版,用了Subword的資訊,速度是不會提升的,只是效果方面的改進,對於中文貌似完全沒用。另外一塊是文字分類的Trick,結論就是對這種簡單的任務,用簡單的模型效果就不錯了。具體方法就是把句子每個word的vec求平均,然後直接用簡單的LR分類就行。FastText的Fast指的是這個。https://www.zhihu.com/question/48345431/answer/111513229 這個知乎答案總結得挺好的,取平均其實算DL的average pooling,呵呵。
最近在一個專案裡使用了fasttext[1], 這是facebook今年開源的一個詞向量與文字分類工具,在學術上沒有什麼創新點,但是好處就是模型簡單,訓練速度又非常快。我在最近的一個專案裡嘗試了一下,發現用起來真的很順手,做出來的結果也可以達到上線使用的標準。
其實fasttext使用的模型與word2vec的模型在結構上是一樣的,拿cbow來說,不同的只是在於word2vec cbow的目標是通過當前詞的前後N個詞來預測當前詞,在使用層次softmax的時候,huffman樹葉子節點處是訓練語料裡所有詞的向量。
而fasttext在進行文字分類時,huffmax樹葉子節點處是每一個類別標籤的詞向量,在訓練的過程中,訓練語料的每一個詞也會得到對應的詞向量,輸入為一個window內的詞對應的詞向量,hidden layer為這幾個詞的線性相加,相加的結果作為該文件的向量,再通過層次softmax得到預測標籤,結合文件的真實標籤計算loss,梯度與迭代更新詞向量。
fasttext有別於word2vec的另一點是加了ngram切分這個trick,將長詞再通過ngram切分為幾個短詞,這樣對於未登入詞也可以通過切出來的ngram詞向量合併為一個詞。由於中文的詞大多比較短,這對英文語料的用處會比中文語料更大。
此外,fasttext相比deep learning模型的優點是訓練速度極快。我們目前使用fasttext來進行客戶填寫的訂單地址到鎮這一級別的分類。每一個省份建立一個模型,每個模型要分的類別都有1000多類,200萬左右的訓練資料,12個執行緒1分鐘不到就可以訓練完成,最終的分類準確率與模型魯棒性都比較高(區縣級別分類正確準確率高於99.5%, 鎮級別高於98%),尤其是對縮寫地名,或者漏寫了市級行政區、區縣級行政區的情況也都可以正確處理。
引數方面
-
loss function選用hs(hierarchical softmax)要比ns(negative sampling) 訓練速度要快很多倍,並且準確率也更高。
-
wordNgrams 預設為1,設定為2以上可以明顯提高準確率。
-
如果詞數不是很多,可以把bucket設定的小一點,否則預留會預留太多bucket使模型太大。
因為facebook提供的只是C++版本的程式碼,原本還以為要自己封裝一個Python介面,結果上github一搜已經有封裝的python介面了[2]。用起來特別方便,覺得還不能滿足自己的使用要求,修改原始碼也非常方便。
對於同樣的文字分類問題,後來還用單向LSTM做了一遍,輸入pre-trained的embedding詞向量,並且在訓練的時候fine-tune,與fasttext對比,即使使用了GTX 980的GPU,訓練速度還是要慢很多,並且,準確準確率和fasttext是差不多的。
所以對於文字分類,先用fasttext做一個簡單的baseline是很適合的。
fastText 原始碼分析
介紹
fastText 是 facebook 近期開源的一個詞向量計算以及文字分類工具,該工具的理論基礎是以下兩篇論文:
這篇論文提出了用 word n-gram 的向量之和來代替簡單的詞向量的方法,以解決簡單 word2vec 無法處理同一詞的不同形態的問題。fastText 中提供了 maxn 這個引數來確定 word n-gram 的 n 的大小。
這篇論文提出了 fastText 演算法,該演算法實際上是將目前用來算 word2vec 的網路架構做了個小修改,原先使用一個詞的上下文的所有詞向量之和來預測詞本身(CBOW 模型),現在改為用一段短文字的詞向量之和來對文字進行分類。
在我看來,fastText 的價值是提供了一個 更具可讀性,模組化程度較好 的 word2vec 的實現,附帶一些新的分類功能,本文詳細分析它的原始碼。
頂層結構
fastText 的程式碼結構以及各模組的功能如下圖所示:
分析各模組時,我只會解釋該模組的 主要呼叫路徑 下的原始碼,以 註釋 的方式說明,其它的功能性程式碼請大家自行閱讀。如果對 word2vec 的理論和相關術語不瞭解,請先閱讀這篇 word2vec 中的數學原理詳解。
訓練資料格式
訓練資料格式為一行一個句子,每個詞用空格分割,如果一個詞帶有字首“__label__
”,那麼它就作為一個類標籤,在文字分類時使用,這個字首可以通過-label
引數自定義。訓練檔案支援 UTF-8 格式。
fasttext 模組
fasttext 是最頂層的模組,它的主要功能是訓練
和預測
,首先是訓練
功能的呼叫路徑,第一個函式是 train
,它的主要作用是 初始化引數,啟動多執行緒訓練,請大家留意原始碼中的相關部分。
void FastText::train(std::shared_ptr<Args> args) {
args_ = args;
dict_ = std::make_shared<Dictionary>(args_);
std::ifstream ifs(args_->input);
if (!ifs.is_open()) {
std::cerr << "Input file cannot be opened!" << std::endl;
exit(EXIT_FAILURE);
}
// 根據輸入檔案初始化詞典
dict_->readFromFile(ifs);
ifs.close();
// 初始化輸入層, 對於普通 word2vec,輸入層就是一個詞向量的查詢表,
// 所以它的大小為 nwords 行,dim 列(dim 為詞向量的長度),但是 fastText 用了
// word n-gram 作為輸入,所以輸入矩陣的大小為 (nwords + ngram 種類) * dim
// 程式碼中,所有 word n-gram 都被 hash 到固定數目的 bucket 中,所以輸入矩陣的大小為
// (nwords + bucket 個數) * dim
input_ = std::make_shared<Matrix>(dict_->nwords()+args_->bucket, args_->dim);
// 初始化輸出層,輸出層無論是用負取樣,層次 softmax,還是普通 softmax,
// 對於每種可能的輸出,都有一個 dim 維的引數向量與之對應
// 當 args_->model == model_name::sup 時,訓練分類器,
// 所以輸出的種類是標籤總數 dict_->nlabels()
if (args_->model == model_name::sup) {
output_ = std::make_shared<Matrix>(dict_->nlabels(), args_->dim);
} else {
// 否則訓練的是詞向量,輸出種類就是詞的種類 dict_->nwords()
output_ = std::make_shared<Matrix>(dict_->nwords(), args_->dim);
}
input_->uniform(1.0 / args_->dim);
output_->zero();
start = clock();
tokenCount = 0;
// 庫採用 C++ 標準庫的 thread 來實現多執行緒
std::vector<std::thread> threads;
for (int32_t i = 0; i < args_->thread; i++) {
// 實際的訓練發生在 trainThread 中
threads.push_back(std::thread([=]() { trainThread(i); }));
}
for (auto it = threads.begin(); it != threads.end(); ++it) {
it->join();
}
// Model 的所有引數(input_, output_)是在初始化時由外界提供的,
// 此時 input_ 和 output_ 已經處於訓練結束的狀態
model_ = std::make_shared<Model>(input_, output_, args_, 0);
saveModel();
if (args_->model != model_name::sup) {
saveVectors();
}
}
下面,我們進入 trainThread
函式,看看訓練的主體邏輯,該函式的主要工作是 實現了標準的隨機梯度下降,並隨著訓練的進行逐步降低學習率。
void FastText::trainThread(int32_t threadId) {
std::ifstream ifs(args_->input);
// 根據執行緒數,將訓練檔案按照總位元組數(utils::size)均分成多個部分
// 這麼做的一個後果是,每一部分的第一個詞有可能從中間被切斷,
// 這樣的"小噪音"對於整體的訓練結果無影響
utils::seek(ifs, threadId * utils::size(ifs) / args_->thread);
Model model(input_, output_, args_, threadId);
if (args_->model == model_name::sup) {
model.setTargetCounts(dict_->getCounts(entry_type::label));
} else {
model.setTargetCounts(dict_->getCounts(entry_type::word));
}
// 訓練檔案中的 token 總數
const int64_t ntokens = dict_->ntokens();
// 當前執行緒處理完畢的 token 總數
int64_t localTokenCount = 0;
std::vector<int32_t> line, labels;
// tokenCount 為所有執行緒處理完畢的 token 總數
// 當處理了 args_->epoch 遍所有 token 後,訓練結束
while (tokenCount < args_->epoch * ntokens) {
// progress = 0 ~ 1,代表當前訓練程式,隨著訓練的進行逐漸增大
real progress = real(tokenCount) / (args_->epoch * ntokens);
// 學習率根據 progress 線性下降
real lr = args_->lr * (1.0 - progress);
localTokenCount += dict_->getLine(ifs, line, labels, model.rng);
// 根據訓練需求的不同,這裡用的更新策略也不同,它們分別是:
// 1. 有監督學習(分類)
if (args_->model == model_name::sup) {
dict_->addNgrams(line, args_->wordNgrams);
supervised(model, lr, line, labels);
// 2. word2vec (CBOW)
} else if (args_->model == model_name::cbow) {
cbow(model, lr, line);
// 3. word2vec (SKIPGRAM)
} else if (args_->model == model_name::sg) {
skipgram(model, lr, line);
}
// args_->lrUpdateRate 是每個執行緒學習率的變化率,預設為 100,
// 它的作用是,每處理一定的行數,再更新全域性的 tokenCount 變數,從而影響學習率
if (localTokenCount > args_->lrUpdateRate) {
tokenCount += localTokenCount;
// 每次更新 tokenCount 後,重置計數
localTokenCount = 0;
// 0 號執行緒負責將訓練進度輸出到螢幕
if (threadId == 0) {
printInfo(progress, model.getLoss());
}
}
}
if (threadId == 0) {
printInfo(1.0, model.getLoss());
std::cout << std::endl;
}
ifs.close();
}
一哄而上的並行訓練:每個訓練執行緒在更新引數時並沒有加鎖,這會給引數更新帶來一些噪音,但是不會影響最終的結果。無論是 google 的 word2vec 實現,還是 fastText 庫,都沒有加鎖。
從 trainThread
函式中我們發現,實際的模型更新策略發生在 supervised
,cbow
,skipgram
三個函式中,這三個函式都呼叫同一個 model.update
函式來更新引數,這個函式屬於 model 模組,但在這裡我先簡單介紹它,以方便大家理解程式碼。
update 函式的原型為
void Model::update(const std::vector<int32_t>& input, int32_t target, real lr)
該函式有三個引數,分別是“輸入”,“類標籤”,“學習率”。
- 輸入是一個
int32_t
陣列,每個元素代表一個詞在 dictionary 裡的 ID。對於分類問題,這個陣列代表輸入的短文字,對於 word2vec,這個陣列代表一個詞的上下文。 - 類標籤是一個
int32_t
變數。對於 word2vec 來說,它就是帶預測的詞的 ID,對於分類問題,它就是類的 label 在 dictionary 裡的 ID。因為 label 和詞在詞表裡一起存放,所以有統一的 ID 體系。
下面,我們回到 fasttext 模組的三個更新函式:
void FastText::supervised(Model& model, real lr,
const std::vector<int32_t>& line,
const std::vector<int32_t>& labels) {
if (labels.size() == 0 || line.size() == 0) return;
// 因為一個句子可以打上多個 label,但是 fastText 的架構實際上只有支援一個 label
// 所以這裡隨機選擇一個 label 來更新模型,這樣做會讓其它 label 被忽略
// 所以 fastText 不太適合做多標籤的分類
std::uniform_int_distribution<> uniform(0, labels.size() - 1);
int32_t i = uniform(model.rng);
model.update(line, labels[i], lr);
}
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);
// 在一個句子中,每個詞可以進行一次 update
for (int32_t w = 0; w < line.size(); w++) {
// 一個詞的上下文長度是隨機產生的
int32_t boundary = uniform(model.rng);
bow.clear();
// 以當前詞為中心,將左右 boundary 個詞加入 input
for (int32_t c = -boundary; c <= boundary; c++) {
// 當然,不能陣列越界
if (c != 0 && w + c >= 0 && w + c < line.size()) {
// 實際被加入 input 的不止是詞本身,還有詞的 word n-gram
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w + c]);
bow.insert(bow.end(), ngrams.cbegin(), ngrams.cend());
}
}
// 完成一次 CBOW 更新
model.update(bow, line[w], lr);
}
}
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);
// 採用詞+word n-gram 來預測這個詞的上下文的所有的詞
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w]);
// 在 skipgram 中,對上下文的每一個詞分別更新一次模型
for (int32_t c = -boundary; c <= boundary; c++) {
if (c != 0 && w + c >= 0 && w + c < line.size()) {
model.update(ngrams, line[w + c], lr);
}
}
}
}
訓練部分的程式碼已經分析完畢,預測部分的程式碼就簡單多了,它的主要邏輯都在 model.predict
函式裡。
void FastText::predict(const std::string& filename, int32_t k, bool print_prob) {
std::vector<int32_t> line, labels;
std::ifstream ifs(filename);
if (!ifs.is_open()) {
std::cerr << "Test file cannot be opened!" << std::endl;
exit(EXIT_FAILURE);
}
while (ifs.peek() != EOF) {
// 讀取輸入檔案的每一行
dict_->getLine(ifs, line, labels, model_->rng);
// 將一個詞的 n-gram 加入詞表,用於處理未登入詞。(即便一個詞不在詞表裡,我們也可以用它的 word n-gram 來預測一個結果)
dict_->addNgrams(line, args_->wordNgrams);
if (line.empty()) {
std::cout << "n/a" << std::endl;
continue;
}
std::vector<std::pair<real, int32_t>> predictions;
// 呼叫 model 模組的預測介面,獲取 k 個最可能的分類
model_->predict(line, k, predictions);
// 輸出結果
for (auto it = predictions.cbegin(); it != predictions.cend(); it++) {
if (it != predictions.cbegin()) {
std::cout << ' ';
}
std::cout << dict_->getLabel(it->second);
if (print_prob) {
std::cout << ' ' << exp(it->first);
}
}
std::cout << std::endl;
}
ifs.close();
}
通過對 fasttext 模組的分析,我們發現它最核心的預測和更新邏輯都在 model 模組中,接下來,我們進入 model 模組一探究竟。
model 模組
model 模組對外提供的服務可以分為 update
和 predict
兩類,下面我們分別對它們進行分析。由於這裡的引數較多,我們先以圖示標明各個引數在模型中所處的位置,以免各位混淆。
圖中所有變數的名字全部與 model 模組中的名字保持一致,注意到 wo_
矩陣在不同的輸出層結構中扮演著不同的角色。
update
update
函式的作用已經在前面介紹過,下面我們看一下它的實現:
void Model::update(const std::vector<int32_t>& input, int32_t target, real lr) {
// target 必須在合法範圍內
assert(target >= 0);
assert(target < osz_);
if (input.size() == 0) return;
// 計算前向傳播:輸入層 -> 隱層
hidden_.zero();
for (auto it = input.cbegin(); it != input.cend(); ++it) {
// hidden_ 向量儲存輸入詞向量的均值,
// addRow 的作用是將 wi_ 矩陣的第 *it 列加到 hidden_ 上
hidden_.addRow(*wi_, *it);
}
// 求和後除以輸入詞個數,得到均值向量
hidden_.mul(1.0 / input.size());
// 根據輸出層的不同結構,呼叫不同的函式,在各個函式中,
// 不僅通過前向傳播算出了 loss_,還進行了反向傳播,計算出了 grad_,後面逐一分析。
// 1. 負取樣
if (args_->loss == loss_name::ns) {
loss_ += negativeSampling(target, lr);
} else if (args_->loss == loss_name::hs) {
// 2. 層次 softmax
loss_ += hierarchicalSoftmax(target, lr);
} else {
// 3. 普通 softmax
loss_ += softmax(target, lr);
}
nexamples_ += 1;
// 如果是在訓練分類器,就將 grad_ 除以 input_ 的大小
// 原因不明
if (args_->model == model_name::sup) {
grad_.mul(1.0 / input.size());
}
// 反向傳播,將 hidden_ 上的梯度傳播到 wi_ 上的對應行
for (auto it = input.cbegin(); it != input.cend(); ++it) {
wi_->addRow(grad_, *it, 1.0);
}
}
下面我們看看三種輸出層對應的更新函式:negativeSampling
,hierarchicalSoftmax
,softmax
。
model 模組中最有意思的部分就是將層次 softmax 和負取樣統一抽象成多個二元 logistic regression 計算。
如果使用負取樣,訓練時每次選擇一個正樣本,隨機取樣幾個負樣本,每種輸出都對應一個引數向量,儲存於 wo_
的各行。對所有樣本的引數更新,都是一次獨立的 LR 引數更新。
如果使用層次 softmax,對於每個目標詞,都可以在構建好的霍夫曼樹上確定一條從根節點到葉節點的路徑,路徑上的每個非葉節點都是一個 LR,引數儲存在 wo_
的各行上,訓練時,這條路徑上的 LR 各自獨立進行引數更新。
無論是負取樣還是層次 softmax,在神經網路的計算圖中,所有 LR 都會依賴於 hidden_
的值,所以 hidden_
的梯度 grad_
是各個 LR 的反向傳播的梯度的累加。
LR 的程式碼如下:
real Model::binaryLogistic(int32_t target, bool label, real lr) {
// 將 hidden_ 和引數矩陣的第 target 行做內積,並計算 sigmoid
real score = utils::sigmoid(wo_->dotRow(hidden_, target));
// 計算梯度時的中間變數
real alpha = lr * (real(label) - score);
// Loss 對於 hidden_ 的梯度累加到 grad_ 上
grad_.addRow(*wo_, target, alpha);
// Loss 對於 LR 引數的梯度累加到 wo_ 的對應行上
wo_->addRow(hidden_, target, alpha);
// LR 的 Loss
if (label) {
return -utils::log(score);
} else {
return -utils::log(1.0 - score);
}
}
經過以上的分析,下面三種邏輯就比較容易理解了:
real Model::negativeSampling(int32_t target, real lr) {
real loss = 0.0;
grad_.zero();
for (int32_t n = 0; n <= args_->neg; n++) {
// 對於正樣本和負樣本,分別更新 LR
if (n == 0) {
loss += binaryLogistic(target, true, lr);
} else {
loss += binaryLogistic(getNegative(target), false, lr);
}
}
return loss;
}
real Model::hierarchicalSoftmax(int32_t target, real lr) {
real loss = 0.0;
grad_.zero();
// 先確定霍夫曼樹上的路徑
const std::vector<bool>& binaryCode = codes[target];
const std::vector<int32_t>& pathToRoot = paths[target];
// 分別對路徑上的中間節點做 LR
for (int32_t i = 0; i < pathToRoot.size(); i++) {
loss += binaryLogistic(pathToRoot[i], binaryCode[i], lr);
}
return loss;
}
// 普通 softmax 的引數更新
real Model::softmax(int32_t target, real lr) {
grad_.zero();
computeOutputSoftmax();
for (int32_t i = 0; i < osz_; i++) {
real label = (i == target) ? 1.0 : 0.0;
real alpha = lr * (label - output_[i]);
grad_.addRow(*wo_, i, alpha);
wo_->addRow(hidden_, i, alpha);
}
return -utils::log(output_[target]);
}
predict
predict 函式可以用於給輸入資料打上 1 ~ K 個類標籤,並輸出各個類標籤對應的概率值,對於層次 softmax,我們需要遍歷霍夫曼樹,找到 top-K 的結果,對於普通 softmax(包括負取樣和 softmax 的輸出),我們需要遍歷結果陣列,找到 top-K。
void Model::predict(const std::vector<int32_t>& input, int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
assert(k > 0);
heap.reserve(k + 1);
// 計算 hidden_
computeHidden(input);
// 如果是層次 softmax,使用 dfs 遍歷霍夫曼樹的所有葉子節點,找到 top-k 的概率
if (args_->loss == loss_name::hs) {
dfs(k, 2 * osz_ - 2, 0.0, heap);
} else {
// 如果是普通 softmax,在結果陣列裡找到 top-k
findKBest(k, heap);
}
// 對結果進行排序後輸出
// 因為 heap 中雖然一定是 top-k,但並沒有排好序
std::sort_heap(heap.begin(), heap.end(), comparePairs);
}
void Model::findKBest(int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
// 計算結果陣列
computeOutputSoftmax();
for (int32_t i = 0; i < osz_; i++) {
if (heap.size() == k && utils::log(output_[i]) < heap.front().first) {
continue;
}
// 使用一個堆來儲存 top-k 的結果,這是算 top-k 的標準做法
heap.push_back(std::make_pair(utils::log(output_[i]), i));
std::push_heap(heap.begin(), heap.end(), comparePairs);
if (heap.size() > k) {
std::pop_heap(heap.begin(), heap.end(), comparePairs);
heap.pop_back();
}
}
}
void Model::dfs(int32_t k, int32_t node, real score, std::vector<std::pair<real, int32_t>>& heap) {
if (heap.size() == k && score < heap.front().first) {
return;
}
if (tree[node].left == -1 && tree[node].right == -1) {
// 只輸出葉子節點的結果
heap.push_back(std::make_pair(score, node));
std::push_heap(heap.begin(), heap.end(), comparePairs);
if (heap.size() > k) {
std::pop_heap(heap.begin(), heap.end(), comparePairs);
heap.pop_back();
}
return;
}
// 將 score 累加後遞迴向下收集結果
real f = utils::sigmoid(wo_->dotRow(hidden_, node - osz_));
dfs(k, tree[node].left, score + utils::log(1.0 - f), heap);
dfs(k, tree[node].right, score + utils::log(f), heap);
}
其它模組
除了以上兩個模組,dictionary 模組也相當重要,它完成了訓練檔案載入,雜湊表構建,word n-gram 計算等功能,但是並沒有太多演算法在裡面。
其它模組例如 Matrix, Vector 也只是封裝了簡單的矩陣向量操作,這裡不再做詳細分析。
附錄:構建霍夫曼樹演算法分析
在學資訊理論的時候接觸過構建 Huffman 樹的演算法,課本中的方法描述往往是:
找到當前權重最小的兩個子樹,將它們合併
演算法的效能取決於如何實現這個邏輯。網上的很多實現都是在新增節點都時遍歷一次當前所有的樹,這種演算法的複雜度是 O(n2)O(n2),效能很差。
聰明一點的方法是用一個優先順序佇列來儲存當前所有的樹,每次取 top 2,合併,加回佇列。這個演算法的複雜度是 O(nlogn)O(nlogn),缺點是必需使用額外的資料結構,而且進堆出堆的操作導致常數項較大。
word2vec 以及 fastText 都採用了一種更好的方法,時間複雜度是 O(nlogn)O(nlogn),只用了一次排序,一次遍歷,簡潔優美,但是要理解它需要進行一些推理。
演算法如下:
void Model::buildTree(const std::vector<int64_t>& counts) {
// 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];
}
// leaf 指向當前未處理的葉子節點的最後一個,也就是權值最小的葉子節點
int32_t leaf = osz_ - 1;
// node 指向當前未處理的非葉子節點的第一個,也是權值最小的非葉子節點
int32_t node = osz_;
// 逐個構造所有非葉子節點(i >= osz_, i < 2 * osz - 1)
for (int32_t i = osz_; i < 2 * osz_ - 1; i++) {
// 最小的兩個節點的下標
int32_t mini[2];
// 計算權值最小的兩個節點,候選只可能是 leaf, leaf - 1,
// 以及 node, node + 1
for (int32_t j = 0; j < 2; j++) {
// 從這四個候選裡找到 top-2
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);
}
}
演算法首先對輸入的葉子節點進行一次排序(O(nlogn)O(nlogn) ),然後確定兩個下標 leaf
和 node
,leaf
總是指向當前最小的葉子節點,node
總是指向當前最小的非葉子節點,所以,最小的兩個節點可以從 leaf, leaf - 1, node, node + 1 四個位置中取得,時間複雜度 O(1)O(1),每個非葉子節點都進行一次,所以總複雜度為 O(n)O(n),演算法整體複雜度為 O(nlogn)O(nlogn)。