数据工程
15 万亿个 token——LLaMA 3 的训练数据量。这个数字听起来很大,但它从哪来?不可能从天上掉下来。
这一节从互联网原始 HTML 出发,走完数据清洗的完整 pipeline:文本提取、质量过滤、去重、数据混合,每一步为什么做、怎么做。
数据工程的起点是 Common Crawl——一个每月爬取整个互联网的非营利项目,原始数据量约 500TB/月。这些数据是 HTML 格式,里面混着导航栏、广告、JavaScript 代码和评论区内容。
直接喂给模型等于喂垃圾,需要一套系统的方法把原始 HTML 变成干净、可训练的文本。整个过程分为五步:文本提取 → 质量过滤 → 去重 → 数据混合 → Tokenize。每一步的筛选标准都会直接影响最终模型的质量,因此每一步都需要明 确的判断规则。
这一节按顺序拆解这五个步骤,每步用真实数据片段展示筛选前后的变化。
0. 数据 Pipeline 总览
先看全景,再逐个击破:
Common Crawl (原始 HTML,~500TB/月)
│
▼
┌─────────────┐
│ 1. 文本提取 │ HTML → 纯文本,去掉导航/广告/脚本
└──────┬──────┘
▼
┌─────────────┐
│ 2. 语言过滤 │ 只要中文/英文等目标语 言
└──────┬──────┘
▼
┌─────────────┐
│ 3. 质量过滤 │ 去广告/导航/乱码/太短/太长
└──────┬──────┘
▼
┌─────────────┐
│ 4. 去重 │ 精确去重 + MinHash 近似去重
└──────┬──────┘
▼
┌─────────────┐
│ 5. 数据混合 │ Common Crawl + Wiki + Books + Code
└──────┬──────┘
▼
干净训练数据 ✨
1. 文本提取:从 HTML 里提取正文
1.1 Common Crawl 里有什么?
Common Crawl 每个月爬取数十亿个网页,存储为 WARC(Web ARChive)格式。每个 WARC 文件里是一个完整的 HTML 页面——包含正文,也包含大量和正文无关的内容:
- 导航栏:「首页 | 关于 | 联系我们」
- 广告弹窗:「限时优惠!点击购买!」
- JavaScript 代码:「function trackUser(){...}」
- CSS 样式:「.sidebar { float: right; }」
- 页脚:「Copyright 2024. All rights reserved.」
- 评论区:「沙发!好文!顶!」
一篇正常的文章被这些东西包裹着。文本提取的任务就是识别并保留正文,丢弃其余部分。这 个过程不完全自动化——现代工具(如 trafilatura、resiliparse)结合了 HTML 标签分析和机器学习模型来判断哪些内容是正文。
1.2 用一个模拟的 HTML 演示提取过程
# === 模拟: 一个典型的 Common Crawl HTML 页面 ===
raw_html = """
<!DOCTYPE html>
<html>
<head>
<title>What is Machine Learning? - AI Blog</title>
<meta name="description" content="Learn ML basics">
<script src="tracking.js"></script>
<style>.ad { color: red; }</style>
</head>
<body>
<nav>
<a href="/">Home</a> |
<a href="/about">About</a> |
<a href="/contact">Contact</a>
</nav>
<div class="sidebar">
<div class="ad">
<h3>Sponsored</h3>
<p>Buy the best AI course! 50% off today only!</p>
<button>Click Here!</button>
</div>
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-12345-6']);
_gaq.push(['_trackPageview']);
</script>
</div>
<article>
<h1>What is Machine Learning?</h1>
<p>Machine learning is a subset of artificial intelligence
that enables systems to learn and improve from experience
without being explicitly programmed.</p>
<p>The process of learning begins with observations or data,
such as examples, direct experience, or instruction.</p>
<p>Machine learning algorithms build a mathematical model
based on sample data, known as "training data".</p>
</article>
<div class="comments">
<p>User123: Great article!</p>
<p>Bot456: Buy followers at cheap-followers.com!</p>
</div>
<footer>
<p>Copyright 2024 AI Blog. All rights reserved.</p>
<p>Terms of Service | Privacy Policy | Cookie Settings</p>
</footer>
</body>
</html>
"""
print("=== 原始 HTML ===")
print(raw_html[:500])
print("...")
print()
print("↑ 里面混了:导航栏、广告、JS跟踪代码、评论区垃圾、页脚")
import re
# === 手工模拟文本提取过程 ===
print("=== 文本提取:HTML → 纯文本 ===")
print()
# Step 1: 去掉 <script> 和 <style> 标签及其内容
def remove_scripts_styles(html):
"""去掉 script 和 style 标签(包括内容)"""
html = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL | re.IGNORECASE)
return html
# Step 2: 去掉所有 HTML 标签
def strip_tags(html):
"""去掉所有 <...> 标签"""
return re.sub(r'<[^>]+>', '', html)
# Step 3: 清理空白
def clean_whitespace(text):
"""合并多空行、去首尾空白"""
text = re.sub(r'[ \t]+', ' ', text) # 合并连续空格/tab
text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) # 多空行 → 一个空行
return text.strip()
# 一步一步走
print("Step 1 — 去掉 script/style 标签:")
no_scripts = remove_scripts_styles(raw_html)
print(f" 原始: {len(raw_html)} 字符 → 去掉后: {len(no_scripts)} 字符")
print()
print("Step 2 — 去掉所有 HTML 标签:")
plain_text = strip_tags(no_scripts)
print(f" 去掉标签后: {len(plain_text)} 字符")
print()
print("Step 3 — 清理空白:")
clean_text = clean_whitespace(plain_text)
print(f" 清理空白后: {len(clean_text)} 字符")
print()
print("=" * 60)
print("提取结果:")
print("=" * 60)
print(clean_text)
print("=" * 60)
print()
print("注意:导航栏(Home|About|Contact)还在、广告还在、评论区还在")
print(" → 这些需要在后续的「质量过滤」步骤中清除")
2. 质量过滤
文本提取出来后,大部分内容仍然是低质量的。想象一下 Common Crawl 里有什么:自动生成的 SEO 页面、乱码、只有导航栏没有正文的页面、重复上千次的广告文案……这些内容如果直接拿去训练,模型学到的东西还不如不学。
因此质量过滤是整个 pipeline 中最关键的一 步:宁可丢掉一些好数据,也不能让大量垃圾混入训练集。常用的方法分两个层次:
2.1 启发式规则(快速筛掉明显垃圾)
这些规则不需要模型,直接检查文本的统计特征。每条规则针对一类常见垃圾:
import numpy as np
np.random.seed(42)
# === 质量过滤规则:逐条演示 ===
print("=== 质量过滤规则 ===")
print()
# 准备几条「待审」文本
samples = [
{"name": "好文章", "text": "Machine learning is a subset of artificial intelligence that enables systems to learn and improve from experience without being explicitly programmed. The field has grown rapidly since the 2010s, driven by advances in deep learning and the availability of large datasets."},
{"name": "广告", "text": "BUY NOW!!! Click here!!! Limited time offer!!! Subscribe today and get 50% OFF!!! Don't miss this opportunity!!!"},
{"name": "目录页", "text": "Chapter 1. Introduction. Chapter 2. Methods. Chapter 3. Results. Chapter 4. Discussion. Chapter 5. Conclusion. Appendix A. Appendix B. References."},
{"name": "太短", "text": "Hello world."},
{"name": "随机字符", "text": "asdfjkl; qwerty zxcvbnm @#$%@# 123456789 !!!!!!!"},
{"name": "重复模板", "text": "This is a blog post.\n" * 30 + "unique content here"},
]
def quality_check(text):
"""返回 (是否保留, 丢弃原因)"""
# 规则 1: 长度过滤
words = text.split()
if len(words) < 5:
return False, f"太短 ({len(words)} 个词 < 5)"
if len(text) > 5000:
return False, f"太长 ({len(text)} 字符 > 5000)"
# 规则 2: 平均词长 — 正常英文词长 3-10,乱码词很长
avg_word_len = np.mean([len(w) for w in words])
if avg_word_len > 12:
return False, f"平均词长异常 ({avg_word_len:.1f} > 12)"
# 规则 3: 特殊字符占比 — 正常文章标点不会超过 15%
special_count = sum(1 for c in text if not c.isalnum() and not c.isspace())
special_ratio = special_count / max(len(text), 1)
if special_ratio > 0.25:
return False, f"特殊字符太多 ({special_ratio:.1%} > 25%)"
# 规则 4: 行重复率 — 模板页面有很多重复行
lines = [l.strip() for l in text.split('\n') if l.strip()]
if len(lines) > 3:
unique_ratio = len(set(lines)) / len(lines)
if unique_ratio < 0.4:
return False, f"行重复率过高 ({unique_ratio:.1%} < 40%)"
# 规则 5: 大写字母占比 — 全是 CAPS LOCK 的通常是垃圾
if len(text) > 50:
upper_ratio = sum(1 for c in text if c.isupper()) / sum(1 for c in text if c.isalpha())
if upper_ratio > 0.5:
return False, f"大写字母太多 ({upper_ratio:.1%} > 50%)"
return True, "通过"
print(f"{'文本':<12s} {'结果':>8s} {'原因'}")
print("-" * 55)
for sample in samples:
passed, reason = quality_check(sample['text'])
status = "✅ 保留" if passed else "❌ 丢弃"
print(f"{sample['name']:<12s} {status:>8s} {reason}")
print()
print("真实系统通常有 20-50 条这样的规则。")
print("这能过滤掉约 60-80% 的 Common Crawl 文本。")
2.2 基于模型的过滤 — 请「语文老师」来评分
除了人工规则,还可以用一个已经训练好的轻量级语言模型(如 KenLM)来给每篇文章打分。
思路和做英语阅读一样:老师看一眼文章就能判断「这是人写的」还是「这是随机生成的」。
用语言模型计算文章的「困惑度」(Perplexity, PPL):
PPL 很低 (< 10): 文章太简单,像「a a a a a...」→ 扔
PPL 正常 (10-1000): 像正常人类写的 → 留
PPL 很高 (> 1000): 乱码 → 扔
有些系统还训练一个二分类器:Wikipedia 文章 = 好(正例),Common Crawl 随机文章 = 坏(负例)。训练后让它给每篇文章打分。
关键洞察:Wikipedia = 「教科书级别」的好文本,用维基当尺子来量其他文本。
2.3 PII 和安全过滤:不是所有“正常文字”都适合训练
前面的规则会筛掉乱码、广告和模板页。但还有一类内容看起来很正常,却不应该直接喂给模型:个人信息和危险字符串。
PII(Personally Identifiable Information)指能识别到具体人的信息,比如邮箱、手机号、身份证号、家庭地址。模型如果在训练中反复见到这些内容,可能会在生成时复述出来。安全过滤还会检查 API key、私钥、密码、恶意脚本、明显仇恨/色情/暴力文本等。
这一步和“文章写得好不好”不是一回事。一篇文章可以很通顺,但里面夹着邮箱、电话、密钥;质量过滤会放过它,PII/安全过滤必须单独处理。
工业系统通常有两种做法:
| 做法 | 适合场景 | 例子 |
|---|---|---|
| 删除整篇文档 | 命中严重风险 | 泄露密钥、身份证、恶意代码 |
| 脱敏后保留 | 正文有价值,但夹杂少量 PII | 邮箱替换成 |
关键不是“看到邮箱就一定扔掉”,而是先判断风险等级:风险高的丢,风险低且正文有价值的可以脱敏。
import re
# === PII 和安全过滤演示 ===
print("=== PII / 安全过滤 ===")
print()
records = [
{
"name": "普通教程",
"text": "This tutorial explains gradient descent with a small example.",
},
{
"name": "含邮箱",
"text": "Contact me at [email protected] for the dataset notes.",
},
{
"name": "含手机号",
"text": "我的手机号是 13812345678,训练日志在这里。",
},
{
"name": "疑似密钥",
"text": "AWS_SECRET_ACCESS_KEY = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AB",
},
]
patterns = {
"EMAIL": r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
"PHONE_CN": r"1[3-9]\d{9}",
"SECRET": r"(api[_-]?key|secret[_a-z0-9-]*|token|password)\s*[=:]\s*[^\s]+",
}
def redact_or_drop(text):
"""检测 PII / 密钥;返回处理动作和处理后的文本"""
lowered = text.lower()
if re.search(patterns["SECRET"], lowered):
return "丢 弃", "命中疑似密钥"
cleaned = text
hit = False
for label, pattern in patterns.items():
if label == "SECRET":
continue
if re.search(pattern, cleaned):
hit = True
cleaned = re.sub(pattern, f"<{label}>", cleaned)
if hit:
return "脱敏保留", cleaned
return "保留", cleaned
for record in records:
action, result = redact_or_drop(record["text"])
print(f"{record['name']:<8s} -> {action}")
print(f" {result}")
print()
print("关键观察:PII/安全过滤解决的是泄露风险,不是文章质量问题。")