65.9K
CodeProject 正在变化。 阅读更多。
Home

使用深度学习和 Deeplearning4j 的长短期记忆(LSTM)神经网络进行垃圾邮件检测

2018年3月5日

CPOL

10分钟阅读

viewsIcon

8386

downloadIcon

169

在 Scala 中使用 Deeplearning4j 进行垃圾邮件检测。

引言

本文旨在描述长短期记忆 (LSTM) 神经网络 (NNs) 在垃圾邮件检测中的应用。用于构建 NN 模型的库是 Deeplearning4j,一个用于 Java 的深度学习库(顾名思义),但实际上本文的代码是用 Scala 编写的。

为了将神经网络应用于语言处理任务,需要一种合适的词表示,这种表示允许词被用作 NN 的输入。这里将使用的词表示是全局向量 (Global Vectors),或称 GloVe 表示。

使用的数据集包含三个部分:用于训练的 1000 条垃圾邮件,用于训练的 1000 条正常邮件,以及用于测试的 100 条混合(垃圾邮件和正常邮件)邮件。所有邮件都已相应标记。

因此,本文的具体目标是展示如何使用 Deeplearning4j 生成一个由标记的(GloVe)词向量序列组成的数据集,然后使用该数据集训练和测试一个 LSTM NN。此外,还将解释和论证所使用的参数和配置。

架构和工作流程

Deeplearning4j (DL4j,下同) 的后端是数值计算库 Nd4j。参照 Python,ND4j 可以被认为是 Java 世界的 NumPy。ND4j,就像 NumPy 一样,使用 n 维数组,或 `NDArray`。然后 DL4j 提供了一些便利工具,用于将真实世界的数据集加载并转换为 `NDArray`。下图展示了这些便利工具和当前问题的工作流程。

提供了一个初始文件:“SpamDetectionData.txt”。如前所述,该文件包含所有训练和测试数据。然后它被分割成两个主要文件:“train.txt”和“test.txt”。然后 DL4j 介入。实现了 `DataSetIterator` 接口,并用于创建迭代器对象以遍历数据集。

此迭代器通过类移动遍历数据集,通过重写的 `next()`(或 `next(n)`)方法返回一个 `DataSet` 对象,该对象实际上包含下一个数据样本或批次。

尽管上图显示 `DataSet` 对象直接馈送到学习过程,但 API 允许直接馈送迭代器;但在内部,`DataSet` 无论如何都会通过迭代器获取。

Using the Code

全局向量词表示

如前所述,将使用 GloVe 词向量表示。生成这些词表示的算法在 Jeffrey Pennington、Richard Socher 和 Christopher D. Manning 于 2014 年发表的这篇论文中进行了解释,其目的是将词编码为向量,同时保留它们的语义关系。

该算法相对简单。考虑一个大型文档集,例如所有维基百科条目。在一次遍历中,算法将此集合中的所有单词收集到一个大矩阵 `X` 中,其中每行和每列都代表每个单词,每个单元格 `X(i, j)` 存储单词 `i` 和 `j` 在集合中共同出现的次数;这就是词-词共现矩阵。

有了这个矩阵,可以计算单词 `j` 在单词 `i` 上下文中出现的概率,即 `P(j|i)=X(i, j)/sum(X(i,:))`。但作者们认为,给定三个单词 `i`、`j` 和 `k`,如果单词 `k` 与 `i` 语义相关,则概率比 `P(k|i)/P(k|j)` 将很大;如果单词 `k` 与 `j` 语义相关,则该比率将很小;如果 `k` 与两者均无语义关系,则该比率将接近 1。因此,应该找到一个函数 `F`,可以将 `i`、`j` 和 `k` 之间的这种关系编码成向量 `wi`、`wj` 和 `wk`,即找到 `F`,使得 `F(wi, wj, wk)=P(k|i)/P(k|j)`。经过一些数学工作(请查阅论文),他们提出了以下成本函数 `J=sum(sum(f(X(i, j))*(dot(xi, xj) - bi - bj - log(X(i, j))), i), j)`,其中加权函数 `f` 必须遵守论文中表达的某些条件。因此,最小化 `J` 关于 `wi`、`wj` 以及偏差 `bi` 和 `bj` 的值,即可得到 GloVe 词表示。

