赞
踩
以豆瓣电影评论文本的情绪分类为例,说明BERT模型的基本使用方法。
(1)BERT原理
BERT全称为Bidirectional Encoder Representation from Transformers(来自Transformers的双向编码表示),从名称上就可以看出,BERT模型主要结构是Transformer的encoder层,会正序+反序双向输入来理解一句话,比以往的单向输入理解能力增强。BERT模型是近年来自然语言处理领域公认的里程碑模型,具备很强大的语义信息提取能力,是谷歌耗费了巨大的资源训练得到的,目前已经作为一项基础能力在自然语言处理场景中被广泛应用。对于基础使用者来说,不用太过深究BERT的深层原理,只需要将其视为文本信息特征提取器,了解其适用场景、输入输出格式、特定的一些超参数即可。
关于Transformer的原理可以参考读书笔记《深度学习进阶:自然语言处理》第8章 Attention。
(2)开源BERT预训练模型选择
BERT发布以来受到NLP开发者的追捧,也衍生出很多变体。如何选择合适的预训练模型可以参考《BERT模型系列大全解读 - Keep Learning的文章 - 知乎》。
本文将使用哈工大开发的BERT变体预训练模型chinese-roberta-wwm-ext进行实验。
豆瓣电影有很多用户的短评及打分,且该网站将1星及2星分类为差评,3星为一般,4星及5星为好评。网上有很多地方能下载到豆瓣电影评论数据集,所以就不用爬取了。本文按照0.4小节(3)的方式获取数据。
抱抱脸(HuggingFace)网站是NLP集合网站,调用相关模型、数据集的接口方便快捷,随着热度的上升,大多数模型创建者都会将该网站作为分发平台。由于某些不可抗因素,需要一些手段才能登录该网站。
(1)API文档
点击栏目“Docs”可以进入HuggingFace提供的代码包的接口文档,主要关注Transformers、Datasets两个代码包。分别点击进入后可以看到详细的安装、使用说明。
(2)模型市场
点击栏目“Models”可以进入模型集市。本文我们使用哈工大的chinese-roberta-wwm-ext模型,点击可以进入该模型的详情页。
HuggingFace还提供了模型填词的简单样例。
API文档中介绍了加载该模型的两种方式:1)在线加载;2)本地加载。由于国内访问该网站并不稳定,建议先下载到本地再使用。点击“Files and versions”进入文件列表。
该模型提供了PyTorch、Tensorflow、Flax框架的模型格式,本文使用PyTorch架构,所以三个最大的模型文件中只需要下载pytorch_model.bin。其他小文件中除了README.md、.gitattributes,都需要下载。
(3)数据集市场
豆瓣评论数据集还是有很多地方能下载到的,这里是正好在抱抱脸网站上看到了一个合适的,就在此处获取了。
搜索结果中有两个都是豆瓣评论数据集,最后一个数据量比较小,但是满足本文的要求,就选它了。
使用BERT模型进行一般文本分类的过程如图0-1所示。
可以看出整个过程是很简单的,一个batch的若干文本转化为id,BERT模型提取每句文本的向量表达特征,再经过分类器输出分类结果。分类结果与文本已知的分类标签进行对比,计算误差损失,根据损失优化模型的参数。如此循环直到模型误差或循环次数达到一定的程度。
本文将在1.2和1.3中详细介绍上面的数据处理及训练过程。
代码模块0:数据读取
def read_datafile(self, filename, rate=None):
if rate is not None and not self.rate_check(rate):
raise ValueError(f'Data sampling rate {rate} is not legal!')
file_type = filename.split('.')[-1]
if file_type in ('txt', 'csv'):
df = pd.read_csv(filename, sep=self.sep, encoding='utf-8',
names=self.columns, usecols=self.columns_idx,
skiprows=lambda x: secret_random(rate)
if rate is not None else None,
engine=self.engine)
elif file_type in ('xls', 'xlsx', 'xlsm', 'xlsb'):
df = pd.read_excel(filename, header=self.header_num,
engine=self.engine, names=self.columns,
usecols=self.columns_idx,
skiprows=lambda x: secret_random(rate)
if rate is not None else None)
else:
df = pd.DataFrame()
return df
0.4(3)中的下载数据集中,我们需要的是short_comment和score,且需要将score划分为“差评”(0)、“好评”(1)、“一般”(2)。
需要的数据如下:
def comment_type(score):
if score <= 2:
return 0
if score == 3:
return 2
return 1
df['label'] = df['score'].apply(lambda x: comment_type(int(x)))
df = df[['short_comment', 'label']]
df
上面的数据分开作训练(1100)、测试(200)、验证(200)、推理(100)用。
代码模块1:Tokenizer(标记)
神经网络不能识别非数值的数据,需要给每个字符分配一个整数id作为标记。Tokenizer就是根据编码表将这个标记过程自动化。
不同的语句长短不一,为了能将不同语句转化为统一长度的张量进行批量处理,Tokenizer需要将过长的语句截断,将较短的语句补长。Tokenizer还会在语句的开头补充[CLS]符号,id为101,在句尾补充[SEP]符号,id为102。对于长度不够的语句,在句尾用0补齐,保证同一批输入模型的文本编码保持相同的长度。
BERT支持一条文本标记后id列表的最大长度为512,可以按照数据情况设置更短的长度,减小模型计算规模。
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained(paras.get('pre_model_path'))
tokenizer.encode('好好学习,天天向上')
[101, 1962, 1962, 2110, 739, 8024, 1921, 1921, 1403, 677, 102]
代码模块2:BERT的Pooler Output(全句含义输出)
BERT对输入的标记id序列进行编码,输出Last Hidden State层和Pooler Output层供下游任务使用。
Last Hidden State层将每个输入的token id编码为768维的向量。例如一条语句在经过Tokenizer标记之后成为20个token的id序列,将这个序列输入给BERT模型,将在Last Hidden State层得到一个20×768的向量。这样具体到每个字符的向量输出在对每个字符进行分类的时候是有用的。
但是对于整个文本的分类或语义提取,如何表示文本整体的含义呢?
一个简单的方法就是将该文本所有字符的向量求和或求平均,形成1×768的向量。或者使用[CLS]符号的向量表示整个文本的语义。BERT提供的Pooler Output层就是在[CLS]符号的向量上增加了一层Dropout层和一层全连接层,最终还是输出一个1×768的向量。
class CommentModel(BertBase):
def __init__(self, pre_model_path, config, out_size, num_embedding=None):
super(CommentModel, self).__init__(pre_model_path, config,
num_embedding)
self.dense = nn.Linear(config.hidden_size, out_size)
self.activation = nn.Softmax(dim=-1)
self.define_layers = ['dense']
def forward(self, input_ids, attention_mask, token_type_ids):
bert_output = self.bert(
input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids
)
bert_output = bert_output.pooler_output
output = self.activation(self.dense(bert_output))
return output
bert_output.pooler_output将输出整个文本的语义向量。
代码模块3:Classifier(分类器)
分类器将对Pooler Output层的输出向量进行分类。此处可以使用多种多样的分类器,但是一般我们使用全连接层将768维的输入向量转化为维度与分类类别数相同的输出向量。例如分类类别数为3,分类器就需要将768维向量转化为3维向量,对该3维向量进行归一化后,每个维度的数值表示文本归属于该类别的可能性。例如某条文本经过处理后的输出为[0.05, 0.70, 0.25],表示该文本有70%的可能属于类别1。关于神经网络如何实现分类功能,请参考文章《神经网络,了解这些就够了》中的5.1小节。
上面代码中的nn.Linear(config.hidden_size, out_size)
就是一个将BERT输出向量映射到分类空间的分类器。
代码模块4:Loss(损失函数)
对于多分类问题,一般使用交叉熵损失函数计算预测结果与真实结果之间的损失值。loss.backward()计算梯度,optimizer.step()更新参数,optimizer.zero_grad()将梯度清零(不然下次计算梯度会叠加)。
loss = cross_entropy(pre_res.cpu(), label)
loss.backward()
optimizer.step()
optimizer.zero_grad()
代码模块5:Optimizer(优化器)
优化器根据损失值,依据一定的优化原理进行后向传播,计算神经网络每个参数的梯度,根据梯度更新神经网络参数,使得损失值减小。常用的优化器包括SGD、Momentum、Adam等。关于后向传播及优化器的更多信息可以参考文章《神经网络,了解这些就够了》中的第三节。
optimizer = torch.optim.AdamW(comment_model.parameters(),
lr=paras.get('learning_rate'))
PyTorch调用优化器就是如此优雅
代码模块6:Inference(推理)
推理的过程没有太多可讲的,需要注意的是,推理的时候需要通过model.eval()和torch.no_grad()告诉模型进入推理模式,不需要计算梯度了。
comment_model.eval()
with torch.no_grad():
for batch in infer_dataloader:
(ids, mask, type_ids, label) = batch
ids, mask, type_ids = ids.to(dev), mask.to(dev), type_ids.to(dev)
tmp_res = comment_model(ids, mask, type_ids)
将训练过程中打印出来的每个epoch的训练损失值和在验证集上的准确率形成曲线如下,可见大概训练到50轮的时候损失达到了理想的状态,但是在验证集上的准确率一直在60%左右波动。调整了几次参数基本都是如此。
仔细查看推理错误的结果,发现其实也不能说预测结果就是绝对错了。用户短评的主观性很强,更重要的是评语体现出来的态度与最终的打分有时候并不是强关联的。案例数据集选取失败了。
老话说得好:数据决定上限,算法逼近上限。该数据集的上限似乎就只能这样了。后面有时间换数据集再进行实验。本文就先重在过程吧/狗头。
我们可以对比其他的文本分类方法。下面是使用TF-IDF提取文本特征,然后使用神经网络作为分类器的方案。可以看出效果和本文的BERT方案效果类似。详情请参阅文章《关于神经网络,了解这些就够了》第4节。
提示学习(Prompt Learning)是在不显著改变预训练语言模型结构和参数的情况下,通过向输入增加“提示信息”,将下游任务改为文本生成任务。
提示学习根据需要构建提示模板,让模型完成提示模板中的完形填空任务,使用填空内容表示文本的语义或分类的类别。
例如文本分类场景,构建提示模板:“[X]”这句话的情感分类是[MASK]。使用时,将原始文本替换模板中的[X]符号,标记成id后输入BERT模型,从输出的Last Hidden State层中获取[MASK]符号的表示,再接入下游的分类任务。
例如语义获取场景,构建提示模板:“[X]”这句话的意思是[MASK]。[MASK]符号的向量表示就可以视为原始文本的语义。
提示模板可以千变万化,效果往往各有不同。另外,提示学习还有一种在训练中生成模板的方法,此次不做讨论。
提示学习的思想被应用到自然语言处理的很多场景,在语义表示、文本分类等多个场景中取得了SOTA的效果。
本来想对比一下加入提示学习对结果的影响,但是数据集上限似乎不能支撑结果对比了。占位,后面补上。
pass
本文代码参考:GitCode
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。