news 2026/10/6 3:02:10

SIGHAN中文纠错数据集全解析:从原始标注到BERT训练实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SIGHAN中文纠错数据集全解析:从原始标注到BERT训练实战

简介:SIGHAN中文纠错数据集是汉语语法错误检测与拼音标注领域的权威资源,由新加坡国立大学团队创建。这份压缩包在原始SIGHAN数据基础上进行了系统格式转换,面向中文自然语言处理研究者、算法工程师及相关专业学生,可用于中文拼写检查、语法纠错模型训练与语料库建设。包内共78个文件,以txt格式原始语料与sgml标注文件为主,同时包含readme说明文档、py转换脚本、jar工具、pdf技术文档及xlsx整理表格等,压缩包整体约19.92MB,目录结构清晰,便于按SIGHAN 7/8及CLP14CSC等子集检索使用。目前已有379人学习下载。借助转换脚本与格式说明,使用者可快速将原始错误标注转为适合机器学习模型批量处理的CSV或CoNLL格式,并完成训练、验证、测试集划分;配套文档还展示了错误类型覆盖与标注规则,能有效降低数据预处理门槛,帮助读者构建更精准的中文纠错系统。

1. SIGHAN中文纠错数据集:绕不开的“标准错题本”

做中文拼写纠错(Chinese Spelling Check,CSC)的从业者,十有八九绕不开 SIGHAN。这是 ACL 下设中文信息处理兴趣小组在历年中文纠错评测任务里发布的一组公开语料,每一句都标注了“哪个字错了、错在哪、属于什么错误类型”,颗粒度细到字符级,所以中文纠错方向的论文几乎都拿它当基准。更关键的是,这个 zip 在原始语料之外还附了一版转换后格式——不用再从零写清洗对齐管线,解压之后直接就能往 BERT 类模型的数据 loader 里塞。你如果正在做中文纠错、语文作文批改相关项目,或准备复现一篇纠错论文,这份数据包会是你最省事的一块敲门砖。

2. 读懂SIGHAN原始标注:错误类型、位置字段与读取脚本

2.1 错误类型只有四类,位置字段却最容易看走眼

SIGHAN 原始数据最常见的版式是每行五个字段,tab 分隔:序号、错误句、正确句、错误位置、错误类型。错误句是“带错原文”,正确句是“改完之后的句子”,位置字段标的是错误发生在第几个字符,类型字段则用一个字母说明错误性质。我见过不少人一拿到数据就急着写转换脚本,结果栽在位置字段上,所以我建议第一步先把原始标注的语义拆清楚。

四类错误分别是:S(substitution,替换)、D(deletion,缺失)、I(insertion,插入)、R(reversal,倒序)。替换是说一个字符被写成了另一个字,源句和目标句长度相等;缺失是源句少了一个字,目标句反而更长;插入是源句多了一个字,目标句更短;倒序则是相邻两个字符顺序写反。部分 re-pack 版本里还有第五类 U,表示标注者也无法判断的类型,处理时建议不要直接丢弃,而是单独归为一类。

举个演示样例(不是数据集原文,是我随手造的):错误句“学声乐可以陶治情操”,正确句“学声乐可以陶冶情操”,这里“治”写错,应为“冶”,位置在第 7 个字符,类型是 S。用列表示就是学/声/乐/可/以/陶/治/情/操,第 7 位标错。这个位置按字符数从 1 开始计数,包含标点、数字和所有可见字符,不区分全角半角。

注意:SIGHAN 的位置从 1 开始,而 PyTorch 和大部分模型内部习惯从 0 开始。转换时统一减去 1,别一半 0 一半 1 地混着用。

位置字段的写法在不同发布批次里也不一样,有写成纯数字的7,有写成范围3-4的,有多个位置用空格或逗号分隔的。类型字段同样可能是一个字母也可能是多个字母。所以读取函数不能写死分割符,我一般用正则来拆:

