【NLP自然语言处理学习笔记07 Bert理论+莫烦pytorch代码理解】


BERT的主要特点是双向编码无监督的迁移学习模型,它由预训练和微调组成;

在预训练阶段,通过不同的预训练任务在无标签数据上训练模型。
再微调训练阶段,BERT模型首先使用预训练的参数进行初始化,所有的参数都使用来自有标签的下流任务。

BERT则采用了Masked Language Model(MLM)和Next Sentence Prediction(NSP)两种预训练任务,使得模型能够同时利用左侧和右侧的上下文信息进行预测;

Bert输入部分

bert 在输入部分会较transform有些不同,它包括token_embeddings+segment embeddings+position embeddings三部分组成,包含了单词本身的信息、句子的信息和位置信息
在这里插入图片描述

#token_embeddings[n,n_vocab,model_dim]
        self.word_emb = nn.Embedding(n_vocab, model_dim)
        self.word_emb.weight.data.normal_(0, 0.1)

#segment embeddings[n,max_seg(step),model_dim]
        self.segment_emb = nn.Embedding(num_embeddings=max_seg, embedding_dim=model_dim) 
        self.segment_emb.weight.data.normal_(0, 0.1)

#position embeddings[n,max_len,model_dim]
        self.position_emb = torch.empty(1, max_len, model_dim)       
        # 使用 Kaiming 正态分布初始化方法对位置嵌入向量进行初始化
        # 使用 fan_out 模式进行初始化,权重矩阵的每个输出通道将具有相同的方差,根据激活函数的非线性特性进行缩放
        nn.init.kaiming_normal_(self.position_emb, mode='fan_out', nonlinearity='relu')
        self.position_emb = nn.Parameter(self.position_emb)


def input_emb(self, seqs, segs):#输入 = 词向量+分割向量+位置向量
        # device = next(self.parameters()).device
        # self.position_emb = self.position_emb.to(device)
    return self.word_emb(seqs) + self.segment_emb(segs) + self.position_emb  

预训练阶段

Bert有两个预训练任务:Masked LM 和 Next Sentence Prediction

Masked Language Model(MLM)

在进行MLM任务时,BERT会对输入序列进行一次完整的正向传递和反向传递。在正向传递中,通过Transformer网络结构处理输入序列,获取每个位置的隐藏表示。在需要进行Mask预测的位置上,将单词替换为[MASK]标记。然后,在反向传递过程中,BERT模型根据上下文信息预测被遮盖的单词。

一般步骤 :

  1. 数据准备:首先,需要准备一个大规模的文本语料库用于BERT的预训练。然后,将每个句子拆分为单词或子词(如WordPiece),并添加一些特殊标记,如[CLS]表示序列的开头,[SEP]表示句子之间的分隔。
