对话系统 NLU项目总结报告

1. 项目介绍

本项目是基于自然语言理解(NLU)的意图识别和槽值检测(语义槽填充)联合学习的任务,Joint intent classification and slot filling。

1.1 背景知识简介

因为本项目是对话系统中的NLU任务,所以我想在具体介绍项目之前,先和大家在大体上看看一个对话系统,需要具备哪些模块。

对于一个对话系统来说,通常由以下几个模块组成,和用户语音相关的 ASR(Automatic Speech Recognition) 和 TTS(Text to Speech)、自然语言理解 NLU(Natural Language Understanding)、对话管理 DM(Dialogue Management)、自然语言生成 NLG(Natural Language Generation)、知识库 KB(Knowledge Base)。

其中,和 NLP 工程师最相关的是 NLU、DM 和 NLG 三个模块。今天我们来看看第一个模块——NLU,到底是怎么个意思。

大体上,NLU所做的工作就是将用户说的话(非结构化的自然语言),处理成结构化的表示(比如键值对的形式,可以认为是python中的字典。术语为:语义帧,Semantic frame)。

对于一般的对话系统中的NLU任务来说,一般分为三个模块,分别是:领域识别(Domain Classification)、意图识别(Intent Classification)和槽值填充(Slot Filling/Tagging)。

领域识别和意图识别都是分类问题,而语义槽填充是序列标注问题。

具体来说,先通过 领域识别 得到该输入序列所对应的领域,比如是“订火车票相关”还是“地图相关”。接着通过 意图识别 得到该输入序列所对应的具体意图,比如在“订火车票相关”这个领域中,具体是 “查询”,还是”预定“的意图,或者在 ”地图“相关领域中,是想”查找路线“还是”查找位置“。 最后通过 槽值填充 来找到 语义帧(Semantic frame),之后会给到 对话管理模块(Dialogue Management)来进行后续的处理,DM 模块往往是和业务强相关的。目前在大多数商业系统中都是通过堆规则的方式实现的,当然也可以使用强化学习等手段进行训练。这里暂不展开来说。

之所以要分成 领域识别意图识别 两部分,先分出领域,再细分意图,这么做其实是为了更方便的扩展和维护系统。如果系统设计的时候可以确定领域数量(未来不考虑扩展),那么其实就可以省略领域识别这个步骤和模块。

那么对于领域识别和意图识别来说,各种分类模型都可以使用。比如传统的 RNN、CNN,甚至 Transformer等,在接下来要介绍的项目中,我们使用的是 Bert。

而对于语义槽填充来说,目的是在用户的指令中,将涉及的意图对应的参数(语义帧)找到,或者说抽取出与该意图相关的参数信息。当然了,语义槽是提前定义好的。其对应的是序列标注任务(对输入的每一个词或者字,都输出一个标记)。

1.2 数据集介绍

SMP2019 中文人机对话技术评测(The Evaluation of Chinese Human-Computer Dialogue Technology,SMP2019-ECDT),是由全国社会媒体处理大会(Social Media Processing,SMP)主办的,专注于以社会媒体处理为主题的科学研究与工程开发,为传播社会媒体处理最新的学术研究与技术成果提供广泛的交流平台。

本次使用的数据集共包含 2579个数据对,其中 2000个用于训练数据集,579个用于验证数据集。

统计如下:

  • 训练集:2000
  • 验证集:579

原始数据集样例:

