构建语言模型:一步一步的BERT实现指南

介绍

过去几年中,处理语言的机器学习模型取得了快速的进展。这一进展已经离开了研究实验室,开始为一些领先的数字产品提供动力。一个很好的例子是BERT模型现在是谷歌搜索背后的重要力量。谷歌相信,这一举措(将自然语言理解应用于搜索的进展)代表了“过去五年中的最大跃进,也是搜索历史上最大的跃进之一。”让我们来了解一下BERT是什么?

BERT是指双向编码器的Transformer表示。其设计涉及从未标记的文本中预训练深度双向表示,根据左右上下文进行条件训练。我们可以通过添加一个额外的输出层来增强预训练的BERT模型,以适用于不同的NLP任务。

学习目标

  • 了解BERT的架构和组成部分。
  • 学习BERT输入所需的预处理步骤以及如何处理不同的输入序列长度。
  • 掌握使用流行的机器学习框架(如TensorFlow或PyTorch)实现BERT的实际知识。
  • 学习如何针对特定的下游任务(如文本分类或命名实体识别)微调BERT。

现在另一个问题会出现,为什么我们需要它?让我解释一下。

这篇文章是作为数据科学博客马拉松的一部分发布的。

为什么我们需要BERT?

恰当的语言表示是机器理解一般语言的能力。像word2Vec或Glove这样的无上下文模型为词汇表中的每个单词生成一个单一的词嵌入表示。例如,术语“起重机”在“天空中的起重机”和“用于举起重物的起重机”中具有相同的表示。上下文模型根据句子中的其他单词表示每个单词。因此,BERT是一个双向上下文模型,可以双向捕捉这些关系。

BERT建立在最近的工作和关于预训练上下文表示的巧妙思想上,包括半监督序列学习、生成式预训练、ELMo、OpenAI Transformer、ULMFit和Transformer。尽管这些模型都是单向或浅度双向的,但BERT是完全双向的。

我们可以根据特定目的在我们的数据上训练BERT模型,例如情感分析或问答,以提供高级预测,或者我们可以使用它们从我们的文本数据中提取高质量的语言特征。接下来要问的问题是,“背后发生了什么?”让我们继续了解一下。

背后的核心思想是什么?

为了理解这些思想,我们首先需要了解一些事情,比如:

  • 什么是语言建模?
  • 语言模型试图解决的问题是什么?

让我们通过一个基于上下文的填空来理解这个问题。

一个语言模型(单向方法)将通过说出以下单词来完成这个句子:

  • cart
  • pair

大多数受访者(80%)会选择pair,而20%会选择cart。两者都是合理的,但我应该考虑哪个?使用各种技术选择适当的单词填入空白。

现在BERT登场,一个双向训练的语言模型。这意味着我们对语言上下文有更深入的理解,而不仅仅是单向语言模型。

此外,BERT基于Transformer模型架构,而不是LSTMs。

BERT的架构

BERT(来自Transformer的双向编码器表示)是一种基于Transformer的语言模型架构。它由多层自注意力和前馈神经网络组成。BERT采用双向方法来捕捉句子中前面和后面单词的上下文信息。根据模型架构的规模,BERT有四种预训练版本:

1)BERT-Base(大小写/非大小写):12层,768隐藏节点,12个注意力头,110M参数

2) BERT-Large(大小写敏感/非大小写敏感):24层,1024个隐藏节点,16个注意力头,340M参数

根据您的需求,您可以选择BERT的预训练权重。例如,如果我们无法访问Google TPU,我们将选择基本模型。然后,“大小写敏感”与“非大小写敏感”的选择取决于任务是否需要字母大小写。让我们深入了解。

它是如何工作的?

BERT通过利用无监督预训练后的监督微调来发挥作用。本节将介绍两个方面:文本预处理和预训练任务。

文本预处理

基本Transformer包括用于读取文本输入的编码器和用于生成任务预测的解码器。BERT只需要编码器元素,因为其目标是创建一种语言表示模型。BERT编码器的输入是首先转换为向量的令牌流。然后神经网络对它们进行处理。

首先,每个输入嵌入结合了以下三个嵌入:

令牌、分段和位置嵌入被加在一起形成BERT的输入表示。

  • 令牌嵌入:在第一个句子的开头,添加了一个[CLS]令牌作为输入词令牌,每个句子之后添加一个[SEP]令牌。
  • 分段嵌入:每个令牌都被标记为句子A或句子B。因此,编码器可以知道哪些句子是哪些。
  • 位置嵌入:给每个令牌一个位置嵌入,以显示它在句子中的位置。