class MRPCData(tDataset):
    num_seg = 3  # 表示数据的分段数量
    pad_id = PAD_ID

    def __init__(self, data_dir="./MRPC/", rows=None, proxy=None):
        maybe_download_mrpc(save_dir=data_dir, proxy=proxy)  #下载 MRPC 数据集;proxy(默认为 None):可选的代理服务器地址
        data, self.v2i, self.i2v = _process_mrpc(data_dir, rows)  #处理 MRPC 数据集,返回处理后的数据、词汇表和反向词汇表

        # 返回所有样本的最大长度
        # zip 返回形式 data["train"]["s1id"]+data["test"]["s1id"] :data["train"]["s2id"] + data["test"]["s2id"]
        self.max_len = max([len(s1) + len(s2) + 3 for s1, s2 in zip(
                data["train"]["s1id"] + data["test"]["s1id"], data["train"]["s2id"] + data["test"]["s2id"])])
        # 以列表的形式存储第一句子和第二句子的长度
        self.xlen = np.array([
            [
                len(data["train"]["s1id"][i]), len(data["train"]["s2id"][i])
            ] for i in range(len(data["train"]["s1id"]))], dtype=int)

        #x是【<GO>句子1<SEP>句子2<SEP>】的一个向量形式表示
        x = [
            [self.v2i["<GO>"]] + data["train"]["s1id"][i] + [self.v2i["<SEP>"]] + data["train"]["s2id"][i] + [
                self.v2i["<SEP>"]]
            for i in range(len(self.xlen))
        ]
        self.x = pad_zero(x, max_len=self.max_len) #将x进行填充 max-len = 70
        # 使data["train"]["is_same"]维度变为二维数组;
        # [:, None] 是对数组进行切片和重塑的操作; : 表示选择所有行,而 None 表示增加一个维度
        self.nsp_y = data["train"]["is_same"][:, None]   #
        # 返回特征数据 self.x 的形状,self.num_seg - 1 表示了要使用的初始值
        self.seg = np.full(self.x.shape, self.num_seg - 1, np.int32)  # [160,70]

        # seg用来区别第一个句子和第二个句子
        for i in range(len(x)):
            si = self.xlen[i][0] + 2   # +2是考虑到标记
            self.seg[i, :si] = 0    # 将第一个句子的分割信息置为0 【用来区别第一个句子和第二个句子】
            si_ = si + self.xlen[i][1] + 1   #第二个句子的长度,并加上1
            self.seg[i, si:si_] = 1   # 将第二个句子标记为1

        # 得到了一个不包含特殊词汇的词汇集合
        self.word_ids = np.array(list(set(self.i2v.keys()).difference(
            [self.v2i[v] for v in ["<PAD>", "<MASK>", "<SEP>"]])))

    # x[idx]【x是【<GO>句子1<SEP>句子2<SEP>】的一个向量形式表示】, seg[idx]【区别第一/二个句子】,
    # xlen[idx]【第一个句子和第二个句子长度】, nsp_y[idx] bool类型表示两个句子是否相等
    def __getitem__(self, idx):
        return self.x[idx], self.seg[idx], self.xlen[idx], self.nsp_y[idx]
  1. 遮盖单词:对于每个输入句子,按照一定的概率选择其中的一些单词进行遮盖。通常情况下,随机把一句话中 15% 的 token(字或词)替换:

有 80% 的几率被替换成 [MASK]
有 10% 的几率被替换成任意一个其它的 token
有 10% 的几率原封不动
【为什么要这样呢???】:为了缓解微调和预训练的不匹配问题

# 区别是否进行掩码操作
def _get_loss_mask(len_arange, seq, pad_id):
    # 随机选择一些索引,作为掩码
    rand_id = np.random.choice(len_arange, size=max(2, int(MASK_RATE * len(len_arange))), replace=False)
    loss_mask = np.full_like(seq, pad_id, dtype=bool)   #生成与seq类型相同、并且所有元素都是 pad_id
    loss_mask[rand_id] = True
    return loss_mask[None, :], rand_id    # loss_mask用于区别是否进行掩码操作



def do_mask(seq, len_arange, pad_id, mask_id):   # len_arange 是一个索引数组的长度,seq 是一个序列,pad_id 是填充标记
    loss_mask, rand_id = _get_loss_mask(len_arange, seq, pad_id)
    seq[rand_id] = mask_id #随机选择的索引位置的值替换为掩码标记
    return loss_mask


def do_replace(seq, len_arange, pad_id, word_ids):
    loss_mask, rand_id = _get_loss_mask(len_arange, seq, pad_id)
    seq[rand_id] = torch.from_numpy(np.random.choice(word_ids, size=len(rand_id))).type(torch.IntTensor)
    return loss_mask


def do_nothing(seq, len_arange, pad_id):
    loss_mask, _ = _get_loss_mask(len_arange, seq, pad_id)
    return loss_mask
