本文为博客 BERT Fine-Tuning Tutorial with PyTorch 的翻译

在本教程中,我将向你展示如何使用 BERT 与 huggingface PyTorch 库来快速高效地微调模型,以获得接近句子分类的最先进性能。更广泛地讲,我将描述转移学习在NLP中的实际应用,以最小的努力在一系列NLP任务上创建高性能模型。

介绍

历史

2018年是NLP的突破性一年。转移学习,特别是像Allen AI的ELMO、OpenAI的Open-GPT和谷歌的BERT这样的模型,让研究人员用最小的特定任务微调粉碎了多个基准,并为NLP社区的其他成员提供了预训练的模型,这些模型可以轻松地(用更少的数据和更少的计算时间)进行微调和实施,以产生最先进的结果。遗憾的是,对于许多刚开始接触NLP的人,甚至对于一些有经验的实践者来说,这些强大模型的理论和实际应用仍然没有得到很好的理解。

什么是 BERT?

BERT(Bidirectional Encoder Representations from Transformers)于2018年底发布,我们将在本教程中使用该模型,为读者更好地理解和实践指导在NLP中使用转移学习模型。BERT是一种预训练语言表征的方法,它被用来创建模型,然后NLP实践者可以免费下载并使用这些模型。你可以使用这些模型从你的文本数据中提取高质量的语言特征,也可以用你自己的数据在特定的任务(分类、实体识别、问题回答等)上对这些模型进行微调,以产生最先进的预测。

这篇文章将解释如何修改和微调BERT,以创建一个强大的NLP模型,快速给你提供最先进的结果。

微调的优势

在本教程中,我们将使用BERT来训练一个文本分类器。具体来说,我们将把预先训练好的 BERT 模型,在最后添加一层未经训练的神经元,并为我们的分类任务训练新模型。为什么要这样做,而不是训练一个很适合你需要的特定深度学习模型(CNN、BiLSTM等)?

  1. 更快的发展

    • 首先,预先训练的BERT模型权重已经编码了很多关于我们语言的信息。因此,训练我们的微调模型所需要的时间要少得多--就好像我们已经广泛地训练了我们网络的底层,只需要在使用它们的输出作为分类任务的特征时轻轻地调整它们。事实上,作者建议在特定的NLP任务上对BERT进行微调只需要2-4个纪元的训练(相比之下,从头开始训练原始的BERT模型或LSTM需要数百个GPU小时!)。
  2. 更少的数据

    • 此外,也许同样重要的是,由于预先训练的权重,这种方法允许我们在一个比从头开始建立的模型所需的更小的数据集上微调我们的任务。从零开始建立的NLP模型的一个主要缺点是,我们通常需要一个大得令人望而却步的数据集来训练我们的网络以达到合理的精度,这意味着必须将大量的时间和精力投入到数据集的创建中。通过对BERT的微调,我们现在能够摆脱在更小的训练数据量上训练一个模型达到良好的性能。
  3. 更好的结果

    • 最后,这种简单的微调程序(通常是在BERT的基础上增加一个全连接的层,并进行几个纪元的训练)被证明可以通过最小的任务特定调整来实现最先进的结果,适用于各种各样的任务:分类、语言推理、语义相似性、问题回答等。与其实施在特定任务上表现出良好效果的定制和有时模糊的架构,不如简单地对BERT进行微调,这被证明是一个更好的(或至少相等的)替代方案。

NLP的转变

这种向转移学习的转变与几年前计算机视觉领域发生的相同转变并行。为计算机视觉任务创建一个好的深度学习网络可能需要数百万个参数,而且训练成本非常高。研究人员发现,深度网络可以学习分层的特征表示(在最低层有简单的特征,如边缘,在较高层有逐渐复杂的特征)。与其每次从头开始训练一个新的网络,不如将训练好的网络的低层泛化图像特征复制并转移到另一个有不同任务的网络中使用。很快,下载一个预先训练好的深度网络,并迅速对其进行重新训练以适应新的任务,或者在上面添加额外的层,这比从头开始训练网络的昂贵过程要好得多。对于许多人来说,2018年引入的深度预训练语言模型(ELMO、BERT、ULMFIT、Open-GPT等)标志着NLP中向转移学习的转变,就像计算机视觉看到的那样。

让我们开始吧!

1. 设置

1.1. 检查 GPU

为了让 torch 使用 GPU,我们需要识别并指定 GPU 作为设备。稍后,在我们的训练循环中,我们将把数据加载到设备上。

import torch

# If there's a GPU available...
if torch.cuda.is_available():

# Tell PyTorch to use the GPU.
device = torch.device("cuda")