{
		"text": "请帮我打开uc",
		"domain": "app",
		"intent": "LAUNCH",
		"slots": {
		  "name": "uc"
		}
},
{
	  "text": "打开汽车之家",
	  "domain": "app",
	  "intent": "LAUNCH",
	  "slots": {
	    "name": "汽车之家"
}

应用场景:任务型对话系统(非闲聊对话系统)

可以看到,每个文本不仅有对应的领域、意图,还有包含的语义槽值。

1.3 评价指标

对于领域分类、意图识别,我们采用准确率(acc)来评价,对于语义槽填充,我们通常采用F值来评价。对于domain,当预测的值与标准答案相同时即为正确。对于intent来说,当domain预测正确,且intent的预测的值与标准答案相同时才为正确。对于slots来说,我们采用F值作为评价指标,当预测的slots的一个key-value组合都符合标准答案的一个key-value组合才为正确(domain和intent的也必须正确)。

为了综合考虑模型的能力,我们通常采用句准确率(sentence acc)来衡量一句话领域分类、意图识别和语义槽填充的综合能力,即以上三项结果全部正确时候才算正确,其余均算错误。

为了方便起见,本项目中我们都采用准确率(acc)来评价。并没有计算 utterance level 的acc(也就是句准确率),只是在每个位置计算 acc。

2. 技术方案梳理

2.1 模型实现目标

通常来说,实现一个对话系统中的 NLU 任务,要分三步,第一步领域识别,第二步意图识别 ,第三步槽值填充。如果不考虑未来系统中领域的扩展,我们可以将第一步和第二步合并起来,那么合并之后就有两步要做,第一步意图识别,第二步槽值填充(“三步并作两步”)。我们的目标是,进一步提高效率,同时完成意图识别和槽值填充这两个步骤(“两步合成一步”)。

2.2 模型框架介绍

模型结构概览

2.2.1 BERT 模型简介

熟悉 BERT 的朋友可能一眼就会认出,这个模型是基于 BERT 来做的。没错,那在介绍具体模型之前,我们可以先来简单回顾一下 BERT 模型。

BERT 模型输入

  • “多层,双向,Encoder”
    BERT 模型是一个基于 Transformer 模型的 多层双向self-attention Transformer 编码器结构(multi-layer bidirectional Transformer encoder)。其输入由三个部分组成,分别是 Token Embeddings 词嵌入向量、Segment Embeddings 句子编码向量 和 Position Embeddings 位置编码向量,这三部分相加组成了最终 BERT 的输入向量。

  • “三输入,两 token”
    对于单句分类和标注任务来说,Segment Embeddings 句子编码向量不做区分。对每句话来说,都会在句子最前边加入一个特别的分类token [CLS],在句子结尾加上 [SEP] 分割 token。对输入 BERT 模型的 token 序列X来说,X = (x1, x2, … , xt),则输出也是等长的,即 H = (h1, h2, … , ht)。

  • “无监督 pre-train Embedding,有监督 fine-tune Transfer ”
    BERT 模型是在大规模未标记的文本上进行预训练得到的词嵌入参数的(用无监督学习 的方式进行 预训练 pre-training,得到 词嵌入 Embedding)。

    有监督学习 的方式来进行微调(fine-tuning),来应用到具体的下游任务场景中,实现 **迁移学习(Transfer Learning)**的效果。

注:在微调的时候,BERT模型只是用预训练好的参数进行初始化的,并且是基于下游任务的有标签数据来训练的,所以是有监督学习。

  • “MLM,NSP”
    Masked Language Model(MLM):随机 mask 一些百分比的输入 tokens,然后预测哪些被 mask 掉的 tokens。

    Next Sentence Prediction(NSP):为了理解句子之间关系,对“下一个句子”进行了二分类的预训练,这些用于训练的句子对可以从任何单语言的预料中获取。

如果对 Bert 模型还不太熟悉的朋友,可以去查阅 Bert 原论文和相关资料。以上只做简单概述。

2.2.2 项目模型讲解

好,再回顾 BERT 模型之后,我们正式进入本次项目的模型讲解。
模型结构概览
再次回到这张图,这次再看的时候,可能会比之前更加清晰。

BERT常规操作,在原句前加一个 [CLS] token,在原句之后加一个 [SEP] token,将拼接之后的序列扔给 BERT 模型,BERT 模型会在每一个位置输出一个对应的表示向量(hidden state)。我们可以看到,模型中的意图识别部分继续沿用了 BERT 的 [CLS] 部分进行分类,而在槽值填充部分,直接用输出结果进行序列标注。

  • 意图识别部分:由 [CLS] token 输出的 hidden state(记为 h1),先过一层全连接层,再由 softmax 计算得到不同意图的概率。其公式如下:
    在这里插入图片描述
  • 槽值填充部分:除 [CLS] token 之外输出的所有 hidden states(记为 h2, h3, … , ht),过 softmax 层来实现序列标注,得到语义槽值。其公式如下:
    在这里插入图片描述
  • 槽值填充部分:除 [CLS] token 之外输出的所有 hidden states(记为 h2, h3, … , ht),过 softmax 层来实现序列标注,得到语义槽值。其公式如下:
    在这里插入图片描述

2.3 模型实现步骤

模型理论说完,我们来看具体实现。

2.3.1 数据处理

  1. 先将原始数据由 json 格式,处理成 tsv 格式,并分成训练数据和验证数据。另外在序列标注任务的数据处理的时候,要注意使用 BIO标记法。
  2. 将全部意图和全部槽值单独保存成文件,便于后续模型使用。

经上述两步,得到如下四个文件,分别是:train.tsv、test.tsv、slot_vocab、cls_vocab

train.tsv 和 test.tsv 数据展示:

poetry@query	我想听首唐诗。	o o o o b-dynasty o o
match@query	江苏舜天队这个星期的比赛时间给我找出来	b-name i-name i-name i-name o b-datetime_date i-datetime_date i-datetime_date i-datetime_date o o o o o o o o o o
message@send	给严巧发短信	o b-name i-name o o o
poetry@query	来一首李白的诗。	o o o b-author i-author o o o
epg@query	收看安徽卫视节目。	o o b-tvchannel i-tvchannel i-tvchannel i-tvchannel o o o

slot_vocab 展示

B-content
B-endLoc_poi
B-dynasty
I-teleOperator
I-headNum

cls_vocab 展示

health@QUERY
app@QUERY
app@LAUNCH
email@LAUNCH
riddle@QUERY

2.3.2 数据集处理相关

  • dataset.py
    接下来定义模型所需的数据集 dataset 类
class NLUDataset(Dataset):
    def __init__(self, paths, tokz, cls_vocab, slot_vocab, logger, max_lengths=2048):
        self.logger = logger
        self.data = NLUDataset.make_dataset(paths, tokz, cls_vocab, slot_vocab, logger, max_lengths)

    @staticmethod
    def make_dataset(paths, tokz, cls_vocab, slot_vocab, logger, max_lengths):
        logger.info('reading data from {}'.format(paths))
        dataset = []
        for path in paths:
            with open(path, 'r', encoding='utf8') as f:
                lines = [i.strip().lower() for i in f.readlines() if len(i.strip()) != 0]
                lines = [i.split('\t') for i in lines]
                for label, utt, slots in lines:
                    utt = tokz.convert_tokens_to_ids(list(utt)[:max_lengths])
                    slots = [slot_vocab[i] for i in slots.split()]
                    assert len(utt) == len(slots)
                    dataset.append([int(cls_vocab[label]),
                                    [tokz.cls_token_id] + utt + [tokz.sep_token_id], 
                                    tokz.create_token_type_ids_from_sequences(token_ids_0=utt),
                                    [tokz.pad_token_id] + slots + [tokz.pad_token_id]])
        logger.info('{} data record loaded'.format(len(dataset)))
        return dataset

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        intent, utt, token_type, slot = self.data[idx]
        return {"intent": intent, "utt": utt, "token_type": token_type, "slot": slot}

写一个类专门用来控制 padding 的行为,之后会用到 dataloader中去。

class PadBatchSeq:     
    def __init__(self, pad_id):
        self.pad_id = pad_id

    def __call__(self, batch):
        res = dict()
        res['intent'] = torch.LongTensor([i['intent'] for i in batch])
        max_len = max([len(i['utt']) for i in batch])
        res['utt'] = torch.LongTensor([i['utt'] + [self.pad_id] * (max_len - len(i['utt'])) for i in batch])
        res['mask'] = torch.LongTensor([[1] * len(i['utt']) + [0] * (max_len - len(i['utt'])) for i in batch])  # attention_mask
        res['token_type'] = torch.LongTensor([i['token_type'] + [self.pad_id] * (max_len - len(i['token_type'])) for i in batch])
        res['slot'] = torch.LongTensor([i['slot'] + [self.pad_id] * (max_len - len(i['slot'])) for i in batch])
        return PinnedBatch(res)

注:其实在使用 hugging face 的 BERT 做 token时,可以同时定义 padding 的操作,但会有一定的损失,因为这样做的话会用整个数据中最长的序列长度来作为 max_len。

而使用单独的类来控制 padding 行为这种方式,我们可以使用一个batch中最长的序列来作为其 max_len,效果会更好。

特殊的类,可以看到 PadBatchSeq 类做完 padding 之后的结果,其实是包在另一个类中的,即如下类。

class PinnedBatch:
    def __init__(self, data):
        self.data = data

    def __getitem__(self, k):
        return self.data[k]

    def pin_memory(self):
        for k in self.data.keys():
            self.data[k] = self.data[k].pin_memory()
        return self

关键在于重载其中的 pin_memory 方法。

因为在训练深度神经网络时,大部分时间都用于从 CPU 往 GPU 搬数据的过程(拷贝数据这一项工作),为了使 “搬数据” 和 “训练(计算)” 这两个事情并行,就可以调用 变量的 pin_memory 方法,这样的话在 Pytorch 后端实现的时候,就会把这个变量放到一个 特殊的 memory 缓存中,从而实现异步地进行 “搬数据” 和 “计算”两个过程。也就是说,在把这部分数据从缓存搬到 GPU 的过程,我们的 CPU 是可以同时做计算的(CPU 往 GPU 进行拷贝的过程中)。换句话说,就是在做数据搬运的同时,CPU 可以同时做计算。

这种方式可以帮助我们更快的进行训练,提高训练效率。

对应于 模型训练相关类(trainer.py)中 ,初始化 model 时, non_blocking=True 参数,即搬数据的操作不会 blocking CPU 上的计算,当然了会 blocking GPU 上的计算(因为 GPU 是要数据搬完才能开始计算)。所以在搬数据的时候,就可以进行其他的操作,比如初始化 criterion、optimizer 等。

model = model.to(device, non_blocking=True)

2.3.3 模型定义类

  • NLU_model.py
    定义一个模型构建类,包含具体模型结构和相关参数的定义。
class NLUModule(BertPreTrainedModel):
    def __init__(self, config):
        super().__init__(config)
        self.num_intent_labels = config.num_intent_labels
        self.num_slot_labels = config.num_slot_labels

        self.bert = BertModel(config) # 先初始化一个 BERT 模型
        self.dropout = nn.Dropout(config.hidden_dropout_prob)
        self.intent_classifier = nn.Linear(config.hidden_size, config.num_intent_labels) # intent head
        self.slot_classifier = nn.Linear(config.hidden_size, config.num_slot_labels)  # slot head

        self.init_weights()

    def forward(
        self,
        input_ids=None,
        attention_mask=None,
        token_type_ids=None,
        position_ids=None,
        head_mask=None,
        inputs_embeds=None,
        output_attentions=None,
        output_hidden_states=None,
    ):
        r"""
        labels (:obj:`torch.LongTensor` of shape :obj:`(batch_size,)`, `optional`):
            Labels for computing the sequence classification/regression loss.
            Indices should be in :obj:`[0, ..., config.num_labels - 1]`.
            If :obj:`config.num_labels == 1` a regression loss is computed (Mean-Square loss),
            If :obj:`config.num_labels > 1` a classification loss is computed (Cross-Entropy).
        """
        outputs = self.bert(
            input_ids,
            attention_mask=attention_mask,
            token_type_ids=token_type_ids,
            position_ids=position_ids,
            head_mask=head_mask,
            inputs_embeds=inputs_embeds,
            output_attentions=output_attentions,
            output_hidden_states=output_hidden_states,
        )
				# outputs: [token_rep, pooled_output, ...]
				## 这里的 outputs 就是 BERT 的输出
				
				## 这里的 pooled_output 其实表示的就是 [CLS] 所对应的输出
        pooled_output = outputs[1]  # batch_size * hidden_size  
				## 这里的 seq_encoding 表示的是 [CLS] 之后的所有位置的 token 的输出,所以长度是 seq_len
        seq_encoding = outputs[0]  # batch_size * seq_len * hidden_size
				
				# pooled_output 先过 dropout,再过 intent_classifier,就可以得到 intent 对应的 logits
        pooled_output = self.dropout(pooled_output) 
        intent_logits = self.intent_classifier(pooled_output) # batch_size * num_intent

				# 对 [CLS] 之后的每个位置的 token 都做分类,得到 slot_logits
        slot_logits = self.slot_classifier(seq_encoding)  # batch_size * seq_len * num_slot

        return intent_logits, slot_logits

2.3.4 训练相关类

  • trainer.py
    定义一个模型训练相关的类,来统一管理模型的训练行为。这样做可以非常方便的进行模型扩展和修改。
class Trainer:
    def __init__(self, args, model, tokz, train_dataset, valid_dataset,
                 log_dir, logger, device=torch.device('cuda'), valid_writer=None, distributed=False):
        self.config = args
        self.device = device
        self.logger = logger
        self.log_dir = log_dir
        self.tokz = tokz
        self.rank = torch.distributed.get_rank() if distributed else -1
        self.train_writer = SummaryWriter(os.path.join(log_dir, 'train'))
        if valid_writer is None:
            self.valid_writer = SummaryWriter(os.path.join(log_dir, 'valid'))
        else:
            self.valid_writer = valid_writer
        self.model = model.to(device, non_blocking=True) # 搬运数据和CPU计算 并行
        self.criterion = nn.CrossEntropyLoss(ignore_index=tokz.pad_token_id, reduction='none').to(device)

        base_optimizer = Adam(self.model.parameters(), lr=self.config.lr, weight_decay=0.01)
        if hasattr(self.model, 'config'):
            self.optimizer = NoamOpt(self.model.config.hidden_size, 0.1, self.config.lr_warmup, base_optimizer)
        else:
            self.optimizer = NoamOpt(self.model.module.config.hidden_size, 0.1, self.config.lr_warmup, base_optimizer)
				
				## 多卡GPU 并行计算需要定义 sampler
        self.train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset) if distributed else torch.utils.data.RandomSampler(train_dataset)
        self.valid_sampler = torch.utils.data.distributed.DistributedSampler(valid_dataset) if distributed else None

        self.train_dataloader = DataLoader(
            train_dataset, sampler=self.train_sampler, batch_size=self.config.bs, num_workers=self.config.n_jobs, pin_memory=True,
            collate_fn=PadBatchSeq(self.tokz.pad_token_id))

        self.valid_dataloader = DataLoader(
            valid_dataset, sampler=self.valid_sampler, batch_size=self.config.bs, num_workers=self.config.n_jobs, pin_memory=True,
            collate_fn=PadBatchSeq(self.tokz.pad_token_id))

    def state_dict(self):
        return self.model.state_dict()
        
    def load_state_dict(self, state_dict):
        self.model.load_state_dict(state_dict)

    def _eval_train(self, epoch):
        self.model.train()

        intent_loss, slot_loss, intent_acc, slot_acc, step_count = 0, 0, 0, 0, 0
        total = len(self.train_dataloader)
        if self.rank in [-1, 0]:
            TQDM = tqdm(enumerate(self.train_dataloader), desc='Train (epoch #{})'.format(epoch),
                        dynamic_ncols=True, total=total)
        else:
            TQDM = enumerate(self.train_dataloader)

        for i, data in TQDM:
            text = data['utt'].to(self.device, non_blocking=True)
            intent_labels = data['intent'].to(self.device, non_blocking=True)
            slot_labels = data['slot'].to(self.device, non_blocking=True)
            mask = data['mask'].to(self.device, non_blocking=True)
            token_type = data['token_type'].to(self.device, non_blocking=True)

            intent_logits, slot_logits = self.model(input_ids=text, attention_mask=mask, token_type_ids=token_type)
						
						# intent loss
            batch_intent_loss = self.criterion(intent_logits, intent_labels).mean()
            
						# slot loss
						batch_slot_loss = self.criterion(slot_logits.view(-1, slot_logits.shape[-1]), slot_labels.view(-1)).mean()
            slot_mask = 1 - slot_labels.eq(self.tokz.pad_token_id).float() # batch_size * seq_len
            batch_slot_loss = (batch_slot_loss * slot_mask.view(-1)).sum() / slot_mask.sum()

            batch_loss = batch_intent_loss + batch_slot_loss
            batch_intent_acc = (torch.argmax(intent_logits, dim=-1) == intent_labels).float().mean()
            batch_slot_acc = (torch.argmax(slot_logits, dim=-1) == slot_labels)
            batch_slot_acc = torch.sum(batch_slot_acc * slot_mask) / torch.sum(slot_mask)

            full_loss = batch_loss / self.config.batch_split # batch_split 以时间换空间
            full_loss.backward() # full_loss 会求每个 batch 对应的梯度,然后把梯度攒下来,之后统一做参数更新(step操作)

            intent_loss += batch_intent_loss.item()
            slot_loss += batch_slot_loss.item()
            intent_acc += batch_intent_acc.item()
            slot_acc += batch_slot_acc.item()
            step_count += 1

            curr_step = self.optimizer.curr_step()
            lr = self.optimizer.param_groups[0]["lr"]
            # self.logger.info('epoch %d, batch %d' % (epoch, i))
            if (i + 1) % self.config.batch_split == 0:
                # update weights
                self.optimizer.step()
                self.optimizer.zero_grad()

                intent_loss /= step_count
                slot_loss /= step_count
                intent_acc /= step_count
                slot_acc /= step_count

                if self.rank in [-1, 0]:
                    self.train_writer.add_scalar('loss/intent_loss', intent_loss, curr_step)
                    self.train_writer.add_scalar('loss/slot_loss', slot_loss, curr_step)
                    self.train_writer.add_scalar('acc/intent_acc', intent_acc, curr_step)
                    self.train_writer.add_scalar('acc/slot_acc', slot_acc, curr_step)
                    self.train_writer.add_scalar('lr', lr, curr_step)
                    TQDM.set_postfix({'intent_loss': intent_loss, 'intent_acc': intent_acc, 'slot_loss': slot_loss, 'slot_acc': slot_acc})

                intent_loss, slot_loss, intent_acc, slot_acc, step_count = 0, 0, 0, 0, 0

                # only valid on dev and sample on dev data at every eval_steps
                if curr_step % self.config.eval_steps == 0:
                    self._eval_test(epoch, curr_step)

    def _eval_test(self, epoch, step):
        self.model.eval()
        with torch.no_grad():
            dev_intent_loss = torch.tensor(0.0, dtype=torch.float32, device=self.device)
            dev_slot_loss = torch.tensor(0.0, dtype=torch.float32, device=self.device)
            dev_intent_acc = torch.tensor(0.0, dtype=torch.float32, device=self.device)
            dev_slot_acc = torch.tensor(0.0, dtype=torch.float32, device=self.device)
            count = torch.tensor(0.0, dtype=torch.float32, device=self.device)

            for data in self.valid_dataloader:
                text = data['utt'].to(self.device, non_blocking=True)
                intent_labels = data['intent'].to(self.device, non_blocking=True)
                slot_labels = data['slot'].to(self.device, non_blocking=True)
                mask = data['mask'].to(self.device, non_blocking=True)
                token_type = data['token_type'].to(self.device, non_blocking=True)

                intent_logits, slot_logits = self.model(input_ids=text, attention_mask=mask, token_type_ids=token_type)
                
                batch_intent_loss = self.criterion(intent_logits, intent_labels)
                batch_slot_loss = self.criterion(slot_logits.view(-1, slot_logits.shape[-1]), slot_labels.view(-1))
                slot_mask = 1 - slot_labels.eq(self.tokz.pad_token_id).float()
                batch_slot_loss = (batch_slot_loss * slot_mask.view(-1)).view(text.shape[0], -1).sum(dim=-1) / slot_mask.sum(dim=-1)
                
                dev_intent_loss += batch_intent_loss.sum()
                dev_slot_loss += batch_slot_loss.sum()

                batch_intent_acc = (torch.argmax(intent_logits, dim=-1) == intent_labels).sum()
                batch_slot_acc = (torch.argmax(slot_logits, dim=-1) == slot_labels)
                batch_slot_acc = torch.sum(batch_slot_acc * slot_mask, dim=-1) / torch.sum(slot_mask, dim=-1)

                dev_intent_acc += batch_intent_acc
                dev_slot_acc += batch_slot_acc.sum()
                count += text.shape[0]

            if self.rank != -1:
                torch.distributed.all_reduce(dev_intent_loss, op=torch.distributed.reduce_op.SUM)
                torch.distributed.all_reduce(dev_slot_loss, op=torch.distributed.reduce_op.SUM)
                torch.distributed.all_reduce(dev_intent_acc, op=torch.distributed.reduce_op.SUM)
                torch.distributed.all_reduce(dev_slot_acc, op=torch.distributed.reduce_op.SUM)
                torch.distributed.all_reduce(count, op=torch.distributed.reduce_op.SUM)

            dev_intent_loss /= count
            dev_slot_loss /= count
            dev_intent_acc /= count
            dev_slot_acc /= count

            if self.rank in [-1, 0]:
                self.valid_writer.add_scalar('loss/intent_loss', dev_intent_loss, step)
                self.valid_writer.add_scalar('loss/slot_loss', dev_slot_loss, step)
                self.valid_writer.add_scalar('acc/intent_acc', dev_intent_acc, step)
                self.valid_writer.add_scalar('acc/slot_acc', dev_slot_acc, step)
                log_str = 'epoch {:>3}, step {}'.format(epoch, step)
                log_str += ', dev_intent_loss {:>4.4f}'.format(dev_intent_loss)
                log_str += ', dev_slot_loss {:>4.4f}'.format(dev_slot_loss)
                log_str += ', dev_intent_acc {:>4.4f}'.format(dev_intent_acc)
                log_str += ', dev_slot_acc {:>4.4f}'.format(dev_slot_acc)
                self.logger.info(log_str)

        self.model.train()

    def train(self, start_epoch, epochs, after_epoch_funcs=[], after_step_funcs=[]):
        for epoch in range(start_epoch + 1, epochs):
            self.logger.info('Training on epoch'.format(epoch))
            if hasattr(self.train_sampler, 'set_epoch'):
                self.train_sampler.set_epoch(epoch)
            self._eval_train(epoch)
            for func in after_epoch_funcs:
                func(epoch, self.device)

