ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于TaCL-BERT的中文命名实体识别与分词联合建模实践

基于TaCL-BERT的中文命名实体识别与分词联合建模实践 简介这是一份基于中文TaCL-BERT的命名实体识别与中文分词Python实现项目面向自然语言处理方向的学生可用于课程设计、期末大作业或BERT应用入门。项目提供训练、推理、数据准备及模型检查点获取等完整流程有助于理解中文分词和命名实体识别任务中预训练模型的调用与微调方式。压缩包共23个文件包括16个shell脚本、5个Python源文件、1个依赖清单和1份使用手册整体仅63KB轻量且便于快速部署。shell脚本负责自动化下载基准数据与预训练权重Python文件实现模型构建、训练和指标计算txt文件用于安装依赖docx手册则详细说明运行步骤与注意事项整体目录结构清晰、便于按需查阅。目前已有257人学习使用项目下载即可运行无需修改适合作为高分课程设计或期末大作业的参考实现方案。1. 用中文 TaCL-BERT 同时解决命名实体识别和中文分词值得动手试一次拿到“python实现基于中文TaCL-BERT的中文命名实体识别及中文分词”这个需求时大多数人的第一反应是分开做jieba 先分词再用一个 NER 模型抽实体。但两条链路一旦接起来词边界和实体边界经常会互相打架最后还要人肉修数据。一个更省心的做法是让中文 TaCL-BERT 直接做序列标注——一个模型、一份标注数据同时输出分词结果和实体标签。TaCL-BERT 是面向中文的预训练模型通过义原信号补强了传统 BERT 在中文词表上的短板在字级别序列标注任务上比直接用 BERT-base 稳定。这篇笔记写给正在做中文文本清洗、知识图谱构建或裁判文书、病历信息抽取的从业者目标是能照着复现一套可用的联合标注方案并避开我在实际训练里踩过的几个坑。2. 环境与权重准备最小依赖跑通 TaCL-BERT 中文加载2.1 依赖安装torch、transformers、datasets 的版本搭配整套方案基于 HuggingFace 生态训练和推理都在 PyTorch 下进行。我的推荐组合是 Python 3.8 到 3.10PyTorch 1.12 以上transformers 4.28 以上。Python 3.12 在部分 CUDA 扩展上会碰到编译失败的玄学问题训练任务没必要赶这个新。transformers 版本太低的话BertForTokenClassification的加载接口和hf_hub行为都有差异复现时容易卡在环境上。pip install torch1.12 pip install transformers4.28 pip install datasets huggingface_hub seqevalseqeval 是评估序列标注结果时最常用的工具后面计算实体 F1 和分词 F1 都会用到。datasets 和 huggingface_hub 负责数据缓存与权重下载。装包这步看起来简单但很多人在本地跑不起来最后排查一圈问题出在 vscode python 环境配置选错了解释器pip 装到了一个环境python 命令用的是另一个环境。装完之后建议先用python -c import transformers, torch; print(transformers.__version__)确认解释器一致。提示如果机器上有多个 virtualenv 或 conda 环境先在终端里which python和which pip对齐再装依赖不然之后的报错会让你以为是模型代码问题。2.2 下载中文权重用 snapshot_download 把模型落到本地目录TaCL-BERT 的中文权重在 HuggingFace 上可以找到结构是标准的 bert-base 格式包含config.json、pytorch_model.bin、vocab.txt这些文件。我不建议直接from_pretrained远程加载训练时每次启动都要重新拉权重浪费时间还容易读到缓存里不完整的文件。from huggingface_hub import snapshot_download model_dir snapshot_download( repo_id你的TaCL-BERT中文权重仓库名, local_dir./tacl-bert-zh ) print(model_dir)local_dir指定下载落地目录文件完整保存在本地断点续传也比硬编码在from_pretrained里方便。把repo_id换成你在 Hub 上搜到的 TaCL 中文仓库名即可。下载完成后检查一下目录正常会有词表、配置和模型权重三类文件。权重文件体积大约 400MB 左右这是 BERT-base 的正常体量下载慢属于正常现象不用怀疑文件损坏。2.3 最小推理验证模型和 tokenizer 是否匹配权重落地的下一步是跑一次最简前向过程目的是在开始训练前确认模型能加载、词表能匹配、分类头维度正确。这时候分类头还是随机初始化的logits 没有任何语义但能帮你滤掉一批低级错误比如权重下载不完整、vocab目录和模型不匹配。import torch from transformers import BertTokenizer, BertForTokenClassification model_dir ./tacl-bert-zh tokenizer BertTokenizer.from_pretrained(model_dir) model BertForTokenClassification.from_pretrained( model_dir, num_labels17 ) model.eval() text 南京市长江大桥 tokens tokenizer.tokenize(text) print(token 数量:, len(tokens)) print(token 序列:, tokens)这里num_labels17和后续设计的联合标签集数量对应如果你打算换成三分类或六分类这个数字要跟着改。模型能顺利加载、tokenizer 能把句子切成一串单字 token说明权重和词表是配套的。如果在这里报出维度不一致的错基本可以判断是磁盘里混进了别的模型版本重新下载或更换仓库再试。3. 分词与命名实体识别统一建模标签设计与数据预处理3.1 为什么中文分词和命名实体识别能共用一个模型中文分词和中文命名实体识别本质上都是字级别的序列标注问题。分词要判断每个字在词中的位置是词首、词中、词尾还是单字成词NER 要判断每个字是否属于某个实体以及属于什么类型。TaCL-BERT 的输入是字级别的 token输出每个 token 分布在各个标签上的概率所以两个任务天然可以在一个模型里完成。在联合方案里一个字的标签同时携带两类信息词边界信息以及实体类型信息。比如北京的“京”在分词标签里是词尾 E在实体维度上属于地点 LOC那么它的联合标签就是E-LOC。模型只输出一个标签序列解码时既还原词序列又还原实体列表彻底避免了多模型管线里分词结果和实体结果对不齐的问题。这也带来一个额外的好处模型训练时分词任务和实体任务互相提供监督信号。实体边界约束了词边界词边界也约束了实体内部结构相当于一个多任务学习的效果。相对单独两个模型串联联合训练的收敛速度和稳定性都更好。3.2 联合标签集设计词边界标签和实体标签的笛卡尔积取舍最直观的思路是把分词标签和实体标签做全量笛卡尔积比如B-PER、I-PER、B-LOC等等。但中文实体基本都是完整词或单字词不会出现实体跨多个普通词的情况全量组合会产生大量几乎不出现的标签反而稀释分类头的学习信号。实践中我一般用 17 个标签的设计把非实体词、实体单字词、实体多字词分开定义。这样标签集里没有纯I标签而是用M和E表示多字词的词中和词尾与通用 BMES 分词标签保持同一套体系。标签含义O特殊 token 占位[CLS]、[SEP] 等位置S非实体的单字词B / M / E非实体的多字词分别表示词首、词中、词尾S-PER / S-ORG / S-LOC单字词且同时是一个实体B-PER / M-PER / E-PER多字词且同时是一个 PER 实体B-ORG / M-ORG / E-ORG多字词且同时是一个 ORG 实体B-LOC / M-LOC / E-LOC多字词且同时是一个 LOC 实体O只在[CLS]和[SEP]位置上使用。句子内部每个字都至少属于一个词所以非实体的字不会出现在O上而是落在S、B、M、E中。这样设计的好处是解码时词边界的还原不需要额外规则直接从标签前缀就能拿到完整词序列。3.3 数据预处理把 CoNLL 格式语料转成模型输入我常用的数据格式是 CoNLL 风格每个文件按行拆分每行是“一个空格一个标签”句子之间用一个空行隔开。这种格式的好处是手工标注和脚本转换都直观也方便后续接入 datasets 库。def load_conll(file_path): sentences [] labels [] cur_chars [] cur_labels [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: if cur_chars: sentences.append(cur_chars) labels.append(cur_labels) cur_chars [] cur_labels [] continue parts line.split() cur_chars.append(parts[0]) cur_labels.append(parts[1]) if cur_chars: sentences.append(cur_chars) labels.append(cur_labels) return sentences, labels加载完成后需要把标签转换为 id 序列同时注意和 tokenizer 的 token 序列长度对齐。中文 BERT 的 tokenizer 大多数情况下是一个字一个 token但遇到数字、英文、特殊符号时仍然可能拆出子词或不一致的 token所以不能直接拿原始字符长度当 token 数量。label2id { O: 0, S: 1, B: 2, M: 3, E: 4, S-PER: 5, S-ORG: 6, S-LOC: 7, B-PER: 8, M-PER: 9, E-PER: 10, B-ORG: 11, M-ORG: 12, E-ORG: 13, B-LOC: 14, M-LOC: 15, E-LOC: 16, } def encode_example(chars, labels, tokenizer, max_len128): input_ids [tokenizer.cls_token_id] labels_ids [-100] for ch, lb in zip(chars, labels): token tokenizer.convert_tokens_to_ids(ch) input_ids.append(token) labels_ids.append(label2id[lb]) input_ids.append(tokenizer.sep_token_id) labels_ids.append(-100) attention_mask [1] * len(input_ids) if len(input_ids) max_len: input_ids input_ids[:max_len] labels_ids labels_ids[:max_len] attention_mask attention_mask[:max_len] else: pad_len max_len - len(input_ids) input_ids [tokenizer.pad_token_id] * pad_len labels_ids [-100] * pad_len attention_mask [0] * pad_len return { input_ids: input_ids, attention_mask: attention_mask, labels: labels_ids, }-100在 PyTorch 的CrossEntropyLoss里表示忽略该位置的损失[CLS]、[SEP]和补位 token 都用它来屏蔽。max_len128是常见的中文短文本配置如果语料里有长句后面我会讲到滑窗预测的处理办法这里先按截断处理。tokenizer.convert_tokens_to_ids(ch)直接按字符查词表比调用tokenizer.encode更可控因为能严格保持标签对齐遇到词表里没有的生僻字会转成[UNK]这是后面要专门排查的坑。4. 微调实现训练脚本与关键参数说明4.1 模型加载与分类头维度设置模型结构上直接复用BertForTokenClassification这个类已经在 BERT 之上加好了一个线性分类头。加载时关键参数是num_labels必须和上一章设计的标签数量一致这里是 17。加载完成后检查一下模型的分类头输出维度是否正确。from transformers import BertForTokenClassification model BertForTokenClassification.from_pretrained( ./tacl-bert-zh, num_labels17 ) print(model.classifier.out_features)打印结果应该是 17。如果这里和 label2id 的数量对不上训练时CrossEntropyLoss计算维度就会报错。注意如果你的下游任务只需要实体类型不需要分词词边界可以缩减标签集但联合方案就是靠这 17 个标签同步输出两个任务的结果不建议随意合并。4.2 训练主循环学习率、warmup 与梯度裁剪训练时最重要的参数是学习率。中文 BERT 微调我一般只用2e-5超过5e-5就会看到 loss 出现奇怪波动。按 batch size 16 和 max_len 128 的配置显存占用在 8GB 左右的显卡上可以跑没有 GPU 的话 CPU 也能训练但速度会慢到让人怀疑人生。import torch from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup epochs 3 batch_size 16 learning_rate 2e-5 optimizer AdamW(model.parameters(), lrlearning_rate, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) loss_fct torch.nn.CrossEntropyLoss(ignore_index-100) def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss 0.0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) outputs model( input_idsinput_ids, attention_maskattention_mask, labelslabels ) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() return total_loss / len(dataloader)AdamW的weight_decay0.01是 HuggingFace 的常见默认correct_bias不需要手动设置。warmup 比例 10% 是通用配置数据量小的时候可以提高到 20%。梯度裁剪max_norm1.0是必须加的中文序列标注的 loss 偶尔会因为个别样本出现 spike不裁剪的话一次 update 就能把预训练权重打坏。整个训练循环里也注意观察 loss 变化如果前几个 epoch loss 没有明显下降大概率是学习率或者标签对齐出了问题。4.3 训练过程监控验证集 F1 比 loss 更重要训练过程中单独的 loss 值只能反映拟合程度不能直接反映分词和实体的质量。我通常在每轮epoch结束跑一次验证集用解码脚本生成词序列和实体列表再分别用分词 F1 和实体 F1 两个指标评估。实体 F1 由 seqeval 提供分词 F1 则需要自己写一个按词集合计算准确率、召回率的函数。from seqeval.metrics import f1_score def eval_on_valid(model, dataloader, device): model.eval() true_entities [] pred_entities [] for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels] with torch.no_grad(): logits model(input_idsinput_ids, attention_maskattention_mask).logits preds torch.argmax(logits, dim-1).cpu().numpy() for i in range(len(labels)): # 把 -100 和特殊 token 过滤掉提取实体类型序列 pass return f1_score(true_entities, pred_entities)seqeval 计算的是实体级别的 F1要求输入是每个 token 对应的实体类型列表O表示无实体。由于我们联合标签里非实体 token 是S、B、M、E需要先映射成O再交给 seqeval 处理。这个地方很容易漏掉我第一次跑验证的时候实体 F1 直接变成 0后来才发现是标签映射的问题。5. 训练与推理避坑标签冲突、截断与解码错位5.1 实体边界和分词边界打架标签设计阶段就要防现象模型预测的实体内部出现了明显不合理的分词比如实体“北京市”被拆成了“北/京/市”三个单字词实体类型虽然对了但分词结果完全不可用。原因如果标签集里实体标签和词边界标签是两套独立体系模型会分别学习两种边界解码时两者就会冲突。解决在标签设计阶段就让实体标签本身携带词边界信息实体多字词内部的字必须用B-*、M-*、E-*标注而不是独立的B-I-E外加一套分词标签。训练前最好写一个脚本扫描标注数据检查是否存在“实体标签是B-PER但下一个字是普通B而不是M-PER/E-PER”的情况这种冲突样本必须提前纠正。5.2 长文本被 max_len 截断实体尾部直接丢失现象训练前几个 epoch loss 正常下降但验证时发现长句末尾的实体全预测错了或者某个实体只预测出前半部分。原因max_len128截断实体后半段被强制切掉标签序列也同步截断模型根本没机会学到完整模式。推理阶段同样的问题也会导致实体残缺。解决训练数据里如果长句占比高先把max_len提到 256 或 512代价是显存占用和训练时间上升。推理阶段可以用滑窗方式做重叠预测把长文本切成有重叠的若干段每段独立预测最后选择实体置信度更高的边界。简单实现是窗口长度固定 96、重叠 32然后合并重复区域的标签遇到矛盾标签时以后段窗口的预测为准。5.3 加载已微调模型时分类头维度不匹配现象训练到一半中断再次加载保存的模型时报size mismatch for classifier.weight说什么也加载不进去。原因保存模型时用了错误的num_labels比如之前用 17 个标签训练加载时代码里写成了 9 个分类头的权重维度自然对不上。覆盖式保存会把旧的权重覆盖掉后悔药都找不到。解决每次保存 checkpoint 时把label2id和id2label一并存成 JSON 文件和模型放在同一个目录。加载前从 JSON 推断标签数量再用BertForTokenClassification.from_pretrained(..., num_labelslen(label2id))。如果遇到已经存在的维度不匹配可以用ignore_mismatched_sizesTrue强制加载主体权重并重新初始化分类头但这对被覆盖的旧模型仍然无效。5.4 类别不均衡和学习率过大的双重打击现象训练初始阶段 loss 还正常第二个 epoch 突然掉到接近 0但验证集实体 F1 是 0预测结果几乎全是S或O。原因实体类别在数据里占比很低模型快速学会了把所有字都预测为最常见类别联合标签体系里最常见的是S。学习率过高会加速这种峰度坍塌。解决先把lr降到2e-5再给CrossEntropyLoss传入weight按每个标签出现频次的倒数设置权重。常见做法是给S、B、M、E这些高频非实体标签较低权重给B-PER、E-LOC这些实体标签较高权重。权重可以按总样本数除以每个类出现的次数计算也可以手工拍一个范围实体标签权重在 2 到 5 之间通常够用。5.5 解码顺序错位词序列和实体列表完全对不上现象模型输出的标签单独看没问题但还原出的分词结果里实体内部的词被切开了或者词序列正确但实体边界落在词中间生成的实体文本根本不是一个连续词。原因解码时先独立还原词边界再独立还原实体边界两套还原逻辑没有互相约束。比如某个字预测为M-LOC分词逻辑把它当作普通词中字实体逻辑把它当作实体的中间字两边各自跑完后拼接在一起就错位了。解决解码时以标签本身携带的词边界信息为唯一准绳不额外做两套判断。遇到S-LOC、B-ORG、E-PER这类带实体前缀的标签先按前缀的S、B、M、E处理词边界再按后缀的实体类型标注当前词的实体类型。这样就保证实体永远落在某个完整词上不会出现跨词的实体。6. 线上验证与进阶用法把解码脚本写成可复用的一等公民联合标签的解码逻辑是整个推理链路里最需要固化下来的部分最好写成独立函数而不是散落在脚本各处。以下解码函数可以直接复用输入是一行字的列表和对应预测标签 id 列表输出是词列表和实体列表。def joint_decode(tokens, label_ids, id2label): words [] entities [] cur_word cur_etype None for token, label_id in zip(tokens, label_ids): label id2label[label_id] if label O: continue if label.startswith(B) or label.startswith(S): if cur_word: words.append(cur_word) if cur_etype: entities.append((cur_etype, cur_word)) cur_word token cur_etype label.split(-)[1] if - in label else None elif label.startswith(M) or label.startswith(E): cur_word token if label.startswith(E): words.append(cur_word) if cur_etype: entities.append((cur_etype, cur_word)) cur_word cur_etype None if cur_word: words.append(cur_word) if cur_etype: entities.append((cur_etype, cur_word)) return words, entities验证时用两个指标分词 F1 和实体 F1。分词 F1 把词列表转成集合和标准切分结果计算集合级别的准确率和召回率实体 F1 用 seqeval按句子为单位提交预测实体类型序列。两套指标一起看能快速区分模型是哪部分出了问题。进阶方面我发现最实用的一招是保留推理阶段的词边界解码结果同时叠加一份领域词表做最后修正。比如医疗文本场景下模型可能把“阿司匹林肠溶片”切成“阿司匹林/肠溶片”但知识图谱实体要求必须是完整实体名这时用词表覆盖规则把完整实体拼回去即可。这个后处理要严格控制范围只对词表里明确存在的连续 token 生效不要用模式匹配做模糊合并否则会把模型预测正确的其他词边界搅乱。这几年我做中文文本清理项目凡是同时要求出分词和实体的任务第一反应都是直接上一个带联合标签的序列标注模型不再拆成两条独立 pipeline。用 TaCL-BERT 的主要原因在于它的中文词表学习经过义原信息增强对分词边界和实体边界的感知比原版中文 BERT 更敏感。如果你手里也有类似需求先从 17 标签加 2e-5 学习率跑一个最小验证把解码脚本固定下来再扩数据希望帮到你。本文还有配套的精品资源点击获取
返回列表