自然语言处理进阶手册--置业问答分类器

置业问答分类器

参数设计及数据预处理

此实验中的数据为置业顾问与潜在顾客间的问答,首先熟悉新闻数据,格式为 “问句\t回答\t标签”,如下例所示:
首付多少 首套房 1
首付多少 首付办完 63万左右 1
首付多少 包含全部 0
价格最低多少卖? 没有说,但是估计少不到多少 1
价格最低多少卖? 3楼房子卖了 0
采光怎么样呢? 今天有时间吗 0
采光怎么样呢? 我拿好钥匙可以直接现场看的 0
采光怎么样呢? 好的 就考虑义堂这块小三室吗 0

此任务目标在于判断输入的一问一答是否是真正的问答对,1 表示是,0 表示否。由于 Bert 模型在预训练过程中本身会输入两个句子,判断是否为上下文关系,与当前任务目标一致,因此本项目可直接应用 Bert 预训练模型来进行微调。
由于本项目涉及到的参数较多,这里将所有参数整合到类 Config 中,主要涉及数据及模型相关的参数:

import torch
!pip install pytorch_transformers
from pytorch_transformers import BertModel, BertTokenizer


class Config(object):
    """配置参数"""

    def __init__(self, dataset):  # dataset指数据所在的文件名
        self.model_name = 'bert'
        self.train_path = dataset + '/train.txt'                                # 训练集
        self.dev_path = dataset + '/valid.txt'                                  # 验证集
        self.test_path = dataset + '/test.txt'                                  # 测试集
        self.save_path = dataset + self.model_name + '.pkl'        # 模型训练结果存储路径
        self.device = torch.device(
            'cuda' if torch.cuda.is_available() else 'cpu')   # 设备

        # 若超过 1000 batch 效果还没提升,则提前结束训练
        self.require_improvement = 1000
        self.class_list = [x.strip() for x in open(
            dataset + '/class.txt').readlines()]                       # 类别名单
        self.num_classes = len(self.class_list)                        # 类别数
        self.n_epochs = 1                                              # epoch 数
        self.batch_size = 38                                            # 一个 batch 的大小
        # 每句话处理成的长度(短填长切)
        self.pad_size = 20
        self.learning_rate = 5e-5                                       # 学习率
        self.bert_path = 'bert_wwm_pretrain'                            # 中文 Bert 预训练模型路径
        self.tokenizer = BertTokenizer.from_pretrained(
            self.bert_path)  # 预训练模型的 tokenizer
        self.hidden_size = 768      # 隐层大小

初始化参数类:

# 下载并解压数据集
!wget -nc "https://labfile.oss.aliyuncs.com/courses/3382/BeiKeData.zip"
!unzip "BeiKeData.zip"
# 下载并解压预训练模型
!wget -nc "https://labfile.oss.aliyuncs.com/courses/3382/bert_wwm_pretrain.zip"
!unzip "bert_wwm_pretrain.zip"
# 初始化参数类
config = Config("BeiKeData")

接下来为 Bert 模型准备输入数据,首先需要设置一些特殊符号(这是 Bert 在预训练过程中数据本身带有的符号):

# 分别为 padding 符号, bert 中分类标志符号以及句子隔开符号
PAD, CLS, SEP = '[PAD]', '[CLS]', '[SEP]'

对于输入 Bert 的一对数据(一问一答)而言,需要转化为三种输入数据,首先假设数据对为:
问句 1:首付多少
答句 2:首付办完63万左右

三大输入数据:
input_ids(文本 id 向量):添加标志符号,分词,并且截长补短(假设每个句子 pad_size = 5):‘[CLS] 首付 多少 [PAD] [SEP] 首付 办完 63 万 左右 [PAD] [SEP]’,接着将文本中的单词转化为对应的 id。
attention_mask(文本 mask 向量):由于 bert 模型会对 padding 的地方不进行 self-attention 处理,因此模型在输入中也要求需要输入一个相关向量,其表现形式为,mask 的地方对应为 0,其它地方为 1。当前例子对应的向量为,[1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0]。
token_type_ids(文本类型向量):生成句子类型相关的 id,第一个句子中的单词对应的 id 为 0(包括第一个 [SEP]),第二个的为 1。当前例子对应的向量为,[0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]。