预训练任务

BERT已经完成了两个NLP任务:

1. 预测遮蔽语言

语言建模的任务是从一串词中预测下一个词。在遮蔽语言模型中,一些输入令牌会被随机遮蔽,只有那些遮蔽的令牌被预测,而不是它后面的令牌。

  • 令牌[MASK]:此令牌表示另一个令牌缺失。
  • 遮蔽令牌[MASK]并不总是用于替换遮蔽的单词,因为在这种情况下,遮蔽的令牌在微调之前将永远不会显示。因此,对15%的令牌进行随机选择。另外,在选择用于遮蔽的15%令牌中:

2. 下一个句子预测

下一个句子预测任务评估了第二个句子是否真实跟随第一个句子。这是一个二元分类问题。

从任何单语语料库构建这个工作很容易。识别两个句子之间的关系是有益的,因为它在各种下游任务中是必要的,例如问答和自然语言推理。

BERT的实现

实现BERT(双向编码器表示来自Transformers)涉及使用预训练的BERT模型,并在特定任务上进行微调。这包括对文本数据进行标记化、编码序列、定义模型架构、训练模型和评估其性能。BERT的实现提供了强大的语言建模能力,可以用于强大的自然语言处理任务,如文本分类和情感分析。以下是实现BERT的步骤列表:

  • 导入所需的库和数据集
  • 将数据集拆分为训练/测试
  • 导入BERT-基本-非大小写
  • 标记化和编码序列
  • 列表转张量
  • 数据加载器
  • 模型架构
  • 微调
  • 进行预测

让我们从问题陈述开始。

问题陈述

目标是创建一个能够将短信信息分类为垃圾信息或非垃圾信息的系统。该系统旨在通过准确识别和过滤垃圾信息来提高用户体验并防止潜在的安全威胁。任务涉及开发一个能够区分垃圾信息和合法文本的模型,实现对不需要的信息的及时检测和处理。

我们有一些短信信息,这就是问题所在。其中大部分是真实的邮件。然而,其中一些是垃圾信息。我们的目标是创建一个能够立即确定一条文本是否为垃圾信息的系统。数据集链接:()

导入所需库和数据集

导入任务所需的库和数据集。通过加载所需的依赖项并使数据集可用于进一步处理和分析,准备环境。

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import transformers
from transformers import AutoModel, BertTokenizerFast

# 指定GPU
device = torch.device("cuda")

df = pd.read_csv("../input/spamdatatest/spamdata_v2.csv")
df.head()

数据集包含两列 – “label”和“text”。列“text”包含消息正文,“label”是一个二进制变量,其中1表示垃圾信息,0表示非垃圾信息。

# 检查类别分布
df['label'].value_counts(normalize = True)

将数据集分为训练集和测试集

将数据集分为训练集、验证集和测试集。

我们使用像scikit-learn的train_test_split函数这样的库根据给定的参数将数据集分成三部分。

得到的训练集、验证集和测试集分别为train_text、val_text和test_text,并附带各自的标签:train_labels、val_labels和test_labels。这些集合可以用于训练、验证和测试机器学习模型。

通过对假设数据进行模型性能评估,可以评估模型并避免过拟合。

# 将训练数据集划分为训练集、验证集和测试集
train_text, temp_text, train_labels, temp_labels = train_test_split(df['text'], df['label'], 
                                                                    random_state=2018, 
                                                                    test_size=0.3, 
                                                                    stratify=df['label'])


val_text, test_text, val_labels, test_labels = train_test_split(temp_text, temp_labels, 
                                                                random_state=2018, 
                                                                test_size=0.5, 
                                                                stratify=temp_labels)

导入BERT-Base-Uncased

使用Hugging Face Transformers库的AutoModel.from_pretrained()函数导入BERT-base预训练模型。这样用户可以访问BERT架构及其预训练的权重,用于强大的语言处理任务。

还使用BertTokenizerFast.from_pretrained()函数加载了BERT tokenizer。标记器负责将输入文本转换为BERT能理解的标记。’Bert-base-uncased’标记器专门设计用于处理小写文本,并与’Bert-base-uncased’预训练模型对齐。

# 导入BERT-base预训练模型
bert = AutoModel.from_pretrained('bert-base-uncased')

# 加载BERT tokenizer
tokenizer = BertTokenizerFast.from_pretrained('bert-base-uncased')