在这篇文章中使用了一个预训练集。它由在 Wikipedia 2014 和 Gigaword 5 语料库上训练的 50 维词向量组成。这个集合被加载到一个哈希映射 `gloveMap[String, INDArray]` 中,其键是单词,值是它们在 `NDArray` 对象中的向量表示。

val gloveMap = new mutable.HashMap[String, INDArray]()
val gloveIt = Source.fromFile(gloveFile).getLines()

for (line <- gloveIt) {
  val word = line.takeWhile(_.isLetter)
  val rest = line.substring(line.indexOf(" ")).trim.replaceAll(" ", ",")

  val glove = Nd4j.readTxtString(IOUtils.toInputStream(
    "[NDArray]" +
      "{\n" +
      "\"filefrom\":\"dl4j\",\n" +
      "\"ordering\":\"c\",\n" +
      "\"shape\":[1, " + gloveDim + "],\n" +
      "\"data\":\n" +
      "[" + rest + "]\n" +
      "}", StandardCharsets.UTF_8))

  gloveMap += (word -> glove)
}

数据准备

在实现数据集迭代器之前,必须将文件“SpamDetectionData.txt”中的原始数据转换为适合使用的格式。为此,将执行以下预处理操作:

  • 将标签“Spam”和“Ham”分别替换为 0 和 1;
  • 去除所有 HTML 标签;以及
  • 分离标点符号,因为 GloVe 数据集对它们有向量表示,所以它们必须被视为单词。

结果生成了两个文件:“train.txt”包含所有训练集,“test.txt”包含测试集。但在原始文件中,“Spam”和“Ham”标签的数据是连续排列的。为了方便训练数据的获取,训练集文件应包含交替的样本。这样可以直接输入训练数据,无需任何形式的混洗。因此,创建了两个临时文件,一个用于“Spam”训练样本,另一个用于“Ham”训练样本,然后通过将前两个文件以“Spam”和“Ham”样本交替合并生成最终的“train.txt”文件。这个过程如下图所示。测试文件则可以直接生成。

该过程在 `Main` 类的 `generateSeparateFiles()` 方法中实现。

数据集迭代器类

垃圾邮件检测数据集的自定义迭代器继承自 `DataSetIterator` 接口并实现了其虚方法。本文将重点关注构造函数和方法 `next(num: Int): DataSet`。

构造函数接收五个参数(也是属性)

  • path:存放“train.txt”和“test.txt”文件的目录路径;
  • batchSize:数据集批次的大小;
  • maxNumWords:由于消息的长度可能不同,此整数设置了一个限制,超出此限制的消息将被截断;
  • gloveHash:将单词映射到其 `NDArray` 向量表示的哈希表;以及
  • isTraining:如果遍历训练集则为 true,如果遍历测试集则为 false。
class SpamDataIterator(val path: String,
                       val batchSize: Int,
                       val maxWordNumber: Int,
                       val gloveHash: mutable.HashMap[String, INDArray],
                       val isTraining: Boolean) extends DataSetIterator {
    // ...
}

表示消息的词向量序列在构造函数中一次性全部存储在内存中。这仅在数据集较小的情况下才可能实现,对于较大的数据集应采用不同的策略。将构成整个训练集的 INDArray 会预先分配。

val inputs: INDArray = Nd4j.create(nLines, gloveHash.head._2.shape()(1), maxWordNumber)
val targets: INDArray = Nd4j.create(nLines, 2, maxWordNumber)

