目录
3、一定的正则化手段(早停:连续1000batch验证集数据没有提升,就停止训练)
前言
深度学习模型在计算机视觉与语音识别方面取得了卓越的成就,在 NLP 领域也是可以的。将卷积神经网络CNN应用到文本分类任务,利用多个不同size的kernel来提取句子中的关键信息(类似 n-gram 的关键信息),从而能够更好地捕捉局部相关性。
一、论文笔记
1、Yoon Kim在2014年 “Convolutional Neural Networks for Sentence Classification” 论文中提出TextCNN(利用卷积神经网络对文本进行分类的算法)(该论文翻译)。
假设我们有一些句子需要对其进行分类。句子中每个词是由n维词向量组成的,也就是说输入矩阵大小为m*n,其中m为句子长度。CNN需要对输入样本进行卷积操作,对于文本数据,filter不再横向滑动,仅仅是向下移动,有点类似于N-gram在提取词与词间的局部相关性。图中共有三种步长策略,分别是2,3,4,每个步长都有两个filter(实际训练时filter数量会很多)。在不同词窗上应用不同filter,最终得到6个卷积后的向量。然后对每一个向量进行最大化池化操作并拼接各个池化值,最终得到这个句子的特征表示,将这个句子向量丢给分类器进行分类,至此完成整个流程。
(1)嵌入层(Embedding Layer)
通过一个隐藏层, 将 one-hot 编码的词投影到一个低维空间中,本质上是特征提取器,在指定维度中编码语义特征。 这样, 语义相近的词, 它们的欧氏距离或余弦距离也比较近。(作者使用的单词向量是预训练的,方法为fasttext得到的单词向量,当然也可以使用word2vec和GloVe方法训练得到的单词向量)。
(2)卷积层(Convolution Laye)
在处理图像数据时,CNN使用的卷积核的宽度和高度的一样的,但是在text-CNN中,卷积核的宽度是与词向量的维度一致!这是因为我们输入的每一行向量代表一个词,在抽取特征的过程中,词做为文本的最小粒度。而高度和CNN一样,可以自行设置(通常取值2,3,4,5),高度就类似于n-gram了。由于我们的输入是一个句子,句子中相邻的词之间关联性很高,因此,当我们用卷积核进行卷积时,不仅考虑了词义而且考虑了词序及其上下文(类似于skip-gram和CBOW模型的思想)。
(3)池化层(Pooling Layer)
因为在卷积层过程中我们使用了不同高度的卷积核,使得我们通过卷积层后得到的向量维度会不一致,所以在池化层中,我们使用1-Max-pooling对每个特征向量池化成一个值,即抽取每个特征向量的最大值表示该特征,而且认为这个最大值表示的是最重要的特征。当我们对所有特征向量进行1-Max-Pooling之后,还需要将每个值给拼接起来。得到池化层最终的特征向量。在池化层到全连接层之前可以加上dropout防止过拟合。
(4)全连接层(Fully connected layer)
全连接层跟其他模型一样,假设有两层全连接层,第一层可以加上’relu’作为激活函数,第二层则使用softmax激活函数得到属于每个类的概率。
(5)TextCNN的小变种
在词向量构造方面可以有以下不同的方式: CNN-rand: 随机初始化每个单词的词向量通过后续的训练去调整。 CNN-static: 使用预先训练好的词向量,如word2vec训练出来的词向量,在训练过程中不再调整该词向量。 CNN-non-static: 使用预先训练好的词向量,并在训练过程进一步进行调整。 CNN-multichannel: 将static与non-static作为两通道的词向量。
(6)参数与超参数
sequence_length (Q: 对于CNN, 输入与输出都是固定的,可每个句子长短不一, 怎么处理? A: 需要做定长处理, 比如定为n, 超过的截断, 不足的补0. 注意补充的0对后面的结果没有影响,因为后面的max-pooling只会输出最大值,补零的项会被过滤掉)
num_classes (多分类, 分为几类)
vocabulary_size (语料库的词典大小, 记为|D|)
embedding_size (将词向量的维度, 由原始的 |D| 降维到 embedding_size)
filter_size_arr (多个不同size的filter)
2、2015年“A Sensitivity Analysis of (and Practitioners' Guide to) Convolutional Neural Networks for Sentence Classification”论文详细地阐述了关于TextCNN模型的调参心得。
(1)TextCNN详细过程:
Embedding:第一层是图中最左边的7乘5的句子矩阵,每行是词向量,维度=5,这个可以类比为图像中的原始像素点。
Convolution:然后经过 kernel_sizes=(2,3,4) 的一维卷积层,每个kernel_size 有两个输出 channel。
MaxPolling:第三层是一个1-max pooling层,这样不同长度句子经过pooling层之后都能变成定长的表示。
FullConnection and Softmax:最后接一层全连接的 softmax 层,输出每个类别的概率。
(2)论文调参结论:
~使用预训练的word2vec 、 GloVe初始化效果会更好。一般不直接使用One-hot。
~卷积核的大小影响较大,一般取1~10,对于句子较长的文本,则应选择大一些。
~卷积核的数量也有较大的影响,一般取100~600 ,同时一般使用Dropout(0~0.5)。
~激活函数一般选用ReLU 和 tanh。
~池化使用1-max pooling。
~随着feature map数量增加,性能减少时,试着尝试大于0.5的Dropout。
~评估模型性能时,记得使用交叉验证。
二、基于pytorch的文本预处理
1、读取数据集
with open(file_path, 'r', encoding='UTF-8') as f:
2、构建词表
def build_vocab(file_path, tokenizer, max_size, min_freq):
vocab_dic = {}
with open(file_path, 'r', encoding='UTF-8') as f:
for line in tqdm(f):
lin = line.strip()
if not lin:
continue
content = lin.split('\t')[0]
for word in tokenizer(content):
vocab_dic[word] = vocab_dic.get(word, 0) + 1 #词频
vocab_list = sorted([_ for _ in vocab_dic.items() if _[1] >= min_freq], key=lambda x: x[1], reverse=True)[:max_size]#可以将输入的文本中的每个词编号,编号是根据词频的,词频越大,编号越小。
vocab_dic = {word_count[0]: idx for idx, word_count in enumerate(vocab_list)} #每个词对应的编码和词频
vocab_dic.update({UNK: len(vocab_dic), PAD: len(vocab_dic) + 1})#对UNK进行编号
return vocab_dic
3、将文字转换成数字特征
4、将每条文本转换为数字列表
5、将每条文本设置为相同长度
def build_dataset(config,use_word):
if use_word:
tokenizer = lambda x:x.split(' ') #以空格隔开,word-level
else:
tokenizer = lambda x:[y for y in x]#char-level
if os.path.exists(config.vocab_path): #config.vocab_path ='THUCNews/data/vocab.pkl'
vocab = pkl.load(open(config.vocab_path,'rb'))
else:
vocab = build_vocab(config.train_path,#'THUCNews/data/train.txt'
tokenizer=tokenizer,#tokenizer = lambda x: [y for y in x] tokenizer目前还是一个函数,把参数传进去,返回的是个列表
max_size=MAX_VOCAB_SIZE,
min_freq=1
)
pkl.dump(vocab,open(config.vocab_path,'wb'))
print(f"Vocab size:{len(vocab)}")
def load_dataset(path,pad_size=32):
contents = []
with open(path,'r',encoding='UTF-8') as f:
for line in tqdm(f):
lin = line.strip()
if not lin:
continue
content,label = lin.split('\t')
words_line = []
token = tokenizer(content)#使用Tokenizer将文字转换成数字特征
seq_len = len(token)
#由于每句话的长度不唯一,需要将每句话的长度设置一个固定值。将超过
#固定值的部分截掉,不足的在最前面用0填充。
if pad_size:
if len(token) < pad_size:
token.extend([PAD]*(pad_size-len(token)))
else:
token = token[:pad_size]
seq_len=pad_size
#word to id
for word in token:
words_line.append(vocab.get(word,vocab.get(UNK)))#将每条文本转变成一个向量
contents.append((words_line,int(label),seq_len))
return contents # [([...], 0), ([...], 1), ...]
train = load_dataset(config.train_path, config.pad_size)
dev = load_dataset(config.dev_path, config.pad_size)
test = load_dataset(config.test_path, config.pad_size)
return vocab, train, dev, test
6、构建batches
class DatasetIterater(object):
def __init__(self,batches,batch_size,device):
self.batch_size = batch_size
self.batches = batches
self.n_batches = len(batches) // batch_size
self.residue = False#记录batch数量是否为整数
if len(batches) % self.n_batches != 0:
self.residue = True #不是整数
self.index =0
self.device = device
def _to_tensor(self,datas):
x = torch.LongTensor([_[0] for _ in datas]).to(self.device)
y = torch.LongTensor([_[1] for _ in datas]).to(self.device)
#pad前的长度(超过pad_size的设为pad_size)
seq_len = torch.LongTensor([_[2] for _ in datas]).to(self.device)
return (x,seq_len),y
def __next__(self):
if self.residue and self.index == self.n_batches:
batches = self.batches[self.index * self.batch_size: len(self.batches)]
self.index += 1
batches = self._to_tensor(batches)
return batches
elif self.index >= self.n_batches:
self.index = 0
raise StopIteration
else:
batches = self.batches[self.index* self.batch_size :(self.index+1)*self.batch_size]
self.index += 1
batches = self._to_tensor(batches)
return batches
def __iter__(self):
return self
def __len__(self):
if self.residue:
return self.n_batches+1
else:
return self.n_batches
def build_iterator(dataset,config):
iter = DatasetIterater(dataset,config.batch_size,config.device)
return iter
7、将每个词编码转换为词向量
Embedding层基于上文所得的词编码,对每个词进行one-hot编码,每个词都会是一个vocabulary_size维的向量;然后通过神经网络的训练迭代更新得到一个合适的权重矩阵(具体实现过程可以参考skip-gram模型),行大小为vocabulary_size,列大小为词向量的维度,将本来以one-hot编码的词向量映射到低维空间,得到低维词向量。需要声明一点的是Embedding层是作为模型的第一层,在训练模型的同时,得到该语料库的词向量。当然,也可以使用已经预训练好的词向量表示现有语料库中的词。
这里没有用训练好的词向量
if __name__ == "__main__":
'''提取预训练词向量'''
# 下面的目录、文件名按需更改。
train_dir = "./THUCNews/data/train.txt"
vocab_dir = "./THUCNews/data/vocab.pkl"
pretrain_dir = "./THUCNews/data/sgns.sogou.char"
emb_dim = 300
filename_trimmed_dir = "./THUCNews/data/embedding_SougouNews"
if os.path.exists(vocab_dir):
word_to_id = pkl.load(open(vocab_dir, 'rb'))
else:
# tokenizer = lambda x: x.split(' ') # 以词为单位构建词表(数据集中词之间以空格隔开)
tokenizer = lambda x: [y for y in x] # 以字为单位构建词表
word_to_id = build_vocab(train_dir, tokenizer=tokenizer, max_size=MAX_VOCAB_SIZE, min_freq=1)
pkl.dump(word_to_id, open(vocab_dir, 'wb'))
embeddings = np.random.rand(len(word_to_id), emb_dim)
f = open(pretrain_dir, "r", encoding='UTF-8')
for i, line in enumerate(f.readlines()):
# if i == 0: # 若第一行是标题,则跳过
# continue
lin = line.strip().split(" ")#以空格为分隔符
if lin[0] in word_to_id:#如果
idx = word_to_id[lin[0]]
emb = [float(x) for x in lin[1:301]]
embeddings[idx] = np.asarray(emb, dtype='float32')
f.close()
np.savez_compressed(filename_trimmed_dir, embeddings=embeddings)
文本预处理目的:将每个样本转换为一个数字矩阵,矩阵的每一行表示一个词向量。
三、基于pytorch的TextCNN模型的构建
1、模型构建
class Config(object):
"""配置参数"""
def __init__(self,dataset,embedding):
# dataset = 'THUCNews', embedding='random'
self.model_name ='TextCNN'
self.train_path = dataset+'/data/train.txt'
self.dev_path = dataset + '/data/dev.txt'
self.test_path = dataset +'/data/test.txt'
self.class_list = [x.strip() for x in open(
dataset + '/data/class.txt',encoding='utf-8').readlines()]
self.vocab_path = dataset + '/data/vocab.pkl'
self.save_path = dataset + '/saved_dict/'+self.model_name+'ckpt'
self.embedding_pretrained = torch.tensor(
np.load(dataset+'/data/'+embedding)["embedding"].astype('float32')
) if embedding !='random' else None #预训练词向量
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') #设备
self.dropout =0.5 # 随机失活
self.require_improvement =1000# 若超过1000batch效果还没提升,则提前结束训练
self.num_classes = len(self.class_list)#类别数
self.n_vocab = 0 #词表大小在运行时赋予值
self.num_epochs = 20
self.batch_size = 128 #mini-batch的大小
self.pad_size =32#每句话处理成的长度
self.learning_rate =1e-3#学习率
self.embed = self.embedding_pretrained.size(1)\
if self.embedding_pretrained is not None else 300#字向量维度
# [vocab_size,x] vocab_size 是词表的大小 x是维度 (用x维的向量表示一个词)
self.filter_sizes =(2,3,4)#卷积核尺寸
self.num_filters = 256#卷积核数量(channels数)
class Model(nn.Module):
def __init__(self,config):
super(Model,self).__init__()
if config.embedding_pretrained is not None:
self.embedding = nn.Embedding.from_pretrained(config.embedding_pretrained,freeze=False)
else:
self.embedding = nn.Embedding(config.n_vocab,config.embed,padding_idx=config.n_vocab - 1)
self.convs = nn.ModuleList(
[nn.Conv2d(1,config.num_filters,(k,config.embed)) for k in config.filter_sizes])
self.dropout =nn.Dropout(config.dropout)
self.fc =nn.Linear(config.num_filters * len(config.filter_sizes),config.num_classes)
def conv_and_pool(self,x,conv):
x = F.relu(conv(x)).squeeze(3) #(batch_size,out_filters,pad_size-k+1)
x = F.max_pool1d(x,x.size(2)).squeeze(2)#(batch_size,out_filters)
return x
def forward(self,x): #x->(batch_size,n_vocab)
out=self.embedding(x[0]) #(batch_size,pad_size,embedding_dim)
out= out.unsqueeze(1) #(batch_size,1,pad_size,embedding_dim)
out=torch.cat([self.conv_and_pool(out,conv) for conv in self.convs] ,1)#(batch_size,out_filters*len(filter_sizes))
out=self.dropout(out)
out=self.fc(out) #
return out #(batch_size,num_classes)
四、训练模型
1、训练模型的基本步骤
2、每个一定的batch就查看验证集的情况
3、一定的正则化手段(早停:连续1000batch验证集数据没有提升,就停止训练)
4、测试数据
def train(config,model,train_iter,dev_iter,test_iter):
start_time =time.time()
model.train()
optimizer = torch.optim.Adam(model.parameters(),lr=config.learning_rate)
#学习率指数衰减,每次epoch:学习率=gamma*学习率
#scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer,gamma=0.9)
total_batch = 0#记录进行到多少batch
dev_best_loss = float('inf')
last_improve = 0 #记录是否很久没有效果提升
writer = SummaryWriter(log_dir = config.log_path + '/' + time.strftime('%m-%d_%H.%M',time.localtime()))
#前向传播得到预测值 --> 求预测值与真实值的损失 -->优化器梯度清零 --> 求所有参数的梯度 --> 优化器更新梯度
for epoch in range(config.num_epochs):
print('Epoch [{}/{}]'.format(epoch+1,config.num_epochs))
#scheduler.stop()#学习率衰减
for i, (trains,labels) in enumerate(train_iter):
outputs = model(trains)
model.zero_grad()
loss = F.cross_entropy(outputs,labels)
loss.backward()
optimizer.step()
if total_batch % 100 ==0:
#每多少轮输出在训练集和验证集上的效果
true = labels.data.cpu()
#torch.max(outputs.data,1)这个函数返回的是两个值,第一个值是具体的value(我们用下划线_表示),第二个值是value所在的index(也就是predicted)。dim=1表示输出所在行的最大值,若改写成dim=0则输出所在列的最大值
predic = torch.max(outputs.data,1)[1].cpu() ##outputs->(batch_size,num_classes)
train_acc = metrics.accuracy_score(true,predic)#计算此时模型在验证集上的损失和准确率
if dev_loss < dev_best_loss:
dev_best_loss = dev_loss # 更新验证集最小损失
torch.save(model.state_dict(), config.save_path) # 保存在验证集上损失最小的参数
improve = '*' # 效果提升标志
last_improve = total_batch # 计算上次提升 位于哪个batch
else:
improve = ''
time_dif = get_time_dif(start_time)
msg = 'Iter: {0:>6}, Train Loss: {1:>5.2}, Train Acc: {2:>6.2%}, Val Loss: {3:>5.2}, Val Acc: {4:>6.2%}, Time: {5} {6}'
print(msg.format(total_batch, loss.item(), train_acc, dev_loss, dev_acc, time_dif, improve))
# 保存 训练集(当前batch)、验证集的损失和准确率信息 方便可视化 以batch为单位
writer.add_scalar("loss/train", loss.item(), total_batch)
writer.add_scalar("loss/dev", dev_loss, total_batch)
writer.add_scalar("acc/train", train_acc, total_batch)
writer.add_scalar("acc/dev", dev_acc, total_batch)
model.train() # 回到训练模式
total_batch += 1
if total_batch - last_improve > config.require_improvement: # 如果长期没有提高 就提前终止
# 验证集loss超过1000batch没下降,结束训练
print("No optimization for a long time, auto-stopping...")
flag = True
break
if flag:
break
writer.close()
test(config,model,test_iter)
def evaluate(config, model, data_iter, test=False):
model.eval() # 测试模式
loss_total = 0
predict_all = np.array([], dtype=int) # 存储验证集所有batch的预测结果
labels_all = np.array([], dtype=int) # 存储验证集所有batch的真实标签
with torch.no_grad():
for texts, labels in data_iter:
outputs = model(texts)
loss = F.cross_entropy(outputs, labels)
loss_total += loss
labels = labels.data.cpu().numpy()
predic = torch.max(outputs.data, 1)[1].cpu().numpy()
labels_all = np.append(labels_all, labels)
predict_all = np.append(predict_all, predic)
acc = metrics.accuracy_score(labels_all, predict_all) # 计算验证集准确率
if test: # 如果是测试集的话 计算一下分类报告和混淆矩阵
report = metrics.classification_report(labels_all, predict_all, target_names=config.class_list, digits=4)
confusion = metrics.confusion_matrix(labels_all, predict_all) # 计算混淆矩阵
return acc, loss_total / len(data_iter), report, confusion
return acc, loss_total / len(data_iter) # 返回准确率和每个batch的平均损失
def test(config, model, test_iter):
# test
model.load_state_dict(torch.load(config.save_path)) #加载使验证集损失最小的参数
model.eval() #测试模式
start_time = time.time()
test_acc, test_loss, test_report, test_confusion = evaluate(config, model, test_iter, test=True) #计算测试集准确率,每个batch的平均损失 分类报告、混淆矩阵
msg = 'Test Loss: {0:>5.2}, Test Acc: {1:>6.2%}'
print(msg.format(test_loss, test_acc))
print("Precision, Recall and F1-Score...")
print(test_report)
print("Confusion Matrix...")
print(test_confusion)
time_dif = get_time_dif(start_time)
print("Time usage:", time_dif)
五、绘制TextCNN模型结构图
1、绘制模型图
六、pytorch模型的保存与加载
(50条消息) TextCNN文本分类(keras实现)_Asia-Lee的博客-CSDN博客_textcnn文本分类