# 获取训练集中所有消息的长度
seq_len = [len(i.split()) for i in train_text]

pd.Series(seq_len).hist(bins = 30)

对序列进行标记化和编码

BERT如何实现分词?

对于分词,BERT使用了WordPiece。

我们将词汇表初始化为语言中的所有单个字符,然后通过迭代更新现有单词的最常见/最可能的组合来更新词汇表。

为了保持一致性,输入序列长度限制为512个字符。

我们利用BERT的分词器对训练、验证和测试集中的序列进行分词和编码。通过使用tokenizer.batch_encode_plus()函数,文本序列被转换为数值标记。

为了统一序列长度,每个集合的最大长度设为25。当设置pad_to_max_length=True参数时,序列将相应地填充或截断。当启用truncation=True参数时,超过指定最大长度的序列将被截断。

# 在训练集中对序列进行分词和编码
tokens_train = tokenizer.batch_encode_plus(
    train_text.tolist(),
    max_length = 25,
    pad_to_max_length=True,
    truncation=True
)

# 在验证集中对序列进行分词和编码
tokens_val = tokenizer.batch_encode_plus(
    val_text.tolist(),
    max_length = 25,
    pad_to_max_length=True,
    truncation=True
)

# 在测试集中对序列进行分词和编码
tokens_test = tokenizer.batch_encode_plus(
    test_text.tolist(),
    max_length = 25,
    pad_to_max_length=True,
    truncation=True
)

列表转张量

使用PyTorch将分词的序列和对应的标签转换为张量。”torch.tensor()”函数从分词的序列和标签创建张量。

对于每个集合(训练集、验证集和测试集),将分词的输入序列转换为张量使用”torch.tensor(tokens_train[‘input_ids’])”。类似地,将注意力掩码转换为张量使用”torch.tensor(tokens_train[‘attention_mask’])”。利用”torch.tensor(train_labels.tolist())”将标签转换为张量。

将数据转换为张量可以进行高效的计算,并与PyTorch模型兼容,可以使用BERT或其他PyTorch生态系统中的模型进行进一步处理和训练。

将列表转换为张量

train_seq = torch.tensor(tokens_train[‘input_ids’]) train_mask = torch.tensor(tokens_train[‘attention_mask’]) train_y = torch.tensor(train_labels.tolist())

val_seq = torch.tensor(tokens_val[‘input_ids’]) val_mask = torch.tensor(tokens_val[‘attention_mask’]) val_y = torch.tensor(val_labels.tolist())

test_seq = torch.tensor(tokens_test[‘input_ids’]) test_mask = torch.tensor(tokens_test[‘attention_mask’]) test_y = torch.tensor(test_labels.tolist())

数据加载器

使用PyTorch的TensorDataset、DataLoader、RandomSampler和SequentialSampler类创建数据加载器。TensorDataset类将输入序列、注意力掩码和标签封装为单个数据集对象。

我们使用RandomSampler对训练集进行随机采样,确保在训练过程中具有多样的数据表示。相反,我们在验证集中使用SequentialSampler按顺序测试数据。

为了在训练和验证过程中实现高效的迭代和批处理数据,我们使用DataLoader。该工具可以创建具有指定批大小的数据集迭代器,简化了整个过程。

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

# 定义批大小
batch_size = 32

# 封装张量
train_data = TensorDataset(train_seq, train_mask, train_y)

# 用于训练集的采样器
train_sampler = RandomSampler(train_data)

# 训练集的数据加载器
train_dataloader = DataLoader(train_data, sampler=train_sampler, batch_size=batch_size)

# 封装张量
val_data = TensorDataset(val_seq, val_mask, val_y)

# 用于验证集的采样器
val_sampler = SequentialSampler(val_data)

# 验证集的数据加载器
val_dataloader = DataLoader(val_data, sampler = val_sampler, batch_size=batch_size)

模型架构

BERT_Arch类继承自nn.Module类,并将BERT模型作为参数进行初始化。通过设置BERT模型的参数不需要梯度(param.requires_grad = False),我们确保在训练过程中只有添加的层的参数被训练。这种技术允许我们利用预训练的BERT模型进行迁移学习,并将其适应到特定的任务。

# 冻结所有参数
对于参数 in bert.parameters():
    参数.requires_grad = False

该架构包括一个dropout层、一个ReLU激活函数、两个全连接层(分别为768和512个单元)和一个softmax激活函数。前向方法接受句子ID和掩码作为输入,通过BERT模型将它们传递到分类令牌(cls_hs)输出,然后应用定义的层和激活函数产生最终的分类概率。

