赞
踩
Eclipse Deeplearning4j GitChat课程:Deeplearning4j 快速入门_专栏
Eclipse Deeplearning4j 系列博客:万宫玺的专栏_wangongxi_CSDN博客
Eclipse Deeplearning4j Github:https://github.com/eclipse/deeplearning4j
Eclipse Deeplearning4j 社区:https://community.konduit.ai/
在之前的博客中,我们使用TextCNN对中文进行情感分析,其中词嵌入使用的是预训练模型来初始化词向量。在训练过程中,我们并不更新词向量,只更新卷积和池化层以及最后全连接层的模型参数,总的参数量控制在10W浮点数左右,属于非常轻巧的模型,也适合线上实时调用。这篇博客主要探讨下端到端的建模,也就是将词向量的构建融合到整体模型当中,在更新卷积层等参数的同时也更新词向量的分布。相对于单独构建词向量模型的做法,端到端的建模会更加直观一些,也不用去调研开源的预训练模型或者自己构建一个预训练模型。但必须指出的是,模型的参数量会急剧上升,训练阶段也必定会导致计算量和存储的上升,至于线上serving阶段的时效性理论上并不会有太多改变,因为词嵌入模块仅仅提供了类似字典的lookUp的功能。
整体模型结构和之前介绍TextCNN的博客中的结构类似,不同点在于增加了Embedding层以及Reshape层。我们先给出具体的code:
/*Embedding+CNN的端到端模型*/
private ComputationGraph getModel(final int vectorSize, final int numFeatureMap, final int corpusLenLimit, final int vocabSize, final int batchSize) {
ComputationGraphConfiguration config = new NeuralNetConfiguration.Builder()
.weightInit(WeightInit.XAVIER)
.updater(new Adam(0.01))
.convolutionMode(ConvolutionMode.Same)
.graphBuilder()
.addInputs("input")
.addLayer("embedding", new EmbeddingSequenceLayer.Builder()
.nIn(vocabSize).nOut(vectorSize).build(), "input")
.addVertex("reshape", new ReshapeVertex('c', new int[] {-1, 1, vectorSize, corpusLenLimit}, new int[] {-1, 1, 1, corpusLenLimit}), "embedding")
.addLayer("2-gram",new ConvolutionLayer.Builder().kernelSize(vectorSize, 2).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).activation(Activation.LEAKYRELU).build(),"reshape")
.addLayer("3-gram",
new ConvolutionLayer.Builder().kernelSize(vectorSize, 3).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).activation(Activation.LEAKYRELU).build(),"reshape")
.addLayer("4-gram",
new ConvolutionLayer.Builder().kernelSize(vectorSize, 4).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).build(),"reshape")
.addVertex("merge", new MergeVertex(), "2-gram", "3-gram", "4-gram")
.addLayer("globalPool",
new GlobalPoolingLayer.Builder()
.poolingType(PoolingType.MAX).dropOut(0.5).build(), "merge")
.addLayer("out",
new OutputLayer.Builder().lossFunction(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.activation(Activation.SOFTMAX).nOut(2).build(),
"globalPool")
.setOutputs("out")
.setInputTypes(InputType.recurrent(vocabSize))
.build();
ComputationGraph net = new ComputationGraph(config);
net.init();
return net;
}
这里对新增的EmbeddingSequenceLayer和RedshapeVertex进行说明。
EmbeddingSequenceLayer是1.0.0-beta版本新增的Layer模块,主要是对于现有的EmbeddingLayer进行功能扩展,直接支持时序数据以及时序Mask功能。现有的EmbeddingLayer的功能更多的是一张lookUp表,可以批量地查询词向量。虽然可以通过reshape的方式来支持时序数据,但并不直观,因此这里使用EmbeddingSequenceLayer来处理时序数据。
从I/O data flow层面分析,EmbeddingSequenceLayer支持[mb, seq_len]
或者[mb, 1, seq_len]
格式的输入数据,并输出[mb, embedding_size, seq_len]
格式的数据。换句话说,支持对时序数据进行向量化的操作。另外,由于时序数据一般都是变长的,因此需要基于Mask机制来标识序列实际有效的长度和位置。这个在1.2部分的data flow详细分析中会具体展开。
RedshapeVertex顾名思义是对数据的reshape操作,当然也包括Mask部分的reshape。RedshapeVertex层的第一个参数是底层数据存储的order,这里可以不关注。第二和第三个参数分别代表输入数据和Mask数据需要被规整后的shape。需要说明的是,由于mb是动态的,因此用-1来代替。如果从处理图像数据的角度看,EmbeddingSequenceLayer的输出可以被认为是一批灰度图,是height=embedding_size,width=seq_len,depth/channel=1的图像数据,这也是文本包括语音等时序数据可以通过CNN来处理的原因。为了适配后续卷积层处理数据的格式,我们通过ReshapeVertex将原始的时序数据增加一个维度且等于1,从3D变换成4D的张量,这个新增维度的物理含义是图像中的channel或者depth,对于灰度图channel/depth即等于1。
对于ReshapeVertex操作,它的I/O data shape其实是开发人员根据需要指定的,比如上面代码中实现了从[mb, embedding_size, seq_len]
到[mb, 1, embedding_size, seq_len]
的转换,目的也是为了适配卷积层的操作。对于Mask的reshape操作同样放到1.2的部分中阐述。
除了这两个部分以外,其余的模块和之前TextCNN的博客中描述的是一致的。如果有需要,可以翻阅前面的博客。我们通过summary接口来打印下模型的详细信息,超参数设置如下。
final int vectorSize = 8;
final int numFeatureMap = 3;
final int corpusLenLimit = 10;
final int vocabSize = 10000;
final int batchSize = 2;
ComputationGraph graph = getModel(vectorSize, numFeatureMap, corpusLenLimit, vocabSize, batchSize);
System.out.println(graph.summary(InputType.recurrent(vocabSize)));
可以得到如下的信息:
==============================================================================================================================================================================================================
VertexName (VertexType) nIn,nOut TotalParams ParamsShape Vertex Inputs InputShape OutputShape
==============================================================================================================================================================================================================
input (InputVertex) -,- - - - - -
embedding (EmbeddingSequenceLayer) 10000,8 80,000 W:{10000,8} [input] InputTypeRecurrent(10000,format=NCW) InputTypeRecurrent(8,timeSeriesLength=1,format=NCW)
reshape (ReshapeVertex) -,- - - [embedding] - InputTypeConvolutional(h=8,w=100,c=1,NCHW)
2-gram (ConvolutionLayer) 1,3 51 W:{3,1,8,2}, b:{3} [reshape] InputTypeConvolutional(h=8,w=100,c=1,NCHW) InputTypeConvolutional(h=1,w=100,c=3,NCHW)
3-gram (ConvolutionLayer) 1,3 75 W:{3,1,8,3}, b:{3} [reshape] InputTypeConvolutional(h=8,w=100,c=1,NCHW) InputTypeConvolutional(h=1,w=100,c=3,NCHW)
4-gram (ConvolutionLayer) 1,3 99 W:{3,1,8,4}, b:{3} [reshape] InputTypeConvolutional(h=8,w=100,c=1,NCHW) InputTypeConvolutional(h=1,w=100,c=3,NCHW)
merge (MergeVertex) -,- - - [2-gram, 3-gram, 4-gram] - InputTypeConvolutional(h=1,w=100,c=9,NCHW)
globalPool (GlobalPoolingLayer) -,- 0 - [merge] InputTypeConvolutional(h=1,w=100,c=9,NCHW) InputTypeFeedForward(9)
out (OutputLayer) 9,2 20 W:{9,2}, b:{2} [globalPool] InputTypeFeedForward(9) InputTypeFeedForward(2)
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Total Parameters: 80,245
Trainable Parameters: 80,245
Frozen Parameters: 0
==============================================================================================================================================================================================================
从调用summary接口的结果来看,主要的参数量集中在EmbeddingSequenceLayer这层。这也符合上文的有关分析。需要说明的是,这里为了简化参数分析,超参数的设置并不是真正training阶段的超参。这批超参数是为了方便说明网络结构和参数以及1.2部分阐述data flow所准备的,因此诸如featureMap数量等都设置的比较少。下面就结合summary的结果来整体说明下上述结构每一层data shape的变换,包括Mask部分data shape的变化。
这部分内容首先给出的每一层Layer的数据shape变换情况,包括Mask部分的变换,并且做些说明。首先来看EmbeddedSeqLayer这一层。
定义:
.addLayer("embedding", new EmbeddingSequenceLayer.Builder() .nIn(vocabSize).nOut(vectorSize).build(), "input")
Data I/O Shape:
input:[mb, seq_len]
output:[mb, embedding_size, seq_len]
Mask I/O Shape:
input:[mb, seq_len]
output:[mb, seq_len]
说明:这一层Layer的I/O数据格式比较清晰,是对原始数据(比如一段分词后文本序列)进行向量化,那自然output的部分会扩展出向量长度这一维度。Mask部分的目的是标识实际有效的文本序列,原因上文也提到过。这些部分均会参与forward+backward pass的计算。
例子:I/O tensor + mask tensor
input
Rank: 2, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,10], Stride: [10,1]
[[ 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000],
[ 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]]
mask
Rank: 2, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,10], Stride: [10,1]
[[ 1.0000, 1.0000, 0, 0, 0, 0, 0, 0, 0, 0],
[ 1.0000, 1.0000, 1.0000, 0, 0, 0, 0, 0, 0, 0]]
embedding
Rank: 3, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,8,10], Stride: [80,1,8]
[[[ -0.4199, -0.4199, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.2123, 0.2123, 0, 0, 0, 0, 0, 0, 0, 0],
[ -0.4690, -0.4690, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.3492, 0.3492, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.5633, 0.5633, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.2636, 0.2636, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.0920, 0.0920, 0, 0, 0, 0, 0, 0, 0, 0],
[ -1.1781, -1.1781, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.4199, -0.4199, -0.4199, 0, 0, 0, 0, 0, 0, 0],
[ 0.2123, 0.2123, 0.2123, 0, 0, 0, 0, 0, 0, 0],
[ -0.4690, -0.4690, -0.4690, 0, 0, 0, 0, 0, 0, 0],
[ 0.3492, 0.3492, 0.3492, 0, 0, 0, 0, 0, 0, 0],
[ 0.5633, 0.5633, 0.5633, 0, 0, 0, 0, 0, 0, 0],
[ 0.2636, 0.2636, 0.2636, 0, 0, 0, 0, 0, 0, 0],
[ 0.0920, 0.0920, 0.0920, 0, 0, 0, 0, 0, 0, 0],
[ -1.1781, -1.1781, -1.1781, 0, 0, 0, 0, 0, 0, 0]]]
简单说明下这个例子。模型的输入是[2, 10]
的tensor/matrix,代表batch=2的时序数据,且为了方便我们将元素值都固定1.0(当然这同实际情况不相符,实际应用中序列中每个元素对应一个词)。这里先不考虑padding的情况,当然如果需要padding,在遵循约定的前提下,通过padding zero即可。接着说明mask的情况,可以比较直观得看到,是一个batch=2的multi-hot的tensor。这个tensor中的第一个序列代表前两个元素是有效的,第二个序列代表前三个元素是有效的,序列长度等于input tensor的长度。最后看下embedding的输出tensor,从打印出的信息也可以看到,是个[2, 8, 10]
的tensor,其中dim=1就是新增的代表词向量长度的维度。由于mask tensor的作用,我们可以看到无效的embedding部分都用0来占位了。有效元素的位置和mask tensor本身元素的位置是一致的。
定义:
.addVertex("reshape", new ReshapeVertex('c', new int[] {-1, 1, vectorSize, corpusLenLimit}, new int[] {-1, 1, 1, corpusLenLimit}), "embedding")
Data I/O Shape:
input: [mb, embedding_size, seq_len]
output:[mb, 1, embedding_size, seq_len]
Mask I/O Shape:
input: [mb, seq_len]
output:[mb, 1, 1, seq_len]
说明:ReshapeVertex对上一层输出的tensor进行reshape操作。由于reshape操作并不改变总的元素数量,更多的时候是对了适配不同Layer或者Op的操作,因此从上面给出的shape可以看出data和mask tensor为了适配后续卷积层的操作,将维度都扩展到了4D,另外由于mini-batch size是不定的,还是用-1来代替。
例子:data output tensor
reshape
Rank: 4, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,1,8,10], Stride: [80,80,10,1]
[[[[ 0.1742, 0.1742, 0, 0, 0, 0, 0, 0, 0, 0],
[ -0.2452, -0.2452, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.1370, 0.1370, 0, 0, 0, 0, 0, 0, 0, 0],
[ -0.1828, -0.1828, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.8138, 0.8138, 0, 0, 0, 0, 0, 0, 0, 0],
[ -0.1781, -0.1781, 0, 0, 0, 0, 0, 0, 0, 0],
[ 0.3427, 0.3427, 0, 0, 0, 0, 0, 0, 0, 0],
[ -0.4277, -0.4277, 0, 0, 0, 0, 0, 0, 0, 0]]],
[[[ 0.1742, 0.1742, 0.1742, 0, 0, 0, 0, 0, 0, 0],
[ -0.2452, -0.2452, -0.2452, 0, 0, 0, 0, 0, 0, 0],
[ 0.1370, 0.1370, 0.1370, 0, 0, 0, 0, 0, 0, 0],
[ -0.1828, -0.1828, -0.1828, 0, 0, 0, 0, 0, 0, 0],
[ 0.8138, 0.8138, 0.8138, 0, 0, 0, 0, 0, 0, 0],
[ -0.1781, -0.1781, -0.1781, 0, 0, 0, 0, 0, 0, 0],
[ 0.3427, 0.3427, 0.3427, 0, 0, 0, 0, 0, 0, 0],
[ -0.4277, -0.4277, -0.4277, 0, 0, 0, 0, 0, 0, 0]]]]
.addLayer("2-gram",
new ConvolutionLayer.Builder().kernelSize(vectorSize, 2).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).activation(Activation.LEAKYRELU).build(),"reshape")
.addLayer("3-gram",
new ConvolutionLayer.Builder().kernelSize(vectorSize, 3).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).activation(Activation.LEAKYRELU).build(),"reshape")
.addLayer("4-gram",
new ConvolutionLayer.Builder().kernelSize(vectorSize, 4).stride(vectorSize, 1).nIn(1)
.nOut(numFeatureMap).build(),"reshape")
Data I/O Shape(以2-gram层为例子):
input:[mb, 1, embedding_size, seq_len]
output:[mb, num_featureMap, 1, seq_len]
Mask I/O Shape(以2-gram层为例子):
input:[mb, 1, 1, seq_len]
output:[mb, 1, 1, seq_len]
说明:2-gram~4-gram这三层都是类似的卷积层,它们的作用的在之前TextCNN的博客中提到过,是通过类似NLP中的N-gram语言模型来组合特征,当然如果认为1-gram也是有用的,也可以添加1-gram的卷积层。由于我们在网络结构中设置了.convolutionMode(ConvolutionMode.Same)
的卷积模式,因此输出的featureMap的大小和原始输入是保持一致的。至于kernel和stride的设置,这里不多赘述了,上一篇TextCNN的博客中已经有过描述。
例子:
2-gram
Rank: 4, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,3,1,10], Stride: [10,20,10,1]
[[[[ 0.2885, 0.1101, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.2393, -0.0018, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0062, -0.0012, 0, 0, 0, 0, 0, 0, 0, 0]]],
[[[ 0.2885, 0.2885, 0.1101, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.2393, 0.2393, -0.0018, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0062, -0.0062, -0.0012, 0, 0, 0, 0, 0, 0, 0]]]]
3-gram
Rank: 4, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,3,1,10], Stride: [10,20,10,1]
[[[[ -0.0025, -0.0084, -0.0088, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.0229, 0.0386, 0.0026, 0, 0, 0, 0, 0, 0, 0]],
[[-5.0364e-5, 0.0585, 0.1782, 0, 0, 0, 0, 0, 0, 0]]],
[[[ -0.0025, -0.0113, -0.0084, -0.0088, 0, 0, 0, 0, 0, 0]],
[[ 0.0229, 0.0255, 0.0386, 0.0026, 0, 0, 0, 0, 0, 0]],
[[-5.0364e-5, 0.1732, 0.0585, 0.1782, 0, 0, 0, 0, 0, 0]]]]
4-gram
Rank: 4, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,3,1,10], Stride: [10,20,10,1]
[[[[ 0.4807, 0.5605, 0.5802, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.4883, 0.4193, 0.4487, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5566, 0.4977, 0.4056, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]]],
[[[ 0.4456, 0.5612, 0.5605, 0.5802, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5397, 0.4372, 0.4193, 0.4487, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5915, 0.4615, 0.4977, 0.4056, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]]]]
定义:
.addVertex("merge", new MergeVertex(), "2-gram", "3-gram", "4-gram")
Data Output Shape:
input:[mb, num_featureMap, 1, seq_len]
output:[mb, 3*num_featureMap, 1, seq_len]
Mask Output Shape:不参与计算
说明:merge操作是按照tensor的某一维度进行数据的合并。默认情况下,对于CNN结构的4D数据会沿着channel/depth方向进行merge,因此上一个模块三个N-gram层分别输出的3个feature Map会合并成3x3=9个feature Map。当然,对于其他场景,开发人员可以自定义merge的维度,这里不再展开。
例子:
merge
Rank: 4, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,9,1,10], Stride: [90,10,10,1]
[[[[ 0.2885, 0.1101, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.2393, -0.0018, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0062, -0.0012, 0, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0025, -0.0084, -0.0088, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.0229, 0.0386, 0.0026, 0, 0, 0, 0, 0, 0, 0]],
[[-5.0364e-5, 0.0585, 0.1782, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.4807, 0.5605, 0.5802, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.4883, 0.4193, 0.4487, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5566, 0.4977, 0.4056, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]]],
[[[ 0.2885, 0.2885, 0.1101, 0, 0, 0, 0, 0, 0, 0]],
[[ 0.2393, 0.2393, -0.0018, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0062, -0.0062, -0.0012, 0, 0, 0, 0, 0, 0, 0]],
[[ -0.0025, -0.0113, -0.0084, -0.0088, 0, 0, 0, 0, 0, 0]],
[[ 0.0229, 0.0255, 0.0386, 0.0026, 0, 0, 0, 0, 0, 0]],
[[-5.0364e-5, 0.1732, 0.0585, 0.1782, 0, 0, 0, 0, 0, 0]],
[[ 0.4456, 0.5612, 0.5605, 0.5802, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5397, 0.4372, 0.4193, 0.4487, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]],
[[ 0.5915, 0.4615, 0.4977, 0.4056, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000, 0.5000]]]]
定义:
.addLayer("globalPool",new GlobalPoolingLayer.Builder() .poolingType(PoolingType.MAX).dropOut(0.5).build(), "merge")
Data I/O Shape:
input:[mb, 3*num_featureMap, 1, seq_len]
output:[mb, 3*num_featureMap]
Mask I/O Shape:
input:[mb, seq_len]
output:[]
说明:GlobalPoolingLayer的作用默认对CNN格式的4D数据进行dim=2,3维度上的pooling,这里我们设置的是max pooling。当然开发人员可以指定pooling的维度,这个也有接口暴露给开发人员,这里不多叙述了。可以看到,对于输入数据是[mb, 3*num_featureMap, 1, seq_len]
这样的4D数据时,global pooling操作的是对[1, seq_len]
切面的数据进行最大池化。它的物理含义也可以认为是在时序上选择最有意义的特征。对于mask tensor来说,它跟随池化操作一起参与计算,并且在这个Layer计算结束后,mask tensor将不再对后续计算起作用。
例子:
globalPool
Rank: 2, DataType: FLOAT, Offset: 0, Order: c, Shape: [2,9], Stride: [9,1]
[[ 1.7355, 0, 0.3975, -0.0005, 0.1839, 0.3885, 0.5214, 0.5861, 0.7745],
[ 1.7355, 0, 0.3975, -0.0005, 0.1839, 0.3885, 0.6785, 0.6118, 0.8175]]
最后一层是全连接层并做softmax+BCE的处理,比较常规,这里不展开叙述了。
这次建模使用的语料和之前博客中使用的一样,是苏剑林老师科学空间开源的评论数据集,总体数量是2W左右的文本。同样使用jieba分词对语料进行了切词处理,这里为了简化预处理流程因此不做停用词等处理了。
语料的预处理在分词基础上需要完成词标签映射表、标注标签映射表的构建以及最长序列长度的记录。此外,按照70% vs 30% 的比例构建训练集和验证集。这里先给出主要的逻辑再做些简单分析。
public void init() {
String line = null;
try(BufferedReader br = Files.newReader(new File("comment/corpus.txt"), Charset.forName("UTF-8"))){
while( (line = br.readLine()) != null ) {
String[] words = line.split(" ");
maxLen = Math.max(maxLen, words.length);
corpus.add(words);
for( String word : words) {
if( !wordIndex.containsKey(word) ) {
wordIndex.put(word, index);
++index;
}
}
}
}catch(Exception ex) {
System.err.println(ex.getMessage());
System.err.println("Error Line: " + line);
}
labelIndex.put("正面", 0);
labelIndex.put("负面", 1);
//
try(BufferedReader br = Files.newReader(new File("comment/label.txt"), Charset.forName("UTF-8"))){
while( (line = br.readLine()) != null ) {
label.add(line);
}
}catch(Exception ex) {
System.err.println(ex.getMessage());
System.err.println("Error Line: " + line);
}
//
this.maxLen = this.maxLen > 256 ? 256 : this.maxLen; //截断最长的语料长度
return;
}
在上面逻辑中wordIndex
和labelIndex
都是HashMap的实例对象,分别存储词和词标签,分类标注和标注标签。这里分词标签使用自增整型变量即可。在此基础上我们给出构建训练集和验证集的逻辑。
private Pair<DataSetIterator,DataSetIterator> getData(final int mb) {
List<org.nd4j.linalg.dataset.api.DataSet> dsLst = Lists.newLinkedList();
List<org.nd4j.linalg.dataset.api.DataSet> dsTrainLst = Lists.newLinkedList();
List<org.nd4j.linalg.dataset.api.DataSet> dsTestLst = Lists.newLinkedList();
for(int i = 0; i < corpus.size(); ++i) {
INDArray featureInd = Nd4j.zeros(new int[] {1, maxLen});
INDArray labelInd = Nd4j.zeros(new int[] {1, 2});
INDArray featureMaskInd = Nd4j.zeros(new int[] {1, maxLen});
//
String[] words = corpus.get(i);
String labelLine = label.get(i);
//
for(int j = 0; j < words.length && j < this.maxLen; ++j) {
String word = words[j];
int wi = wordIndex.get(word);
featureInd.putScalar(new int[] {0, j}, wi);
featureMaskInd.putScalar(new int[] {0, j}, 1);
}
switch(labelIndex.get(labelLine)) {
case 0:labelInd.putScalar(new int[] {0, 0}, 1.0);break;
case 1:labelInd.putScalar(new int[] {0, 1}, 1.0);break;
}
//
org.nd4j.linalg.dataset.api.DataSet ds = new DataSet(featureInd, labelInd, featureMaskInd, null);
dsLst.add(ds);
}
Collections.shuffle(dsLst);
int totalSize = dsLst.size();
int totalTrainSize = (int)(totalSize * 0.7);
dsTrainLst.addAll(dsLst.subList(0, totalTrainSize));
dsTestLst.addAll(dsLst.subList(totalTrainSize, totalSize));
return Pair.of(new ListDataSetIterator(dsTrainLst, mb), new ListDataSetIterator(dsTestLst, mb));
}
上面这部分逻辑先将所有的语料通过转换为DataSet
实例对象存储在dsLst
中,并且需要指出的是一条语料对应一个DataSet
实例对象。通过shuffle
接口随机打乱所有的语料,由于这份数据集正负样本基本是均衡的状态,所以可以不考虑样本权重或者类别权重的问题。在随机shuffle
后,我们通过截图前70%的样本作为训练数据,剩下的30%就作为验证集了。另外,ListDataSetIterator
的构建函数中可以通过传入mini-batch参数来自动生成微批的数据迭代器。这里对于mask tensor的构建简单解释下,对于所有语料我们都是构建以最长序列的长度为长度的向量,因此mask tensor/array的长度等于maxLen
,同时对应于某条语料的有效元素的数量对应于mask array中都设置为1,其余为0。
在上面模型构建和数据构建的基础上,调用相关的fit
方法和eval
方法进行模型训练和交叉验证就比较简单了。这里给出实际的超参数和training逻辑。
final int vectorSize = 128;
final int numFeatureMap = 100;
final int corpusLenLimit = this.maxLen;
final int vocabSize = index;
final int batchSize = 32;
ComputationGraph graph = getModel(vectorSize, numFeatureMap, corpusLenLimit, vocabSize, batchSize);
//
Pair<DataSetIterator,DataSetIterator> pairIter = getData(batchSize);
DataSetIterator trainIter = pairIter.getKey();
DataSetIterator testIter = pairIter.getValue();
graph.setListeners(new ScoreIterationListener(10));
for( int epoch = 0; epoch < 20; ++epoch ) {
graph.fit(trainIter);
trainIter.reset();
if( epoch > 0 && epoch % 2 == 0 ) {
testIter.reset();
Evaluation eval = graph.evaluate(testIter);
System.out.println(eval.stats());
}
}
超参数是保持了和之前TextCNN博客中的一致性,包括featureMap的数量、batch的数量都是尽量保持一致。一共训练20个epoch,并且没经过2个epoch进行Recall/Precision/F1-score以及混淆矩阵的计算。我们直接看下最后的日志结果。
整体准确率在90%,正样本的Precision和Recall都在0.9左右,F1-score也达到了0.9的数值。混淆矩阵能直观的看到有多少样本被正确分类,多少样本被错误分类。这里比较清晰,可自行分析。
这里对上面的几个部分做下小结。首先我们对模型的整体结构做了介绍,相较于之前TextCNN的博客,添加了Embedded层和Reshape的操作,其余都和之前的保持一致。此外,超参数也是同之前的保持一致。最终在相同epoch轮次的训练后,得到的评估指标比之前文章中的指标略微差了一些,之前文章中acc达到了92%,这里是90%。不过之前TextCNN的文章中调用了内置的文本处理方法,对停用词是会做过滤的,而这里并没有做,这肯定会增加很多噪声,如果也做停用词过滤等预处理操作,那么估计acc再增长个2个点应该没啥问题。下面就两个问题做下讨论,一个是卷积层featureMap的数量问题,另一个则是Reshape操作是否可以转换成RnnToCnnPreProcessor的问题。
在TextCNN的论文中有提到使用的featureMap数量是100个,而且是3-gram~5-gram的feature map的数量各100个。因此为了尽量复现论文的结果,这里也设置为100个feature map。我们知道,feature map的数量越多,training和serving阶段的计算量必定会增加,time cost必定会有所增加。虽然CUDA或者MKL-DNN支持feature map的并行计算,但在保证离线指标下降不多的前提下,减少feature map的数量肯定有积极的意义。那么就自然产生了一个问题,设置多少个feature map合理呢?我认为应当同语料的长度有关。
回想下,整个TextCNN的网络结构,如果从物理意义上去考虑,它是在做什么呢?首先序列向量化之后,通过reshape操作送到卷积层,通过不同尺度的kernel提取类似N-gram语言模型的特征并且merge在一起,最后通过global pooling沿着时间维度进行最大池化,也就是提取最有意义的那个组合词特征,最后把这些最有意义的组合词合并在一起对类别进行预测,这其实就是idea的整个执行流程。feature map的数量从某种意义上来说,可以代表提取的不同的有意义的词组合。那么对于本文中提到的分类问题,如果长度是512或者256个词构成的短文本,需要多少N-gram组合词来决定情感的倾向,feature map的数量其实就可以设置成多少。但似乎100个feature map有些过于多了。因此我个人觉得,是否可以从10个feature map开始尝试,进行超参的grid search,最终决定这个超参数也是比较合理的。这边尝试了下将feature Map设置成10的时候得到的指标,从指标上看,同设置成100的时候差别不是很大,因此笔者认为这确实是可以探讨的一个方向。
首先说下结论,目前RnnToCnnPreProcessor
的实现并在这里支持TextCNN的构建,主要原因在于mask tensor的问题上。我们先来看一段源码:
@Override
public Pair<INDArray, MaskState> feedForwardMaskArray(INDArray maskArray, MaskState currentMaskState,
int minibatchSize) {
//Assume mask array is 2d for time series (1 value per time step)
if (maskArray == null) {
return new Pair<>(maskArray, currentMaskState);
} else if (maskArray.rank() == 2) {
//Need to reshape mask array from [minibatch,timeSeriesLength] to 4d minibatch format: [minibatch*timeSeriesLength, 1, 1, 1]
return new Pair<>(TimeSeriesUtils.reshapeTimeSeriesMaskToCnn4dMask(maskArray,
LayerWorkspaceMgr.noWorkspacesImmutable(), ArrayType.INPUT), currentMaskState);
} else {
throw new IllegalArgumentException("Received mask array of rank " + maskArray.rank()
+ "; expected rank 2 mask array. Mask array shape: " + Arrays.toString(maskArray.shape()));
}
}
从这段源码里面,可以比较清楚得看到,通过RnnToCnnPreProcessor
处理或的数据,它的mask shape会变为[mb*seq_len, 1, 1, 1]
而并非是output:[mb, 1, 1, seq_len]
,虽然这不影响前向转播的整体计算,但是计算的结果并不是mask掉部分time step,因此这里无法使用RnnToCnnPreProcessor
来对时序数据进行4D化的处理。当然,如果有的场景可以不考虑mask的问题,依然是可以使用的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。