资讯动态

BERT+BiLSTM+CRF中文NER实战:标签对齐与F1调参

发布时间:2026/10/8 20:00:57 来源:尧图企业网站定制
简介这是一份面向Python课程设计的高分项目源码采用经典的BERT、BiLSTM与CRF联合模型实现中文命名实体识别可准确抽取人名、地名、机构名等实体非常适合正在完成相关课题或期末大作业的学生参考使用。压缩包内共18个文件以9个Python脚本为主覆盖数据预处理、模型构建、训练、验证、测试与结果评估的完整流程同时提供Markdown格式的说明文档和网络结构示意图整体仅134KB轻量且便于查看理解。目前已有691人学习下载。代码内部模块划分明确包含核心模型、循环神经网络单元、基础配置、日志与结果记录等模块下载后即可直接运行无需修改。借助这份源码读者既能快速搭建一套可用的中文命名实体识别系统也能深入理解预训练模型与序列标注方法结合的实践细节为课程设计或毕业答辩提供有力支撑。1. 为什么课程设计都爱选BERTBiLSTMCRF中文NER源码的黄金组合中文命名实体识别NER是自然语言处理课程设计里出现频率最高的题目之一而BERTBiLSTMCRF是这类源码里最不容易出错的基座结构。任务本身很直观给一句中文让模型自动标出人名、地名、机构名。看起来人眼十秒能完成但机器要同时处理中文没有空格边界、专有名词变体多、训练样本少三个问题。BERT把整句话的语义编码成上下文向量BiLSTM在序列方向做二次特征抽取CRF负责在解码阶段找到全局最优的标签序列。这个三层结构的优势在于每个模块都能独立讲清答辩时逻辑清楚训练数据充足时F1通常在85%以上是一份投入产出比很高的python课程设计选题。真正决定成绩的往往不是模型结构而是数据预处理里的标签对齐、CRF的转移约束、BERT层学习率这三个细节。同样一份源码这三个点处理得好与坏F1能从95%掉到60%。下面按一次完整实现流程来拆。2. 数据准备与预处理把BIO标注变成BERT能认的token序列2.1 BIO标注规则与三类实体怎么选中文NER最常用的标注体系是BIO。B表示实体起始字I表示实体内部字O表示非实体。以句子“张三向百度公司提交简历”为例按字符打标签如下字张三向百度公司提交简历标签B-PERI-PEROB-ORGI-ORGI-ORGI-ORGOOOO这里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_len128): 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_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt ) 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, dtypetorch.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_size768, 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后面为什么还要接LSTMBiLSTM在BERT输出的768维向量基础上再从左到右和从右到左各扫一遍序列。这里有一个答辩时一定会被问到的问题BERT自己已经做了双向self-attention为什么还要再接BiLSTM一个客观的回答是BERT输出的特征擅长捕捉全局语境但它的self-attention对相邻标签之间的局部一致性没有任何约束而NER最终要输出一串标签序列标签与标签之间是否连贯是决定性因素。BiLSTM通过门控机制在序列上做了一步局部特征融合相当于在送入CRF之前先把发射分数“顺”了一遍。实践中在训练样本少于两万条的中文NER任务上BERTBiLSTMCRF通常比直接用BERTCRF更稳因为BiLSTM把768维压缩到低维一定程度上抑制了BERT在小数据上的过拟合。self.bilstm nn.LSTM( input_size768, # BERT hidden_size hidden_size256, # 双向后每方向256维 num_layers1, # 课程设计一层够用两层显存翻倍收益甚微 batch_firstTrue, bidirectionalTrue )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), dim1 ) 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, dim1) 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(dim1).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(dim1) 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(dim1)[1] best_tags torch.zeros(batch_size, seq_len, dtypetorch.long, deviceemissions.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_hidden256, num_tags7, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.bilstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue ) 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_idsinput_ids, attention_maskattention_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_sequencepadding位置也直接进了LSTM这在固定长度、padding占比不高的课程设计里可以接受省去排序和pack的复杂度代价是padding位置的隐状态会产生少量噪声最终的mask处理会把这些位置的预测擦掉。4. 训练与评估让F1从0涨到85%以上的调参路线4.1 核心参数表与分层学习率设置训练参数直接影响收敛速度和最终F1乱设的结果往往是loss卡住不动。下面是一组在中文NER课程设计里反复验证过的基础参数可以在它基础上微调。参数推荐值说明max_len128中文126字CLS/SEP覆盖绝大多数句子batch_size32显存不足就降到16配合梯度累积epochs5小数据跑太快过拟合5轮后早停BERT层学习率2e-5微调BERT必须用小学习率下游层学习率1e-3BiLSTM/FC/CRF随机初始化可大步学习weight_decay0.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, eps1e-8)这里把BERT的bias和LayerNorm参数单独拎出来不对它们做权重衰减是BERT微调的标准操作。AdamW使用eps1e-8比默认的1e-6在低学习率下更稳定。4.2 训练循环warmup、梯度裁剪与验证训练循环里需要同时处理warmup、梯度裁剪和早停。warmup用transformers自带的调度器即可。from transformers import get_linear_schedule_with_warmup train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, collate_fncollate_fn) eval_loader DataLoader(eval_dataset, batch_size32, shuffleFalse, collate_fncollate_fn) model BERTBiLSTMCRF().cuda() optimizer build_optimizer(model) total_steps len(train_loader) * 5 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_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(fepoch {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. 避坑指南BERTBiLSTMCRF在中文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_size64但峰值显存只有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, devicecuda, max_len128): model.eval() text text[:max_len - 2] encoding tokenizer( text, truncationTrue, max_lengthmax_len, return_tensorspt ) 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:end1], start, end)) return results这里用encoding.char_to_token(i)把第i个字符映射到token索引再取出对应预测标签可以兼容英文混排的场景比手动数token位置更稳妥。演示时可以批量跑几句样本把“实体类型、原文片段、起止位置”打印成表格评委一眼就能看出模型学会了什么。6.2 答辩演示的验收建议与我的一个教训答辩演示建议准备三个正确case和一个错误case。正确case覆盖人名、地名、机构名各一个错误case选一个边界混淆明显的例子比如机构名内嵌地名说明模型“看到了什么才错的”这比只展示90%的准确率更有说服力。我第一版实现为了省事直接跳过BiLSTM只用BERTCRF在CLUENER子集上F1有88.3后来加上BiLSTM想冲分反而掉到85排查了半天发现是BERT层学习率被我设成了1e-3BERT权重被冲坏。把学习率改回分层设置后F1涨到91.6。这个教训说明结构不是越深越好数据质量和训练参数才是决定上限的因素。希望帮你把这份课程设计源码跑通并拿到应得的分数。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