1. 大模型预训练里,数据质量过滤到底在解决什么问题
做过大模型预训练的人都有一个共识:模型效果的上限,很大程度上在数据准备阶段就已经被决定了。你后面用再多的卡、调再多的超参,如果喂进去的语料本身掺杂了大量重复、乱码、低信息密度的内容,模型学到的就是一堆噪声。MindSpore 作为一套全场景深度学习框架,在 Ascend 昇腾硬件上有比较完整的分布式训练能力,但框架本身并不会帮你解决“数据干不干净”这件事——这部分工作得我们自己动手。
我这次要聊的,就是基于 MindSpore 做大模型预训练的数据质量过滤方案。简单说,它是一套在把原始语料喂给模型之前,对文本做多级清洗、打分、去重、筛选的处理流水线。它能解决的问题很具体:把网页抓取、文档抽取、代码仓库等来源的脏数据,处理成相对干净、信息密度高、重复率低的训练语料。适合谁看?如果你正在用 MindSpore 跑预训练,或者准备搭一套自己的语料处理管线,又或者你只是想知道工业界到底怎么过滤数据,这篇都能直接抄作业。
我先把结论摆前面:数据过滤不是单一算法,而是一条由多个过滤器串联的流水线,每个过滤器负责干掉一类脏数据,最后按质量分排序取头部。下面我按整体设计、核心细节、实操落地、问题排查四个部分展开,中间会穿插我在实际跑任务时踩过的坑。
2. 整体方案设计与过滤思路拆解
2.1 为什么是“多级流水线”而不是“一个模型搞定”
刚接触这块的时候,我也想过偷懒:能不能训一个质量分类器,一把梭把所有脏数据都判掉?实测下来不行。原因有三个。
第一,不同脏数据的特征维度完全不同。乱码是字符层面的问题,重复是文档层面的问题,低质内容是语义层面的问题。你用一个模型去同时判断这三类,等于让一个人同时干三种完全不同的活,准确率必然打折。
第二,成本。质量分类器通常是基于预训练模型微调的,推理成本远高于规则过滤。如果让所有数据都过一遍大模型,几 TB 的语料跑下来,光推理开销就够呛。合理的做法是先用便宜的规则干掉大部分明显脏数据,再用模型处理剩下的疑难杂症。
第三,可解释性和可调性。规则过滤你能明确知道它为什么删了这条数据,阈值也好调。模型打分是个黑盒,出了问题不好定位。
所以我的方案是典型的漏斗结构:粗过滤(规则)→ 精细过滤(统计+模型)→ 去重 → 质量打分排序。越往后成本越高,但数据量越小。
2.2 过滤器的分层设计
我把整条流水线分成四层,每层的目标和手段都不一样:
| 层级 | 目标 | 主要手段 | 处理成本 |
|---|---|---|---|
| 第一层:格式清洗 | 去掉非文本噪声 | 编码修复、HTML 剥离、控制字符清理 | 极低 |
| 第二层:规则过滤 | 干掉明显低质样本 | 长度、符号比、重复行、敏感词 | 低 |
| 第三层:语义去重 | 降低重复率 | MinHash + LSH、SimHash | 中 |
| 第四层:质量打分 | 排序取头部 | 困惑度、质量分类器、启发式打分 | 高 |
这个分层不是拍脑袋定的,而是遵循一个原则:能用便宜手段解决的,绝不留给贵的手段。比如一段全是 HTML 标签的文本,第一层就干掉了,根本轮不到质量分类器去判断它好不好。
2.3 和 MindSpore 训练流程怎么衔接
数据过滤和训练是解耦的,但衔接方式有讲究。我的做法是离线过滤 + 在线读取:过滤流水线离线跑完,产出一份干净的语料和对应的质量分,训练时用 MindSpore 的MindDataset或自定义GeneratorDataset读取。
为什么不边训边过滤?因为过滤本身有随机性和排序逻辑,如果每次 epoch 都重新过滤,数据分布会漂移,训练不稳定。离线过滤一次,产出的数据集是固定的,训练可复现。
这里有个细节:质量分要一起存下来。训练时可以按分数做课程学习——先训高质量数据,再逐步混入中质量数据。这个后面实操部分会讲。
3. 核心过滤器的原理与实现细节
3.1 格式清洗:别小看编码和 HTML
第一层看着简单,但坑最多。网页抓下来的文本,编码五花八门,UTF-8、GBK、Latin-1 混在一起。如果编码判断错了,整段文本会变成乱码,而这种乱码如果没被识别出来,会直接污染训练集。
我的处理顺序是:先做编码探测(用chardet或charset-normalizer),再统一转 UTF-8,然后剥离 HTML 标签和 JS/CSS 残留,最后清理控制字符。
import re from charset_normalizer import from_bytes def clean_format(raw_bytes): # 编码探测并统一转 UTF-8 best = from_bytes(raw_bytes).best() if best is None: return None text = str(best) # 剥离 HTML 标签 text = re.sub(r'<[^>]+>', ' ', text) # 去掉控制字符(保留换行和制表) text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) # 合并多余空白 text = re.sub(r'[ \t]+', ' ', text) return text.strip()注意:编码探测不是 100% 准的,尤其是短文本。我的经验是,如果探测置信度低于某个阈值,宁可丢弃这条数据,也不要赌。一条乱码混进去,可能影响一批样本的训练。
3.2 规则过滤:长度、符号比、重复行
第二层是规则过滤,核心是几个启发式指标。这些指标看着土,但实测非常有效。
长度过滤:太短的文本信息量不足,太长的可能是拼接的垃圾。我一般设最小 50 字符、最大 100000 字符。这个阈值不是固定的,中文语料和代码语料的合理区间完全不同,得按数据源调。
符号比过滤:统计非字母数字字符的占比。正常文本这个比例通常在 0.1 到 0.3 之间,如果超过 0.5,大概率是乱码或者特殊符号堆砌。
重复行比例:一段文本里如果超过 30% 的行是重复的,基本可以判定是低质内容,比如导航栏、广告模板。
def rule_filter(text, min_len=50, max_len=100000, max_symbol_ratio=0.5, max_dup_line_ratio=0.3): if len(text) < min_len or len(text) > max_len: return False # 符号比 symbol_count = sum(1 for c in text if not c.isalnum() and not c.isspace()) if symbol_count / len(text) > max_symbol_ratio: return False # 重复行比例 lines = [l.strip() for l in text.split('\n') if l.strip()] if lines: dup_ratio = 1 - len(set(lines)) / len(lines) if dup_ratio > max_dup_line_ratio: return False return True实操心得:规则过滤的阈值一定要在小样本上先跑一遍,人工看被删掉的数据长什么样。我有一次把 max_symbol_ratio 设成 0.3,结果大量正常的技术文档被误删——因为代码块里符号本来就多。后来调到 0.5 才正常。
3.3 语义去重:MinHash + LSH 是性价比之王
去重是数据过滤里最影响效果的一环。大模型预训练最怕的就是重复数据,重复会让模型过拟合到特定内容,还会浪费算力。
去重分两个层次:精确去重和近似去重。精确去重简单,算个哈希就行。但真正麻烦的是近似去重——同一篇文章被转载、改了几个字、换了标题,哈希完全不一样,但内容高度重复。
我的方案是MinHash + LSH。原理不复杂:把文档切成 n-gram,用多个哈希函数算出最小哈希值组成签名,签名相似的文档就是近似重复。LSH 用来加速查找,避免两两比较。
from datasketch import MinHash, MinHashLSH def build_minhash(text, num_perm=128): m = MinHash(num_perm=num_perm) # 用字符级 5-gram,中文场景比词级更稳 for i in range(len(text) - 5 + 1): m.update(text[i:i+5].encode('utf-8')) return m def dedup(docs, threshold=0.8): lsh = MinHashLSH(threshold=threshold, num_perm=128) keep = [] for idx, doc in enumerate(docs): m = build_minhash(doc) if not lsh.query(m): lsh.insert(str(idx), m) keep.append(idx) return keep注意:
num_perm和threshold是两个关键参数。num_perm越大越准但越慢,128 是个平衡点。threshold设 0.8 意味着相似度超过 80% 就判为重复。中文语料我一般设 0.7 到 0.8,因为中文改写后相似度下降没英文那么明显。
3.4 质量打分:困惑度 + 分类器双保险
最后一层是质量打分,目的是给每条数据一个分数,训练时按分数排序取头部。
我用了两个信号:困惑度和质量分类器。
困惑度用一个小的语言模型算,困惑度越低说明文本越“通顺”、越符合语言规律。但困惑度有个问题:它偏好常见表达,对专业但冷门的内容不友好。所以不能只看困惑度。
质量分类器是拿一个预训练模型(比如小的 BERT 类模型)在人工标注的高/低质数据上微调出来的。标注数据不用太多,几千条就能出一个可用的分类器。
最终分数是两者的加权:
def quality_score(text, ppl_model, clf_model, w1=0.4, w2=0.6): ppl = compute_perplexity(text, ppl_model) # 困惑度归一化到 0-1,越低越好 ppl_score = 1.0 / (1.0 + ppl) clf_score = clf_model.predict_proba(text) # 0-1 return w1 * ppl_score + w2 * clf_score实操心得:权重别拍脑袋定。我的做法是拿一批人工标注的样本,网格搜索 w1、w2,看哪个组合和人工排序的相关性最高。实测下来分类器权重要高一些,因为困惑度容易被短文本“骗”——短文本困惑度天然低。
4. 基于 MindSpore 的实操落地流程
4.1 环境准备与依赖
先说环境。MindSpore 的安装按官方文档来,CPU 版和 Ascend 版命令不同。数据过滤这部分其实主要跑在 CPU 上,因为规则和 MinHash 都是 CPU 密集型,质量分类器可以用 MindSpore 推理,也可以先用 PyTorch 训好再转过来。
# 安装 MindSpore(CPU 版示例,具体版本按官方文档选) pip install mindspore # 数据过滤相关依赖 pip install datasketch charset-normalizer tqdm如果你在 VS Code 里用 MindSpore 内核,记得把 Python 解释器切到装了 MindSpore 的那个环境,不然 import 会报错。这个坑我踩过,折腾了半天发现是解释器选错了。
4.2 流水线的组织方式
整条流水线我用一个Pipeline类串起来,每个过滤器是一个函数,输入输出都是文档列表。这样方便单独测试某一层,也方便调整顺序。
class DataPipeline: def __init__(self, config): self.config = config def run(self, raw_docs): # 第一层:格式清洗 docs = [clean_format(d) for d in raw_docs] docs = [d for d in docs if d] # 第二层:规则过滤 docs = [d for d in docs if rule_filter(d, **self.config['rule'])] # 第三层:去重 keep_idx = dedup(docs, **self.config['dedup']) docs = [docs[i] for i in keep_idx] # 第四层:质量打分 scored = [(d, quality_score(d, **self.config['score'])) for d in docs] scored.sort(key=lambda x: x[1], reverse=True) return scored4.3 参数选择与计算过程
参数不是随便设的,我拿长度过滤举个例子说明怎么算。
假设你的语料是中文网页文本,先抽样 10000 条,统计长度分布。你会发现大部分文本长度集中在 200 到 5000 字符之间。这时候最小长度设 50 是安全的,能干掉那些只有标题或几个字的碎片。最大长度呢?如果设 100000,可能只有 0.1% 的数据超过,那这个上限基本不起作用;如果设 10000,可能误删长文档。我的做法是看分位数:取 99.9 分位作为上限,这样只干掉极端值。
去重的 threshold 也是类似思路。先在小样本上跑不同 threshold,看去重率和保留数据的质量。threshold 太低会误删,太高去重不彻底。中文语料我实测 0.75 是个不错的起点。
4.4 和 MindSpore 训练对接
过滤完的数据,我存成 JSONL 或者 MindRecord 格式。MindRecord 是 MindSpore 的原生格式,读取效率高,适合大规模数据。
import mindspore.dataset as ds from mindspore.mindrecord import FileWriter # 写 MindRecord schema = {"text": {"type": "string"}, "score": {"type": "float32"}} writer = FileWriter("clean_data.mindrecord", shard_num=4) writer.add_schema(schema) for text, score in scored_docs: writer.write_raw_data([{"text": text, "score": score}]) writer.commit() # 训练时读取 dataset = ds.MindDataset("clean_data.mindrecord", columns_list=["text", "score"], shuffle=True)提示:如果要做课程学习,可以在读取时按 score 过滤,先训高分数据。MindSpore 的
filter算子支持这个操作,但要注意 filter 是在读取后执行的,会先加载再过滤,大数据集下效率不高。更好的做法是离线按分数分桶,存成多个 MindRecord 文件。
5. 常见问题与排查技巧实录
5.1 过滤后数据量骤减怎么办
这是最常见的问题。跑完流水线发现数据只剩原来的 10%,第一反应肯定是哪里出错了。
排查顺序:先看每一层的留存率。我在 Pipeline 里加了日志,每层处理完打印剩余数量。如果某一层留存率异常低,问题就在那层。
常见原因和对策:
| 现象 | 可能原因 | 对策 |
|---|---|---|
| 格式清洗后剩 50% | 编码探测失败率高 | 降低置信度阈值,或换探测库 |
| 规则过滤后剩 30% | 阈值过严 | 抽样看被删数据,放宽阈值 |
| 去重后剩 20% | threshold 过低 | 调高 threshold,或检查 n-gram 粒度 |
| 打分后剩 10% | 分数阈值过高 | 看分数分布,取合适分位 |
5.2 去重把不同内容误判为重复
MinHash 的 n-gram 粒度是关键。如果你用词级 n-gram,中文分词一旦出错,相似度计算就崩了。我建议中文场景直接用字符级 n-gram,粒度取 5 到 8 个字符。这样即使分词不准,也不影响去重效果。
另一个原因是 threshold 设太低。0.6 以下就容易误判了,正常内容改几个字相似度不会掉那么多。
5.3 质量分类器打分偏差
分类器打分偏差通常来自训练数据。如果你的标注数据里高质样本都是新闻,那模型会偏向新闻风格,把技术文档、小说都打成低分。
解决办法是标注数据要覆盖多种类型。我一般按数据源分层采样,每个来源都标一些,保证分类器见多识广。另外,分类器的输出概率要做校准,不然分数不可比。
5.4 流水线跑得太慢
几 TB 数据跑下来,性能是绕不开的。我的优化经验:
- 并行化:格式清洗和规则过滤是纯 CPU 操作,用多进程跑,
multiprocessing.Pool就行。 - 去重分片:MinHash 可以分片并行,最后合并 LSH 索引。注意分片要按文档哈希分,保证相同文档进同一片。
- 分类器批推理:质量分类器一定要批处理,batch size 拉满,别一条条推理。
- IO 优化:读写用流式,别一次性 load 进内存。
实操心得:我一开始用单进程跑,1TB 数据跑了三天。改成 32 进程并行后,压缩到 6 小时。并行化是性价比最高的优化,没有之一。
6. 几个容易被忽略的细节
6.1 敏感内容过滤要单独做
数据质量过滤和质量打分是两回事。质量高不代表内容合适。敏感内容过滤应该作为独立的一层,用专门的词表和分类器处理。这部分我不展开,但提醒一句:别把它和质量过滤混在一起,否则阈值调整会互相干扰。
6.2 保留过滤日志
每条被删的数据,最好记录它被哪一层、因为什么原因删掉。这个日志在排查问题和向团队解释时非常有用。我一般存成{doc_id, layer, reason}的格式,方便后续分析。
6.3 过滤规则要版本化
数据过滤方案不是一次定死的。随着对数据的理解加深,规则会不断调整。每次调整都要记录版本,并且训练时记录用了哪个版本的数据。不然模型效果变化了,你都不知道是数据变了还是超参变了。
6.4 小规模验证再全量跑
全量跑一次成本很高,一定要先在小样本(比如 10 万条)上验证整条流水线,人工检查过滤结果,确认没问题再全量。我吃过亏,全量跑完才发现去重阈值设错了,白跑一天。
7. 后续可以扩展的方向
这套方案跑通之后,还有几个可以深挖的点。一是动态阈值,根据数据源自动调整过滤参数,而不是全局一套。二是质量分和训练损失的关联分析,看看高分数据是不是真的带来更低的 loss,用数据反过来指导过滤策略。三是多模态数据过滤,图片、视频的质量评估逻辑和文本完全不同,需要单独设计。
我个人在实际操作中的体会是,数据过滤这件事,没有一劳永逸的方案,只有不断迭代的流程。每次训练完看效果,回头分析数据,调整过滤器,再训。这个循环跑几轮,数据质量才会有质的提升。别指望第一版方案就完美,先跑通,再优化。