为了实现以上转换,首先设计函数 sen2id(),能够将一个句子转换为 id,截长补短,并且对 padding 的地方进行 mask 处理,输出 token_ids, mask:

def sen2id(sen):
    token = config.tokenizer.tokenize(sen)  # 分词
    token_ids = config.tokenizer.convert_tokens_to_ids(token)  # 转换为 id
    mask = []
    if config.pad_size:  # 截长补短
        if len(token) < config.pad_size:
            token_ids += ([0] * (config.pad_size - len(token)))
            mask = [1] * len(token) + [0] * (config.pad_size -
                                             len(token))  # mask 的地方对应为 0,其它地方为 1
        else:
            token_ids = token_ids[:config.pad_size]
            mask = [1] * config.pad_size
    return token_ids, mask

下一步整合两个句子的 token_ids, mask 作为 bert 的最终输入:

def id2final_input(sen1_token_ids, sen2_token_ids, sen1_mask, sen2_mask):
    # 结合两个句子、三个标志符对应的 id
    input_ids = config.tokenizer.convert_tokens_to_ids(config.tokenizer.tokenize(CLS)) + sen1_token_ids + \
        config.tokenizer.convert_tokens_to_ids(config.tokenizer.tokenize(SEP)) + sen2_token_ids + \
        config.tokenizer.convert_tokens_to_ids(config.tokenizer.tokenize(SEP))
    token_type_ids = [0] * (config.pad_size + 2) + [1] * \
        (config.pad_size + 1)  # 句子类型对应的 向量
    attention_mask = [1] + sen1_mask + [1] + \
        sen2_mask + [1]  # 结合两个句子、三个标志符对应的 mask

    return input_ids, token_type_ids, attention_mask

综合以上处理过程加载训练集、验证集及测试集:

from tqdm import tqdm  # 可视化加载进程


def load_dataset(path):
    contents = []  # 用于保存所有数据
    with open(path, 'r', encoding='UTF-8') as f:
        for line in tqdm(f):
            line = line.strip()
            if not line:  # 空行不作处理
                continue
            # 句子形式为 "句 1 \t 句 2 \t label",所以用 \t 进行切分
            sen1, sen2, label = line.split('\t')
            # 分别获取每个句子的 token_ids, mask
            sen1_token_ids, sen1_mask = sen2id(sen1)
            sen2_token_ids, sen2_mask = sen2id(sen2)
            # 综合输出
            input_ids, token_type_ids, attention_mask = id2final_input(
                sen1_token_ids, sen2_token_ids, sen1_mask, sen2_mask)
            contents.append(
                (input_ids, int(label), token_type_ids, attention_mask))
    return contents

加载数据:

# 加载训练、验证、测试集
traindata = load_dataset(config.train_path)
validdata = load_dataset(config.dev_path)
testdata = load_dataset(config.test_path)

一条数据最终的输入形式如下:

for i in traindata[0]:  # 四元组(文本分词后的 id,标签,文本长度(不包括 padding ),mask)
    print(i)

下一步为设计数据迭代器,批次化地输出数据,注意 _to_tensor() 涉及的输入变量有三大类:

import math

# 数据迭代器


class DatasetIterator:
    def __init__(self, dataset, config):
        self.batch_size = config.batch_size  # 一批数据量的大小
        self.device = config.device
        self.dataset = dataset
        self.n_batches = math.floor(
            len(dataset)/config.batch_size)  # 向下取整的批次大小
        self.num = len(dataset)  # 数据量大小
        self.residue = True  # 默认不可以取整
        if len(dataset) % self.batch_size == 0:  # 所有数据量是否能被批数据量整除
            self.residue = False
        self.index = 0

    def _to_tensor(self, datas):  # 将数据转换为 tensor,并且 copy 一份到 device 所指定的环境上
        x = torch.LongTensor([_[0] for _ in datas]).to(self.device)  # 文本 id 向量
        y = torch.LongTensor([_[1] for _ in datas]).to(self.device)  # 标签
        type_ids = torch.LongTensor(
            [_[2] for _ in datas]).to(self.device)  # 文本类型向量
        mask = torch.LongTensor([_[3] for _ in datas]).to(
            self.device)  # 文本 mask 向量
        return (x, type_ids, mask), y

    def __next__(self):  # 返回迭代器的下一个元素
        if self.residue and self.index == self.n_batches:  # 在不能取整的情况下,对于最后一批数据,需要额外分为一种情况
            batch_data = self.dataset[self.index *
                                      self.batch_size:len(self.dataset)]
            self.index += 1
            batch_data = self._to_tensor(batch_data)
            return batch_data  # 返回一个 batch 的数据
        elif self.index >= self.n_batches:  # 当 index 超出范围时,停止迭代
            self.index = 0
            raise StopIteration
        else:  # 其它情况
            batch_data = self.dataset[self.index *
                                      self.batch_size:(self.index+1)*self.batch_size]
            self.index += 1
            batch_data = self._to_tensor(batch_data)
            return batch_data

    def __iter__(self):
        return self

    def __len__(self):  # 迭代器长度
        if self.residue:  # 如果不能取整,迭代器中的元素为向下取整的值 +1
            return self.n_batches + 1
        else:
            return self.n_batches

将三个数据集放入数据迭代器:

# 数据迭代器
train_iter = DatasetIterator(traindata, config)
valid_iter = DatasetIterator(validdata, config)
test_iter = DatasetIterator(testdata, config)

Bert 中文预训练模型

基于预训练中文模型 Bert,在其后加全连接层,映射至二维输出,设计一个问答二分类模型。

import torch.nn as nn

# 模型


class WWMBert(nn.Module):
    def __init__(self, config):
        super(WWMBert, self).__init__()
        self.bert = BertModel.from_pretrained(
            config.bert_path)  # 获取 Bert 预训练模型
        for param in self.bert.parameters():  # Bert 预训练模型中的参数随着训练进行微调
            param.requires_grad = True
        self.fc = nn.Linear(config.hidden_size, config.num_classes)  # 最后添加全边接层

    def forward(self, x):
        context = x[0]  # 文本 id 向量
        type_ids = x[1]  # 句子类型向量
        mask = x[2]  # 文本 mask 向量
        _, pooled = self.bert(
            context, token_type_ids=type_ids, attention_mask=mask)
        out = self.fc(pooled)  # 最后输出
        return out

可知,定义模型的方式与其它实验中的方法无二致,基于 BertModel.from_pretrained(config.bert_path) 便可以引入 Bert 预训练模型,事实上直接可以把它当作你的模型中的一个比较巨大的 layer 而已。在其后再添加什么样的操作,只需要增加不同的 layer 即可,比如 Linear, LSTM, CNN 等,均可尝试。
查看模型:

# 模型初始化
bert_model = WWMBert(config).to(config.device)
bert_model

可知,Bert 模型由 BertEmbeddings, BertEncoder(12 层 BertLayer), BertOutput 以及 BertPooler 构成。其中的主要成分 BertLayer 便是基于自注意力机制的结构。

训练及测试

最后一部分主要涉及模型的训练及测试,训练过程与其它一般项目类似。特别地,对模型中的参数进行正则化,除却偏置参数。

!pip install pytorch_pretrained_bert
from pytorch_pretrained_bert.optimization import BertAdam

criterion = nn.CrossEntropyLoss()