print('There are %d GPU(s) available.' % torch.cuda.device_count())

print('We will use the GPU:', torch.cuda.get_device_name(0))

# If not...
else:
print('No GPU available, using the CPU instead.')
device = torch.device("cpu")

1.2. 安装 HuggingFace 库

接下来,让我们安装 HuggingFace 的transformers包,它将为我们提供一个与BERT一起工作的pytorch接口。(这个库包含了其他预训练语言模型的接口,如OpenAI的GPT和GPT-2)。我们选择了pytorch接口,因为它在高级API(它很容易使用,但不能深入了解事情的工作原理)和tensorflow代码(它包含了很多细节,但经常让我们偏离了关于tensorflow的课程,而这里的目的是BERT!)之间取得了很好的平衡。

目前,Hugging Face库似乎是最被广泛接受的、最强大的与BERT合作的pytorch接口。除了支持各种不同的预先训练好的变换模型外,该库还包含了这些模型的预构建修改,适合你的特定任务。例如,在本教程中,我们将使用BertForSequenceClassification

该库还包括用于标记分类、问题回答、下句预测等的特定任务类。使用这些预建的类可以简化为您的目的修改BERT的过程。

本笔记本中的代码其实是 HuggingFace 的run_glue.py示例脚本的简化版。

run_glue.py是一个很有用的工具,它允许你选择你想运行的GLUE基准任务,以及你想使用的预训练模型(你可以看到可能的模型列表这里)。它还支持使用CPU、单个GPU或多个GPU。如果你想进一步提高速度,它甚至支持使用16位精度。

不幸的是,所有这些可配置性都是以可读性为代价的。在这篇Notebook中,我们已经大大简化了代码,并添加了大量的注释,以使其清楚地了解发生了什么。

2. 加载 CoLA 数据集

我们将使用The Corpus of Linguistic Acceptability (CoLA)数据集进行单句分类。它是一组被标记为语法正确或不正确的句子。它于2018年5月首次发布,是 "GLUE Benchmark "中包含的测试之一,BERT等模型都在此基础上进行比赛。

2.1. 下载和解压

该数据集托管在GitHub上的这个repo中:https://nyu-mll.github.io/CoLA/,下载链接

2.2. 解析

我们可以从文件名中看到,"tokenized" 和 "raw"版本的数据都是可用的。

我们不能使用预标记版本,因为为了应用预训练的BERT,我们必须使用模型提供的标记器。这是因为:(1)模型有一个特定的、固定的词汇,(2)BERT tokenizer有一种特殊的方式来处理词汇外的词汇。

我们将使用pandas来解析"域内"训练集,并查看其一些属性和数据点。

import pandas as pd

# Load the dataset into a pandas dataframe.
df = pd.read_csv("./cola_public/raw/in_domain_train.tsv", delimiter='\t', header=None, names=['sentence_source', 'label', 'label_notes', 'sentence'])

# Report the number of sentences.
print('Number of training sentences: {:,}\n'.format(df.shape[0]))

# Display 10 random rows from the data.
df.sample(10)

我们实际关心的两个属性是"句子"和它的"标签",这个标签被称为"可接受性判断"(0=不可接受,1=可接受)。

下面是五个被标注为语法上不可接受的句子。请注意,这个任务比情感分析之类的工作要难得多!

print(df.loc[df.label == 0].sample(5)[['sentence', 'label']])

让我们将训练集的句子和标签提取为 numpy ndarrays。

# Get the lists of sentences and their labels.
sentences = df.sentence.values
labels = df.label.values

3. Tokenization & Input 格式化

在本节中,我们将把我们的数据集转换为BERT可以训练的格式。

3.1. BERT Tokenizer

为了将我们的文本输入到 BERT,必须将其分割成 tokens,然后这些 tokens 必须被映射到 tokenizer 词汇表中的索引。

Tokenization 必须由 BERT 中包含的 Tokenizer 来执行--下面的单元格将为我们下载。我们将在这里使用 "uncases "版本。

from transformers import BertTokenizer

# Load the BERT tokenizer.
print('Loading BERT tokenizer...')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', do_lower_case=True)

让我们把tokenizer应用到一个句子上,看看输出。

# Print the original sentence.
print('Original: ', sentences[0])

# Print the sentence split into tokens.
print('Tokenized: ', tokenizer.tokenize(sentences[0]))

# Print the sentence mapped to token ids.
print('Token IDs: ', tokenizer.convert_tokens_to_ids(tokenizer.tokenize(sentences[0])))

当我们实际转换所有的句子时,我们将使用tokenize.encode函数来处理这两个步骤,而不是分别调用tokenizeconvert_tokens_to_ids

