[关闭]
@evilking 2018-04-30T20:50:30.000000Z 字数 9733 阅读 2059

NLP

Word2Vec源码分析

前面分析了 Word2Vec 的原理,下面就结合具体的 java代码实现 来分析这些过程。

源码下载地址: https://github.com/yao8839836/doc2vec_java

导入eclipse工程后目录结构为:


分析入口

我们之间运行 test 包下的 Word2VecTest.java 类;输出结果如下:

  1. alpha:0.025 Progress: 0%
  2. alpha:0.02478179420538269 Progress: 0%
  3. alpha:0.024563522955572507 Progress: 1%
  4. ......
  5. alpha:5.556709506363112E-4 Progress: 97%
  6. alpha:3.3718151684991317E-4 Progress: 98%
  7. Vocab size: 5574
  8. Words in train file: 1145821
  9. sucess train over!
  10. ......

我们先看看该测试类的 main() 方法:

  1. //语料库
  2. File result = new File("file//amazon_docs.txt");
  3. //word2vec 学习器
  4. Learn lean = new Learn();
  5. //训练模型
  6. lean.learnFile(result);
  7. //保存模型
  8. lean.saveModel(new File("model//amazon_vector.mod"));
  9. //重新加载模型喂给其他算法
  10. Word2VEC w2v = new Word2VEC();
  11. w2v.loadJavaModel("model//amazon_vector.mod");
  12. //获取单词 "windows" 的词向量
  13. float[] vector = w2v.getWordVector("windows");

如上面注释所示,核心步就是第 5 行和第 7 行.

下面我们分别来分析.


学习器的构造

学习器的构造方法里主要做了一件事就是初始化 的近似 ,以后可通过查表法快速的求解出 的近似.

  1. /**
  2. * Precompute the exp() table f(x) = x / (x + 1)
  3. */
  4. private void createExpTable() {
  5. for (int i = 0; i < EXP_TABLE_SIZE; i++) {
  6. expTable[i] = Math
  7. .exp(((i / (double) EXP_TABLE_SIZE * 2 - 1) * MAX_EXP));
  8. //防止分母为 0
  9. expTable[i] = expTable[i] / (expTable[i] + 1);
  10. }
  11. }

我们知道 曲线是在 0 的附近变换比较剧烈,差不多当 或者 的时候基本保持不变了,而在计算机内计算 的原理是按它的泰勒展开来近似:

当展开的阶数比较高时,计算就比较费时了.

于是我们可以利用如下公式近似:

其中 为步长, 为要将 等距剖分的份数.

结合代码看 x 的取值为:


因为 即为 ,所以

该计算公式与上面的理论一致.


训练预处理

笔者将正式训练之前的步骤都划分为训练预处理步. 下面代码跟踪到 Learn 类的 learnFile() 方法里.

词典表

由前面的理论可知,要先对语料库中的每个单词根据词频构建一棵 Haffman 树,于是先要对语料库内的单词进行统计,构建词典表.

具体方法实现是在 readVocab(file) 中:

  1. private void readVocab(File file) throws IOException {
  2. MapCount<String> mc = new MapCount<>();
  3. //获取每个单词的词频
  4. try (BufferedReader br = new BufferedReader(new InputStreamReader(
  5. new FileInputStream(file)))) {
  6. String temp = null;
  7. while ((temp = br.readLine()) != null) {
  8. String[] split = temp.split(" ");
  9. //统计文件中所有单词数
  10. trainWordsCount += split.length;
  11. for (String string : split) {
  12. mc.add(string);
  13. }
  14. }
  15. br.close();
  16. }
  17. for (Entry<String, Integer> element : mc.get().entrySet()) {
  18. //过滤掉词频过小的单词,阈值为 5
  19. if (element.getValue() < freqThresold)
  20. continue;
  21. //为每个单词创建叶子节点,其中包括词向量
  22. wordMap.put(element.getKey(),
  23. new WordNeuron(element.getKey(),
  24. element.getValue(), layerSize));
  25. }
  26. }

其中后半部分会为每个单词创建一个 WordNeuron 对象,构造方法为:

  1. public WordNeuron(String name, int freq, int layerSize) {
  2. this.name = name;
  3. this.freq = freq;
  4. //词向量
  5. this.syn0 = new double[layerSize];
  6. Random random = new Random();
  7. //词向量随机初始化: (rand()/Rand_max - 0.5)/m
  8. for (int i = 0; i < syn0.length; i++) {
  9. syn0[i] = (random.nextDouble() - 0.5) / layerSize;
  10. }
  11. }

这里显示出词向量的初始化是通过公式:


则初始词向量的分量均落在区间 ,这里 表示词向量的长度,在代码中为 layerSize .


构建 Haffman 树

构建 Haffman 树是这一句:

  1. //根据词频创建 haffman 树
  2. new Haffman(layerSize).make(wordMap.values());

Haffman 构造方法中,设置 this.layerSize 为传入的 layerSize,表示 Haffman 树的各个结点的参数向量的维度与词向量相同,这样才能做 .

具体来看 make() 方法:

  1. //自动使用自然的顺序排序
  2. //由小到大
  3. private TreeSet<Neuron> set = new TreeSet<>();
  4. public void make(Collection<Neuron> neurons) {
  5. set.addAll(neurons);
  6. //只剩一个节点时,表示整棵树已构建完成
  7. while (set.size() > 1) {
  8. merger();
  9. }
  10. }
  11. private void merger() {
  12. //创建最小的两个节点的父节点
  13. HiddenNeuron hn = new HiddenNeuron(layerSize);
  14. //弹出第一个元素(也是最小的)
  15. Neuron min1 = set.pollFirst();
  16. //弹出第一个元素(此时也是最小的)
  17. Neuron min2 = set.pollFirst();
  18. hn.freq = min1.freq + min2.freq;
  19. min1.parent = hn;
  20. min2.parent = hn;
  21. min1.code = 0; //左边小的标记为 0
  22. min2.code = 1;
  23. set.add(hn); //将父节点加回数据集
  24. }

通过迭代 merger() 方法,逐步构建最小的两棵子树的父节点,并将该父节点加入到森林中. 一直到森林中只剩下一棵树时,表示 Haffman 树构建完成.


构建分类路径

我们知道 Hierarchical Softmax 模型 是在 Haffman树中按照根节点到达叶子节点的路径逐步做二分类来实现的. 于是要先为每个叶子节点构建分类路径.

  1. // 为每个神经元构建分类路径
  2. for (Neuron neuron : wordMap.values()) {
  3. ((WordNeuron) neuron).makeNeurons();
  4. }

具体到 makeNeurons() 方法:

  1. public List<Neuron> makeNeurons() {
  2. if (neurons != null) {
  3. return neurons;
  4. }
  5. Neuron neuron = this;
  6. //根节点到叶子节点路径上所有的非叶子节点
  7. neurons = new LinkedList<>();
  8. while ((neuron = neuron.parent) != null) {
  9. neurons.add(neuron);
  10. }
  11. Collections.reverse(neurons);
  12. // 0 1 编码
  13. codeArr = new int[neurons.size()];
  14. //d_j 从第二个开始,i 从 1开始
  15. for (int i = 1; i < neurons.size(); i++) {
  16. codeArr[i - 1] = neurons.get(i).code;
  17. }
  18. codeArr[codeArr.length - 1] = this.code;
  19. return neurons;
  20. }

其中 neurons 中的每个元素 neuron 的参数向量可以相当于是一个逻辑回归分类器的参数,而 codeArr 中的 0 1 标号相当于是分类结果.

到这里预处理部分就做完了.


训练

训练逻辑主要在 trainModel() 方法中,这里分别实现了 CBOW 模型Skip-gram 模型,后面我们分别来分析.

  1. private void trainModel(File file) throws IOException {
  2. try (BufferedReader br = new BufferedReader(new InputStreamReader(
  3. new FileInputStream(file)))) {
  4. String temp = null;
  5. long nextRandom = 5;
  6. //主要用来自适应调整学习率
  7. int wordCount = 0;
  8. int lastWordCount = 0;
  9. int wordCountActual = 0;
  10. while ((temp = br.readLine()) != null) {
  11. if (wordCount - lastWordCount > 10000) {
  12. //已处理的单词数
  13. wordCountActual += wordCount - lastWordCount;
  14. lastWordCount = wordCount;
  15. //自适应学习率
  16. alpha = startingAlpha
  17. * (1 - wordCountActual
  18. / (double) (trainWordsCount + 1));
  19. //有一个学习率过小阈值
  20. if (alpha < startingAlpha * 0.0001) {
  21. alpha = startingAlpha * 0.0001;
  22. }
  23. }
  24. String[] strs = temp.split(" ");
  25. //更新处理的单词数
  26. wordCount += strs.length;
  27. List<WordNeuron> sentence = new ArrayList<WordNeuron>();
  28. //上下文采样
  29. for (int i = 0; i < strs.length; i++) {
  30. Neuron entry = wordMap.get(strs[i]);
  31. if (entry == null) {
  32. continue;
  33. }
  34. if (sample > 0) {
  35. double ran = (Math.sqrt(entry.freq
  36. / (sample * trainWordsCount)) + 1)
  37. * (sample * trainWordsCount) / entry.freq;
  38. nextRandom = nextRandom * 25214903917L + 11;
  39. //子采样
  40. if (ran < (nextRandom & 0xFFFF) / (double) 65536) {
  41. continue;
  42. }
  43. }
  44. sentence.add((WordNeuron) entry);
  45. }
  46. //所以这里上下文并不是连续的前后几个单词
  47. for (int index = 0; index < sentence.size(); index++) {
  48. nextRandom = nextRandom * 25214903917L + 11;
  49. //默认使用 Skip-gram模型
  50. if (isCbow) {
  51. // CBOW 模型
  52. cbowGram(index, sentence, (int) nextRandom % window);
  53. } else {
  54. // Skip-gram 模型
  55. skipGram(index, sentence, (int) nextRandom % window);
  56. }
  57. }
  58. }
  59. br.close();
  60. }
  61. }

