在智慧司法領域中,針對法律裁判文書的分析和挖掘已經成爲計算法學的研究熱點。目前公開的裁判文書資料大都以長篇文本的形式出現,內容主要包含案號、當事人、案由、審理過程、裁判結果、判決依據等,篇幅較長、表述複雜,不管對於普通民衆或是司法領域從業人員而言,經過閱讀裁判文書來準確、快速地瞭解案件要點信息,都是一項複雜、耗時的工做。所以,藉助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