不过,在我们这样做之前,我们需要先谈谈BERT的一些格式化要求。

3.2. 需要的格式化

上面的代码遗漏了一些必要的格式化步骤,我们将在这里看看。

  • 补充说明:我觉得BERT的输入格式似乎 "过于规范"了......。我们被要求提供一些信息,这些信息看起来是多余的,或者说它们可以很容易地从数据中推断出来,而不需要我们明确地提供。但事实就是如此,我想一旦我对BERT的内部结构有了更深入的了解,它就会变得更有意义。

我们需要做的是 1. 在每个句子的开头和结尾添加特殊的标记。 2. 将所有句子的长度固定为一个固定的长度。 3. 用"注意力遮盖"明确区分真正的 token 和填充 token。

特殊 Tokens

[SEP]

在每个句子的末尾,我们需要附加特殊的"[SEP]"令牌。

这个标记是双句子任务的产物,即给BERT两个独立的句子,并要求它确定一些事情(例如,句子A中的问题的答案能否在句子B中找到?

我还不确定为什么当我们只有单句输入的时候,还需要 token,但它确实需要!

[CLS]

对于分类任务,我们必须在每个句子的开头加上特殊的"[CLS]"标记。

这个标记具有特殊的意义。BERT 由12个 Transformer 层组成。每个 Transformer 都会接收一个标记嵌入的列表,并在输出中产生相同数量的嵌入(当然是改变了特征值!)。

Illustration of CLS token purpose
Illustration of CLS token purpose

在最后一个(第12个) Transformer 的输出端,分类器只使用第一个嵌入(对应[CLS]标记)

"The first token of every sequence is always a special classification token ([CLS]). The final hidden state corresponding to this token is used as the aggregate sequence representation for classification tasks." (摘自BERT论文)

你可能会想到在最终的嵌入上尝试一些池化策略,但这并不是必须的。因为 BERT 被训练成只使用这个[CLS]标记进行分类,我们知道模型已经被激励将分类步骤所需的一切编码到那个单一的 768 值嵌入向量中。它已经为我们完成了池化工作!

句子长度 & 注意力遮盖

我们数据集中的句子显然有不同的长度,那么BERT是如何处理的呢?

BERT有两个约束条件。 1. 所有的句子必须被填充或截断成一个固定的长度。 2. 最大的句子长度是512个tokens。

填充是通过一个特殊的"[PAD]"令牌来完成的,它在BERT词汇表中的索引0。下面的插图演示了填充到8个令牌的 "MAX_LEN"。

"注意力遮盖"只是一个1和0的数组,表示哪些标记是padding,哪些不是(看起来有点多余,不是吗!)。这个掩码告诉BERT中的"自我关注"机制不要将这些pad标记纳入它对句子的解释中。

不过,最大长度确实会影响训练和评估速度。

例如,用特斯拉K80。

MAX_LEN = 128 --> 训练一个 epoch 需要 5:28

MAX_LEN = 64 --> 训练一个 epoch 需要 2:57

3.3. Tokenize 数据集

transformers库提供了一个有用的 "encode" 函数,它将为我们处理大部分的解析和数据准备步骤。

在我们准备好对文本进行编码之前,我们需要决定一个最大句子长度来进行填充/截断。

下面的单元格将对数据集进行一次标记化处理,以测量最大句子长度。

max_len = 0

# For every sentence...
for sent in sentences:

# Tokenize the text and add `[CLS]` and `[SEP]` tokens.
input_ids = tokenizer.encode(sent, add_special_tokens=True)

# Update the maximum sentence length.
max_len = max(max_len, len(input_ids))

print('Max sentence length: ', max_len)

为了防止有一些较长的测试句子,我将最大长度设置为64。

现在我们准备好执行真正的 tokenization 了。

tokenizer.encode_plus函数为我们结合了多个步骤。

  1. 将句子分割成token。
  2. 添加特殊的[CLS][SEP]标记。
  3. 将这些标记映射到它们的ID上。
  4. 把所有的句子都垫上或截断成相同的长度。
  5. 创建注意力遮盖,明确区分真实 token 和[PAD]token。

前四项功能在tokenizer.encode中,但我使用tokenizer.encode_plus来获得第五项(注意力遮盖)。文档在这里.

# Tokenize all of the sentences and map the tokens to thier word IDs.
input_ids = []
attention_masks = []

# For every sentence...
for sent in sentences:
# `encode_plus` will:
# (1) Tokenize the sentence.
# (2) Prepend the `[CLS]` token to the start.
# (3) Append the `[SEP]` token to the end.
# (4) Map tokens to their IDs.
# (5) Pad or truncate the sentence to `max_length`
# (6) Create attention masks for [PAD] tokens.
encoded_dict = tokenizer.encode_plus(
sent, # Sentence to encode.
add_special_tokens = True, # Add '[CLS]' and '[SEP]'
max_length = 64, # Pad & truncate all sentences.
pad_to_max_length = True,
return_attention_mask = True, # Construct attn. masks.
return_tensors = 'pt', # Return pytorch tensors.
)

# Add the encoded sentence to the list.
input_ids.append(encoded_dict['input_ids'])

# And its attention mask (simply differentiates padding from non-padding).
attention_masks.append(encoded_dict['attention_mask'])

# Convert the lists into tensors.
input_ids = torch.cat(input_ids, dim=0)
attention_masks = torch.cat(attention_masks, dim=0)
labels = torch.tensor(labels)

# Print sentence 0, now as a list of IDs.
print('Original: ', sentences[0])
print('Token IDs:', input_ids[0])

3.4. 训练 & 验证切分

把我们的训练集分成 90% 用于训练,10% 用于验证。

from torch.utils.data import TensorDataset, random_split

# Combine the training inputs into a TensorDataset.
dataset = TensorDataset(input_ids, attention_masks, labels)

# Create a 90-10 train-validation split.

# Calculate the number of samples to include in each set.
train_size = int(0.9 * len(dataset))
val_size = len(dataset) - train_size

# Divide the dataset by randomly selecting samples.
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])

