本文翻译自作者在medium发布的一篇推文,这里是原文链接
本文是 Word Embedding 系列的第一篇。本文适合中级以上的读者或者训练过word2vec/doc2vec/Paragraph Vectors的读者阅读,但别担心,我将在接下来的推文中介绍理论以及背景知识,并联系论文讲解代码是如何实现的。
我会尽力不把各位读者引导到一大堆冗长而又无法让人真正理解的教程中,最后以放弃告终(相信我,我也是网上诸多教程的受害者)。我想我们可以一起从代码层面来了解word2vec,这样我们可以知道如何设计并实现我们自己的word embedding 和language model.
如果您曾经自己训练过word vectors,会发现尽管使用相同的数据进行训练,但每次训练得到的模型和词向量表示都不一样。这是因为在训练过程中引入了随机性所致。让我们一起来从代码中找到这些随机性是如何引入的,以及如何消除这种随机性。我将用DL4j的Paragraph Vectors的实现来展示代码。如果您想看其他包的实现,可以看gensim的doc2vec,它有相同的实现方法。
随机性从哪里来
模型权重和词向量的初始化
我们知道在训练最初,模型各参数和词向量表示会随机初始化,这里的随机性是由seed控制实现的。因此,当我们把seed设为0,我们在每次训练中会得到完全相同的初始化。这里来看seed是如何影响初始化的,syn0是模型权重。
// Nd4j 设置有关生成随机数的seed
Nd4j.getRandom().setSeed(configuration.getSeed());
// Nd4j 为 syn0 初始化一个随机矩阵
syn0 = Nd4j.rand(new int[] {vocab.numWords(), vectorLength}, rng).subi(0.5).divi(vectorLength);复制代码
PV-DBOW 算法
如果我们使用PV-DBOW算法训练Paragraph Vectors,在训练迭代中,单词会从窗口中随机取得并计算、更新模型。但是这里的随机在代码实现中并不是真正的随机。
// nextRandom 是一个 AtomicLong,并被threadId初始化
this.nextRandom = new AtomicLong(this.threadId);复制代码
nextRandom在trainSequence(sequence, nextRandom, alpha);
被用到,在trainSequence
中,nextRandom.set(nextRandom.get() * 25214903917L + 11);
如果我们更加深入到每个训练的步骤,我们会发现nextRandom产生于相同的步骤及方法,即进行固定的数学运算(到这里和这里了解为什么这样做),所以nextRandom
是依赖于threadId
的数字,而threadId
是0,1,2,3,...所以这里我们实际上不再有随机性。
并行tokenization
因为对文本的处理是一项耗时的工作,所以进行并行tokenization可以提高性能,但训练的一致性将不能得到保证。并行处理下,提供给每个thread进行训练的数据将出现随机性。从代码中可以看到,如果我们将allowParallelBuilder
设为false
,进行tokenization的runnable
将阻塞其他thread直到tokenization结束,从而保持输入训练数据的一致性。
if (!allowParallelBuilder) {
try {
runnable.awaitDone();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}复制代码
为各个thread提供训练数据的队列
该队列是一个LinkedBlockingQueue
,这个队列从迭代器中取出训练文本,然后提供给各个线程进行训练。因为各个线程请求数据的时间可以是任意的,所以在每次训练中,每个线程得到的数据也是不一样的。请看这里的代码具体实现。
// 初始化一个 sequencer 来提供数据给每个线程
val sequencer = new AsyncSequencer(this.iterator, this.stopWords);
// 每个线程使用同一个 sequencer
// worker是我们设置的进行训练的线程数
for (int x = 0; x < workers; x++) {
threads.add(x, new VectorCalculationsThread(x, ..., sequencer);
threads.get(x).start();
}
// 在sequencer中 初始化一个 LinkedBlockingQueue buffer
// 同时保持该buffer的size在[limitLower, limitUpper]
private final LinkedBlockingQueue> buffer;
limitLower = workers * batchSize;
limitUpper = workers * batchSize * 2;
// 线程从buffer中读取数据
buffer.poll(3L, TimeUnit.SECONDS);复制代码
所以,如果我们将worker
设为1,即采用单线程进行训练,那么每次训练我们将得到相同顺序的数据。这里需要注意的是,如果采用单线程,训练的速度将会大幅降低。
总结
为了将随机性排除,我们需要做以下:
- 将
seed
设为0; - 将
allowParallelTokenization
设为false
; - 将
worker
设为1。
最终,我们的训练代码将会像:
ParagraphVectors vec = new ParagraphVectors.Builder()
.minWordFrequency(1)
.labels(labelsArray)
.layerSize(100)
.stopWords(new ArrayList())
.windowSize(5)
.iterate(iter)
.allowParallelTokenization(false)
.workers(1)
.seed(0)
.tokenizerFactory(t)
.build();
vec.fit();复制代码
如果您觉得对上述内容不理解,那么别担心,我将在之后的推文中联系代码和论文,详细解释word embedding以及language model的技术。
参考
- Deeplearning4j, ND4J, DataVec and more - deep learning & linear algebra for Java/Scala with GPUs + Spark - From Skymind http://deeplearning4j.org https://github.com/deeplearning4j/deeplearning4j
- Java™ Platform, Standard Edition 8 API Specification https://docs.oracle.com/javase/8/docs/api/