其中,在显存不太够用的情况下,经常采用 batch_split 方法,目的是以时间换空间。

full_loss = batch_loss / self.config.batch_split # batch_split 以时间换空间
full_loss.backward() # full_loss 会求每个 batch 对应的梯度,然后把梯度攒下来,之后统一做参数更新(step操作)

另外,再做 dataloader 时,可以看到用到了我们之前定义的 PadBatchSeq 类。

self.train_dataloader = DataLoader(
            train_dataset, sampler=self.train_sampler, batch_size=self.config.bs, num_workers=self.config.n_jobs, pin_memory=True,
            collate_fn=PadBatchSeq(self.tokz.pad_token_id))

self.valid_dataloader = DataLoader(
            valid_dataset, sampler=self.valid_sampler, batch_size=self.config.bs, num_workers=self.config.n_jobs, pin_memory=True,
            collate_fn=PadBatchSeq(self.tokz.pad_token_id))

2.3.5 优化器相关

  • optim.py
    定义一个模型所需优化器 optimizer 的类,来统一管理模型的优化相关。

注:Adam 类可以直接用 pytorch 官方的 ,这样copy 过来只是为了方便地添加 Warm-up 对应的类。

class Adam(torch.optim.Optimizer):
    def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0, amsgrad=False):
        defaults = dict(lr=lr, betas=betas, eps=eps, weight_decay=weight_decay, amsgrad=amsgrad)
        super(Adam, self).__init__(params, defaults)

    def step(self, closure=None):
        """Performs a single optimization step.
        Arguments:
            closure (callable, optional): A closure that reevaluates the model
                and returns the loss.
        """
        loss = None
        if closure is not None:
            loss = closure()

        for group in self.param_groups:
            for p in group['params']:
                if p.grad is None:
                    continue
                grad = p.grad.data
                if grad.is_sparse:
                    raise RuntimeError('Adam does not support sparse gradients, please consider SparseAdam instead')
                amsgrad = group['amsgrad']

                state = self.state[p]

                # State initialization
                if len(state) == 0:
                    state['step'] = 0
                    # Exponential moving average of gradient values
                    state['exp_avg'] = torch.zeros_like(p.data)
                    # Exponential moving average of squared gradient values
                    state['exp_avg_sq'] = torch.zeros_like(p.data)
                    if amsgrad:
                        # Maintains max of all exp. moving avg. of sq. grad. values
                        state['max_exp_avg_sq'] = torch.zeros_like(p.data)

                exp_avg, exp_avg_sq = state['exp_avg'], state['exp_avg_sq']
                if amsgrad:
                    max_exp_avg_sq = state['max_exp_avg_sq']
                beta1, beta2 = group['betas']

                state['step'] += 1

                # Decay the first and second moment running average coefficient
                exp_avg.mul_(beta1).add_(grad, alpha=1 - beta1)
                exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1.0 - beta2)
                if amsgrad:
                    # Maintains the maximum of all 2nd moment running avg. till now
                    torch.max(max_exp_avg_sq, exp_avg_sq, out=max_exp_avg_sq)
                    # Use the max. for normalizing running avg. of gradient
                    denom = max_exp_avg_sq.sqrt().add_(group['eps'])
                else:
                    denom = exp_avg_sq.sqrt().add_(group['eps'])

                bias_correction1 = 1 - beta1 ** state['step']
                bias_correction2 = 1 - beta2 ** state['step']
                step_size = group['lr'] * math.sqrt(bias_correction2) / bias_correction1

                if group['weight_decay'] != 0:
                    p.data.add_(p.data, alpha=-group['weight_decay'] * group['lr'])

                p.data.addcdiv_(exp_avg, denom, value=-step_size)

        return loss