import re from typing import List, Tuple def parse_sighan_positions(pos_field: str) -> List[Tuple[int, int]]: """解析位置字段,兼容 '3'、'3-4'、'3 5'、'3,5' 等常见写法。""" out = [] for token in re.split(r"[\s,,]+", pos_field.strip()): if not token: continue if "-" in token: left, right = token.split("-", 1) out.append((int(left), int(right))) else: p = int(token) out.append((p, p)) return out def parse_sighan_line(line: str): fields = line.rstrip("\n").split("\t") if len(fields) < 5: return None idx, wrong, correct, pos_field, type_field = fields[:5] positions = parse_sighan_positions(pos_field) types = type_field.split() if len(types) < len(positions): types = types + ["U"] * (len(positions) - len(types)) return {"idx": idx, "wrong": wrong, "correct": correct, "errors": list(zip(positions, types))}

这个函数做两件关键事:一是用正则把位置字段里的空格、逗号、全角逗号全当成分隔符,避免因为数据格式不统一直接崩;二是当类型字段数量少于位置数量时自动补U,保证errors列表里每个错误点都有对应类型。实际数据里的脏行不少,返回None并统计丢弃行数比直接抛异常更适合批量转换场景。

2.2 位置字段基于哪一句,其实是有讲究的

对替换类错误,源句和目标句同一位置的字都能对上,位置没有歧义。但对缺失和插入类错误,位置到底是按源句数还是按目标句数,不同数据集的处理习惯不同。以缺失为例,源句少了一个字,“少了字的位置”在源句里根本不存在字符,标注者只能按目标句去数位置,而这个位置在转换时又不能在源句里直接取到字符。插入则正好反过来。

所以拿到原始数据后,我习惯先做一个断言:对每条 S 类型、等长的错误,检查源句和目标句在标注位置上的字符确实不同;对 I/D 类型,检查两个句子的长度差与错误类型自洽。这一步能把相当一部分格式理解错误挡在建模之前。

def quick_check(item): src, tgt = item["wrong"], item["correct"] for (a, b), typ in item["errors"]: if len(src) == len(tgt) and typ == "S" and a == b: assert src[a - 1] != tgt[a - 1], ( f"位置{a}两个字符相同: {src[a - 1]!r}" ) return True

这个检查看起来很基础,但很多踩坑都是从“我以为类型字段是 S,结果两个字符都一样”开始的。转换后格式再好,原始标注读错了,后面全是白做。

3. 转换后格式到底转了什么:三条落地路径与对齐逻辑

“转换后格式”这个词没有统一标准,不同作者打的包差异很大。我会按自己多次整理这份数据时的习惯,把最常见的三种落地形态各讲一遍,你看手头的 zip 时心里就有谱了。

3.1 字符级序列标签:喂给 BERT 类纠错模型的默认选择

中文拼写纠错里最主流的做法是把它建模成序列标注:对源句每个字符预测一个标签,标签非 O 就说明这个字错了,再根据标签类型决定怎么改。转换后格式如果走这条路,通常是一个 JSONL 文件,每一行是一个字典,包含序号、源句、目标句、错误列表,以及可选的对齐后的字符级标签列表。

{ "idx": 1, "src": "学声乐可以陶治情操", "tgt": "学声乐可以陶冶情操", "errors": [ {"start": 7, "end": 7, "type": "S", "src_char": "治", "tgt_char": "冶"} ], "labels": ["O", "O", "O", "O", "O", "O", "S", "O", "O"] }

其中labels与src逐字符对齐,长度相同,这是序列标注模型最想要的形态。注意:D(缺失)类型在源句里没有对应字符,常见做法是把缺失位置左侧的字符标成D,让模型学“这个字附近少了一个字”。这个挂靠位置没有绝对标准,我用的是左侧挂靠,如果你看过别家的转换格式用的是右侧,也不奇怪。

为什么首选字符级序列标注?因为中文拼写纠错的错误密度很低,绝大多数句子只需要改一到两个字,用生成式模型重写整句的代价太高,而且错误位置信息是明确给出的,直接监督一个分类头更省资源、也更容易诊断问题。

3.2 平行句对:喂给生成式纠错模型的前提

