原文:
annas-archive.org/md5/f5d28e569048a2e5329b5ab42aa19b12
译者:飞龙
第五章:实现自然语言处理
本章将讨论 DL4J 中的词向量(Word2Vec)和段落向量(Doc2Vec)。我们将逐步开发一个完整的运行示例,涵盖所有阶段,如 ETL、模型配置、训练和评估。Word2Vec 和 Doc2Vec 是 DL4J 中的自然语言处理(NLP)实现。在讨论 Word2Vec 之前,值得简单提一下词袋模型算法。
词袋模型是一种计数文档中词汇出现次数的算法。这将使我们能够执行文档分类。词袋模型和 Word2Vec 只是两种不同的文本分类方法。Word2Vec可以利用从文档中提取的词袋来创建向量。除了这些文本分类方法之外,词频-逆文档频率(TF-IDF)可以用来判断文档的主题/上下文。在 TF-IDF 的情况下,将计算所有单词的分数,并将词频替换为该分数。TF-IDF 是一种简单的评分方案,但词嵌入可能是更好的选择,因为词嵌入可以捕捉到语义相似性。此外,如果你的数据集较小且上下文是特定领域的,那么词袋模型可能比 Word2Vec 更适合。
Word2Vec 是一个两层神经网络,用于处理文本。它将文本语料库转换为向量。
请注意,Word2Vec 并不是一个深度神经网络(DNN)。它将文本数据转化为 DNN 可以理解的数字格式,从而实现定制化。
我们甚至可以将 Word2Vec 与 DNN 结合使用来实现这一目的。它不会通过重建训练输入词;相反,它使用语料库中的邻近词来训练词汇。
Doc2Vec(段落向量)将文档与标签关联,它是 Word2Vec 的扩展。Word2Vec 尝试将词与词相关联,而 Doc2Vec(段落向量)则将词与标签相关联。一旦我们将文档表示为向量格式,就可以将这些格式作为输入提供给监督学习算法,将这些向量映射到标签。
本章将涵盖以下几种方法:
-
读取和加载文本数据
-
对数据进行分词并训练模型
-
评估模型
-
从模型生成图形
-
保存和重新加载模型
-
导入 Google News 向量
-
排查问题和调整 Word2Vec 模型
-
使用 Word2Vec 进行基于 CNN 的句子分类
-
使用 Doc2Vec 进行文档分类
技术要求
克隆我们的 GitHub 仓库后,导航到名为 Java-Deep-Learning-Cookbook/05_Implementing_NLP/sourceCode
的目录。然后,通过导入 pom.xml
将 cookbookapp
项目作为 Maven 项目导入。
要开始使用 DL4J 中的 NLP,请在 pom.xml
中添加以下 Maven 依赖:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-nlp</artifactId>
<version>1.0.0-beta3</version>
</dependency>
数据要求
项目目录中有一个 resource
文件夹,其中包含用于 LineIterator
示例所需的数据:
对于 CnnWord2VecSentenceClassificationExample
或 GoogleNewsVectorExampleYou
,你可以从以下网址下载数据集:
-
Google News 向量:
deeplearning4jblob.blob.core.windows.net/resources/wordvectors/GoogleNews-vectors-negative300.bin.gz
-
IMDB 评论数据:
ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
请注意,IMDB 评论数据需要提取两次才能获得实际的数据集文件夹。
对于t-分布随机邻域嵌入(t-SNE)可视化示例,所需的数据(words.txt
)可以在项目根目录中找到。
读取和加载文本数据
我们需要加载原始文本格式的句子,并使用一个下划线迭代器来迭代它们。文本语料库也可以进行预处理,例如转换为小写。在配置 Word2Vec 模型时,可以指定停用词。在本教程中,我们将从各种数据输入场景中提取并加载文本数据。
准备就绪
根据你要加载的数据类型和加载方式,从第 1 步到第 5 步选择一个迭代器方法。
如何做…
- 使用
BasicLineIterator
创建句子迭代器:
File file = new File("raw_sentences.txt");
SentenceIterator iterator = new BasicLineIterator(file);
- 使用
LineSentenceIterator
创建句子迭代器:
File file = new File("raw_sentences.txt");
SentenceIterator iterator = new LineSentenceIterator(file);
- 使用
CollectionSentenceIterator
创建句子迭代器:
List<String> sentences= Arrays.asList("sample text", "sample text", "sample text");
SentenceIterator iter = new CollectionSentenceIterator(sentences);
- 使用
FileSentenceIterator
创建一个句子迭代器:
SentenceIterator iter = new FileSentenceIterator(new File("/home/downloads/sentences.txt"));
- 使用
UimaSentenceIterator
创建一个句子迭代器。
添加以下 Maven 依赖:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-nlp-uima</artifactId>
<version>1.0.0-beta3</version>
</dependency>
然后使用迭代器,如下所示:
SentenceIterator iterator = UimaSentenceIterator.create("path/to/your/text/documents");
你也可以像这样使用它:
SentenceIterator iter = UimaSentenceIterator.create("path/to/your/text/documents");
- 将预处理器应用到文本语料库:
iterator.setPreProcessor(new SentencePreProcessor() {
@Override
public String preProcess(String sentence) {
return sentence.toLowerCase();
}
});
它是如何工作的……
在第 1 步中,我们使用了BasicLineIterator
,这是一个基础的单行句子迭代器,没有涉及任何自定义。
在第 2 步中,我们使用LineSentenceIterator
来遍历多句文本数据。这里每一行都被视为一个句子。我们可以用它来处理多行文本。
在第 3 步中,**CollectionSentenceIterator
**将接受一个字符串列表作为文本输入,每个字符串表示一个句子(文档)。这可以是一个包含推文或文章的列表。
在第 4 步中,**FileSentenceIterator
**处理文件/目录中的句子。每个文件的句子将逐行处理。
对于任何复杂的情况,我们建议使用**UimaSentenceIterator
**,它是一个适当的机器学习级别管道。它会遍历一组文件并分割句子。 UimaSentenceIterator
管道可以执行分词、词形还原和词性标注。其行为可以根据传递的分析引擎进行自定义。这个迭代器最适合复杂数据,比如来自 Twitter API 的数据。分析引擎是一个文本处理管道。
如果你想在遍历一次后重新开始迭代器的遍历,你需要使用 reset()
方法。
我们可以通过在数据迭代器上定义预处理器来规范化数据并移除异常。因此,在步骤 5 中,我们定义了一个归一化器(预处理器)。
还有更多…
我们还可以通过传递分析引擎来使用 UimaSentenceIterator
创建句子迭代器,代码如下所示:
SentenceIterator iterator = new UimaSentenceIterator(path,AnalysisEngineFactory.createEngine( AnalysisEngineFactory.createEngineDescription(TokenizerAnnotator.getDescription(), SentenceAnnotator.getDescription())));
分析引擎的概念借鉴自 UIMA 的文本处理管道。DL4J 提供了用于常见任务的标准分析引擎,支持进一步的文本自定义并决定句子的定义方式。分析引擎是线程安全的,相较于 OpenNLP 的文本处理管道。基于 ClearTK 的管道也被用来处理 DL4J 中常见的文本处理任务。
另请参见
-
UIMA:
uima.apache.org/
-
OpenNLP:
opennlp.apache.org/
分词数据并训练模型
我们需要执行分词操作以构建 Word2Vec 模型。句子(文档)的上下文是由其中的单词决定的。Word2Vec 模型需要的是单词而非句子(文档)作为输入,因此我们需要将句子拆分为原子单元,并在每次遇到空格时创建一个令牌。DL4J 拥有一个分词器工厂,负责创建分词器。 TokenizerFactory
为给定的字符串生成分词器。在这个教程中,我们将对文本数据进行分词,并在其上训练 Word2Vec 模型。
如何操作…
- 创建分词器工厂并设置令牌预处理器:
TokenizerFactory tokenFactory = new DefaultTokenizerFactory();
tokenFactory.setTokenPreProcessor(new CommonPreprocessor());
- 将分词器工厂添加到 Word2Vec 模型配置中:
Word2Vec model = new Word2Vec.Builder()
.minWordFrequency(wordFrequency)
.layerSize(numFeatures)
.seed(seed)
.epochs(numEpochs)
.windowSize(windowSize)
.iterate(iterator)
.tokenizerFactory(tokenFactory)
.build();
- 训练 Word2Vec 模型:
model.fit();
如何运作…
在步骤 1 中,我们使用了 DefaultTokenizerFactory()
来创建分词器工厂,用于将单词进行分词。 这是 Word2Vec 的默认分词器,它基于字符串分词器或流分词器。我们还使用了 CommonPreprocessor
作为令牌预处理器。预处理器会从文本语料库中移除异常。 CommonPreprocessor
是一个令牌预处理器实现,它移除标点符号并将文本转换为小写。它使用 toLowerCase(String)
方法,其行为取决于默认区域设置。
以下是我们在步骤 2 中所做的配置:
-
minWordFrequency()
:这是词语在文本语料库中必须出现的最小次数。在我们的示例中,如果一个词出现次数少于五次,那么它将不会被学习。词语应在文本语料库中出现多次,以便模型能够学习到关于它们的有用特征。在非常大的文本语料库中,适当提高词语出现次数的最小值是合理的。 -
layerSize()
:这定义了词向量中的特征数量。它等同于特征空间的维度数。用 100 个特征表示的词会成为 100 维空间中的一个点。 -
iterate()
:这指定了训练正在进行的批次。我们可以传入一个迭代器,将其转换为词向量。在我们的例子中,我们传入了一个句子迭代器。 -
epochs()
:这指定了整个训练语料库的迭代次数。 -
windowSize()
:这定义了上下文窗口的大小。
还有更多内容……
以下是 DL4J Word2Vec 中可用的其他词法分析器工厂实现,用于为给定输入生成词法分析器:
-
NGramTokenizerFactory
:这是一个基于n-gram 模型创建词法分析器的工厂。N-grams 是由文本语料库中的连续单词或字母组成,长度为n。 -
PosUimaTokenizerFactory
:这是一个创建词法分析器的工厂,能够过滤部分词性标注。 -
UimaTokenizerFactory
:这是一个使用 UIMA 分析引擎进行词法分析的工厂。该分析引擎对非结构化信息进行检查、发现并表示语义内容。非结构化信息包括但不限于文本文件。
以下是 DL4J 中内置的词元预处理器(不包括CommonPreprocessor
):
-
EndingPreProcessor
:这是一个去除文本语料库中词尾的预处理器——例如,它去除词尾的s、ed、.、ly和ing。 -
LowCasePreProcessor
:这是一个将文本转换为小写格式的预处理器。 -
StemmingPreprocessor
:该词法分析器预处理器实现了从CommonPreprocessor
继承的基本清理,并对词元执行英文 Porter 词干提取。 -
CustomStemmingPreprocessor
:这是一个词干预处理器,兼容不同的词干处理程序,例如 lucene/tartarus 定义的SnowballProgram
,如RussianStemmer
、DutchStemmer
和FrenchStemmer
。这意味着它适用于多语言词干化。 -
EmbeddedStemmingPreprocessor
:该词法分析器预处理器使用给定的预处理器并在其基础上对词元执行英文 Porter 词干提取。
我们也可以实现自己的词元预处理器——例如,一个移除所有停用词的预处理器。
评估模型
我们需要在评估过程中检查特征向量的质量。这将帮助我们了解生成的 Word2Vec 模型的质量。在本食谱中,我们将采用两种不同的方法来评估 Word2Vec 模型。
如何操作…
- 找到与给定词语相似的词:
Collection<String> words = model.wordsNearest("season",10);
您将看到类似以下的n输出:
week
game
team
year
world
night
time
country
last
group
- 找到给定两个词的余弦相似度:
double cosSimilarity = model.similarity("season","program");
System.out.println(cosSimilarity);
对于前面的示例,余弦相似度的计算方法如下:
0.2720930874347687
它是如何工作的…
在第一步中,我们通过调用wordsNearest()
,提供输入和数量n
,找到了与给定词语上下文最相似的前n个词。n
的数量是我们希望列出的词数。
在第二步中,我们尝试找出两个给定词语的相似度。为此,我们实际上计算了这两个给定词语之间的余弦相似度。余弦相似度是我们用来衡量词语/文档相似度的有用度量之一。我们使用训练好的模型将输入词语转化为向量。
还有更多…
余弦相似度是通过计算两个非零向量之间的角度余弦值来度量相似度的。这个度量方法衡量的是方向性,而不是大小,因为余弦相似度计算的是文档向量之间的角度,而不是词频。如果角度为零,那么余弦值将达到 1,表示它们非常相似。如果余弦相似度接近零,则表示文档之间的相似度较低,文档向量将是正交(垂直)关系。此外,彼此不相似的文档会产生负的余弦相似度。对于这些文档,余弦相似度可能会达到-1,表示文档向量之间的角度为 180 度。
从模型生成图表
我们已经提到,在训练 Word2Vec 模型时,我们使用了100
的层大小。这意味着可以有 100 个特征,并最终形成一个 100 维的特征空间。我们无法绘制一个 100 维的空间,因此我们依赖于 t-SNE 进行降维。在本食谱中,我们将从 Word2Vec 模型中生成 2D 图表。
准备工作
对于这个配方,请参考以下 t-SNE 可视化示例://github.com/PacktPublishing/Java-Deep-Learning-Cookbook/blob/master/05_Implementing_NLP/sourceCode/cookbookapp/src/main/java/com/javadeeplearningcookbook/examples/TSNEVisualizationExample.java。
示例将在 CSV 文件中生成 t-SNE 图表。
如何操作…
- 在源代码的开头添加以下代码片段,以设置当前 JVM 运行时的数据类型:
Nd4j.setDataType(DataBuffer.Type.DOUBLE);
- 将词向量写入文件:
WordVectorSerializer.writeWordVectors(model.lookupTable(),new File("words.txt"));
- 使用
WordVectorSerializer
将唯一单词的权重分离成自己的列表:
Pair<InMemoryLookupTable,VocabCache> vectors = WordVectorSerializer.loadTxt(new File("words.txt"));
VocabCache cache = vectors.getSecond();
INDArray weights = vectors.getFirst().getSyn0();
- 创建一个列表来添加所有独特的词:
List<String> cacheList = new ArrayList<>();
for(int i=0;i<cache.numWords();i++){
cacheList.add(cache.wordAtIndex(i));
}
- 使用
BarnesHutTsne
构建一个双树 t-SNE 模型来进行降维:
BarnesHutTsne tsne = new BarnesHutTsne.Builder()
.setMaxIter(100)
.theta(0.5)
.normalize(false)
.learningRate(500)
.useAdaGrad(false)
.build();
- 建立 t-SNE 值并将其保存到文件:
tsne.fit(weights);
tsne.saveAsFile(cacheList,"tsne-standard-coords.csv");
如何操作…
在第 2 步中,来自训练模型的词向量被保存到本地计算机,以便进一步处理。
在第 3 步中,我们使用 WordVectorSerializer
从所有独特的词向量中提取数据。基本上,这将从提到的输入词汇中加载一个内存中的 VocabCache。但它不会将整个词汇表/查找表加载到内存中,因此能够处理通过网络传输的大型词汇表。
VocabCache
管理存储 Word2Vec 查找表所需的信息。我们需要将标签传递给 t-SNE 模型,而标签就是通过词向量表示的词。
在第 4 步中,我们创建了一个列表来添加所有独特的词。
BarnesHutTsne
短语是 DL4J 实现类,用于双树 t-SNE 模型。Barnes–Hut 算法采用双树近似策略。建议使用其他方法,如 主成分分析 (PCA) 或类似方法,将维度降到最多 50。
在第 5 步中,我们使用 BarnesHutTsne
设计了一个 t-SNE 模型。这个模型包含以下组件:
-
theta()
:这是 Barnes–Hut 平衡参数。 -
useAdaGrad()
:这是在自然语言处理应用中使用的传统 AdaGrad 实现。
一旦 t-SNE 模型设计完成,我们可以使用从词汇中加载的权重来拟合它。然后,我们可以将特征图保存到 Excel 文件中,如第 6 步所示。
特征坐标将如下所示:
我们可以使用 gnuplot 或任何其他第三方库来绘制这些坐标。DL4J 还支持基于 JFrame 的可视化。
保存并重新加载模型
模型持久化是一个关键话题,尤其是在与不同平台操作时。我们还可以重用该模型进行进一步的训练(迁移学习)或执行任务。
在本示例中,我们将持久化(保存和重新加载)Word2Vec 模型。
如何操作…
- 使用
WordVectorSerializer
保存 Word2Vec 模型:
WordVectorSerializer.writeWord2VecModel(model, "model.zip");
- 使用
WordVectorSerializer
重新加载 Word2Vec 模型:
Word2Vec word2Vec = WordVectorSerializer.readWord2VecModel("model.zip");
如何操作…
在第 1 步中,writeWord2VecModel()
方法将 Word2Vec 模型保存为压缩的 ZIP 文件,并将其发送到输出流。它保存了完整的模型,包括 Syn0
和 Syn1
。Syn0
是存储原始词向量的数组,并且是一个投影层,可以将词的独热编码转换为正确维度的密集嵌入向量。Syn1
数组代表模型的内部隐含权重,用于处理输入/输出。
在第 2 步中,readWord2VecModel()
方法加载以下格式的模型:
-
二进制模型,可以是压缩的或未压缩的
-
流行的 CSV/Word2Vec 文本格式
-
DL4J 压缩格式
请注意,只有权重会通过此方法加载。
导入 Google 新闻向量
Google 提供了一个预训练的大型 Word2Vec 模型,包含大约 300 万个 300 维的英文单词向量。它足够大,并且预训练后能够展示出良好的结果。我们将使用 Google 向量作为输入单词向量进行评估。运行此示例至少需要 8 GB 的 RAM。在本教程中,我们将导入 Google News 向量并进行评估。
如何操作…
- 导入 Google News 向量:
File file = new File("GoogleNews-vectors-negative300.bin.gz");
Word2Vec model = WordVectorSerializer.readWord2VecModel(file);
- 对 Google News 向量进行评估:
model.wordsNearest("season",10))
工作原理…
在步骤 1 中,使用 readWord2VecModel()
方法加载保存为压缩文件格式的预训练 Google News 向量。
在步骤 2 中,使用 wordsNearest()
方法根据正负分数查找与给定单词最相近的单词。
执行完步骤 2 后,我们应该看到以下结果:
你可以尝试使用自己的输入来测试此技术,看看不同的结果。
还有更多…
Google News 向量的压缩模型文件大小为 1.6 GB。加载和评估该模型可能需要一些时间。如果你第一次运行代码,可能会观察到 OutOfMemoryError
错误:
现在我们需要调整虚拟机(VM)选项,以便为应用程序提供更多内存。你可以在 IntelliJ IDE 中调整 VM 选项,如下图所示。你只需要确保分配了足够的内存值,并重新启动应用程序:
故障排除和调优 Word2Vec 模型
Word2Vec 模型可以进一步调整,以产生更好的结果。在内存需求较高而资源不足的情况下,可能会发生运行时错误。我们需要对它们进行故障排除,理解发生的原因并采取预防措施。在本教程中,我们将对 Word2Vec 模型进行故障排除和调优。
如何操作…
-
在应用程序控制台/日志中监控
OutOfMemoryError
,检查是否需要增加堆空间。 -
检查 IDE 控制台中的内存溢出错误。如果出现内存溢出错误,请向 IDE 中添加 VM 选项,以增加 Java 堆内存。
-
在运行 Word2Vec 模型时,监控
StackOverflowError
。注意以下错误:
这个错误可能是由于项目中不必要的临时文件造成的。
-
对 Word2Vec 模型进行超参数调优。你可能需要多次训练,使用不同的超参数值,如
layerSize
、windowSize
等。 -
在代码层面推导内存消耗。根据代码中使用的数据类型及其消耗的数据量来计算内存消耗。
工作原理…
内存溢出错误通常表明需要调整 VM 选项。如何调整这些参数取决于硬件的内存容量。对于步骤 1,如果你使用的是像 IntelliJ 这样的 IDE,你可以通过 VM 属性如-Xmx
、-Xms
等提供 VM 选项。VM 选项也可以通过命令行使用。
例如,要将最大内存消耗增加到 8 GB,你需要在 IDE 中添加-Xmx8G
VM
参数。
为了缓解步骤 2 中提到的StackOverflowError
,我们需要删除在项目目录下由 Java 程序执行时创建的临时文件。这些临时文件应类似于以下内容:
关于步骤 3,如果你观察到你的 Word2Vec 模型未能包含原始文本数据中的所有词汇,那么你可能会考虑增加 Word2Vec 模型的 层大小 。这个layerSize
实际上就是输出向量维度或特征空间维度。例如,在我们的代码中,我们的layerSize
为100
。这意味着我们可以将其增大到更大的值,比如200
,作为一种解决方法:
Word2Vec model = new Word2Vec.Builder()
.iterate(iterator)
.tokenizerFactory(tokenizerFactory)
.minWordFrequency(5)
.layerSize(200)
.seed(42)
.windowSize(5)
.build();
如果你有一台 GPU 加速的机器,你可以用它来加速 Word2Vec 的训练时间。只要确保 DL4J 和 ND4J 后端的依赖项按常规添加即可。如果结果看起来仍然不对,确保没有归一化问题。
如wordsNearest()
等任务默认使用归一化权重,而其他任务则需要未归一化的权重。
关于步骤 4,我们可以使用传统方法。权重矩阵是 Word2Vec 中内存消耗最大的部分。其计算方法如下:
词汇数 * 维度数 * 2 * 数据类型内存占用
例如,如果我们的 Word2Vec 模型包含 100,000 个词汇,且使用 long
数据类型,100 个维度,则权重矩阵的内存占用为 100,000 * 100 * 2 * 8(long 数据类型大小)= 160 MB RAM,仅用于权重矩阵。
请注意,DL4J UI 仅提供内存消耗的高层概览。
另见
- 请参考官方的 DL4J 文档,
deeplearning4j.org/docs/latest/deeplearning4j-config-memory
以了解更多关于内存管理的信息。
使用 Word2Vec 进行 CNN 的句子分类
神经网络需要数值输入才能按预期执行操作。对于文本输入,我们不能直接将文本数据输入神经网络。由于Word2Vec
将文本数据转换为向量,因此可以利用 Word2Vec,使得我们可以将其与神经网络结合使用。我们将使用预训练的 Google News 词向量模型作为参考,并在其基础上训练 CNN 网络。完成此过程后,我们将开发一个 IMDB 评论分类器,将评论分类为正面或负面。根据论文arxiv.org/abs/1408.5882
中的内容,结合预训练的 Word2Vec 模型和 CNN 会带来更好的结果。
我们将采用定制的 CNN 架构,并结合 2014 年 Yoon Kim 在其论文中建议的预训练词向量模型,arxiv.org/abs/1408.5882
。该架构稍微比标准 CNN 模型更为复杂。我们还将使用两个巨大的数据集,因此应用程序可能需要相当大的 RAM 和性能基准,以确保可靠的训练时间并避免OutOfMemory
错误。
在本教程中,我们将使用 Word2Vec 和 CNN 进行句子分类。
准备开始
你还应该确保通过更改 VM 选项来增加更多的 Java 堆空间——例如,如果你的 RAM 为 8GB,可以设置-Xmx2G -Xmx6G
作为 VM 参数。
我们将在第一步中提取 IMDB 数据。文件结构将如下所示:
如果我们进一步进入数据集目录,你会看到它们被标记为以下内容:
如何执行…
- 使用
WordVectorSerializer
加载词向量模型:
WordVectors wordVectors = WordVectorSerializer.loadStaticModel(new File(WORD_VECTORS_PATH));
- 使用
FileLabeledSentenceProvider
创建句子提供器:
Map<String,List<File>> reviewFilesMap = new HashMap<>();
reviewFilesMap.put("Positive", Arrays.asList(filePositive.listFiles()));
reviewFilesMap.put("Negative", Arrays.asList(fileNegative.listFiles()));
LabeledSentenceProvider sentenceProvider = new FileLabeledSentenceProvider(reviewFilesMap, rndSeed);
- 使用
CnnSentenceDataSetIterator
创建训练迭代器或测试迭代器,以加载 IMDB 评论数据:
CnnSentenceDataSetIterator iterator = new CnnSentenceDataSetIterator.Builder(CnnSentenceDataSetIterator.Format.CNN2D)
.sentenceProvider(sentenceProvider)
.wordVectors(wordVectors) //we mention word vectors here
.minibatchSize(minibatchSize)
.maxSentenceLength(maxSentenceLength) //words with length greater than this will be ignored.
.useNormalizedWordVectors(false)
.build();
- 通过添加默认的超参数来创建
ComputationGraph
配置:
ComputationGraphConfiguration.GraphBuilder builder = new NeuralNetConfiguration.Builder()
.weightInit(WeightInit.RELU)
.activation(Activation.LEAKYRELU)
.updater(new Adam(0.01))
.convolutionMode(ConvolutionMode.Same) //This is important so we can 'stack' the results later
.l2(0.0001).graphBuilder();
- 使用
addLayer()
方法配置ComputationGraph
的层:
builder.addLayer("cnn3", new ConvolutionLayer.Builder()
.kernelSize(3,vectorSize) //vectorSize=300 for google vectors
.stride(1,vectorSize)
.nOut(100)
.build(), "input");
builder.addLayer("cnn4", new ConvolutionLayer.Builder()
.kernelSize(4,vectorSize)
.stride(1,vectorSize)
.nOut(100)
.build(), "input");
builder.addLayer("cnn5", new ConvolutionLayer.Builder()
.kernelSize(5,vectorSize)
.stride(1,vectorSize)
.nOut(100)
.build(), "input");
- 将卷积模式设置为稍后堆叠结果:
builder.addVertex("merge", new MergeVertex(), "cnn3", "cnn4", "cnn5")
- 创建并初始化
ComputationGraph
模型:
ComputationGraphConfiguration config = builder.build();
ComputationGraph net = new ComputationGraph(config);
net.init();
- 使用
fit()
方法进行训练:
for (int i = 0; i < numEpochs; i++) {
net.fit(trainIterator);
}
- 评估结果:
Evaluation evaluation = net.evaluate(testIter);
System.out.println(evaluation.stats());
- 获取 IMDB 评论数据的预测:
INDArray features = ((CnnSentenceDataSetIterator)testIterator).loadSingleSentence(contents);
INDArray predictions = net.outputSingle(features);
List<String> labels = testIterator.getLabels();
System.out.println("\n\nPredictions for first negative review:");
for( int i=0; i<labels.size(); i++ ){
System.out.println("P(" + labels.get(i) + ") = " + predictions.getDouble(i));
}
它是如何工作的…
在第 1 步,我们使用 loadStaticModel()
从给定路径加载模型;但是,你也可以使用 readWord2VecModel()
。与 readWord2VecModel()
不同,loadStaticModel()
使用主机内存。
在第 2 步,FileLabeledSentenceProvider
被用作数据源从文件中加载句子/文档。我们使用相同的方式创建了 CnnSentenceDataSetIterator
。CnnSentenceDataSetIterator
处理将句子转换为 CNN 的训练数据,其中每个单词使用指定的词向量模型进行编码。句子和标签由 LabeledSentenceProvider
接口提供。LabeledSentenceProvider
的不同实现提供了不同的加载句子/文档及标签的方式。
在第 3 步,我们创建了 CnnSentenceDataSetIterator
来创建训练/测试数据集迭代器。我们在这里配置的参数如下:
-
sentenceProvider()
:将句子提供者(数据源)添加到CnnSentenceDataSetIterator
。 -
wordVectors()
:将词向量引用添加到数据集迭代器中——例如,Google News 词向量。 -
useNormalizedWordVectors()
:设置是否可以使用标准化的词向量。
在第 5 步,我们为 ComputationGraph
模型创建了层。
ComputationGraph
配置是一个用于神经网络的配置对象,具有任意连接结构。它类似于多层配置,但允许网络架构具有更大的灵活性。
我们还创建了多个卷积层,并将它们按不同的滤波宽度和特征图堆叠在一起。
在第 6 步,MergeVertex
在激活这三层卷积层时执行深度连接。
一旦第 8 步之前的所有步骤完成,我们应该会看到以下评估指标:
在第 10 步,contents
指的是来自单句文档的字符串格式内容。
对于负面评论内容,在第 9 步后我们会看到以下结果:
这意味着该文档有 77.8%的概率呈现负面情绪。
还有更多内容…
使用从预训练无监督模型中提取的词向量进行初始化是提升性能的常见方法。如果你记得我们在这个示例中做过的事情,你会记得我们曾为同样的目的使用了预训练的 Google News 向量。对于 CNN,当其应用于文本而非图像时,我们将处理一维数组向量来表示文本。我们执行相同的步骤,如卷积和最大池化与特征图,正如在第四章《构建卷积神经网络》中讨论的那样。唯一的区别是,我们使用的是表示文本的向量,而不是图像像素。随后,CNN 架构在 NLP 任务中展现了出色的结果。可以在www.aclweb.org/anthology/D14-1181
中找到有关此主题的进一步见解。
计算图的网络架构是一个有向无环图,其中图中的每个顶点都是一个图顶点。图顶点可以是一个层,或者是定义随机前向/反向传递功能的顶点。计算图可以有任意数量的输入和输出。我们需要叠加多个卷积层,但在普通的 CNN 架构中这是不可能的。
ComputaionGraph
提供了一个配置选项,称为 convolutionMode
。convolutionMode
决定了网络配置以及卷积和下采样层的卷积操作如何执行(对于给定的输入大小)。网络配置如 stride
/padding
/kernelSize
适用于特定的卷积模式。我们通过设置 convolutionMode
来配置卷积模式,因为我们希望将三个卷积层的结果叠加为一个并生成预测。
卷积层和下采样层的输出尺寸在每个维度上计算方式如下:
outputSize = (inputSize - kernelSize + 2padding) / stride + 1*
如果 outputSize
不是整数,则在网络初始化或前向传递过程中将抛出异常。我们之前讨论过 MergeVertex
,它用于组合两个或更多层的激活值。我们使用 MergeVertex
来执行与卷积层相同的操作。合并将取决于输入类型——例如,如果我们想要合并两个卷积层,样本大小(batchSize
)为 100
,并且 depth
分别为 depth1
和 depth2
,则 merge
将按以下规则叠加结果:
depth = depth1 + depth2
使用 Doc2Vec 进行文档分类
Word2Vec 将词汇与词汇相关联,而 Doc2Vec(也称为段落向量)的目的是将标签与词汇相关联。在本食谱中,我们将讨论 Doc2Vec。文档按特定方式标记,使文档根目录下的子目录表示文档标签。例如,所有与金融相关的数据应放在finance
子目录下。在这个食谱中,我们将使用 Doc2Vec 进行文档分类。
如何操作…
- 使用
FileLabelAwareIterator
提取并加载数据:
LabelAwareIterator labelAwareIterator = new FileLabelAwareIterator.Builder()
.addSourceFolder(new ClassPathResource("label").getFile()).build();
- 使用
TokenizerFactory
创建一个分词器:
TokenizerFactory tokenizerFactory = new DefaultTokenizerFactory();
tokenizerFactory.setTokenPreProcessor(new CommonPreprocessor());
- 创建一个
ParagraphVector
模型定义:
ParagraphVectors paragraphVectors = new ParagraphVectors.Builder()
.learningRate(learningRate)
.minLearningRate(minLearningRate)
.batchSize(batchSize)
.epochs(epochs)
.iterate(labelAwareIterator)
.trainWordVectors(true)
.tokenizerFactory(tokenizerFactory)
.build();
- 通过调用
fit()
方法训练ParagraphVectors
:
paragraphVectors.fit();
- 为未标记的数据分配标签并评估结果:
ClassPathResource unClassifiedResource = new ClassPathResource("unlabeled");
FileLabelAwareIterator unClassifiedIterator = new FileLabelAwareIterator.Builder()
.addSourceFolder(unClassifiedResource.getFile())
.build();
- 存储权重查找表:
InMemoryLookupTable<VocabWord> lookupTable = (InMemoryLookupTable<VocabWord>)paragraphVectors.getLookupTable();
- 如下伪代码所示,预测每个未分类文档的标签:
while (unClassifiedIterator.hasNextDocument()) {
//Calculate the domain vector of each document.
//Calculate the cosine similarity of the domain vector with all
//the given labels
//Display the results
}
- 从文档中创建标记,并使用迭代器来检索文档实例:
LabelledDocument labelledDocument = unClassifiedIterator.nextDocument();
List<String> documentAsTokens = tokenizerFactory.create(labelledDocument.getContent()).getTokens();
- 使用查找表来获取词汇信息(
VocabCache
):
VocabCache vocabCache = lookupTable.getVocab();
- 计算
VocabCache
中匹配的所有实例:
AtomicInteger cnt = new AtomicInteger(0);
for (String word: documentAsTokens) {
if (vocabCache.containsWord(word)){
cnt.incrementAndGet();
}
}
INDArray allWords = Nd4j.create(cnt.get(), lookupTable.layerSize());
- 将匹配词的词向量存储在词汇中:
cnt.set(0);
for (String word: documentAsTokens) {
if (vocabCache.containsWord(word))
allWords.putRow(cnt.getAndIncrement(), lookupTable.vector(word));
}
- 通过计算词汇嵌入的平均值来计算领域向量:
INDArray documentVector = allWords.mean(0);
- 检查文档向量与标记词向量的余弦相似度:
List<String> labels = labelAwareIterator.getLabelsSource().getLabels();
List<Pair<String, Double>> result = new ArrayList<>();
for (String label: labels) {
INDArray vecLabel = lookupTable.vector(label);
if (vecLabel == null){
throw new IllegalStateException("Label '"+ label+"' has no known vector!");
}
double sim = Transforms.cosineSim(documentVector, vecLabel);
result.add(new Pair<String, Double>(label, sim));
}
- 显示结果:
for (Pair<String, Double> score: result) {
log.info(" " + score.getFirst() + ": " + score.getSecond());
}
它是如何工作的…
在第 1 步中,我们使用FileLabelAwareIterator
创建了数据集迭代器。
FileLabelAwareIterator
是一个简单的基于文件系统的LabelAwareIterator
接口。它假设你有一个或多个按以下方式组织的文件夹:
-
一级子文件夹:标签名称
-
二级子文件夹:该标签对应的文档
查看以下截图,了解此数据结构的示例:
在第 3 步中,我们通过添加所有必需的超参数创建了**ParagraphVector
**。段落向量的目的是将任意文档与标签关联。段落向量是 Word2Vec 的扩展,学习将标签和词汇相关联,而 Word2Vec 将词汇与其他词汇相关联。我们需要为段落向量定义标签,才能使其正常工作。
有关第 5 步中所做内容的更多信息,请参阅以下目录结构(项目中的unlabeled
目录下):
目录名称可以是随机的,不需要特定的标签。我们的任务是为这些文档找到适当的标签(文档分类)。词嵌入存储在查找表中。对于任何给定的词汇,查找表将返回一个词向量。
词汇嵌入存储在查找表中。对于任何给定的词汇,查找表将返回一个词向量。
在第 6 步中,我们通过段落向量创建了InMemoryLookupTable
。InMemoryLookupTable
是 DL4J 中的默认词汇查找表。基本上,查找表作为隐藏层操作,词汇/文档向量作为输出。
第 8 步到第 12 步仅用于计算每个文档的领域向量。
在第 8 步中,我们使用第 2 步中创建的分词器为文档创建了令牌。在第 9 步中,我们使用第 6 步中创建的查找表来获取VocabCache
。VocabCache
存储了操作查找表所需的信息。我们可以使用VocabCache
在查找表中查找单词。
在第 11 步中,我们将单词向量与特定单词的出现次数一起存储在一个 INDArray 中。
在第 12 步中,我们计算了这个 INDArray 的均值,以获取文档向量。
在零维度上的均值意味着它是跨所有维度计算的。
在第 13 步中,余弦相似度是通过调用 ND4J 提供的cosineSim()
方法计算的。我们使用余弦相似度来计算文档向量的相似性。ND4J 提供了一个功能接口,用于计算两个领域向量的余弦相似度。vecLabel
表示从分类文档中获取的标签的文档向量。然后,我们将vecLabel
与我们的未标记文档向量documentVector
进行比较。
在第 14 步之后,你应该看到类似于以下的输出:
我们可以选择具有更高余弦相似度值的标签。从前面的截图中,我们可以推断出第一篇文档更可能是与金融相关的内容,概率为 69.7%。第二篇文档更可能是与健康相关的内容,概率为 53.2%。
第六章:构建用于时间序列的 LSTM 网络
在本章中,我们将讨论如何构建长短期记忆(LSTM)神经网络来解决医学时间序列问题。我们将使用来自 4,000 名重症监护病房(ICU)患者的数据。我们的目标是通过给定的一组通用和序列特征来预测患者的死亡率。我们有六个通用特征,如年龄、性别和体重。此外,我们还有 37 个序列特征,如胆固醇水平、体温、pH 值和葡萄糖水平。每个患者都有多个针对这些序列特征的测量记录。每个患者的测量次数不同。此外,不同患者之间测量的时间间隔也有所不同。
由于数据的序列性质,LSTM 非常适合此类问题。我们也可以使用普通的递归神经网络(RNN)来解决,但 LSTM 的目的是避免梯度消失和梯度爆炸。LSTM 能够捕捉长期依赖关系,因为它具有单元状态。
在本章中,我们将涵盖以下配方:
-
提取和读取临床数据
-
加载和转换数据
-
构建网络的输入层
-
构建网络的输出层
-
训练时间序列数据
-
评估 LSTM 网络的效率
技术要求
克隆 GitHub 仓库后,进入Java-Deep-Learning-Cookbook/06_Constructing_LSTM_Network_for_time_series/sourceCode
目录。然后,通过导入pom.xml
,将cookbookapp-lstm-time-series
项目作为 Maven 项目导入。
从这里下载临床时间序列数据:skymindacademy.blob.core.windows.net/physionet2012/physionet2012.tar.gz
。该数据集来自 PhysioNet 心脏病挑战 2012。
下载后解压文件。你应该会看到以下目录结构:
特征存储在名为sequence
的目录中,标签存储在名为mortality
的目录中。暂时忽略其他目录。你需要在源代码中更新特征/标签的文件路径,以便运行示例。
提取和读取临床数据
ETL(提取、转换、加载的缩写)是任何深度学习问题中最重要的一步。在本方案中,我们将重点讨论数据提取,其中我们将讨论如何提取和处理临床时间序列数据。我们在前几章中了解了常规数据类型,例如普通的 CSV/文本数据和图像。现在,让我们讨论如何处理时间序列数据。我们将使用临床时间序列数据来预测患者的死亡率。
如何操作…
- 创建一个
NumberedFileInputSplit
实例,将所有特征文件合并在一起:
new NumberedFileInputSplit(FEATURE_DIR+"/%d.csv",0,3199);
- 创建一个
NumberedFileInputSplit
实例,将所有标签文件合并在一起:
new NumberedFileInputSplit(LABEL_DIR+"/%d.csv",0,3199);
- 为特征/标签创建记录读取器:
SequenceRecordReader trainFeaturesReader = new CSVSequenceRecordReader(1, ",");
trainFeaturesReader.initialize(new NumberedFileInputSplit(FEATURE_DIR+"/%d.csv",0,3199));
SequenceRecordReader trainLabelsReader = new CSVSequenceRecordReader();
trainLabelsReader.initialize(new NumberedFileInputSplit(LABEL_DIR+"/%d.csv",0,3199));
它是如何工作的…
时间序列数据是三维的。每个样本由它自己的文件表示。列中的特征值是在不同的时间步骤上测量的,这些时间步骤由行表示。例如,在第 1 步中,我们看到了下面的快照,其中显示了时间序列数据:
每个文件代表一个不同的序列。当你打开文件时,你会看到在不同时间步骤上记录的观察值(特征),如下所示:
标签包含在一个 CSV 文件中,其中包含值0
表示死亡,值1
表示生存。例如,对于1.csv
中的特征,输出标签位于死亡目录下的1.csv
中。请注意,我们共有 4000 个样本。我们将整个数据集分为训练集和测试集,使得训练数据包含 3200 个样本,测试数据包含 800 个样本。
在第 3 步中,我们使用了NumberedFileInputSplit
来读取并将所有文件(特征/标签)以编号格式合并在一起。
CSVSequenceRecordReader
用于读取 CSV 格式的数据序列,其中每个序列都定义在自己的文件中。
如上图所示,第一行仅用于特征标签,需要跳过。
因此,我们创建了以下 CSV 序列读取器:
SequenceRecordReader trainFeaturesReader = new CSVSequenceRecordReader(1, ",");
加载和转换数据
数据提取阶段之后,我们需要在将数据加载到神经网络之前进行数据转换。在数据转换过程中,确保数据集中的任何非数字字段都被转换为数字字段是非常重要的。数据转换的作用不仅仅是这样。我们还可以去除数据中的噪声并调整数值。在此方案中,我们将数据加载到数据集迭代器中,并按需要转换数据。
在上一个方案中,我们将时间序列数据提取到记录读取器实例中。现在,让我们从这些实例中创建训练/测试迭代器。我们还将分析数据并在需要时进行转换。
准备就绪
在我们继续之前,请参考下面的截图中的数据集,以了解每个数据序列的样子:
首先,我们需要检查数据中是否存在任何非数值特征。我们需要将数据加载到神经网络中进行训练,并且它应该是神经网络能够理解的格式。我们有一个顺序数据集,并且看起来没有非数值值。所有 37 个特征都是数值型的。如果查看特征数据的范围,它接近于标准化格式。
它是如何做的…
- 使用
SequenceRecordReaderDataSetIterator
创建训练迭代器:
DataSetIterator trainDataSetIterator = new SequenceRecordReaderDataSetIterator(trainFeaturesReader,trainLabelsReader,batchSize,numberOfLabels,false, SequenceRecordReaderDataSetIterator.AlignmentMode.ALIGN_END);
- 使用
SequenceRecordReaderDataSetIterator
创建测试迭代器:
DataSetIterator testDataSetIterator = new SequenceRecordReaderDataSetIterator(testFeaturesReader,testLabelsReader,batchSize,numberOfLabels,false, SequenceRecordReaderDataSetIterator.AlignmentMode.ALIGN_END);
它是如何工作的…
在步骤 1 和 2 中,我们在创建训练和测试数据集的迭代器时使用了AlignmentMode
。AlignmentMode
处理不同长度的输入/标签(例如,一对多和多对一的情况)。以下是一些对齐模式的类型:
-
ALIGN_END
:这是用于在最后一个时间步对齐标签或输入。基本上,它在输入或标签的末尾添加零填充。 -
ALIGN_START
:这是用于在第一个时间步对齐标签或输入。基本上,它在输入或标签的末尾添加零填充。 -
EQUAL_LENGTH
:这假设输入时间序列和标签具有相同的长度,并且所有示例的长度都相同。 -
SequenceRecordReaderDataSetIterator
:这个工具帮助从传入的记录读取器生成时间序列数据集。记录读取器应基于序列数据,最适合用于时间序列数据。查看传递给构造函数的属性:
DataSetIterator testDataSetIterator = new SequenceRecordReaderDataSetIterator(testFeaturesReader,testLabelsReader,batchSize,numberOfLabels,false, SequenceRecordReaderDataSetIterator.AlignmentMode.ALIGN_END);
testFeaturesReader
和 testLabelsReader
分别是输入数据(特征)和标签(用于评估)的记录读取器对象。布尔属性(false
)表示我们是否有回归样本。由于我们在讨论时间序列分类问题,这里为 false
。对于回归数据,必须将其设置为 true
。
构建网络的输入层
LSTM 层将具有门控单元,能够捕捉长期依赖关系,不同于常规 RNN。让我们讨论一下如何在网络配置中添加一个特殊的 LSTM 层。我们可以使用多层网络或计算图来创建模型。
在这个示例中,我们将讨论如何为我们的 LSTM 神经网络创建输入层。在以下示例中,我们将构建一个计算图,并向其中添加自定义层。
它是如何做的…
- 使用
ComputationGraph
配置神经网络,如下所示:
ComputationGraphConfiguration.GraphBuilder builder = new NeuralNetConfiguration.Builder()
.seed(RANDOM_SEED)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.weightInit(WeightInit.XAVIER)
.updater(new Adam())
.dropOut(0.9)
.graphBuilder()
.addInputs("trainFeatures");
- 配置 LSTM 层:
new LSTM.Builder()
.nIn(INPUTS)
.nOut(LSTM_LAYER_SIZE)
.forgetGateBiasInit(1)
.activation(Activation.TANH)
.build(),"trainFeatures");
- 将 LSTM 层添加到
ComputationGraph
配置中:
builder.addLayer("L1", new LSTM.Builder()
.nIn(86)
.nOut(200)
.forgetGateBiasInit(1)
.activation(Activation.TANH)
.build(),"trainFeatures");
它是如何工作的…
在步骤 1 中,我们在调用 graphBuilder()
方法后定义了一个图顶点输入,如下所示:
builder.addInputs("trainFeatures");
通过调用graphBuilder()
,我们实际上是在构建一个图构建器,以创建计算图配置。
一旦 LSTM 层在步骤 3 中被添加到ComputationGraph
配置中,它们将作为输入层存在于ComputationGraph
配置中。我们将前面提到的图顶点输入(trainFeatures
)传递给我们的 LSTM 层,如下所示:
builder.addLayer("L1", new LSTM.Builder()
.nIn(INPUTS)
.nOut(LSTM_LAYER_SIZE)
.forgetGateBiasInit(1)
.activation(Activation.TANH)
.build(),"trainFeatures");
最后的属性trainFeatures
指的是图的顶点输入。在这里,我们指定L1
层为输入层。
LSTM 神经网络的主要目的是捕获数据中的长期依赖关系。tanh
函数的导数在达到零值之前可以持续很长一段时间。因此,我们使用Activation.TANH
作为 LSTM 层的激活函数。
forgetGateBiasInit()
设置忘记门的偏置初始化。1
到5
之间的值可能有助于学习或长期依赖关系的捕获。
我们使用Builder
策略来定义 LSTM 层及其所需的属性,例如nIn
和nOut
。这些是输入/输出神经元,正如我们在第三章,构建二分类深度神经网络和第四章,构建卷积神经网络中所看到的那样。我们通过addLayer
方法添加 LSTM 层。
构建网络的输出层
输出层设计是配置神经网络层的最后一步。我们的目标是实现一个时间序列预测模型。我们需要开发一个时间序列分类器来预测患者的死亡率。输出层的设计应该反映这一目标。在本教程中,我们将讨论如何为我们的用例构建输出层。
如何操作…
- 使用
RnnOutputLayer
设计输出层:
new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX)
.nIn(LSTM_LAYER_SIZE).nOut(labelCount).build()
- 使用
addLayer()
方法将输出层添加到网络配置中:
builder.addLayer("predictMortality", new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX)
.nIn(LSTM_LAYER_SIZE).nOut(labelCount).build(),"L1");
如何工作…
在构建输出层时,注意前一个 LSTM 输入层的nOut
值。这个值将作为输出层的nIn
。nIn
应该与前一个 LSTM 输入层的nOut
值相同。
在步骤 1 和步骤 2 中,我们实际上是在创建一个 LSTM 神经网络,它是常规 RNN 的扩展版本。我们使用了门控单元来实现某种内部记忆,以保持长期依赖关系。为了使预测模型能够进行预测(如患者死亡率),我们需要通过输出层生成概率。在步骤 2 中,我们看到SOFTMAX
被用作神经网络输出层的激活函数。这个激活函数在计算特定标签的概率时非常有用。MCXENT
是 ND4J 中负对数似然误差函数的实现。由于我们使用的是负对数似然损失函数,它将在某次迭代中,当某个标签的概率值较高时,推动结果的输出。
RnnOutputLayer
更像是常规前馈网络中的扩展版本输出层。我们还可以将RnnOutputLayer
用于一维的 CNN 层。还有另一个输出层,叫做RnnLossLayer
,其输入和输出激活相同。在RnnLossLayer
的情况下,我们有三个维度,分别是[miniBatchSize, nIn, timeSeriesLength]
和[miniBatchSize, nOut, timeSeriesLength]
的形状。
请注意,我们必须指定要连接到输出层的输入层。再看看这段代码:
builder.addLayer("predictMortality", new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX)
.nIn(LSTM_LAYER_SIZE).nOut(labelCount).build(),"L1")
我们提到过,L1
层是从输入层到输出层的。
训练时间序列数据
到目前为止,我们已经构建了网络层和参数来定义模型配置。现在是时候训练模型并查看结果了。然后,我们可以检查是否可以修改任何先前定义的模型配置,以获得最佳结果。在得出第一个训练会话的结论之前,务必多次运行训练实例。我们需要观察稳定的输出,以确保性能稳定。
在这个示例中,我们将训练 LSTM 神经网络来处理加载的时间序列数据。
如何操作……
- 从之前创建的模型配置中创建
ComputationGraph
模型:
ComputationGraphConfiguration configuration = builder.build();
ComputationGraph model = new ComputationGraph(configuration);
- 加载迭代器并使用
fit()
方法训练模型:
for(int i=0;i<epochs;i++){
model.fit(trainDataSetIterator);
}
你也可以使用以下方法:
model.fit(trainDataSetIterator,epochs);
然后,我们可以通过直接在fit()
方法中指定epochs
参数来避免使用for
循环。
它是如何工作的……
在第 2 步中,我们将数据集迭代器和训练轮数传递给训练会话。我们使用了一个非常大的时间序列数据集,因此较大的轮数将导致更长的训练时间。此外,较大的轮数并不总是能保证良好的结果,甚至可能导致过拟合。所以,我们需要多次运行训练实验,以找到轮数和其他重要超参数的最佳值。最佳值是指你观察到神经网络性能最大化的界限。
实际上,我们在使用内存门控单元优化训练过程。正如我们之前在构建网络的输入层这一部分所讨论的,LSTM 非常适合在数据集中保持长期依赖关系。
评估 LSTM 网络的效率
在每次训练迭代后,通过评估模型并与一组评估指标进行比较,来衡量网络的效率。我们根据评估指标进一步优化模型,并在接下来的训练迭代中进行调整。我们使用测试数据集进行评估。请注意,我们在这个用例中执行的是二分类任务。我们预测的是患者存活的概率。对于分类问题,我们可以绘制接收者操作特征(ROC)曲线,并计算曲线下面积(AUC)分数来评估模型的表现。AUC 分数的范围是从 0 到 1。AUC 分数为 0 表示 100% 的预测失败,而 1 表示 100% 的预测成功。
如何实现…
- 使用 ROC 进行模型评估:
ROC evaluation = new ROC(thresholdSteps);
- 从测试数据的特征生成输出:
DataSet batch = testDataSetIterator.next();
INDArray[] output = model.output(batch.getFeatures());
- 使用 ROC 评估实例,通过调用
evalTimeseries()
执行评估:
INDArray actuals = batch.getLabels();
INDArray predictions = output[0]
evaluation.evalTimeSeries(actuals, predictions);
- 通过调用
calculateAUC()
来显示 AUC 分数(评估指标):
System.out.println(evaluation.calculateAUC());
它是如何工作的…
在步骤 3 中,actuals
是测试输入的实际输出,而 predictions
是测试输入的观察输出。
评估指标基于 actuals
和 predictions
之间的差异。我们使用 ROC 评估指标来找出这个差异。ROC 评估适用于具有输出类别均匀分布的数据集的二分类问题。预测患者死亡率只是另一个二分类难题。
ROC
的参数化构造函数中的 thresholdSteps
是用于 ROC 计算的阈值步数。当我们减少阈值时,会得到更多的正值。这提高了敏感度,意味着神经网络在对某个项进行分类时将对其类别的唯一分类信心较低。
在步骤 4 中,我们通过调用 calculateAUC()
打印了 ROC 评估指标:
evaluation.calculateAUC();
calculateAUC()
方法将计算从测试数据绘制的 ROC 曲线下的面积。如果你打印结果,你应该看到一个介于 0
和 1
之间的概率值。我们还可以调用 stats()
方法显示整个 ROC 评估指标,如下所示:
stats()
方法将显示 AUC 分数以及 AUPRC(精准率/召回率曲线下面积)指标。AUPRC 是另一种性能评估指标,其中曲线表示精准率和召回率之间的权衡。对于一个具有良好 AUPRC 分数的模型,能够在较少的假阳性结果下找到正样本。
第七章:构建用于序列分类的 LSTM 神经网络
在上一章中,我们讨论了如何为多变量特征对时间序列数据进行分类。本章将创建一个长短期记忆(LSTM)神经网络来对单变量时间序列数据进行分类。我们的神经网络将学习如何对单变量时间序列进行分类。我们将使用UCI(即加利福尼亚大学欧文分校)的合成控制数据,并以此为基础对神经网络进行训练。数据将有 600 个序列,每个序列之间用新行分隔,方便我们的操作。每个序列将在 60 个时间步骤上记录数据。由于这是单变量时间序列,我们的 CSV 文件中将仅包含每个记录的示例列。每个序列都是一个记录的示例。我们将把这些数据序列分成训练集和测试集,分别进行训练和评估。分类/标签的可能类别如下:
-
正常
-
循环的
-
增长趋势
-
下降趋势
-
向上移动
-
向下移动
本章节我们将介绍以下食谱:
-
提取时间序列数据
-
加载训练数据
-
对训练数据进行归一化
-
构建网络的输入层
-
构建网络的输出层
-
评估 LSTM 网络的分类输出
让我们开始吧。
技术要求
克隆我们的 GitHub 仓库后,导航到Java-Deep-Learning-Cookbook/07_Constructing_LSTM_Neural_network_for_sequence_classification/sourceCode
目录。然后将cookbookapp
项目作为 Maven 项目导入,通过导入pom.xml
。
从这个 UCI 网站下载数据:archive.ics.uci.edu/ml/machine-learning-databases/synthetic_control-mld/synthetic_control.data
。
我们需要创建目录来存储训练数据和测试数据。请参阅以下目录结构:
我们需要为训练集和测试集分别创建两个单独的文件夹,然后分别为features
和labels
创建子目录:
该文件夹结构是前述数据提取的前提条件。我们在执行提取时会将特征和标签分开。
请注意,在本食谱的所有章节中,我们使用的是 DL4J 版本 1.0.0-beta 3,除了这一章。在执行我们在本章中讨论的代码时,你可能会遇到以下错误:
Exception in thread "main" java.lang.IllegalStateException: C (result) array is not F order or is a view. Nd4j.gemm requires the result array to be F order and not a view. C (result) array: [Rank: 2,Offset: 0 Order: f Shape: [10,1], stride: [1,10]]
在写作时,DL4J 的一个新版本已发布,解决了该问题。因此,我们将使用版本 1.0.0-beta 4 来运行本章中的示例。
提取时间序列数据
我们正在使用另一个时间序列的用例,但这次我们针对的是时间序列单变量序列分类。在配置 LSTM 神经网络之前,首先需要讨论 ETL。数据提取是 ETL 过程中的第一阶段。本食谱将涵盖该用例的数据提取。
如何操作…
- 使用编程方式对序列数据进行分类:
// convert URI to string
final String data = IOUtils.toString(new URL(url),"utf-8");
// Get sequences from the raw data
final String[] sequences = data.split("\n");
final List<Pair<String,Integer>> contentAndLabels = new ArrayList<>();
int lineCount = 0;
for(String sequence : sequences) {
// Record each time step in new line
sequence = sequence.replaceAll(" +","\n");
// Labels: first 100 examples (lines) are label 0, second 100 examples are label 1, and so on
contentAndLabels.add(new Pair<>(sequence, lineCount++ / 100));
}
- 按照编号格式将特征/标签存储在各自的目录中:
for(Pair<String,Integer> sequencePair : contentAndLabels) {
if(trainCount<450) {
featureFile = new File(trainfeatureDir+trainCount+".csv");
labelFile = new File(trainlabelDir+trainCount+".csv");
trainCount++;
} else {
featureFile = new File(testfeatureDir+testCount+".csv");
labelFile = new File(testlabelDir+testCount+".csv");
testCount++;
}
}
- 使用
FileUtils
将数据写入文件:
FileUtils.writeStringToFile(featureFile,sequencePair.getFirst(),"utf-8");
FileUtils.writeStringToFile(labelFile,sequencePair.getSecond().toString(),"utf-8");
它是如何工作的…
下载后,当我们打开合成控制数据时,它将如下所示:
在前面的截图中标记了一个单独的序列。总共有 600 个序列,每个序列由新的一行分隔。在我们的示例中,我们可以将数据集划分为 450 个序列用于训练,剩下的 150 个序列用于评估。我们正试图将给定的序列分类到六个已知类别中。
请注意,这是一个单变量时间序列。记录在单个序列中的数据跨越不同的时间步。我们为每个单独的序列创建单独的文件。单个数据单元(观察值)在文件中由空格分隔。我们将空格替换为换行符,以便单个序列中每个时间步的测量值出现在新的一行中。前 100 个序列代表类别 1,接下来的 100 个序列代表类别 2,依此类推。由于我们处理的是单变量时间序列数据,因此 CSV 文件中只有一列。所以,单个特征在多个时间步上被记录。
在步骤 1 中,contentAndLabels
列表将包含序列到标签的映射。每个序列代表一个标签。序列和标签一起构成一个对。
现在我们可以采用两种不同的方法来划分数据用于训练/测试:
-
随机打乱数据,选择 450 个序列用于训练,剩下的 150 个序列用于评估/测试。
-
将训练/测试数据集划分为类别在数据集中的分布均匀。例如,我们可以将训练数据划分为 420 个序列,每个类别有 70 个样本,共六个类别。
我们使用随机化作为一种提高神经网络泛化能力的手段。每个序列到标签的对都写入一个单独的 CSV 文件,遵循编号的文件命名规则。
在步骤 2 中,我们提到训练用的样本为 450 个,剩余的 150 个用于评估。
在步骤 3 中,我们使用了来自 Apache Commons 库的FileUtils
将数据写入文件。最终的代码如下所示:
for(Pair<String,Integer> sequencePair : contentAndLabels) {
if(trainCount<traintestSplit) {
featureFile = new File(trainfeatureDir+trainCount+".csv");
labelFile = new File(trainlabelDir+trainCount+".csv");
trainCount++;
} else {
featureFile = new File(testfeatureDir+testCount+".csv");
labelFile = new File(testlabelDir+testCount+".csv");
testCount++;
}
FileUtils.writeStringToFile(featureFile,sequencePair.getFirst(),"utf-8");
FileUtils.writeStringToFile(labelFile,sequencePair.getSecond().toString(),"utf-8");
}
我们获取序列数据并将其添加到features
目录中,每个序列将由一个单独的 CSV 文件表示。类似地,我们将相应的标签添加到单独的 CSV 文件中。
label
目录中的1.csv
将是feature
目录中1.csv
特征的对应标签。
加载训练数据
数据转换通常是数据提取后的第二个阶段。我们讨论的时间序列数据没有任何非数字字段或噪音(数据已经过清理)。因此,我们可以专注于从数据中构建迭代器,并将其直接加载到神经网络中。在本食谱中,我们将加载单变量时间序列数据用于神经网络训练。我们已经提取了合成控制数据并以合适的格式存储,以便神经网络能够轻松处理。每个序列覆盖了 60 个时间步。在本食谱中,我们将把时间序列数据加载到适当的数据集迭代器中,供神经网络进行进一步处理。
它是如何做的…
- 创建一个
SequenceRecordReader
实例,从时间序列数据中提取并加载特征:
SequenceRecordReader trainFeaturesSequenceReader = new CSVSequenceRecordReader();
trainFeaturesSequenceReader.initialize(new NumberedFileInputSplit(new File(trainfeatureDir).getAbsolutePath()+"/%d.csv",0,449));
- 创建一个
SequenceRecordReader
实例,从时间序列数据中提取并加载标签:
SequenceRecordReader trainLabelsSequenceReader = new CSVSequenceRecordReader();
trainLabelsSequenceReader.initialize(new NumberedFileInputSplit(new File(trainlabelDir).getAbsolutePath()+"/%d.csv",0,449));
- 为测试和评估创建序列读取器:
SequenceRecordReader testFeaturesSequenceReader = new CSVSequenceRecordReader();
testFeaturesSequenceReader.initialize(new NumberedFileInputSplit(new File(testfeatureDir).getAbsolutePath()+"/%d.csv",0,149));
SequenceRecordReader testLabelsSequenceReader = new CSVSequenceRecordReader();
testLabelsSequenceReader.initialize(new NumberedFileInputSplit(new File(testlabelDir).getAbsolutePath()+"/%d.csv",0,149));|
- 使用
SequenceRecordReaderDataSetIterator
将数据输入到我们的神经网络中:
DataSetIterator trainIterator = new SequenceRecordReaderDataSetIterator(trainFeaturesSequenceReader,trainLabelsSequenceReader,batchSize,numOfClasses);
DataSetIterator testIterator = new SequenceRecordReaderDataSetIterator(testFeaturesSequenceReader,testLabelsSequenceReader,batchSize,numOfClasses);
- 重写训练/测试迭代器(使用
AlignmentMode
)以支持不同长度的时间序列:
DataSetIterator trainIterator = new SequenceRecordReaderDataSetIterator(trainFeaturesSequenceReader,trainLabelsSequenceReader,batchSize,numOfClasses,false, SequenceRecordReaderDataSetIterator.AlignmentMode.ALIGN_END);
它是如何工作的…
我们在步骤 1 中使用了NumberedFileInputSplit
。必须使用NumberedFileInputSplit
从多个遵循编号文件命名规则的文件中加载数据。请参阅本食谱中的步骤 1:
SequenceRecordReader trainFeaturesSequenceReader = new CSVSequenceRecordReader();
trainFeaturesSequenceReader.initialize(new NumberedFileInputSplit(new File(trainfeatureDir).getAbsolutePath()+"/%d.csv",0,449));
我们在前一个食谱中将文件存储为一系列编号文件。共有 450 个文件,每个文件代表一个序列。请注意,我们已经为测试存储了 150 个文件,如步骤 3 所示。
在步骤 5 中,numOfClasses
指定了神经网络试图进行预测的类别数量。在我们的示例中,它是6
。我们在创建迭代器时提到了AlignmentMode.ALIGN_END
。对齐模式处理不同长度的输入/标签。例如,我们的时间序列数据有 60 个时间步,且只有一个标签出现在第 60 个时间步的末尾。这就是我们在迭代器定义中使用AlignmentMode.ALIGN_END
的原因,如下所示:
DataSetIterator trainIterator = new SequenceRecordReaderDataSetIterator(trainFeaturesSequenceReader,trainLabelsSequenceReader,batchSize,numOfClasses,false, SequenceRecordReaderDataSetIterator.AlignmentMode.ALIGN_END);
我们还可以有时间序列数据,在每个时间步产生标签。这些情况指的是多对多的输入/标签连接。
在步骤 4 中,我们开始使用常规的创建迭代器方式,如下所示:
DataSetIterator trainIterator = new SequenceRecordReaderDataSetIterator(trainFeaturesSequenceReader,trainLabelsSequenceReader,batchSize,numOfClasses);
DataSetIterator testIterator = new SequenceRecordReaderDataSetIterator(testFeaturesSequenceReader,testLabelsSequenceReader,batchSize,numOfClasses);
请注意,这不是创建序列读取器迭代器的唯一方法。DataVec 中有多种实现可支持不同的配置。我们还可以在样本的最后时间步对输入/标签进行对齐。为此,我们在迭代器定义中添加了AlignmentMode.ALIGN_END
。如果时间步长不一致,较短的时间序列将会填充至最长时间序列的长度。因此,如果有样本的时间步少于 60 步,则会将零值填充到时间序列数据中。
归一化训练数据
数据转换本身可能不会提高神经网络的效率。同一数据集中大范围和小范围的值可能会导致过拟合(模型捕捉到噪声而非信号)。为了避免这种情况,我们对数据集进行归一化,DL4J 提供了多种实现来完成这一操作。归一化过程将原始时间序列数据转换并拟合到一个确定的值范围内,例如*(0, 1)*。这将帮助神经网络以更少的计算量处理数据。我们在前面的章节中也讨论了归一化,表明它会减少在训练神经网络时对数据集中特定标签的偏倚。
如何操作…
- 创建标准归一化器并拟合数据:
DataNormalization normalization = new NormalizerStandardize();
normalization.fit(trainIterator);
- 调用
setPreprocessor()
方法以实时规范化数据:
trainIterator.setPreProcessor(normalization);
testIterator.setPreProcessor(normalization);
如何工作…
在第 1 步中,我们使用NormalizerStandardize
来归一化数据集。NormalizerStandardize
会对数据(特征)进行归一化,使其具有0的均值和1的标准差。换句话说,数据集中的所有值都将归一化到*(0, 1)*的范围内:
DataNormalization normalization = new NormalizerStandardize();
normalization.fit(trainIterator);
这是 DL4J 中的标准归一化器,尽管 DL4J 中还有其他归一化器实现。还请注意,我们不需要对测试数据调用fit()
,因为我们使用在训练过程中学习到的缩放参数来缩放测试数据。
我们需要像第 2 步中展示的那样,为训练/测试迭代器调用setPreprocessor()
方法。一旦使用setPreprocessor()
设置了归一化器,迭代器返回的数据将会自动使用指定的归一化器进行归一化。因此,重要的是在调用fit()
方法时同时调用setPreprocessor()
。
构建网络的输入层
层配置是神经网络配置中的一个重要步骤。我们需要创建输入层来接收从磁盘加载的单变量时间序列数据。在这个示例中,我们将为我们的用例构建一个输入层。我们还将添加一个 LSTM 层作为神经网络的隐藏层。我们可以使用计算图或常规的多层网络来构建网络配置。在大多数情况下,常规多层网络就足够了;然而,我们的用例使用的是计算图。在本示例中,我们将为网络配置输入层。
如何操作…
- 使用默认配置配置神经网络:
NeuralNetConfiguration.Builder neuralNetConfigBuilder = new NeuralNetConfiguration.Builder();
neuralNetConfigBuilder.seed(123);
neuralNetConfigBuilder.weightInit(WeightInit.XAVIER);
neuralNetConfigBuilder.updater(new Nadam());
neuralNetConfigBuilder.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue);
neuralNetConfigBuilder.gradientNormalizationThreshold(0.5);
- 通过调用
addInputs()
来指定输入层标签:
ComputationGraphConfiguration.GraphBuilder compGraphBuilder = neuralNetConfigBuilder.graphBuilder();
compGraphBuilder.addInputs("trainFeatures");
- 使用
addLayer()
方法添加 LSTM 层:
compGraphBuilder.addLayer("L1", new LSTM.Builder().activation(Activation.TANH).nIn(1).nOut(10).build(), "trainFeatures");
工作原理…
在步骤 1 中,我们指定了默认的seed
值、初始的默认权重(weightInit
)、权重updater
等。我们将梯度规范化策略设置为ClipElementWiseAbsoluteValue
。我们还将梯度阈值设置为0.5
,作为gradientNormalization
策略的输入。
神经网络在每一层计算神经元的梯度。我们在标准化训练数据这一部分中已经使用标准化器对输入数据进行了标准化。需要提到的是,我们还需要对梯度值进行标准化,以实现数据准备的目标。如步骤 1 所示,我们使用了ClipElementWiseAbsoluteValue
梯度标准化。它的工作方式是使梯度的绝对值不能超过阈值。例如,如果梯度阈值为 3,则值的范围为[-3, 3]。任何小于-3 的梯度值都将被视为-3,任何大于 3 的梯度值将被视为 3。范围在[-3, 3]之间的梯度值将保持不变。我们已经在网络配置中提到了梯度标准化策略和阈值,如下所示:
neuralNetConfigBuilder.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue);
neuralNetConfigBuilder.gradientNormalizationThreshold(thresholdValue);
在步骤 3 中,trainFeatures
标签引用了输入层标签。输入基本上是由graphBuilder()
方法返回的图顶点对象。步骤 2 中指定的 LSTM 层名称(我们示例中的L1
)将在配置输出层时使用。如果存在不匹配,我们的程序将在执行过程中抛出错误,表示层的配置方式导致它们断开连接。我们将在下一个教程中更深入地讨论这个问题,当时我们将设计神经网络的输出层。请注意,我们尚未在配置中添加输出层。
为网络构建输出层
输入/隐藏层设计之后的下一步是输出层设计。正如我们在前面章节中提到的,输出层应该反映你希望从神经网络中获得的输出。根据使用场景的不同,你可能需要一个分类器或回归模型。因此,输出层必须进行配置。激活函数和误差函数需要根据其在输出层配置中的使用进行合理化。本教程假设神经网络的配置已经完成到输入层定义为止。这将是网络配置中的最后一步。
如何操作…
- 使用
setOutputs()
设置输出标签:
compGraphBuilder.setOutputs("predictSequence");
- 使用
addLayer()
方法和RnnOutputLayer
构造输出层:
compGraphBuilder.addLayer("predictSequence", new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX).nIn(10).nOut(numOfClasses).build(), "L1");
工作原理…
在第 1 步中,我们为输出层添加了一个predictSequence
标签。请注意,在定义输出层时,我们提到了输入层的引用。在第 2 步中,我们将其指定为L1
,这是在前一个步骤中创建的 LSTM 输入层。我们需要提到这一点,以避免在执行过程中因 LSTM 层与输出层之间的断开连接而导致的错误。此外,输出层的定义应该与我们在setOutput()
方法中指定的层名称相同。
在第 2 步中,我们使用RnnOutputLayer
构建了输出层。这个 DL4J 输出层实现用于涉及递归神经网络的使用案例。它在功能上与多层感知器中的OutputLayer
相同,但输出和标签的形状调整是自动处理的。
评估 LSTM 网络的分类输出
现在我们已经配置好神经网络,下一步是启动训练实例,然后进行评估。评估阶段对训练实例非常重要。神经网络将尝试优化梯度以获得最佳结果。一个最佳的神经网络将具有良好且稳定的评估指标。因此,评估神经网络以将训练过程引导至期望的结果是很重要的。我们将使用测试数据集来评估神经网络。
在上一章中,我们探讨了时间序列二分类的一个使用案例。现在我们有六个标签进行预测。我们讨论了多种方法来提高网络的效率。在下一步骤中,我们将采用相同的方法,评估神经网络的最佳结果。
如何做…
- 使用
init()
方法初始化ComputationGraph
模型配置:
ComputationGraphConfiguration configuration = compGraphBuilder.build();
ComputationGraph model = new ComputationGraph(configuration);
model.init();
- 设置分数监听器以监控训练过程:
model.setListeners(new ScoreIterationListener(20), new EvaluativeListener(testIterator, 1, InvocationType.EPOCH_END));
- 通过调用
fit()
方法启动训练实例:
model.fit(trainIterator,numOfEpochs);
- 调用
evaluate()
计算评估指标:
Evaluation evaluation = model.evaluate(testIterator);
System.out.println(evaluation.stats());
它是如何工作的…
在第 1 步中,我们在配置神经网络的结构时使用了计算图。计算图是递归神经网络的最佳选择。我们使用多层网络得到的评估得分大约为 78%,而使用计算图时得分高达 94%。使用ComputationGraph
可以获得比常规多层感知器更好的结果。ComputationGraph
适用于复杂的网络结构,并且可以根据不同层的顺序进行定制。第 1 步中使用了InvocationType.EPOCH_END
(分数迭代)来在测试迭代结束时调用分数迭代器。
请注意,我们为每次测试迭代调用了分数迭代器,而不是为训练集迭代调用。为了记录每次测试迭代的分数,需要通过调用setListeners()
设置适当的监听器,在训练事件开始之前,如下所示:
model.setListeners(new ScoreIterationListener(20), new EvaluativeListener(testIterator, 1, InvocationType.EPOCH_END));
在第 4 步中,模型通过调用evaluate()
进行了评估:
Evaluation evaluation = model.evaluate(testIterator);
我们将测试数据集以迭代器的形式传递给evaluate()
方法,这个迭代器是在加载训练数据步骤中创建的。
此外,我们使用stats()
方法来显示结果。对于一个有 100 个训练周期(epochs)的计算图,我们得到以下评估指标:
现在,以下是您可以执行的实验,以便进一步优化结果。
我们在示例中使用了 100 个训练周期。您可以将训练周期从 100 减少,或者将其设置为一个特定的值。注意哪个方向能带来更好的结果。当结果达到最佳时停止。我们可以在每个训练周期结束后评估一次结果,以了解我们应该朝哪个方向继续。请查看以下训练实例日志:
在前面的示例中,准确率在上一个训练周期后下降。因此,您可以决定最佳的训练周期数量。如果我们选择更大的训练周期,神经网络将仅仅记住结果,这会导致过拟合。
在最开始没有对数据进行随机化时,您可以确保六个类别在训练集中的分布是均匀的。例如,我们可以将 420 个样本用于训练,180 个样本用于测试。这样,每个类别将有 70 个样本。然后,我们可以进行随机化,并创建迭代器。请注意,在我们的示例中,我们有 450 个用于训练的样本。在这种情况下,标签/类别的分布并不是唯一的,我们完全依赖于数据的随机化。
第八章:在无监督数据上执行异常检测
在本章中,我们将使用修改版国家标准与技术研究院(MNIST)数据集,通过一个简单的自编码器进行异常检测,且没有任何预训练。我们将识别给定 MNIST 数据中的离群值。离群数字可以被认为是最不典型或不正常的数字。我们将对 MNIST 数据进行编码,然后在输出层解码回来。然后,我们将计算 MNIST 数据的重建误差。
与数字值相似的 MNIST 样本将具有较低的重建误差。然后,我们将根据重建误差对它们进行排序,并使用 JFrame 窗口显示最佳样本和最差样本(离群值)。自编码器使用前馈网络构建。请注意,我们并没有进行任何预训练。我们可以在自编码器中处理特征输入,并且在任何阶段都不需要 MNIST 标签。
在本章中,我们将涵盖以下食谱:
-
提取并准备 MNIST 数据
-
构建输入的密集层
-
构建输出层
-
使用 MNIST 图像进行训练
-
根据异常得分评估并排序结果
-
保存生成的模型
让我们开始吧。
技术要求
JFrame 特定的实现可以在这里找到:
克隆我们的 GitHub 仓库后,导航到Java-Deep-Learning-Cookbook/08_Performing_Anomaly_detection_on_unsupervised data/sourceCode
目录。然后,通过导入pom.xml
将cookbook-app
项目作为 Maven 项目导入。
请注意,我们使用的 MNIST 数据集可以在这里找到:yann.lecun.com/exdb/mnist/
但是,我们不需要为本章下载数据集:DL4J 有一个自定义实现,允许我们自动获取 MNIST 数据。我们将在本章中使用它。
提取并准备 MNIST 数据
与有监督的图像分类任务不同,我们将对 MNIST 数据集执行异常检测任务。更重要的是,我们使用的是无监督模型,这意味着我们在训练过程中不会使用任何类型的标签。为了启动 ETL 过程,我们将提取这种无监督的 MNIST 数据并将其准备好,以便可以用于神经网络的训练。
如何操作…
- 使用
MnistDataSetIterator
为 MNIST 数据创建迭代器:
DataSetIterator iter = new MnistDataSetIterator(miniBatchSize,numOfExamples,binarize);
- 使用
SplitTestAndTrain
将基础迭代器拆分为训练/测试迭代器:
DataSet ds = iter.next();
SplitTestAndTrain split = ds.splitTestAndTrain(numHoldOut, new Random(12345));
- 创建列表以存储来自训练/测试迭代器的特征集:
List<INDArray> featuresTrain = new ArrayList<>();
List<INDArray> featuresTest = new ArrayList<>();
List<INDArray> labelsTest = new ArrayList<>();
- 将之前创建的特征/标签列表填充数据:
featuresTrain.add(split.getTrain().getFeatures());
DataSet dsTest = split.getTest();
featuresTest.add(dsTest.getFeatures());
INDArray indexes = Nd4j.argMax(dsTest.getLabels(),1);
labelsTest.add(indexes);
- 对每个迭代器实例调用
argmax()
,如果标签是多维的,则将其转换为一维数据:
while(iter.hasNext()){
DataSet ds = iter.next();
SplitTestAndTrain split = ds.splitTestAndTrain(80, new Random(12345)); // 80/20 split (from miniBatch = 100)
featuresTrain.add(split.getTrain().getFeatures());
DataSet dsTest = split.getTest();
featuresTest.add(dsTest.getFeatures());
INDArray indexes = Nd4j.argMax(dsTest.getLabels(),1);
labelsTest.add(indexes);
}
它是如何工作的…
在步骤 1 中,我们使用 MnistDataSetIterator
在一个地方提取并加载 MNIST 数据。DL4J 提供了这个专门的迭代器来加载 MNIST 数据,而无需担心自行下载数据。你可能会注意到,MNIST 数据在官方网站上是 ubyte
格式。这显然不是我们想要的格式,因此我们需要分别提取所有的图像,以便正确加载到神经网络中。
因此,在 DL4J 中拥有像 MnistDataSetIterator
这样的 MNIST 迭代器实现非常方便。它简化了处理 ubyte
格式 MNIST 数据的常见任务。MNIST 数据共有 60,000 个训练数字,10,000 个测试数字和 10 个标签。数字图像的尺寸为 28 x 28,数据的形状是扁平化格式:[ minibatch
,784]。MnistDataSetIterator
内部使用 MnistDataFetcher
和 MnistManager
类来获取 MNIST 数据并将其加载到正确的格式中。在步骤 1 中,binarize
:true
或 false
表示是否对 MNIST 数据进行二值化。
请注意,在步骤 2 中,numHoldOut
表示用于训练的样本数量。如果 miniBatchSize
为 100
且 numHoldOut
为 80
,则剩余的 20 个样本用于测试和评估。我们可以使用 DataSetIteratorSplitter
代替步骤 2 中提到的 SplitTestAndTrain
进行数据拆分。
在步骤 3 中,我们创建了列表来维护与训练和测试相关的特征和标签。它们分别用于训练和评估阶段。我们还创建了一个列表,用于存储来自测试集的标签,在测试和评估阶段将异常值与标签进行映射。这些列表在每次批次发生时都会填充一次。例如,在 featuresTrain
或 featuresTest
的情况下,一个批次的特征(经过数据拆分后)由一个 INDArray
项表示。我们还使用了 ND4J 中的 argMax()
函数,它将标签数组转换为一维数组。MNIST 标签从 0
到 9
实际上只需要一维空间来表示。
在以下代码中,1
表示维度:
Nd4j.argMax(dsTest.getLabels(),1);
同时请注意,我们使用标签来映射异常值,而不是用于训练。
构建输入的密集层
神经网络设计的核心是层架构。对于自编码器,我们需要设计在前端进行编码、在另一端进行解码的密集层。基本上,我们就是通过这种方式重建输入。因此,我们需要设计我们的层结构。
让我们从配置默认设置开始设置我们的自编码器,然后进一步定义自编码器所需的输入层。记住,神经网络的输入连接数应等于输出连接数。
如何做…
- 使用
MultiLayerConfiguration
构建自编码器网络:
NeuralNetConfiguration.Builder configBuilder = new NeuralNetConfiguration.Builder();
configBuilder.seed(12345);
configBuilder.weightInit(WeightInit.XAVIER);
configBuilder.updater(new AdaGrad(0.05));
configBuilder.activation(Activation.RELU);
configBuilder.l2(l2RegCoefficient);
NeuralNetConfiguration.ListBuilder builder = configBuilder.list();
- 使用
DenseLayer
创建输入层:
builder.layer(new DenseLayer.Builder().nIn(784).nOut(250).build());
builder.layer(new DenseLayer.Builder().nIn(250).nOut(10).build());
它是如何工作的…
在第 1 步中,在配置通用神经网络参数时,我们设置了默认的学习率,如下所示:
configBuilder.updater(new AdaGrad(learningRate));
Adagrad
优化器基于在训练期间参数更新的频率。Adagrad
基于矢量化学习率。当接收到更多更新时,学习率会较小。这对于高维度问题至关重要。因此,这个优化器非常适合我们的自编码器应用场景。
在自编码器架构中,我们在输入层执行降维。这也被称为对数据进行编码。我们希望确保从编码数据中解码出相同的特征集合。我们计算重建误差,以衡量我们与编码前的真实特征集合有多接近。在第 2 步中,我们尝试将数据从较高维度(784
)编码到较低维度(10
)。
构建输出层
作为最后一步,我们需要将数据从编码状态解码回原始状态。我们能否完美地重建输入?如果可以,那么一切都好。否则,我们需要计算相关的重建误差。记住,输出层的输入连接应该与前一层的输出连接相同。
如何做…
- 使用
OutputLayer
创建一个输出层:
OutputLayer outputLayer = new OutputLayer.Builder().nIn(250).nOut(784)
.lossFunction(LossFunctions.LossFunction.MSE)
.build();
- 将
OutputLayer
添加到层定义中:
builder.layer(new OutputLayer.Builder().nIn(250).nOut(784)
.lossFunction(LossFunctions.LossFunction.MSE)
.build());
它是如何工作的…
我们提到了均方误差(MSE)作为与输出层相关的误差函数。lossFunction
,在自编码器架构中,通常是 MSE。MSE 在计算重建输入与原始输入之间的接近程度时是最优的。ND4J 有一个 MSE 的实现,即LossFunction.MSE
。
在输出层,我们得到重建后的输入,并且它们的维度与原始输入相同。然后我们将使用误差函数来计算重建误差。在第 1 步中,我们构建了一个输出层,用于计算异常检测的重建误差。重要的是,输入和输出层的输入连接和输出连接需要保持一致。一旦定义了输出层,我们需要将其添加到一个层配置堆栈中,以此来创建神经网络的配置。在第 2 步中,我们将输出层添加到之前维护的神经网络配置构建器中。为了遵循直观的方法,我们首先创建了配置构建器,而不像这里所采用的简单方法:github.com/PacktPublishing/Java-Deep-Learning-Cookbook/blob/master/08_Performing_Anomaly_detection_on_unsupervised%20data/sourceCode/cookbook-app/src/main/java/MnistAnomalyDetectionExample.java
。
你可以通过在Builder
实例上调用build()
方法来获取配置实例。
使用 MNIST 图像进行训练
一旦构建了各层并形成了神经网络,我们就可以启动训练过程。在训练过程中,我们会多次重建输入并评估重建误差。在之前的示例中,我们通过根据需要定义输入和输出层完成了自编码器网络配置。请注意,我们将使用自编码器进行异常检测,因此我们使用其自身的输入特征来训练网络,而不是标签。因为我们使用自编码器进行异常检测,所以我们先编码数据,然后再解码回来以衡量重建误差。基于此,我们列出 MNIST 数据中最可能的异常。
如何操作…
- 选择正确的训练方法。以下是训练过程中预期会发生的情况:
Input -> Encoded Input -> Decode -> Output
所以,我们需要训练输出与输入相对应(理想情况下,输出 ~ 输入)。
- 使用
fit()
方法训练每个特征集:
int nEpochs = 30;
for( int epoch=0; epoch<nEpochs; epoch++ ){
for(INDArray data : featuresTrain){
net.fit(data,data);
}
}
它是如何工作的…
fit()
方法接受特征和标签作为第一和第二个属性。我们会将 MNIST 特征与它们自己进行重建。换句话说,我们试图在特征被编码后重新创建它们,并检查它们与实际特征的差异。在训练过程中,我们测量重建误差,并且只关注特征值。因此,输出将与输入进行验证,并类似于自编码器的功能。所以,第 1 步对于评估阶段也至关重要。
请参考以下代码块:
for(INDArray data : featuresTrain){
net.fit(data,data);
}
这就是我们为何将自编码器训练为其自身特征(输入)的原因,在第 2 步中我们通过这种方式调用fit()
:net.fit(data,data)
。
根据异常评分评估和排序结果
我们需要计算所有特征集的重建误差。根据这个,我们会找出所有 MNIST 数字(0 到 9)的离群数据。最后,我们将在 JFrame 窗口中显示离群数据。我们还需要来自测试集的特征值用于评估。我们还需要来自测试集的标签值,标签不是用来评估的,而是用于将异常与标签关联。然后,我们可以根据每个标签绘制离群数据。标签仅用于在 JFrame 中根据相应的标签绘制离群数据。在本配方中,我们评估了训练好的自编码器模型用于 MNIST 异常检测,然后排序结果并显示出来。
如何操作…
- 构建一个将每个 MNIST 数字与一组(score, feature)对相关联的映射:
Map<Integer,List<Pair<Double,INDArray>>> listsByDigit = new HashMap<>();
- 遍历每一个测试特征,计算重建误差,生成分数-特征对用于显示具有低重建误差的样本:
for( int i=0; i<featuresTest.size(); i++ ){
INDArray testData = featuresTest.get(i);
INDArray labels = labelsTest.get(i);
for( int j=0; j<testData.rows(); j++){
INDArray example = testData.getRow(j, true);
int digit = (int)labels.getDouble(j);
double score = net.score(new DataSet(example,example));
// Add (score, example) pair to the appropriate list
List digitAllPairs = listsByDigit.get(digit);
digitAllPairs.add(new Pair<>(score, example));
}
}
- 创建一个自定义的比较器来排序映射:
Comparator<Pair<Double, INDArray>> sortComparator = new Comparator<Pair<Double, INDArray>>() {
@Override
public int compare(Pair<Double, INDArray> o1, Pair<Double, INDArray> o2) {
return Double.compare(o1.getLeft(),o2.getLeft());
}
};
- 使用
Collections.sort()
对映射进行排序:
for(List<Pair<Double, INDArray>> digitAllPairs : listsByDigit.values()){
Collections.sort(digitAllPairs, sortComparator);
}
- 收集最佳/最差数据,以在 JFrame 窗口中显示用于可视化:
List<INDArray> best = new ArrayList<>(50);
List<INDArray> worst = new ArrayList<>(50);
for( int i=0; i<10; i++ ){
List<Pair<Double,INDArray>> list = listsByDigit.get(i);
for( int j=0; j<5; j++ ){
best.add(list.get(j).getRight());
worst.add(list.get(list.size()-j-1).getRight());
}
}
- 使用自定义的 JFrame 实现进行可视化,比如
MNISTVisualizer
,来展示结果:
//Visualize the best and worst digits
MNISTVisualizer bestVisualizer = new MNISTVisualizer(imageScale,best,"Best (Low Rec. Error)");
bestVisualizer.visualize();
MNISTVisualizer worstVisualizer = new MNISTVisualizer(imageScale,worst,"Worst (High Rec. Error)");
worstVisualizer.visualize();
它是如何工作的…
通过步骤 1 和步骤 2,对于每个 MNIST 数字,我们维护一个(score, feature)对的列表。我们构建了一个将每个 MNIST 数字与这个列表相关联的映射。最后,我们只需要排序就可以找到最好的/最差的案例。
我们还使用了score()
函数来计算重建误差:
double score = net.score(new DataSet(example,example));
在评估过程中,我们会重建测试特征,并测量它与实际特征值的差异。较高的重建误差表明存在较高比例的离群值。
在步骤 4 之后,我们应该能看到 JFrame 可视化的重建误差,如下所示:
可视化依赖于 JFrame。基本上,我们所做的是从第 1 步中创建的映射中取出N个最佳/最差的对。我们制作一个最佳/最差数据的列表,并将其传递给我们的 JFrame 可视化逻辑,以便在 JFrame 窗口中显示离群值。右侧的 JFrame 窗口表示离群数据。我们将 JFrame 的实现留到一边,因为这超出了本书的范围。完整的 JFrame 实现请参考“技术要求”部分中提到的 GitHub 源代码。
保存结果模型
模型持久化非常重要,因为它使得无需重新训练即可重复使用神经网络模型。一旦自编码器被训练用于执行离群值检测,我们就可以将模型保存到磁盘以供以后使用。我们在前一章中解释了ModelSerializer
类,我们用它来保存自编码器模型。
如何操作…
- 使用
ModelSerializer
持久化模型:
File modelFile = new File("model.zip");
ModelSerializer.writeModel(multiLayerNetwork,file, saveUpdater);
- 向持久化模型中添加一个标准化器:
ModelSerializer.addNormalizerToModel(modelFile,dataNormalization);
它是如何工作的…
在本章中,我们正式针对 DL4J 版本 1.0.0-beta 3。我们使用ModelSerializer
将模型保存到磁盘。如果你使用的是新版本 1.0.0-beta 4,还有另一种推荐的保存模型方法,即使用MultiLayerNetwork
提供的save()
方法:
File locationToSave = new File("MyMultiLayerNetwork.zip");
model.save(locationToSave, saveUpdater);
如果你希望未来训练网络,使用saveUpdater = true
。
还有更多内容…
要恢复网络模型,调用restoreMultiLayerNetwork()
方法:
ModelSerializer.restoreMultiLayerNetwork(new File("model.zip"));
此外,如果你使用最新版本 1.0.0-beta 4,你可以使用MultiLayerNetwork
提供的load()
方法:
MultiLayerNetwork restored = MultiLayerNetwork.load(locationToSave, saveUpdater);
第九章:使用 RL4J 进行强化学习
强化学习是一种以目标为导向的机器学习算法,它训练智能体做出一系列决策。对于深度学习模型,我们在现有数据上训练它们,并将学习应用于新数据或未见过的数据。强化学习通过根据持续反馈调整自己的行为来展现动态学习,以最大化奖励。我们可以将深度学习引入强化学习系统,这就是深度强化学习。
RL4J 是一个与 DL4J 集成的强化学习框架。RL4J 支持两种强化学习算法:深度 Q 学习和 A3C(即异步演员-评论家智能体)。Q 学习是一种离策略强化学习算法,旨在为给定的状态寻求最佳动作。它通过采取随机动作从当前策略之外的动作中学习。在深度 Q 学习中,我们使用深度神经网络来找到最佳的 Q 值,而不是像常规 Q 学习那样使用值迭代。在本章中,我们将使用 Project Malmo 设置一个由强化学习驱动的游戏环境。Project Malmo 是一个基于 Minecraft 的强化学习实验平台。
本章将涵盖以下内容:
-
设置 Malmo 环境及相关依赖
-
设置数据要求
-
配置和训练一个深度 Q 网络(DQN)智能体
-
评估 Malmo 智能体
技术要求
本章的源代码可以在这里找到:
克隆我们的 GitHub 仓库后,导航到Java-Deep-Learning-Cookbook/09_Using_RL4J_for_Reinforcement learning/sourceCode
目录。然后,通过导入pom.xml
将cookbookapp
项目作为 Maven 项目导入。
你需要设置一个 Malmo 客户端来运行源代码。首先,根据你的操作系统下载最新的 Project Malmo 版本:github.com/Microsoft/malmo/releases
-
对于 Linux 操作系统,按照此处的安装说明操作:
github.com/microsoft/malmo/blob/master/doc/install_linux.md
-
对于 Windows 操作系统,按照此处的安装说明操作:
github.com/microsoft/malmo/blob/master/doc/install_windows.md
-
对于 macOS 操作系统,按照此处的安装说明操作:
github.com/microsoft/malmo/blob/master/doc/install_macosx.md
要启动 Minecraft 客户端,请导航到 Minecraft 目录并运行客户端脚本:
-
双击
launchClient.bat
(在 Windows 上)。 -
在控制台上运行
./launchClient.sh
(无论是在 Linux 还是 macOS 上)。
如果你在 Windows 上遇到启动客户端时的问题,可以在这里下载依赖关系查看工具:lucasg.github.io/Dependencies/
。
然后,按照以下步骤操作:
-
解压并运行
DependenciesGui.exe
。 -
在
Java_Examples
目录中选择MalmoJava.dll
,查看类似下面所示的缺失依赖项:
如果出现任何问题,缺失的依赖项将在列表中标记出来。你需要添加缺失的依赖项,以便成功重新启动客户端。任何缺失的库/文件应该存在于PATH
环境变量中。
你可以参考此处的操作系统特定构建说明:
-
github.com/microsoft/malmo/blob/master/doc/build_linux.md
(Linux) -
github.com/microsoft/malmo/blob/master/doc/build_windows.md
(Windows) -
github.com/microsoft/malmo/blob/master/doc/build_macosx.md
(macOS)
如果一切顺利,你应该能看到类似下面的画面:
此外,你需要创建一个任务架构来构建游戏窗口的模块。完整的任务架构可以在本章节的项目目录中找到:github.com/PacktPublishing/Java-Deep-Learning-Cookbook/blob/master/09_Using_RL4J_for_Reinforcement%20learning/sourceCode/cookbookapp/src/main/resources/cliff_walking_rl4j.xml
。
设置 Malmo 环境及其相关依赖
我们需要设置 RL4J Malmo 依赖项来运行源代码。就像任何其他 DL4J 应用一样,我们还需要根据硬件(CPU/GPU)添加 ND4J 后端依赖。在本教程中,我们将添加所需的 Maven 依赖并设置环境来运行应用程序。
准备工作
在我们运行 Malmo 示例源代码之前,Malmo 客户端应该已经启动并正常运行。我们的源代码将与 Malmo 客户端进行通信,以便创建并执行任务。
如何操作…
- 添加 RL4J 核心依赖:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>rl4j-core</artifactId>
<version>1.0.0-beta3</version>
</dependency>
- 添加 RL4J Malmo 依赖:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>rl4j-malmo</artifactId>
<version>1.0.0-beta3</version>
</dependency>
-
为 ND4J 后端添加依赖:
- 对于 CPU,你可以使用以下配置:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-native-platform</artifactId>
<version>1.0.0-beta3</version>
</dependency>
-
- 对于 GPU,你可以使用以下配置:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-10.0</artifactId>
<version>1.0.0-beta3</version>
</dependency>
- 为
MalmoJavaJar
添加 Maven 依赖:
<dependency>
<groupId>com.microsoft.msr.malmo</groupId>
<artifactId>MalmoJavaJar</artifactId>
<version>0.30.0</version>
</dependency>
它是如何工作的…
在第 1 步中,我们添加了 RL4J 核心依赖项,将 RL4J DQN 库引入到我们的应用程序中。在第 2 步中,添加了 RL4J Malmo 依赖项,以构建 Malmo 环境并在 RL4J 中构建任务。
我们还需要添加特定于 CPU/GPU 的 ND4J 后端依赖项(第 3 步)。最后,在第 4 步中,我们添加了MalmoJavaJar
的依赖项(第 4 步),它作为 Java 程序与 Malmo 交互的通信接口。
设置数据要求
Malmo 强化学习环境的数据包括代理正在移动的图像帧。Malmo 的示例游戏窗口如下所示。这里,如果代理走过熔岩,它会死亡:
Malmo 要求开发者指定 XML 模式以生成任务。我们需要为代理和服务器创建任务数据,以便在世界中创建方块(即游戏环境)。在本示例中,我们将创建一个 XML 模式来指定任务数据。
如何执行此操作…
- 使用
<ServerInitialConditions>
标签定义世界的初始条件:
Sample:
<ServerInitialConditions>
<Time>
<StartTime>6000</StartTime>
<AllowPassageOfTime>false</AllowPassageOfTime>
</Time>
<Weather>clear</Weather>
<AllowSpawning>false</AllowSpawning>
</ServerInitialConditions>
- 访问
www.minecraft101.net/superflat/
并为超平坦世界创建您自己的预设字符串:
- 使用
<FlatWorldGenerator>
标签生成具有指定预设字符串的超平坦世界:
<FlatWorldGenerator generatorString="3;7,220*1,5*3,2;3;,biome_1"/>
- 使用
<DrawingDecorator>
标签在世界中绘制结构:
Sample:
<DrawingDecorator>
<!-- coordinates for cuboid are inclusive -->
<DrawCuboid x1="-2" y1="46" z1="-2" x2="7" y2="50" z2="18" type="air" />
<DrawCuboid x1="-2" y1="45" z1="-2" x2="7" y2="45" z2="18" type="lava" />
<DrawCuboid x1="1" y1="45" z1="1" x2="3" y2="45" z2="12" type="sandstone" />
<DrawBlock x="4" y="45" z="1" type="cobblestone" />
<DrawBlock x="4" y="45" z="12" type="lapis_block" />
<DrawItem x="4" y="46" z="12" type="diamond" />
</DrawingDecorator>
- 使用
<ServerQuitFromTimeUp>
标签为所有代理指定时间限制:
<ServerQuitFromTimeUp timeLimitMs="100000000"/>
- 使用
<ServerHandlers>
标签将所有任务处理器添加到方块中:
<ServerHandlers>
<FlatWorldGenerator>{Copy from step 3}</FlatWorldGenerator>
<DrawingDecorator>{Copy from step 4}</DrawingDecorator>
<ServerQuitFromTimeUp>{Copy from step 5}</ServerQuitFromTimeUp>
</ServerHandlers>
- 在
<ServerSection>
标签下添加<ServerHandlers>
和<ServerInitialConditions>
:
<ServerSection>
<ServerInitialConditions>{Copy from step 1}</ServerInitialConditions>
<ServerHandlers>{Copy from step 6}</ServerHandlers>
</ServerSection>
- 定义代理的名称和起始位置:
Sample:
<Name>Cristina</Name>
<AgentStart>
<Placement x="4.5" y="46.0" z="1.5" pitch="30" yaw="0"/>
</AgentStart>
- 使用
<ObservationFromGrid>
标签定义方块类型:
Sample:
<ObservationFromGrid>
<Grid name="floor">
<min x="-4" y="-1" z="-13"/>
<max x="4" y="-1" z="13"/>
</Grid>
</ObservationFromGrid>
- 使用
<VideoProducer>
标签配置视频帧:
Sample:
<VideoProducer viewpoint="1" want_depth="false">
<Width>320</Width>
<Height>240</Height>
</VideoProducer>
- 提到当代理与使用
<RewardForTouchingBlockType>
标签的方块类型接触时将获得的奖励点数:
Sample:
<RewardForTouchingBlockType>
<Block reward="-100.0" type="lava" behaviour="onceOnly"/>
<Block reward="100.0" type="lapis_block" behaviour="onceOnly"/>
</RewardForTouchingBlockType>
- 提到奖励点数以向代理发出命令,使用
<RewardForSendingCommand>
标签:
Sample:
<RewardForSendingCommand reward="-1"/>
- 使用
<AgentQuitFromTouchingBlockType>
标签为代理指定任务终点:
<AgentQuitFromTouchingBlockType>
<Block type="lava" />
<Block type="lapis_block" />
</AgentQuitFromTouchingBlockType>
- 在
<AgentHandlers>
标签下添加所有代理处理器函数:
<AgentHandlers>
<ObservationFromGrid>{Copy from step 9}</ObservationFromGrid>
<VideoProducer></VideoProducer> // Copy from step 10
<RewardForTouchingBlockType>{Copy from step 11}</RewardForTouchingBlockType>
<RewardForSendingCommand> // Copy from step 12
<AgentQuitFromTouchingBlockType>{Copy from step 13} </AgentQuitFromTouchingBlockType>
</AgentHandlers>
- 将所有代理处理器添加到
<AgentSection>
中:
<AgentSection mode="Survival">
<AgentHandlers>
{Copy from step 14}
</AgentHandlers>
</AgentSection>
- 创建一个
DataManager
实例来记录训练数据:
DataManager manager = new DataManager(false);
它是如何工作的…
在第 1 步中,以下配置被添加为世界的初始条件:
-
StartTime
:这指定了任务开始时的时间,以千分之一小时为单位。6000 表示中午 12 点。 -
AllowPassageOfTime
:如果设置为false
,则会停止昼夜循环。在任务期间,天气和太阳位置将保持不变。 -
Weather
:这指定了任务开始时的天气类型。 -
AllowSpawning
:如果设置为true
,则在任务期间将生成动物和敌对生物。
在第 2 步中,我们创建了一个预设字符串来表示在第 3 步中使用的超平面类型。超平面类型指的就是任务中看到的表面类型。
在第 4 步,我们使用 DrawCuboid
和 DrawBlock
向世界中绘制了结构。
我们遵循三维空间 (x1,y1,z1)
-> (x2,y2,z2)
来指定边界。type
属性用于表示块类型。你可以为实验添加任何 198 个可用的块。
在第 6 步,我们将所有与世界创建相关的任务处理程序添加到<ServerHandlers>
标签下。然后,在第 7 步,我们将它们添加到<ServerSection>
父标签中。
在第 8 步,<Placement>
标签用于指定玩家的起始位置。如果未指定起始点,它将随机选择。
在第 9 步,我们指定了游戏窗口中地面块的位置。在第 10 步,viewpoint
设置了相机的视角:
viewpoint=0 -> first-person
viewpoint=1 -> behind
viewpoint=2 -> facing
在第 13 步,我们指定了智能体移动在步骤结束后会停止的块类型。最后,我们在第 15 步的 AgentSection
标签中添加了所有特定于智能体的任务处理程序。任务架构创建将在第 15 步结束。
现在,我们需要存储来自任务的训练数据。我们使用 DataManager
来处理训练数据的记录。如果rl4j-data
目录不存在,它会创建该目录,并随着强化学习训练的进行存储训练数据。我们在第 16 步创建 DataManager
时传递了 false
作为属性。这意味着我们不会持久化训练数据或模型。如果要持久化训练数据和模型,请传递 true
。请注意,在配置 DQN 时,我们需要用到数据管理器实例。
另见
-
请参考以下文档,创建你自己的 Minecraft 世界自定义 XML 架构:
配置和训练 DQN 智能体
DQN 是一种强化学习的重要类别,称为价值学习。在这里,我们使用深度神经网络来学习最优 Q 值函数。在每次迭代中,网络会近似 Q 值,并根据贝尔曼方程对其进行评估,以衡量智能体的准确性。Q 值应该在智能体在世界中进行动作时得到优化。因此,如何配置 Q-learning 过程非常重要。在这个教程中,我们将配置 DQN 以进行 Malmo 任务,并训练智能体完成任务。
准备工作
以下内容的基础知识是此教程的先决条件:
-
Q-learning
-
DQN
Q-learning 基础将有助于在配置 DQN 的 Q-learning 超参数时。
如何操作…
- 为任务创建一个动作空间:
Sample:
MalmoActionSpaceDiscrete actionSpace =
new MalmoActionSpaceDiscrete("movenorth 1", "movesouth 1", "movewest 1", "moveeast 1");
actionSpace.setRandomSeed(rndSeed);
- 为任务创建一个观测空间:
MalmoObservationSpace observationSpace = new MalmoObservationSpacePixels(xSize, ySize);
- 创建一个 Malmo 一致性策略:
MalmoDescretePositionPolicy obsPolicy = new MalmoDescretePositionPolicy();
- 在 Malmo Java 客户端周围创建一个 MDP(马尔可夫决策过程)包装器:
Sample:
MalmoEnv mdp = new MalmoEnv("cliff_walking_rl4j.xml", actionSpace, observationSpace, obsPolicy);
- 使用
DQNFactoryStdConv
创建一个 DQN:
Sample:
public static DQNFactoryStdConv.Configuration MALMO_NET = new DQNFactoryStdConv.Configuration(
learingRate,
l2RegParam,
updaters,
listeners
);
- 使用
HistoryProcessor
对像素图像输入进行缩放:
Sample:
public static HistoryProcessor.Configuration MALMO_HPROC = new HistoryProcessor.Configuration(
numOfFrames,
rescaledWidth,
rescaledHeight,
croppingWidth,
croppingHeight,
offsetX,
offsetY,
numFramesSkip
);
- 通过指定超参数创建 Q 学习配置:
Sample:
public static QLearning.QLConfiguration MALMO_QL = new QLearning.QLConfiguration(
rndSeed,
maxEpochStep,
maxStep,
expRepMaxSize,
batchSize,
targetDqnUpdateFreq,
updateStart,
rewardFactor,
gamma,
errorClamp,
minEpsilon,
epsilonNbStep,
doubleDQN
);
- 使用
QLearningDiscreteConv
创建 DQN 模型,通过传递 MDP 包装器和DataManager
:在QLearningDiscreteConv
构造函数中:
Sample:
QLearningDiscreteConv<MalmoBox> dql =
new QLearningDiscreteConv<MalmoBox>(mdp, MALMO_NET, MALMO_HPROC, MALMO_QL, manager);
- 训练 DQN:
dql.train();
它是如何工作的…
在第 1 步中,我们通过指定一组定义好的 Malmo 动作,为代理定义了一个动作空间。例如,movenorth 1
表示将代理向北移动一个区块。我们将一个字符串列表传递给MalmoActionSpaceDiscrete
,指示代理在 Malmo 空间中的动作。
在第 2 步中,我们根据输入图像(来自 Malmo 空间)的位图大小(由xSize
和ySize
指定)创建了一个观察空间。此外,我们假设有三个颜色通道(R、G、B)。代理需要在运行之前了解观察空间。我们使用了MalmoObservationSpacePixels
,因为我们目标是从像素中获取观察。
在第 3 步中,我们使用MalmoDescretePositionPolicy
创建了一个 Malmo 一致性策略,以确保即将到来的观察处于一致的状态。
MDP 是强化学习中在网格世界环境中使用的一种方法。我们的任务有网格形式的状态。MDP 需要一个策略,强化学习的目标是为 MDP 找到最优策略。MalmoEnv
是一个围绕 Java 客户端的 MDP 包装器。
在第 4 步中,我们使用任务架构、动作空间、观察空间和观察策略创建了一个 MDP 包装器。请注意,观察策略与代理在学习过程结束时希望形成的策略不同。
在第 5 步中,我们使用**DQNFactoryStdConv
**通过添加卷积层构建了 DQN。
在第 6 步中,我们配置了HistoryProcessor
来缩放并移除不需要的像素。HistoryProcessor
的实际目的是执行经验回放,在这种回放中,代理的过去经验将在决定当前状态的动作时考虑。通过使用HistoryProcessor
,我们可以将部分状态观察转变为完全观察状态,也就是说,当当前状态是先前状态的积累时。
下面是第 7 步中在创建 Q 学习配置时使用的超参数:
-
maxEpochStep
:每个周期允许的最大步数。 -
maxStep
:允许的最大步数。当迭代次数超过maxStep
指定的值时,训练将结束。 -
expRepMaxSize
:经验回放的最大大小。经验回放是指基于过去的过渡数量,代理可以决定下一步要采取的行动。 -
doubleDQN
:这决定了是否在配置中启用了双重 DQN(如果启用,则为 true)。 -
targetDqnUpdateFreq
:常规的 Q 学习在某些条件下可能会高估动作值。双 Q 学习通过增加学习的稳定性来解决这个问题。双 DQN 的主要思想是在每M
次更新后冻结网络,或者在每M
次更新后平滑平均。M
的值被称为targetDqnUpdateFreq
。 -
updateStart
:在开始时进行无操作(什么也不做)的步骤数,以确保 Malmo 任务以随机配置开始。如果代理每次都以相同的方式开始游戏,代理将记住行动序列,而不是根据当前状态学习采取下一个行动。 -
gamma
:这也被称为折扣因子。折扣因子会乘以未来的奖励,防止代理被高奖励吸引,而不是学习如何采取行动。接近 1 的折扣因子表示考虑来自远期的奖励,而接近 0 的折扣因子表示考虑来自近期的奖励。 -
rewardFactor
:这是一个奖励缩放因子,用于缩放每一步训练的奖励。 -
errorClamp
:这会在反向传播期间截断损失函数相对于输出的梯度。对于errorClamp = 1
,梯度分量会被截断到范围 (-1, 1)。 -
minEpsilon
:Epsilon 是损失函数相对于激活函数输出的导数。每个激活节点的梯度会根据给定的 epsilon 值计算,以用于反向传播。 -
epsilonNbStep
:Epsilon 值将在epsilonNbStep
步骤中退火至minEpsilon
。
还有更多…
我们可以通过在代理路径上放置熔岩来让任务变得更加困难,放置熔岩的条件是执行了一定数量的动作。首先,使用 XML 模式创建一个任务规范:
MissionSpec mission = MalmoEnv.loadMissionXML("cliff_walking_rl4j.xml");
现在,设置熔岩挑战任务变得非常简单,如下所示:
mission.drawBlock(xValue, yValue, zValue, "lava");"
malmoEnv.setMission(mission);
MissionSpec
是一个类文件,包含在 MalmoJavaJar
依赖项中,我们可以用它来设置 Malmo 空间中的任务。
评估 Malmo 代理
我们需要评估代理,看看它在游戏中的表现如何。我们刚刚训练了我们的代理让它在世界中导航并达到目标。在这个过程中,我们将评估训练好的 Malmo 代理。
准备工作
作为前提,我们需要持久化代理的策略,并在评估期间重新加载它们。
代理在训练后使用的最终策略(在 Malmo 空间中进行移动的策略)可以如下面所示保存:
DQNPolicy<MalmoBox> pol = dql.getPolicy();
pol.save("cliffwalk_pixel.policy");
dql
指的是 DQN 模型。我们获取最终的策略,并将它们存储为 DQNPolicy
。DQN 策略提供模型估计的具有最高 Q 值的动作。
它可以在稍后进行恢复以进行评估/推理:
DQNPolicy<MalmoBox> pol = DQNPolicy.load("cliffwalk_pixel.policy");
如何操作…
- 创建一个 MDP 封装器来加载任务:
Sample:
MalmoEnv mdp = new MalmoEnv("cliff_walking_rl4j.xml", actionSpace, observationSpace, obsPolicy);
- 评估代理:
Sample:
double rewards = 0;
for (int i = 0; i < 10; i++) {
double reward = pol.play(mdp, new HistoryProcessor(MALMO_HPROC));
rewards += reward;
Logger.getAnonymousLogger().info("Reward: " + reward);
}
它是如何工作的…
Malmo 任务/世界在步骤 1 中启动。在步骤 2 中,MALMO_HPROC
是历史处理器配置。你可以参考之前食谱中的第 6 步,查看示例配置。一旦代理被评估,你应该能看到如下结果:
对于每次任务评估,我们都会计算奖励分数。正向奖励分数表示代理已到达目标。最后,我们会计算代理的平均奖励分数。
在上面的截图中,我们可以看到代理已经到达了目标。这是理想的目标位置,无论代理如何决定在方块中移动。训练结束后,代理将形成最终的策略,代理可以利用该策略到达目标,而不会掉入岩浆。评估过程将确保代理已经足够训练,能够独立进行 Malmo 游戏。