`inputs` 存储所有词向量序列,`targets` 存储每个词向量序列的期望输出(垃圾邮件为 `[1, 0]`,正常邮件为 `[0, 1]`),`nLines` 代表样本数量,`gloveHash.head._2.shape()(1)` 给出 GloVe 向量的维度(本例中为 50)。因此,输入和目标都是三维数组。下表描述了训练 `NDArrays` 的维度。

  输入数组 目标数组
维度 1 样本数量 样本数量
维度 2 输入大小 可能输出标签的数量
维度 3 最大消息长度 最大消息长度

如前所述,如果消息包含的单词数量超过 `maxWordNumber`,则会被截断;但如果单词数量少于此限制,则需要某种机制来告知学习过程数据结束和填充开始的位置。为此,使用了掩码,并且输入和目标数组都需要它们。

val inputsMask: INDArray = Nd4j.zeros(nLines, maxWordNumber)
val targetsMask: INDArray = Nd4j.zeros(nLines, maxWordNumber)

对于每一个 `i` 和 `j`,如果 `inputs(i, :, j)` 包含有效的词向量(没有填充),则 `inputsMask(i, j)` 应为 1。在 `targetsMask` 的情况下,除最后一个有效词向量的位置为 1 外,其余应全为零。

在下面的代码片段中,`line` 表示正在迭代的文件(“train.txt”或“test.txt”)中的一行,`lineCount` 是其索引。用于形成输入:

val inputVal = line.substring(2).split(" ")
var idx = 0
inputVal.foreach({ w =>
  if (gloveHash.contains(w) && idx < maxWordNumber) {
    inputs.put(
      Array(
        NDArrayIndex.point(lineCount),
        NDArrayIndex.all(),
        NDArrayIndex.point(idx)
      ),
      gloveHash(w)
    )

    idx += 1
  }
})

在此代码中,对于训练/测试文件中的每一行中的每个单词 `w`,其 GloVe 表示(由 `gloveHash(w)` 给出)被放入 `inputs` 数组的相应位置。`NDArrayIndex` 类用于选择 `NDArray` 的区域。在上述代码中,这意味着 `inputs(lineCount, :, idx) = gloveHash(w)`,其中 `lineCount` 是样本(词向量序列)的索引,`idx` 是序列中词向量的索引。

重写的 `next(num: Int): DataSet` 方法如下所示。它以 `DataSet` 对象的形式返回先前加载的数据集的切片。

override def next(num: Int): DataSet = {
  val dsSliceIdx = Array(
    NDArrayIndex.interval(position, Math.min(position + num, lineCount)),
    NDArrayIndex.all(),
    NDArrayIndex.all()
  )

  val dataSet = new DataSet(
    inputs.get(dsSliceIdx(0), dsSliceIdx(1), dsSliceIdx(2)),
    targets.get(dsSliceIdx(0), dsSliceIdx(1), dsSliceIdx(2)),
    inputsMask.get(dsSliceIdx(0), dsSliceIdx(1)),
    targetsMask.get(dsSliceIdx(0), dsSliceIdx(1))
  )

  position += num

  dataSet
}

模型构建

所用神经网络的拓扑结构如图所示。它有 50 个输入,两个具有 30 和 15 个神经元(分别)的隐藏 LSTM 层,使用双曲正切 (`tanh`) 激活函数,以及一个具有两个 softmax 神经元的输出层。

在 DL4j 中创建具有此拓扑的 NN,采用构建器模式,如下面代码所示。还可以看到,该模式也应用于层的创建。学习率和网络拓扑通过试错法选择,直到达到可接受的性能。