另一条路是把数据整理成src和tgt两组平行文本,直接用来微调 BART、T5 这类生成模型。转换后格式如果走这条路,除了文本对,还要把错误位置信息保留在一个单独字段里,这样模型可以额外接入“哪些位置可能错了”的提示。

{ "idx": 2, "src": "他喜欢打蓝球", "tgt": "他喜欢打篮球", "errors": [ {"start": 6, "end": 6, "type": "S", "src_char": "蓝", "tgt_char": "篮"} ], "src_length": 6, "tgt_length": 6 }

保留src_length和tgt_length看着多余,实际上有奇效:后面加载数据时可以直接用长度差判断句子是替换型错误还是插入/缺失型,不需要再回头扫描错误类型字段。生成式模型的 tokenizer 是 BPE 级别,字符位置没法直接对上,所以转换格式里必须额外给“错误在原文里的字符偏移”,否则位置信息就丢了。

3.3 最小转换脚本:把原始 TSV 拍平成 JSONL

如果手头这份 zip 里只有原始 TSV,没有现成转换结果,你就需要自己写一次转换。我一般用一个极简脚本,只做三件事:解析原始行、按统一 schema 输出、统计成功和丢弃行数。

import json from pathlib import Path def convert_sighan_tsv(source: Path, output: Path) -> None: ok = dropped = 0 with source.open("r", encoding="utf-8") as fi, \ output.open("w", encoding="utf-8") as fo: for line in fi: parsed = parse_sighan_line(line) if parsed is None: dropped += 1 continue errors = [] for (a, b), typ in parsed["errors"]: errors.append({ "start": a, "end": b, "type": typ, "src_char": parsed["wrong"][a - 1] if a <= len(parsed["wrong"]) else None, "tgt_char": parsed["correct"][a - 1] if a <= len(parsed["correct"]) else None, }) record = { "idx": parsed["idx"], "src": parsed["wrong"], "tgt": parsed["correct"], "errors": errors, } fo.write(json.dumps(record, ensure_ascii=False) + "\n") ok += 1 print(f"ok={ok}, dropped={dropped}")

这里有几个参数细节值得注意。ensure_ascii=False会把中文直接以 UTF-8 写进文件,否则 json.dumps 默认会把中文转成\u转义序列,之后人工检查数据时根本没法看,也容易在后续加载时徒增解码心智负担。src_char和tgt_char只按start位置取字符,对缺失和插入类错误可能取到 None,我在代码里用条件表达式兜了底,不会抛越界异常。最后那个ok和dropped的输出不是装饰,它能帮你快速判断原始文件有没有大面积脏行,如果 dropped 占比超过 1%,先回去检查解析逻辑,别急着往下训练。

4. 用转换后数据跑通最小纠错基线:从 JSONL 到训练循环

转换后格式的价值要落到模型上才算数。这一章我带你走一遍最小可跑的流程:把 JSONL 读进来、转成 BERT 能吃的输入、配上几个关键参数。

4.1 数据加载:字符标签与 token 的对齐方法

BERT 类模型的 tokenizer 会对句子做切分,中文 BERT 下绝大多数汉字是一个 token,但[CLS]、[SEP]以及个别标点会改变序列长度,所以字符级标签不能直接塞给模型,必须和 token 对齐。正确做法是让 tokenizer 返回offset_mapping,再按字符偏移把标签映射到 token 上。

import json import torch from transformers import AutoTokenizer IGNORE_INDEX = -100 LABEL_O = "O" LABEL2ID = {"O": 0, "S": 1, "D": 2, "I": 3, "R": 4} def load_and_align(jsonl_path, tokenizer, max_len=128): encodings = [] with open(jsonl_path, encoding="utf-8") as f: for line in f: item = json.loads(line) enc = tokenizer( item["src"], max_length=max_len, truncation=True, padding="max_length", return_tensors="pt", return_offsets_mapping=True, ) char_labels = [LABEL2ID[LABEL_O]] * len(item["src"]) for err in item["errors"]: pos = err["start"] - 1 if err["type"] in LABEL2ID: char_labels[pos] = LABEL2ID[err["type"]] # 按 offset_mapping 映射到 token 空间 token_labels = [IGNORE_INDEX] * len(enc["input_ids"][0]) for t, (start, end) in enumerate(enc["offset_mapping"][0]): if start == end: continue # [CLS]/[SEP] 或 padding 位置 chunk = char_labels[start:end] if not chunk: continue token_labels[t] = chunk[-1] # 取区间内最后一个标签 encodings.append({ "input_ids": enc["input_ids"][0], "attention_mask": enc["attention_mask"][0], "labels": torch.tensor(token_labels, dtype=torch.long), }) return encodings

