體識(shí)別源碼解析:從課設(shè)到實(shí)戰(zhàn))
簡(jiǎn)介本資源為基于Python實(shí)現(xiàn)雙向LSTM條件隨機(jī)場(chǎng)CRF的命名實(shí)體識(shí)別模型課程作業(yè)完整包面向計(jì)算機(jī)、人工智能、自動(dòng)化等專業(yè)的學(xué)生與教師適用于期末課程設(shè)計(jì)、課程大作業(yè)或畢業(yè)設(shè)計(jì)場(chǎng)景。項(xiàng)目聚焦NLP四大基礎(chǔ)任務(wù)之一的序列標(biāo)注通過循環(huán)神經(jīng)網(wǎng)絡(luò)對(duì)句子逐字打標(biāo)簽并在LSTM層后引入CRF模型利用梯度下降自動(dòng)學(xué)習(xí)轉(zhuǎn)移參數(shù)有效避免連續(xù)B-LOC等非法標(biāo)簽序列從而提升標(biāo)注準(zhǔn)確率。壓縮包共3個(gè)文件包含1個(gè)py源碼、1個(gè)pdf作業(yè)報(bào)告和1個(gè)md說明文檔整體約201KB源碼經(jīng)本地調(diào)試可直接運(yùn)行報(bào)告則完整記錄了實(shí)驗(yàn)思路與結(jié)果分析。目前已有138人學(xué)習(xí)下載得分96分讀者可借此掌握BiLSTM與CRF結(jié)合的建模流程、損失函數(shù)設(shè)計(jì)及序列標(biāo)注評(píng)估方法也可在此基礎(chǔ)上修改調(diào)整以適配不同實(shí)體類型或數(shù)據(jù)集具備較高的學(xué)習(xí)借鑒價(jià)值。1. 從一份 96 分課設(shè)說起B(yǎng)iLSTMCRF 到底解決了什么如果你正在搜「雙向LSTM 命名實(shí)體識(shí)別 源碼」大概率是兩種情況要么課程大作業(yè)卡在序列標(biāo)注這一步要么想找一個(gè)能跑通、有報(bào)告、結(jié)構(gòu)清晰的參考實(shí)現(xiàn)。這份資源就是沖著這個(gè)場(chǎng)景來的——一個(gè)基于 Python 的 BiLSTMCRF 命名實(shí)體識(shí)別項(xiàng)目附帶作業(yè)報(bào)告和說明文檔得分 96。它要解決的核心問題很具體給定一句「我 愛 北 京」模型要輸出「O O B-LOC I-LOC」這樣的標(biāo)簽序列把地名、人名、機(jī)構(gòu)名從文本里摳出來。命名實(shí)體識(shí)別是 NLP 四大基礎(chǔ)任務(wù)里序列標(biāo)注的典型代表也是很多人第一次接觸「輸入輸出等長(zhǎng)」建模的入口。純 LSTM 能跑但會(huì)冒出「B-LOC 后面跟 B-LOC」這種現(xiàn)實(shí)中不存在的標(biāo)簽組合CRF 層就是來兜這個(gè)底的。這份源碼把 LSTM 的上下文建模能力和 CRF 的標(biāo)簽轉(zhuǎn)移約束拼在一起適合想搞懂序列標(biāo)注完整鏈路、又不想從零搭框架的人。2. 拆開 sequence_tagging.py數(shù)據(jù)流、模型結(jié)構(gòu)與訓(xùn)練循環(huán)2.1 從原始句子到嵌入向量輸入管線的三個(gè)關(guān)鍵決策拿到一份序列標(biāo)注源碼第一件事不是看模型多大而是看數(shù)據(jù)怎么進(jìn)、標(biāo)簽怎么對(duì)齊。這份項(xiàng)目的輸入是一條句子輸出是等長(zhǎng)的標(biāo)簽序列中間要經(jīng)過詞表映射、序列填充、批次對(duì)齊三步。常見做法是先把語料按字切分中文場(chǎng)景下字級(jí)比詞級(jí)更穩(wěn)避免分詞誤差傳導(dǎo)統(tǒng)計(jì)頻次建詞表低頻字統(tǒng)一映射到UNK然后每條樣本轉(zhuǎn)成索引序列。真正容易翻車的是填充和 mask。一個(gè) batch 里句子長(zhǎng)度不一短句要補(bǔ)PAD到統(tǒng)一長(zhǎng)度但PAD位置的損失不能算進(jìn)總損失否則模型會(huì)學(xué)著去預(yù)測(cè)填充位。我一般會(huì)在數(shù)據(jù)預(yù)處理階段就生成一個(gè) mask 向量訓(xùn)練時(shí)用它乘掉無效位置的 loss。下面這段是典型的批處理邏輯你可以對(duì)照自己的實(shí)現(xiàn)看有沒有漏掉 maskdef batchify(data, word2id, tag2id, batch_size, pad_tokenPAD): # data: [(words, tags), ...] batches [] for i in range(0, len(data), batch_size): chunk data[i:i batch_size] max_len max(len(w) for w, _ in chunk) word_ids, tag_ids, masks [], [], [] for words, tags in chunk: w_id [word2id.get(w, word2id[UNK]) for w in words] t_id [tag2id[t] for t in tags] # 補(bǔ)齊到 batch 內(nèi)最大長(zhǎng)度 pad_len max_len - len(words) w_id [word2id[pad_token]] * pad_len t_id [tag2id[PAD]] * pad_len mask [1] * len(words) [0] * pad_len word_ids.append(w_id) tag_ids.append(t_id) masks.append(mask) batches.append((word_ids, tag_ids, masks)) return batches這段代碼里mask是后續(xù)損失計(jì)算的關(guān)鍵1表示真實(shí) token0表示填充位。參數(shù)上batch_size一般設(shè) 16 到 64太小梯度震蕩太大顯存吃緊pad_token和UNK要分開別混用。如果你發(fā)現(xiàn)訓(xùn)練 loss 降得很快但驗(yàn)證集 F1 不動(dòng)先查 mask 有沒有正確傳到 loss 函數(shù)里。2.2 BiLSTM 編碼層為什么雙向比單向更適合中文 NER單向 LSTM 只能看到當(dāng)前字左邊的上下文但「北京」這個(gè)詞光看「北」不知道后面跟的是不是「京」得雙向才能同時(shí)利用前后信息。BiLSTM 就是把一個(gè)正向 LSTM 和一個(gè)反向 LSTM 的輸出拼起來每個(gè)時(shí)間步得到2 * hidden_dim維的表示。這份源碼里隱藏層維度通常設(shè) 128 或 256層數(shù) 1 到 2 層足夠再深對(duì)小數(shù)據(jù)集反而容易過擬合。實(shí)現(xiàn)上要注意兩點(diǎn)一是 pack_padded_sequence把填充位壓掉再送進(jìn) LSTM否則反向 LSTM 會(huì)從PAD開始讀污染上下文表示二是 dropout 的位置一般加在嵌入層之后和 LSTM 輸出之后概率 0.3 到 0.5。下面是一個(gè)簡(jiǎn)化的編碼層寫法import torch import torch.nn as nn from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers1, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(dropout) def forward(self, word_ids, lengths): # word_ids: (B, L), lengths: 每條真實(shí)長(zhǎng)度 emb self.dropout(self.embedding(word_ids)) packed pack_padded_sequence(emb, lengths, batch_firstTrue, enforce_sortedFalse) out, _ self.lstm(packed) out, _ pad_packed_sequence(out, batch_firstTrue) return self.dropout(out) # (B, L, 2*hidden_dim)padding_idx0讓嵌入層不更新填充位的向量enforce_sortedFalse省去手動(dòng)排序的麻煩。hidden_dim設(shè) 128 時(shí)輸出維度是 256后面接 CRF 之前通常再加一個(gè)線性層把維度映射到標(biāo)簽數(shù)。如果你用的是中文數(shù)據(jù)集嵌入維度 128 到 300 都常見預(yù)訓(xùn)練詞向量能加就加對(duì) F1 提升明顯。2.3 CRF 層標(biāo)簽轉(zhuǎn)移矩陣是怎么學(xué)出來的LSTM 輸出的是每個(gè)位置對(duì)每個(gè)標(biāo)簽的「發(fā)射分?jǐn)?shù)」但標(biāo)簽之間不是獨(dú)立的——「B-LOC」后面接「I-LOC」合理接「B-PER」就不合理。CRF 層引入一個(gè)轉(zhuǎn)移矩陣transitions[i][j]表示從標(biāo)簽 i 轉(zhuǎn)到標(biāo)簽 j 的分?jǐn)?shù)訓(xùn)練時(shí)這個(gè)矩陣和 LSTM 參數(shù)一起用梯度下降學(xué)。解碼時(shí)用 Viterbi 算法找全局最優(yōu)路徑而不是每個(gè)位置貪心取最大。這份源碼里 CRF 的實(shí)現(xiàn)有兩種常見寫法一種是直接調(diào)torchcrf這類庫一種是手寫前向算法和 Viterbi。手寫的好處是能看清 loss 怎么算的。核心是forward算所有路徑的分?jǐn)?shù)和配分函數(shù)viterbi找最優(yōu)路徑。下面這段是手寫 CRF 的關(guān)鍵部分class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags # 轉(zhuǎn)移矩陣transitions[i][j] 表示 i - j 的分?jǐn)?shù) self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 非法轉(zhuǎn)移如 START - 非 B在訓(xùn)練中會(huì)被壓低 self.start_trans nn.Parameter(torch.randn(num_tags)) self.end_trans nn.Parameter(torch.randn(num_tags)) def forward(self, emissions, tags, mask): # emissions: (B, L, num_tags), tags: (B, L), mask: (B, L) # 返回負(fù)對(duì)數(shù)似然訓(xùn)練時(shí)最小化 ...transitions是隨機(jī)初始化的訓(xùn)練幾輪后你會(huì)看到「B-LOC - I-LOC」的分?jǐn)?shù)明顯高于「B-LOC - B-LOC」這就是 CRF 在自動(dòng)學(xué)約束。參數(shù)上num_tags等于標(biāo)簽集大小比如 BIO 標(biāo)注下 LOC/PER/ORG 各兩個(gè)標(biāo)簽加 O就是 7 個(gè)。注意PAD標(biāo)簽不要參與轉(zhuǎn)移計(jì)算mask 要傳進(jìn) CRF 的 loss 里。2.4 訓(xùn)練循環(huán)與評(píng)估loss 怎么算、F1 怎么看訓(xùn)練循環(huán)本身不復(fù)雜但序列標(biāo)注的評(píng)估不能只看準(zhǔn)確率。因?yàn)椤窸」標(biāo)簽通常占大多數(shù)全預(yù)測(cè) O 也能有很高的 accuracy但 F1 會(huì)慘不忍睹。這份報(bào)告里用的應(yīng)該是實(shí)體級(jí)別的 precision/recall/F1也就是把預(yù)測(cè)的標(biāo)簽序列還原成實(shí)體區(qū)間再和真實(shí)區(qū)間比對(duì)。def train_step(model, crf, batch, optimizer): word_ids, tag_ids, masks batch lengths [sum(m) for m in masks] emissions model(word_ids, lengths) # CRF 的負(fù)對(duì)數(shù)似然 loss crf.neg_log_likelihood(emissions, tag_ids, masks) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() return loss.item()梯度裁剪max_norm5.0是防梯度爆炸的常規(guī)操作LSTM 加 CRF 的結(jié)構(gòu)對(duì)學(xué)習(xí)率比較敏感一般用 Adam學(xué)習(xí)率 1e-3 起步訓(xùn)練 20 到 50 輪看驗(yàn)證集 F1 什么時(shí)候不再漲。如果你發(fā)現(xiàn) loss 變成 nan先查學(xué)習(xí)率是不是太大再查 CRF 的轉(zhuǎn)移矩陣有沒有出現(xiàn)極端值。3. 跑通這份源碼環(huán)境、參數(shù)與復(fù)現(xiàn)步驟3.1 環(huán)境配置Python 版本、依賴與常見安裝坑這份項(xiàng)目是純 Python 實(shí)現(xiàn)核心依賴是 PyTorch。環(huán)境上建議 Python 3.8 到 3.10太新的版本有些舊版 torch 裝不上。依賴清單一般包括torch、numpy、tqdm如果報(bào)告里用了預(yù)訓(xùn)練詞向量可能還有g(shù)ensim。安裝時(shí)最容易卡在 torch 的版本和 CUDA 匹配上——如果你沒有 GPU直接裝 CPU 版就行命令里帶cpu后綴。# 創(chuàng)建虛擬環(huán)境避免污染全局 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安裝 CPU 版 torch版本按你 Python 版本選 pip install torch1.13.1 --index-url https://download.pytorch.org/whl/cpu pip install numpy tqdm如果你用 VSCode 或 PyCharm記得把解釋器切到剛建的 venv 里否則跑起來會(huì)報(bào)「No module named torch」。Windows 下路徑分隔符和編碼問題也常見讀語料時(shí)統(tǒng)一用encodingutf-8別依賴系統(tǒng)默認(rèn)編碼。3.2 數(shù)據(jù)格式與標(biāo)簽體系BIO 標(biāo)注怎么對(duì)齊序列標(biāo)注的數(shù)據(jù)格式通常是每行「字 標(biāo)簽」句子之間空行分隔。這份項(xiàng)目用的應(yīng)該是 BIO 標(biāo)注B- 表示實(shí)體開頭I- 表示實(shí)體內(nèi)部O 表示非實(shí)體。比如「我 愛 北 京」對(duì)應(yīng)「O O B-LOC I-LOC」。你要做的是確認(rèn)自己的數(shù)據(jù)標(biāo)簽集和代碼里的tag2id一致不一致就改配置別硬跑。標(biāo)簽含義示例O非實(shí)體我、愛B-LOC地點(diǎn)實(shí)體開頭北I-LOC地點(diǎn)實(shí)體內(nèi)部京B-PER人名實(shí)體開頭張I-PER人名實(shí)體內(nèi)部三標(biāo)簽體系一旦定了訓(xùn)練集、驗(yàn)證集、測(cè)試集必須用同一套。常見錯(cuò)誤是訓(xùn)練集有 B-ORG 但驗(yàn)證集沒有導(dǎo)致評(píng)估時(shí)除零。我一般會(huì)在預(yù)處理階段先掃一遍所有標(biāo)簽打印標(biāo)簽集和頻次確認(rèn)沒有遺漏。3.3 參數(shù)怎么調(diào)嵌入維度、隱藏層、學(xué)習(xí)率的實(shí)操建議這份源碼的默認(rèn)參數(shù)能跑但想拿到更好的 F1 得調(diào)幾個(gè)關(guān)鍵項(xiàng)。嵌入維度 128 起步有預(yù)訓(xùn)練詞向量就設(shè) 300隱藏層維度 128 到 256層數(shù) 1 層夠用學(xué)習(xí)率 1e-3配合 Adam 和梯度裁剪dropout 0.3 到 0.5數(shù)據(jù)量小就調(diào)大。batch_size 看顯存CPU 跑就設(shè) 16GPU 可以 64。config { embed_dim: 128, hidden_dim: 256, num_layers: 1, dropout: 0.4, lr: 1e-3, batch_size: 32, epochs: 30, max_norm: 5.0 }調(diào)參順序建議先固定其他項(xiàng)單獨(dú)調(diào)學(xué)習(xí)率和 dropout。如果訓(xùn)練 loss 震蕩大降學(xué)習(xí)率如果訓(xùn)練 loss 降但驗(yàn)證 F1 不漲加 dropout 或減層數(shù)。別一上來就網(wǎng)格搜索小數(shù)據(jù)集上隨機(jī)性很大跑三組對(duì)比就夠了。4. 避坑與排查跑這份源碼時(shí)最容易翻車的五個(gè)地方4.1 現(xiàn)象loss 一直是 nan原因?qū)W習(xí)率過大或 CRF 轉(zhuǎn)移矩陣溢出解決降學(xué)習(xí)率并加梯度裁剪這是最典型的翻車現(xiàn)場(chǎng)。BiLSTM 加 CRF 的結(jié)構(gòu)里CRF 的前向算法涉及指數(shù)和對(duì)數(shù)運(yùn)算如果發(fā)射分?jǐn)?shù)或轉(zhuǎn)移分?jǐn)?shù)數(shù)值過大logsumexp會(huì)溢出。先把學(xué)習(xí)率從 1e-3 降到 5e-4 或 1e-4再確認(rèn)clip_grad_norm_有沒有加。如果還不行檢查 CRF 的transitions初始化是不是用了太大的方差改成torch.randn(num_tags, num_tags) * 0.1試試。4.2 現(xiàn)象驗(yàn)證集 F1 始終為 0原因標(biāo)簽映射錯(cuò)位或 mask 沒傳進(jìn)評(píng)估解決打印第一條樣本的預(yù)測(cè)和真實(shí)標(biāo)簽逐位比對(duì)F1 為 0 通常不是模型沒學(xué)到而是評(píng)估代碼把標(biāo)簽對(duì)錯(cuò)了。先拿一條訓(xùn)練集樣本過一遍模型把預(yù)測(cè)標(biāo)簽序列和真實(shí)標(biāo)簽序列都打印出來看是不是整體偏移了一位或者PAD標(biāo)簽混進(jìn)了實(shí)體區(qū)間。另一個(gè)常見原因是評(píng)估時(shí)沒排除填充位導(dǎo)致實(shí)體邊界算錯(cuò)。我一般會(huì)在評(píng)估函數(shù)里加一句斷言確認(rèn)預(yù)測(cè)長(zhǎng)度和真實(shí)長(zhǎng)度一致。4.3 現(xiàn)象訓(xùn)練集 loss 降但驗(yàn)證集 loss 漲原因過擬合解決加 dropout、減層數(shù)、早停小數(shù)據(jù)集上 BiLSTM 很容易過擬合訓(xùn)練 loss 一路降驗(yàn)證 loss 從第幾輪開始反彈。這時(shí)候別硬訓(xùn)加 dropout 到 0.5隱藏層降到 128或者直接早?!?yàn)證 F1 連續(xù) 5 輪不漲就停。如果數(shù)據(jù)量實(shí)在小可以考慮凍結(jié)嵌入層只訓(xùn) LSTM 和 CRF 部分。4.4 現(xiàn)象CPU 跑一輪要幾小時(shí)原因batch_size 太小或沒壓填充解決用 pack_padded_sequence 并適當(dāng)加大 batchCPU 上跑 LSTM 本來就慢如果還沒用pack_padded_sequence填充位也參與計(jì)算時(shí)間翻倍。確認(rèn)編碼層有沒有壓填充batch_size 從 16 加到 32 或 64能明顯提速。如果還是慢考慮把隱藏層維度降到 128或者先用小規(guī)模數(shù)據(jù)調(diào)試通再上全量。4.5 現(xiàn)象換自己的數(shù)據(jù)后報(bào) KeyError原因標(biāo)簽集或詞表不匹配解決統(tǒng)一用配置文件管理標(biāo)簽映射這份源碼的tag2id和word2id大概率是寫死或從訓(xùn)練集建的。換數(shù)據(jù)后如果出現(xiàn)訓(xùn)練集沒有的標(biāo)簽就會(huì) KeyError。正確做法是把標(biāo)簽集和詞表都存成 json訓(xùn)練和評(píng)估都從同一份配置讀。別在代碼里硬編碼標(biāo)簽列表改起來容易漏。5. 從能跑到好用CRF 解碼驗(yàn)證與一個(gè)提分技巧跑通之后怎么確認(rèn) CRF 真的在起作用最直接的辦法是對(duì)比實(shí)驗(yàn)把 CRF 層去掉只留 BiLSTM 加 softmax看 F1 掉多少。如果掉得明顯說明轉(zhuǎn)移約束確實(shí)在壓非法序列。另一個(gè)驗(yàn)證方法是拿一條測(cè)試樣本打印 Viterbi 解碼的最優(yōu)路徑分?jǐn)?shù)再和貪心解碼的結(jié)果比看 CRF 有沒有糾正貪心解出來的非法標(biāo)簽組合。# 對(duì)比 CRF 解碼和貪心解碼 with torch.no_grad(): emissions model(word_ids, lengths) # CRF Viterbi 解碼 best_path crf.viterbi_decode(emissions, mask)[0] # 貪心解碼每個(gè)位置取最大發(fā)射分?jǐn)?shù) greedy_path emissions.argmax(dim-1)[0].tolist() print(Viterbi:, best_path) print(Greedy :, greedy_path)如果 Viterbi 路徑里沒有出現(xiàn)「B-LOC 接 B-LOC」這種組合而貪心路徑出現(xiàn)了就說明 CRF 的轉(zhuǎn)移矩陣學(xué)到了合理約束。這個(gè)對(duì)比我每次調(diào)完 CRF 參數(shù)都會(huì)跑一遍比只看 F1 更直觀。提分技巧上最劃算的是加預(yù)訓(xùn)練詞向量。中文可以用騰訊詞向量或中文維基訓(xùn)練的詞向量加載后凍結(jié)前幾輪再解凍微調(diào)。另一個(gè)技巧是調(diào)整 CRF 的初始轉(zhuǎn)移矩陣把明顯非法的轉(zhuǎn)移比如 O 到 I-XXX初始化成很小的值能加快收斂。我一般會(huì)在訓(xùn)練前手動(dòng)把transitions里非法組合設(shè)成 -1e4讓模型少走彎路。從那以后我每次拿到序列標(biāo)注項(xiàng)目都強(qiáng)制先跑一遍「去 CRF 對(duì)比」和「Viterbi vs 貪心」這兩個(gè)驗(yàn)證確認(rèn) CRF 不是擺設(shè)再繼續(xù)調(diào)參。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取