如何用序列分类方式进行法律要素与当事人关联性分析

在智慧司法领域中,针对法律裁判文书的分析和挖掘已经成为计算法学的研究热点。目前公开的裁判文书资料大都以长篇文本的形式出现,内容主要包含案号、当事人、案由、审理过程、裁判结果、判决依据等,篇幅较长、表述复杂,不管对于普通民众或是司法领域从业人员而言,经过阅读裁判文书来准确、快速地了解案件要点信息,都是一项复杂、耗时的工做。所以,借助AI技术快速准确解构裁判文书,结构化展现文书中的关键信息,成为了大数据时代司法领域的迫切需求之一。php

2020“睿聚杯”全国高校法律科技创新大赛,是面向全国高校开展的一场高水平的法律科创竞赛。本文介绍了比赛冠军团队采用的技术方案,该方案的优点在于其基于百度飞桨平台实现,使用ERNIE做为预训练模型,并以“序列分类”为主要思路完成比赛项目方案。该方案最终以F1=91.991的成绩取得了第一名,相比Baseline的分数提升了3.267。git

下载安装命令

## CPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/cpu paddlepaddle

## GPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/gpu paddlepaddle-gpu

赛题分析算法

在众多裁判文书信息挖掘与分析任务中,“法律要素与当事人关联性分析任务”因其对判决结果影响的重要性和算法设计技术难度,受到了愈来愈多法律科技研究人员的关注。举例而言,“多人多罪”在司法行业中是一种比较常见的现象,且在司法行业须要对每一个人的不一样罪名进行判断。本题目须要利用模型和算法对输入的文本、法律要素与当事人进行匹配判断,判断在当前输入文本中,法律要素与当事人之间的对应关系。json

本次竞赛的主题是“法律要素与当事人的关联性分析”,核心是根据给定信息,判断要素与当事人是否匹配。网络

数据样例

首先,对比赛提供的数据进行分析,数据的内容和形式以下:app

  • 文 号:(2016)豫1402刑初53号框架

  • 段落内容:商丘市梁园区人民检察院指控:一、2015年7月17日、18日,被告人刘磊、杜严二人分别在山东省单县中心医院和商丘市工行新苑盗窃现代瑞纳轿车两辆,共价值人民币107594元。其中将一辆轿车低价卖给被告人苗某某,被告人苗某某明知是赃车而予以收购。公诉机关向法庭提供了被告,是被告人供述、被害人陈述、证人证言、鉴定意见、有关书证等证据,认为被告刘磊、杜严的行为触犯了《中华人民共和国刑法》第二百六十四条之规定,构成盗窃罪。系共同犯罪。被害人苗某某的行为触犯了《中华人民共和国刑法》第二百一十二条第一款之规定,构成掩饰、隐瞒犯罪所得罪。请求依法判处。dom

  • 被告人集合:[“刘磊”,“杜严”,“苗某某”]ide

  • 句 子:一、2015年7月17日、18日,被告人刘磊、杜严二人分别在山东省单县中心医院和商丘市工行新苑盗窃现代瑞纳轿车两辆,共价值人民币107594元函数

  • 要素原始值:盗窃现代瑞纳轿车

  • 要素名称:盗窃、抢劫、诈骗、抢夺的机动车

  • 被告人:[“刘磊”]

这里给出了一条数据样例,每条数据中都包括以上字段。其中段落内容直接来自于公开法律文书,被告人集合是全部段落中提到的被告人。句子是段落中的某个片断,包含须要分析要素的原始表达。咱们须要根据这些已知信息,预测出与要素名称相对应的被告人。

官方给定的数据集中的文本均来源于公开的法律文书,共包含6958条样本数据,模型最终评价指标是宏平均F1(Macro-averaging F1)。

Baseline(official)

图1. Baseline模型结构

咱们对官方提供的Baseline方案进行了分析:官方提供的Baseline方案将这个任务定义为NER,将要素原始值和句子输入到模型中,在句子中标记出与该要素原始值对应的人名,模型结构如图1所示。

在本例中,句子包含多我的名(赵某甲、赵某、龙某),但与给定要素相关的只有赵某甲,所以模型只标出赵某甲。该方案难以应对句子中没有人或者包含多我的名的状况。

任务定义:序列分类

Baseline方案采用的NER形式对于句子中没有人名的状况和包含多我的名的状况效果较差,所以咱们结合给定的数据从新构思赛题方案。考虑到数据中已经给定了被告人集合,咱们将赛题任务从新定义为序列分类任务,如图2所示。将被告人、要素名称以及句子做为输入,判断输入的被告人是否与给定要素名称相关,若相关则模型预测1,不然预测0。

图2. Sequence Classification模型结构

 

模型描述