这段代码有两个关键点。一是char_labels先按源句长度初始化成全 O,再把每个错误位置覆盖成对应类型,这样源句里的正常字符和错误字符就各自有了明确标签。二是token_labels通过chunk[-1]取区间最后一个标签,这种取法在中文场景下基本不会出错,因为一个 token 通常只覆盖一个汉字;如果以后换成英文纠错数据,一个 token 可能覆盖多个字符,就需要改成“取最后一个非 O 的标签”或“取出现频率最高的标签”。

我在加载时统一用padding="max_length"把输入打到定长,训练脚本里就不用写动态 padding 的逻辑了,显存稍微多花一点,但换来的是代码简单不少。如果你的数据量大,再改用collate_fn做动态 padding,这里先保证能跑通。

4.2 参数配置:max_len、batch_size、学习率与类别权重

参数推荐值理由
max_len128SIGHAN 句子基本在百字以内,128 可以覆盖绝大多数样本,截断造成的标签损失很小
batch_size16 或 32BERT 加一个线性分类头,参数量不大,显存允许就 32
learning_rate2e-5 到 3e-5微调 BERT 的通用区间,再大容易把预训练权重冲坏
epochs3 到 5数据量只有几千句,轮次太多必然过拟合
类别权重O 设为 0.1,S 设为 1.0O 标签占绝对多数,不给权重模型会学成“全预测 O”

这里的类别权重值得单独解释一下。中文纠错数据里正常字符永远是绝大多数,一个句子最多错两三个字,如果直接拿交叉熵训练,模型只要全预测 O 就能拿到 95% 以上的准确率,但 F1 会非常难看。所以损失函数里通常要对 O 类降权重,让模型更重视非 O 类别的错误。PyTorch 的CrossEntropyLoss(weight=...)可以直接传权重向量,顺序要和LABEL2ID保持一致。

训练时我还建议固定随机种子。数据本身不大,模型初始化和数据 shuffle 的随机性对结果影响很明显,同一条训练配置不同 seed 跑出的 F1 可能差两三个点。固定 seed 之后至少能做到任何一次改动都能在可控噪声下对比出真实效果。

4.3 快速验收:用验证集判断转换有没有跑偏

跑第一个 epoch 之前,先做一个低成本的验收:加载原始 SIGHAN 验证集,统计转换后 JSONL 里的错误类型分布和平均错误数。如果统计结果和已知的 SIGHAN 分布差异过大,大概率是格式解析或转换逻辑出了问题。常见的异常信号包括:替换类占比异常高(说明位置解析把其他类型算错了)、缺失和插入的比例完全为零(说明这版数据格式里 D/I 的位置表达方式和你理解的不一样)、平均错误数超过 2 个(这不符合学生作文改错的普遍密度)。

from collections import Counter def statistic(jsonl_path): type_counter = Counter() total_err = 0 with open(jsonl_path, encoding="utf-8") as f: for line in f: item = json.loads(line) total_err += len(item["errors"]) type_counter.update(e["type"] for e in item["errors"]) print(type_counter, "avg_err=", total_err / sum(1 for _ in jsonl_path))

这段统计脚本写得非常简单,但每个字段都有用。type_counter让你一眼看到四类错误占比,avg_err帮你判断转换后格式是否保留了完整的错误信息。等这一步确认没问题,再花时间调模型参数才不浪费。

5. 避坑:SIGHAN与转换后格式里最容易翻车的五个细节

