[学习笔记]神经网络之二:使用Bert进行二分类

本篇记录一下如何使用bert进行二分类。这里用到的库是pyotrch-pretrained-bert,原生的bert使用的是TensorFlow,这个则是pytorch版本。

本篇文章主要参考了基于BERT fine-tuning的中文标题分类实战的代码以及如何用 Python 和 BERT 做中文文本二元分类?的数据。

本文的github代码地址:https://github.com/sky94520/binary-classification

1.样本数据

首先是要训练的文本数据,部分文本内容如下:

图1 部分文本数据

该文本是来自于知乎: 如何用 Python 和 BERT 做中文文本二元分类?的数据源,该作者使用BERT进行二分类达到了88%左右的准确率。

数据链接,原作者把数据pickle序列化,我个人在使用的时候是反序列化到了一个excel文件,并且分成了两个工作表,分别是train工作表和test工作表,代码如下:

import pandas as pd


df = pd.read_pickle('dianping_train_test.pickle')
writer = pd.ExcelWriter(dianping_train_test.xls')
df[0].to_excel(writer, 'train')
df[1].to_excel(writer, 'test')
writer.close()

这一步的目的是为了查看文本内容,训练样本1600,测试样本400。

2.网络结构

首先导入包:

import time
import pandas as pd
import numpy as np
import torch
from torch import nn
from sklearn.metrics import classification_report
from concurrent.futures import ThreadPoolExecutor
from torch.utils.data import TensorDataset, DataLoader
from pytorch_pretrained_bert import BertTokenizer, BertModel
from pytorch_pretrained_bert.optimization import BertAdam

pyotrch-pretrained-bert中提供了一些预定义的网络模型,其中就有用于分类的模型,导入语句如下:

from pytorch_pretrained_bert import BertForSequenceClassification

本文并未使用到这个类,而是参照着该类写了一个ClassifyModel类:

class ClassifyModel(nn.Module):
    def __init__(self, pretrained_model_name_or_path, num_labels, is_lock=False):
        super(ClassifyModel, self).__init__()
        self.bert = BertModel.from_pretrained(pretrained_model_name_or_path)
        self.classifier = nn.Linear(768, num_labels)
        if is_lock:
            # 加载并冻结bert模型参数
            for name, param in self.bert.named_parameters():
                if name.startswith('pooler'):
                    continue
                else:
                    param.requires_grad_(False)

    def forward(self, input_ids, token_type_ids=None, attention_mask=None):
        _, pooled = self.bert(input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
        logits = self.classifier(pooled)
        return logits

ClassifyModel类比BertForSequenceClassification类多出来的功能是可以冻结Bert预训练模型的参数,从而加快训练速度、减少占用资源、提升训练效果

关于冻结参数,目前我参考的是9012年,该用bert打比赛了这篇帖子,不过我对是否冻结所有的bert参数抱有疑问,因为冻结所有参数后的效果并不好,基于这个问题,我稍微查看了下pytorch的部分源码:

class BertModel(BertPreTrainedModel):
    def __init__(self, config):
        super(BertModel, self).__init__(config)
        self.embeddings = BertEmbeddings(config)
        self.encoder = BertEncoder(config)
        self.pooler = BertPooler(config)
        self.apply(self.init_bert_weights)

    def forward(self, input_ids, token_type_ids=None, attention_mask=None, output_all_encoded_layers=True):
        if attention_mask is None:
            attention_mask = torch.ones_like(input_ids)
        if token_type_ids is None:
            token_type_ids = torch.zeros_like(input_ids)

        extended_attention_mask = extended_attention_mask.to(dtype=next(self.parameters()).dtype) # fp16 compatibility
        extended_attention_mask = (1.0 - extended_attention_mask) * -10000.0

        embedding_output = self.embeddings(input_ids, token_type_ids)
        encoded_layers = self.encoder(embedding_output,
                                      extended_attention_mask,
                                      output_all_encoded_layers=output_all_encoded_layers)
        sequence_output = encoded_layers[-1]
        pooled_output = self.pooler(sequence_output)
        if not output_all_encoded_layers:
            encoded_layers = encoded_layers[-1]
        return encoded_layers, pooled_output

在__init__ 函数中,BertModel类定义了embedding、encoder和pooler,顾名思义,embedding为嵌入层,encoder为编码层,pooler则是一个全连接层。

在forward函数中,如果output_all_encoded_layers=True,那么encoded_layer就是12层transformer的结果,否则只返回最后一层transformer的结果,pooled_output的输出就是最后一层Transformer经过self.pooler层后得到的结果。

那么接下来我们看一看这个BertPooler的定义:

class BertPooler(nn.Module):
    def __init__(self, config):
        super(BertPooler, self).__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        self.activation = nn.Tanh()

    def forward(self, hidden_states):
        # We "pool" the model by simply taking the hidden state corresponding
        # to the first token.
        first_token_tensor = hidden_states[:, 0]
        pooled_output = self.dense(first_token_tensor)
        pooled_output = self.activation(pooled_output)
        return pooled_output

 从__init__函数中可以了解到,BertPooler是一个输入为768,输出为768、激活函数是tanh的全连接层;forward函数则表明,该层传入的是[CLS]的字向量,Bert论文中提到:“每个序列的第一个标记始终是特殊分类嵌入([CLS])。该特殊标记对应的最终隐藏状态(即, Transformer 的输出)被用作分类任务中该序列的总表示”,可以理解为[CLS]代表的向量包含了这个句子的含义。

然后我就进行了不精确测试,参数如下:batch_size=32 max_seq_len=200 learning_rate=5e-2 epochs=4

第一次是把Bert的所有参数全部冻结,即:

# 加载并冻结bert模型参数
for param in self.bert.parameters():
    param.requires_grad_(False)

forward代码:

    def forward(self, input_ids, token_type_ids=None, attention_mask=None):
        encoded_layers, pooled = self.bert(input_ids, token_type_ids, attention_mask, output_all_encoded_layers=False)
        logits = self.classifier(pooled)
        return logits

 得到的结果如下:

图2 第一次测试

 综合几次,基本上精确率在80%左右

 第二次是把Bert的pooler层以外的所有参数都冻结:

            # 加载并冻结bert模型参数
            for name, param in self.bert.named_parameters():
                if name.startswith('pooler'):
                    continue
                else:
                    param.requires_grad_(False)

forward函数不变,效果如下:

图3 第二次测试

 综合几次,精确率在85%-86%之间

得到的结论如下:不冻结Bert的pooler权重,冻结其他层,训练结果提升,训练时间减少,内存占用减少

由于这是一个二分类,而pooler out输出的维度是768维,因此还需要在Bert的pooler out输出后加个输入为768,输出为2的全连接层。微调则主要微调的新加入的这个全连接层的权重链接矩阵。

3.数据格式转换

对于Bert来说,我们的输入表示能够在一个标记序列中清楚地表征单个或一对文本句子,BERT会把给定序列对应的标记嵌入、句子嵌入和位置嵌入求和来构造其输入表示,

图5 输入的可视化表示

 

bert要求数据输入有着特定的格式,并且序列的最大长度为512,这里设最大长度为max_seq_len(由于硬件限制或者是文本长度,往往不需要设置到512):

  1. 首先,需要使用bert的分字工具(BertTokenizer)对输入文本进行分字,然后把字ID化,并且在两边分别加上[CLS]和[SEP],此为第一个输入seq;
  2. 接着,对于那些小于max_seq_len的文本,需要以0进行填充,而为了表征seq中的符号是有意义的,所以还需要一个mask,对于那些大于max_seq_len的文本,需要进行截断,此为第二个输入seq_mask;
  3. 最后,还有一个segment,段的概念,由于这里是单句,所以这个值为0,此为第三个输入segment。

第一步和第二步综合得到了Token embedding ;第三步得到了segment embedding,第一步的文本顺序则可以得到Position Embedding。

class DataProcessForSingleSentence(object):
    def __init__(self, bert_tokenizer, max_workers=10):
        """
        :param bert_tokenizer: 分词器
        :param max_workers:  包含列名comment和sentiment的data frame
        """
        self.bert_tokenizer = bert_tokenizer
        self.pool = ThreadPoolExecutor(max_workers=max_workers)

    def get_input(self, dataset, max_seq_len=30):
        sentences = dataset.iloc[:, 1].tolist()
        labels = dataset.iloc[:, 2].tolist()
        # 切词
        token_seq = list(self.pool.map(self.bert_tokenizer.tokenize, sentences))
        # 获取定长序列及其mask
        result = list(self.pool.map(self.trunate_and_pad, token_seq,
                                    [max_seq_len] * len(token_seq)))
        seqs = [i[0] for i in result]
        seq_masks = [i[1] for i in result]
        seq_segments = [i[2] for i in result]

        t_seqs = torch.tensor(seqs, dtype=torch.long)
        t_seq_masks = torch.tensor(seq_masks, dtype=torch.long)
        t_seq_segments = torch.tensor(seq_segments, dtype=torch.long)
        t_labels = torch.tensor(labels, dtype=torch.long)

        return TensorDataset(t_seqs, t_seq_masks, t_seq_segments, t_labels)

    def trunate_and_pad(self, seq, max_seq_len):
        # 对超长序列进行截断
        if len(seq) > (max_seq_len - 2):
            seq = seq[0: (max_seq_len - 2)]
            # 添加特殊字符
        seq = ['[CLS]'] + seq + ['[SEP]']
        # id化
        seq = self.bert_tokenizer.convert_tokens_to_ids(seq)
        # 根据max_seq_len与seq的长度产生填充序列
        padding = [0] * (max_seq_len - len(seq))
        # 创建seq_mask
        seq_mask = [1] * len(seq) + padding
        # 创建seq_segment
        seq_segment = [0] * len(seq) + padding
        # 对seq拼接填充序列
        seq += padding
        assert len(seq) == max_seq_len
        assert len(seq_mask) == max_seq_len
        assert len(seq_segment) == max_seq_len
        return seq, seq_mask, seq_segment

4.数据读取

在有了DataProcessForSingleSentence类之后,接着就要把训练数据和测试数据加载进来:

def load_data(filepath, pretrained_model_name_or_path, max_seq_len, batch_size):
    """
    加载excel文件,有train和test 的sheet
    :param filepath: 文件路径
    :param pretrained_model_name_or_path: 使用什么样的bert模型
    :param max_seq_len: bert最大尺寸,不能超过512
    :param batch_size: 小批量训练的数据
    :return: 返回训练和测试数据迭代器 DataLoader形式
    """
    io = pd.io.excel.ExcelFile(filepath)
    raw_train_data = pd.read_excel(io, sheet_name='train')
    raw_test_data = pd.read_excel(io, sheet_name='test')
    io.close()
    # 分词工具
    bert_tokenizer = BertTokenizer.from_pretrained(pretrained_model_name_or_path, do_lower_case=True)
    processor = DataProcessForSingleSentence(bert_tokenizer=bert_tokenizer)
    # 产生输入句 数据
    train_data = processor.get_input(raw_train_data, max_seq_len)
    test_data = processor.get_input(raw_test_data, max_seq_len)

    train_iter = DataLoader(dataset=train_data, batch_size=batch_size, shuffle=True)
    test_iter = DataLoader(dataset=test_data, batch_size=batch_size, shuffle=True)
    return train_iter, test_iter

这里使用pytorch的DataLoader类,来进行批量样本训练。

5.评估

每经过一代,都可以对训练结果进行测试:

def evaluate_accuracy(data_iter, net, device):
    # 记录预测标签和真实标签
    prediction_labels, true_labels = [], []
    with torch.no_grad():
        for batch_data in data_iter:
            batch_data = tuple(t.to(device) for t in batch_data)
            # 获取给定的输出和模型给的输出
            labels = batch_data[-1]
            output = net(*batch_data[:-1])
            predictions = output.softmax(dim=1).argmax(dim=1)
            prediction_labels.append(predictions.detach().cpu().numpy())
            true_labels.append(labels.detach().cpu().numpy())

    return classification_report(np.concatenate(true_labels), np.concatenate(prediction_labels))

测试结果使用sklearn包的classification_report函数,该函数会返回精确度,准确率、召回率和F1。

6.训练

一切都准备好之后,就可以对模型进行训练了:

if __name__ == '__main__':
    batch_size, max_seq_len = 32, 200
    train_iter, test_iter = load_data('dianping_train_test.xls', 'bert-base-chinese', max_seq_len, batch_size)
    # 加载模型
    # model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=2)
    model = ClassifyModel('bert-base-chinese', num_labels=2, is_lock=True)
    print(model)

    optimizer = BertAdam(model.parameters(), lr=5e-05)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    loss_func = nn.CrossEntropyLoss()

    for epoch in range(4):
        start = time.time()
        model.train()
        # loss和精确度
        train_loss_sum, train_acc_sum, n = 0.0, 0.0, 0
        for step, batch_data in enumerate(train_iter):
            batch_data = tuple(t.to(device) for t in batch_data)
            batch_seqs, batch_seq_masks, batch_seq_segments, batch_labels = batch_data

            logits = model(batch_seqs, batch_seq_masks, batch_seq_segments)
            logits = logits.softmax(dim=1)
            loss = loss_func(logits, batch_labels)
            loss.backward()
            train_loss_sum += loss.item()
            train_acc_sum += (logits.argmax(dim=1) == batch_labels).sum().item()
            n += batch_labels.shape[0]
            optimizer.step()
            optimizer.zero_grad()
        # 每一代都判断
        model.eval()

        result = evaluate_accuracy(test_iter, model, device)
        print('epoch %d, loss %.4f, train acc %.3f, time: %.3f' %
              (epoch + 1, train_loss_sum / n, train_acc_sum / n, (time.time() - start)))
        print(result)

    torch.save(model, 'fine_tuned_chinese_bert.bin')

BERT可以使用cpu进行微调,但是速度比较慢因此这里首先会判断GPU是否可用,可用的话device="cuda",否则device="cpu"。然后需要把模型和tensor全都转到device上进行计算。

存储在不同位置中的数据是不可以直接进行计算的,如果对在GPU上的数据进⾏行行运算,那么结果还是存放在GPU上,所以在evaluate_accuracy函数中需要把预测结果调用cpu()。

bert论文中指出:

图6 官方译文摘选

 因此这里我选定了batch_size=32 learngin_rate=5e-5 epochs=4 max_seq_len=200进行训练。

batch_size和max_seq_len的选取和硬件有着很大的关系,比如我的电脑显卡为gtx 1660ti 6G,由于我这里冻结了bert的参数,所以可以设置batch_szie=32 max_seq_len=200,不然batch_size和max_seq_len都要进行几乎一半的缩小。

贴上我的第四代训练的结果:

图7 第二次测试第四代结果

 参考:

  • 23
    点赞
  • 65
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值