简介:SIGHAN中文纠错数据集及转换后格式.zip包含新加坡国立大学构建的中文拼写与语法纠错语料,覆盖clp14csc、sighan7csc、sighan8csc等多届竞赛官方数据及raw原始文本,面向汉语言处理研究者、NLP工程师与中文纠错系统开发者,可用于错别字识别、词序纠正和词语搭配检测等模型训练。
资源共78个文件,约19.92MB,txt与sgml保存原始标注语料及错误位置标记,readme与md为使用说明,py脚本便于读写语料和生成pair数据,jar、pdf、xlsx补充辅助工具与参考。
已有379人学习下载。转换后的简化/繁体文本和统一格式可直接用于基准测试、数据划分与错误标签建模,省去繁琐清洗;配套说明与生成脚本还支持定制多源语料及错误类型分布,是复现SIGHAN评测和对比不同纠错算法的实用起点。
1. SIGHAN中文纠错数据集及转换后格式:为什么NLP从业者手里都存着一份这样的zip
接到中文拼写纠错的需求,无论是做客服日志清洗、搜索词纠错还是文本校对,第一个绕不开的问题就是:拿什么数据验证方案。业内绕了这么多年,最后还是会绕回SIGHAN这组基准数据。这个zip压缩包的核心价值,不是它多稀有,而是它把原来分散在历届评测任务里的原始语料,整理成了可以直接喂给脚本的“转换后格式”。对刚接触中文纠错的人,省去的是到处找数据集和解析格式的时间;对做过几轮项目的人,这份数据能帮你在半天内把模型基线、评测脚本和数据管道全部跑通。本文就顺着这个压缩包讲清楚:里面的数据从哪来、长什么样、如何转成BERT能吃的格式,以及转换过程中那些不带踩一次根本不会知道的坑。
2. 理解SIGHAN的原始格式:三届评测、两种标注载体与一个隐藏前提
2.1 SIGHAN 2013/2014/2015:从“句子对”到“错误类型标注”,数据差异在哪
SIGHAN是东亚语言处理领域的评测组织,中文拼写纠错(Chinese Spelling Check)是它这些年持续做的任务之一。公开能拿到的数据主要来自三届评测:2013、2014和2015。三届的侧重点不太一样,简单说,2013年更像“能不能发现错字”,2014年开始要求“发现错字并给出正确字”,2015年则加入了大量模拟生成的拼音混淆错误,让任务从纯视觉混淆扩展到了音近字混淆。
这三届数据在错误类型覆盖上有明显差异。如果你拿到的是别人整理好的“转换后格式”,通常已经把这些差异抹平,统一成了同一种结构化文件。但如果没有抹平,你就要自己处理一个现实:2013年的数据主要覆盖单字替换错误,2014年新增了多字词错误和字序颠倒,2015年则把拼音相似和字形相似混在一起。用2015年训练、2013年测试,效果往往比反过来用好,因为2015年模拟生成的数据规模更大,错误模式更“均匀”。
| 届别 | 错误类型重心 | 标注粒度 | 常见用途 |
|---|---|---|---|
| SIGHAN 2013 | 单字替换、少量删除 | 句子对(错句+正句) | 基线检测、字级纠错 |
| SIGHAN 2014 | 替换、删除、插入、多字词 | 句子对+错误类型标签 | 错误检测与定位 |
| SIGHAN 2015 | 拼音混淆、字形混淆 | 句子对+混淆对 | 音近/形近纠错、增强训练 |
我一般拿到一份转换后的数据,第一件事是先统计“错误类型分布”而不是直接开始训练。这个动作能避免一个很尴尬的情况:模型在测试集上F1很高,但因为测试集里绝大多数错误是单字替换,模型实际只学会了“猜字”,根本没有泛化能力。所以,无论下游是什么模型,前面花半小时理解三届数据的差异,是值得的。
2.2 打开zip后常见的三种文件形态:SGML、双文件句对与制表符标注
这个zip解压后,里面文件的呈现方式通常逃不出三种形态。第一种是SGML/XML风格的文档,带<DOC>、<SENT>这样的标签,正确句子和错误句子成对出现;第二种是纯文本句对,一个文件里每两行一组,上一行是错句,下一行是正句,或者干脆错句和正句放两个文件,按行号对应;第三种是制表符分隔的标注文件,每行是“错句、正句、错误位置、错误类型”的组合。
这三种形态对应完全不同的解析策略。SGML形态需要用XML解析器取标签内文本;双文件句对需要按行索引做zip配对;制表符标注则需要小心“错误位置”到底是按字符偏移还是按词语偏移。特别要留意的是,网上流传的很多版本经过了无数次转发和打包,文件头注释、编码声明、标签名都可能被改动过。你拿到的“转换后格式”,不一定等于“格式正确”,它只是“被上一次打包的人处理过的格式”。
所以我面对一个陌生的SIGHAN相关zip,习惯是不要急着写大段解析代码,先解压后逐个文件查看前几行,确认是三届数据里的哪一届、文件是什么编码、句子对按什么方式组织。这一步看起来笨,却能省掉后面大量调试时间。原始SIGHAN数据规模本来就不算大,全系列加起来也就是几千句的量级,用文本编辑器打开看一眼完全可行。
2.3 为什么原始格式不能直接喂给BERT:字对齐、稀疏错误与评测口径
很多人第一次把原始句子对直接塞进模型,发现效果不如论文里写得好,就开始怀疑模型或者调参。实际上问题多半出在数据格式上。BERT类模型需要的是“每个字一个标签”的序列标注输入,或者“错句+正句”的生成式输入。原始SIGHAN的句子对没有给出字级别的错误位置,你拿到的是一整句错误句子和一整句正确的句子,模型没有中间监督信号。
就算你把句子对直接当成训练样本,误差和错误也很隐蔽。中文在BERT里按字切开是没错的,但英文、数字、标点符号会被Tokenizer切成子词甚至多个token,标签序列长度和输入序列长度对不上,训练时Loss直接算错。更深一层的问题是错误率太稀疏:一个句子里通常只有一到两个错字,如果模型预测全部是“没有错误”,准确率也能到95%以上,但纠错任务等于什么都没做。所以评测指标不能直接用准确率,得用错误检测的Precision、Recall和F1,而且要区分“检测”和“纠正”两个阶段分别计算。
这就解释了为什么“转换后格式”是这份数据的关键价值所在。所谓转换,本质上是把原始句子对变成模型能直接消费的监督信号:要么转成字符级BIO标签,让模型做序列标注;要么转成src-tgt句子对,让模型做生成。转换过程还需要把错误类型、混淆字频率统计出来,作为后续评测的辅助信息。理解了这一层,你才不会被网上各种互相矛盾的脚本带偏。
3. 把SIGHAN转成模型能吃的格式:BIO序列标注与句子对的完整转换脚本
3.1 从错句/对句到src-tgt JSON:先做最小可用的数据清洗
拿到SIGHAN原始句对后,我最先做的事是把它转成一个稳定的中间格式:一个JSON数组,每个元素包含src和tgt两个字段,再加一个id。这样做的好处是后续所有脚本只需要依赖这一个接口,不用再面对SGML和其他乱七八糟的变体。下面这个脚本处理的是最常见的“错误句子和正确句子分文件存放”的形态:
import json from pathlib import Path def build_jsonl(src_path: str, tgt_path: str, out_path: str) -> None: src_lines = Path(src_path).read_text(encoding='utf-8').splitlines() tgt_lines = Path(tgt_path).read_text(encoding='utf-8').splitlines() # 行数不一致时,以较短的一侧为准,并打印告警 if len(src_lines) != len(tgt_lines): print(f'[warn] src={len(src_lines)} vs tgt={len(tgt_lines)}, will truncate') n = min(len(src_lines), len(tgt_lines)) src_lines, tgt_lines = src_lines[:n], tgt_lines[:n] with open(out_path, 'w', encoding='utf-8') as f: for i, (src, tgt) in enumerate(zip(src_lines, tgt_lines)): record = {'id': i, 'src': src.strip(), 'tgt': tgt.strip()} f.write(json.dumps(record, ensure_ascii=False) + '\n') print(f'[done] {out_path}, total={len(src_lines)}')逻辑说明:脚本把两个纯文本句对文件按行号对齐,生成JSONL格式的中间文件。src是含有错字的句子,tgt是正确句子。核心参数是encoding='utf-8',这里强制指定编码,避免在不带BOM的Linux服务器上解析失败。ensure_ascii=False保证中文字符按原文写入,而不是转成\uXXXX。
参数说明:src_path和tgt_path分别指向错句文件和正句文件;out_path是输出JSONL路径。如果两个文件行数不一致,这个脚本会取较小值并打印告警,因为实际数据里偶尔会有空行或尾部截断。一个有用的调整参数是是否做strip,个别句子首尾带空格,保留空格会让后续字符对齐产生一个看不见的偏置,所以我默认直接去掉。如果你拿到的句对是“同一个文件里每两行一组”的形态,只需要把这个脚本的读取部分改成两步:先读所有行,再按奇数行和偶数行拆分即可。
3.2 用编辑距离对齐生成BIO标签:字符级标注的三个判定分支
句子对有了,下一步是生成字符级标签。常见做法是用编辑距离对齐src和tgt,把对应关系切成“相等、替换、删除、插入”四类操作。下面这段代码用difflib实现这个对齐,并输出BIO标签,其中B表示错误起始,I表示错误延续,O表示正确字符:
import difflib import json def tag_bio(src: str, tgt: str) -> list[str]: sm = difflib.SequenceMatcher(None, src, tgt) labels = ['O'] * len(src) for tag, i1, i2, j1, j2 in sm.get_opcodes(): if tag == 'equal': continue if tag == 'replace': for k in range(i1, i2): labels[k] = 'B' if k == i1 else 'I' elif tag == 'delete': # 目标中没有对应字符,同样标为错误 for k in range(i1, i2): labels[k] = 'B' if k == i1 else 'I' elif tag == 'insert': # 源字符没有对应字符,在源端没有位置,只影响tgt # 这里把前一个字符标为I,近似表示“附近有错误” if i1 > 0: labels[i1 - 1] = 'I' if labels[i1 - 1] != 'O' else 'B' return labels with open('sighan.jsonl', encoding='utf-8') as f: for line in f: rec = json.loads(line) rec['labels'] = tag_bio(rec['src'], rec['tgt']) print(rec['src'], rec['labels'], sep='\t')逻辑说明:difflib返回的是从src变换到tgt的最小编辑操作序列。replace和delete都会在src里留下错误字符,所以把它们标记为B或I;insert表示tgt里多出了字符,src里没有对应位置,常见近似处理是把前一个字符也标成I,表达“这一带存在错误”。这种粗糙处理对训练影响有限,因为插入错误在中文里本来就少。
参数说明:autojunk是SequenceMatcher的一个隐藏参数,默认开启,在长文本里会对重复字符做误判。处理SIGHAN这种短句子时,建议显式传入autojunk=False,可以这样写:difflib.SequenceMatcher(None, src, tgt, autojunk=False)。另一个关键点是标签数组长度必须等于len(src),因为BIO标签是“给每个源端字符一个标签”,跟模型输出的序列长度对齐。如果你要训练一个生成式纠错模型,就不需要这个标签,直接用src-tgt句子对即可。
3.3 输出到单文件并做训练/验证/测试切分:我常用的三层拆分法
转换后的数据要真正用于训练,还需要完成两件事:一是把所有样本合并到一个单文件里,方便后续Dataloader读取;二是做切分。SIGHAN数据集的句子长度集中在10到40个字之间,而且错误分布很不均匀,如果完全随机切分,很容易出现验证集里全是O标签的情况。我常用的做法是按“句子长度分层抽样”,每一层内部再随机分配,具体实现如下:
import json import random from collections import defaultdict random.seed(42) data = [json.loads(line) for line in open('sighan.jsonl', encoding='utf-8')] buckets = defaultdict(list) for rec in data: length = len(rec['src']) bucket = 0 if length <= 15 else 1 if length <= 30 else 2 buckets[bucket].append(rec) train, dev, test = [], [], [] for bucket in buckets.values(): random.shuffle(bucket) n = len(bucket) train += bucket[:int(n * 0.8)] dev += bucket[int(n * 0.8):int(n * 0.9)] test += bucket[int(n * 0.9):] for name, split in [('train', train), ('dev', dev), ('test', test)]: with open(f'{name}.jsonl', 'w', encoding='utf-8') as f: for rec in split: f.write(json.dumps(rec, ensure_ascii=False) + '\n') print(name, len(split))逻辑说明:先按源句长度分三个桶,短句、中句、长句各自内部再按8:1:1切分。这样做的好处是验证集和测试集里的句长分布跟训练集接近,不会出现测试集全是长句导致模型表现骤降的情况。切分前设置random.seed(42)保证结果可复现,这一点在调试时非常重要,没有固定随机种子,两次实验的差异会把你搞疯。
参数说明:三个长度桶的边界是15和30,这个值不是拍脑袋定的。SIGHAN句子的中位数长度大约在20字左右,15以下属于短句,30以上属于长句。如果你在自己的业务数据上做,建议先跑一句len(rec['src'])的分位数统计再定边界。切分比例0.8/0.1/0.1是我反复用下来比较稳的默认值,数据量少于300句时,可以把比例调成0.7/0.15/0.15,保证测试集至少有几十个样本,否则F1的置信区间会大到没有意义。
4. 转换数据时绕不开的坑:5个我实测翻车过的典型问题
4.1 乱码:UTF-8 no BOM、GBK祖传问题与“两个字符”的假象
现象:解压后用Python读文件,打印出来是一堆锟斤拷或者��,但是用记事本打开又是正常的。这是Windows和Linux之间编码交互最常见的翻车现场。
原因:SIGHAN相关数据在国内论坛、网盘里转过很多手,很多文件是GBK或GB18030编码的,而接收方系统默认用UTF-8解析。还有一类情况是文件被转成UTF-8但带着BOM头,Python的utf-8编码读进来会把\ufeff粘在第一个字符串前面,导致第一行text变成奇怪的字符,且不影响后续行。
解决:先做编码探测,再按探测结果读取。用chardet是最快的一条路:import chardet; raw = open(path, 'rb').read(10000); enc = chardet.detect(raw)['encoding'],然后再用对应编码读全文。读取后立刻用text.replace('\ufeff', '')把BOM清掉。还有一个细节:普通open(..., encoding='utf-8')遇到非法字节会直接抛异常,用errors='ignore'可以跳过,但这个做法要格外小心,它会静默吞掉真正的错误字符,导致句子对错位。更稳妥的是errors='replace',先把异常位置暴露出来,再人工判断是不是需要清理的行。
4.2 BIO标签错位:数字、英文和全角标点把序列长度撑出多余字符
现象:标签长度明明等于len(src),但训练时BERT的input_ids长度和labels长度不一致,Loss直接报错。
原因:中文字符在BERT Tokenizer里是一个字一个token,但数字串、英文单词、全角标点可能被切成多个token。例如“100元”里的“100”会被拆成“10”和“0”两个字,甚至拆成[unused]这样的片段。你的BIO标签是按“字”生成的,模型是按“token”计算的,两者的序列长度不相等。
解决:转换时统一按“字符”切分,同时在Tokenizer侧用add_special_tokens=False并检查len(tokenizer.tokenize(src))是否等于len(src)。如果不等,说明句子里有数字或英文,有两个treatment:一是保持BIO标签不变,训练时把标签按token展开,展开规则是第一个子词继承原标签,后续子词全部设为-100让模型忽略;二是更省事的做法,在转换脚本里过滤掉含英文和数字的句子。对于刚开始跑通基线的人来说,我建议先用第二个做法,因为SIGHAN本身以纯中文为主,过滤后剩余的句子也足够测试流程。
4.3 训练测试重叠:直接把2013训练集和2014测试集混在一起的漏网之鱼
现象:模型在测试集上F1高达0.93,换到真实业务数据上掉到0.6以下,而且想不明白为什么泛化这么差。
原因:SIGHAN历届评测的句子来源有重叠,同一个句子在不同年份里被重复使用,只是错误类型标注不同。如果你把2013训练集和2014测试集合并再切分,重叠句子可能同时出现在训练集和测试集里,模型等于“背”了答案,测试指标虚高。
解决:在做任何切分之前,先对src做去重,并在训练阶段把重复出现的句子打上标记。常见做法是维护一个set存所有见过句子,sample的src已经在set里,就不再放入训练集,只保留一份。特别注意src要去掉首尾空白再判断,否则“你好”和“你好 ”会被当成两个句子。这一步看起来笨,但能救回很多模型的泛化能力。我自己曾经因为忽略这一点,在论文复现时多花了三天时间排查,最后发现是数据泄漏问题。
4.4 错误样本太少,模型学成“永远输出O”:召回率归零的玄学解法
现象:训练Loss下降得很正常,验证集准确率有97%,但错误检测的Recall是0%。模型把所有句子全预测成“没有错字”了。
原因:这是极端的类别不平衡问题。SIGHAN数据集里,正确字符和错误字符的比例大约在几百比一,模型只要全预测O,Loss也不会太高。序列标注模型在这种数据上天然容易“偷懒”,它没有动力去冒险预测一个B或I。
解决:最直接的方法是给错误标签更高的Loss权重。在PyTorch的CrossEntropyLoss里传weight参数,把B和I的权重设为正常字符的5到10倍,O保持1。还有一个常见做法是把“没有错字的句子”从训练数据里降采样,只保留20%左右,让模型看到更多错误样本。这两种手法可以叠加,但权重不要一开始就给到15以上,否则模型会走向另一个极端:把所有字都标成错。先固定一个验证脚本,每次调整后看Recall和Precision的折中曲线,再决定往哪边调。
4.5 多字词只标了单字:B/I 标签在“替换型”错误上的合并规则
现象:句子“他喜欢喝可乐”被改成“他喜欢喝雪碧”,生成的标签是“喜、欢、喝”对应的位置都是O,“可”标成B,“乐”标成I,看起来没问题。但另一种情况“他喜欢喝可乐”改成“他喜欢喝汽水”,生成的是“可”B、“乐”O、“汽”B、“水”O,把一个完整的词错误切成了两个不连贯的错误片段。
原因:difflib做的是最小编辑距离对齐,它优先匹配单个字符而不是整词。两个词整体替换时,它可能认为“可”对应“汽”,“乐”对应“水”,从而输出两个replace操作。从序列标注的角度看,这会把一个词级错误拆成两个单字错误,模型接收到的信号是碎片化的。
解决:转换后做一个后处理合并。扫描labels数组,如果出现“B, I, B”这种近距离模式,且中间间隔的字符数少于2,就把它们合并成一个连续的B和I区域。更精细一点的做法,是在生成标签时把tgt也参与对齐,检查连续替换区域是否构成一个完整词。但为了实用,我通常采用一种更简单的策略:在BIO标签之外,额外输出一个“错误跨度”字段,记录每个连续错误块的起止位置,训练时用这两个信息计算span级别的Loss。这样既保留了字符级信号,又给了模型一个整合的提示。
5. 用转换后的格式做一次有效验证:错误类型分布与混淆对的检查脚本
数据转换完毕,不要急着丢进模型就开始调参。我用转换后的JSONL文件做的第一件事,是生成一份错误分析报告。它的作用不是展示成绩,而是验证刚才的标签生成得对不对。如果你发现错误类型分布跟SIGHAN官方的描述相差太大,那大概率是转换脚本里有bug,而不是数据本身变了。
import json from collections import Counter def analyze(path: str) -> None: err_types = Counter() confusions = Counter() for line in open(path, encoding='utf-8'): rec = json.loads(line) src, tgt, labels = rec['src'], rec['tgt'], rec['labels'] for i, label in enumerate(labels): if label == 'B': if i + 1 < len(labels) and labels[i + 1] == 'I': err_types['multi-char'] += 1 else: err_types['single-char'] += 1 if i < len(tgt): confusions[(src[i], tgt[i])] += 1 elif label == 'I': continue print('错误类型分布:', err_types.most_common()) print('最常见混淆对:', confusions.most_common(20)) analyze('train.jsonl')这个脚本统计了两件事:错误是单字还是多字,以及最常见的混淆字对。你会在多数SIGHAN数据里看到“的→得”“在→再”“他→她”“已→己”这类高频组合。这些混淆对是后续做数据增强的原料,把它们单独抽出来,可以针对性地生成更多训练样本,比盲目把普通句子改错要有效得多。
验证错误类型分布还带来一个进阶用法:把“转换后格式”直接当成评测基准。具体来说,先对测试集运行你的纠错模型,把模型输出转回字符序列,然后用同样的编辑距离对齐函数,计算预测标签和真实标签之间的Precision、Recall和F1。这里最关键的口径是“检测F1”和“纠正F1”分开报,许多论文只报纠正F1,掩盖了模型无法定位错误位置的问题。我自己的习惯是始终两个指标都看,如果纠正F1高而检测F1低,说明模型依赖句子级别的强先验,并没有真正学到错字模式。
坦白说,SIGHAN这套数据放在今天并不算大,全量数据量级也就是几千个句子,很多大模型用一点点样本就能刷出很高的分数。但它依然是中文纠错领域最没有争议的参照系,特别是在你要对比不同方案、写技术方案、或者向团队证明这个方向值得投入的时候,一份处理干净、格式统一的“转换后格式”能让你少走很多弯路。这些年见过不少人换过各种模型结构,折腾过各种预训练权重,最后真正拉开差距的,反而是数据转换这一步做得够不够细。希望这篇文章能帮你在SIGHAN上少踩几个我已经踩过的坑,把精力留在更值得研究的模型和鲁棒性问题上。
本文还有配套的精品资源,点击获取