BiLSTM+CRF的损失由发射矩阵和转移矩阵计算而得 BiLSTM+CRF命名实体识别:达观杯败走记(下篇

如果是训练,那么直接用发射矩阵和真实标签去计算Loss,用于更新梯度。

这需要用到CRF中的forward函数。

如果是预测,那么就用发射矩阵去进行维特比解码,得到最优路径(预测的标签)。

这需要用到CRF中的decode函数。

所谓的CRF层,其实也就是一些可学习的参数,如下图:

我仔细看了这个库的代码,才发现原来这个库会把自动添加<start>和<end>的转移概率,这也就意味着,我们无需在标签中手动加入这两个标记,同样样本的前后也无需添加这两个标记。

补救措施就是:在建立字、标签到id的映射时,去掉这两个标记,还有样本和标签前后都不要加这两个标记。

 

02

损失的计算

BiLSTM+CRF的损失由发射矩阵和转移矩阵计算而得。

输入一个句子,预测的标签序列(路劲)有很多条,而正确的标签序列是其中的一条。

每条标签序列都可以计算一个分数,由句子中每个字和标签对应的发射概率,以及标签之间的转移概率,加和而成,公式如下:

P是发射矩阵,size为n×k,k为真实标签的个数,不包括<start>和<end>。

A为转移矩阵,size为(k+2)×(k+2),需要加上<start>和<end>。

同样我们可以算出正确的标签序列的分数,一般把这个分数叫做 Gold Score。


用Gold Score和所有可能路径的分数,算一个softmax的概率,显然我们要让正确的标签序列的概率最大。

为了方便求解,在等式左右两边加了log,同时用动态规划来计算。

再加一个负号,作为Loss,让Loss最小化。

$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$

CRF层

如果是训练,那么直接用发射矩阵和真实标签去计算Loss,用于更新梯度。

这需要用到CRF中的forward函数。

如果是预测,那么就用发射矩阵去进行维特比解码,得到最优路径(预测的标签)。

这需要用到CRF中的decode函数。

所谓的CRF层,其实也就是一些可学习的参数,如下图:

我仔细看了这个库的代码,才发现原来这个库会把自动添加<start>和<end>的转移概率,这也就意味着,我们无需在标签中手动加入这两个标记,同样样本的前后也无需添加这两个标记。

补救措施就是:在建立字、标签到id的映射时,去掉这两个标记,还有样本和标签前后都不要加这两个标记。

class CRF(nn.Module):
    """Conditional random field."""

    def __init__(self, num_tags: int, batch_first: bool = False) -> None:
        if num_tags <= 0:
            raise ValueError(f'invalid number of tags: {num_tags}')
        super().__init__()
        self.num_tags = num_tags
        self.batch_first = batch_first

        """ 这个库里面会自动添加<start>和<end>的转移概率,
        所以无需再手动在样本和标签前后加入<start>和<end>标记 """
        self.start_transitions = nn.Parameter(torch.empty(num_tags))
        self.end_transitions = nn.Parameter(torch.empty(num_tags))

        """ 转移概率矩阵,tags不包含<start>和<end>标记 """
        self.transitions = nn.Parameter(torch.empty(num_tags, num_tags))

        self.reset_parameters()

四:模型的训练

01

模型的训练

开始训练模型。

首先加载数据集,把样本和标签都转化为id。

然后产生batch训练数据。为了使用CoNLL-2000的评估脚本,我把BatchManager的代码改了一点(后面会介绍),每个batch包含样本、样本的id,标签的id和MASK矩阵。

chars, char_ids, seg_ids, tag_ids, mask = batch

接着初始化模型,并设为用GPU训练。

按照上面说的,item到id的映射中已经去掉了<start>和<end>,

char_to_id
{'<pad>': 0, '<unk>': 1, '0': 2, ',': 3, ':': 4, '。': 5, '无': 6, '、': 7, '常': 8, ...}

tag_to_id
{'<pad>': 0, 'O': 1, 'I-TES': 2, 'I-DIS': 3, 'I-SGN': 4, 'B-TES': 5, 'E-TES': 6, ...}

用F1宏平均作为early stop的监控指标,同时使用了学习率衰减和梯度截断。

config.steps_check设为了100,也就是每100个batch在验证集上跑一次,如果F1值有提高,那就保存模型,并在测试集上测试并打印结果。

那损失是怎么计算出来的呢?

验证集和测试集上的F1值是怎么算的呢?

def train():

    """ 1: 加载数据集,把样本和标签都转化为id"""
    if os.path.isfile(config.data_proc_file):

        with open(config.data_proc_file, "rb") as f:
            train_data,dev_data,test_data = pickle.load(f)
            char_to_id,id_to_char,tag_to_id,id_to_tag = pickle.load(f)
            emb_matrix = pickle.load(f)

        logger.info("%i / %i / %i sentences in train / dev / test." % (len(train_data), len(dev_data), len(test_data)))

    else:

        train_data,dev_data,test_data, char_to_id, tag_to_id, id_to_tag, emb_matrix = build_dataset()

    """ 2: 产生batch训练数据 """
    train_manager = BatchManager(train_data, config.batch_size)
    dev_manager = BatchManager(dev_data, config.batch_size)
    test_manager = BatchManager(test_data, config.batch_size) 

    model = NERLSTM_CRF(config, char_to_id, tag_to_id, emb_matrix)
    model.train()
    model.to(device)
    optimizer = optim.Adam(model.parameters(), lr=config.lr, weight_decay=config.weight_decay)

    """ 3: 用early stop 防止过拟合 """
    total_batch = 0  
    dev_best_f1 = float('-inf')
    last_improve = 0  
    flag = False     

    start_time = time.time()
    logger.info(" 开始训练模型 ...... ")
    for epoch in range(config.max_epoch):

        logger.info('Epoch [{}/{}]'.format(epoch + 1, config.max_epoch))

        for index, batch in enumerate(train_manager.iter_batch(shuffle=True)):

            optimizer.zero_grad()

            """ 计算损失和反向传播 """
            _, char_ids, seg_ids, tag_ids, mask = batch
            loss = model.log_likelihood(char_ids,seg_ids,tag_ids, mask)
            loss.backward()

            """ 梯度截断,最大梯度为5 """
            nn.utils.clip_grad_norm_(parameters=model.parameters(), max_norm=config.clip)
            optimizer.step()

            if total_batch % config.steps_check == 0:

                model.eval()
                dev_f1,dev_loss = evaluate(model, dev_manager, id_to_tag)

                """ 以f1作为early stop的监控指标 """
                if dev_f1 > dev_best_f1:

                    evaluate(model, test_manager, id_to_tag, test=True)
                    dev_best_f1 = dev_f1
                    torch.save(model, os.path.join(config.save_dir,"medical_ner.ckpt"))
                    improve = '*'
                    last_improve = total_batch
                else:
                    improve = ''

                time_dif = get_time_dif(start_time)
                msg = 'Iter: {} | Dev Loss: {:.4f} | Dev F1-macro: {:.4f} | Time: {} | {}'
                logger.info(msg.format(total_batch, dev_loss, dev_f1, time_dif, improve))  

                model.train()

            total_batch += 1
            if total_batch - last_improve > config.require_improve:
                """ 验证集f1超过5000batch没上升,结束训练 """
                logger.info("No optimization for a long time, auto-stopping...")
                flag = True
                break
        if flag:
            break                

02

损失的计算

BiLSTM+CRF的损失由发射矩阵和转移矩阵计算而得。

输入一个句子,预测的标签序列(路劲)有很多条,而正确的标签序列是其中的一条。

每条标签序列都可以计算一个分数,由句子中每个字和标签对应的发射概率,以及标签之间的转移概率,加和而成,公式如下:

P是发射矩阵,size为n×k,k为真实标签的个数,不包括<start>和<end>。

A为转移矩阵,size为(k+2)×(k+2),需要加上<start>和<end>。

同样我们可以算出正确的标签序列的分数,一般把这个分数叫做 Gold Score。


用Gold Score和所有可能路径的分数,算一个softmax的概率,显然我们要让正确的标签序列的概率最大。

为了方便求解,在等式左右两边加了log,同时用动态规划来计算。

再加一个负号,作为Loss,让Loss最小化。

具体细节可参考:

BiLSTM上的CRF,用命名实体识别任务来解释CRF(2)损失函数

现在我们再回到是否加<start>和<end>这个问题上来。
 

从损失函数的公式可以看到,样本前后可以不加这两个标记,标签序列是要加的。

03

模型的评估

命名实体识别可以看作是token(字)级别或实体级别的多分类,评估指标还是Precision,Recall和F1值。

所以从token和实体两个角度来看,命名实体识别的评测方式分为两种,一是基于所有token标签的评测,二是考虑实体边界+实体类型的评测。

基于所有token标签的评测,是一种宽松匹配的方法,就是把所有测试样本的真实标签展成一个列表,把预测标签也展成一个列表,然后直接计算Precision、Recall和F1值。

考虑实体边界和实体类型的评测方法,是一种精准匹配的方法,只有当实体边界和实体类别同时被标记正确,才能认为实体识别正确。

考虑实体边界和实体类型的方法实现起来比较复杂,千万不要为难自己,直接用别人写好的包就行。

用的比较多的是CoNLL-2000的一个评估脚本,原本是用Perl写的,网上有python的实现,支持IOBES格式。

这次就是参考了这个python版的实现:

https://github.com/spyysalo/conlleval.py

原代码是把文本、真实标签和预测标签用空格拼接,写入一个预测结果文件,再直接加载该文件进行评估,并写入一个评估结果文件。

预测结果文件的格式如下:

无 O O
长 B-PT B-PT
期 I-PT I-PT
外 I-PT I-PT
地 I-PT I-PT
居 I-PT I-PT
住 I-PT I-PT
史 E-PT E-PT
。 O O

无 O O
家 B-DIS B-DIS
族 I-DIS I-DIS
性 I-DIS I-DIS
遗 I-DIS I-DIS
传 I-DIS I-DIS
病 E-DIS E-DIS
史 O O
。 O O

所以在准备数据和生成batch的时候,我们也需要拆成字的样本,而不仅是id和MASK矩阵。

class BatchManager(object):

    def __init__(self, data,  batch_size):

    def sort_and_pad(self, data, batch_size):

    @staticmethod
    def pad_data(data):
        """
        构造一个mask矩阵,对pad进行mask,不参与loss的计算
        另外,除了id以外,字本身,因为用CoNLL-2000的脚本评估时需要,所以也加上。
        """

        batch_chars = []
        batch_chars_idx = []
        batch_segs_idx = []
        batch_tags_idx = []
        batch_mask = []

        max_length = max([len(sentence[0]) for sentence in data])
        for line in data:
            chars, chars_idx, segs_idx, tags_idx = line

            padding = [0] * (max_length - len(chars_idx))

            """ CoNLL-2000的评估脚本需要用到 """
            batch_chars.append(chars + padding)

            batch_chars_idx.append(chars_idx + padding)
            batch_segs_idx.append(segs_idx + padding)
            batch_tags_idx.append(tags_idx + padding)
            batch_mask.append([1] * len(chars_idx) + padding)

        batch_chars_idx = torch.LongTensor(batch_chars_idx).to(device)
        batch_segs_idx = torch.LongTensor(batch_segs_idx).to(device)
        batch_tags_idx = torch.LongTensor(batch_tags_idx).to(device)
        batch_mask = torch.tensor(batch_mask,dtype=torch.uint8).to(device)

        return [batch_chars, batch_chars_idx, batch_segs_idx, batch_tags_idx, batch_mask]

    def iter_batch(self, shuffle=True):

另外为了在训练过程中能够进行测试,并打印测试结果,需要对conlleval.py中的report函数进行一点修改,不再是保存为一个评估结果文件,而是放在一个列表里。

def report_notprint(counts, out=None):
    if out is None:
        out = sys.stdout

    overall, by_type = metrics(counts)

    c = counts
    final_report = []
    line = []
    line.append('processed %d tokens with %d phrases; ' %
              (c.token_counter, c.found_correct))
    line.append('found: %d phrases; correct: %d.\n' %
              (c.found_guessed, c.correct_chunk))
    final_report.append("".join(line))

    if c.token_counter > 0:
        line = []
        line.append('accuracy: %6.2f%%; ' %
                  (100.*c.correct_tags/c.token_counter))
        line.append('precision: %6.2f%%; ' % (100.*overall.prec))
        line.append('recall: %6.2f%%; ' % (100.*overall.rec))
        line.append('FB1: %6.2f\n' % (100.*overall.fscore))
        final_report.append("".join(line))

    for i, m in sorted(by_type.items()):
        line = []
        line.append('%17s: ' % i)
        line.append('precision: %6.2f%%; ' % (100.*m.prec))
        line.append('recall: %6.2f%%; ' % (100.*m.rec))
        line.append('FB1: %6.2f  %d\n' % (100.*m.fscore, c.t_found_guessed[i]))
        final_report.append("".join(line))
    return final_report

训练过程中打印的测试结果如下:

好,介绍完了conlleval.py这个包的使用,我们回来看模型的评估部分代码。

首先计算得到预测的标签和损失。

为了使用CoNLL-2000的实体识别评估脚本,我们需要按其要求的格式来处理预测的标签,即:家 B-DIS B-DIS 这种形式。

def evaluate_helper(model, data_manager, id_to_tag):


    with torch.no_grad():

        total_loss = 0
        results = []
        for batch in data_manager.iter_batch():

            chars, char_ids, seg_ids, tag_ids, mask = batch

            batch_paths = model(char_ids,seg_ids,mask)
            loss = model.log_likelihood(char_ids, seg_ids, tag_ids,mask)
            total_loss += loss.item()    

            """ 忽略<pad>标签,计算每个样本的真实长度 """
            lengths = [len([j for j in i if j > 0]) for i in tag_ids.tolist()]

            tag_ids = tag_ids.tolist()
            for i in range(len(chars)):
                result = []
                string = chars[i][:lengths[i]]

                """ 把id转换为标签 """
                gold = [id_to_tag[int(x)] for x in tag_ids[i][:lengths[i]]]
                pred = [id_to_tag[int(x)] for x in batch_paths[i][:lengths[i]]]               

                """ 用CoNLL-2000的实体识别评估脚本, 需要按其要求的格式保存结果,
                即 字-真实标签-预测标签 用空格拼接"""
                for char, gold, pred in zip(string, gold, pred):
                    result.append(" ".join([char, gold, pred]))
                results.append(result)

        aver_loss = total_loss / (data_manager.len_data * config.batch_size)        
        return results, aver_loss  

接着调用评估脚本,计算每类实体的Precision、Recall和F1值,如果是测试的话,打印测试结果,如上图所示。

def evaluate(model, data, id_to_tag, test=False):

    """ 得到预测的标签(非id)和损失 """
    ner_results, aver_loss = evaluate_helper(model, data, id_to_tag)

    """ 用CoNLL-2000的实体识别评估脚本来计算F1值 """
    eval_lines = test_ner(ner_results, config.save_dir)

    if test:

        """ 如果是测试,则打印评估结果 """
        for line in eval_lines:
            logger.info(line)

    f1 = float(eval_lines[1].strip().split()[-1]) / 100

    return f1, aver_loss

04

模型的预测

预测部分比较简单,加载训练好的模型,将文本转化为id,并提取分词特征,送入模型中进行维特比解码,得到预测的路径(标签的id),再转化为标签。

维特比算法这里就不提了。

def predict(input_str):

    with open(config.map_file, "rb") as f:
        char_to_id, id_to_char, tag_to_id, id_to_tag = pickle.load(f)

    """ 用cpu预测 """
    model = torch.load(os.path.join(config.save_dir,"medical_ner_f1_0.976.ckpt"), 
                       map_location="cpu"
    )
    model.eval()

    if not input_str:
        input_str = input("请输入文本: ")    

    _, char_ids, seg_ids, _ = prepare_dataset([input_str], char_to_id, tag_to_id, test=True)[0]
    char_tensor = torch.LongTensor(char_ids).view(1,-1)
    seg_tensor = torch.LongTensor(seg_ids).view(1,-1)

    with torch.no_grad():

        """ 得到维特比解码后的路径,并转换为标签 """
        paths = model(char_tensor,seg_tensor)    
        tags = [id_to_tag[idx] for idx in paths[0]]

    pprint(result_to_json(input_str, tags))


if __name__ == "__main__":

    if config.train:

        train()

    else:

        input_str = "循环系统由心脏、血管和调节血液循环的神经体液组织构成,循环系统疾病也称为心血管病。"
        predict(input_str)

最后用result_to_json这个函数,对预测进行进行规范输出,得到的结果如下。

输出了提取的实体、类别以及在句中的位置边界。

{'entities': [{'end': 7, 'start': 5, 'type': 'ORG', 'word': '心脏'},
              {'end': 10, 'start': 8, 'type': 'ORG', 'word': '血管'},
              {'end': 40, 'start': 36, 'type': 'DIS', 'word': '心血管病'}],

 'string': '循环系统由心脏、血管和调节血液循环的神经体液组织构成,循环系统疾病也称为心血管病。'}

好了,这个模型的介绍到此为止,更多的代码细节,感兴趣的同学自己去跑跑。

参考资料:

1:《Neural Architectures for Named Entity Recognition》

2:《BiLSTM上的CRF,用命名实体识别任务来解释CRF(2)损失函数》

3:https://github.com/spyysalo/conlleval.py

4:https://github.com/Alic-yuan/nlp-beginner-finish

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值