print('{:>5,} training samples'.format(train_size))
print('{:>5,} validation samples'.format(val_size))

我们还将使用 torch DataLoader 类为我们的数据集创建一个迭代器。这有助于在训练过程中节省内存,因为与for循环不同,有了迭代器,整个数据集不需要加载到内存中。

from torch.utils.data import DataLoader, RandomSampler, SequentialSampler

# The DataLoader needs to know our batch size for training, so we specify it
# here. For fine-tuning BERT on a specific task, the authors recommend a batch
# size of 16 or 32.
batch_size = 32

# Create the DataLoaders for our training and validation sets.
# We'll take training samples in random order.
train_dataloader = DataLoader(
train_dataset, # The training samples.
sampler = RandomSampler(train_dataset), # Select batches randomly
batch_size = batch_size # Trains with this batch size.
)

# For validation the order doesn't matter, so we'll just read them sequentially.
validation_dataloader = DataLoader(
val_dataset, # The validation samples.
sampler = SequentialSampler(val_dataset), # Pull out batches sequentially.
batch_size = batch_size # Evaluate with this batch size.
)

4. 训练我们的分类模型

现在我们的输入数据已经被正确格式化了,是时候微调一下BERT模型了。

4.1. BertForSequenceClassification

对于这个任务,我们首先要修改预先训练好的 BERT 模型,给出分类的输出,然后我们要在我们的数据集上继续训练模型,直到整个模型,端到端都很适合我们的任务。

值得庆幸的是,huggingface pytorch的实现中包含了一套针对各种NLP任务设计的接口。虽然这些接口都是建立在训练好的 BERT 模型之上,但每个接口都有不同的顶层和输出类型,以适应其特定的 NLP 任务。

以下是目前提供的类列表,供微调。

  • BertModel
  • BertForPreTraining
  • BertForMaskedLM
  • BertForNextSentencePrediction(下句预测)
  • BertForSequenceClassification - 我们将使用的那个。
  • BertForTokenClassification
  • BertForQuestionAnswering

这些文档可以在这里下找到。

我们将使用BertForSequenceClassification。这是普通的BERT模型,上面增加了一个用于分类的单线性层,我们将使用它作为句子分类器。当我们输入数据时,整个预先训练好的BERT模型和额外的未经训练的分类层会根据我们的特定任务进行训练。

OK,让我们加载 BERT 吧! 有几个不同的预训练 BERT 模型可供选择。"bert-base-uncased "指的是只有小写字母("uncased")的版本,是两者中较小的版本("base "vs "large")。

from_pretrained的文档可以在这里找到,附加参数定义在这里

from transformers import BertForSequenceClassification, AdamW, BertConfig

# Load BertForSequenceClassification, the pretrained BERT model with a single
# linear classification layer on top.
model = BertForSequenceClassification.from_pretrained(
"bert-base-uncased", # Use the 12-layer BERT model, with an uncased vocab.
num_labels = 2, # The number of output labels--2 for binary classification.
# You can increase this for multi-class tasks.
output_attentions = False, # Whether the model returns attentions weights.
output_hidden_states = False, # Whether the model returns all hidden-states.
)

