☰
预训练模型数据清洗全流程:原始语料到MindRecord的高效实践
2026/9/26 8:09:57 网站建设 项目流程

做预训练模型最容易被低估的环节,就是数据清洗。很多人把大模型项目第一优先级放在并行策略和模型结构上,结果一遍一遍重跑,终于认识到数据质量才是天花板。在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解码,失败则用errors="ignore",再统一转回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", shuffle=False) # TextFileDataset的每条记录是包含一行的数组 dataset = dataset.map(operations=lambda 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 规则清洗的优先级

我在项目里把规则清洗按优先级排成了这样:

  1. 解码清洗(第一步已经完成)。
  2. 去标签和HTML实体。
  3. 处理URL、邮箱、特殊符号。
  4. 去掉列表序号、表格残留、markdown标记。
  5. 统一标点、压缩换行和空格。
  6. 识别并丢弃“无意义文本”(例如纯数字、符号堆叠)。

优先级的意义在于:有些规则会改变文本长度,影响后续匹配。比如先去掉URL,再做语言识别,比分两步反过来好很多。否则URL里的一大堆外文字符会影响语言判断。

常见的有用正则,比如清理URL:

URL_RE = re.compile( r"(https?://|www\.)[^\s]+|(?:http|ftp)s?://[^\s]+", flags=re.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(predicate=lambda x: lang_filter(x[0]))

这里有一个重要细节:语言识别模型是按句子还是按整篇来预测,效果差异很大。整篇预测会掩盖内部语言混杂,句子级预测更精准但更慢。我通常先按句号/感叹号切分,对每个句子打分,然后计算该文档得分分布,只有目标语言句子占比超过一定阈值才保留整篇。

另一个容易踩的坑是:语种ID并不等于内容质量。有些中文网页是机器翻译出来的,语法完全不通,但语言识别置信度很高。这就要靠下一层困惑度过滤来解决。

2.3 用困惑度给文本打质量分

困惑度(Perplexity,PPL)是文本质量过滤器里很实用的一个指标。它的含义是语言模型对文本“惊讶程度”的指数化表示。高质量的人类写作,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, k=5): 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_perm=128): m = MinHash(num_perm=num_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(operations=parse_jsonl) dataset = dataset.filter(predicate=dedup_by_id)

这样做的好处是把“所有样本的逻辑”与“分布式数据读取”解耦。清洗和去重可以反复迭代,而训练时读取到的已经是干净且唯一的样本。

4. Tokenize、长度切分与MindRecord落盘

清洗完成的数据还不能直接扔给模型。模型读的是整数ID,不是字符串,所以还需要tokenize、切长度、打包成固定结构,最后写入高效读的二进制文件。

4.1 选tokenizer:BPE还是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_tokens=True) 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, dtype=np.int32) ids = np.array(ids, dtype=np.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_num=8) 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小时。数据清洗永远没有“完美”,但只要每轮的决策都有数据支撑,你就能在不断调整中逼近最舒服的那一套方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询