定义一个类,实现 Warm-up 操作

在使用 transformer 模型时,基本都会使用 warm up,已经成为了一种标配。具体来说,是指 learning rate 先线性上升,再类似对数下降,这样能起到一个比较好的训练效果。

class NoamOpt:
    def __init__(self, embeddings_size, factor, warmup, optimizer):
        self.embeddings_size = embeddings_size
        self.factor = factor
        self.warmup = warmup
        self.optimizer = optimizer

        self._step = 1
        
    def state_dict(self):
        return {'step': self._step,
                'optimizer': self.optimizer.state_dict()}

    def load_state_dict(self, state_dict):
        self._step = state_dict['step']
        self.optimizer.load_state_dict(state_dict['optimizer'])

    def zero_grad(self):
        return self.optimizer.zero_grad()

    @property
    def param_groups(self):
        return self.optimizer.param_groups

    def step(self):
        self._step += 1
        rate = self.rate()
        for p in self.optimizer.param_groups:
            p['lr'] = rate
        self.optimizer.step()

    def curr_step(self):
        return self._step

    def rate(self, step=None):
        if step is None:
            step = self._step
            
        return self.factor * (self.embeddings_size ** (-0.5) * min(step ** (-0.5), step * self.warmup ** (-1.5)))

2.3.6 工具相关

  • utils.py
    包含项目中所需的一些工具方法。

2.3.7 训练脚本

  • train.py
    运行此脚本,即可开始训练模型。
train_path = os.path.join(args.save_path, 'train')
log_path = os.path.join(args.save_path, 'log')

def save_func(epoch, device):
    filename = utils.get_ckpt_filename('model', epoch)
    torch.save(trainer.state_dict(), os.path.join(train_path, filename))

try:
    if args.local_rank == -1 or args.local_rank == 0:
        if not os.path.isdir(args.save_path):
            os.makedirs(args.save_path)
    while not os.path.isdir(args.save_path):
        pass
    logger = utils.get_logger(os.path.join(args.save_path, 'train.log'))

    if args.local_rank == -1 or args.local_rank == 0:
        for path in [train_path, log_path]:
            if not os.path.isdir(path):
                logger.info('cannot find {}, mkdiring'.format(path))
                os.makedirs(path)

        for i in vars(args):
            logger.info('{}: {}'.format(i, getattr(args, i)))

    distributed = (args.local_rank != -1)
    if distributed:
        torch.cuda.set_device(args.local_rank)
        device = torch.device("cuda", args.local_rank)
        torch.distributed.init_process_group(backend='nccl', init_method='env://')
        torch.manual_seed(args.seed)
    else:
        device = torch.device("cuda", 0)
    tokz = BertTokenizer.from_pretrained(args.bert_path)
    _, intent2index, _ = utils.load_vocab(args.intent_label_vocab)
    _, slot2index, _ = utils.load_vocab(args.slot_label_vocab)
    train_dataset = dataset.NLUDataset([args.train_file], tokz, intent2index, slot2index, logger, max_lengths=args.max_length)
    valid_dataset = dataset.NLUDataset([args.valid_file], tokz, intent2index, slot2index, logger, max_lengths=args.max_length)

    logger.info('Building models, rank {}'.format(args.local_rank))
    bert_config = BertConfig.from_pretrained(args.bert_path)
    bert_config.num_intent_labels = len(intent2index)
    bert_config.num_slot_labels = len(slot2index)
    model = NLUModule.from_pretrained(args.bert_path, config=bert_config).to(device)

    if distributed:
        model = DistributedDataParallel(model, device_ids=[args.local_rank], output_device=args.local_rank)

    trainer = Trainer(args, model, tokz, train_dataset, valid_dataset, log_path, logger, device, distributed=distributed)

    start_epoch = 0
    if args.local_rank in [-1, 0]:
        trainer.train(start_epoch, args.n_epochs, after_epoch_funcs=[save_func])
    else:
        trainer.train(start_epoch, args.n_epochs)

