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

置业问答分类器

参数设计及数据预处理

此实验中的数据为置业顾问与潜在顾客间的问答,首先熟悉新闻数据,格式为 “问句\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)

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,039评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,426评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,417评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,868评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,892评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,692评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,416评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,326评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,782评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,957评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,102评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,790评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,442评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,996评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,113评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,332评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,044评论 2 355

推荐阅读更多精彩内容