简介:这是一份面向Python课程设计的高分项目源码,采用经典的BERT、BiLSTM与CRF联合模型,实现中文命名实体识别,可准确抽取人名、地名、机构名等实体,非常适合正在完成相关课题或期末大作业的学生参考使用。压缩包内共18个文件,以9个Python脚本为主,覆盖数据预处理、模型构建、训练、验证、测试与结果评估的完整流程,同时提供Markdown格式的说明文档和网络结构示意图,整体仅134KB,轻量且便于查看理解。目前已有691人学习下载。代码内部模块划分明确,包含核心模型、循环神经网络单元、基础配置、日志与结果记录等模块,下载后即可直接运行,无需修改。借助这份源码,读者既能快速搭建一套可用的中文命名实体识别系统,也能深入理解预训练模型与序列标注方法结合的实践细节,为课程设计或毕业答辩提供有力支撑。
1. 为什么课程设计都爱选BERT+BiLSTM+CRF:中文NER源码的黄金组合
中文命名实体识别(NER)是自然语言处理课程设计里出现频率最高的题目之一,而BERT+BiLSTM+CRF是这类源码里最不容易出错的基座结构。任务本身很直观:给一句中文,让模型自动标出人名、地名、机构名。看起来人眼十秒能完成,但机器要同时处理中文没有空格边界、专有名词变体多、训练样本少三个问题。BERT把整句话的语义编码成上下文向量,BiLSTM在序列方向做二次特征抽取,CRF负责在解码阶段找到全局最优的标签序列。这个三层结构的优势在于每个模块都能独立讲清,答辩时逻辑清楚,训练数据充足时F1通常在85%以上,是一份投入产出比很高的python课程设计选题。
真正决定成绩的往往不是模型结构,而是数据预处理里的标签对齐、CRF的转移约束、BERT层学习率这三个细节。同样一份源码,这三个点处理得好与坏,F1能从95%掉到60%。下面按一次完整实现流程来拆。
2. 数据准备与预处理:把BIO标注变成BERT能认的token序列
2.1 BIO标注规则与三类实体怎么选
中文NER最常用的标注体系是BIO。B表示实体起始字,I表示实体内部字,O表示非实体。以句子“张三向百度公司提交简历”为例,按字符打标签如下:
| 字 | 张 | 三 | 向 | 百 | 度 | 公 | 司 | 提 | 交 | 简 | 历 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 标签 | B-PER | I-PER | O | B-ORG | I-ORG | I-ORG | I-ORG | O | O | O | O |
这里PER是人名,ORG是机构名,LOC是地名。课程设计默认选这三个类别就够用:类别太少体现不出模型能力,类别太多(比如加上时间、数字、专有名词)会显著增加标注成本和实体边界纠缠的难度。机构名是三个类别里最容易错的,因为“北京大学”里套着地名“北京”,数据预处理时要先定规则:嵌套地名不单独标注,整个“北京大学”按一个ORG实体处理。
实际做课程设计时,常见做法是用公开标注语料,而不是自己从零标。CLUENER2020、MSRA中文NER数据集都是中文NER常用来源。建议的做法是只取其中符合PER/ORG/LOC三类实体的句子,统一转成字符级BIO格式,这样代码逻辑和数据分布都干净。自己标注的话,500条句子是底线,低于这个数量模型基本学不到稳定的实体边界。
2.2 BERT分词与标签对齐:纯中文也会踩的WordPiece坑
BERT的tokenizer对中文的处理和英文不一样。bert-base-chinese在词表里直接收录了常用汉字,所以绝大多数中文按单字切分,一个汉字对应一个token。但一旦文本里混入英文、数字、括号,WordPiece分词会把这些内容拆成多个subword,破坏“一个汉字一个token”的假设。先用一段最小代码验证:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') print(tokenizer.tokenize("张三向百度公司提交简历")) # ['张', '三', '向', '百', '度', '公', '司', '提', '交', '简', '历'] print(tokenizer.tokenize("我买了个iPhone12手机")) # ['我', '买', '了', '个', 'i', '##phone', '##12', '手', '机']“iPhone12”这个实体被拆成了i、##phone、##12三个token,而这个词的实体标签只有一个。如果直接拿tokenizer输出去和标签对位,标签数量就会对不上,训练时模型看到的标签序列整体错位,loss降不下去,预测结果更是全乱。凡是看到##开头的token,都表示它是前一个字符被WordPiece拆分出来的续片段,标签应该复制前一个字符的标签。
2.3 预处理代码:Dataset类、align函数与collate_fn
下面这段预处理代码是整个课程设计源码里最重要的部分,它把原始文本和字符级BIO标签,变成BERT需要的input_ids、attention_mask,以及对齐后的label序列。
import torch from torch.utils.data import Dataset from transformers import BertTokenizer LABEL2ID = {"O": 0, "B-PER": 1, "I-PER": 2, "B-ORG": 3, "I-ORG": 4, "B-LOC": 5, "I-LOC": 6} ID2LABEL = {v: k for k, v in LABEL2ID.items()} def align_labels_with_tokens(text, labels, tokenizer): """把按字符标注的labels对齐到BERT的subword token序列。 BERT中文分词大部分按字切,但英文/数字会被拆分, 拆分出的'##'续token要复制前一个字符的标签。 """ words = tokenizer.tokenize(text) label_ids = [LABEL2ID["O"]] # [CLS] 位置固定为O last_label = LABEL2ID["O"] char_idx = 0 for w in words: if w.startswith("##"): label_ids.append(last_label) # 续token沿用前字标签 else: label_ids.append(labels[char_idx]) last_label = labels[char_idx] char_idx += 1 label_ids.append(LABEL2ID["O"]) # [SEP] 位置固定为O assert char_idx <= len(text), "字符索引越界,检查标注长度" return label_ids class NERDataset(Dataset): def __init__(self, texts, labels_list, tokenizer, max_len=128): self.texts = texts self.labels_list = labels_list self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): raw_text = self.texts[idx] raw_labels = self.labels_list[idx] # 先按字符截断,给[CLS]和[SEP]各留一个位置 text = raw_text[:self.max_len - 2] labels = raw_labels[:self.max_len - 2] encoding = self.tokenizer( text, max_length=self.max_len, truncation=True, padding='max_length', return_tensors='pt' ) input_ids = encoding['input_ids'].squeeze(0) attention_mask = encoding['attention_mask'].squeeze(0) label_ids = align_labels_with_tokens(text, labels, self.tokenizer) label_ids = label_ids[:self.max_len] if len(label_ids) < self.max_len: label_ids += [LABEL2ID["O"]] * (self.max_len - len(label_ids)) return { 'input_ids': input_ids, 'attention_mask': attention_mask, 'labels': torch.tensor(label_ids, dtype=torch.long) } def collate_fn(batch): """固定max_len的batch不需要额外动态padding,直接stack""" return { 'input_ids': torch.stack([x['input_ids'] for x in batch]), 'attention_mask': torch.stack([x['attention_mask'] for x in batch]), 'labels': torch.stack([x['labels'] for x in batch]), }这里有两个参数要说明。一是max_len - 2,因为BERT会在首尾插入[CLS]和[SEP],所以原始文本最多只能保留126个字符,否则总长度会超过128被截断。二是align函数里char_idx <= len(text)的断言:纯中文场景下char_idx会等于文本长度;如果文本含英文,分词后token数会多于字符数,char_idx小于文本长度是正常现象,所以断言只能设成小于等于。
关于是否用tokenizer(text)一次编码还是先tokenize再转id,我更推荐这里的方式:只调用一次tokenizer拿到对齐的input_ids,再用tokenize做标签对齐,两边都使用同一个tokenizer,避免分词结果不一致。如果后续要支持英文混合文本,更稳妥的方案是用tokenizer.backend_tokenizer拿到word_ids,但课程设计阶段上述代码已经足够。
3. 模型搭建:BERT编码、BiLSTM抽取、CRF解码的三级流水线
3.1 BERT层:加载中文预训练模型的三个细节
BERT层是整个模型的语义编码器,直接使用bert-base-chinese预训练权重。加载代码很简单,但有几个细节会影响效果。
from transformers import BertModel bert = BertModel.from_pretrained('bert-base-chinese') # bert-base-chinese: 12层Transformer, hidden_size=768, 12个attention头 out = bert(input_ids, attention_mask) # out.last_hidden_state: (batch_size, seq_len, 768)第一个细节是必须用from_pretrained加载预训练权重,不要自己BertModel(config)随机初始化。随机初始化的BERT在几千条训练数据上根本学不出中文语义特征。第二个细节是取last_hidden_state而不是pooler_output,序列标注任务需要每个token位置的向量,pooler_output只保留[CLS]的聚合表示。第三个细节是微调时BERT层默认参与训练,不需要手动冻结。
在实际实验中,有过尝试提取最后四层拼接的做法,在这类任务上比单用最后一层收益不大,反而增加显存占用和反向传播耗时。课程设计的体量下直接用最后一层即可,把省下的资源留给BiLSTM的hidden_size。
3.2 BiLSTM层:hidden_size怎么选,BERT后面为什么还要接LSTM
BiLSTM在BERT输出的768维向量基础上,再从左到右和从右到左各扫一遍序列。这里有一个答辩时一定会被问到的问题:BERT自己已经做了双向self-attention,为什么还要再接BiLSTM?
一个客观的回答是:BERT输出的特征擅长捕捉全局语境,但它的self-attention对相邻标签之间的局部一致性没有任何约束,而NER最终要输出一串标签序列,标签与标签之间是否连贯是决定性因素。BiLSTM通过门控机制在序列上做了一步局部特征融合,相当于在送入CRF之前先把发射分数“顺”了一遍。实践中,在训练样本少于两万条的中文NER任务上,BERT+BiLSTM+CRF通常比直接用BERT+CRF更稳,因为BiLSTM把768维压缩到低维,一定程度上抑制了BERT在小数据上的过拟合。
self.bilstm = nn.LSTM( input_size=768, # BERT hidden_size hidden_size=256, # 双向后每方向256维 num_layers=1, # 课程设计一层够用,两层显存翻倍收益甚微 batch_first=True, bidirectional=True )hidden_size建议选128或256。选256时双向输出512维,再经过线性层映射到7个标签类别,特征表达空间足够;选128可以省一半显存,在batch_size受限的时候更灵活。dropout设0.5,放在BiLSTM输出和线性层之间,防止模型把小训练集背下来。
3.3 CRF层:配分函数与维特比回溯的实现
CRF是序列标注的最后一环。它不再逐token独立决策,而是给整条标签序列打分,显式建模相邻标签的转移关系。例如“B-PER后面跟I-PER”合法,但“B-PER后面直接跟I-ORG”就不合法。模型训练时,CRF要最大化目标标签序列的得分,同时压低所有其他合法路径的总得分。
下面的CRF类不依赖torchcrf第三方库,逻辑完整,适合课程设计答辩时展示实现细节。
import torch import torch.nn as nn class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags = num_tags # trans[i][j] 表示从标签 j 转移到标签 i 的得分 self.trans = nn.Parameter(torch.randn(num_tags, num_tags) / num_tags ** 0.5) self.start_trans = nn.Parameter(torch.randn(num_tags)) self.end_trans = nn.Parameter(torch.randn(num_tags)) # 约束:不允许 O 转移到 I-* 实体内部 # O是0号标签,I-PER/I-ORG/I-LOC分别是2/4/6 with torch.no_grad(): self.trans.data[2, 0] = -10000 self.trans.data[4, 0] = -10000 self.trans.data[6, 0] = -10000 def forward_score(self, emissions, mask): """计算所有合法路径的对数配分函数logZ""" batch_size, seq_len, num_tags = emissions.size() mask = mask.float() log_alpha = self.start_trans.unsqueeze(0) + emissions[:, 0] for t in range(1, seq_len): prev = log_alpha log_alpha_t = torch.logsumexp( prev.unsqueeze(2) + self.trans.unsqueeze(0), dim=1 ) + emissions[:, t] cur_mask = mask[:, t:t + 1] log_alpha = cur_mask * log_alpha_t + (1 - cur_mask) * prev log_alpha = log_alpha + self.end_trans.unsqueeze(0) return torch.logsumexp(log_alpha, dim=1) def gold_score(self, emissions, labels, mask): """直接计算给定标签序列的路径得分""" batch_size, seq_len, num_tags = emissions.size() mask = mask.float() labels = labels.long() first_emit = emissions[:, 0].gather(1, labels[:, 0:1]).squeeze(-1) score = self.start_trans[labels[:, 0]] + first_emit for t in range(1, seq_len): trans_score = self.trans[labels[:, t], labels[:, t - 1]] emit_score = emissions[:, t].gather(1, labels[:, t:t + 1]).squeeze(-1) score = score + (trans_score + emit_score) * mask[:, t] last_idx = mask.sum(dim=1).long() - 1 last_label = labels.gather(1, last_idx.unsqueeze(1)).squeeze(1) score = score + self.end_trans[last_label] return score def decode(self, emissions, mask): """维特比解码:返回每个token的最优标签""" batch_size, seq_len, num_tags = emissions.size() mask = mask.float() score = self.start_trans.unsqueeze(0) + emissions[:, 0] backpointers = [] for t in range(1, seq_len): prev = score scores_with_trans = prev.unsqueeze(2) + self.trans.unsqueeze(0) best_score, best_prev = scores_with_trans.max(dim=1) best_score = best_score + emissions[:, t] backpointers.append(best_prev) cur_mask = mask[:, t:t + 1] score = cur_mask * best_score + (1 - cur_mask) * prev score = score + self.end_trans.unsqueeze(0) best_last = score.max(dim=1)[1] best_tags = torch.zeros(batch_size, seq_len, dtype=torch.long, device=emissions.device) best_tags[:, -1] = best_last for t in range(seq_len - 1, 0, -1): prev_best = backpointers[t - 1].gather(1, best_tags[:, t:t + 1]).squeeze(1) best_tags[:, t - 1] = prev_best best_tags = torch.where(mask.bool(), best_tags, torch.zeros_like(best_tags)) return best_tags三个方法各对应一个阶段:forward_score是训练时用动态规划计算配分函数;gold_score是查表累加目标路径的得分,两者相减就是CRF的负对数似然;decode是推理时用维特比算法回溯最优路径,backpointers每一行记录的是当前步最优的前驱标签。
代码里有一个容易看糊涂的地方:cur_mask * log_alpha_t + (1 - cur_mask) * prev。BERT的padding位置是无效token,这些位置不参与转移计算,所以用mask把当前位置的得分替换成上一步的得分,让状态沿着序列“空转”过去,等到了真正有效的token再继续更新。
3.4 把三个模块拼起来:模型类与损失函数
模型类的整体逻辑很简单,forward阶段依次通过BERT、BiLSTM、dropout、线性层,输出发射分数矩阵;损失计算时把发射分数和标签一起交给CRF。
import torch.nn as nn from transformers import BertModel class BERTBiLSTMCRF(nn.Module): def __init__(self, lstm_hidden=256, num_tags=7, dropout=0.5): super().__init__() self.bert = BertModel.from_pretrained('bert-base-chinese') self.bilstm = nn.LSTM( input_size=768, hidden_size=lstm_hidden, num_layers=1, batch_first=True, bidirectional=True ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags) def forward(self, input_ids, attention_mask): bert_out = self.bert( input_ids=input_ids, attention_mask=attention_mask ).last_hidden_state lstm_out, _ = self.bilstm(bert_out) lstm_out = self.dropout(lstm_out) emissions = self.fc(lstm_out) return emissions def compute_loss(self, emissions, labels, attention_mask): log_z = self.crf.forward_score(emissions, attention_mask) gold_score = self.crf.gold_score(emissions, labels, attention_mask) return (log_z - gold_score).mean()loss = logZ - gold_score就是CRF的负对数似然。模型训练时梯度会把gold_score往上推,把其他路径的总势能往下压,最终让目标路径在所有合法路径中的概率占比趋近于1。这里注意BiLSTM的输入没有用pack_padded_sequence,padding位置也直接进了LSTM,这在固定长度、padding占比不高的课程设计里可以接受,省去排序和pack的复杂度,代价是padding位置的隐状态会产生少量噪声,最终的mask处理会把这些位置的预测擦掉。
4. 训练与评估:让F1从0涨到85%以上的调参路线
4.1 核心参数表与分层学习率设置
训练参数直接影响收敛速度和最终F1,乱设的结果往往是loss卡住不动。下面是一组在中文NER课程设计里反复验证过的基础参数,可以在它基础上微调。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| max_len | 128 | 中文126字+CLS/SEP,覆盖绝大多数句子 |
| batch_size | 32 | 显存不足就降到16,配合梯度累积 |
| epochs | 5 | 小数据跑太快过拟合,5轮后早停 |
| BERT层学习率 | 2e-5 | 微调BERT必须用小学习率 |
| 下游层学习率 | 1e-3 | BiLSTM/FC/CRF随机初始化,可大步学习 |
| weight_decay | 0.01 | 只对BERT权重衰减,bias不衰减 |
| warmup比例 | 10% | 前10%训练步数线性升温 |
| 梯度裁剪阈值 | 5.0 | 防止CRF和LSTM梯度爆炸 |
BERT层和下游层为什么要分开设置学习率?因为BERT的预训练权重已经收敛到了一个比较好的语义表征点,学习率太大一步就把知识破坏掉;而BiLSTM、线性层、CRF是随机初始化的,需要相对大的学习率快速进入有效状态。混用一个学习率是最常见的翻车原因。
from transformers import AdamW def build_optimizer(model): no_decay = ['bias', 'LayerNorm.weight'] bert_params = [ {'params': [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], 'lr': 2e-5, 'weight_decay': 0.01}, {'params': [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], 'lr': 2e-5, 'weight_decay': 0.0}, ] downstream_params = [ {'params': [p for n, p in model.named_parameters() if not n.startswith('bert.')], 'lr': 1e-3} ] return AdamW(bert_params + downstream_params, eps=1e-8)这里把BERT的bias和LayerNorm参数单独拎出来,不对它们做权重衰减,是BERT微调的标准操作。AdamW使用eps=1e-8,比默认的1e-6在低学习率下更稳定。
4.2 训练循环:warmup、梯度裁剪与验证
训练循环里需要同时处理warmup、梯度裁剪和早停。warmup用transformers自带的调度器即可。
from transformers import get_linear_schedule_with_warmup train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, collate_fn=collate_fn) eval_loader = DataLoader(eval_dataset, batch_size=32, shuffle=False, collate_fn=collate_fn) model = BERTBiLSTMCRF().cuda() optimizer = build_optimizer(model) total_steps = len(train_loader) * 5 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) best_f1 = 0.0 for epoch in range(5): model.train() for batch in train_loader: batch = {k: v.cuda() for k, v in batch.items()} emissions = model(batch['input_ids'], batch['attention_mask']) loss = model.compute_loss(emissions, batch['labels'], batch['attention_mask']) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad() f1 = evaluate(model, eval_loader) print(f"epoch {epoch} dev_f1 {f1:.4f}") if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), 'best_ner_model.pt')clip_grad_norm_这一行很多教程会漏掉。BiLSTM和CRF叠加之后梯度范数经常超过10,不裁剪的话几个step后loss直接nan。5.0是一个保守但有效的阈值。早停只保留验证集上F1最高的权重,这是课程设计里最简单也最有效的“后悔药”。
4.3 实体级F1评估:不是看每字准确率
中文NER的评估指标不能看逐token准确率。因为O标签占比超过80%,一个“全预测成O”的模型准确率也能有80%以上,但实际一个实体都找不出来。课程设计必须用实体级F1:一个实体只有当类型正确、起始位置和结束位置都完全一致,才算预测正确。
def extract_entities(label_ids, id2label): """把BIO标签序列还原成实体列表""" entities = [] cur_type, cur_start = None, None for i, x in enumerate(label_ids): tag = id2label[x] if tag.startswith('B-'): if cur_type is not None: entities.append((cur_type, cur_start, i - 1)) cur_type, cur_start = tag[2:], i elif tag.startswith('I-'): if cur_type is None or cur_type != tag[2:]: continue # 孤立I标签,直接忽略 else: if cur_type is not None: entities.append((cur_type, cur_start, i - 1)) cur_type = None if cur_type is not None: entities.append((cur_type, cur_start, len(label_ids) - 1)) return set(entities) def entity_f1(pred_ids, true_ids, id2label): pred_set = extract_entities(pred_ids, id2label) true_set = extract_entities(true_ids, id2label) tp = len(pred_set & true_set) fp = len(pred_set - true_set) fn = len(true_set - pred_set) p = tp / (tp + fp) if tp + fp > 0 else 0 r = tp / (tp + fn) if tp + fn > 0 else 0 f1 = 2 * p * r / (p + r) if p + r > 0 else 0 return p, r, f1评估时把黄金标签和预测标签都过一遍extract_entities,再用集合运算算交集差集。注意这里孤立的I标签(没有同类型B开头的I)被直接跳过,因为CRF理论上会拦掉这种非法转移,但防御性处理还是要有,毕竟数据里可能出现标注噪声。
5. 避坑指南:BERT+BiLSTM+CRF在中文NER上的5个翻车现场
5.1 标签错位:输出长度总和输入对不上
现象:训练时loss能降,但预测出的标签序列长度比原文本多了几个,打印出来看到一段连续实体全部错位。
原因:最常见的是预处理阶段假设“一个汉字恰好一个token”,忽略了英文和数字会被BERT分词器拆成多个subword。比如“iPhone12”被拆成三个token,但标签只复制了一份,导致从这个词往后所有token的标签错一位。另一个来源是截断时只截断了input_ids,没同步截断label_ids。
解决:用第2章的align_labels_with_tokens统一处理标签对齐,并在__getitem__里先按max_len-2截断字符再进tokenizer。调试时写一个断言,检查每条样本的len(label_ids) == len(input_ids),数据流问题会在第一时间暴露,而不是等到训练完才发现端倪。
5.2 预测结果出现“B-PER后直接跟I-ORG”这类非法转移
现象:模型能圈出实体,但经常出现同一实体内部标签类型跳变,比如“张三”预测成B-PER、I-ORG。
原因:CRF的转移矩阵是随机初始化的,如果不对初始转移做约束,模型可能会学到一些数据里不存在的非法转移模式。特别是训练数据本身标注噪声大时,CRF会把噪声也当规律学进去。
解决:在CRF类初始化时,把O转移到I类实体内部的转移分数固定为-10000。还可以进一步约束同类型实体内部的跨类转移,比如B-PER不能直接转移到I-ORG。如果用了torchcrf库,它有直接的allowed_transitions参数,原理和手写约束一致。
5.3 显存OOM或者训练中途进程被杀
现象:batch_size设32跑得好好的,改成64之后直接CUDA out of memory。
原因:BERT本身是大参数模型,BiLSTM会保留每个时间步的隐状态用于反向传播,CRF的动态规划图也在显存里占一块。显存瓶颈通常不在参数,而在中间激活值和CRF的分支计算。
解决:优先把max_len从256降到128,这一步能省掉近一半的序列维度显存;其次把lstm_hidden从256降到128;最后再考虑batch_size降到16并用梯度累积补足batch size。梯度累积的做法是累计4个step的梯度再更新一次参数,效果等价于batch_size=64但峰值显存只有16。
5.4 CRF loss长时间不降
现象:train loss在前几百步一直稳定在某个数值附近,甚至从30多开始缓慢上涨。
原因:最常见的是BERT层学习率调得过大,预训练权重被迅速破坏;另一个可能原因是数据预处理错误,标签全为0,模型学到的全是O标签。还有一种情况是BERT部分根本没参与训练,只更新了下游参数,语义特征没有更新。
解决:先打印一条样本的input_ids和labels,确认标签不是全0。然后单独检查BERT层参数是否更新:记录训练前后model.bert.embeddings.word_embeddings.weight的norm变化,如果几乎不变说明优化器参数分组有问题。最后确认BERT学习率在2e-5附近,不要超过5e-5。
5.5 训练F1很高,验证F1只有60
现象:训练集实体级F1到了94,验证集只有60多,而且每轮验证集分数波动很大。
原因:过拟合,本质是训练数据量太少。BERT参数量过亿,即使有BiLSTM和dropout,几千条样本也扛不住它记住训练集分布。另一个隐性原因是训练集和验证集来自不同数据源,实体风格不一致。
解决:第一选择是换用公开标注语料扩充训练集,CLUENER2020这类数据集有上万条,足够BERT稳定微调。第二选择是调大dropout到0.5,并把epochs降到3,配合早停。课程设计里更稳妥的做法是把数据集按8:1:1划分,训练和验证从同一来源切分,避免跨数据源的实体风格差异带来的虚低分数。
6. 从源码到演示系统:推理封装、实体抽取与答辩技巧
6.1 一键预测函数:把文本直接变成实体列表
课程设计最后要能给人演示,而不是只能在训练脚本里打印loss。封装一个predict_entities函数,输入一句中文,输出实体类型和原文片段。
def predict_entities(text, model, tokenizer, device='cuda', max_len=128): model.eval() text = text[:max_len - 2] encoding = tokenizer( text, truncation=True, max_length=max_len, return_tensors='pt' ) input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) with torch.no_grad(): emissions = model(input_ids, attention_mask) pred_ids = model.crf.decode(emissions, attention_mask).squeeze(0).cpu().tolist() # 用char_to_token把token标签映射回字符位置 char_labels = [] for i in range(len(text)): tok_idx = encoding.char_to_token(i) if tok_idx is None: char_labels.append(LABEL2ID["O"]) else: char_labels.append(pred_ids[tok_idx]) entities = extract_entities(char_labels, ID2LABEL) results = [] for etype, start, end in entities: results.append((etype, text[start:end+1], start, end)) return results这里用encoding.char_to_token(i)把第i个字符映射到token索引,再取出对应预测标签,可以兼容英文混排的场景,比手动数token位置更稳妥。演示时可以批量跑几句样本,把“实体类型、原文片段、起止位置”打印成表格,评委一眼就能看出模型学会了什么。
6.2 答辩演示的验收建议与我的一个教训
答辩演示建议准备三个正确case和一个错误case。正确case覆盖人名、地名、机构名各一个,错误case选一个边界混淆明显的例子,比如机构名内嵌地名,说明模型“看到了什么才错的”,这比只展示90%的准确率更有说服力。我第一版实现为了省事直接跳过BiLSTM,只用BERT+CRF,在CLUENER子集上F1有88.3;后来加上BiLSTM想冲分,反而掉到85,排查了半天发现是BERT层学习率被我设成了1e-3,BERT权重被冲坏。把学习率改回分层设置后F1涨到91.6。这个教训说明结构不是越深越好,数据质量和训练参数才是决定上限的因素。希望帮你把这份课程设计源码跑通并拿到应得的分数。
本文还有配套的精品资源,点击获取