def train(model, data_iter, config):
    model.train()
    train_loss = 0
    # 设置被正则化的参数
    param_optimizer = list(model.named_parameters())
    # weight decay 是放在正则项(regularization)前面的一个系数,用于调节正则化的强度
    no_decay = ['bias', 'LayerNorm.bias', 'LayerNorm.weight']  # 此类参数不进行正则化
    optimizer_grouped_parameters = [
        {'params': [p for n, p in param_optimizer if not any(
            nd in n for nd in no_decay)], 'weight_decay': 0.01},
        {'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}]
    # 优化器
    optimizer = BertAdam(optimizer_grouped_parameters,
                         lr=config.learning_rate,
                         warmup=0.05,  # 预热阶段
                         t_total=data_iter.num * config.n_epochs)  # 迭代次数

    for data, labels in data_iter:
        model.zero_grad()
        output = model(data)  # 模型输出
        loss = criterion(output, labels)  # 计算损失
        loss.backward()  # 损失回传
        optimizer.step()
        train_loss += loss.item()  # 损失叠加

    return train_loss / data_iter.num

接下来设计的函数用于验证或测试过程,特别地,在测试模式中,输出更多的性能计算结果:

from sklearn import metrics


def test(model, data_iter, config, test_mode=True):  # 分为验证与测试两种模式
    model.eval()
    test_loss = 0
    predict_all = np.array([], dtype=int)
    labels_all = np.array([], dtype=int)

    with torch.no_grad():
        for texts, labels in data_iter:
            outputs = model(texts)
            loss = criterion(outputs, labels)
            test_loss += loss.item()
            labels = labels.data.cpu().numpy()
            predic = torch.max(outputs.data, 1)[1].cpu().numpy()
            labels_all = np.append(labels_all, labels)
            predict_all = np.append(predict_all, predic)
    acc = metrics.accuracy_score(labels_all, predict_all)

    if test_mode:  # 如果为测试模式,更多的性能计算
        report = metrics.classification_report(
            labels_all, predict_all, target_names=config.class_list, digits=4)
        confusion = metrics.confusion_matrix(labels_all, predict_all)
        return acc, test_loss / data_iter.num, report, confusion
    return acc, test_loss / data_iter.num

在定义好了训练及测试函数后,在最后部分进行模型训练及验证。首先设置随机种子:

import numpy as np

# 保证每次结果一样
SEED = 123
torch.manual_seed(SEED)
torch.cuda.manual_seed(SEED)
torch.backends.cudnn.deterministic = True

训练开始:

import time

best_valid_loss = float('inf')
# 开始训练
for epoch in range(config.n_epochs):
    start_time = time.time()  # 计时

    train_loss = train(bert_model, train_iter, config)
    valid_acc, valid_loss = test(
        bert_model, valid_iter, config, test_mode=False)

    if valid_loss < best_valid_loss:  # 保存最好的模型
        best_valid_loss = valid_loss
        torch.save(bert_model.state_dict(), 'beike_classifier_cpu.pkl')

    secs = int(time.time() - start_time)
    mins = secs / 60
    secs = secs % 60

    # 训练过程可视化
    print('Epoch: %d' % (epoch + 1),
          " | %d minutes, %d seconds" % (mins, secs))
    print(f'\tLoss: {train_loss:.4f}(train)')
    print(
        f'\tLoss: {valid_loss:.4f}(valid)\t|\tAcc: {valid_acc * 100:.1f}%(valid)')

应用训练好的模型对测试集进行预测:

# 记录时长
start_time = time.time()
print('测试结果:')
test_model = WWMBert(config).to(config.device)
# 加载模型
test_model.load_state_dict(torch.load("beike_classifier_cpu.pkl"))

由于在线环境运行较慢,上面只训练了 1 个 EPOCH 作为演示。接下来,你可以下载我在本地训练了 30 个 EPOCH 的模型用于推理:

!wget -nc "https://labfile.oss.aliyuncs.com/courses/3382/beike_classifier.pkl.zip"
!unzip -o beike_classifier.pkl.zip
test_model.load_state_dict(torch.load("beike_classifier.pkl",map_location=torch.device('cpu')))

进行测试:

test_acc, test_loss, test_report, test_confusion = test(
    test_model, test_iter, config, test_mode=True)

打印测试结果:

secs = int(time.time() - start_time)
mins = secs / 60
secs = secs % 60
print('time in %d minutes, %d seconds' % (mins, secs))

msg = 'Test Loss: {0:>5.2},  Test Acc: {1:>6.2%}'
print(msg.format(test_loss, test_acc))
print("Precision, Recall and F1-Score...")
print(test_report)
print("Confusion Matrix...")
print(test_confusion)

对于二分类任务以及当前的训练数据量而言,以上的实验结果其实并不如意,同学们可以在此基础上从不同的方面尝试优化,例如数据预处理、参数调节、替换预训练模型、在预训练模型后接不一样的结构等。

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

辣椒种子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值