#完成了根据随机概率生成不同类型的掩码或替换处理,并返回相应的结果。
def random_mask_or_replace(data, arange, dataset):
    # x[idx]【x是【<GO>句子1<SEP>句子2<SEP>】的一个向量形式表示】, seg[idx]【区别第一/二个句子】,
    # xlen[idx]【第一个句子和第二个句子长度】, nsp_y[idx] bool类型表示两个句子是否相等
    seqs, segs, xlen, nsp_labels = data   # [32,72],[32,72],[32,2],[32,1]
    seqs_ = seqs.data.clone()
    p = np.random.random()
    if p < 0.7:
        # 将需要被掩盖的位置对应的预测值排除在损失计算之外,以避免无效计算和损失干扰
        loss_mask = np.concatenate([
            do_mask(
                seqs[i],
                np.concatenate((arange[:xlen[i, 0]], arange[xlen[i, 0] + 1:xlen[i].sum() + 1])), #将数组 arange 中除了索引 xlen[i, 0] 对应的元素外的其他元素拼接在一起,生成一个新的数组
                dataset.pad_id,
                dataset.mask_id
            )
            for i in range(len(seqs))], axis=0)
    elif p < 0.85:
        # do nothing 直接拼接
        loss_mask = np.concatenate([
            do_nothing(
                seqs[i],
                np.concatenate((arange[:xlen[i, 0]], arange[xlen[i, 0] + 1:xlen[i].sum() + 1])),
                dataset.pad_id
            )
            for i in range(len(seqs))], axis=0)
    else:
        # replace 对每个句子进行替换操作并拼接
        loss_mask = np.concatenate([
            do_replace(
                seqs[i],
                np.concatenate((arange[:xlen[i, 0]], arange[xlen[i, 0] + 1:xlen[i].sum() + 1])),
                dataset.pad_id,
                dataset.word_ids
            )
            for i in range(len(seqs))], axis=0)
    loss_mask = torch.from_numpy(loss_mask).unsqueeze(2)   
    return seqs, segs, seqs_, loss_mask, xlen, nsp_labels
  1. 前向传递和反向传递:将构造的样本输入到BERT模型中进行前向传递和反向传递。在前向传递过程中,BERT通过一系列的Transformer层来处理输入序列,得到每个位置的隐藏表示。在反向传递过程中,BERT根据上下文信息预测被遮盖的单词。
  2. 目标函数和优化:通过比较预测的结果和真实标签,计算出MLM任务的损失函数。通常使用交叉熵作为损失函数,并使用梯度下降等优化算法来更新BERT模型中的参数。

让模型预测和还原被遮盖掉或替换掉的部分,计算损失的时候,只计算在第 1 步里被随机遮盖或替换的部分,其余部分不做损失,其余部分无论输出什么,都无所谓

  1. 重复训练:重复上述过程,对整个语料库进行多轮的预训练。每一轮都会对语料库进行随机采样,以增加训练样本的多样性。

def train():
    MODEL_DIM = 256
    N_LAYER = 4
    LEARNING_RATE = 1e-4
    dataset = utils.MRPCData("./MRPC", 1600) #这里只取了1600个数据
    #统计数据集中所有样本的词汇
    print("num word: ", dataset.num_word)
    model = BERT(
        model_dim=MODEL_DIM, max_len=dataset.max_len, num_layer=N_LAYER, num_head=4, n_vocab=dataset.num_word,
        lr=LEARNING_RATE, max_seg=dataset.num_seg, drop_rate=0.2, padding_idx=dataset.pad_id
    )


    loader = DataLoader(dataset, batch_size=32, shuffle=True)
    arange = np.arange(0, dataset.max_len) #[0----71].shape=[72,1]
    for epoch in range(5):
        for batch_idx, batch in enumerate(loader):  # batch_idx = num/32
            seqs, segs, seqs_, loss_mask, xlen, nsp_labels = random_mask_or_replace(batch, arange, dataset)
            seqs, segs, seqs_, nsp_labels, loss_mask = seqs.type(torch.LongTensor).to(device), segs.type(
                torch.LongTensor).to(device), seqs_.type(torch.LongTensor).to(device), nsp_labels.to(
                device), loss_mask.to(device)
            loss, pred = model.step(seqs, segs, seqs_, loss_mask, nsp_labels)   # pred = [32,72,2829]
            if batch_idx % 100 == 0:
                pred = pred[0].cpu().data.numpy().argmax(axis=1)   # 是第几批次,一共有32批
                print(
                    "\n\nEpoch: ", epoch,
                    "|batch: ", batch_idx,    # 是这批次的第几个了
                    "| loss: %.3f" % loss,
                    #seqs[0] 表示取出批次中第一个样本;取出目标第一个句子输出
                    "\n| tgt: ", " ".join([dataset.i2v[i] for i in seqs[0].cpu().data.numpy()[:xlen[0].sum() + 1]]),
                    # 取出预测中第一个句子输出
                    "\n| prd: ", " ".join([dataset.i2v[i] for i in pred[:xlen[0].sum() + 1]]),
                    # seqs_ 转换为一个字符串,并过滤掉掩码
                    "\n| tgt word: ", [dataset.i2v[i] for i in (seqs_[0] * loss_mask[0].view(-1)).cpu().data.numpy() if
                                       i != dataset.v2i["<PAD>"]],
                    # 将预测词汇转换为一个字符串,并过滤掉掩码
                    "\n| prd word: ", [dataset.i2v[i] for i in pred * (loss_mask[0].view(-1).cpu().data.numpy()) if
                                       i != dataset.v2i["<PAD>"]],
                )

