戰(zhàn))
簡(jiǎn)介本資源面向自然語言處理方向的學(xué)生與開發(fā)者提供一套基于BERTBiLSTMCRF的法律文書命名實(shí)體識(shí)別完整項(xiàng)目源碼聚焦交通肇事案的事件要素抽取任務(wù)可作為課程設(shè)計(jì)、期末大作業(yè)或NLP入門實(shí)戰(zhàn)參考。壓縮包共48個(gè)文件約694KB以21個(gè)Python腳本為核心涵蓋模型定義、數(shù)據(jù)加載、訓(xùn)練與預(yù)測(cè)流程另含xml配置、train/test/dev數(shù)據(jù)集、日志、pkl映射文件及README說明文檔結(jié)構(gòu)清晰便于二次開發(fā)。目前已有190人學(xué)習(xí)下載。項(xiàng)目整合預(yù)訓(xùn)練語言模型與序列標(biāo)注架構(gòu)配套項(xiàng)目說明與預(yù)訓(xùn)練權(quán)重加載腳本讀者可據(jù)此理解法律領(lǐng)域?qū)嶓w抽取的完整鏈路包括數(shù)據(jù)預(yù)處理、模型搭建、訓(xùn)練評(píng)估與結(jié)果輸出并可直接運(yùn)行驗(yàn)證省去從零搭建的調(diào)試成本適合希望快速上手NLP序列標(biāo)注實(shí)踐的學(xué)習(xí)者。1. 法律文書要素抽取BERTBiLSTMCRF 到底在解決什么交通肇事案的卷宗里真正決定定性和量刑的往往就那么幾個(gè)詞肇事時(shí)間、肇事地點(diǎn)、車牌號(hào)、傷亡人數(shù)、責(zé)任認(rèn)定結(jié)果。一份判決書動(dòng)輒幾千字人工摘這些要素一個(gè)熟手一天也就處理幾十份還容易漏。把這件事交給模型就是命名實(shí)體識(shí)別NER要干的活。而 BERTBiLSTMCRF 這套組合是中文法律文書 NER 里被驗(yàn)證過很多次的經(jīng)典結(jié)構(gòu)BERT 負(fù)責(zé)把「肇事逃逸」和「駕車駛離」這種語義相近但字面不同的表述壓到相近的向量空間BiLSTM 負(fù)責(zé)捕捉實(shí)體跨詞的上下文依賴CRF 負(fù)責(zé)保證輸出的標(biāo)簽序列合法——比如「B-地點(diǎn)」后面不會(huì)突兀地跟一個(gè)「I-人名」。這篇筆記就圍繞這個(gè)標(biāo)題把數(shù)據(jù)怎么標(biāo)、模型怎么搭、參數(shù)怎么調(diào)、坑在哪一條條講清楚適合手里有法律文書數(shù)據(jù)、想跑通要素抽取的 Python 開發(fā)者照著復(fù)現(xiàn)。2. 為什么是 BERTBiLSTMCRF三層結(jié)構(gòu)各管什么2.1 單用 BERT 做序列標(biāo)注差在哪很多人第一反應(yīng)是直接在 BERT 后面接一個(gè)線性分類層每個(gè) token 輸出一個(gè)標(biāo)簽。這條路能跑但在法律文書上會(huì)暴露兩個(gè)問題。第一BERT 的輸出是逐 token 獨(dú)立的標(biāo)簽之間的轉(zhuǎn)移約束它學(xué)不到模型可能吐出「I-時(shí)間」開頭這種非法序列。第二法律文書里實(shí)體邊界經(jīng)常靠長(zhǎng)距離依賴判斷比如「被告人張某于 2023 年 5 月 12 日 19 時(shí)許」「19 時(shí)許」是不是時(shí)間實(shí)體要看前面有沒有「于……日」這個(gè)框架單層分類對(duì)這類結(jié)構(gòu)不敏感。CRF 解決的正是第一類問題。它把標(biāo)簽序列當(dāng)成一個(gè)整體來打分轉(zhuǎn)移矩陣?yán)铩窧-地點(diǎn) → I-地點(diǎn)」的分?jǐn)?shù)高「B-地點(diǎn) → I-人名」的分?jǐn)?shù)低解碼時(shí)用 Viterbi 找全局最優(yōu)路徑非法序列自然被壓下去。這也是為什么在法律、醫(yī)療、金融這些對(duì)實(shí)體邊界要求嚴(yán)的領(lǐng)域CRF 幾乎是標(biāo)配。2.2 BiLSTM 夾在中間不是多余的既然 BERT 已經(jīng)很強(qiáng)為什么還要塞一層 BiLSTM這里有個(gè)實(shí)操層面的理由BERT 的預(yù)訓(xùn)練目標(biāo)讓它更擅長(zhǎng)抓全局語義但對(duì)相鄰 token 之間的細(xì)粒度模式比如「肇」「事」「車」三個(gè)字連在一起才是完整實(shí)體捕捉得不夠銳利。BiLSTM 雙向掃描一遍前向 LSTM 記住「被告人」開頭后向 LSTM 記住「駕駛」結(jié)尾把局部序列特征再?gòu)?qiáng)化一次再交給 CRF。在中文法律文書這種實(shí)體嵌套多、簡(jiǎn)稱多的語料上加這一層通常能把 F1 拉高 1 到 3 個(gè)百分點(diǎn)。代價(jià)是參數(shù)量和推理時(shí)間增加。如果只是做個(gè) demoBERT線性層夠用如果要上生產(chǎn)、對(duì)邊界敏感BiLSTM 這層值得留。2.3 標(biāo)簽體系怎么定BIO 還是 BIOES標(biāo)簽體系直接決定后面所有代碼的寫法。BIO 只有 B實(shí)體開始、I實(shí)體內(nèi)部、O非實(shí)體三種簡(jiǎn)單但邊界模糊BIOES 多了 E實(shí)體結(jié)束和 S單字實(shí)體邊界更清晰代價(jià)是標(biāo)簽數(shù)翻倍。交通肇事案要素抽取我一般用 BIOES因?yàn)椤杠嚺铺?hào)」這種實(shí)體經(jīng)常是「京 A12345」這種字母數(shù)字混排BIO 下模型容易把邊界切錯(cuò)。下面是一份典型的標(biāo)簽定義標(biāo)簽含義示例B-TIME時(shí)間實(shí)體開始2023 年I-TIME時(shí)間實(shí)體內(nèi)部5 月E-TIME時(shí)間實(shí)體結(jié)束12 日S-LOC單字地點(diǎn)實(shí)體京B-PLATE車牌開始京 AI-PLATE車牌內(nèi)部12345O非實(shí)體被告人標(biāo)簽定完寫進(jìn)一個(gè)labels.txt一行一個(gè)順序固定。后面模型輸出的 id 和這個(gè)文件必須嚴(yán)格對(duì)應(yīng)錯(cuò)一位整個(gè)訓(xùn)練就白跑。3. 從原始判決書到可訓(xùn)練數(shù)據(jù)標(biāo)注與預(yù)處理3.1 數(shù)據(jù)從哪來、怎么切法律文書公開渠道能拿到判決書文本但直接拿來訓(xùn)練不行得先做幾件事去掉頁眉頁腳、去掉審判人員簽名段落、把全角標(biāo)點(diǎn)統(tǒng)一成半角。交通肇事案的要素集中在「經(jīng)審理查明」到「本院認(rèn)為」之間可以按這個(gè)區(qū)間截取減少無關(guān)噪聲。切分粒度上中文 NER 一般按字切不按詞切。原因是法律文書里專有名詞多分詞器容易把「交通事故責(zé)任認(rèn)定書」切成奇怪的組合按字切反而穩(wěn)定。BERT 的中文預(yù)訓(xùn)練模型本身就是按字做的銜接也順。3.2 標(biāo)注工具與格式轉(zhuǎn)換標(biāo)注可以用 brat、Label Studio 這類工具導(dǎo)出成 JSON 或 TSV。核心是把「字符位置 標(biāo)簽」轉(zhuǎn)成模型要的「每字一標(biāo)簽」。下面這段代碼把「實(shí)體區(qū)間列表」轉(zhuǎn)成 BIOES 序列def spans_to_bioes(text, spans): text: 原始字符串 spans: [(start, end, label), ...] end 為開區(qū)間 返回: 與 text 等長(zhǎng)的標(biāo)簽列表 tags [O] * len(text) for start, end, label in spans: if end - start 1: tags[start] fS-{label} else: tags[start] fB-{label} for i in range(start 1, end - 1): tags[i] fI-{label} tags[end - 1] fE-{label} return tags邏輯說明單字實(shí)體直接標(biāo) S多字實(shí)體首字 B、末字 E、中間全 I。參數(shù)上要注意end是開區(qū)間如果標(biāo)注工具給的是閉區(qū)間調(diào)用前要end 1。這一步錯(cuò)了后面標(biāo)簽全錯(cuò)位而且不報(bào)錯(cuò)屬于最隱蔽的翻車點(diǎn)。3.3 構(gòu)建 Dataset 與對(duì)齊 BERT 分詞BERT 分詞器會(huì)在字之間插[CLS]、[SEP]還可能把某些字符拆成子詞。中文按字切時(shí)基本一字一 token但仍要對(duì)齊標(biāo)簽保證input_ids和labels長(zhǎng)度一致from torch.utils.data import Dataset import torch class NerDataset(Dataset): def __init__(self, texts, tags_list, tokenizer, label2id, max_len256): self.texts texts self.tags_list tags_list self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] tags self.tags_list[idx] # 按字切不加特殊符號(hào)以外的處理 chars list(text) input_ids self.tokenizer.convert_tokens_to_ids(chars) # 手動(dòng)加 [CLS] 和 [SEP] cls_id self.tokenizer.cls_token_id sep_id self.tokenizer.sep_token_id input_ids [cls_id] input_ids[:self.max_len - 2] [sep_id] label_ids [self.label2id[O]] \ [self.label2id[t] for t in tags[:self.max_len - 2]] \ [self.label2id[O]] return { input_ids: torch.tensor(input_ids), labels: torch.tensor(label_ids), attention_mask: torch.ones(len(input_ids), dtypetorch.long) }邏輯說明這里沒有用tokenizer()的自動(dòng)編碼而是手動(dòng)convert_tokens_to_ids目的是完全掌控對(duì)齊關(guān)系。參數(shù)max_len256是經(jīng)驗(yàn)值交通肇事案要素句一般不超過這個(gè)長(zhǎng)度超長(zhǎng)截?cái)鄷r(shí)優(yōu)先保留「經(jīng)審理查明」段落。[CLS]和[SEP]位置標(biāo) O不參與實(shí)體損失計(jì)算時(shí)可以在 loss 里 mask 掉。提示如果換用其他中文預(yù)訓(xùn)練模型先確認(rèn)它的分詞粒度是不是按字。按詞分詞的模型直接套這段代碼會(huì)錯(cuò)位。4. 模型搭建BERT 輸出怎么接 BiLSTM 再接 CRF4.1 整體結(jié)構(gòu)與前向傳播結(jié)構(gòu)順序是 BERT → BiLSTM → 線性層 → CRF。BERT 輸出每個(gè) token 的隱狀態(tài)BiLSTM 再掃一遍線性層把維度映射到標(biāo)簽數(shù)CRF 負(fù)責(zé)序列打分。下面是一個(gè)可運(yùn)行的 PyTorch 實(shí)現(xiàn)import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden self.bert.config.hidden_size # 通常 768 self.bilstm nn.LSTM( input_sizehidden, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue ) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state # [B, L, 768] lstm_out, _ self.bilstm(seq_out) # [B, L, 512] lstm_out self.dropout(lstm_out) emissions self.classifier(lstm_out) # [B, L, num_tags] if labels is not None: # CRF 的 loss 是負(fù)對(duì)數(shù)似然取負(fù)號(hào) loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: # 解碼返回最優(yōu)路徑 return self.crf.decode(emissions, maskattention_mask.bool())邏輯說明lstm_hidden256雙向拼接后是 512和classifier輸入對(duì)上。dropout0.3是法律文書這種中等規(guī)模語料的常用值數(shù)據(jù)量上萬條可以降到 0.1。CRF 的mask參數(shù)必須傳否則 padding 位置會(huì)參與打分訓(xùn)練 loss 會(huì)虛低驗(yàn)證時(shí) F1 卻上不去這是很典型的黑匣子現(xiàn)象。4.2 三個(gè)必調(diào)參數(shù)學(xué)習(xí)率、batch size、CRF 學(xué)習(xí)率BERT 微調(diào)的學(xué)習(xí)率不能大一般 2e-5 到 5e-5。BiLSTM 和 CRF 是隨機(jī)初始化的需要更大的學(xué)習(xí)率常見做法是分組設(shè)置bert_params list(model.bert.named_parameters()) other_params [(n, p) for n, p in model.named_parameters() if not n.startswith(bert)] optimizer torch.optim.AdamW([ {params: [p for _, p in bert_params], lr: 3e-5}, {params: [p for _, p in other_params], lr: 1e-3} ], weight_decay0.01)邏輯說明BERT 用 3e-5BiLSTMCRF 用 1e-3差兩個(gè)數(shù)量級(jí)。如果統(tǒng)一用一個(gè)學(xué)習(xí)率要么 BERT 被訓(xùn)崩要么 CRF 學(xué)不動(dòng)。weight_decay0.01是 AdamW 的常規(guī)值對(duì) BERT 微調(diào)有正則效果。batch size 方面單卡 16GB 顯存max_len256時(shí) batch 開到 16 比較穩(wěn)。顯存不夠就降到 8同時(shí)把學(xué)習(xí)率按比例調(diào)小一點(diǎn)否則梯度噪聲大收斂曲線會(huì)抖。4.3 訓(xùn)練循環(huán)與驗(yàn)證指標(biāo)訓(xùn)練時(shí)每個(gè) epoch 結(jié)束在驗(yàn)證集上算實(shí)體級(jí) F1用seqeval庫不要用 token 級(jí)準(zhǔn)確率——后者在 O 標(biāo)簽占 90% 以上的語料上能到 0.9但模型可能一個(gè)實(shí)體都沒識(shí)別出來。from seqeval.metrics import f1_score, classification_report def evaluate(model, dataloader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for batch in dataloader: logits model(batch[input_ids], batch[attention_mask]) for pred, true in zip(logits, batch[labels]): preds.append([id2label[i] for i in pred]) trues.append([id2label[i] for i in true.tolist()]) return f1_score(trues, preds)邏輯說明seqeval按實(shí)體整體算 P/R/F1邊界錯(cuò)一個(gè)字就算錯(cuò)符合法律要素抽取的實(shí)際要求。驗(yàn)證時(shí)記得把[CLS]、[SEP]和 padding 位置的標(biāo)簽去掉否則會(huì)拉低指標(biāo)。5. 避坑與排查訓(xùn)練不收斂、F1 上不去的五個(gè)真實(shí)原因5.1 現(xiàn)象loss 一直降F1 卻卡在 0.3 不動(dòng)原因通常是標(biāo)簽對(duì)齊錯(cuò)了。手動(dòng)拼input_ids時(shí)如果[CLS]加了但標(biāo)簽沒加對(duì)應(yīng)占位或者截?cái)鄷r(shí)input_ids截了而labels沒截模型學(xué)到的就是錯(cuò)位映射。排查方法取一條樣本把input_ids解碼回文字和labels逐位對(duì)照看實(shí)體位置是否吻合。解決就是統(tǒng)一在 Dataset 里做對(duì)齊別在別處再動(dòng)。5.2 現(xiàn)象驗(yàn)證集 F1 波動(dòng)極大相鄰兩個(gè) epoch 差 0.2原因一般是 batch size 太小加上學(xué)習(xí)率偏高梯度噪聲大。法律文書語料如果只有幾千條batch8 時(shí)尤其明顯。解決把 BERT 學(xué)習(xí)率降到 2e-5BiLSTM 那組降到 5e-4同時(shí)開梯度累積累積 4 步等效 batch32。另外驗(yàn)證集本身如果只有幾十條指標(biāo)波動(dòng)也正常建議驗(yàn)證集至少 500 條以上。5.3 現(xiàn)象模型把「被告人張某」整體標(biāo)成一個(gè)人名原因是標(biāo)簽體系里人名和稱謂沒分開或者訓(xùn)練數(shù)據(jù)里「被告人姓名」總是連在一起標(biāo)注。解決在標(biāo)注規(guī)范里明確「被告人」是 O「張某」才是 B-PERSON重新標(biāo)一批數(shù)據(jù)。如果重標(biāo)成本高可以在后處理里用規(guī)則把「被告人」「被害人」前綴切掉。5.4 現(xiàn)象車牌號(hào)識(shí)別率特別低車牌是字母數(shù)字混排BERT 中文預(yù)訓(xùn)練時(shí)這類字符見得少向量質(zhì)量差。解決有兩個(gè)方向一是把車牌里的字母數(shù)字在預(yù)處理時(shí)統(tǒng)一大寫減少形態(tài)變化二是在 BiLSTM 后單獨(dú)給車牌類實(shí)體加權(quán)l(xiāng)oss 里對(duì) B-PLATE、I-PLATE 乘一個(gè) 1.5 的系數(shù)。實(shí)測(cè)第二種能把車牌 F1 從 0.6 拉到 0.78 左右。5.5 現(xiàn)象推理時(shí) CRF 解碼報(bào) mask 維度錯(cuò)誤torchcrf的decode要求 mask 是 bool 類型且形狀和 emissions 前兩維一致。常見錯(cuò)誤是傳了attention_mask但沒轉(zhuǎn) bool或者 batch 里最后一條樣本長(zhǎng)度不同導(dǎo)致 padding 沒對(duì)齊。解決在 collate_fn 里用pad_sequence統(tǒng)一長(zhǎng)度mask 用attention_mask.bool()別用 int。6. 進(jìn)階用規(guī)則后處理把 F1 再抬一截模型跑通之后真正上生產(chǎn)前還有一步規(guī)則后處理。法律文書的要素有很強(qiáng)的格式規(guī)律模型漏掉的規(guī)則能補(bǔ)模型標(biāo)錯(cuò)的規(guī)則能糾。我一般會(huì)加三層規(guī)則。第一層是時(shí)間歸一化。模型識(shí)別出「2023 年 5 月 12 日 19 時(shí)許」后處理統(tǒng)一轉(zhuǎn)成2023-05-12 19:00這種標(biāo)準(zhǔn)格式方便入庫。第二層是車牌校驗(yàn)。中國(guó)大陸車牌有固定格式用正則[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊][A-Z][A-Z0-9]{5}過濾一遍模型輸出不符合這個(gè)模式的直接丟棄。第三層是實(shí)體去重。同一份文書里「張某」可能出現(xiàn)多次按字符位置去重只保留首次出現(xiàn)。import re PLATE_RE re.compile( r[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊] r[A-Z][A-Z0-9]{5} ) def post_process(entities): entities: [{text:..., label:..., start:...}, ...] cleaned [] seen set() for ent in entities: key (ent[text], ent[label]) if key in seen: continue if ent[label] PLATE and not PLATE_RE.fullmatch(ent[text]): continue seen.add(key) cleaned.append(ent) return cleaned邏輯說明fullmatch要求整個(gè)字符串匹配車牌模式避免「京 A12345 號(hào)車」這種帶后綴的被誤判。去重按(text, label)做同一實(shí)體不同位置只留一個(gè)。這一步不復(fù)雜但在實(shí)際項(xiàng)目里能把精確率抬 3 到 5 個(gè)百分點(diǎn)。驗(yàn)證后處理效果別只看整體 F1要分實(shí)體類型看。時(shí)間、地點(diǎn)這類規(guī)則強(qiáng)的實(shí)體后處理提升明顯人名、責(zé)任認(rèn)定這類語義強(qiáng)的后處理基本沒幫助甚至可能誤傷。我一般會(huì)留一個(gè)--use_postprocess開關(guān)A/B 對(duì)比后再?zèng)Q定線上開不開。最后說個(gè)習(xí)慣每次改完標(biāo)簽體系或預(yù)處理邏輯先拿 20 條樣本過一遍全流程人工核對(duì)輸入輸出再啟動(dòng)訓(xùn)練。這個(gè)動(dòng)作花不了十分鐘但能省下幾個(gè)小時(shí)的無效訓(xùn)練。法律文書 NER 這活模型結(jié)構(gòu)是骨架數(shù)據(jù)對(duì)齊是血肉規(guī)則后處理是衣服哪一層偷懶都會(huì)在最終指標(biāo)上現(xiàn)形。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取