val mlpConf = new NeuralNetConfiguration.Builder()
  .seed(Random.nextInt())
  .learningRate(1e-2)
  .weightInit(WeightInit.XAVIER)
  .updater(Updater.ADAGRAD)
  .list()
  .layer(0, new GravesLSTM.Builder()
    .nIn(gloveDim)
    .nOut(30)
    .activation(Activation.TANH)
    .build())
  .layer(1, new GravesLSTM.Builder()
    .nIn(30)
    .nOut(15)
    .activation(Activation.TANH)
    .build())
  .layer(2, new RnnOutputLayer.Builder()
    .nIn(15)
    .nOut(2)
    .activation(Activation.SOFTMAX)
    .lossFunction(LossFunctions.LossFunction.MCXENT)
    .build())
  .build()

所使用的权重初始化算法是 Xavier 算法 (`WeightInit.XAVIER`)。根据 Andrew Jones 的这篇博文,它包括从零均值和方差 `var(W) = 1 / nInputs` 的概率分布中抽取权重,通常是均匀分布或高斯分布。查看 Xavier Glorot 和 Yoshua Bengio 的论文,Andrew 引用此论文作为提出初始化启发式的论文,可以看出作者在每个层中从区间 `[-sqrt(6) / sqrt(n`j` + n`j+1`), sqrt(6) / sqrt(n`j` + n`j+1`)]` 中的均匀分布初始化权重,其中 `n`j 是第 `j` 层中的神经元数量。

为了更新梯度,使用了自适应梯度算法 Adagrad (`Updater.ADAGRAD`)。它具有强调很少出现的特征的特性。引用 John Duchi、Elad Hazan 和 Yoram Singer 的原始论文

非正式地,我们的程序为频繁出现的特征提供非常低的学习率,而为不频繁出现的特征提供高的学习率,其直觉是每次看到不频繁的特征时,学习器都应该“引起注意”。因此,这种适应有助于发现和识别具有很强预测性但相对罕见的特征。

Sebastian Ruder 在这份调查中对 Adagrad 进行了更实用的介绍。

回到 DL4j,现在必须使用上述配置对象创建 NN 对象。

val mlp: MultiLayerNetwork = new MultiLayerNetwork(mlpConf)
mlp.init()

模型训练与评估

通过调用 mlp NN 对象的 fit 方法来训练模型。该方法接收训练集迭代器 trainIt。由于每次调用 fit 都意味着使用迭代器遍历整个集合,因此在每个 epoch 结束时我们都应该重置迭代器。

for (i <- 0 until nEpochs) {
  mlp.fit(trainIt)
  spamIt.reset()

  val evaluation = mlp.evaluate(testIt)
  println(evaluation.stats())
}

在每个 epoch 结束时,使用评估对象评估模型。该对象由 NN 对象的 `evaluate` 方法返回,该方法接收测试集迭代器作为参数。评估对象有一个 `stats` 方法,该方法返回一个包含简要性能报告的字符串。此处呈现的模型在 1 个 epoch(即,单次遍历训练集)的输出如下,显示网络正确分类了所有测试样本。

Examples labeled as Spam classified by model as Spam: 43 times
Examples labeled as Ham classified by model as Ham: 57 times

==========================Scores========================================
 Accuracy:        1
 Precision:       1
 Recall:          1
 F1 Score:        1
========================================================================

模型每迭代得分曲线通过 DL4j UI 获得,如下图所示。

最后的 remarks

本文展示了如何使用 DL4j 训练 LSTM NN 进行垃圾邮件检测,并使用 GloVe 词向量表示。尽管该系统显示出良好的结果,但仍可探索一些变量以获得更好的结果:

  • 更高维度的 GloVe 向量:在更大的数据集中,拥有更高维度的向量可能很有用,因为这意味着更高的编码词义能力,并且有更高维度向量的预训练 GloVe 集可用;
  • 更新算法:选择 Adagrad 是因为其强调稀有特征的特性,但其他算法改进了其某些方面,并且还有其他算法被提出,因此这也可能是一个改进点;以及
  • 网络拓扑和参数:这是在追求更好结果时总是可以改变的一点,主要是通过试错法,但保持模型足够简单也很重要。
© . All rights reserved.