5.1 字符位置差一:永远先断言再进模型

现象:训练时 loss 下不去,回看样本发现模型预测的错误位置普遍比真实位置偏右一位,或者整体偏左一位。原因:SIGHAN 位置从 1 开始,很多转换代码忘了减一;还有人把换行符、空字符也算进了长度。解决:在数据加载函数最前面加一个断言,等长句子里 S 类型必须满足src[post-1] != tgt[post-1],有一处不满足直接抛异常。宁可让训练卡在数据加载,也不要让错误标签悄悄溜进模型。

5.2 插入与缺失类错误让序列标注直接对不齐

现象:转换后格式的labels长度和src长度不一致,模型输出维度对不上,报shape mismatch。原因:缺失类错误在src里没有对应字符,标签无处可挂;插入类错误虽然src里有字符,但目标句里没有对应位置,简单按位置取tgt_char会取错。解决:转换脚本里对 D 类标签做左右挂靠,对 I 类标签直接把多余字符标成 I;如果跑生成式模型,则只需要保证src和tgt两个字段完整,不需要强行对齐。这个挂靠规则要在 README 或注释里明确写出来,否则后面换人接手时又是一轮翻车。

5.3 标签与 BERT 的 tokenize 结果错位

现象:打印input_ids解码后得到的文本和原句一模一样,但训练时正确率却很低。原因:标签是按字符序列写的,实际喂给模型的是 token 序列,二者长度对不上,又没有做offset_mapping对齐。解决:加载数据时让 tokenizer 返回return_offsets_mapping=True,按偏移把字符标签映射到 token 空间,特殊 token 和 padding 位置用IGNORE_INDEX屏蔽。别把“看起来一样”当成“数值对齐”,打印中间张量验证是唯一的可靠方法。

5.4 解压 zip 时 CRC 校验失败,数据处理到一半才发现

现象:解压过程中报invalid zip archive、CRC check failed,或者 zip 能解压但里面某几个文件为空文件。原因:zip 从网络传输中断、磁盘空间不足、压缩包本身不完整。解决:解压前先做完整校验,拿一个 zip 包先用unzip -t跑一遍,确认所有条目都通过 CRC 校验再解压;解压后再用du -sh对比压缩包和解压目录的大小,必要时直接看文件行数。这一步看起来浪费时间,却能避免后续模型脚本在一个损坏文件上反复报错。

unzip -t SIGHAN中文纠错数据集及转换后格式.zip

unzip -t不带参数直接测试所有文件,只要输出里出现OK之外的任何信息,都说明包里文件有问题。很多项目的根因不是代码 bug,而是数据文件本身缺了内容,这类问题最隐蔽。

5.5 繁体、简体与全角标点转换后的字符对不上

现象:转出来的src_char和tgt_char看着是同一个字,但模型预测总是错;或者评估时字符级 F1 忽高忽低。原因:原始 SIGHAN 文本包含繁体字和全角标点,部分转换代码做了繁简归一,另一部分没做,导致同一份数据里两种编码风格混着用。解决:转换前明确全流程是否做繁简转换,并在转换脚本里统一处理。做纠错模型的话我建议保留原字符不做繁简归一,因为真实使用场景里用户输入什么就改什么;但在计算字符级指标时,要明确比较的是“原字符”还是“归一化后字符”,两种口径的不同直接影响 F1。评估前先确认口径再确认代码。

6. 进阶验证:字符级F1这样算,复现才有底气

6.1 检测 F1 与修正 F1 分开算

中文纠错评估里,检测和修正是两个概念。检测只看“模型有没有发现这个字错了”,修正还要看“发现之后有没有改对”。单独一个总 F1 无法体现模型是在哪一步崩的,所以我会把两个指标拆开算,分别对应计算机和人工排查问题时的不同线索。

def detection_f1(pred_flags, gold_flags): tp = fp = fn = 0 for p, g in zip(pred_flags, gold_flags): if g == 1: if p == 1: tp += 1 else: fn += 1 elif p == 1: fp += 1 prec = tp / (tp + fp + 1e-9) rec = tp / (tp + fn + 1e-9) f1 = 2 * prec * rec / (prec + rec + 1e-9) return prec, rec, f1