except:
    logger.error(traceback.format_exc())

注:

  1. 关键第一步先做 token。
  2. 有了 token 之后,就可以初始化 dataset。
  3. 准备好 bert_config。
  4. 有了 bert_config,就可以初始化 预训练模型 model。
  5. 有了以上的 model、tokz、dataset,就可以初始化 trainer,进行训练了。训练可直接通过 trainer.trian() 开始。
  6. 要提前下载准备好 hugging face的 BERT 相关模型。
  7. trainer.py中使用了tensorboard 来记录训练过程,比如记录训练的loss、acc等,很方便。

2.4 训练结果展示

intent_loss=0.00551, intent_acc=0.8, slot_loss=0.00309, slot_acc=1
dev_intent_loss 0.4817, dev_slot_loss 0.4695, dev_intent_acc 0.7634, dev_slot_acc 0.9266

[trainer.py][line: 175][INFO]         >> epoch  29, step 1920, dev_intent_loss 0.4817, dev_slot_loss 0.4695, dev_intent_acc 0.7634, dev_slot_acc 0.9266
Train (epoch #29): 100%|█████████████████████| 67/67 [00:29<00:00,  2.25it/s, intent_loss=0.00551, intent_acc=0.8, slot_loss=0.00309, slot_acc=1]

注:模型 Epoch 为 30、LR 为 8e-6。

3. 模型优化方向

槽值填充或者说槽值预测依赖于对周围单词的预测,研究表明,结构化的预测模型可以提高槽值填充的性能,因此我们可以使用条件随机场 CRF(Conditional Random Field)。具体来说,可以对原有的 BERT 模型输出的 hidden state,再过一层 CRF 层,这里不做展开。

4. 总结

NLU是对话系统中非常重要的一个模块,也是自然语言处理中不可或缺的一部分,我们通过这个项目,从头到尾实现了一个对话系统中的 NLU 模块,除了大体上的模型结构,另外其中包含的很多细节要好好注意,希望各位看官有所收获。如果有任何疑问欢迎讨论,写得有不足之处,也请不吝指教。

5. 参考

https://arxiv.org/pdf/1902.10909.pdf
https://arxiv.org/pdf/1810.04805.pdf
https://zhuanlan.zhihu.com/p/126200398
https://blog.csdn.net/weixin_37947156/article/details/85313616

另:原创,转载请声明,并附上原文出处链接,谢谢~

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值