由于词之间的相对垂直关系可以使任意的,只取决于页面宽度,因此关联信息主要体现在"水平"方向上。
对于图像这种二维输入使用的是二维卷积,而对于句子这种一维输入,我们主要关注的是词条在一维空间维度的关系,所以做的是一维卷积。这里的卷积核也可以是是一维的。
如果将文本想象为图像,则"第二个"维度是词向量的全长,一般是100维到500维,就像一个真实的图像。我们只需要关心卷积核的"宽度",要注意,每个单词的词条或之后的字符词条在句子"图像"中都相当于一个像素。
卷积这个术语其实是一种简写。如何滑动窗口对模型本身没有影响。各个位置的数据决定了运算结果。计算"快照"的顺序并不重要,只需要保证按照与窗口在输入上的位置相同的方式来重构输出即可。
在前向传播的过程中,对于给定的输入样本,卷积核中的权重值不变,这意味着对于一个给定卷积核,可以并行的处理其所有"快照"并同时合成输出"图像"。这就是卷积神经网络速度快的原因。
卷积神经完了的处理速度,加上忽略特征位置的能力,是研究人员一直使用卷积方法来提取特征的原因。
Keras实现:准备数据
可以通过Keras文档提供的卷积神经网络实例来看一下Python中的卷积,它设计了一个一维卷积网络来分类IMDB电影评论数据集。
每个数据点都预先标记为0(消极)或1(积极)。下面的代码中,将把实例IMDB电影评论数据集替换为原始文本数据集,这样可以亲自预处理文本,然后会看到是否可以使用这个已经训练的网络来分类它从未见过的文本:
python
import numpy as np
#处理填充输入数据的辅助模块
from keras.api.preprocessing import sequence
#基础的Keras神经网络模块
from keras.api.models import Sequential
#模块中常用的层对象
from keras.api.layers import Dense,Dropout,Activation
#卷积层和池化
from keras.api.layers import Conv1D,GlobalAvgPool1D
首先下载数据集,之后将其解压。
训练目录中的评论数据被分为pos和neg中的两类文本文件。需要在Python中以适当的标签先读取出来然后打乱顺序,使样本不会全部是正例或负例。如果用以标签排序的数据进行训练,会使训练结果偏向于后出现的内容,尤其是在使用默写超参数的清醒下。具体代码:
python
import glob
import os
from random import shuffle
def pre_process_data(filpath):
positive_path=os.path.join(filpath,'pos')
negative_path=os.path.join(filpath,'neg')
pos_label=1
neg_label=0
dataset=[]
for filename in glob.glob(os.path.join(positive_path,'*.txt')):
with open(filename,'r') as f:
dataset.append(pos_label,f.read())
for filename in glob.glob(os.path.join(negative_path,'*.txt')):
with open(filename,'r') as f:
dataset.append(neg_label,f.read())
shuffle(dataset)
return dataset
第一个示例文档如下所示,元组的第一个元素是情感的目标值:1表示积极情感,0表示消极情感:
下一步是数据的分词和向量化,将基于预训练的Word2vec词向量,一次可以先通过nlpia包处理这些数据。
编写一个辅助函数来对数据进行分词,并创建一个用于传递给模型的输入词条向量列表:
python
word_vectors=KeyedVectors.load_word2vec_format('GoogleNews-vectors-negative300.bin',binary=True,limit=200000)
def tokenize_and_vectorize(dataset):
tokenizer=TreebankWordTokenizer()
vectorized_data=[]
excepted=[]
for sample in dataset:
tokens=tokenizer.tokenize(sample[1])
sample_vecs=[]
for token in tokens:
try:
sample_vecs.append(word_vectors[token])
except KeyError:
pass
vectorized_data.append(sample_vecs)
return vectorized_data
这个地方有一些信息损失,谷歌新闻的Word2vec必会被中只包含了一部分停用词。
下面,手机目标值(0表示负面评价,1表示正面评价),将其按照与训练样本相同的顺序排列:
python
def collect_excepted(dataset):
excepted=[]
for sample in dataset:
excepted.append(sample[0])
return excepted
然后将数据传入函数:
python
vectorized_data=tokenize_and_vectorize(dataset)
excepted=collect_excepted(dataset)
接下来,将准备好的数据分成训练集和测试集。
如果直接对导入的数据集进行80/20划分,会忽略test文件夹中的数据,实际上,下载的原始数据中的训练目录和测试目录都包含了有效的训练数据和测试数据,可以对数据进行随意组合,数据越多越好。下载的大多数数据集中的train/和test/目录是由该数据包的维护者按照特定的训练集/测试集比例划分的。
下面的代码将数据集分别放入训练集和对应的"正确"答案,以及测试集和对应的答案中,可以让网络对测试集中的样本进行"预测",验证它能否正确学到一些训练数据之外的东西:
python
split_point=int(len(vectorized_data)*0.8)
X_train=vectorized_data[:split_point]
y_train=excepted[:split_point]
X_test=vectorized_data[split_point:]
y_test=excepted[split_point:]
下面,设置网络的大部分超参数:
maxlen变量用于设置评论的最大长度。因为卷积神经网络的每个输入必须具有相同的维数,所以需要截断超出400个词条的样本,并填充少于400个词条的样本,填充值可以是Null或0;在表示原始文本时,通常使用"PAD"标记来表示填充位置。这个处理将系统引入新的数据。不过网络本身也会学习这个模式,使PAD="ignore me"称为网络结构的一部分。
这里的填充是为了使输入大小保持一致。对每个训练样本的开头和结尾填充都需要单独考虑,这具体取决于是否希望输出具有相同大小以及结束位置上的词条是否与中间位置上的词条作相同的处理或者是否对起始/结束词条区别对待,下面是具体代码:
python
maxlen=400
#在后向传播误差和更新权重前,向网络输入的样本数量
batch_size=32
#传入卷积神经网络中词条向量的长度
embedding_dims=300
#要训练的卷积核的数量
filters=250
#卷积核大小:每个卷积核将是一个矩阵:embedding_dims*kernel_size,
kernel_size=3
#在普通的前馈网络中,传播链端点的神经元数量
hidden_dims=250
#整个训练数据集在网络中的传入次数
epochs=2
Keras中提供了预处理辅助方法pad_sequences,理论上可以用于填充输入数据,但是,它只对标量序列有效,但这里是向量序列。所以我们需要自己编写一个辅助函数来填充输入数据:
python
def pad_turnc(data,maxlen):
new_data=[]
zero_vector=[]
for _ in range(len(data[0][0])):
zero_vector.append(0.0)
for sample in data:
if len(sample)>maxlen:
temp=sample[:maxlen]
elif len(sample)<maxlen:
temp=sample
additional_elems=maxlen-len(sample)
for _ in range(additional_elems):
temp.append(zero_vector)
else:
temp=sample
new_data.append(temp)
return new_data
然后,需要将训练数据和测试数据都传递到填充器/截断器,然后将其转换为numpy数组,以便在Keras中使用。这就是该CNN网络所需形状的张量(大小为样本数量*序列长度*词向量长度)。
python
X_train=pad_turnc(X_train,maxlen)
X_test=pad_turnc(X_test,maxlen)
X_train=np.reshape(X_train,(len(X_train),maxlen,embedding_dims))
y_train=np.array(y_train)
X_test=np.reshape(X_test,(len(X_train),maxlen,embedding_dims))
y_test=np.array(y_test)
卷积神经网络架构
下面是从基本的神经网络模型类Sequential开始,Sequential是Keras中神经网络的基类之一。
我们添加的第一个部分是卷积层,在这里,假设输出层的维度小于输入层,填充符号设置为'valid'。每个卷积核从句首的最左侧边缘开始,到最右侧边缘的最后一个词条停止。
卷积核每次将移动衣蛾词条(步长),卷积核(窗口宽度)大小设置为3个词条,并使用'relu'作为激活函数,每一步都将卷积核的权重与它正在查看的3个词条(逐个元素)相乘,然后进行加和,如果结果大于0,则继续传递,否则输出0,最后的结果(正数或0)将传递给修正线性单元激活函数,具体代码如下:
python
print('模型构建:')
model=Sequential()
model.add(Conv1D(
filters,
kernel_size,
padding='valid',
activation='relu',
strides=1,
input_shape=(maxlen,embedding_dims)
))
池化
池化是卷积神经网络中一种降维方法。在某种程度上,我们正在通过并行计算来加快处理速度,但是每个我们定义的卷积核都会创建一个新"版本"的数据样本,即经过卷积过滤的数据样本。在前面的代码中,第一层卷积网络将产生250个过滤后的版本数据。池化不但能够在一定程度上缓解输出过多的情况,还有另一个显著特征。
池化的关键思想是把每个卷积核的输出均匀地分成多个子部分,对于每个子部分,挑选或计算出一个具有代表的值,然后就可以将原始输出放在一边而只使用这些具有代表性的值的集合来作为下一次的输入。
一般来说,丢弃数据不是一个理想的方案,但事实证明,这是学习源数据高阶表示的一种有效途径。卷积核通过这种训练来发现数据中的模式,这些模式存在于词和相邻词之间的关系中。
在图像处理中,第一层学到的往往是边缘信息,位于像素密度迅速变化的部分。后面的层会学到形状和纹理等概念。再之后的层可能会学到"内容"或"含义"。类似的过程也会发生在文本上。
池化有两种方式:平均池化和最大池化。平均池化是比较直观的方式,通过求子集的平均值理论上可以保留最多的数据信息。而最大池化有一个有趣额特征,通过取给定区域中的最大激活值,网络可以看到这个片段中最突出的特征。网络有一条学习路径来决定应该看什么,而不管确切的像素级位置。
除了降维和节省计算量,还有一些特殊收获:位置不变性。如果原始输入在相似但有区别的输入样本中的位置发生轻微变化,则最大化池化层仍然会输出类似的内容。这在图像识别领域中是一个非常有用的特性,在自然语言处理中也有类似的作用。
在Keras的简单示例中,使用的是GlobalMaxPooling1D层。不是对每个卷积核输出的各个子部分取最大值,而是对该卷积核整体输出取最大值,这将导致大量的信息损失。但即使损失了这些信息,模型也不会有问题:
python
model.add(GlobalAveragePooling1D())
上面的重要内容:
- 对于每个输入样本,应用一个卷积核(权重和激活函数)
- 卷积输出的一维向量的维度略小于原始输入(输入数据维度为400),卷积核开始于输入数据的左对齐位置,结束于右对齐位置,输出维度为1*398
- 对于每个卷积核的输出(有250个卷积核),取每个一维向量的最大值
- 对于每个输入样本得到一个1*250向量(250是卷积核的数量)
对于每个输入样本,都有一个一维向量,网络认为这个向量很好的表示了输入样本。这就是输入数据的语义表示(当然还比较粗糙),并且,这只是在以情感为训练目标的上下文中的语义表示。所以,它并不能对电影评论的内容进行编码,只能对情感编码。这里是一个很重要的节点,一旦对网络进行训练,这个语义就会变得非常有用(一般把它理解成一个"重要思想")。就像将词嵌入向量之后一样,还可以对这些应语义表示进行数学运算。
还有一个重要的目标,那就是情感标签。将当前的向量传入一个标准的前馈网络,在Keras中就是Dense层。当前语义向量中的元素数量与Dense层中的节点数量相同,但这只是一个巧合。Dense层中250个神经元(hidden_dims),每个神经元都有250个权重与池化层传递过来的输入相对应。可以使用dropout层来进行调整,以防止过拟合。
dropout
dropout是一种特殊的技术,用于防止神经网络中的过拟合。它并不是自然语言处理中特有的,但用在这里效果很好。其理念是,在训练过程中,如果按照一定比例随机"关掉"部分进入下一次的输入数据,这样模型就不会学到训练集的特点,导致"过拟合",而是会学到更多数据中的略有差别的表示模式,从而在看到全新的数据时,能够对数据进行概括并做出更精确的预测。
模式通过假设在某特定输入时,进入dropout层的输出(来自上一次的输出)为零来实现dropout。这样接收到dropout输入数据的每个神经元的权重对整体误差的贡献实际上也是零。因此,在反向传播过程中这些权重不会更新。然后网络将被迫依赖不同权重集之间的关系来实现优化目标。
在Keras中dropout层接收的参数是输入数据随机关闭的比例。在这个case中,仅为每个训练样本随机选择80%的嵌入数据按原样传递到下一层,其余会设置为0.一般将dropout参数设置为20%,不过50%的比例也可以有很好的结果,还可以使用其他超参数。
然后在每个神经元的输出端使用修正线性单元作为激活函数:
python
model.add(Dense(hidden_dims))
model.add(Dropout(0.2))
model.add(Activation('relu'))
输出层
最后一次,或者说输出层,是实际的分类器,这里有一个基于Sigmoid激活函数的神经元,它的输出是0到1之间的值。在验证阶段,Keras将小于0.5的值分为0类,大于0.5的值分为1类,但在计算损失时,是用目标值减去由Sigmoid计算的实际值来得到的:(y-f(x))。
下面将数据投射到只有单个神经元的输出层,并将信号传入Sigmoid激活函数:
python
model.add(Dense(1))
model.add(Activation('sigmoid'))
这时就有了一个在Keras中定义的卷积神经网络模型,接下来就是编译和训练:
python
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
网络的训练目标是最小化损失函数loss,在这里我们使用binary_crossentropy。Keras中定义了13个损失函数,并且用户可以自己定义自己的损失函数。
binary_crossentropy和categorical_crossentropy的区别:
- 两者在数学定义上很相似;
- 很多情况下可以将binary_crossentropy看作是categorical_crossentropy的一种特殊情况;
- categorical_crossentropy常用于多分类预测,在这些情况下,目标输出将是一个独热编码的n维向量,每个位置代表n个类中的一个类。
当前这个例子中,网络中的最后一次代码如下:
python
model.add(Dense(num_classes))
model.add(Activation('sigmoid'))
在这种情况下,目标值将去输出值(y-f(x))将是一个n维向量将去另一个n维向量。categorical_crossentropy会尝试来最小化这个值。
下面还是二分类问题:
优化
optimizer参数用于设置网络在训练阶段的一系列优化策略,包括随机梯度下降,Adam和RSMProp等。这些优化策略都是神经网络中针对最小化损失函数的不同方法,针对特定问题可以尝试不同的优化方法。对于某个问题,虽然很多优化器能收敛,但有些不会,并且它们会以不同的速率收敛。
它们的作用来自根据当前的训练状态动态的改变训练参数,特别是学习率参数。例如,学习率可能会随着时间的推移而衰减。或者还有一些方法会使用动量,根据权重最后一次成功减少损失的移动方向来增加学习率。
每个优化器本身都有一些超参数,如学习率,Keras对这些超参数都设有很好的默认值,所以一开始不必过多考虑这些超参数。
拟合
compile完成模型的构建,fit完成模型的训练。训练过程中所有的操作,包括输入与权重相乘、激活函数、反向传播等都是由这一条语句启动的。这个过程耗费的时间取决于硬件配置、模型大小、数据规模,可能需要几秒到几个月不等。在大多数情况,使用GPU可以大大减少训练时间。不过这中情况比较少,大多数现代CPU都能在合理的时间内完成运行,如下面的代码所示:
python
model.fit(X_train,y_train,
batch_size=batch_size,
epochs=epochs,
validation_data=(X_test,y_test))
训练
如果希望在完成训练后保存模型状态(现在并不打算把模型保存在内存中),可以将模型的结构保存在JSON文件中,并将训练后的权重保存在另一个文件中,以便之后重新实例化:
python
model_structure=model.to_json()
with open('cnn_model.json','w') as json_file:
json_file.write(model_structure)
model.save_weights('cnn_weights.h5')
这样训练好的模型将保存在磁盘上,它已经收敛了,所以无须再训练一次。
Keras在训练阶段提供了一些非常有用的回调方法,可以作为关键词参数传递给fit方法,例如检查点checkpointing,当精确率提高或损失减少时可以迭代地保存模型,或者早停EarlyStopping,当在一个指定的评价方法上模型效果不再改善时,则提取停止训练。而令人兴奋的是,它们实现了TensorBoard回调方法,只有在TensorFlow作为后端时TensorBoard才能发挥作用,但它提供了强大的探查模型内部结果的功能,在排除故障和调优是不可或缺的。
神经元的初始权重是随机选择的,但是可以通过为随机数生成器设置种子来克服这种随机性,实现一个可重复的流水线。这样做可以使每次运行时的初始权重为相同的随机值,这对模型调试和调优很有帮助。
起始点可能会使模型陷入局部极小值,甚至阻止模型收敛,所以可以尝试一些不同的种子。
要设置种子,在模型定义前添加以下两行代码,传入seed参数的数值并不重要,只要保持一致,模型机会将权重初始化为小值:
python
import numpy as np
np.random.seed(1234)
现在还没有看到明显的过拟合迹象;训练集和验证集上的精确率都有所提高。可以让模型再运行一两个训练周期,看受可以在不过拟合的情况下继续提供精确率。只要模型还在内存中,或者从保存文件中重新加载进来,Keras就可以从这个保存点继续进行训练。只要再次调用fit方法(无论是否更改样本数据),就能从最近一次状态中恢复训练。
刚刚对模型进行描述,并将其编译为初始未训练状态,然后调用fit方法,通过反向传播每个样本的误差来学习最后面的卷积核和前馈全连接网络之间的权重,以及250个不同的卷积核各自的权重。
训练过程中用损失来报告进度,这里用的是binary_crossentropy。对于每个批次,Keras都报告一个度量指标,即我们与为样本提供的标签之间的距离。精确率是指"正确猜测的百分比"。但这个度量指标可能会误导人,尤其使用不平衡数据集的时候。
val_loss和val_acc是相同的度量指标,只是针对的是如下测试数据集:
validation_data=(X_test,y_test)
验证样本不会展示给网络进行训练,只用来验证模型的预测效果,并产生度量指标报告。反向传播算法不会发生在这些样本上。这有助于跟踪模型在新的、真知数据上的泛化效果。
在流水线中使用模型
拿到一个训练完成的模型之后,可以向模型传入一个新的样本数据,看看网络会如何识别这个数据。输入数据可以是一条聊天信息或X等。
首先,如果模型不在内存中,则需要从模型文档中实例化训练好的模型:
python
from keras.api.models import model_from_json
with open('cnn_model.json','r') as json_file:
json_string=json_file.read()
model=model_from_json(json_string)
model.load_weights('cnn_weights.h5')
现在编一个有明显负向情感的句子,看网络有什么看法:
python
sample_1="I hate that dismal weather had me down for so long, when will if break!"
有了训练好的模型,可以快速对新样本数据进行测试。虽然还是需要大量的计算,不过对于每个样本,只需要一次前向传播就能得到结果,不需要反向传播:
python
vec_list=tokenize_and_vectorize([(1,sample_1)])
test_vec_list=pad_turnc(vec_list,maxlen=maxlen)
test_vec=np.reshape(test_vec_list,(len(test_vec_list),maxlen,embedding_dims))
print(model.predict(test_vec))
Keras中predict方法给出了网络最后一次的原始输出,在本例中,只有一个神经元,因为最后一次是Sigmoid,它将输出一个0到1之间的值。
Keras中perdict_classes方法可以输出期待的0或1.如果是多分类问题,网络的最后一次可能是softmax函数,每个输出节点代表一个类,节点的输出值为该类的概率,调用perdict_classes方法将返回输出概率最高的那个节点。