相比于BERT而言,ERNIE对中文实体更加敏感,所以本方案选取ERNIE做为主体。如图3所示,为了使输入更符合ERNIE的预训练方式,本方案将被告人和要素名称做为输入的sentence A,句子做为sentence B。将CLS位置的hidden state外接一层全链接网络,经过sigmoid函数将logit压缩到0到1之间。

为了加强关键部分的信息,咱们在被告人和要素原始值两端各添加了四个特殊标记[PER_S]和[PER_E]分别表示句中“被告人(person)”的起始位置start和end,[OVS]和[OVE]分别表示“要素原始值(ovalue)”的起始位置start和end,以指望模型可以学习到这种范式,更多地关注到这两部分信息。

图3. Model Description

Model核心代码:

class ErnieForElementClassification(ErnieModel):
    def __init__(self, cfg, name=None):
        super(ErnieForElementClassification, self).__init__(cfg, name=name)
        initializer = F.initializer.TruncatedNormal(scale=cfg['initializer_range'])
        self.classifier = _build_linear(cfg['hidden_size'], cfg['num_labels'], append_name(name, 'cls'), initializer)  
        prob = cfg.get('classifier_dropout_prob', cfg['hidden_dropout_prob'])
        self.dropout = lambda i: L.dropout(i, dropout_prob=prob,   dropout_implementation="upscale_in_train",) if self.training else i

    @add_docstring(ErnieModel.forward.__doc__)
    def forward(self, *args, **kwargs):
        labels = kwargs.pop('labels', None)
        pooled, encoded = super(ErnieForElementClassification, self).forward(*args, **kwargs)
        hidden = self.dropout(pooled)
        logits = self.classifier(hidden)
        logits = L.sigmoid(logits)
        sqz_logits = L.squeeze(logits, axes=[1])
        if labels is not None:
            if len(labels.shape) == 1:
                labels = L.reshape(labels, [-1, 1])
            part1 = L.elementwise_mul(labels, L.log(logits))
            part2 = L.elementwise_mul(1-labels, L.log(1-logits))
            loss = - L.elementwise_add(part1, part2)
            loss = L.reduce_mean(loss)
            return loss, sqz_logits
        else:
            return sqz_logits

数据去噪

在本地实验阶段,咱们将官方提供的6958条原始数据(train.txt)按照以上说明的形式处理后获得31030条新数据,并按照8:2的比例划分训练集和测试集。经过分析官方给定的数据,咱们发现给定的训练数据中部分数据存在如下两个问题:

(1) sentence不包含被告人集合中的任意一个名称(sentence中找不到被告人)

(2) sentence不是段落内容的一部分(段落中找不到sentence)

若数据存在问题(1),则只经过给定的sentence没法判断要素名称对应的被告人,须要在段落中定位到sentence并根据其先后的信息进一步判断。若一条数据同时存在问题(1)和问题(2),那么根据该条数据给定的信息将不足以判断要素对应的被告人是哪个。

本方案将同时知足问题(1)和问题(2)的数据看成噪声数据,在训练过程当中将这部分数据剔除。处理后数据集信息以下表:

注释:

  • Original:官方提供的原始数据集train.txt。

  • Preprocessed:将Original数据从新整理,将“被告人集合”拆分红单独的“被告人”。

  • Denoised:去除Preprocessed中,同时知足问题(1)和(2)的样本。

  • Denoised_without_no_person:去除Denoised中,存在问题(1)的样本。

按照模型的输入形式,咱们结合官方提供的数据形式,对数据进行批处理,核心代码以下:

def pad_data(file_name, tokenizer, max_len):
    """         This function is used as the Dataset Class in PyTorch     """
    # configuration:
    file_content = json.load(open(file_name, encoding='utf-8'))
    data = []

    for line in file_content:
        paragraph = line['paragraph']
        person = line['person']
        element = line['element_name']
        sentence = line['sentence']
        ovalue = line["ovalue"]
        label = line['label']

        sentence_a = add_dollar2person(person) + element
        sentence_b = add_star2sentence(sentence, ovalue)

        src_id, sent_id = tokenizer.encode(sentence_a, sentence_b, truncate_to=max_len-3)      # 3 special tokens

        # pad src_id and sent_id (with 0 and 1 respectively)
        src_id = np.pad(src_id, [0, max_len-len(src_id)], 'constant', constant_values=0)
        sent_id = np.pad(sent_id, [0, max_len-len(sent_id)], 'constant', constant_values=1)

        data.append((src_id, sent_id, label))
    return data


def make_batches(data, batch_size, shuffle=True):
    """         This function is used as the DataLoader Class in PyTorch     """
    if shuffle:
        np.random.shuffle(data)
    loader = []
    for j in range(len(data)//batch_size):
        one_batch_data = data[j * batch_size:(j + 1) * batch_size]
        src_id, sent_id, label = zip(*one_batch_data)

        src_id = np.stack(src_id)
        sent_id = np.stack(sent_id)
        label = np.stack(label).astype(np.float32)  # change the data type to compute BCELoss conveniently

        loader.append((src_id, sent_id, label))
    return loader

在数据处理完成以后,咱们开始模型的训练,模型训练的核心代码以下:

def train(model, dataset, lr=1e-5, batch_size=1, epochs=10):

    max_steps = epochs * (len(dataset) // batch_size)
    # max_train_steps = args.epoch * num_train_examples // args.batch_size  // dev_count
    optimizer = AdamW(
        # learning_rate=LinearDecay(lr, int(0), max_steps),
        learning_rate=lr,
        parameter_list=model.parameters(),
        weight_decay=0)

    model.train()
    logging.info('start training process!')
    for epoch in range(epochs):
        # shuffle the dataset every epoch by reloading it
        data_loader = make_batches(dataset, batch_size=batch_size, shuffle=True)

        running_loss = 0.0
        for i, data in enumerate(data_loader):
            # prepare inputs for the model
            src_ids, sent_ids, labels = data

            # convert numpy variables to paddle variables
            src_ids = D.to_variable(src_ids)
            sent_ids = D.to_variable(sent_ids)
            labels = D.to_variable(labels)

            # feed into the model
            outs = model(src_ids, sent_ids, labels=labels)

            loss = outs[0]
            loss.backward()
            optimizer.minimize(loss)
            model.clear_gradients()

            running_loss += loss.numpy()[0]
            if i % 10 == 9:
                print('epoch: ', epoch + 1'\tstep: ', i + 1'\trunning_loss: ', running_loss)
                running_loss = 0.0

        state_dict = model.state_dict()
        F.save_dygraph(state_dict, './saved/plan3_all/model_'+str(epoch+1)+'epoch')
        print('model_'+str(epoch+1)+'epoch saved')

    logging.info('all model parameters saved!')

效果对比

最终与baseline相比,咱们的方案在F一、Precision和Recall三项指标上都有明显的提高。在全部25支参赛队伍中排名第一,其中F1和Precision值均为全部参赛队伍最好成绩。

方案总结

本方案将比赛任务从新定义为序列分类任务,这一任务形式将判断要素名称与被告人之间关系所需的关键信息直接做为模型的输入,而且在关键信息处添加了特殊符号,有效加强了关键信息,下降了模型判断的难度。在训练数据方面,本方案剔除了部分噪声数据。

 

实验结果也代表这一操做可以提高模型的预测表现。在测试阶段,本方案对于句子中没有被告人的状况采起了向前扩一句的方式。这一方式可以解决部分问题,但对于前一句仍不包含被告人的状况效果较差。而且在扩句后,输入序列的长度增长,而输入序列的最大长度不能超过512。所以,本方案仍需解决如下两种状况:

(1) 向前扩句后,句子中仍不包含被告人的状况;

(2) 输入序列较长的状况(分词以后达到1000个token以上)

方案改进

 

针对上一节总结的两个问题,咱们有以下的方案,但因为时间缘由未能彻底实现。如下是咱们的思路:

(1) 滑窗策略:若句子中不包含被告人,则使用该句以前的全部信息(或者直接输入段落)。这样输入序列的长度会大幅增长,这时采用多个ERNIE 512窗口,stride=128,对完整序列进行滑窗,不一样窗口重叠的地方采用pooling的方式获取最终隐藏状态。这样就打破了ERNIE输入512长度的限制;

(2) 拼接关键向量:在滑窗策略中,输入序列增长以后,相应的冗余信息也会增长。所以咱们将进一步对【被告人】和【要素原始值】的信息进行加强。现有的方案是使用[CLS]位置的最终隐藏层向量链接全链接层进行二分类,咱们能够将【被告人】和【要素原始值】每一个token位置的最终隐层向量进行取平均,而后和[CLS]位置的向量进行拼接,将原先768维的向量扩展到2304维,使用新的向量进行二分类。

本项目基于飞桨深度学习框架完成,做为首次接触Paddle的新手,在使用动态图ERNIE代码过程当中领略到了其独特的魅力!这一切都得益于百度为Paddle的使用者开发了详细的使用手册和丰富的学习资料。固然,也要感谢AI Studio提供的GPU算力资源,为咱们模型的训练和评估提供了必要的条件。

下载安装命令

## CPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/cpu paddlepaddle

## GPU版本安装命令
pip install -f https://paddlepaddle.org.cn/pip/oschina/gpu paddlepaddle-gpu