1. 学习内容
AI夏令营第三期–基于论文摘要的文本分类与关键词抽取挑战赛教程
✅ Bert - 预训练+微调范式
✏️:BERT(Bidirectional Encoder Representations from Transformers)的意思是基于双向Transformer的编码器表示,BERT的核心思想是使用双向Transformer来编码文本数据,从而获得文本中每个词的上下文相关的向量表示,然后将这些向量表示作为输入,用于不同的下游任务,如文本分类、文本生成、文本摘要等。
✏️:BERT的 预训练(Pre-training) 过程是指在大规模的通用数据集上进行无监督或自监督的学习,目的是让模型学习到通用的知识和能力,如词汇、语法、语义、逻辑、常识等。
BERT使用了两种预训练任务,分别是:
掩码语言模型(Masked Language Model,MLM):这个任务是指在输入的文本中随机地遮盖一些词,然后让模型根据上下文来预测被遮盖的词。这个任务可以让模型学习到词汇和语法的知识。
下一个句子预测(Next Sentence Prediction,NSP):这个任务是指给定两个句子A和B,让模型判断B是否是A的下一个句子。这个任务可以让模型学习到语义和逻辑的知识。
BERT使用了数TB甚至数PB的数据集来进行预训练,如英文维基百科、书籍语料库等。BERT使用了数千甚至数万个GPU或TPU等高性能计算设备来进行并行计算和优化。BERT预训练后得到了一个通用的编码器模型,它可以将任意长度的文本转换为固定长度的向量表示。
✏️:BERT的 微调(Fine-tuning) 过程是指在有标注的数据上进行有监督的学习,目的是让模型适应特定的任务和场景,如文本分类、文本生成、文本摘要等。BERT使用了一种简单而有效的微调方法,即在预训练好的编码器模型上添加一个简单的输出层,然后根据不同的任务和场景来调整输出层的结构和参数。例如,在文本分类任务中,输出层可以是一个全连接层或者一个softmax层;在文本生成任务中,输出层可以是一个解码器或者一个线性层等。
BERT使用了少量标注好的数据来进行微调,如GLUE、SQuAD等公开数据集。BERT使用了相对较少的计算资源来进行微调,一般只需要几个小时或几天就可以完成。BERT微调后得到了一个针对特定任务和场景的模型,它可以根据输入的文本来产生相应的输出或行为。
✏️:以上就是BERT处理文本分类任务的预训练和微调过程。从这个过程中可以看出,BERT利用了“大规模预训练+微调”的范式,在预训练阶段学习到通用的知识和能力,在微调阶段适应特定的任务和场景,在各种领域和场景中都能够展现出惊人的效果。事实上,BERT不仅在文本分类任务上表现优异,还在文本生成、文本摘要、机器翻译、问答系统等任务上刷新了多项记录,成为了自然语言处理领域的一个里程碑技术。
✅ Transformer
BERT 乃至目前正火的 LLM 的成功,都离不开 Attention 机制与基于 Attention 机制搭建的 Transformer架构。
在 Attention 机制提出之前,深度学习主要有两种基础架构:卷积神经网络(CNN)与循环神经网络(RNN)。其中,CNN 在CV 领域表现突出,而 RNN 及其变体 LSTM 在 NLP 方向上一枝独秀。然而,RNN 架构存在两个天然缺陷:①序列依序计算的模式限制了计算机并行计算的能力,导致 RNN 为基础架构的模型虽然参数量不算特别大,但计算时间成本却很高。 ② RNN难以捕捉长序列的相关关系。在 RNN 架构中,距离越远的输入之间的关系就越难被捕捉,同时RNN需要将整个序列读入内存依次计算,也限制了序列的长度。
针对上述两个问题, 2017年 Vaswani 等人发表了论文《Attention Is All You Need》,创造性提出了 Attention 机制并完全抛弃了 RNN 架构。Attention机制最先源于计算机视觉领域,其核心思想为当我们关注一张图片,我们往往无需看清楚全部内容而仅将注意力集中在重点部分即可。而在自然语言处理领域,我们往往也可以通过将重点注意力集中在一个或几个token,从而取得更高效高质的计算效果。
Attention 机制的特点是通过计算 Query(查询值) 与 Key(键值) 的相关性为真值加权求和,从而拟合序列中每个词同其他词的相关关系。其大致计算过程如图:
具体而言,可以简单理解为一个输入序列通过不同的参数矩阵映射为 Q、K、V 三个矩阵,其中,Q 是计算注意力的另一个句子(或词组),V 为待计算句子,K 为待计算句子中每个词的对应键。通过对 Q 和 K 做点积,可以得到待计算句子(V)的注意力分布(即哪些部分更重要,哪些部分没有这么重要),基于注意力分布对 V 做加权求和即可得到输入序列经过注意力计算后的输出,其中与 Q (即计算注意力的另一方)越重要的部分得到的权重就越高。
✅ Attention
Transformer 基于 Attention 机制搭建了 Encoder-Decoder(编码器-解码器) 结构,主要适用于 Seq2Seq(序列到序列) 任务,即输入是一个自然语言序列,输出也是一个自然语言序列。其整体架构如下:
Transformer 由一个 Encoder,一个 Decoder 外加一个 Softmax 分类器与两层编码层构成。上图中左侧方框为 Encoder,右侧方框为 Decoder。
由于是一个 Seq2Seq 任务,在训练时,Transformer 的训练语料为若干个句对,具体子任务可以是机器翻译、阅读理解、机器对话等。
下图是一个英语与德语的机器翻译任务。在训练时,句对会被划分为输入语料和输出语料,输入语料将从左侧通过编码层进入 Encoder,输出语料将从右侧通过编码层进入 Decoder。Encoder 的主要任务是对输入语料进行编码再输出给 Decoder,Decoder 再根据输出语料的历史信息与 Encoder 的输出进行计算,输出结果再经过一个线性层和 Softmax 分类器即可输出预测的结果概率,整体逻辑如下图:
✅ 预训练任务
BERT 的模型架构直接使用了 Transformer 的 Encoder 作为整体架构,其最核心的思想在于提出了两个新的预训练任务—— MLM(Masked Language Model,掩码模型) 和 NSP(Next Sentence Prediction,下个句子预测),而不是沿用传统的 LM(语言模型)。
MLM 任务,是 BERT 能够深层拟合双向语义特征的基础。简单来讲,MLM 任务即以一定比例对输入语料的部分 token 进行遮蔽,替换为 (MASK)标签,再让模型基于其上下文预测还原被遮蔽的单词,即做一个完形填空任务。由于在该任务中,模型需要针对 (MASK) 标签左右的上下文信息来预测标签本身,从而会充分拟合双向语义信息。
例如,原始输入为 I like you。以30%的比例进行遮蔽,那么遮蔽之后的输入可能为:I (MASK) you。而模型的任务即为基于该输入,预测出 (MASK) 标签对应的单词为 like。
NSP 任务,是 BERT 用于解决句级自然语言处理任务的预训练任务。BERT 完全采用了预训练+微调的范式,因此着重通过预训练生成的模型可以解决各种多样化的下游任务。MLM 对 token 级自然语言处理任务(如命名实体识别、关系抽取等)效果极佳,但对于句级自然语言处理任务(如句对分类、阅读理解等),由于预训练与下游任务的模式差距较大,因此无法取得非常好的效果。NSP 任务,是将输入语料都整合成句对类型,句对中有一半是连贯的上下句,标记为 IsNext,一半则是随机抽取的句对,标记为 NotNext。模型则需要根据输入的句对预测是否是连贯上下句,即预测句对的标签。
例如,原始输入句对可能是 (I like you ; Because you are so good) 以及 (I like you; Today is a nice day)。而模型的任务即为对前一个句对预测 IsNext 标签,对后一个句对预测 NotNext 标签。
基于上述两个预训练任务,BERT 可以在预训练阶段利用大量无标注文本数据实现深层语义拟合,从而取得良好的预测效果。同时,BERT 追求预训练与微调的深层同步,由于 Transformer 的架构可以很好地支持各类型的自然语言处理任务,从而在 BERT 中,微调仅需要在预训练模型的最顶层增加一个 SoftMax 分类层即可。同样值得一提的是,由于在实际下游任务中并不存在 MLM 任务的遮蔽,因此在策略上进行了一点调整,即对于选定的遮蔽词,仅 80% 的遮蔽被直接遮蔽,其余将有 10% 被随机替换,10% 被还原为原单词。
2. 实践项目
同之前博客介绍的,仍然是任务一,文本分类任务。
【NLP】Datawhale-AI夏令营Day3打卡:Bert模型
3. 实践代码
深度学习方法(Bert模型):
由于本地环境出了一些问题,今天重新安装了 Anaconda + Pytorch,稍微调了一下batch_size和epochs,再次跑了一下baseline代码。
代码包括以下几个部分:
1️⃣ 导入模块
导入我们本次Baseline代码所需的模块
2️⃣ 设置全局配置
3️⃣ 数据收集与准备
在赛题主页下载数据,读取数据集,数据预处理(考虑数据扩增)
4️⃣ 构建训练所需的dataloader与dataset
定义dataset: Pytorch 中自定义dataset需要继承 torch.utils.data.Dataset,继承Dataset这个类时,它要求你必须重写 getitem(self, index)、len(self) 两个方法,前者通过提供索引返回数据,也就是提供 DataLoader获取数据的方式;后者返回数据集的长度,DataLoader依据 len 确定自身索引采样器的长度。
而本次教程中,我们在初始化类方法 init 中引入我们的训练数据,重写 getitem(self, index)方法,保证能够取出index对应行的text 与label 值,也就是我们训练需要的数据以及训练希望得到的结果,在 len 方法中,我们需要返回数据集的总长度,这里直接利用Dataframe数据的len()方法就能完成。
构造Dataloader: 在Dataloader中,我们需要利用Dataloader来加载训练数据与训练目标,需要注意的是,加载完成后的数据需要为tensor(张量)形式,因此在下文中我们定义了collate_fn来帮助完成batch组装以及将文本内容向量化,而文本内容向量化这部分内容,我们利用bert模型来完成,而label值的向量化我们直接使用torch.LongTensor()方法完成。
5️⃣ 定义模型
pytorch中定义模型需要继承nn.Module类,其中需要至少定义两个方法,一个是初始化模型结构的方法__init__,另一个方法forward来完成推理流程。
6️⃣ 模型训练、评估
7️⃣ 结果输出
#import 相关库 #导入前置依赖 import os import pandas as pd import torch from torch import nn from torch.utils.data import Dataset, DataLoader # 用于加载bert模型的分词器 from transformers import AutoTokenizer # 用于加载bert模型 from transformers import BertModel from pathlib import Path batch_size = 8 # 文本的最大长度 text_max_length = 128 # 总训练的epochs数 epochs = 10 # 学习率 lr = 3e-5 # 取多少训练集的数据作为验证集 validation_ratio = 0.1 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 每多少步,打印一次loss log_per_step = 50 # 数据集所在位置 dataset_dir = Path("./Dataset") os.makedirs(dataset_dir) if not os.path.exists(dataset_dir) else '' # 模型存储路径 model_dir = Path("./model/bert_checkpoints") # 如果模型目录不存在,则创建一个 os.makedirs(model_dir) if not os.path.exists(model_dir) else '' print("Device:", device) # 读取数据集,进行数据处理 pd_train_data = pd.read_csv('./Dataset/train.csv') pd_train_data['title'] = pd_train_data['title'].fillna('') pd_train_data['abstract'] = pd_train_data['abstract'].fillna('') test_data = pd.read_csv('./Dataset/testB.csv') test_data['title'] = test_data['title'].fillna('') test_data['abstract'] = test_data['abstract'].fillna('') pd_train_data['text'] = pd_train_data['title'].fillna('') + ' ' + pd_train_data['author'].fillna('') + ' ' + pd_train_data['abstract'].fillna('')+ ' ' + pd_train_data['Keywords'].fillna('') test_data['text'] = test_data['title'].fillna('') + ' ' + test_data['author'].fillna('') + ' ' + test_data['abstract'].fillna('')+ ' ' + pd_train_data['Keywords'].fillna('') # 从训练集中随机采样测试集 validation_data = pd_train_data.sample(frac=validation_ratio) train_data = pd_train_data[~pd_train_data.index.isin(validation_data.index)] # 构建Dataset class MyDataset(Dataset): def __init__(self, mode='train'): super(MyDataset, self).__init__() self.mode = mode # 拿到对应的数据 if mode == 'train': self.dataset = train_data elif mode == 'validation': self.dataset = validation_data elif mode == 'test': # 如果是测试模式,则返回内容和uuid。拿uuid做target主要是方便后面写入结果。 self.dataset = test_data else: raise Exception("Unknown mode {}".format(mode)) def __getitem__(self, index): # 取第index条 data = self.dataset.iloc[index] # 取其内容 text = data['text'] # 根据状态返回内容 if self.mode == 'test': # 如果是test,将uuid做为target label = data['uuid'] else: label = data['label'] # 返回内容和label return text, label def __len__(self): return len(self.dataset) train_dataset = MyDataset('train') validation_dataset = MyDataset('validation') train_dataset.__getitem__(0) #获取Bert预训练模型 tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") #接着构造我们的Dataloader。 #我们需要定义一下collate_fn,在其中完成对句子进行编码、填充、组装batch等动作: def collate_fn(batch): """ 将一个batch的文本句子转成tensor,并组成batch。 :param batch: 一个batch的句子,例如: [('推文', target), ('推文', target), ...] :return: 处理后的结果,例如: src: {'input_ids': tensor([[ 101, ..., 102, 0, 0, ...], ...]), 'attention_mask': tensor([[1, ..., 1, 0, ...], ...])} target:[1, 1, 0, ...] """ text, label = zip(*batch) text, label = list(text), list(label) # src是要送给bert的,所以不需要特殊处理,直接用tokenizer的结果即可 # padding='max_length' 不够长度的进行填充 # truncation=True 长度过长的进行裁剪 src = tokenizer(text, padding='max_length', max_length=text_max_length, return_tensors='pt', truncation=True) return src, torch.LongTensor(label) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn) validation_loader = DataLoader(validation_dataset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn) inputs, targets = next(iter(train_loader)) print("inputs:", inputs) print("targets:", targets) #定义预测模型,该模型由bert模型加上最后的预测层组成 class MyModel(nn.Module): def __init__(self): super(MyModel, self).__init__() # 加载bert模型 self.bert = BertModel.from_pretrained('bert-base-uncased', mirror='tuna') # 最后的预测层 self.predictor = nn.Sequential( nn.Linear(768, 256), nn.ReLU(), nn.Linear(256, 1), nn.Sigmoid() ) def forward(self, src): """ :param src: 分词后的推文数据 """ # 将src直接序列解包传入bert,因为bert和tokenizer是一套的,所以可以这么做。 # 得到encoder的输出,用最前面[CLS]的输出作为最终线性层的输入 outputs = self.bert(**src).last_hidden_state[:, 0, :] # 使用线性层来做最终的预测 return self.predictor(outputs) model = MyModel() model = model.to(device) #定义出损失函数和优化器。这里使用Binary Cross Entropy: criteria = nn.BCELoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) # 由于inputs是字典类型的,定义一个辅助函数帮助to(device) def to_device(dict_tensors): result_tensors = {} for key, value in dict_tensors.items(): result_tensors[key] = value.to(device) return result_tensors #定义一个验证方法,获取到验证集的精准率和loss def validate(): model.eval() total_loss = 0. total_correct = 0 for inputs, targets in validation_loader: inputs, targets = to_device(inputs), targets.to(device) outputs = model(inputs) loss = criteria(outputs.view(-1), targets.float()) total_loss += float(loss) correct_num = (((outputs >= 0.5).float() * 1).flatten() == targets).sum() total_correct += correct_num return total_correct / len(validation_dataset), total_loss / len(validation_dataset) if hasattr(torch.cuda, 'empty_cache'): torch.cuda.empty_cache() # 首先将模型调成训练模式 model.train() # 清空一下cuda缓存 if torch.cuda.is_available(): torch.cuda.empty_cache() # 定义几个变量,帮助打印loss total_loss = 0. # 记录步数 step = 0 # 记录在验证集上最好的准确率 best_accuracy = 0 # 开始训练 for epoch in range(epochs): model.train() for i, (inputs, targets) in enumerate(train_loader): # 从batch中拿到训练数据 inputs, targets = to_device(inputs), targets.to(device) # 传入模型进行前向传递 outputs = model(inputs) # 计算损失 loss = criteria(outputs.view(-1), targets.float()) loss.backward() optimizer.step() optimizer.zero_grad() total_loss += float(loss) step += 1 if step % log_per_step == 0: print("Epoch {}/{}, Step: {}/{}, total loss:{:.4f}".format(epoch+1, epochs, i, len(train_loader), total_loss)) total_loss = 0 del inputs, targets # 一个epoch后,使用过验证集进行验证 accuracy, validation_loss = validate() print("Epoch {}, accuracy: {:.4f}, validation loss: {:.4f}".format(epoch+1, accuracy, validation_loss)) torch.save(model, model_dir / f"model_{epoch}.pt") # 保存最好的模型 if accuracy > best_accuracy: torch.save(model, model_dir / f"model_best.pt") best_accuracy = accuracy #加载最好的模型,然后进行测试集的预测 model = torch.load(model_dir / f"model_best.pt") model = model.eval() test_dataset = MyDataset('test') test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn) results = [] for inputs, ids in test_loader: outputs = model(inputs.to(device)) outputs = (outputs >= 0.5).int().flatten().tolist() ids = ids.tolist() results = results + [(id, result) for result, id in zip(outputs, ids)] test_label = [pair[1] for pair in results] test_data['label'] = test_label test_data['Keywords'] = test_data['title'].fillna('') test_data[['uuid', 'Keywords', 'label']].to_csv('submit_task_bert.csv', index=None)
4. 遇到的问题和解决方法
都是小问题
问题1:
显存不足
解决1:
改小 batch_size
关掉正在运行的其他进程
问题2:
ModuleNotFoundError: No module named ‘sklearn’
解决2:
conda install scikit-learn
5. 实践成绩
8.19凌晨 bert模型返回分数:0.89562(batch_size=1, epochs = 10)
8.19 bert模型返回分数:0.99452 (batch_size=8, epochs = 10)
相比之前的机器学习方法,效果显著提升,但运行时间也变长了
8.14 submit_task1.csv 得分:0.67116
第一次测试是基于逻辑回归模型的,没有考虑停用词;
8.16 submit_task_LR.csv 得分:0.67435
第二次测试也是基于逻辑回归模型的,但考虑了停用词;
8.16 submit_task_SVM.csv 得分:0.6778
第三次测试是基于SVM模型的,考虑了停用词;
8.16 submit_task_AdaBoost.csv 得分:0.76263
第四次测试是基于AdaBoost模型的,考虑了停用词;
8.17 尝试了XGBoost模型、GBDT模型,并且稍微调整了一下AdaBoost参数,但效果不行,得分比baseline低了将近0.01,这里就不展示了。