Next Sentence Prediction(NSP)

NSP任务的基本思想是:对于给定的一对句子A和B,BERT模型需要判断这两个句子是否是连续的。构造训练样本对,其中包含正确连续句子对(positive example)和随机连续句子对(negative example)。语料中50%的句子,选择其相应的下一句一起形成上下句,作为正样本;其余50%的句子随机选择一句非下一句一起形成上下句,作为负样本。这种设定,有利于sentence-level tasks。
具体过程如下:

  1. 数据准备:从大规模的文本语料库中提取句子对,并根据一定的规则确定它们是否是连续的。
  2. 样本标记:对于每个句子对,将其标记为连续(IsNext)或不连续(NotNext)。将连续的句子对标记为IsNext,而不连续的句子对则标记为NotNext。
  3. 前向传递和反向传递:将编码后的句子对输入到BERT模型中进行前向传递和反向传递。
  4. 通常使用交叉熵作为损失函数,并使用梯度下降等优化算法来更新BERT模型中的参数。

损失计算

在BERT中,通常使用两个主要的损失函数:掩码语言建模(Masked Language Modeling, MLM)损失和下一句预测(Next Sentence Prediction, NSP)损失。

(1)掩码语言建模(MLM)损失:

在BERT的预训练阶段,输入序列中的某些词或子词会被随机掩码(通常被替换为特殊的 [MASK] 标记)。MLM损失用于训练模型预测这些被掩码的词是什么。

(2)下一句预测(NSP)损失:

在BERT的预训练阶段,为了训练模型判断两个句子之间的关联性,需要使用下一句预测损失。

计算NSP损失的步骤如下:
使用二分类交叉熵损失函数来计算模型预测值与真实值之间的差异。目标是最小化交叉熵损失,使模型能够准确地判断句子关联性。

最终,BERT的总损失由MLM损失和NSP损失的加权和构成。权重可以根据具体任务的需求进行调整。

    def step(self, seqs, segs, seqs_, loss_mask, nsp_labels):
        device = next(self.parameters()).device
        self.opt.zero_grad()
        mlm_logits, nsp_logits = self(seqs, segs, training=True)  # [n, step, n_vocab]=[32,72,11885], [n, n_cls] = [32,2]
        mlm_loss = cross_entropy(
            #根据loss_mask选择需要计算损失的位置的logits
            torch.masked_select(mlm_logits, loss_mask).reshape(-1, mlm_logits.shape[2]),
            torch.masked_select(seqs_, loss_mask.squeeze(2))
        )
        nsp_loss = cross_entropy(nsp_logits, nsp_labels.reshape(-1))
        loss = mlm_loss + 0.2 * nsp_loss
        loss.backward()
        self.opt.step()
        return loss.cpu().data.numpy(), mlm_logits

微调阶段

在具体任务中,微调所做的是不同的:
(1)分类任务:
分类任务的开头是cls,然后将该位置的 output,丢给 Linear Classifier,让其 predict 一个 class 即可
在这里插入图片描述
(2)词性标注
将句子中各个字对应位置的 output 分别送入不同的 Linear,预测出该字的标签。
在这里插入图片描述
(3)语言推理
给定一个前提,然后给出一个假设,模型要判断出这个假设是 正确、错误还是不知道。对 [CLS] 的 output 进行预测即可
在这里插入图片描述
(4)问答
首先将问题和文章通过 [SEP] 分隔,送入 BERT ,得到黄色部分的输出。训练两个 vector,即橙色和黄色的向量。进行 dot product,然后通过 softmax,看哪一个输出的值最大,得到最后输出
在这里插入图片描述

数据准备

model_dim=256,
max_len=dataset.max_len = 70,
num_layer=4,
num_head=4,
n_vocab=dataset.num_word ,
lr=1e-4,
max_seg=dataset.num_seg = 3,
drop_rate=0.2,
padding_idx=dataset.pad_id = PAD_ID

bert是以句子为单位进行处理,transform以词为单位进行处理

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值