前面通过单词数的处理进度动态的更新学习率,随着处理的句子越来越多,学习率会逐渐降低,但不会让它一直降低到 0,这里设置了一个阈值,如果学习率 alpha 小于阈值了,就直接将 alpha 设置为阈值 .

中间部分是对当前处理的句子预料做了子采样,以一定的概率去掉词频过高的单词,公式为:

给定一个词频阈值参数 ,词 将以 的概率被舍弃.

代码中实现的公式为:


具体做法是:假设当前处理词 ,先计算 的值,然后产生一个 上的随机数 ,如果 ,则舍弃词 . 由于在区间 上产生一个大于 的随机数的概率是 ,因此上述做法就等同于以 的概率舍弃词 .

接下来主要是对上下文中的每个词去调用 CBOW模型 或者 Skip-Gram模型.


CBOW模型

CBOW模型的训练方法 cbowGram(int index,list<WordNeuron> sentence,int b) 主要分两步:

先来看第一步,输入层到隐藏层:

  1. //隐藏层
  2. for (a = b; a < window * 2 + 1 - b; a++)
  3. //除当前词
  4. if (a != window) {
  5. //上下文窗口内的其他词索引
  6. c = index - window + a;
  7. if (c < 0)
  8. continue;
  9. if (c >= sentence.size())
  10. continue;
  11. last_word = sentence.get(c);
  12. if (last_word == null)
  13. continue;
  14. //将上下文的词向量直接相加
  15. for (c = 0; c < layerSize; c++)
  16. neu1[c] += last_word.syn0[c];
  17. }

这里主要是将上下文窗口内的其他词的词向量进行累加,构成 neu1 向量.

其中需要注意的是 for (a = b; a < window * 2 + 1 - b; a++)b 是一个随机数,表示要将 window * 2 范围首尾去掉单词的个数,则 a 即在窗口中间左右 window - b 长的范围内变动;c = index -window + aindex - windowwindow左边界索引,index -window + a 为去掉 b 长度后上下文窗口变动范围内的单词索引.

第二步 Hierarchical softmax 层:

  1. // HIERARCHICAL SOFTMAX
  2. for (int d = 0; d < neurons.size(); d++) {
  3. HiddenNeuron out = (HiddenNeuron) neurons.get(d);
  4. double f = 0;
  5. // Propagate hidden -> output
  6. //先计算 X^T * theta
  7. for (c = 0; c < layerSize; c++)
  8. f += neu1[c] * out.syn1[c];
  9. //查 e^x 的近似表
  10. if (f <= -MAX_EXP)
  11. continue;
  12. else if (f >= MAX_EXP)
  13. continue;
  14. else{
  15. //这步没看明白为什么这么写
  16. f = expTable[(int) ((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))];
  17. }
  18. //计算梯度 g
  19. // 'g' is the gradient multiplied by the learning rate
  20. // double g = (1 - word.codeArr[d] - f) * alpha;
  21. // double g = f*(1-f)*( word.codeArr[i] - f) * alpha;
  22. double g = f * (1 - f) * (word.codeArr[d] - f) * alpha;
  23. //更新 e
  24. for (c = 0; c < layerSize; c++) {
  25. neu1e[c] += g * out.syn1[c];
  26. }
  27. //更新softmax 非叶子节点的参数向量 theta
  28. // Learn weights hidden -> output
  29. for (c = 0; c < layerSize; c++) {
  30. out.syn1[c] += g * neu1[c];
  31. }
  32. }
  33. for (a = b; a < window * 2 + 1 - b; a++) {
  34. if (a != window) {
  35. c = index - window + a;
  36. if (c < 0)
  37. continue;
  38. if (c >= sentence.size())
  39. continue;
  40. last_word = sentence.get(c);
  41. if (last_word == null)
  42. continue;
  43. //更新 V(w) 词向量
  44. for (c = 0; c < layerSize; c++)
  45. last_word.syn0[c] += neu1e[c];
  46. }
  47. }