class BERT_Arch(nn.Module):

    def __init__(self, bert):
        super(BERT_Arch, self).__init__()
        
        self.bert = bert 
        
        # dropout层
        self.dropout = nn.Dropout(0.1)
      
        # relu激活函数
        self.relu =  nn.ReLU()

        # 全连接层1
        self.fc1 = nn.Linear(768,512)
      
        # 全连接层2(输出层)
        self.fc2 = nn.Linear(512,2)

        # softmax激活函数
        self.softmax = nn.LogSoftmax(dim=1)

    #定义前向传播
    def forward(self, sent_id, mask):
        
        #将输入传递给模型
        _, cls_hs = self.bert(sent_id, attention_mask=mask, return_dict=False)
      
        x = self.fc1(cls_hs)

        x = self.relu(x)

        x = self.dropout(x)

        # 输出层
        x = self.fc2(x)
      
        # 应用softmax激活
        x = self.softmax(x)

        return x

要使用BERT模型作为参数初始化BERT_Arch类的实例,我们将预训练的BERT模型传递给已定义的架构BERT_Arch。这将确立BERT模型作为自定义架构的骨干。

GPU加速

通过调用to()方法将模型移动到GPU,并指定所需的设备(device)以利用GPU加速。这样可以利用GPU的并行处理能力,在训练和推理过程中进行更快的计算。

# 将预训练的BERT传递给我们定义的架构
model = BERT_Arch(bert)

# 将模型推送到GPU
model = model.to(device)

通过从Hugging Face库中导入Transformers库来导入AdamW优化器。AdamW是Adam优化器的一种变体,包括权重衰减正则化。

然后,通过将模型参数(model.parameters())和学习率(lr)设定为1e-5,来定义优化器。该优化器将在训练过程中更新模型参数,优化模型在手头任务上的性能。

# 从Hugging Face的transformers库中导入优化器AdamW

# 定义优化器 optimizer = AdamW(model.parameters(),lr = 1e-5)

使用sklearn.utils.class_weight模块中的compute_class_weight函数来计算训练标签的类别权重。

from sklearn.utils.class_weight import compute_class_weight

# 计算类别权重 class_weights = compute_class_weight(‘balanced’, np.unique(train_labels), train_labels)

print(“类别权重:”,class_weights)

将类别权重转换为张量,并将其移动到GPU,并使用加权类别权重定义损失函数。将训练轮数设置为10。

# 将类别权重列表转换为张量
weights= torch.tensor(class_weights,dtype=torch.float)

# 推送到GPU
weights = weights.to(device)

# 定义损失函数
cross_entropy  = nn.NLLLoss(weight=weights) 

# 训练轮数
epochs = 10

微调

一个训练函数,它遍历数据批次,执行前向和后向传播,更新模型参数并计算训练损失。该函数还存储模型预测结果并返回平均损失和预测结果。

# 训练模型的函数
def train():
    
    model.train()
    total_loss, total_accuracy = 0, 0
  
    # 空列表保存模型预测结果
    total_preds=[]
  
    # 遍历批次
    for step,batch in enumerate(train_dataloader):
        
        # 每50个批次后更新进度
        if step % 50 == 0 and not step == 0:
            print('  批次 {:>5,}  总批次数 {:>5,}.'.format(step, len(train_dataloader)))
        
        # 推送批次到GPU
        batch = [r.to(device) for r in batch]
 
        sent_id, mask, labels = batch
        
        # 清除之前计算的梯度 
        model.zero_grad()        

        # 获取当前批次的模型预测结果
        preds = model(sent_id, mask)

        # 计算实际值和预测值之间的损失
        loss = cross_entropy(preds, labels)

        # 添加到总损失
        total_loss = total_loss + loss.item()

        # 后向传播计算梯度
        loss.backward()

        # 将梯度裁剪为1.0,有助于防止梯度爆炸问题
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # 更新参数
        optimizer.step()

        # 模型预测结果存储在GPU上,因此将其推送到CPU上
        preds=preds.detach().cpu().numpy()

    # 添加模型预测结果
    total_preds.append(preds)

    # 计算该轮的训练损失
    avg_loss = total_loss / len(train_dataloader)
  
      # 预测结果的形状为(批次数、批次大小、类别数)。
      # 将预测结果重新调整为(样本数、类别数)的形式
    total_preds  = np.concatenate(total_preds, axis=0)

    # 返回损失和预测结果
    return avg_loss, total_preds

