
做预训练模型最容易被低估的环节就是数据清洗。很多人把大模型项目第一优先级放在并行策略和模型结构上结果一遍一遍重跑终于认识到数据质量才是天花板。在MindSpore生态下从原始文本到训练集中间隔着一整套流程清洗、过滤、去重、tokenize、存格式。这一篇就完整过一遍我在实际项目里是怎么用MindSpore把这套流水线搭起来的。无论你是准备从头训练一个多语言模型还是做领域预训练这篇文章都会对你有用。文中涉及的方法是通用的但API示例以MindSpore为主最后会给出几个我在线上环境里亲测有效的排查经验。数据清洗不是一锤子买卖它是一项长线工程做得越扎实后面模型训练越省心。1. 数据源盘点与首轮清洗先把手里的原料摸清楚开始动手清洗前一定要先知道自己的数据是从哪来的。不同的数据源脏东西的种类完全不同上来直接写一套通用清洗规则往往会顾此失彼。1.1 常见数据源长什么样爬虫抓下来的网页原始数据通常会带着大量HTML标签、脚本、导航栏和重复的页头页脚。PDF论文或书籍转换出的TXT常常有断行、重排错乱和乱码。而像Common Crawl这类公开语料虽然已经做过一轮基础处理里面仍有低质内容、重复片段、无意义网页。不管你用哪种来源我建议第一步先统一转成JSONL格式一行一个JSON对象字段至少包含text和source。这样可以断点续跑也方便后面接MindSpore的Dataset。我在实际项目里用到的数据源按属性可以分为这几类数据源主要问题首轮清洗重点网页爬虫数据HTML标签、广告、导航栏去HTML、去模板重复PDF/OCR文本断行混乱、乱码、页眉页脚段落修复、字符纠错公开JSONL语料URL、时间戳、无意义片段字段提取、句子过滤机器翻译/多语种语言混杂、翻译质量差语言识别、置信度过滤代码/文档混合库代码块、标记语言类型分类、拆分代码与正文这些数据源里有相当一部分文件并不是规范UTF-8编码甚至混着GBK、Latin-1和UTF-16。我通常会在进入清洗管道前写一个“字节级消毒”函数尝试以UTF-8解码失败则用errorsignore再统一转回UTF-8输出。这虽然粗暴但能保证后续所有操作在编码一致性上进行。1.2 用MindSpore的Dataset API把原始JSONL读进来MindSpore的mindspore.dataset.TextFileDataset可以直接读取文本文件每一行会变成一条数据。如果你的原始文件已经是JSONL那最省事的做法是先拿到text字段再通过map挂上清洗函数。import json import mindspore.dataset as ds def parse_json(line): obj json.loads(line) return obj[text] dataset ds.TextFileDataset(raw_data.jsonl, shuffleFalse) # TextFileDataset的每条记录是包含一行的数组 dataset dataset.map(operationslambda x: parse_json(x[0]))注意TextFileDataset默认把每一行内容当成一个元素元素类型是[string]所以刚才的lambda里取了x[0]。如果你有多个文件拆分也可以直接往TextFileDataset传文件列表它会自动做多文件读取。如果原始数据不是规整的行或者你需要从某些自定义二进制格式里恢复文本那就改用GeneratorDataset。它的写法自由度高但要自己控制并发和预取深度性能上不如TextFileDataset省心。首轮清洗最忌讳的就是把大量数据一次性装进内存。MindSpore的Dataset API天然是惰性求值的你用map和filter时的操作是边读边处理不会先全量载入。这一点非常关键因为原始语料动辄几十TB如果前几步就把内存搞爆后面根本没法跑。1.3 编码与不可见字符第一道坎很多新手在处理文本时只做了“去HTML标签”这一个动作结果训练时loss莫名跳来跳去。后来一检查发现数据里到处都是\u200b、\ufeff这种零宽字符和BOM模型在学一些肉眼看不见的东西。我在清洗管道里会单独加一个清理不可见字符的函数import re INVISIBLE_CHARS re.compile( r[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b\u200c\u200d\u2060\ufeff\ufff0-\ufff8] ) def strip_invisible(text): text text.replace(\u00a0, ) text INVISIBLE_CHARS.sub(, text) # 把各种制表符、连续空白统一压缩 text re.sub(r[ \t]{2,}, , text) return text.strip()这一步看起来不起眼但能显著降低模型输出乱码的比例。尤其是做中文预训练时全角/半角标点混用也是常见问题建议在首轮清洗时统一转成全角或半角这样后续tokenizer压力会小很多。另外对于网页数据源里的“页头页脚”类模板文本单纯靠正则很难去干净。我会用html2text先把HTML转成纯文本再对连续重复出现的短句做检测比如同一网站每个页面都会有的“联系我们”“版权所有”这类模板片段可以在多次采样后直接喂给一个精确匹配过滤器。2. 规则清洗与语言混杂处理把明显脏的部分先干掉首轮清洗之后数据已经具备“可读性”但依然会有语言混杂、公式乱码、无意义内容等问题。这一阶段是清洗规则密度最高的地方也是耗时最长的部分。2.1 规则清洗的优先级我在项目里把规则清洗按优先级排成了这样解码清洗第一步已经完成。去标签和HTML实体。处理URL、邮箱、特殊符号。去掉列表序号、表格残留、markdown标记。统一标点、压缩换行和空格。识别并丢弃“无意义文本”例如纯数字、符号堆叠。优先级的意义在于有些规则会改变文本长度影响后续匹配。比如先去掉URL再做语言识别比分两步反过来好很多。否则URL里的一大堆外文字符会影响语言判断。常见的有用正则比如清理URLURL_RE re.compile( r(https?://|www\.)[^\s]|(?:http|ftp)s?://[^\s], flagsre.IGNORECASE, ) def strip_urls(text): return URL_RE.sub( , text)不建议把所有URL直接删除不留空位因为可能把两个句子拼到一起。替换成空格更安全。对于HTML标签我是先用BeautifulSoup的get_text做粗略提取再用正则处理残留标签。get_text对嵌套标签的处理比正则稳健得多正则只适合处理那种“明显无嵌套”的简单情形。2.2 语言识别与语种过滤做中英双语预训练时语言混杂会让模型学到错误的对齐关系。比如英文网页里嵌了几十个中文单词如果直接保留模型会把英文和中文错误地混淆在一起。所以需要给每段或每个句子识别语言保留置信度高的目标语言。我常用的是fastText官方发布的语言识别模型lid.176.bin体积小、速度快、精度在工程上足够用。在MindSpore管道里你可以把语言识别封装成普通Python函数挂到map里import fasttext pretrained_lang_model fasttext.load_model(lid.176.bin) TARGET_LANGS {zh, en} def lang_filter(text): if len(text) 20: return False labels, scores pretrained_lang_model.predict(text.replace(\n, )) lang labels[0].replace(__label__, ) conf scores[0] return (lang in TARGET_LANGS) and (conf 0.5) dataset dataset.filter(predicatelambda x: lang_filter(x[0]))这里有一个重要细节语言识别模型是按句子还是按整篇来预测效果差异很大。整篇预测会掩盖内部语言混杂句子级预测更精准但更慢。我通常先按句号/感叹号切分对每个句子打分然后计算该文档得分分布只有目标语言句子占比超过一定阈值才保留整篇。另一个容易踩的坑是语种ID并不等于内容质量。有些中文网页是机器翻译出来的语法完全不通但语言识别置信度很高。这就要靠下一层困惑度过滤来解决。2.3 用困惑度给文本打质量分困惑度PerplexityPPL是文本质量过滤器里很实用的一个指标。它的含义是语言模型对文本“惊讶程度”的指数化表示。高质量的人类写作PPL通常较低而乱写、机器翻译、重复堆砌的内容PPL会偏高。实现思路很简单准备一个小的语言模型可以用中文版GPT-2或BERT甚至你自己训一个tiny模型把文本切成若干段逐段计算平均对数似然再算出PPL。PPL超过某个阈值的段落判定为低质内容。用PyTorch或MindSpore都能写这个打分服务但在清洗流程中我不建议直接在map里加载一个完整transformer。因为map会在多个worker里并行执行每个worker加载一份模型副本非常吃内存。比较实际的方案是先用一个独立脚本批量算好每一条文本的PPL写回JSONL的一个ppl字段然后在清洗管道里只做字段过滤。这样做的好处是门槛试错成本低你不用每次调整阈值都重新跑一遍打分模型。你可以把所有候选阈值都存下来后面哪里数据不够了随时放宽一点再跑一轮而不是从头再来。3. 去重精确哈希、SimHash与MinHash的组合拳语料去重是大模型预训练里最不能省的一步。网上重复文本的规模超乎想象尤其是新闻类、百科类数据同一个内容可以出现成百上千次。如果不去重模型会在重复模式上过拟合表现为生成时反复输出同一段话甚至出现“背诵”现象。3.1 重复数据造成的实际影响重复数据有几个明显的危害训练耗时变长因为模型反复看同样的内容泛化能力下降因为验证集里可能也有重复样本导致指标虚高更严重的是模型可能把“出现频率高”的错误文本当成知识。我测试过一个场景用相同的数据量只把重复比例从8%降到1%小规模预训练模型的评估困惑度下降了约3%。这个收益在训练大模型时会进一步放大所以去重绝不是锦上添花。3.2 精确去重最简单但最有效的第一道关精确去重很容易理解对整篇文本取哈希比如SHA1然后检查是否见过。如果是首次出现保留否则丢弃。在海量数据下直接用Python的set存哈希很容易吃满内存。几十亿条文本的哈希集合几十GB内存都未必够。所以我一般用布隆过滤器先用一个可接受的假阳性率比如0.01%计算位数组大小再把每条哈希写入过滤器中。存在性判断时即使少量误删影响也比内存溢出好得多。import hashlib seen bloom_filter_placeholder # 以你自己的BloomFilter实现为准 def dedup_exact(text): digest hashlib.sha1(text.encode(utf-8)).hexdigest() if digest in seen: return False seen.add(digest) return True注意精确去重对“几乎相同但加了一两个字符”的文本无能为力。实战中很多重复是网页模板导致的比如同一篇新闻在不同网站里前后各加了几行无关内容哈希值完全不同所以必须上模糊去重。3.3 模糊去重MinHash与SimHash的选择模糊去重常用两种方法SimHash和MinHash。SimHash对长文本稳定先把文本切成词带权重每改一个词生成的指纹差异有限再用海明距离判断相似度。缺点是权重工程比较敏感对短文本效果一般。MinHash偏向于集合相似度。做法是把文本切成连续的n-gram通常4~8个字符/词把这些gram集合哈希成多个最小值作为签名然后估计两个文本的Jaccard相似度。MinHash实现直观库也成熟datasketch就能直接用。我在实际项目中用的是MinHash LSH的组合。语法是切分shingles计算MinHash签名再把签名分桶只有桶内候选才去计算真实相似度避免全部两两比对。from datasketch import MinHash, MinHashLSH def shingles(text, k5): text text.strip() gram_list [] for i in range(len(text) - k 1): gram_list.append(text[i:i k]) return set(gram_list) def minhash_from_text(text, num_perm128): m MinHash(num_permnum_perm) for gram in shingles(text): m.update(gram.encode(utf-8)) return m如果你处理的是中英文混合语料建议按不同的语言使用不同的切分方式。中文切字或切词都可以但连续字的5-gram比中文分词更通用不容易受分词错误影响英文则按空格切分后再拼n-gram。模糊去重的配置有几个关键参数num_perm越大相似度估计越准但计算和内存开销越大shingle长度越小对局部重复越敏感但计算量也越大。可以先抽样几百条人工看阈值再全量跑。3.4 在MindSpore数据管线里挂接去重结果去重步骤我不会直接放在MindSpore的map里因为MinHash计算需要全局状态LSH索引、布隆过滤器而且计算量偏重。更合理的做法是先用独立脚本把每条文本的哈希或签名算好把需要剔除的ID集合导出成一个文件再回到MindSpore管道里过滤。比如你可以先得到“重复文本ID”白名单然后在数据管道里按ID过滤DUP_IDS set() with open(dup_ids.txt) as f: for line in f: DUP_IDS.add(line.strip()) def dedup_by_id(record): # record[0] 是 JSON 解析后的 dict return record[0][id] not in DUP_IDS dataset dataset.map(operationsparse_jsonl) dataset dataset.filter(predicatededup_by_id)这样做的好处是把“所有样本的逻辑”与“分布式数据读取”解耦。清洗和去重可以反复迭代而训练时读取到的已经是干净且唯一的样本。4. Tokenize、长度切分与MindRecord落盘清洗完成的数据还不能直接扔给模型。模型读的是整数ID不是字符串所以还需要tokenize、切长度、打包成固定结构最后写入高效读的二进制文件。4.1 选tokenizerBPE还是SentencePiece大模型预训练目前主流是BPE它对大量未知词的处理比单字切分要省词表。中文的话可以把汉字切成单字或双字子词比较灵活。如果你要用现成的多语言词表HuggingFace的tokenizer可以直接导入。在MindSpore项目里它仍然可以作为一个普通Python函数在数据管道里运行只要处理好张量转换就行from tokenizers import Tokenizer tokenizer Tokenizer.from_file(tokenizer.json) def encode_text(text): parsed tokenizer.encode(text, add_special_tokensTrue) return parsed.ids不过tokenizer的加载也不建议每个worker都重复执行通常我会在进程启动的时候只加载一次然后作为全局对象供map使用。如果预料到数据规模极大、tokenizer本身也需要定制那么先从清洗好的语料里采几百万条训练一个新tokenizer。训练的时候要注意保证语料质量足够高不要从原始爬虫数据里直接训否则词表里会充满垃圾字符。词表大小一般设置在32k~128k需平衡模型参数量与数据稀疏度。4.2 把tokenization塞进数据管线在MindSpore里写tokenize函数时要非常注意输入输出的数据类型。map函数返回的值最终会被自动转成MindSpore张量。如果你返回的是一个Python列表而列表长度不定会遇到问题。所以我通常会把不定长ID序列先存入numpy数组并给一个固定max_length上限截断或者用Ragged张量思路先用placeholder补齐到最长长度另外保存一个input_mask标识真实长度。这里有个简单的示意import numpy as np MAX_LEN 2048 def tokenize_and_pad(text): ids tokenizer.encode(text).ids[:MAX_LEN] ids ids [tokenizer.token_to_id([PAD])] * (MAX_LEN - len(ids)) mask [1] * len(ids) [0] * (MAX_LEN - len(ids)) mask np.array(mask, dtypenp.int32) ids np.array(ids, dtypenp.int32) return ids, mask虽然这种做法会浪费一部分存储空间但换来的是训练时张量形状完全统一省去了动态shape带来的复杂处理。对于大模型预训练固定长度训练效率更高。4.3 切长文本与拼接长文本处理通常有两种策略一种是直接截断缺点是丢失上下文另一种是按段落切多段每段重新加分隔符。我比较推荐后者。如果一段长文本最终被切成了4段它们之间没有强依赖关系模型可以把它们当成4个独立样本这比截断后半部分要好。短文本直接构建完整样本会导致有效token太少浪费计算。通常会把若干短文本拼到一个样本里用[SEP]或\n分隔。拼接时要考虑不要让上一个句子的后半段和下一个句子的前半段形成无意义的跨文本依赖因此需要引入额外的分段标记或随机打乱。在预训练任务中比如masked language model这问题还不大但如果做纯因果语言模型拼接标记会影响注意力关系需要小心处理。4.4 用MindRecord格式保存清洗和tokenize之后如果用纯文本文件喂模型会让训练阶段的IO压力非常大每次都要重新解析JSON、重新tokenize。最好把tokenize后的结果写成MindRecord二进制格式。MindRecord是MindSpore的原生二进制数据格式能在训练时被MindDataset高效读取本质上类似TFRecord。写MindRecord需要用MindRecordWriter大致流程如下from mindspore.mindrecord import FileWriter writer FileWriter(pretrain.mindrecord, shard_num8) schema { input_ids: {type: int32, shape: [-1]}, input_mask: {type: int32, shape: [-1]}, } writer.add_schema(schema, preprocessed text dataset) for ids, mask in tokenized_samples: writer.write_raw_data([{input_ids: ids, input_mask: mask}]) writer.commit()这里shard_num控制分片数量建议和训练环境的卡数相关。如果你在64卡上训练可以分成64个文件让每张卡直接读自己那一份。MindRecord既避免了JSON解析的CPU消耗又支持随机访问对大数据量训练非常友好。写MindRecord时有一个容易被忽略的问题写入前一定要确定shape并保证shape一致性。如果你在同一schema里写不同长度的序列读取时会报错。所以我个人习惯是先把所有序列pad到统一长度再写入。这样虽然有一些冗余但训练时不用处理动态shape稳定性和吞吐量反而更好。5. 清洗流程收尾时的验证与线上避坑很多团队的前几版清洗pipeline都跑通了但到了训练阶段才发现数据量缩水太严重、或者某些域被过度过滤。所以清洗流程不仅要跑得快还要留可观测的指标和人工抽检手段。5.1 抽检和分布监控我会在每一道关键过滤后记录保留率。比如数据量原始1000万条。编码清洗后保留99.2%。去HTML后保留97.5%。语言过滤后保留74.1%。困惑度过滤后保留63.0%。精确去重后保留41.8%。模糊去重后保留39.6%。如果某一步的保留率突变就要警惕是不是阈值设得过严或过松。抽检也很重要每10万条随机抽100条文本人工扫一眼看看切断、拼接、标记是否有问题。这个工作枯燥但非常必要。很多清洗问题不是靠检查数据量能发现的而是靠肉眼扫到“某些样本前后被拼错”或“把所有网址删成空格后出现了大量连续空白”。为了自动化可以额外写几个脚本统计平均句子数、只含数字的样本占比、重复n-gram比例、标签闭合率等。这些指标能帮助你判断清洗是否稳定。5.2 处理海量数据时的内存与IO问题当数据是TB级别时数据清洗里的IO策略决定你能不能在几天内跑完。首先要避免频繁的小文件读写。原始爬虫可能有一百万个小文件逐个打开解析非常低效。我在项目中会先把这些小文件按固定大小合并成几个大文件再统一处理。MindSpore的TextFileDataset虽然能接受文件列表但单文件太小、数量太多时底层的文件系统压力也很大。其次要合理控制并行度。map的num_parallel_workers不是越高越好我见过把worker设到64结果CPU被打满内存也爆掉的情况。通常我设为16~32并且配合ds.config.set_prefetch_size控制预取样本数。第三个常见问题是数值类型。写入MindRecord时input_ids如果用了int64存储量和带宽都比int32大一倍。有意识的用int32能显著减少IO占用。有些场景甚至可以用int16前提是词表大小小于65536这需要根据具体情况权衡。5.3 一个实际的误删教训过滤阈值怎么调有一次我给中文科技类语料做困惑度过滤因为嫌低质量句子太多把PPL阈值设到40。结果跑完之后科技文献、专业名词解释等难句子被大量删掉。这些内容虽然PPL高但恰恰是模型最需要学习的“困难样本”。后来我发现只看全局PPL分布来定阈值是个陷阱。正确的做法是分domain统计PPL新闻、百科、技术文档、法律条文各有各的分布不能一把尺子量到底。我对网页来源和图书来源分别采样计算各自的PPL分位数然后用“只过滤分位数之后5%”这样的相对标准代替“PPL必须小于X”的绝对标准。这样既能保留小众高价值数据又能滤掉真正的垃圾文本。另外阈值调整后一定要做回归对比。最简单的方法是用清洗前后的两份数据分别做一个小规模训练看训练loss是否下降得更稳定。这一步虽然花钱但比全量跑完才发现问题要便宜几个数量级。我自己的体会是清洗流程一定要做成可重复、可观测的脚本每道工序都输出一份统计报告不然下次扩数据就抓瞎。另外如果时间允许清洗后的数据最好抽出一小批做mini训练观察loss曲线是否符合预期再大规模投入。这个步骤虽然多花几天但能省下几万个GPU小时。数据清洗永远没有“完美”但只要每轮的决策都有数据支撑你就能在不断调整中逼近最舒服的那一套方案。