简介:SIGHAN中文纠错数据集及转换后格式.zip 面向中文自然语言处理研究者、拼写检查与语法纠错方向的开发者及学生,提供汉语语法错误检测与拼音标注的权威语料。原始数据源自新加坡国立大学团队,涵盖错别字、词序错误、词语搭配不当等多种人工标注错误类型,适合训练与评测中文纠错算法。压缩包共78个文件,约19.92MB,以txt文本为主,辅以sgml标注文件、readme说明、jar工具、pdf论文、xlsx表格及py脚本,兼顾原始语料与转换后格式,便于直接接入模型训练流程。资源内含SIGHAN 7/8及CLP14等版本,并附配对数据生成脚本与简繁转换模块,可帮助读者快速完成数据预处理、错误标注、训练验证测试集划分及CoNLL等格式转换。目前已有378人学习下载,适合需要系统掌握中文纠错语料构建与评估流程的中高级开发者参考。
1. SIGHAN中文纠错数据集:从zip包到可训练格式,中间隔着多少坑
做中文文本纠错(CSC)的人,绕不开 SIGHAN 这三个字。它不是一个数据集,而是从 2013 年到 2015 年连续三届中文拼写纠错评测(Chinese Spelling Check)沉淀下来的一整套标注语料,覆盖简繁体、母语者与非母语者写作场景。你手上拿到的SIGHAN中文纠错数据集及转换后格式.zip,本质就是把这些原始评测文件连同已经转好的训练格式打包在一起——省掉的是你自己从零解析原始标注、对齐字级标签、再拼成 seq2seq 或序列标注输入的那几个小时。
但“省掉”不等于“没有”。我见过太多人解压完直接pd.read_csv,结果训练时 loss 不降、验证集 F1 卡在 0.1 上下,回头才发现原始 SIGHAN 的标注是“句级 + 位置 + 替换字”三元组,而转换后的格式如果没对齐字符偏移,标签整体错位。这篇笔记就按“这个 zip 里到底是什么 → 怎么把它变成能喂给模型的张量 → 转换和对齐环节最容易翻车的地方”这条线走一遍,适合已经跑过 BERT 或 Seq2Seq 微调、但第一次碰中文纠错数据的人。
2. 拆开zip先看清:SIGHAN原始标注与转换后格式的对应关系
2.1 原始SIGHAN三件套:source、target、位置标注
SIGHAN 每一届的语料组织方式略有差异,但核心结构一致:一个*.sgml或*.txt存原始句子,一个存纠错后的句子,还有一个存错误位置和类型。以 2014 年简体中文评测为例,典型文件是SIGHAN14_Train.sgml、SIGHAN14_Train.lst和SIGHAN14_Train.correct。.lst里每行格式是:
<sid> <start_offset>, <end_offset>这里的 offset 是字符级的,不是字节级。很多人用 Python 读文件时默认按字节处理,或者用len()去算中文长度,结果偏移量对不上。正确做法是先把整句读成str,再用切片取错误片段。
.correct文件里每行是:
<sid> <correct_sentence>注意:.correct给的是整句纠正后结果,不是只给替换字。所以如果你要做字级序列标注(每个字标 0/1),需要自己用原始句和纠正句做 diff,再结合.lst的位置做校验。
2.2 转换后格式通常长什么样
常见的转换后格式有两种:一种是parallel 格式(src\ttgt),每行一对句子,直接用于 Seq2Seq 训练;另一种是char-level BIO 格式,每行是字\t标签,用于 BERT + CRF 或 Token Classification。你拿到的 zip 里如果包含train.json、dev.json、test.json,大概率是{"src": "...", "tgt": "..."}或{"text": "...", "labels": [...]}结构。
这里有一个容易忽略的点:SIGHAN 原始数据里,有些句子没有错误。转换时如果直接把所有句子都塞进训练集,负样本比例会偏高,模型倾向于全预测“无错”。我一般会在转换脚本里加一个过滤:只保留至少有一个错误位置的句子作为正样本,同时从无错句里采样等量作为负样本,比例控制在 1:1 到 1:2 之间。
2.3 用Python快速验证zip内文件结构
拿到 zip 先别急着解压到项目根目录,用zipfile列一下清单,确认文件数和目录层级:
import zipfile with zipfile.ZipFile("SIGHAN中文纠错数据集及转换后格式.zip", "r") as z: for info in z.infolist(): # 只打印文件名和大小,避免解压后才发现嵌套了三层 print(f"{info.filename:60s} {info.file_size:>10d} bytes")逻辑说明:infolist()返回每个成员的元数据,file_size是压缩前大小。如果看到__MACOSX/开头的条目,那是 macOS 打包时自动生成的资源分叉文件,解压后可以直接删,不影响数据。参数上,filename可能包含中文,Windows 下用cp936编码解压容易乱码,建议统一用utf-8或gbk显式指定。
3. 把SIGHAN转成序列标注格式:对齐、标签、切分三步走
3.1 字符偏移对齐:为什么你的标签总是错一位
原始.lst里的 offset 是从 0 开始还是从 1 开始,不同年份的 SIGHAN 不一样。2013 年是从 1 开始,2014 和 2015 年是从 0 开始。如果你不确认这一点,直接拿 offset 去切片,就会整体偏移一位。验证方法很简单:取一个已知错误句,用 offset 切出来的片段应该正好是错字。
下面是一个对齐并生成字级标签的脚本:
def align_labels(src_sentence, correct_sentence, error_spans): """ src_sentence: 原始句子 str correct_sentence: 纠正后句子 str error_spans: list of (start, end) 字符级偏移 返回: list of (char, label) label=1 表示该字错误 """ labels = [0] * len(src_sentence) for start, end in error_spans: # 注意 end 通常是 exclusive,即 [start, end) for i in range(start, min(end, len(src_sentence))): labels[i] = 1 # 用纠正句做一次校验:如果 src 和 correct 在非错误位置不一致,说明对齐有问题 for i, (c_src, c_tgt) in enumerate(zip(src_sentence, correct_sentence)): if c_src != c_tgt and labels[i] == 0: print(f"警告:位置 {i} 字符不一致但未标记错误: {c_src} -> {c_tgt}") return list(zip(src_sentence, labels))逻辑说明:先按 error_spans 打标签,再用纠正句做交叉验证。如果出现“字符不一致但标签为 0”,说明 offset 解析有误或句子长度不匹配。参数上,end是否包含在切片内取决于原始标注习惯,SIGHAN 一般用 exclusive,所以range(start, end)是对的。
3.2 从parallel格式到BIO:用diff自动生成标签
如果你只有src\ttgt平行句对,没有原始 offset,可以用difflib.SequenceMatcher自动 diff:
import difflib def parallel_to_bio(src, tgt): """将平行句对转为字级标签,1=错误,0=正确""" matcher = difflib.SequenceMatcher(None, src, tgt) labels = [0] * len(src) for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag != "equal": # 替换或删除:src 中 [i1, i2) 都是错误位置 for i in range(i1, i2): labels[i] = 1 return list(zip(src, labels))逻辑说明:get_opcodes()返回(tag, i1, i2, j1, j2),tag 为replace、delete、insert、equal。对于insert(tgt 多出字符),src 没有对应位置,通常忽略或标记为 0。参数上,SequenceMatcher的autojunk默认 True,对长句可能误判,建议设为False。
3.3 训练/验证/测试切分:别用随机切分
SIGHAN 官方已经给了 train/dev/test 划分,但很多人转换后直接train_test_split(random_state=42),导致同一来源的句子同时出现在训练和验证集,指标虚高。正确做法是保留官方划分,如果官方没给 dev,就从 train 里按文档级切分,而不是句子级。具体来说,如果原始数据有文档 ID,按文档 ID 分组后再切。
from sklearn.model_selection import GroupShuffleSplit # 假设 df 有 'doc_id' 列 gss = GroupShuffleSplit(n_splits=1, test_size=0.1, random_state=42) train_idx, dev_idx = next(gss.split(df, groups=df["doc_id"]))逻辑说明:GroupShuffleSplit保证同一 doc_id 的句子只出现在一边。参数上,test_size=0.1是常见比例,如果数据量小于 5000 句,可以调到 0.15 保证验证集有足够错误样本。
4. 转换后格式的坑:编码、空行、简繁混用与标签泄漏
4.1 编码问题:GBK与UTF-8混用导致读入乱码
SIGHAN 原始文件有的是 GBK,有的是 UTF-8,甚至同一届里不同文件编码不同。如果你用open(path, 'r')不指定 encoding,Windows 下默认 GBK,Linux 下默认 UTF-8,跨平台跑脚本就会翻车。我一般统一用open(path, 'r', encoding='utf-8', errors='replace'),遇到乱码字符先记下来,再单独用gbk重读那个文件。
def read_sighan_file(path): for enc in ["utf-8", "gbk", "gb18030"]: try: with open(path, "r", encoding=enc) as f: return f.readlines() except UnicodeDecodeError: continue raise ValueError(f"无法解码文件: {path}")逻辑说明:按 utf-8 → gbk → gb18030 顺序尝试,gb18030 是 gbk 的超集,覆盖更多生僻字。参数上,errors='replace'会丢失信息,所以这里不用,而是靠异常切换编码。
4.2 空行与空白字符:一个空格让标签整体偏移
原始文件里经常有空行、行尾空格、全角空格。如果你用line.split('\t')后不 strip,src末尾多一个空格,和tgt长度不一致,diff 就会把空格标成错误。处理方式是在读入后统一strip(),但注意:句首句尾的空格可能是标注的一部分,strip 前先确认原始数据是否用空格表示缺失字。
4.3 简繁混用:SIGHAN 2013 的隐藏陷阱
SIGHAN 2013 同时包含简体和繁体,如果你只做简体纠错,需要过滤掉繁体句子。简单方法是统计句子中繁体字符比例,超过阈值就丢弃。但更稳妥的是用opencc做一次转换,再和原始句对比,如果转换后变化很大,说明是繁体句。
from opencc import OpenCC cc = OpenCC("t2s") # 繁体转简体 def is_traditional(sentence, threshold=0.3): converted = cc.convert(sentence) diff = sum(1 for a, b in zip(sentence, converted) if a != b) return diff / max(len(sentence), 1) > threshold逻辑说明:t2s是繁转简配置,如果转换后大量字符变化,说明原句是繁体。参数上,threshold=0.3是经验值,低于这个值可能是简繁混用或个别异体字。
4.4 标签泄漏:验证集里混入了训练集的纠正句
如果你用平行句对做 Seq2Seq,tgt是纠正后句子。切分时如果按src切分,但tgt和另一边的src重复,就会泄漏。检查方法:把训练集的所有tgt和验证集的所有src做交集,如果非空,说明有泄漏。解决方式是按句对整体切分,或者用src的哈希做分组。
5. 避坑与排查:SIGHAN转换中最容易翻车的5个地方
5.1 现象:训练 loss 不降,验证 F1 始终为 0
原因:标签全为 0,或者标签和输入长度不一致导致 padding 后错位。常见于用tokenizer编码后没有对齐labels,tokenizer会把一个中文字拆成多个 subword,而你的标签还是字级。
解决:用tokenizer的word_ids()方法把字级标签映射到 subword 级,只保留第一个 subword 的标签,其余设为 -100(忽略)。
tokenized = tokenizer(src, return_tensors="pt", is_split_into_words=True) word_ids = tokenized.word_ids() labels = [] for wid in word_ids: if wid is None: labels.append(-100) else: labels.append(char_labels[wid])5.2 现象:解压后文件名乱码,读不到文件
原因:zip 打包时用了 GBK 编码文件名,解压时用 UTF-8 解码。
解决:用zipfile手动指定编码解压:
with zipfile.ZipFile(zip_path) as z: for info in z.infolist(): info.filename = info.filename.encode("cp437").decode("gbk") z.extract(info, extract_dir)5.3 现象:offset 切片出来的不是错字
原因:offset 从 1 开始,或者 offset 是字节偏移而非字符偏移。
解决:先确认年份,2013 年 offset 从 1 开始,减 1 再用。如果是字节偏移,先sentence.encode('utf-8')再切片,然后decode。
5.4 现象:模型把所有字都预测为正确
原因:负样本比例过高,或者正样本标签没有正确传播。
解决:检查正负样本比例,控制在 1:1 到 1:2。如果正样本太少,可以用WeightedRandomSampler过采样。
5.5 现象:验证集指标远高于测试集
原因:验证集和测试集同源,或者验证集太小。
解决:确保验证集和测试集来自不同文档或不同年份。如果数据量允许,验证集至少 500 句,其中错误句不少于 200 句。
6. 进阶技巧:用SIGHAN做数据增强与跨域验证
SIGHAN 数据量不大,训练集通常几千句,直接微调 BERT 容易过拟合。我一般会做两件事:一是用同音字/形近字替换做增强,二是用跨年份验证检验泛化。
同音字替换的思路是:从混淆集里随机选一个和原字同音或形近的字,替换后作为新的错误句,标签标 1。混淆集可以用pypinyin生成同音字,形近字可以用zhon或手工整理。注意:替换后要保证句子仍然通顺,否则模型学到的是噪声。我一般只替换名词和动词,不替换虚词。
from pypinyin import pinyin, Style def get_homophones(char): """返回同音字列表,需要预先构建拼音到字的映射""" py = pinyin(char, style=Style.NORMAL)[0][0] return pinyin_dict.get(py, []) def augment_with_homophone(sentence, labels, prob=0.1): """随机替换非错误位置的字为同音字,生成新样本""" new_sentence = [] new_labels = [] for ch, lab in zip(sentence, labels): if lab == 0 and random.random() < prob: homophones = get_homophones(ch) if homophones: new_sentence.append(random.choice(homophones)) new_labels.append(1) continue new_sentence.append(ch) new_labels.append(lab) return "".join(new_sentence), new_labels逻辑说明:只替换原本正确的字,替换后标为错误。参数上,prob=0.1控制增强强度,太高会破坏语义。pinyin_dict需要自己从pypinyin的词典构建,或者用现成的同音字表。
跨年份验证更简单:用 2013 年训练,2014 年验证,2015 年测试。如果指标下降超过 10 个点,说明模型对年份风格过拟合,需要加入年份无关的特征或做领域自适应。我自己的习惯是每次转换完数据,先跑一个 baseline,记录三个年份的 F1,再决定要不要做增强。这个习惯帮我省了很多后悔药——有一次发现 2015 年测试集 F1 只有 0.3,回头查才发现 2015 年数据里繁体句没过滤干净。
希望帮到你。
本文还有配套的精品资源,点击获取