在验证数据上评估模型的评估函数。它计算验证损失,存储模型预测结果,并返回平均损失和预测结果。该函数使用torch.no_grad()停用了dropout层,并进行了不计算梯度的前向传递。

# 评估模型的函数
def evaluate():
    
    print("\n评估中...")
  
    # 停用dropout层
    model.eval()

    total_loss, total_accuracy = 0, 0
    
    # 保存模型预测结果的空列表
    total_preds = []

    # 遍历批次
    for step, batch in enumerate(val_dataloader):
        
        # 每50个批次更新一次进度
        if step % 50 == 0 and not step == 0:
            
            # 计算经过的时间(分钟)
            elapsed = format_time(time.time() - t0)
            
            # 报告进度
            print('  批次 {:>5,}  /  {:>5,}.'.format(step, len(val_dataloader)))

        # 将批次推送到GPU上
        batch = [t.to(device) for t in batch]

        sent_id, mask, labels = batch

        # 不计算自动微分
        with torch.no_grad():
            
            # 模型预测结果
            preds = model(sent_id, mask)

            # 计算实际值和预测值之间的验证损失
            loss = cross_entropy(preds, labels)

            total_loss = total_loss + loss.item()

            preds = preds.detach().cpu().numpy()

            total_preds.append(preds)

    # 计算该轮次的验证损失
    avg_loss = total_loss / len(val_dataloader) 

    # 将预测结果重新整形为(样本数量,类别数量)的形式
    total_preds  = np.concatenate(total_preds, axis=0)

    return avg_loss, total_preds

训练模型

为指定的轮次训练模型。它跟踪最佳验证损失,如果当前验证损失更好,则保存模型权重,并将训练损失和验证损失附加到各自的列表中。每个轮次打印训练损失和验证损失。

# 将初始损失设置为无穷大
best_valid_loss = float('inf')

# 定义轮次
epochs = 1

# 保存每个轮次的训练和验证损失的空列表
train_losses=[]
valid_losses=[]

# 对于每个轮次
for epoch in range(epochs):
     
    print('\n 第 {:} / {:} 轮'.format(epoch + 1, epochs))
    
    # 训练模型
    train_loss, _ = train()
    
    # 评估模型
    valid_loss, _ = evaluate()
    
    # 保存最佳模型
    if valid_loss < best_valid_loss:
        best_valid_loss = valid_loss
        torch.save(model.state_dict(), 'saved_weights.pt')
    
    # 添加训练和验证损失
    train_losses.append(train_loss)
    valid_losses.append(valid_loss)
    
    print(f'\n训练损失: {train_loss:.3f}')
    print(f'验证损失: {valid_loss:.3f}')

使用torch.load()从保存的文件‘saved_weights.pt’加载最佳模型权重,并使用model.load_state_dict()将其设置到模型中。

# 加载最佳模型权重路径为 ‘saved_weights.pt’ model.load_state_dict(torch.load(path))

进行预测

使用训练好的模型对测试数据进行预测,并将预测结果转换为NumPy数组。我们使用scikit-learn的metrics模块中的classification_report函数计算分类指标,包括精确度、召回率和F1分数,以评估模型的性能。

# 对测试数据进行预测
with torch.no_grad():
    preds = model(test_seq.to(device), test_mask.to(device))
    preds = preds.detach().cpu().numpy()

# 模型的性能
preds = np.argmax(preds, axis=1)
print(classification_report(test_y, preds))

结论

总之,BERT毫无疑问是在使用机器学习进行自然语言处理方面的一项突破。它的可操作性和快速微调的特点将很可能在未来实现广泛的实际应用。这个一步一步的BERT实现教程使用户能够构建强大的语言模型,能够准确地理解和生成自然语言。

以下是关于BERT的一些关键点:

  • BERT的成功:BERT通过捕捉深度上下文化表示,彻底改变了自然语言处理领域,从而在各种NLP任务中实现了显著的性能提升。
  • 面向所有人的可访问性:本教程旨在使BERT的实现对各种用户都具有可访问性,无论其专业水平如何。通过按照逐步指南操作,任何人都可以利用BERT的力量构建复杂的语言模型。
  • 实际应用:BERT的多功能性使其能够应用于跨行业的实际问题,包括客户情感分析、聊天机器人、推荐系统等。它的实现可以为企业和研究人员带来实际的利益和见解。

常见问题

本文中显示的媒体不归Analytics Vidhya所有,而是根据作者的决定使用。