Alink漫談(十七) :Word2Vec原始碼分析 之 迭代訓練
0x00 摘要
Alink 是阿里巴巴基於實時計算引擎 Flink 研發的新一代機器學習演算法平臺,是業界首個同時支援批式演算法、流式演算法的機器學習平臺。本文和上文將帶領大家來分析Alink中 Word2Vec 的實現。
因為Alink的公開資料太少,所以以下均為自行揣測,肯定會有疏漏錯誤,希望大家指出,我會隨時更新。
0x01 前文回顧
從前文 Alink漫談(十六) :Word2Vec之建立霍夫曼樹 我們瞭解了Word2Vec的概念、在Alink中的整體架構以及完成對輸入的處理,以及詞典、二叉樹的建立。
此時我們已經有了一個已經構造好的Huffman樹,以及初始化完畢的各個向量,可以開始輸入文字來進行訓練了。
1.1 上文總體流程圖
先給出一個上文總體流程圖:
1.2 回顧霍夫曼樹
1.2.1 變數定義
現在定義變數如下:
- n : 一個詞的上下文包含的詞數,與n-gram中n的含義相同
- m : 詞向量的長度,通常在10~100
- h : 隱藏層的規模,一般在100量級
- N :詞典的規模,通常在1W~10W
- T : 訓練文字中單詞個數
1.2.2 為何要引入霍夫曼樹
word2vec也使用了CBOW與Skip-Gram來訓練模型與得到詞向量,但是並沒有使用傳統的DNN模型。最先優化使用的資料結構是用霍夫曼樹來代替隱藏層和輸出層的神經元,霍夫曼樹的葉子節點起到輸出層神經元的作用,葉子節點的個數即為詞彙表的小大。 而內部節點則起到隱藏層神經元的作用。
以CBOW為例,輸入層為n-1個單詞的詞向量,長度為m(n-1),隱藏層的規模為h,輸出層的規模為N。那麼前向的時間複雜度就是o(m(n-1)h+hN) = o(hN) 這還是處理一個詞所需要的複雜度。如果要處理所有文字,則需要o(hNT)的時間複雜度。這個是不可接受的。
同時我們也注意到,o(hNT)之中,h和T的值相對固定,想要對其進行優化,主要還是應該從N入手。而輸出層的規模之所以為N,是因為這個神經網路要完成的是N選1的任務。那麼可不可以減小N的值呢?答案是可以的。解決的思路就是將一次分類分解為多次分類,這也是Hierarchical Softmax的核心思想。
舉個例子,有[1,2,3,4,5,6,7,8]這8個分類,想要判斷詞A屬於哪個分類,我們可以一步步來,首先判斷A是屬於[1,2,3,4]還是屬於[5,6,7,8]。如果判斷出屬於[1,2,3,4],那麼就進一步分析是屬於[1,2]還是[3,4],以此類推。這樣一來,就把單個詞的時間複雜度從o(hN)降為o(hlogN),更重要的減少了記憶體的開銷。
從輸入到輸出,中間是一個樹形結構,其中的每一個節點都完成一個二分類(logistic分類)問題。那麼就存在一個如何構建樹的問題。這裡採用huffman樹,因為這樣構建的話,出現頻率越高的詞所經過的路徑越短,從而使得所有單詞的平均路徑長度達到最短。
假設 足球 的路徑 1001,那麼當要輸出 足球 這個詞的時候,這個模型其實並不是直接輸出 "1001" 這條路徑,而是在每一個節點都進行一次二分類。這樣相當於將最後輸出的二叉樹變成多個二分類的任務。而路徑中的每個根節點都是一個待求的向量。 也就是說這個模型不僅需要求每個輸入引數的變數,還需要求這棵二叉樹中每個非葉子節點的向量,當然這些向量都只是臨時用的向量。
0x02 訓練
2.1 訓練流程
Alink 實現的是 基於Hierarchical Softmax的Skip-Gram模型。
現在我們先看看基於Skip-Gram模型時, Hierarchical Softmax如何使用。此時輸入的只有一個詞w,輸出的為2c個詞向量context(w)。
訓練的過程主要有輸入層(input),對映層(projection)和輸出層(output)三個階段。
- 我們對於訓練樣本中的每一個詞,該詞本身作為樣本的輸入,其前面的c個詞和後面的c個詞作為了Skip-Gram模型的輸出,期望這些詞的softmax概率比其他的詞大。
- 我們需要先將詞彙表建立成一顆霍夫曼樹(此步驟在上文已經完成)。
- 對於從輸入層到隱藏層(對映層),這一步比CBOW簡單,由於只有一個詞,所以,即x_w就是詞w對應的詞向量。
- 通過梯度上升法來更新我們的θwj−1和x_w,注意這裡的x_w周圍有2c個詞向量,我們期望P(xi|xw),i=1,2...2c最大。此時我們注意到由於上下文是相互的,在期望P(xi|xw),i=1,2...2c最大化的同時,反過來我們也期望P(xw|xi),i=1,2...2c最大。那麼是使用P(xi|xw)好還是P(xw|xi)好呢,word2vec使用了後者,這樣做的好處就是在一個迭代視窗內,我們不是隻更新xw一個詞,而是xi,i=1,2...2c共2c個詞。這樣整體的迭代會更加的均衡。因為這個原因,Skip-Gram模型並沒有和CBOW模型一樣對輸入進行迭代更新,而是對2c個輸出進行迭代更新。
- 從根節點開始,對映層的值需要沿著Huffman樹不斷的進行logistic分類,並且不斷的修正各中間向量和詞向量。
- 假設對映層輸入為 pro(t),單詞為“足球”,即w(t)=“足球”,假設其Huffman碼可知為d(t)=”1001,那麼根據Huffman碼可知,從根節點到葉節點的路徑為“左右右左”,即從根節點開始,先往左拐,再往右拐2次,最後再左拐。
- 既然知道了路徑,那麼就按照路徑從上往下依次修正路徑上各節點的中間向量。在第一個節點,根據節點的中間向量Θ(t,1)和pro(t)進行Logistic分類。如果分類結果顯示為0,則表示分類錯誤 (應該向左拐,即分類到1),則要對Θ(t,1)進行修正,並記錄誤差量。
- 接下來,處理完第一個節點之後,開始處理第二個節點。方法類似,修正Θ(t,2),並累加誤差量。接下來的節點都以此類推。
- 在處理完所有節點,達到葉節點之後,根據之前累計的誤差來修正詞向量v(w(t))。這裡引入學習率概念,η 表示學習率。學習率越大,則判斷錯誤的懲罰也越大,對中間向量的修正跨度也越大。
這樣,一個詞w(t)的處理流程就結束了。如果一個文字中有N個詞,則需要將上述過程在重複N遍,從w(0)~w(N-1)。
這裡總結下基於Hierarchical Softmax的Skip-Gram模型演算法流程,梯度迭代使用了隨機梯度上升法:
- 輸入:基於Skip-Gram的語料訓練樣本,詞向量的維度大小M,Skip-Gram的上下文大小2c,步長η
- 輸出:霍夫曼樹的內部節點模型引數θ,所有的詞向量w
2.2 生成訓練模型
Huffman樹中非葉節點儲存的中間向量的初始化值是零向量,而葉節點對應的單詞的詞向量是隨機初始化的。我們可以看到,對於 input 和 output,是會進行 AllReduce 的,就是聚合每個task的計算結果。
DataSet <Row> model = new IterativeComQueue()
.initWithPartitionedData("trainData", trainData)
.initWithBroadcastData("vocSize", vocSize)
.initWithBroadcastData("initialModel", initialModel)
.initWithBroadcastData("vocabWithoutWordStr", vocabWithoutWordStr)
.initWithBroadcastData("syncNum", syncNum)
.add(new InitialVocabAndBuffer(getParams()))
.add(new UpdateModel(getParams()))
.add(new AllReduce("input"))
.add(new AllReduce("output"))
.add(new AvgInputOutput())
.setCompareCriterionOfNode0(new Criterion(getParams()))
.closeWith(new SerializeModel(getParams()))
.exec();
2.3 初始化詞典&緩衝
InitialVocabAndBuffer類完成此功能,主要是初始化引數,把詞典載入到模型記憶體中。這裡只有迭代第一次才會執行。
- input 陣列存著Vocab的全部詞向量,就是Huffman樹所有葉子節點的詞向量。大小|V|∗|M|,初始化範圍[−0.5M,0.5M],經驗規則。
- output 陣列存著Hierarchical Softmax的引數,就是Huffman樹所有 非葉子 節點的引數向量(對映層到輸出層之間的權重)。大小|V|∗|M|,初始化全為0,經驗規則。實際使用|V−1|組。
private static class InitialVocabAndBuffer extends ComputeFunction {
Params params;
public InitialVocabAndBuffer(Params params) {
this.params = params;
}
@Override
public void calc(ComContext context) {
if (context.getStepNo() == 1) { // 只有迭代第一次才會執行
int vectorSize = params.get(Word2VecTrainParams.VECTOR_SIZE);
List <Long> vocSizeList = context.getObj("vocSize");
List <Tuple2 <Integer, double[]>> initialModel = context.getObj("initialModel");
List <Tuple2 <Integer, Word>> vocabWithoutWordStr = context.getObj("vocabWithoutWordStr");
int vocSize = vocSizeList.get(0).intValue();
// 生成一個 100 x 12 的input,這個迭代之後就是最終的詞向量
double[] input = new double[vectorSize * vocSize];
Word[] vocab = new Word[vocSize];
for (int i = 0; i < vocSize; ++i) {
Tuple2 <Integer, double[]> item = initialModel.get(i);
System.arraycopy(item.f1, 0, input,
item.f0 * vectorSize, vectorSize); //初始化詞向量
Tuple2 <Integer, Word> vocabItem = vocabWithoutWordStr.get(i);
vocab[vocabItem.f0] = vocabItem.f1;
}
context.putObj("input", input); // 把詞向量放入系統上下文
// 生成一個 100 x 11 的output,就是Hierarchical Softmax的引數
context.putObj("output", new double[vectorSize * (vocSize - 1)]);
context.putObj("vocab", vocab);
context.removeObj("initialModel");
context.removeObj("vocabWithoutWordStr");
}
}
}
2.4 更新模型UpdateModel
這裡進行“分散式計算”的分配。其中,如何計算給哪個task傳送多少/傳送起始位置,是在DefaultDistributedInfo完成的。這裡需要結合 pieces 函式進行分析。具體在 [ Alink漫談之三] AllReduce通訊模型 有詳細介紹。具體計算則是在 CalcModel.update 中完成。
private static class UpdateModel extends ComputeFunction {
@Override
public void calc(ComContext context) {
List <int[]> trainData = context.getObj("trainData");
int syncNum = ((List <Integer>) context.getObj("syncNum")).get(0);
DistributedInfo distributedInfo = new DefaultDistributedInfo();
long startPos = distributedInfo.startPos(
(context.getStepNo() - 1) % syncNum,
syncNum,
trainData.size()
);
long localRowCnt = distributedInfo.localRowCnt( //計算本分割槽資訊
(context.getStepNo() - 1) % syncNum,
syncNum,
trainData.size()
);
new CalcModel( //更新模型
params.get(Word2VecTrainParams.VECTOR_SIZE),
System.currentTimeMillis(),
Boolean.parseBoolean(params.get(Word2VecTrainParams.RANDOM_WINDOW)),
params.get(Word2VecTrainParams.WINDOW),
params.get(Word2VecTrainParams.ALPHA),
context.getTaskId(),
context.getObj("vocab"),
context.getObj("input"),
context.getObj("output")
).update(trainData.subList((int) startPos, (int) (startPos + localRowCnt)));
}
}
2.5 計算更新
CalcModel.update 中完成計算更新。
2.5.1 sigmoid函式值近似計算
在利用神經網路模型對樣本進行預測的過程中。須要對其進行預測,此時,須要使用到sigmoid函式,sigmoid函式的具體形式為:σ(x)=1 / (1+e^x)
。
σ(x) 在 x = 0 附近變化劇烈,往兩邊逐漸趨於平緩,當 x > 6 或者 x < -6 時候,函式值就基本不變了,前者趨近於0,後者趨近於1.
假設每一次都請求計算sigmoid值,對效能將會有一定的影響,當sigmoid的值對精度的要求並非非常嚴格時。能夠採用近似計算。在word2vec中。將區間[−6,6](設定的引數MAX_EXP為6)等距離劃分成EXP_TABLE_SIZE等份,並將每個區間中的sigmoid值計算好存入到陣列expTable中。須要使用時,直接從陣列中查詢。
Alink中實現如下:
public class ExpTableArray {
public final static float[] sigmoidTable = {
0.002473f, 0.002502f, 0.002532f, 0.002562f, 0.002593f, 0.002624f, 0.002655f, 0.002687f, 0.002719f, 0.002751f ......
}
}
2.5.2 視窗及上下文
Context(w) 就是在詞 w 的前後各取 C 個詞,Alink是事先設定一個視窗預置引數window(預設為5),每次構造Context(w)時候,首先生成一個[1, window] 上的一個隨機整數 C~ ,於是 w 前後各取 C~ 個詞就構成了 Context(w)。
if (randomWindow) {
b = random.nextInt(window);
} else {
b = 0;
}
int bound = window * 2 + 1 - b;
for (int a = b; a < bound; ++a) {
.....
}
2.5.3 訓練
2.5.3.1 資料結構
在 c語言程式碼 中:
- syn0陣列存著Vocab的全部詞向量,就是Huffman樹所有葉子節點的詞向量,即input -> hidden 的 weights 。大小|V|∗|M|,初始化範圍[−0.5M,0.5M],經驗規則。在code中是一個1維陣列,但是應該按照二維陣列來理解。訪問時實際上可以看成 syn0[i, j] i為第i個單詞,j為第j個隱含單元。
- syn1陣列存著Hierarchical Softmax的引數,就是Huffman樹所有 非葉子 節點的引數向量,即 hidden----> output 的 weights。大小|V|∗|M|,初始化全為0,經驗規則。實際使用|V−1|組。
原本的Softmax問題,被近似退化成了近似log(K)個Logistic迴歸組合成決策樹。
Softmax的K組θ,現在變成了K-1組,代表著二叉樹的K-1個非葉結點。在Word2Vec中,由syn1陣列存放,。
在 Alink程式碼 中:
- input 就對應了 syn0,就是上圖的 v。
- output 就對應了 syn1,就是上圖的 θ。
2.5.3.2 具體程式碼
具體程式碼如下(我們使用最大似然法來尋找所有節點的詞向量和所有內部節點θ):
private static class CalcModel {
public void update(List <int[]> values) {
double[] neu1e = new double[vectorSize];
double f, g;
int b, c, lastWord, l1, l2;
for (int[] val : values) {
for (int i = 0; i < val.length; ++i) {
if (randomWindow) {
b = random.nextInt(window);
} else {
b = 0;
}
// 在Skip-gram模型中。須要使用當前詞分別預測窗體中的詞,因此。這是一個迴圈的過程
// 因為需要預測Context(w)中的每個詞,因此需要迴圈2window - 2b + 1次遍歷整個視窗
int bound = window * 2 + 1 - b;
for (int a = b; a < bound; ++a) {
if (a != window) { //遍歷時跳過中心單詞
c = i - window + a;
if (c < 0 || c >= val.length) {
continue;
}
lastWord = val[c]; //last_word為當前待預測的上下文單詞
l1 = lastWord * vectorSize; //l1為當前單詞的詞向量在syn0中的起始位置
Arrays.fill(neu1e, 0.f); //初始化累計誤差
Word w = vocab[val[i]];
int codeLen = w.code.length;
//根據Haffman樹上從根節點到當前詞的葉節點的路徑,遍歷所有經過的中間節點
for (int d = 0; d < codeLen; ++d) {
f = 0.f;
//l2為當前遍歷到的中間節點的向量在syn1中的起始位置
l2 = w.point[d] * vectorSize;
// 正向傳播,得到該編碼單元對應的output 值f
//注意!這裡用到了模型對稱:p(u|w) = p(w|u),其中w為中心詞,u為context(w)中每個詞, 也就是skip-gram雖然是給中心詞預測上下文,真正訓練的時候還是用上下文預測中心詞, 與CBOW不同的是這裡的u是單個詞的詞向量,而不是視窗向量之和
// 將路徑上所有Node連鎖起來,累積得到 輸入向量與中間結點向量的內積
// f=σ(W.θi)
for (int t = 0; t < vectorSize; ++t) {
// 這裡就是 X * Y
// 對映層即為輸入層
f += input[l1 + t] * output[l2 + t];
}
if (f > -6.0f && f < 6.0f) {
// 從 ExpTableArray 中查詢到相應的值。
f = ExpTableArray.sigmoidTable[(int) ((f + 6.0) * 84.0)];
//@brief此處最核心,loss是交叉熵 Loss=xlogp(x)+(1-x)*log(1-p(x))
//其中p(x)=exp(neu1[c] * syn1[c + l2])/(1+exp(neu1[c] * syn1[c + l2]))
//x=1-code#作者才此處定義label為1-code,實際上也可以是code
//log(L) = (1-x) * neu1[c] * syn1[c + l2] -x*log(1 + exp(neu1[c] * syn1[c + l2]))
//對log(L)中的syn1進行偏導,g=(1 -code - p(x))*syn1
//因此會有
//g = (1 - vocab[word].code[d] - f) * alpha;alpha學習速率
// 'g' is the gradient multiplied by the learning rate
// g是梯度和學習率的乘積
//注意!word2vec中將Haffman編碼為1的節點定義為負類,而將編碼為0的節點定義為正類,即一個節點的label = 1 - d
g = (1.f - w.code[d] - f) * alpha;
// Propagate errors output -> hidden
// 根據計算得到的修正量g和中間節點的向量更新累計誤差
for (int t = 0; t < vectorSize; ++t) {
neu1e[t] += g * output[l2 + t]; // 修改對映後的結果
}
// Learn weights hidden -> output
for (int t = 0; t < vectorSize; ++t) {
output[l2 + t] += g * input[l1 + t]; // 改動對映層到輸出層之間的權重
}
}
}
for (int t = 0; t < vectorSize; ++t) {
input[l1 + t] += neu1e[t]; // 返回改動每個詞向量
}
}
}
}
}
}
}
2.6 平均化
AvgInputOutput 類會對Input,output做平均化。
.add(new AllReduce("input"))
.add(new AllReduce("output"))
.add(new AvgInputOutput())
原因在於做AllReduce時候,會簡單的累積,如果有 context.getNumTask() 個task在同時進行,就容易簡單粗暴的相加,這樣數值就會擴大 context.getNumTask() 倍。
private static class AvgInputOutput extends ComputeFunction {
@Override
public void calc(ComContext context) {
double[] input = context.getObj("input");
for (int i = 0; i < input.length; ++i) {
input[i] /= context.getNumTask(); //平均化
}
double[] output = context.getObj("output");
for (int i = 0; i < output.length; ++i) {
output[i] /= context.getNumTask(); //平均化
}
}
}
2.7 判斷收斂
這裡能夠看到,收斂就是判斷是否達到迭代次數。
private static class Criterion extends CompareCriterionFunction {
@Override
public boolean calc(ComContext context) {
return (context.getStepNo() - 1)
== ((List <Integer>) context.getObj("syncNum")).get(0)
* params.get(Word2VecTrainParams.NUM_ITER);
}
}
2.8 序列化模型
這是在 context.getTaskId() 為 0 的task中完成序列化操作,其他task直接返回。這裡收集了所有task的計算結果。
private static class SerializeModel extends CompleteResultFunction {
@Override
public List <Row> calc(ComContext context) {
// 在 context.getTaskId() 為 0 的task中完成序列化操作,其他task直接返回
if (context.getTaskId() != 0) {
return null; //其他task直接返回
}
int vocSize = ((List <Long>) context.getObj("vocSize")).get(0).intValue();
int vectorSize = params.get(Word2VecTrainParams.VECTOR_SIZE);
List <Row> ret = new ArrayList <>(vocSize);
double[] input = context.getObj("input");
for (int i = 0; i < vocSize; ++i) {
// 完成序列化操作
DenseVector dv = new DenseVector(vectorSize);
System.arraycopy(input, i * vectorSize, dv.getData(), 0, vectorSize);
ret.add(Row.of(i, dv));
}
return ret;
}
}
0x03 輸出模型
輸出模型的程式碼如下,功能分別是:
- 把詞典和計算出來的向量聯絡起來
- 按分割槽分割模型成row
- 傳送模型
model = model
.map(new MapFunction <Row, Tuple2 <Integer, DenseVector>>() {
@Override
public Tuple2 <Integer, DenseVector> map(Row value) throws Exception {
return Tuple2.of((Integer) value.getField(0), (DenseVector) value.getField(1));
}
})
.join(vocab)
.where(0)
.equalTo(0) //把詞典和計算出來的向量聯絡起來
.with(new JoinFunction <Tuple2 <Integer, DenseVector>, Tuple3 <Integer, String, Word>, Row>() {
@Override
public Row join(Tuple2 <Integer, DenseVector> first, Tuple3 <Integer, String, Word> second)
throws Exception {
return Row.of(second.f1, first.f1);
}
})
.mapPartition(new MapPartitionFunction <Row, Row>() {
@Override
public void mapPartition(Iterable <Row> values, Collector <Row> out) throws Exception {
Word2VecModelDataConverter model = new Word2VecModelDataConverter();
model.modelRows = StreamSupport
.stream(values.spliterator(), false)
.collect(Collectors.toList());
model.save(model, out);
}
});
setOutput(model, new Word2VecModelDataConverter().getModelSchema());
3.1 聯絡詞典和向量
.join(vocab).where(0).equalTo(0)
就是把詞典和計算出來的向量聯絡起來。兩個Join來源分別如下:
// 來源1,計算出來的向量
first = {Tuple2@11501}
f0 = {Integer@11509} 9
f1 = {DenseVector@11502} "0.9371751984171548 0.33341686580829943 0.6472255126130384 0.36692156358000316 0.1187895685629788 0.9223451469664975 0.763874142430857 0.1330720374498615 0.9631811135902764 0.9283700030050634......"
// 來源2,詞典
second = {Tuple3@11499} "(9,我們,com.alibaba.alink.operator.batch.nlp.Word2VecTrainBatchOp$Word@1ffa469)"
f0 = {Integer@11509} 9
f1 = "我們"
f2 = {Word2VecTrainBatchOp$Word@11510}
3.2 按分割槽分割模型成row
首先按照分割槽計算,分割模型成row。這裡用到了java 8的新特性 StreamSupport,spliterator。
但是這裡只是使用到了Stream的方式,沒有使用其並行功能(可能過後會有文章進行研究)。
model.modelRows = StreamSupport
.stream(values.spliterator(), false)
.collect(Collectors.toList());
比如某個分割槽得到:
model = {Word2VecModelDataConverter@11561}
modelRows = {ArrayList@11562} size = 3
0 = {Row@11567} "胖,0.4345151137723066 0.4923534386513069 0.49497589358976174 0.10917632806760409 0.7007392318076214 0.6468149904858065 0.3804865818632239 0.4348997489483902 0.03362685646645655 0.29769437681180916 0.04287936035337748..."
1 = {Row@11568} "的,0.4347763498886036 0.6852891840621573 0.9862851622413142 0.7061202166493431 0.9896492612656784 0.46525497532250026 0.03379287230189395 0.809333161215095 0.9230387687661015 0.5100444513892355 0.02436724648194081..."
2 = {Row@11569} "老王,0.4337285110643647 0.7605192699353084 0.6638406386520266 0.909594031681524 0.26995654043189604 0.3732722125930673 0.16171135697228312 0.9759668223869069 0.40331291071231623 0.22651841541002585 0.7150087001048662...."
......
3.3 傳送資料
然後傳送資料
public class Word2VecModelDataConverter implements ModelDataConverter<Word2VecModelDataConverter, Word2VecModelDataConverter> {
public List <Row> modelRows;
@Override
public void save(Word2VecModelDataConverter modelData, Collector<Row> collector) {
modelData.modelRows.forEach(collector::collect); //傳送資料
}
@Override
public TableSchema getModelSchema() {
return new TableSchema( //返回schema
new String[] {"word", "vec"},
new TypeInformation[] {Types.STRING, VectorTypes.VECTOR}
);
}
}
0x04 問題答案
我們上文提到了一些問題,現在逐一回答:
- 哪些模組用到了Alink的分散式處理能力?答案是:
- 分割單詞,計數(為了剔除低頻詞,排序);
- 單詞排序;
- 訓練;
- Alink實現了Word2vec的哪個模型?是CBOW模型還是skip-gram模型?答案是:
- skip-gram模型
- Alink用到了哪個優化方法?是Hierarchical Softmax?還是Negative Sampling?答案是:
- Hierarchical Softmax
- 是否在本演算法內去除停詞?所謂停用詞,就是出現頻率太高的詞,如逗號,句號等等,以至於沒有區分度。答案是:
- 本實現中沒有去處停詞
- 是否使用了自適應學習率?答案是:
- 沒有
0xFF 參考
word2vec原理(二) 基於Hierarchical Softmax的模型
word2vec原理(一) CBOW與Skip-Gram模型基礎
word2vec原理(三) 基於Negative Sampling的模型