pred_flags和gold_flags是等长度 0/1 列表,每个位置表示该字符是否被判定为错误。这个脚本是字符级的,所以位置对齐已经在你前期的offset_mapping里完成,这里只做集合比较。修正 F1 则在pred_flags基础上再叠加一步:只有当错误字符被替换成了正确字符才算正例,否则即使识别出了错误,也只算检测命中。

我自己的习惯是:每次跑完实验,把检测 F1、修正 F1、各类错误的小类 F1 全部打印出来,再和 SIGHAN 基准区间对照。如果检测 F1 明显低于 0.85,先检查数据加载和标签对齐;如果检测正常但修正偏低,再回头查标签映射表或模型预测头。这套分层排查思路帮我避过不少玄学调参,也建议你拿到这份转换后数据时按同样流程先建一条评估基线,再把模型参数改起来。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 3:02:07

JSP网上书店毕设全解析:从数据库到答辩的高频坑与实战经验

做毕业设计选“网上书店”这个题目&#xff0c;十个人里有八个会问你&#xff1a;JSP 不是过时了吗&#xff1f;为什么不用 Spring Boot&#xff1f;导师会不会觉得太简单&#xff1f;但我想说的是&#xff0c;这个题目放在计算机毕设里&#xff0c;恰恰是一个被严重低估的“黄…

作者头像 李华
网站建设 2026/10/6 3:00:07

CRC-4校验码:生成多项式、位运算实现与链路层应用解析

简介&#xff1a;这是一份面向计算机网络学习的CRC-4校验码源码资料包&#xff0c;压缩后仅15KB&#xff0c;包含6个文件。资源以四位循环冗余校验算法为核心&#xff0c;汇编源文件给出底层实现&#xff0c;C语言文件提供查表法加速所需的CRC查找表&#xff0c;同时附带可直接…

作者头像 李华
网站建设 2026/10/6 2:58:18

从一架六旋翼开始:低空飞行器专业写论文,AI 工具到底怎么选?

如果你读的是装备制造大类 / 机电设备类 / 低空飞行器工程技术&#xff0c;大概率会遇到一类很典型的毕业任务&#xff1a; 设计一架小型低空六旋翼飞行器&#xff0c;完成机架结构与动力系统初步设计&#xff0c;建立姿态控制模型&#xff0c;并进行 MATLAB/Simulink 或飞行仿…

作者头像 李华
网站建设 2026/10/6 2:58:00

BCI实战全链路:从脑电特征提取到SVM分类控制小车

简介&#xff1a;围绕脑电信号与脑机接口应用&#xff0c;这份压缩包提供了一套完整的“脑电控制小车”实验工程&#xff0c;覆盖EEG特征提取、小波多分辨率分析、脑电分类模型以及上位机与小车控制逻辑&#xff0c;适合生物信号处理、机器学习和嵌入式控制方向的学习者参考。包…

作者头像 李华
网站建设 2026/10/6 2:57:46

基于Flink全端用户画像的实时商品推荐系统实战

简介&#xff1a;这份资源是《基于Flink全端用户画像商品推荐系统》的完整项目源码包&#xff0c;面向学习大数据实时处理与推荐算法的计算机专业学生及开发者&#xff0c;可作为课程设计、毕业设计或实战练手项目。系统以Apache Flink为核心引擎&#xff0c;覆盖数据采集、实时…

作者头像 李华
网站建设 2026/10/6 2:56:30

Eclipse JEE 2022-03 R版Linux部署避坑指南

简介&#xff1a;本资源是专为Linux平台Java企业级开发者提供的Eclipse JEE 2022-03-R正式发行版&#xff0c;适用于64位x86_64架构的GTK桌面环境&#xff0c;开箱即用&#xff0c;无需安装&#xff0c;可直接解压启动&#xff0c;显著降低Java Web、Servlet、JSP及微服务项目的…

作者头像 李华