# Tell pytorch to run this model on the GPU.
model.cuda()

为了好奇,我们可以在这里按名称浏览所有模型的参数。

在下面的单元格中,我打印出了权重的名称和尺寸,分别为。

  1. 嵌入层。
  2. 十二个变压器中的第一个。
  3. 输出层。
# Get all of the model's parameters as a list of tuples.
params = list(model.named_parameters())

print('The BERT model has {:} different named parameters.\n'.format(len(params)))

print('==== Embedding Layer ====\n')

for p in params[0:5]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))

print('\n==== First Transformer ====\n')

for p in params[5:21]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))

print('\n==== Output Layer ====\n')

for p in params[-4:]:
print("{:<55} {:>12}".format(p[0], str(tuple(p[1].size()))))

4.2. 优化器 & 学习率调度器

现在我们已经加载了我们的模型,我们需要从存储的模型中抓取训练超参数。

为了微调的目的,作者建议从以下数值中选择(来自BERT论文的附录A.3)。

  • batch大小: 16,32。
  • 学习率(Adam): 5e-5、3e-5、2e-5。
  • epoch数: 2、3、4。

我们选择的是: * batch大小:32(在创建DataLoaders时设置)。 * 学习率:2e-5 * Epochs: 4 (我们将看到这可能是太多了...)

epsilon 参数eps = 1e-8是 "一个非常小的数字,以防止在实现中出现任何除以零的情况" (来自这里)。

你可以在run_glue.py这里中找到AdamW优化器的创建。

# Note: AdamW is a class from the huggingface library (as opposed to pytorch) 
# I believe the 'W' stands for 'Weight Decay fix"
optimizer = AdamW(model.parameters(),
lr = 2e-5, # args.learning_rate - default is 5e-5, our notebook had 2e-5
eps = 1e-8 # args.adam_epsilon - default is 1e-8.
)

from transformers import get_linear_schedule_with_warmup

# Number of training epochs. The BERT authors recommend between 2 and 4.
# We chose to run for 4, but we'll see later that this may be over-fitting the
# training data.
epochs = 4

# Total number of training steps is [number of batches] x [number of epochs].
# (Note that this is not the same as the number of training samples).
total_steps = len(train_dataloader) * epochs

# Create the learning rate scheduler.
scheduler = get_linear_schedule_with_warmup(optimizer,
num_warmup_steps = 0, # Default value in run_glue.py
num_training_steps = total_steps)

4.3. 训练循环

下面是我们的训练循环。有很多事情要做,但从根本上讲,我们的循环中的每一个过程都有一个训练阶段和一个验证阶段。

*感谢Stas Bekman贡献了使用验证损失来检测过度拟合的见解和代码!

训练: - 解开我们的数据输入和标签 - 将数据加载到GPU上进行加速 - 清空上一次计算的梯度。 - 在pytorch中,默认情况下梯度会累积(对RNNs等有用),除非你明确地清除它们。 - 正向传递(通过网络输入数据)。 - 后传(反向传播) - 用optimizer.step()告诉网络更新参数。 - 跟踪监测进展的变量

验证: - 解开我们的数据输入和标签 - 将数据加载到GPU上进行加速 - 正向传递(通过网络输入数据) - 计算我们的验证数据的损失,并跟踪监测进度的变量。

Pytorch 向我们隐藏了所有的详细计算,但我们已经对代码进行了注释,以指出上述步骤中的每一行都在进行。

PyTorch也有一些初学者教程,你可能也会觉得很有帮助

定义一个用于计算精度的辅助函数。

import numpy as np

# Function to calculate the accuracy of our predictions vs labels
def flat_accuracy(preds, labels):
pred_flat = np.argmax(preds, axis=1).flatten()
labels_flat = labels.flatten()
return np.sum(pred_flat == labels_flat) / len(labels_flat)

用于格式化 "hh:mm:ss" 的经过时间的辅助函数。

import time
import datetime

def format_time(elapsed):
'''
Takes a time in seconds and returns a string hh:mm:ss
'''
# Round to the nearest second.
elapsed_rounded = int(round((elapsed)))

# Format as hh:mm:ss
return str(datetime.timedelta(seconds=elapsed_rounded))

我们准备开始训练了!

import random

# This training code is based on the `run_glue.py` script here:
# https://github.com/huggingface/transformers/blob/5bfcd0485ece086ebcbed2d008813037968a9e58/examples/run_glue.py#L128

# Set the seed value all over the place to make this reproducible.
seed_val = 42

random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val)
torch.cuda.manual_seed_all(seed_val)

# We'll store a number of quantities such as training and validation loss,
# validation accuracy, and timings.
training_stats = []

