简介面向中文命名实体识别NER的CCKS2019赛题方案与Java代码包聚焦临床文本处理覆盖数据预处理、特征构造、模型训练与评估全流程适合NLP初学者、毕业设计开发者及知识图谱研究人员。压缩包共2696个文件、42.36MB以2449个txt词典/标注数据、100个py源码、81个pyc编译文件为主另含23个csv性能指标、16个xlsx统计表、7个p模型、2个ipynb笔记及word2vec.bin预训练向量便于复现和二次开发。目前已有309人学习内含“小正太浩二”的下载说明及Chinese-clinical-NER-master工程可见基于词典分词、BiLSTMCRF、BERT等方案的对比实验与结果。整体结构清晰既提供可运行的Java版NER系统也给出疾病、解剖部位、手术等实体的词频与重叠分析能为毕业设计或科研提供现成基线。1. 拿到 CCKS2019 中文命名实体识别任务的 zip 包后先搞懂这四件事从 CCKS2019 中文命名实体识别任务的 zip 包里解压出标注数据是很多中文 NLP 从业者第一次做垂类 NER 的起点。这个压缩包装的不只是训练集和测试集它代表一套完整落地链路BIO/BIOES 标注怎么读、预训练模型怎么微调、span 级别怎么评测。我见过不少人在这个任务上卡住问题不在模型不够先进而在四个基础点没理清数据格式、标签体系、BERT 字粒度标签对齐、评测口径。这四个点一次理清后面就是顺水推舟。下面按我处理这类评测数据的老流程展开拆包、统计、搭基线、训练评估、避坑、打包提交。刚接触中文 NER 的同学可以直接照着跑熟手建议直接跳到第 5 章的避坑记录。2. 拆开 zip 看数据标注格式、标签体系与统计脚本2.1 解压后先别急着训练目录结构与标注规范拿到 .zip第一反应别是解压完就开训练。我一般先做三件事确认顶层目录、看 README 或标注说明、把数据文件逐个 head 一遍。对 CCKS2019 中文命名实体识别这类评测任务zip 里通常包含 train、dev、test 这类数据文件和一个说明文档文本行格式常见的是字 制表符 标签的 CoNLL 风格句子之间用空行分隔。领域不同标签集不同常见有B-xxx/I-xxx/O这种 BIO也有B-xxx/I-xxx/E-xxx/S-xxx/O这种 BIOES。unzip -l ../CCKS2019中文命名实体识别任务.zip mkdir -p ccks2019_ner cd ccks2019_ner unzip ../CCKS2019中文命名实体识别任务.zip ls -lh head -n 30 train.txt逻辑说明第一步用unzip -l先看压缩包内的文件清单和大小不实际解压。这样做能提前发现两个问题压缩包有没有顶层目录、是否混入了__MACOSX这类系统残留目录。mkdir -p建独立工作目录再解压避免文件散落在当前路径后面写脚本引用路径时也干净。解压后ls -lh看实际文件大小head -n 30抽样看标注格式。如果每行是字\t标签说明标注粒度是字如果一行有多个空格分区可能是词粒度标注这两种格式的标签对齐写法完全不同。参数说明head -n 30里的 30 可以换成 50目的是至少看到两个完整句子和空行分隔符确认句子边界怎么表达。如果 head 出来的内容中文乱码先file train.txt看编码CCKS2019 这类中文评测数据一般要求 UTF-8 无 BOM有 BOM 会让第一行第一个字符解析出错后面统计标签时容易莫名其妙少一个类别。提示解压后的第一件事不是建模而是确认文件是谁、格式是什么。这个习惯能省掉后面大量的 debug 时间。2.2 用脚本统计标签分布awk 与 Python 的两种姿势接下来是统计这一步不能省。NER 数据里O标签通常占 80% 以上某些细分类别可能只有几百个样本。不摸底就训练后面会发现模型全预测 Oacc 虚高F1 却是 0。awk -F \t {if (NF2) print $2} train.txt | sort | uniq -c | sort -nrfrom collections import Counter label_counter Counter() entity_counter Counter() with open(train.txt, encodingutf-8) as f: cur_entity None for line in f: line line.strip() if not line: cur_entity None continue char, label line.rsplit(maxsplit1) label_counter[label] 1 if label.startswith(B-): cur_entity label[2:] entity_counter[cur_entity] 1 elif label.startswith(I-) and cur_entity is not None: entity_counter[cur_entity] 1 elif label.startswith(O): cur_entity None elif label.startswith(E-) or label.startswith(S-): entity_counter[label[2:]] 1 cur_entity None print(标签级统计:, label_counter.most_common()) print(实体级统计:, entity_counter.most_common())逻辑说明awk 那行适合快速看标签分布-F \t指定制表符分隔NF2把空行和异常行过滤掉sort | uniq -c | sort -nr按出现次数倒序排列。Python 版把统计粒度从标签提升到实体。遇到B-开始记当前实体类型I-延续计数E-/S-收尾。这样能直接看出哪些实体类别是长尾后面做类别权重或数据增强时心里有数。参数说明如果数据文件是空格分隔而不是制表符把 awk 的-F \t改成-F Python 里的rsplit(maxsplit1)天然兼容空格和制表符不用改。统计结果里如果出现标签前后带空格或乱码多半是文件编码问题先用file train.txt确认。再补一个最容易被忽略的统计句长和实体长度分布。句长直接决定 BERT 的max_len参数实体长度决定你要不要为长实体单独调 CRF 的窗口。import statistics sent_lens, entity_lens [], [] with open(train.txt, encodingutf-8) as f: cur_len, cur_entity_len 0, 0 for line in f: line line.strip() if not line: if cur_len 0: sent_lens.append(cur_len) cur_len 0 continue char, label line.rsplit(maxsplit1) cur_len 1 if label.startswith(B-): cur_entity_len 1 elif label.startswith(I-): cur_entity_len 1 elif label.startswith(E-): cur_entity_len 1 entity_lens.append(cur_entity_len) cur_entity_len 0 elif label.startswith(S-): entity_lens.append(1) p90_sent sorted(sent_lens)[int(len(sent_lens) * 0.9)] p90_ent sorted(entity_lens)[int(len(entity_lens) * 0.9)] print(句长 p50/p90/max:, statistics.median(sent_lens), p90_sent, max(sent_lens)) print(实体长 p50/p90/max:, statistics.median(entity_lens), p90_ent, max(entity_lens))逻辑说明句长用分位数 p50、p90 报告比平均值更稳因为少数超长句会把平均拉高导致max_len设置过大、浪费显存。实体长度分布能提醒你如果 p90 实体长度只有 4、5 个字符那模型的主要矛盾是边界切分而不是长距离依赖。参数说明如果 p90 句长是 60 而 max_len 设到 512大部分 padding 都在浪费算力反过来p90 句长已经 120max_len 设 128 就意味着 10% 的句子被截断测试集里这些句子会系统性丢实体。2.3 BIO 还是 BIOES一个影响最终 F1 的标注细节BIO 用 B 表示实体开头、I 表示内部、O 表示外部实现简单是多数评测数据的默认格式。BIOES 额外用 E 表示结尾、S 表示单字实体解码时能天然区分相邻两个同类型实体。BERTCRF 对两种格式都能学但我的经验是BIO 格式下连续两个同类实体紧挨着时CRF 容易把两个实体合并成一个BIOES 因为必须有 E 才能收尾边界错误会明显减少。代价是标签集从2n1变成4n1类别更多、更不平衡。如果原始数据是 BIO我的建议是先按原始格式跑通基线再看错误分析里边界错误的占比如果边界错一半以上再用脚本转成 BIOES。def bio_to_bioes(labels): result labels[:] n len(labels) i 0 while i n: if labels[i].startswith(B-) or labels[i].startswith(S-): etype labels[i][2:] if labels[i].startswith(S-): i 1 continue j i while j 1 n and labels[j 1] I- etype: j 1 if i j: result[i] S- etype else: result[j] E- etype i j 1 else: i 1 return result逻辑说明这个函数只在单句的标签序列上操作。遇到B-xxx后向后扫描连续同类I-xxx如果只有 B 一个字符就改成S-xxx如果后面跟了多个 I就把最后一个 I 改成E-xxx。S-开头的情况直接跳过因为S-本身已经表达单字实体不参与转换。转换后一定要做回验统计转换前后实体数量完全一致不一致就说明 B/I 序列本身有标注错误这种脏数据会在训练时给 CRF 传递错误信号。参数说明转换要在按句子切分后的维度上做不能跨句子很多数据里句子边界是空行处理时先按空行 split。如果原始数据里出现B-xxx后面直接跟B-yyy说明相邻实体共用边界BIOES 转换后两个实体被分开这是正常行为。3. 模型选型与基线搭建为什么 BERTCRF 是 CCKS2019 的中文 NER 标配3.1 中文 NER 选型对照BERTCRF 为什么能当基线CCKS2019 中文命名实体识别这类垂类任务数据量通常不大领域词汇集中实体边界模糊。常见做法是在中文预训练模型上微调。选型时横向对比四条路方案标注效率预期 F1训练成本落地难度BiLSTMCRF低中等低需要词向量对垂类词泛化差BERTSoftmax高中上中实现最简单但标签依赖建模弱BERTCRF中高中代码多几十行边界更准评测任务首选生成式大模型 指令抽取高依赖提示词高推理慢结果不稳定不适合批量评测我的经验是评测任务直接用 BERTCRF 当基线。理由有三中文预训练模型在字粒度上的表示很强微调收敛快CRF 层能学到B 后面必须跟 I 或 E这类硬约束对实体边界和相邻同类实体很关键第三方库和开源实现多遇到问题容易查。对 CCKS2019 这类任务BERTCRF 的 F1 通常能领先 BiLSTMCRF 五到十个点。很多新同学会问既然 ChatGLM、Qwen 都能做信息抽取为什么不直接 prompt 抽取我的观点是评测任务追求可复现的离线指标生成模型在 span 级严格匹配上天然吃亏。它生成的是字符串实体边界要靠对齐器猜一个偏移就是 0 分。加上推理成本高、随机性大同一个样本抽三次结果可能不一样。所以 BERTCRF 才是一直被反复验证的基线方案。3.2 最小可运行模型BertForTokenClassification 加 CRF 层动手前先准备好标签映射。这里有个细节label2id必须固定训练和推理共用同一个否则模型输出和标签集合对不上。label2id {O: 0} with open(train.txt, encodingutf-8) as f: for line in f: parts line.rstrip(\n).split(\t) if len(parts) 2 and parts[1] not in label2id: label2id[parts[1]] len(label2id) id2label {v: k for k, v in label2id.items()} print(label2id:, label2id)逻辑说明把O固定放在 0 号位置这样分类头的输出维度稳定后续分析混淆矩阵也直观。其余标签按在训练集出现的顺序编号如果 dev 或 test 里出现训练集没有的标签说明数据划分的时候标签集合泄露或标注不一致需要回头检查数据。然后定义模型。HuggingFace transformers 里的BertForTokenClassification带了 Dropout 和线性分类头但缺 CRF。我一般包一层 torchcrf。torchcrf 的decode做维特比解码训练时直接返回负对数似然细节处理得比较干净。import torch from torch import nn from transformers import BertModel, BertTokenizer from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, model_namebert-base-chinese, num_labelslen(label2id)): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out self.dropout(outputs.last_hidden_state) logits self.classifier(seq_out) if labels is not None: loss -self.crf(logits, labels, maskattention_mask.bool(), reductionmean) return loss, logits pred self.crf.decode(logits, maskattention_mask.bool()) return pred tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertCRF(num_labelslen(label2id))逻辑说明BertModel输出的last_hidden_state形状是(batch, seq_len, hidden_size)classifier把它映射成(batch, seq_len, num_labels)的发射分数。训练阶段把发射分数和标签一起交给crf计算负对数似然CRF 内部会建模标签转移概率推理阶段用crf.decode做维特比解码拿到全局最优标签序列。maskattention_mask.bool()是关键padding 位置的 token 不参与 CRF 转移计算否则 loss 会被 padding 污染。参数说明bert-base-chinese是字粒度中文预训练模型一个字对应一个 token对中文 NER 很友好。如果语料含大量英文和数字可以考虑hfl/chinese-roberta-wwm-ext它对词边界建模更细。num_labels必须严格等于label2id的长度BIO 格式是2 * 实体类别数 1BIOES 格式是4 * 实体类别数 1多填少填都会在 CRF 初始化时报维度错误。Dropout 默认 0.1如果训练数据只有几千句我会调大到 0.2防止过拟合。4. 训练与评估标签对齐、损失计算与 span F14.1 标签对齐中文 BERT 字粒度与标签序列怎么对上这一节是整个任务最容易翻车的地方。bert-base-chinese基本上一个字一个 token但遇到全角标点、数字、特殊符号时tokenizer 的行为依然可能产生一对多。常见的错误写法先tokenizer.encode拿到 token ids再按 token 数去铺标签结果标签和 token 错位。训练时 loss 照样降但推理出来的标签全是乱的。正确做法是用offset_mapping把每个 token 映射回原始字符串的位置再做标签对齐。def encode_with_labels(texts, labels_list, tokenizer, max_len128): input_ids_list, attention_mask_list, label_ids_list [], [], [] for text, labels in zip(texts, labels_list): tokens tokenizer(text, return_offsets_mappingTrue, truncationTrue, max_lengthmax_len) offset_mapping tokens[offset_mapping] token_ids tokens[input_ids] label_ids [0] * len(token_ids) for i, (start, end) in enumerate(offset_mapping): if start end: continue char_idx start if char_idx len(labels): label_ids[i] label2id[labels[char_idx]] input_ids_list.append(token_ids) attention_mask_list.append(tokens[attention_mask]) label_ids_list.append(label_ids) return input_ids_list, attention_mask_list, label_ids_list逻辑说明return_offsets_mappingTrue返回每个 token 在原始字符串里的起止位置。start 等于 end 的 token 是特殊 token比如[CLS]、[SEP]跳过并保留为 0。中文场景下一个 token 对应一个字所以直接用offset_mapping[i].start作为字下标去取原始标签。这段代码没有做 padding留给 DataLoader 的 collate_fn 统一处理同时把 padding 位置的 label 设为-100让损失函数自动忽略。参数说明max_len128在大多数评测场景够用。如果你在 2.2 节统计出 p90 句长超过 120就要把max_len提到 256。如果你坚持用tokenizer.encode而不做 offset 对齐文本里一旦出现英文、数字或全角字符错位几乎必然发生。这个坑我踩过一次血泪教训。4.2 训练循环批次组织、学习率与早停训练循环本身没什么黑科技关键参数值得记一下。对 CCKS2019 这类中小型数据我常用配置batch_size16learning_rate5e-5epoch3 到 5warmup_ratio0.1max_grad_norm1.0。显存不够时 batch_size 降到 8学习率同步降到 3e-5。from torch.utils.data import DataLoader, Dataset from transformers import AdamW, get_linear_schedule_with_warmup class NERDataset(Dataset): def __init__(self, input_ids, attention_mask, label_ids): self.input_ids input_ids self.attention_mask attention_mask self.label_ids label_ids def __len__(self): return len(self.input_ids) def __getitem__(self, idx): return { input_ids: torch.tensor(self.input_ids[idx], dtypetorch.long), attention_mask: torch.tensor(self.attention_mask[idx], dtypetorch.long), labels: torch.tensor(self.label_ids[idx], dtypetorch.long), } def collate_fn(batch, pad_id0, label_pad_id-100): input_ids [x[input_ids] for x in batch] attention_mask [x[attention_mask] for x in batch] labels [x[labels] for x in batch] input_ids torch.nn.utils.rnn.pad_sequence( input_ids, batch_firstTrue, padding_valuepad_id) attention_mask torch.nn.utils.rnn.pad_sequence( attention_mask, batch_firstTrue, padding_value0) labels torch.nn.utils.rnn.pad_sequence( labels, batch_firstTrue, padding_valuelabel_pad_id) return input_ids, attention_mask, labels逻辑说明collate_fn做动态 paddinglabel_pad_id-100保证 padding 位置不参与 CrossEntropy 计算也不会进入 CRF 的 mask。因为对齐阶段没有 paddingbatch 内序列长度不一pad_sequence把它们垫到 batch 内最大长度比固定max_len省显存。训练循环里加早停和最优 checkpoint 保存这是必须的。评测任务看的不是最后一个 epoch而是验证集上最优的那一个。best_f1 0 for epoch in range(epochs): model.train() for batch in train_loader: input_ids, attention_mask, labels [x.to(device) for x in batch] loss, _ model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() model.eval() v_f1 evaluate(model, valid_loader) # 用 4.3 节的 span_f1 if v_f1 best_f1: best_f1 v_f1 torch.save(model.state_dict(), best_ner.pt) print(fepoch {epoch} f1 {v_f1:.4f})逻辑说明梯度裁剪是 CRF 训练的保险丝因为 CRF 反向传播容易出现梯度爆炸loss 会突然变成 nan。只在验证 F1 提升时保存模型避免 epoch 后期过拟合覆盖掉最优参数。训练和评估之间必须切换model.train()/model.eval()否则 dropout 会污染验证结果。参数说明learning_rate5e-5是从 3e-5 到 5e-5 这个区间里对 BERT 微调最常用的起点如果你用的是 RoBERTa 类模型我一般会降到 3e-5。epochs不要硬编码以验证 F1 连续两轮不升为早停信号。CCKS2019 这类任务通常在 3 到 5 个 epoch 内收敛。4.3 评估脚本token F1 与 span F1 的差距评测口径直接决定调参方向。CCKS2019 这类任务通常按实体 span 评估一个实体只有起点、终点、类型全部正确才算对这叫严格 F1。很多人的代码按 token 级算 F1把 B、I 各当一个 token指标虚高五六个点。线上打分看的是 span不是 token。def extract_entities(preds): entities [] current None for i, label in enumerate(preds): if label.startswith(B-): if current is not None: entities.append(current) current [i, i, label[2:]] elif label.startswith(I-) and current is not None and current[2] label[2:]: current[1] i elif label.startswith(E-) and current is not None: current[1] i entities.append(current) current None elif label.startswith(S-): if current is not None: entities.append(current) entities.append([i, i, label[2:]]) current None else: if current is not None: entities.append(current) current None if current is not None: entities.append(current) return entities def span_f1(gold_spans, pred_spans): correct sum(1 for p in pred_spans if p in gold_spans) precision correct / len(pred_spans) if pred_spans else 0 recall correct / len(gold_spans) if gold_spans else 0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0 return precision, recall, f1逻辑说明extract_entities把标签序列还原成语义上的实体列表每个实体记成[start, end, type]这个结构跟官方评测脚本的比对口径一致。span_f1用 Python 的in做集合比对要求实体的起点、终点、类型完全相同才算命中。在 BIO 标签下E-和S-分支不会触发但代码保留它们BIOES 数据能直接复用。再进一步调参时要看分类别 F1只看总体 F1 会掩盖长尾类别的问题。from collections import defaultdict def per_type_f1(gold_entities, pred_entities): gold_by_type defaultdict(list) pred_by_type defaultdict(list) for e in gold_entities: gold_by_type[e[2]].append(e) for e in pred_entities: pred_by_type[e[2]].append(e) result {} for t in set(gold_by_type) | set(pred_by_type): g, p gold_by_type.get(t, []), pred_by_type.get(t, []) c sum(1 for item in p if item in g) precision c / len(p) if p else 0 recall c / len(g) if g else 0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0 result[t] (precision, recall, f1) return result逻辑说明这个函数按实体类型分组后分别计算 P/R/F1能快速定位是哪一类实体拖了后腿尤其是长尾类别。如果某个类别 F1 明显低优先检查标注噪声和样本量而不是马上换模型。参数说明评估时注意过滤 padding 位置。推理出来的预测序列和标签序列都只保留真实长度部分把[CLS]、[SEP]对应的位置剔除后再extract_entities。5. 中文 NER 训练与交付避坑记录5 个必须提前知道的翻车现场5.1 验证集指标虚高线上成绩却崩了现象训练时验证 F1 有 90 以上提交到评测却只有 70 多。原因最常见的是数据划分没按篇章或文档维度切。CCKS2019 这类数据里同一个文档的多个句子可能顺序散布在文件里如果随机切句子做 train/valid同一个实体的上下文在两边同时出现模型等于见过答案再考试。另一个原因是有人把测试集标签也放进验证集做调参这属于标签泄漏提交必崩。解决严格按文件 ID 或篇章 ID 分组划分保证同一个文档的句子不会同时进训练和验证。划分后随机抽 50 个验证集句子肉眼确认没有和训练集重复的连续段落。5.2 训练 Loss 在降F1 却是 0现象训练几个 epochloss 从 30 降到 2但验证 span F1 一直是 0。原因十有八九是标签对齐错位。bert-base-chinese虽然按字切但遇到全角标点、英文、数字、连续空格时一个字符一个 token的直觉不成立。我踩过一次文本里有罗马数字字符tokenizer 把它拆成两个 token我把标签按长度硬铺整个 batch 的标签全部错位一个位置模型学到的是每个 token 预测下一个字的标签。解决所有预处理统一走 4.1 节的 offset_mapping 对齐逻辑。每次实验前做一个自检随机取一个 batch把input_ids解码回文本和 label 序列一起打印人工比对第 10 到第 30 个 token确认一一对应。这个自检耗时五分钟能省一天 debug。5.3 模型把所有 token 都预测成 O现象acc 很高95% 以上但 F1 很低预测结果全是 O。原因O 标签占比通常超过 85%模型全输出 O 就能把 loss 压得很低。这不是模型坏了是类别不平衡下的正常行为。评测指标是 F1 不是 acc所以这种模型没有实用价值。解决三种手段叠加。第一对非 O 标签加权比如把 O 的损失权重压到 0.3在 CRF 的发射分数上做调整不直观我一般是在训练数据层面做等比例上采样实体样本第二用类别层面的 F1 监控不要只盯 acc第三如果数据量太少考虑用测试集做半监督伪标签增强但前提是基线模型的 F1 已经到 80 以上。5.4 推理结果每次不一样成了随机数发生器现象同一个验证集连续跑两次预测F1 差三个点以上。原因几乎永远是推理时没有切到 eval 模式。BERT 里的 dropout 在model.train()下随机丢神经元带着 dropout 去做维特比解码CRF 拿到的发射分数每次都不一样结果自然不稳定。解决推理时固定写法是model.eval()加with torch.no_grad():。如果加载多卡训练的 checkpoint再设置torch.manual_seed(42)再做预测排除少数算子的随机性。这个坑不大但排查时很容易想到模型结构上白费功夫。5.5 提交的 zip 被评审脚本判文件不存在现象模型效果不错提交 zip 后评审直接报文件不存在或者标签文件缺失。原因打包时目录结构不对。常见做法是把结果文件放在 zip 根目录但评测脚本期望某个固定顶层目录名还有人在 mac 上打包zip 里带__MACOSX目录和一堆隐藏文件评审机器解压后找不到约定的路径。解决提交前先自己完整走一遍解压到新目录 → 运行官方评测脚本的流程确认路径和文件名。如果你的运行环境是 mac 或 Windows打包前先清理隐藏文件用unzip -l检查压缩包内容看到__MACOSX就重新打包。6. 提交前最后一个技巧用 zip 自检脚本把格式错误挡在门外6.1 一个够用的提交自检清单我每次提交 CCKS2019 中文命名实体识别这类评测前都会过一遍这张清单顺序固定缺一不可检查项命令/方式不合格就换压缩包完整性unzip -t重新打包目录结构unzip -l按评审要求重建文件编码file result.txt转成 UTF-8 无 BOM标签集合cut -f2 result.txtsort -u句子对齐对比行数与测试集重新预测这个清单能挡住九成以上的交付事故。评测系统往往不告诉你失败原因只会给一个 0 分或者文件不存在所以验证要放在自己这边。6.2 更稳的半自动自检预测后先验格式再打包与其手动记不如把流程串起来预测完成后自动跑一次标签合法性校验再打包提交。python predict.py --checkpoint best_ner.pt --output result.txt# validate_result.py 简版 legal_labels set([O, B-xxx, I-xxx, E-xxx, S-xxx]) with open(result.txt, encodingutf-8) as f: for i, line in enumerate(f): parts line.rstrip(\n).split(\t) if len(parts) ! 2 or parts[1] not in legal_labels: raise SystemExit(f第 {i1} 行非法: {line.strip()}) print(格式校验通过)mkdir -p submission/result cp result.txt submission/result/ cd submission zip -r ../submission.zip . unzip -l ../submission.zip逻辑说明validate_result.py逐行解析结果文件检查字段数和标签合法性能拦掉 CRF 解码后串行、文件缺行等低级错误。打包时先建固定顶层目录再cd进去压缩压缩包内天然有一个submission顶层目录避免散文件把评审脚本搞懵。unzip -l是提交前最后一道检查确认没有__MACOSX、没有多余文件。参数说明legal_labels必须和你的label2id键集合完全一致如果用了 BIOES就把 B/I/E/S 四类都列全。这种打包方式等价于把当前文件夹压缩成 zip适合 linux 环境在 Windows 上压缩前记得先删除Thumbs.db这类隐藏文件。这是我在做过多次 CCKS2019 中文命名实体识别这类评测后养成的习惯永远不要让一个格式错误毁掉一次有效提交。希望这些踩过的坑和排错顺序能帮到你尤其是标签对齐和压缩包结构这两个环节几乎每个人都在这上面翻过车。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?