Deeplearning4j 实战 (13-2):基于Embedding+CNN的文本分类实现

Deeplearning4j 实战 (13-2):基于Embedding+CNN的文本分类实现

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的功能。

1. 模型结构和data flow的分析

1.1 模型结构

整体模型结构和之前介绍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的变化。

1.2 Data Flow描述

这部分内容首先给出的每一层Layer的数据shape变换情况,包括Mask部分的变换,并且做些说明。首先来看EmbeddedSeqLayer这一层。

1.2.1 词嵌入层

  • 定义:
    .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本身元素的位置是一致的。

1.2.2 Tensor Reshape层

  • 定义:
    .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]]]]

1.2.3 2-gram/3-gram/4-gram层

  • 定义:
.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]]]]

1.2.4 Merge层

  • 定义:
    .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]]]]

1.2.5 Global Pooing层

  • 定义:
    .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的处理,比较常规,这里不展开叙述了。

2. 数据构建与建模

2.1 数据预处理&构建

这次建模使用的语料和之前博客中使用的一样,是苏剑林老师科学空间开源的评论数据集,总体数量是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;
	}

在上面逻辑中wordIndexlabelIndex都是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。

2.2 模型训练&交叉验证

在上面模型构建和数据构建的基础上,调用相关的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的数值。混淆矩阵能直观的看到有多少样本被正确分类,多少样本被错误分类。这里比较清晰,可自行分析。

3. 总结和探讨

这里对上面的几个部分做下小结。首先我们对模型的整体结构做了介绍,相较于之前TextCNN的博客,添加了Embedded层和Reshape的操作,其余都和之前的保持一致。此外,超参数也是同之前的保持一致。最终在相同epoch轮次的训练后,得到的评估指标比之前文章中的指标略微差了一些,之前文章中acc达到了92%,这里是90%。不过之前TextCNN的文章中调用了内置的文本处理方法,对停用词是会做过滤的,而这里并没有做,这肯定会增加很多噪声,如果也做停用词过滤等预处理操作,那么估计acc再增长个2个点应该没啥问题。下面就两个问题做下讨论,一个是卷积层featureMap的数量问题,另一个则是Reshape操作是否可以转换成RnnToCnnPreProcessor的问题。

3.1 featureMap数量

在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的时候差别不是很大,因此笔者认为这确实是可以探讨的一个方向。
在这里插入图片描述

3.2 RnnToCnnPreProcessor是否可以使用

首先说下结论,目前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的问题,依然是可以使用的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
基于CNN文本分类系统可以用于对文本进行分类,例如对新闻进行分类、对电影评论进行情感分析等。下面是一个基于CNN文本分类系统的实现步骤: 1. 数据预处理:将文本转化为数字形式,例如将每个单词映射为一个数字,使用词袋模型或者TF-IDF对文本进行特征提取,将文本转化为向量形式。 2. 构建CNN模型:使用卷积层、池化层和全连接层构建CNN模型。卷积层可以学习文本中的特征,池化层可以减小特征图的大小,全连接层可以将特征映射到类别空间。 3. 模型训练:使用训练集对CNN模型进行训练,使用交叉熵作为损失函数,使用反向传播算法更新模型参数。 4. 模型评估:使用测试集对模型进行评估,计算模型的准确率、精确率、召回率和F1值。 下面是一个基于Keras框架实现CNN文本分类系统的示例代码: ```python from keras.models import Sequential from keras.layers import Embedding, Conv1D, MaxPooling1D, Flatten, Dense # 构建CNN模型 model = Sequential() model.add(Embedding(input_dim=vocab_size, output_dim=embedding_size, input_length=max_len)) model.add(Conv1D(filters=128, kernel_size=5, activation='relu')) model.add(MaxPooling1D(pool_size=5)) model.add(Flatten()) model.add(Dense(units=10, activation='softmax')) # 模型编译 model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) # 模型训练 model.fit(x_train, y_train, epochs=10, batch_size=32) # 模型评估 loss, accuracy = model.evaluate(x_test, y_test) print('Test loss:', loss) print('Test accuracy:', accuracy) ``` 其中,`vocab_size`表示词汇表的大小,`embedding_size`表示词向量的维度,`max_len`表示文本的最大长度,`x_train`和`y_train`表示训练集的输入和输出,`x_test`和`y_test`表示测试集的输入和输出。在上面的代码中,使用了一个卷积层、一个池化层和一个全连接层,其中卷积核的大小为5,池化窗口的大小为5。最后使用交叉熵作为损失函数,使用Adam优化器进行模型优化。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wangongxi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值