# Measure the total training time for the whole run.
total_t0 = time.time()

# For each epoch...
for epoch_i in range(0, epochs):

# ========================================
# Training
# ========================================

# Perform one full pass over the training set.

print("")
print('======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
print('Training...')

# Measure how long the training epoch takes.
t0 = time.time()

# Reset the total loss for this epoch.
total_train_loss = 0

# Put the model into training mode. Don't be mislead--the call to
# `train` just changes the *mode*, it doesn't *perform* the training.
# `dropout` and `batchnorm` layers behave differently during training
# vs. test (source: https://stackoverflow.com/questions/51433378/what-does-model-train-do-in-pytorch)
model.train()

# For each batch of training data...
for step, batch in enumerate(train_dataloader):

# Progress update every 40 batches.
if step % 40 == 0 and not step == 0:
# Calculate elapsed time in minutes.
elapsed = format_time(time.time() - t0)

# Report progress.
print(' Batch {:>5,} of {:>5,}. Elapsed: {:}.'.format(step, len(train_dataloader), elapsed))

# Unpack this training batch from our dataloader.
#
# As we unpack the batch, we'll also copy each tensor to the GPU using the
# `to` method.
#
# `batch` contains three pytorch tensors:
# [0]: input ids
# [1]: attention masks
# [2]: labels
b_input_ids = batch[0].to(device)
b_input_mask = batch[1].to(device)
b_labels = batch[2].to(device)

# Always clear any previously calculated gradients before performing a
# backward pass. PyTorch doesn't do this automatically because
# accumulating the gradients is "convenient while training RNNs".
# (source: https://stackoverflow.com/questions/48001598/why-do-we-need-to-call-zero-grad-in-pytorch)
model.zero_grad()

# Perform a forward pass (evaluate the model on this training batch).
# The documentation for this `model` function is here:
# https://huggingface.co/transformers/v2.2.0/model_doc/bert.html#transformers.BertForSequenceClassification
# It returns different numbers of parameters depending on what arguments
# arge given and what flags are set. For our useage here, it returns
# the loss (because we provided labels) and the "logits"--the model
# outputs prior to activation.
loss, logits = model(b_input_ids,
token_type_ids=None,
attention_mask=b_input_mask,
labels=b_labels)

# Accumulate the training loss over all of the batches so that we can
# calculate the average loss at the end. `loss` is a Tensor containing a
# single value; the `.item()` function just returns the Python value
# from the tensor.
total_train_loss += loss.item()

# Perform a backward pass to calculate the gradients.
loss.backward()

# Clip the norm of the gradients to 1.0.
# This is to help prevent the "exploding gradients" problem.
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

# Update parameters and take a step using the computed gradient.
# The optimizer dictates the "update rule"--how the parameters are
# modified based on their gradients, the learning rate, etc.
optimizer.step()

# Update the learning rate.
scheduler.step()

# Calculate the average loss over all of the batches.
avg_train_loss = total_train_loss / len(train_dataloader)

# Measure how long this epoch took.
training_time = format_time(time.time() - t0)

print("")
print(" Average training loss: {0:.2f}".format(avg_train_loss))
print(" Training epcoh took: {:}".format(training_time))

# ========================================
# Validation
# ========================================
# After the completion of each training epoch, measure our performance on
# our validation set.

print("")
print("Running Validation...")

t0 = time.time()

# Put the model in evaluation mode--the dropout layers behave differently
# during evaluation.
model.eval()

# Tracking variables
total_eval_accuracy = 0
total_eval_loss = 0
nb_eval_steps = 0

# Evaluate data for one epoch
for batch in validation_dataloader:

# Unpack this training batch from our dataloader.
#
# As we unpack the batch, we'll also copy each tensor to the GPU using
# the `to` method.
#
# `batch` contains three pytorch tensors:
# [0]: input ids
# [1]: attention masks
# [2]: labels
b_input_ids = batch[0].to(device)
b_input_mask = batch[1].to(device)
b_labels = batch[2].to(device)

# Tell pytorch not to bother with constructing the compute graph during
# the forward pass, since this is only needed for backprop (training).
with torch.no_grad():

# Forward pass, calculate logit predictions.
# token_type_ids is the same as the "segment ids", which
# differentiates sentence 1 and 2 in 2-sentence tasks.
# The documentation for this `model` function is here:
# https://huggingface.co/transformers/v2.2.0/model_doc/bert.html#transformers.BertForSequenceClassification
# Get the "logits" output by the model. The "logits" are the output
# values prior to applying an activation function like the softmax.
(loss, logits) = model(b_input_ids,
token_type_ids=None,
attention_mask=b_input_mask,
labels=b_labels)

# Accumulate the validation loss.
total_eval_loss += loss.item()

# Move logits and labels to CPU
logits = logits.detach().cpu().numpy()
label_ids = b_labels.to('cpu').numpy()

# Calculate the accuracy for this batch of test sentences, and
# accumulate it over all batches.
total_eval_accuracy += flat_accuracy(logits, label_ids)


# Report the final accuracy for this validation run.
avg_val_accuracy = total_eval_accuracy / len(validation_dataloader)
print(" Accuracy: {0:.2f}".format(avg_val_accuracy))

# Calculate the average loss over all of the batches.
avg_val_loss = total_eval_loss / len(validation_dataloader)

# Measure how long the validation run took.
validation_time = format_time(time.time() - t0)

print(" Validation Loss: {0:.2f}".format(avg_val_loss))
print(" Validation took: {:}".format(validation_time))

# Record all statistics from this epoch.
training_stats.append(
{
'epoch': epoch_i + 1,
'Training Loss': avg_train_loss,
'Valid. Loss': avg_val_loss,
'Valid. Accur.': avg_val_accuracy,
'Training Time': training_time,
'Validation Time': validation_time
}
)

print("")
print("Training complete!")

print("Total training took {:} (h:mm:ss)".format(format_time(time.time()-total_t0)))

我们来看看训练过程的总结。

import pandas as pd

# Display floats with two decimal places.
pd.set_option('precision', 2)

# Create a DataFrame from our training statistics.
df_stats = pd.DataFrame(data=training_stats)

# Use the 'epoch' as the row index.
df_stats = df_stats.set_index('epoch')

# A hack to force the column headers to wrap.
#df = df.style.set_table_styles([dict(selector="th",props=[('max-width', '70px')])])

# Display the table.
print(df_stats)

请注意,虽然训练损失随着时间的推移在下降,但验证损失却在增加!这说明我们的模型训练时间过长,对训练数据的拟合过度。

作为参考,我们使用的是7695个训练样本和856个验证样本)。

