尧图精选

MindSpore大模型预训练数据质量过滤:三层管道清洗实战

🕒 发布时间:2026/10/2 10:28:38 📁 来源:尧图网络
先说我这几轮踩出来的一个规律预训练模型出问题第一步真不用去调模型结构先看数据。很多人不信觉得loss下不去是并行策略不对、学习率不对结果查了半天最后发现是语料里混着大量重复文本、乱码块和机器生成的垃圾段落。我在MindSpore上做大模型预训练也翻过同样的车后来成熟一点之后把数据质量过滤单独拎出来做成了一条固定管线训练前先过一遍后面再跑就顺畅很多。这套方案的核心就是用MindSpore的Dataset接口把清洗、语言判断、长度过滤、去重、质量打分串成一条可复现的数据管道整体面向大模型预训练场景。这篇文章就把完整思路、规则参数、能在MindSpore里直接跑的代码以及踩过的坑都整理出来给正在做预训练或者准备做增量训练的工程师一个能直接参考的落地方案。这套内容适合这么几类人正在MindSpore上跑大模型预训练但loss曲线一言难尽的人要给开源中文语料做清洗、准备训练数据的人以及想把数据处理流程彻底工程化、不再靠“肉眼删文件”的团队。无论你是刚接触数据管道还是已经在别框架里写过MapReduce式的清洗逻辑下面这套方案都能直接迁移。1. 数据质量过滤方案的整体设计思路1.1 预训练数据里的“脏”是怎么来的现在公开的预训练语料尤其是中文语料来源基本都是网页抓取、爬虫采集、OCR识别结果、公众号文章导出、历史论坛数据等等。这些来源决定了里面的数据天然带着各种毛病不完全是你“清洗不够仔细”的问题。我归纳一下最常见的大类第一是重复内容。同一个新闻被多家网站转载只是改了个标题同一个产品介绍出现在几十个页面里中间夹着不同的推荐位文案。这类重复不是说完全相同的字符串而是高度近似简单哈希根本抓不到。第二是语言混杂和乱码。一个中文网页里经常夹杂英文导航、日文评论、韩文标题甚至是一大串Base64编码和解码失败后的替换字符。尤其爬虫拿到的HTML没有正确解码时全部变成“锟斤拷”或者UFFFD这类数据喂进模型就是灾难。第三是低信息密度的模板文本。比如“下一页”、“点击查看更多”、“登录注册”、“手机号验证”、“免责声明”这一类网页固定组件。单看长度不算短但语义上完全没有训练价值还会让模型学到一堆模板化输出。第四是机器生成的SEO垃圾文本。内容农场批量生产的伪原创句子结构极其奇怪关键词堆砌严重痛苦的是用传统规则很难一网打尽检测成本偏高。1.2 三层过滤架构性价比优先面对上面这些数据我从来没有打算只靠一种工具解决问题。公开语料规模动辄几十个TB如果从一开始就上语言模型打分成本上根本扛不住。所以这套方案我设计成三层过滤架构按照计算成本从低到高排列。第一层是规则清洗层。用正则、Unicode规范化和简单的统计特征去掉乱码、控制符、URL、过短样本。这一层的特点是每一条数据只需要CPU上毫秒级的处理即使跑全量数据也花不了多少时间。第二层是统计过滤层。通过计算语言比例、标点密度、重复n-gram比例、字符分布熵等特征把那些有明显统计异常的样本筛掉。这层仍然很便宜但能解决一批“看起来没问题但实际不正常”的数据。第三层才是模型打分层。用一个比较小的语言模型给候选文本算困惑度或者用一个二分类器判断文本是不是“人类手写文本”。这层只在前两层过滤完的数据上跑数据量已经大幅缩减算力成本可以接受。我把这个架构画成一条管线对应到MindSpore里就是一个Dataset对象经过多个map和filter算子串联。层与层之间是有顺序依赖的先做规则清洗是因为清洗会改变文本长度和语言比例特征如果统计过滤跑在清洗前面统计结果就是失真的。先做统计过滤是为了把明显异常的数据大量砍掉让昂贵的模型打分尽量少面对无效样本。层级目标成本典型操作规则清洗层去除乱码、控制符、URL、空白异常极低纯CPU正则Unicode NFKC标准化、替换字符过滤、空白合并统计过滤层筛除语言混杂、长度异常、标点缺失、过度重复低CPU统计字符比例计算、n-gram重复率、长度分位数模型打分层筛除机器生成、SEO堆砌、语义密度过低中高每样本一次小模型推理PPL困惑度、文本类别打分2. 具体过滤规则与参数配置2.1 字符级清洗与乱码检测我在实际项目里清洗函数大概是这么几个步骤先把全角字符转成半角用Unicode的NFKC规范化。这一步能统一很多看起来一样但编码不同的字符尤其对中文语料里的全角标点和英文字母很有用。然后去掉控制字符注意不是简单的\n和\t而是ASCII控制符区间以及Unicode里那些奇怪的格式字符。最后把连续空白合并成单个空格避免后面计算长度和语言比例时被空格干扰。乱码检测我用的核心特征是UFFFD替换字符的出现次数。一个正常获取的文本里基本不会出现这个字符一旦出现频率超过阈值基本可以断定是解码失败。除此之外如果文本里出现了大量Unicode私有区字符或者非常异常的“中文汉字大量异体字”组合也需要留意。这里不需要机器学习几个正则加上一个简单统计就能覆盖80%以上的乱码场景。语言比例是统计过滤层的一个关键特征。对于以中文为主的预训练语料我统计中文字符占比、英文字母占比和数字占比。如果中文字符占比低于某个阈值比如0.4那这条数据很可能是中文网页里的英文导航段落或者纯代码片段。这个阈值不是拍脑袋定的我一开始用0.5发现误杀率偏高很多正常的科普文中英混杂明显后来改成0.4之后效果稳定很多。2.2 长度过滤与长文本截断策略长度过滤看起来简单但阈值选错会带来很隐蔽的问题。如果把最短长度设得太高会误杀大量比较短的对话类数据这类数据对指令跟随能力的培养其实很重要。如果设得太低又会有海量“xx”、“好的”、“详见链接”这种无意义文本混进来。我目前的经验是对于中文预训练语料最短长度按字符数40到80比较合理英文语料按单词数20到30。具体可以先用采样数据跑一遍特征统计看看数据分布再决定阈值。因为每条语料集的来源差异很大一个固定阈值很难适配所有场景。超长文本的处理比最短长度更值得注意。我建议在过滤阶段保留原始长度的完整性不要一刀切截断到模型的最大序列长度。真正的截断应该发生在tokenization阶段或者由dataloader统一做动态padding。原因很简单如果你在过滤阶段截成1024个字符而模型的上下文窗口是4096个token那后续每次训练都只能看到前一部分内容浪费了窗口。更合理的做法是保留长文本在训练时按语义边界切分成多条样本这样长文档里的后段信息也不会被浪费。这里我遇到过的坑是很多人图省事直接在预处理阶段写了个text[:512]结果训出来的模型对长文档的理解能力明显偏弱。因为长文档的后半部分永远没有进入训练。2.3 完全去重与近似去重去重是数据质量过滤里收益最大的一块。公开语料里的重复比例高到吓人我处理过一个几TB的爬虫语料完全重复的文本按哈希去重后直接用掉了接近三成的数据量。完全去重很简单对清洗后的文本做标准化比如把空白全部去掉统一大小写然后算一个SHA1或者MD5。如果哈希值之前出现过这条样本直接丢弃。这里有一个小细节标准化要先做不然后处理时“Hello World”和“hello world”会被判成两条完全不同的数据。近似去重就麻烦一些。两篇文章可能只是改了开头结尾中间段落完全相同这种哈希值完全不同但模型训练时学到的信息高度冗余。处理近似去重的主流方案是MinHash或者SimHash。我一般在离线阶段用Spark或者Ray跑一次全量MinHash把相似度大于某个阈值的文本标记出来。阈值通常设为0.7到0.8之间太低会漏掉变体太高会把一些主题相近但内容不同的文本误杀。在线训练阶段我还会额外维护一个“近期已见文本”的布隆过滤器用来挡住那些刚刚被送去训练过的重复样本。为什么要挡住近期的因为预训练数据循环很多遍一个样本如果连续出现模型会过度记忆这一条。布隆过滤器比Set省内存得多几百万条文本的key只占几百MB比用Python自带的set省太多。代价是布隆过滤器有极小的误判率实际影响可以忽略。2.4 质量信号标点密度、换行比例与困惑度统计过滤并非只能看长度和语言比例。文本质量在统计特征上的反映其实很明显。我常用这么几个信号标点密度、换行比例、URL占比、重复n-gram比例、字符熵。标点密度指的是中英文标点字符数占总字符数的比例。一个正常的书面语段落标点密度一般在5%到15%之间。如果一篇文本标点密度几乎为0大概率是代码、乱码、或者纯关键词堆砌。如果标点密度过高比如超过了25%那可能是机器生成内容因为机器生成的小作文特别喜欢堆逗号和句号来凑字数。换行比例也有类似的效果。正常文章换行频率比较低但如果一条文本里几乎每个短句就有一个换行很可能来自代码文件、日志文件或者网页布局CSS残留。这个特征在识别“看似文章实则代码”的样本时非常有效。URL占比和邮箱、电话等模板特征主要用于识别网页导航内容。内容农场页面里经常夹着几十个超链接这类文本的URL占比会特别高。模型打分层的核心是困惑度PPL。我会用一个大约一两亿参数的小语言模型在GPU上批量计算每条文本的PPL。PPL低的文本通常语法通顺、语义连贯PPL高的文本则可能是乱码、SEO堆砌或者多语言混杂。用PPL做阈值过滤时我一般先跑一批样本文本看PPL分布再用分位数定阈值。比如取PPL的后5%作为异常值直接过滤掉。这里要注意的是PPL对文本越界和语言变化很敏感不同领域的语料PPL分布差异很大最好不要使用一个跨领域固定阈值。3. MindSpore上的工程实现与调试3.1 开发环境VSCode配MindSpore内核先讲一下开发环境。我实习期间带过几个工程能力相对薄弱的同学发现他们效率低的一个共性问题就是不愿意在本地把数据处理管道单独调试总想直接提交到集群上跑然后看一屏的报错日志。我推荐的方式是本地用VSCode连接训练机配置好MindSpore的Python内核先在几百条小样本上把过滤管道调通再放到全量数据上跑。具体步骤如下先创建一个conda环境里面装好MindSpore和ipykernel。这里以MindSpore 2.2 CPU版为例conda create -n ms2 python3.9 conda activate ms2 pip install mindspore2.2.0 pip install ipykernel python -m ipykernel install --user --name ms2 --display-name MindSpore 2.2然后在VSCode里打开数据管道的Python脚本按CtrlShiftP执行Python: Select Interpreter选中刚才建好的ms2环境。如果你想用Notebook调试新建一个.ipynb文件右上角选择内核MindSpore 2.2。这样做的最大好处是算子和参数都能在单元格里单独跑数据流中间结果一目了然不用每次全量重跑。如果训练机是远程服务器先用Remote-SSH插件连上去再在远程端创建虚拟环境并安装ipykernel。VSCode会自动识别远程端的Jupyter内核体验和本地几乎一样。3.2 把过滤规则写成MindSpore的map与filterMindSpore的数据管道核心就两个操作map做逐条变换filter做逐条筛选。实际过滤时我会把每一条规则都变成一个“特征计算”过程先map计算出一堆特征列再filter根据这些特征列做综合判断。这样每一条样本的过滤原因都能保留下来后面分析误杀时非常好用。下面是清洗和统计特征部分的示例代码这套逻辑我在MindSpore 2.x上验证过import re import unicodedata import hashlib CONTROL_CHARS_RE re.compile(r[\x00-\x08\x0b-\x1f\x7f\u0080-\u009f]) URL_RE re.compile(rhttps?://\S|www\.\S) WHITESPACE_RE re.compile(r\s) def normalize_text(text: str) - str: text unicodedata.normalize(NFKC, text) text CONTROL_CHARS_RE.sub(, text) text URL_RE.sub(, text) text WHITESPACE_RE.sub( , text) return text.strip() def compute_lang_features(text: str): total_chars len(text) if total_chars 0: return 0.0, 0.0, 0.0, 0 cn_chars sum(1 for c in text if \u4e00 c \u9fff or \u3400 c \u4dbf) alpha_chars sum(1 for c in text if c.isalpha()) url_chars sum(len(m) for m in URL_RE.findall(text)) cn_ratio cn_chars / total_chars alpha_ratio alpha_chars / total_chars url_ratio url_chars / total_chars return cn_ratio, alpha_ratio, url_ratio, total_chars def generate_features(text): text normalize_text(text) cn_ratio, alpha_ratio, url_ratio, total_chars compute_lang_features(text) dup_key hashlib.md5(re.sub(r\s, , text).encode(utf-8)).hexdigest() return text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key接着在filter里综合判断是否保留样本def quality_filter(text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key): if total_chars 50 or total_chars 20000: return False if cn_ratio 0.4: return False if url_ratio 0.05: return False return True def build_pipeline(ds): ds ds.map( operationsgenerate_features, input_columns[text], output_columns[text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key], column_order[text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key], num_parallel_workers8, ) ds ds.filter( predicatequality_filter, input_columns[text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key], ) return ds这里有几个经验要说一下。第一传给map的函数最好写成模块级的普通函数不要写lambda。因为MindSpore要把算子分发到多个worker进程里去执行lambda的序列化经常出问题。第二num_parallel_workers不要一口气拉满。数据管道如果本身已经很宽每个worker要做正则和哈希计算开太多worker反而会因为进程切换降低吞吐我实测8到16个比较合适。第三如果只是清洗文本没有改列数和列顺序output_columns可以省略。一旦改了返回结构就必须显式声明output_columns和column_order不然下一个算子拿到的是错位的数据。filter在不同MindSpore版本上的行为略有差异。如果你用的是较早的版本可能找不到Dataset.filter接口。替代方案是先map打一个keep标签然后在自定义的for循环里手动过滤最后再包成GeneratorDataset。这种做法不优雅但能绕过版本兼容问题。3.3 去重逻辑如何并入管道上面代码里我在特征函数中生成了dup_key但去重不能只靠map因为map是无状态的处理每一条样本时不知道之前有没有出现过同样的key。如果只是做完全去重可以在外层维护一个Set让过滤函数访问这个Set。_seen_keys set() def dedup_filter(text, cn_ratio, alpha_ratio, url_ratio, total_chars, dup_key): if dup_key in _seen_keys: return False _seen_keys.add(dup_key) return True但这里有一个重要的分布式陷阱MindSpore的map和filter如果启用了多进程并行每个worker进程里维护的_seen_keys是各自的不是全局共享的。也就是说同一个重复样本在worker A和worker B里都会被当成首次出现而放行。如果你只是希望尽量减少重复这问题影响不大但如果要求严格去重在线流程里这样写是做不到的。我的实际做法是完全去重这一类任务放到离线阶段做。先用MapReduce框架或者直接用MindSpore把全量数据过一遍生成dup_key再按dup_key做一次聚合去重最后才生成TFRecord。在线训练的管道里只做一个“近期窗口去重”比如维护一个最多100万条key的LRU缓存这能挡住短时间内重复出现的高频样本内存也不会失控。3.4 模型打分层的接入方式PPL打分我放在整个管道的最后因为前两层过滤完之后数据量已经降到原来的50%到60%左右这时候做模型推理成本可控。具体流程是把所有通过统计过滤的样本写成一个纯文本文件然后调用一个批处理脚本计算PPL最后把PPL结果作为新的一列合并回数据集。打分脚本的核心逻辑大致是python score_ppl.py \ --input data.filtered.tsv \ --output data.filtered.scored.tsv \ --model-path chinese-pert-base-zh \ --batch-size 64 \ --device-id 0score_ppl.py内部可以用MindSpore加载一个小的语言模型对每条文本计算交叉熵损失然后把损失值exp一下得到PPL。这一步我强烈建议用小模型而不是用你正在训练的大模型来做。大模型推理成本太高而且它学到的能力和文本质量之间的关联未必比小模型更直接。打分结果出来后我会再跑一个统计脚本看PPL分布然后把PPL最高的后5%到10%过滤掉。注意不要直接用固定阈值比如PPL500就丢。不同领域文本的PPL天然不同比如古文、口语对话、代码片段其PPL分布会整体偏高。最好的办法是每次根据当前数据的分布动态决定阈值。4. 常见问题与排查技巧实录4.1 怎么验证过滤效果过滤规则调完之后不能只看“过滤了多少数据”还要看过滤之后的数据质量到底提升了什么。我会从四个维度去验证。第一个是保留率。保留率不是越高越好也不是越低越好关键要和后续任务对齐。比如我的目标是做中文通用预训练保留率在70%到80%左右比较合理。如果你把保留率压低到30%说明规则过激大量正常文本被误杀可能把语料的领域多样性也一起杀掉了。第二个是词表覆盖率。我会在过滤前和过滤后分别用候选tokenizer切一遍语料统计词表里的token覆盖率以及OOV频率。如果OOV明显上升很可能是过滤规则偏向某一种文体把大量正常文本误杀了。第三个是训练loss曲线。数据质量最直接的反应是预训练初期的loss下降速度。如果一批数据很脏loss会在最初几千步内反复震荡降不下去。换一批过滤得更干净的数据之后同样超参下loss曲线会明显更顺滑。第四个是抽样人工检查。这个看起来土但在实调阶段最有效。我会从过滤掉的样本里均匀抽500条从保留样本里抽500条肉眼快速过一遍看看有没有明显的错杀和漏网。不要嫌麻烦这一步能发现很多统计特征看不出来的问题比如某条规则对特定网站模板产生了系统性偏好。4.2 高频问题速查表问题可能原因解决方案过滤后数据量骤减语言比例或长度阈值设置过高先对特征做分布统计用分位数定阈值中文样本被大量误杀cn_ratio阈值太高或统计时没考虑标点把阈值降到0.35到0.4重新看分布训练初期loss反复震荡仍有大量低质量、超链接或模板文本残留检查url_ratio特征结合PPL过滤去重Set内存爆炸全量在线去重key数量巨大改用BloomFilter或离线做完全去重filter算子报错或不存在MindSpore版本差异部分版本不支持filter先map打keep标签再收收集时手动过滤同一文本重复出现在训练集多进程环境下Set不共享导致漏去重离线彻底去重之后生成TFRecordPPL过滤后领域分布失衡用了跨领域固定阈值每次根据当前数据分布动态取分位数4.3 实战避坑心得数据清理这种事一次性把所有规则全上是最容易出事的。因为每一条规则之间会互相影响。比如你先做了URL移除文本长度发生了变化再做语言比例统计结果就会和原来不同。所以我强烈建议规则要一条一条加每加一条就重新跑一遍保留率、统计分布和小规模训练验证。还有一个我踩过很多次的坑过滤规则写在线上管道里但是训练用的tokenizer词表是从另一份原始数据训练出来的。这样词表分布和预训练语料分布不一致OOV高最后模型输出质量和生成流畅度都会受影响。要避免这个问题必须确保词表训练语料和预训练语料走同一套清洗规则。另外建议在每条样本的设计里增加一个filter_reason字段把命中的规则id记录下来比如“dup:url_ratio_over_05”。这样当某个batch训练效果不好时可以通过统计filter_reason快速定位到是哪个规则导致的而不是面对一团黑盒数据。最后提一下VSCode里的调试体验。如果你在Notebook里调试大规模数据管道不要一次性把整个数据集都加载进来。先用ds.take(2000)取一个很小的分片把分片上的过滤结果直接打印成表格肉眼确认规则效果之后再扩大到全量。这个方法虽然简单但对心智负担的降低是巨大的我在调过滤规则时基本离不开它。我个人实际用下来的体会是数据清洗里最麻烦的不是“多干掉垃圾”而是“漏掉看不出来的问题”。像语言混杂、模板文本这种异常往往不会让过滤比例出现剧烈变化但会让模型在特定领域上的表现反复抽风。所以规则宁可保守一点把明显异常的过滤干净然后靠PPL打分去兜底那些统计上不明显的低质量文本。这样既不伤数据多样性又能保证整体训练质量。这套MindSpore的数据质量过滤方案每次开新语料之前我都按这个流程走一遍投入产出比远大于调模型结构。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →