缘起
作为一个菜鸟小硕,无师自通入门NLP已经一年多了,想着自己的第一个项目就是情感分析,以及第一篇CCF B类中文期刊也是情感分析,决定开一个关于文本分类的专栏,提供保姆级别的模型训练过程。很多同学在大量阅读论文后,难免有不少的ideas,但仅停留在构思层面,所以,我在这里跟大家分享一下关于动手实现常见深度学习模型的详细过程,也算是对我自己过去入门的一个总结。
提示
本人在读研三,主要捣弄深度学习方面的实验,文本和图像都可以帮做实验;智能算法实现方面也写过不少;目前已发表文本分类(包括情感分析)北大核心论文三篇,小型卫星计算机系统、计算机工程与科学、微电子学与计算机等期刊,深度学习方面可以帮忙搭建实验环境(主要用pytorch)、实验代做(提供模型源代码、实验参数、实验结果图、模型架构visio图)、毕业设计论文辅导或代写;智能算法可帮忙代写算法实现;可轻松搞定课程设计和毕业设计,有需要可以联系qq:791501442,多年老jr随时在线。
实验用到的资源和环境
名称 | 参数值 |
---|---|
深度学习框架 | pytorch1.7.0 |
显卡 | GTX3090 |
方式 | docker |
想要做好深度学习(炼丹),必须得有个满意的实验环境,感谢实验室的老板斥巨资购买了8张3090卡,并且整个linux服务器也是做的相当完美,交互式开发功能真的香,可我还是喜欢用命令行进行连接(其实是环境配置不来哈哈哈)。以docker形式创建独立的开发环境,不用考虑太多其他问题,我用的配置如下图。 | |
![]() | |
在这里提醒下,要使用3090显卡必须pytorch1.7以及cuda11.0以上,TF版本的虽然没用使用过,但是还是需要特定的版本才可以兼容。 |
文本分类
做文本分类任务主要分为以下几个主要步骤
- 数据集读取–根据具体数据格式进行解析
- 搭建模型–魔改关键
- 喂数据–开启训练
- 计算损失函数–优化过程
- 反向梯度传播
情感分析
下面以情感分析任务为例,数据集使用酒店评论chnsenticrop,正负例样本数量为4799和4801,按照8:1:1划分训练集、测试集、验证集,具体数量为9600、1200、1200。数据集样式如下图,标签与内容之间有符号“\t”,数据读取时需要注意。
此处提供数据下载链接:
标签 | 内容 |
---|---|
0 | 房间太旧,太吵.洗手间没有热水,建议携程取消和其合作. 补充点评 2007年11月11日 : 服务态度恶劣,言语不是太冲就是冷冰冰的,房间设施陈旧.卫生间小. |
1 | 装修后还不错,房间很干净,大床房很舒适。 打印很贵 |
0 | 房间设施比较简单。服务员服务也一般,隔音比较差。 晚上的骚扰电话,唉,不提了 |
![]() |
数据读取
读取数据一般会用torch.utils.data下的DataLoader类,具体代码如下,带有注释。
import torch.utils.data as data
import tqdm
train_path = "/opt/data/private/meixiafeng/untitled2/data/chnsenticrop/train.tsv"
# 自定义数据读取类 继承于torch.utils.data.Dataset
train_data = Prepare_DataSet(max_len=max_len, data_file=train_path)
# 通过DataLoader进行加载 此时得到的train_data_loader是一个迭代器
train_data_loader = data.DataLoader(dataset=train_data,
pin_memory=use_cuda,
batch_size=batch_size, # 批处理大小
num_workers=num_workers, # 线程数
shuffle=True) # 是否打乱
# 通过tqdm进行封装
p_bar = tqdm.tqdm(data_loader, unit="batch", ncols=100) # 进度条封装
for step, batch in enumerate(p_bar): # 即可按batch大小进行读取
pass
# Prepare_DataSet数据处理类
class Prepare_DataSet(torch.utils.data.Dataset):
# 重写__len__和__getitem__方法
def __init__(self, max_len=300, data_file=None):
self.max_len = max_len
self.tokenizer = BertTokenizer.from_pretrained(model_name) # 加载分词器
if data_file != None:
self.raw_data = open(data_file, 'r').readlines() # 按照行读取所有的数据行
# 数据集长度
def __len__(self):
return len(self.raw_data)
# 处理单个样本
def __getitem__(self, item):
line = self.raw_data[item]
row = line.strip("\n").split("\t") # 将样本分解成数组形式[标签 内容]
if len(row) != 2:
raise RuntimeError("Data is illegal: " + line)
labels = int(row[0])
sen_code = self.tokenizer(row[1], return_tensors='pt', padding='max_length', # 处理成BERT输入需要的形式
truncation=True, max_length=self.max_len) # max_length:truncation/padding
tokens = torch.LongTensor(sen_code['input_ids'][0])
token_type_ids = torch.LongTensor(sen_code['token_type_ids'][0])
attention_mask = torch.LongTensor(sen_code['attention_mask'][0])
return {"text": row[1], "tokens": tokens, "token_type_ids": token_type_ids,
"attention_mask": attention_mask, "labels": labels} # 返回
设置优化器
优化器的好坏决定了模型训练的收敛速度和性能,我采用了Adam优化器,也算是使用较为广泛的(因为它好用啊~),最近有一个新的变体RAadm[1],效果更好,有机会去尝试一下,看看训练效果是不是真的有提升。优化器主要负责对数据集进行拟合,采用梯度下降算法,此处需要对梯度下降算法有一定的基础知识,最好自己去实现下求导数的过程(需要简单的高数导数知识哦)。
training_data_len = train_data.__len__() # 数据集大小
epochs = 10 # 训练轮次
batch_size = 32 # 批处理大小
gradient_accumulation_steps = 1 # 是否开启梯度累加 为了解决显存不够问题 将多次小batch的梯度累加 作为大的batch的梯度值
init_lr = 5e-5 # 初始学习率
warm_up_proportion = 0.1 # 预热步数 超过后使用策略对学习率进行调整
# 优化器定义
optimizer = optimization.init_bert_adam_optimizer(model, training_data_len, epochs,batch_size,gradient_accumulation_steps, init_lr, warm_up_proportion)
# BertAdam优化器定义
class BERTAdam(Optimizer):
def __init__(self, params, lr, warmup=-1, t_total=-1, schedule='warmup_linear',
b1=0.9, b2=0.999, e=1e-6, weight_decay_rate=0.01,
max_grad_norm=1.0):
# 判断传入数据的有效性
if not lr >= 0.0:
raise ValueError("Invalid learning rate: {} - should be >= 0.0".format(lr))
if schedule not in SCHEDULES:
raise ValueError("Invalid schedule parameter: {}".format(schedule))
if not 0.0 <= warmup < 1.0 and not warmup == -1:
raise ValueError("Invalid warmup: {} - should be in [0.0, 1.0[ or -1".format(warmup))
if not 0.0 <= b1 < 1.0:
raise ValueError("Invalid b1 parameter: {} - should be in [0.0, 1.0[".format(b1))
if not 0.0 <= b2 < 1.0:
raise ValueError("Invalid b2 parameter: {} - should be in [0.0, 1.0[".format(b2))
if not e >= 0.0:
raise ValueError("Invalid epsilon value: {} - should be >= 0.0".format(e))
defaults = dict(lr=lr, schedule=schedule, warmup=warmup, t_total=t_total, # 优化选项
b1=b1, b2=b2, e=e, weight_decay_rate=weight_decay_rate,
max_grad_norm=max_grad_norm)
super(BERTAdam, self).__init__(params, defaults)
def get_lr(self): # 计算学习率变化结果
lr = []
for group in self.param_groups:
for p in group['params']:
state = self.state[p]
if len(state) == 0:
return [0]
if group['t_total'] != -1:
schedule_fct = SCHEDULES[group['schedule']]
# warmup = 0.1 假如总次数是100次 前10步 schedule_fct 返回的是0.1 0.2 0.3 ... 0.9
# 11步之后返回 0.89 0.88 0.87 0.86 ... 0.00
lr_scheduled = group['lr'] * schedule_fct(state['step'] / group['t_total'], group['warmup'])
else:
lr_scheduled = group['lr']
lr.append(lr_scheduled)
return lr
# 梯度下降
def step(self, closure=None):
# 闭包运算 针对需要多次计算的优化算法
loss = None
if closure is not None:
loss = closure()
for group in self.param_groups:
for p in group['params']: # 迭代params内的全部p
if p.grad is None: # 需要loss的反向传播计算才会生成grad
continue
grad = p.grad.data # 具体的梯度值
if grad.is_sparse: # 如果是稀疏梯度
raise RuntimeError('Adam does not support sparse gradients, please consider SparseAdam instead')
state = self.state[p] # 参数的缓存,如momentum的缓存;
# State initialization
if len(state) == 0:
state['step'] = 0 # 步数为0
# Exponential moving average of gradient values
state['next_m'] = torch.zeros_like(p.data) # p.data:具体的参数值tensor
# Exponential moving average of squared gradient values
state['next_v'] = torch.zeros_like(p.data)
next_m, next_v = state['next_m'], state['next_v'] # 更新next_m 等于更新state['next_m']
beta1, beta2 = group['b1'], group['b2'] # 衰减率 0.9 0.999
# Add grad clipping
if group['max_grad_norm'] > 0:
# NN参数,最大梯度范数,范数类型=2(默认)
clip_grad_norm_(p, group['max_grad_norm'])
# Decay the first and second moment running average coefficient
# In-place operations to update the averages at the same time
next_m.mul_(beta1).add_(1 - beta1, grad) # 先乘后加 梯度的指数移动平均
# next_v.mul_(beta2)+(1 - beta2)×grad×grad
next_v.mul_(beta2).addcmul_(1 - beta2, grad, grad) # 逐元素计算 梯度的平方的指数移动平均
update = next_m / (next_v.sqrt() + group['e']) # 梯度的指数移动平均 / (梯度的平方的指数移动平均开根号 + 很小的数)
update += group['weight_decay_rate'] * p.data
# 动态lr
if group['t_total'] != -1:
schedule_fct = SCHEDULES[group['schedule']]
lr_scheduled = group['lr'] * schedule_fct(state['step'] / group['t_total'], group['warmup'])
else:
lr_scheduled = group['lr']
update_with_lr = lr_scheduled * update
p.data.add_(-update_with_lr) # 加一个负数 梯度下降 更新参数
state['step'] += 1
# step_size = lr_scheduled * math.sqrt(bias_correction2) / bias_correction1
# bias_correction1 = 1 - beta1 ** state['step'] 偏差修正
# bias_correction2 = 1 - beta2 ** state['step']
return loss
设置GPU
深度学习一般情况下,肯定需要GPU加速运算,况且NLP一般都是使用预训练模型,效果谁用谁知道哈哈哈,但是针对特定任务效果可能不是最好。下面代码介绍如何设置启动GPU,具体函数的应用和参数含义可以自行查询。
# 设置GPU
gpu_ids = '-1' # 不需要GPU
# gpu_ids = '0,1' # 需要GPU 可以设置多卡
use_cuda = gpu_ids != '-1'
if len(gpu_ids) == 1 and use_cuda: # 一个gpu
master_gpu_id = int(gpu_ids) # 主master
model = model.cuda(int(gpu_ids)) if use_cuda else model
elif use_cuda: # 多个gpu
gpu_ids = [int(each) for each in gpu_ids.split(",")] # ','分割的int数字
master_gpu_id = gpu_ids[0]
model = model.cuda(gpu_ids[0]) # Moves all model parameters and buffers to the GPU
logging.info("Start multi-gpu dataparallel training/evaluating...")
# 前提是在device_ids[0]中保存有parameters and buffers 即 model.cuda(gpu_ids[0])
model = torch.nn.DataParallel(model, device_ids=gpu_ids) # 自动拷贝参数和缓存到所有的GPUs上
else: # 不使用gpu
master_gpu_id = None
开启训练
首先明确训练的过程,将数据集分批喂自己定义好的模型,得到分类概率,再由损失函数计算损失值,然后计算梯度进行反向传播,更新模型参数,以达到对当前数据集的拟合。
# 主要训练函数
def trains(master_gpu_id, model, epochs, optimizer, train_data, test_data,
batch_size, gradient_accumulation_steps=1,
use_cuda=False, num_workers=4):
# master_gpu_id:主GPU卡
# model:定义模型
logging.info("Start Training".center(60, "="))
# 使用DataLoader加载数据
train_data_loader = data.DataLoader(dataset=train_data,
pin_memory=use_cuda,
batch_size=batch_size,
num_workers=num_workers,
shuffle=True) # 是否打乱
for epoch in range(1, epochs + 1):
logging.info("Training Epoch: " + str(epoch))
# 训练
avg_loss = train_epoch(master_gpu_id, model, optimizer, train_data_loader,
gradient_accumulation_steps, use_cuda)
logging.info("Average Loss: " + format(avg_loss, "0.4f"))
# 验证
evaluates(master_gpu_id, model, test_data, batch_size, use_cuda, num_workers)
def train_epoch(master_gpu_id, model, optimizer, data_loader,
gradient_accumulation_steps, use_cuda):
model.train() # 开启训练模式
data_loader.dataset.is_training = True
total_loss = 0.0 # 总共的损失
correct_sum = 0 # 正确预测总和
process_sum = 0 # 处理数据的总和
num_batch = data_loader.__len__()
num_sample = data_loader.dataset.__len__()
p_bar = tqdm.tqdm(data_loader, unit="batch", ncols=100) # 进度条封装
p_bar.set_description('train step loss')
for step, batch in enumerate(p_bar):
# 通过Dataloader进行数据获取 同时写到master_gpu_id卡上
tokens = batch["tokens"].cuda(master_gpu_id) if use_cuda else batch["tokens"] # 获取token
token_type_ids = batch["token_type_ids"].cuda(master_gpu_id) if use_cuda else batch[
"token_type_ids"] # 获取token_type_ids
attention_mask = batch["attention_mask"].cuda(master_gpu_id) if use_cuda else batch[
"attention_mask"] # 获取attention_mask
labels = batch['labels'].cuda(master_gpu_id) if use_cuda else batch['labels']
# 前向传播 返回损失值和分类概率
loss, logits = model(tokens, attention_mask, token_type_ids, labels)
# 取平均
loss = loss.mean()
if gradient_accumulation_steps > 1: # 梯度累积次数 其实意思就是去多次的平均值
# 如果gradient_accumulation_steps == 1的话 代表不累计梯度 即不与前面的mini-batch相关
loss /= gradient_accumulation_steps
loss.backward() # 反向传播
if (step + 1) % gradient_accumulation_steps == 0:
optimizer.step() # 梯度下降
model.zero_grad() # 梯度归零
loss_val = loss.item()
total_loss += loss_val
# 统计
_, top_index = logits.topk(1)
correct_sum += (top_index.view(-1) == labels).sum().item()
process_sum += labels.shape[0]
p_bar.set_description('train step loss ' + format(loss_val, "0.4f"))
return total_loss / num_batch # 返回平均每一个batch的损失值
# 下面三个函数就是反向梯度传导的主要代码
loss.backward() # 反向传播
optimizer.step() # 梯度下降
model.zero_grad() # 梯度归零
# 验证函数与训练函数基本一致 主要是调整model.eval()到测试模式 不使用训练时的dropout
# 以及with torch.no_grad(): 不需要梯度计算
模型定义–魔改关键
我使用了预训练模型替代了Word2vec词向量,效果提升不错,也不用CLS直接分类,而是加入BiGRU进行二次语义提取。
import torch
import torch.nn as nn
from transformers import * # 预训练模型下载库
from model.BiLSTM import BiLSTM
from model.BiGRU import BiGRU
from Output import Output
model_name = 'voidful/albert_chinese_base'
class BERT_BiRNN(nn.Module): # 继承于nn.Module
def __init__(self, input_size=768, hidden_size=256, num_layers=2, linear_high=256, num_classes=2):
super(BERT_BiRNN, self).__init__()
self.pre_model = AutoModel.from_pretrained(model_name) # 词嵌入层
# self.BiRNN = BiLSTM(input_size, hidden_size, num_layers=num_layers)
self.BiRNN = BiGRU(input_size, hidden_size, num_layers=num_layers) # BiGRU层
self.Output = Output(hidden_size, linear_high, num_classes) # 输出层
# 定义模型的张量流动方式
def forward(self, x, attention_mask, token_type_ids, labels):
output = self.pre_model(x, attention_mask, token_type_ids)
output = output[0][:, 1:] # 除去cls
output = self.BiRNN(output)
loss, logits = self.Output(output, labels)
return loss, logits
模型效果评价
计算精确率和召回率即可,然后得到F1值。
def evaluates_pr(labels, predictions):
TP, TN, FP, FN = 0, 0, 0, 0
for label, prediction in zip(labels, predictions):
if label == 1 and prediction == 1:
TP += 1
elif label == 0 and prediction == 0:
TN += 1
elif label == 1 and prediction == 0:
FP += 1
elif label == 0 and prediction == 1:
FN += 1
precise = TP / (TP + FN + 0.0001)
recall = TP / (TP + FP + 0.0001)
return precise, recall
实验效果
跑了5个epoch,效果还是不错的,虽然没有做实验对比~
总结
动手实现一个自己的深度学习-文本分类-情感分析训练框架,后续有不错的idea就可以直接搭建一个新的模型即可,其他代码不用动。NLP目前的趋势就是预训练模型+下游任务进行微调,或者做一个魔改,效果都是很不错的。需要完整代码的朋友可以给我留言,我看到会发给你们。大家有不懂的地方,也可以私聊或者评论,我会一一给大家解决的。本人代码能力和写作文笔能力有限,欢迎大家一起讨论交流,以后也会做一些其他关于NLP的其他任务。