先回顾前面的理论部分描述的算法过程:

wv2

先计算 ,然后计算 ,不过源码中是通过查表法近似计算 的,得到 f.

接着生成梯度 g,理论部分应该是 (1 - word.codeArr[d] - f) * alpha 的,但是源码中是 f * (1 - f) * (word.codeArr[d] - f) * alpha,这里应该是用类似神经网络中常用的梯度下降法来求的梯度:

接下来更新 就更理论算法描述一致了.

后半部分的 for 循环是为了更新当前词的上下文单词的词向量 V(u) .


Skip-gram 模型

Skip-gram 模型的训练过程与CBOW类似,由于Skip-gram模型丢失了词序信息,所以就没有上下文单词的词向量累加操作.

  1. WordNeuron word = sentence.get(index);
  2. int a, c = 0;
  3. for (a = b; a < window * 2 + 1 - b; a++) {
  4. if (a == window) {
  5. continue;
  6. }
  7. c = index - window + a;
  8. if (c < 0 || c >= sentence.size()) {
  9. continue;
  10. }
  11. double[] neu1e = new double[layerSize];// 误差项
  12. // HIERARCHICAL SOFTMAX
  13. List<Neuron> neurons = word.neurons;
  14. WordNeuron we = sentence.get(c);
  15. for (int i = 0; i < neurons.size(); i++) {
  16. HiddenNeuron out = (HiddenNeuron) neurons.get(i);
  17. double f = 0;
  18. //计算 X^T*theta
  19. // Propagate hidden -> output
  20. for (int j = 0; j < layerSize; j++) {
  21. f += we.syn0[j] * out.syn1[j];
  22. }
  23. //查表法计算 e^z
  24. if (f <= -MAX_EXP || f >= MAX_EXP) {
  25. continue;
  26. } else {
  27. f = (f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2);
  28. f = expTable[(int) f];
  29. }
  30. //计算梯度 g
  31. // 'g' is the gradient multiplied by the learning rate
  32. double g = (1 - word.codeArr[i] - f) * alpha;
  33. //更新 e
  34. // Propagate errors output -> hidden
  35. for (c = 0; c < layerSize; c++) {
  36. neu1e[c] += g * out.syn1[c];
  37. }
  38. //更新 theta
  39. // Learn weights hidden -> output
  40. for (c = 0; c < layerSize; c++) {
  41. out.syn1[c] += g * we.syn0[c];
  42. }
  43. }
  44. //更新词向量
  45. // Learn weights input -> hidden
  46. for (int j = 0; j < layerSize; j++) {
  47. we.syn0[j] += neu1e[j];
  48. }
  49. }

先回顾下 skip-gram模型的算法过程:

wv3

for (a = b; a < window * 2 + 1 - b; a++) 其中的 a 含义与 CBOW一致.

先计算 ,然后通过查表法求得 的近似值 f .

然后计算梯度 g,这里用的公式为 g = (1 - word.codeArr[i] - f) * alpha,跟算法描述中的一致.

接着更新 的步骤就更算法描述中一致了.


模型保存

  1. public void saveModel(File file) {
  2. // TODO Auto-generated method stub
  3. try (DataOutputStream dataOutputStream = new DataOutputStream(
  4. new BufferedOutputStream(new FileOutputStream(file)))) {
  5. //单词数保存
  6. dataOutputStream.writeInt(wordMap.size());
  7. //词向量的维度保存
  8. dataOutputStream.writeInt(layerSize);
  9. double[] syn0 = null;
  10. for (Entry<String, Neuron> element : wordMap.entrySet()) {
  11. //保存每个单词
  12. dataOutputStream.writeUTF(element.getKey());
  13. syn0 = ((WordNeuron) element.getValue()).syn0;
  14. //保存其词向量
  15. for (double d : syn0) {
  16. dataOutputStream.writeFloat(((Double) d).floatValue());
  17. }
  18. }
  19. dataOutputStream.close();
  20. } catch (IOException e) {
  21. // TODO Auto-generated catch block
  22. e.printStackTrace();
  23. }
  24. }

小结

到这里整个基于 Hierarchical Softmax 的 Word2Vec 算法实现就分析完了,希望能对读者理解 Word2Vec 有所帮助.

其中也有一处不明白的地方,f = (f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2); 按原理来说,应该可以直接计算 的,但是这里为什么要做这种转行,就想不明白.

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注