验证损失是一个比准确率更精确的衡量标准,因为对于准确率,我们并不关心准确的输出值,而只是关心它落在阈值的哪一边。

如果我们预测的答案是正确的,但置信度较低,那么验证损失会抓住这一点,而准确性则不会。

# Commented out IPython magic to ensure Python compatibility.
import matplotlib.pyplot as plt
# % matplotlib inline

import seaborn as sns

# Use plot styling from seaborn.
sns.set(style='darkgrid')

# Increase the plot size and font size.
sns.set(font_scale=1.5)
plt.rcParams["figure.figsize"] = (12,6)

# Plot the learning curve.
plt.plot(df_stats['Training Loss'], 'b-o', label="Training")
plt.plot(df_stats['Valid. Loss'], 'g-o', label="Validation")

# Label the plot.
plt.title("Training & Validation Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.legend()
plt.xticks([1, 2, 3, 4])

plt.show()

5. 测试集的性能

现在,我们将加载保持数据集,并准备输入,就像我们对训练集所做的那样。然后我们将使用Matthew's correlation coefficient来评估预测,因为这是广大NLP社区用来评估CoLA性能的度量。通过这个指标,+1是最好的分数,-1是最差的分数。通过这种方式,我们可以看到我们在这个特定任务上与最先进模型的表现。

5.1. 数据准备

我们需要应用所有与训练数据相同的步骤来准备我们的测试数据集。

import pandas as pd

# Load the dataset into a pandas dataframe.
df = pd.read_csv("./cola_public/raw/out_of_domain_dev.tsv", delimiter='\t', header=None, names=['sentence_source', 'label', 'label_notes', 'sentence'])

# Report the number of sentences.
print('Number of test sentences: {:,}\n'.format(df.shape[0]))

# Create sentence and label lists
sentences = df.sentence.values
labels = df.label.values

# Tokenize all of the sentences and map the tokens to thier word IDs.
input_ids = []
attention_masks = []

# For every sentence...
for sent in sentences:
# `encode_plus` will:
# (1) Tokenize the sentence.
# (2) Prepend the `[CLS]` token to the start.
# (3) Append the `[SEP]` token to the end.
# (4) Map tokens to their IDs.
# (5) Pad or truncate the sentence to `max_length`
# (6) Create attention masks for [PAD] tokens.
encoded_dict = tokenizer.encode_plus(
sent, # Sentence to encode.
add_special_tokens = True, # Add '[CLS]' and '[SEP]'
max_length = 64, # Pad & truncate all sentences.
pad_to_max_length = True,
return_attention_mask = True, # Construct attn. masks.
return_tensors = 'pt', # Return pytorch tensors.
)

# Add the encoded sentence to the list.
input_ids.append(encoded_dict['input_ids'])

# And its attention mask (simply differentiates padding from non-padding).
attention_masks.append(encoded_dict['attention_mask'])

# Convert the lists into tensors.
input_ids = torch.cat(input_ids, dim=0)
attention_masks = torch.cat(attention_masks, dim=0)
labels = torch.tensor(labels)

# Set the batch size.
batch_size = 32

# Create the DataLoader.
prediction_data = TensorDataset(input_ids, attention_masks, labels)
prediction_sampler = SequentialSampler(prediction_data)
prediction_dataloader = DataLoader(prediction_data, sampler=prediction_sampler, batch_size=batch_size)

5.2. 测试集上进行评估

准备好了测试集,我们就可以应用我们的微调模型对测试集产生预测。

# Prediction on test set

print('Predicting labels for {:,} test sentences...'.format(len(input_ids)))

# Put model in evaluation mode
model.eval()

# Tracking variables
predictions , true_labels = [], []

# Predict
for batch in prediction_dataloader:
# Add batch to GPU
batch = tuple(t.to(device) for t in batch)

# Unpack the inputs from our dataloader
b_input_ids, b_input_mask, b_labels = batch

# Telling the model not to compute or store gradients, saving memory and
# speeding up prediction
with torch.no_grad():
# Forward pass, calculate logit predictions
outputs = model(b_input_ids, token_type_ids=None,
attention_mask=b_input_mask)

logits = outputs[0]

# Move logits and labels to CPU
logits = logits.detach().cpu().numpy()
label_ids = b_labels.to('cpu').numpy()

# Store predictions and true labels
predictions.append(logits)
true_labels.append(label_ids)

print('DONE.')

使用"Matthews correlation coefficient"来衡量CoLA基准的准确性。(MCC)。

我们在这里用MCC是因为班级不平衡。

print('Positive samples: %d of %d (%.2f%%)' % (df.label.sum(), len(df.label), (df.label.sum() / len(df.label) * 100.0)))

from sklearn.metrics import matthews_corrcoef

matthews_set = []

# Evaluate each test batch using Matthew's correlation coefficient
print('Calculating Matthews Corr. Coef. for each batch...')

# For each input batch...
for i in range(len(true_labels)):
# The predictions for this batch are a 2-column ndarray (one column for "0"
# and one column for "1"). Pick the label with the highest value and turn this
# in to a list of 0s and 1s.
pred_labels_i = np.argmax(predictions[i], axis=1).flatten()

# Calculate and store the coef for this batch.
matthews = matthews_corrcoef(true_labels[i], pred_labels_i)
matthews_set.append(matthews)

最后的分数将基于整个测试集,但我们来看看各个 batch 的分数,以了解各 batch 之间指标的差异性。

每个批次都有 32 个句子,除了最后一个 batch 只有 (516 % 32)=4 个测试句子。

# Create a barplot showing the MCC score for each batch of test samples.
ax = sns.barplot(x=list(range(len(matthews_set))), y=matthews_set, ci=None)

plt.title('MCC Score per Batch')
plt.ylabel('MCC Score (-1 to +1)')
plt.xlabel('Batch #')

plt.show()

现在我们将综合所有批次的结果,计算出我们最终的MCC分数。

# Combine the results across all batches. 
flat_predictions = np.concatenate(predictions, axis=0)

# For each sample, pick the label (0 or 1) with the higher score.
flat_predictions = np.argmax(flat_predictions, axis=1).flatten()

# Combine the correct labels for each batch into a single list.
flat_true_labels = np.concatenate(true_labels, axis=0)

# Calculate the MCC
mcc = matthews_corrcoef(flat_true_labels, flat_predictions)

print('Total MCC: %.3f' % mcc)

酷! 在大约半小时内,在不做任何超参数调整(调整学习率、epochs、批次大小、ADAM属性等)的情况下,我们能够得到一个不错的分数。

*注意:为了最大限度地提高分数,我们应该删除 "验证集"(我们用它来帮助确定要训练多少个epochs),并对整个训练集进行训练。

库中记录了这个基准的预期精度这里49.23

你也可以看看官方的排行榜这里

请注意,(由于数据集规模较小?)运行之间的准确率可能会有很大差异。

结论

本篇文章演示了利用预先训练好的 BERT 模型,无论你对哪个具体的 NLP 任务感兴趣,你都可以使用 pytorch 接口以最小的努力和训练时间快速有效地创建一个高质量的模型。