09e-斯坦福CS336作业四:大规模语言模型训练数据收集与处理 💻
本文档详细讲解斯坦福 CS336 课程作业四的核心内容,涵盖从 Common Crawl 原始网页数据到高质量预训练数据集的完整处理流程,包括 HTML 转纯文本、内容过滤策略、大规模数据去重算法,以及高效数据流水线构建 🛠️
This document explains Assignment 4 of Stanford CS336, covering the complete pipeline from raw Common Crawl web data to high-quality pretraining datasets, including HTML-to-text extraction, content filtering strategies, large-scale deduplication algorithms, and efficient data pipeline construction 🛠️
术语表 / Terminology
| 术语 / Term | 中文 | 说明 / Description |
|---|---|---|
| Common Crawl | 通用爬虫数据 | 开源的网页爬虫数据集,包含数十亿网页的原始 HTML |
| WET File | 提取文本文件 | Common Crawl 提供的已提取文本格式,包含网页的纯文本内容 |
| WARC File | 网页存档文件 | Common Crawl 原始格式,包含完整的 HTTP 响应和 HTML 内容 |
| HTML Extraction | HTML 提取 | 从 HTML 文档中提取主要文本内容,去除标签、脚本等噪声 |
| Content Filtering | 内容过滤 | 识别并移除有害、低质量或不合适的文本内容 |
| PII Redaction | 个人隐私信息脱敏 | 检测并删除个人敏感信息(如邮箱、电话、身份证号等) |
| Deduplication | 数据去重 | 识别并移除重复或高度相似的文档 |
| MinHash | 最小哈希 | 一种高效的集合相似度估计算法,用于近似去重 |
| LSH | 局部敏感哈希 | Locality-Sensitive Hashing,快速筛选候选重复对 |
| Jaccard Similarity | 杰卡德相似度 | 两个集合的交集与并集的比值,衡量相似度 |
| n-gram | n元语法 | 连续的 n 个字符或词元,用于构建文本特征集合 |
| Data Pipeline | 数据流水线 | 将多个处理步骤串联成自动化流程 |
章节阅读路线图 🗺️ / Chapter Reading Roadmap
- 作业概述 📋 / Assignment Overview → 理解作业目标和整体架构
- HTML 转纯文本 🌐 / HTML to Text Extraction → 从网页数据中提取干净文本
- 内容过滤策略 🛡️ / Content Filtering Strategies → 过滤有害和低质量内容
- 大规模数据去重 🔍 / Large-Scale Deduplication → MinHash-LSH 算法原理与实现
- 构建数据流水线 🔄 / Building Data Pipeline → 整合所有步骤的完整流程
- 总结 📝 / Summary → 回顾核心要点
1. 作业概述 📋 / Assignment Overview
📖 Note: 本章介绍 CS336 作业四的目标、数据源和整体架构 / This chapter introduces the goals, data sources, and overall architecture of CS336 Assignment 4.
1.1 作业目标 🎯 / Assignment Goals
斯坦福 CS336(Language Modeling from Scratch)的作业四专注于 大规模训练数据的收集和处理 。在真实的大语言模型训练中,数据质量直接决定了模型性能的上限 ------ "Garbage In, Garbage Out"(垃圾进,垃圾出)。
核心任务 📝:
- 将 Common Crawl 的原始 HTML 转换为干净的纯文本
- 过滤有害内容(暴力、色情、仇恨言论等)和低质量文本
- 移除个人隐私信息(PII),如邮箱、电话、身份证号
- 实现高效的文本去重算法,消除重复内容
- 构建完整的数据处理流水线,优化处理效率
1.2 数据源:Common Crawl 🌐 / Data Source
Common Crawl 是一个非营利组织,自 2007 年以来持续爬取互联网网页,提供开放的网页数据集。它是大多数大语言模型(如 GPT-3、LLaMA、Mistral 等)的主要训练数据来源。
数据格式 📦:
- WARC 文件:原始网页存档,包含完整的 HTTP 响应头、HTML 内容、图片等
- WET 文件:已提取的纯文本文件,去除了 HTML 标签,但包含大量噪声(导航栏、广告、脚本残留等)
数据规模 📊:
- 每次爬取包含 数十亿个网页
- 压缩后数据量达 数百 TB
- 经过过滤和去重后,可获得 数万亿高质量 Token
直观类比 🎨:想象 Common Crawl 是一座巨大的"原始矿山"
- WARC 文件是"未加工的矿石"------包含所有杂质
- WET 文件是"粗炼的金属"------去除了大石块,但仍有杂质
- 经过过滤和去重后的数据才是"精炼的钢材"------可以直接用于训练
参考资料:
- Common Crawl - Open Repository of Web Crawl Data -- Common Crawl ⭐值得阅读
- 斯坦福CS336课程:从零打造你的大语言模型 -- 冷月清谈
- 【斯坦福大学CS336 课程】从0开始,手搓大模型,附:代码+课件 -- 知乎 ⭐值得阅读
2. HTML 转纯文本 🌐 / HTML to Text Extraction
🌐 Note: 本章讲解如何从网页 HTML 中提取干净的纯文本内容 / This chapter explains how to extract clean plain text from web HTML.
2.1 为什么需要 HTML 提取?🤔 / Why HTML Extraction?
网页的 HTML 结构包含大量非内容元素:
- HTML 标签 :
<div>、<span>、<a>等结构标签 - 脚本代码 :
<script>中的 JavaScript 代码 - 样式代码 :
<style>中的 CSS 样式 - 导航和广告:侧边栏、页脚、弹窗等无关内容
- 注释和元数据 :
<!-- comment -->、<meta>标签
直观类比 🎨:HTML 就像一个"包装精美的礼盒"
- 礼盒的包装纸、丝带、卡片 = HTML 标签、脚本、样式
- 礼盒里的礼物 = 网页的主要文本内容
- HTML 提取就是"拆礼盒"------去掉包装,拿到核心内容
2.2 提取方法对比 ⚔️ / Extraction Methods Comparison
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| BeautifulSoup 🍲 | 灵活、可控性强 | 需要手动编写规则,易出错 | 简单网页、定制化需求 |
| Trafilatura 🚀 | 高精度、自动识别主内容 | 对特殊网页可能失效 | 大规模爬虫、通用场景 |
| Readability 📖 | 模仿浏览器阅读模式 | 速度较慢 | 新闻文章提取 |
| Newspaper3k 📰 | 针对新闻优化 | 对非新闻网站效果差 | 新闻网站专用 |
性能对比 📊(基于学术研究):
- Trafilatura 的提取准确率 显著高于 BeautifulSoup 的全文本提取
- Trafilatura 能自动过滤导航栏、广告、页脚等噪声
- 处理速度:单个网页 < 1 秒,适合大规模处理
2.3 Trafilatura 实战示例 💻 / Trafilatura Example
python
import trafilatura # 导入 Trafilatura 库,用于网页文本提取 🚀
"""使用 Trafilatura 从 HTML 提取主文本内容
参数:
html_content: HTML 字符串,示例:"<html><body><h1>标题</h1><p>正文...</p></body></html>"
返回:
extracted_text: 提取的纯文本,示例:"标题\n正文..."
示例:
html = requests.get(url).text
text = extract_main_text(html)
"""
def extract_main_text(html_content):
# 使用 trafilatura 提取主文本,自动过滤导航、广告等噪声 🎯
# 数据流动:html_content[str] → trafilatura.extract() → extracted_text[str]
extracted_text = trafilatura.extract(
html_content, # 输入 HTML 字符串
include_comments=False, # 不包含 HTML 注释
include_tables=True, # 包含表格内容
include_links=False, # 不包含链接文本
output_format="txt" # 输出纯文本格式
)
return extracted_text if extracted_text else "" # 返回提取结果,失败时返回空字符串
2.4 提取后的后处理 🔧 / Post-Processing
提取后的文本仍需进一步清理:
python
import re # 导入正则表达式模块,用于文本清洗 🔧
"""清理提取后的文本,移除残留噪声
参数:
text: 原始提取文本,示例:" \n 这是一段正文 \n \n \n 另一段... "
返回:
cleaned_text: 清理后的文本,示例:"这是一段正文\n另一段..."
示例:
cleaned = clean_extracted_text(raw_text)
"""
def clean_extracted_text(text):
# 1️⃣ 移除多余空白行,保留最多连续 2 个换行符
# 数据流动:text[str] → re.sub() → text[str](多余换行被替换)
text = re.sub(r'\n{3,}', '\n\n', text) # 将 3+ 个连续换行替换为 2 个
# 2️⃣ 移除行首行尾空白
# 数据流动:text[str] → strip() → text[str](首尾空格被移除)
text = text.strip()
# 3️⃣ 移除残留的 HTML 实体(如 、&)
# 数据流动:text[str] → re.sub() → text[str](HTML 实体被移除)
text = re.sub(r'&[a-z]+;', ' ', text) # 移除 HTML 实体引用
return text
关键挑战 ⚠️:
- 不同网站结构差异巨大,难以用单一规则处理
- 需要平衡"提取完整性"和"噪声过滤率"
- 动态加载内容(JavaScript)无法通过静态 HTML 获取
参考资料:
- Trafilatura: A Web Scraping Library and Command-Line Tool -- ResearchGate ⭐值得阅读
- Comparing algorithms for extracting content from web pages -- chuniversiteit
- Trafilatura 官方文档 -- Trafilatura
3. 内容过滤策略 🛡️ / Content Filtering Strategies
🛡️ Note: 本章讲解如何识别并过滤有害、低质量内容和个人隐私信息 / This chapter explains how to identify and filter harmful, low-quality content and PII.
3.1 为什么需要内容过滤?🤔 / Why Content Filtering?
Common Crawl 包含互联网上的所有网页,其中不可避免地存在:
- 有害内容:暴力、色情、仇恨言论、极端主义
- 低质量内容:乱码、机器生成文本、SEO 垃圾内容
- 个人隐私信息(PII):邮箱、电话、身份证号、地址
- 代码和数据:非自然语言内容(如 JSON、XML、代码片段)
不过滤的后果 ⚠️:
- 模型学习到有害行为,产生不安全输出
- 模型性能下降,因为低质量数据干扰学习信号
- 隐私泄露风险,模型可能"记住"并输出 PII
直观类比 🎨:内容过滤就像"食品安检"
- 有害内容 = "有毒食品"------必须严格检测并移除
- 低质量内容 = "过期食品"------营养价值低,影响健康
- PII = "个人标签"------需要撕掉以保护隐私
3.2 有害内容检测 🔍 / Harmful Content Detection
检测方法:
-
基于关键词的启发式规则 📝
python# 简单关键词匹配(仅作示例,实际需要更复杂的规则) harmful_keywords = ["暴力关键词", "仇恨言论关键词", "色情关键词"] def is_harmful_heuristic(text): text_lower = text.lower() # 转换为小写,示例:"This is BAD content" → "this is bad content" return any(keyword in text_lower for keyword in harmful_keywords) # 检查是否包含任何有害关键词 -
基于分类器的检测 🤖
- 使用预训练的分类模型(如 Perspective API、Detoxify)
- 对文本进行毒性评分(Toxicity Score)
- 超过阈值则过滤
pythonfrom detoxify import Detoxify # 导入 Detoxify 毒性检测库 🤖 """使用预训练分类器检测有害内容 参数: text: 待检测文本,示例:"这是一段正常的内容" threshold: 毒性阈值(默认 0.5),示例:0.5 表示毒性概率超过 50% 则判定有害 返回: is_harmful: 是否有害(布尔值),示例:True 表示有害 toxicity_score: 毒性评分,示例:0.85 表示 85% 的毒性概率 示例: harmful, score = detect_harmful_content("有害内容示例") """ def detect_harmful_content(text, threshold=0.5): model = Detoxify('original') # 加载预训练毒性检测模型 scores = model.predict(text) # 预测毒性评分,返回:{'toxicity': 0.85, 'severe_toxicity': 0.12, ...} toxicity_score = scores['toxicity'] # 获取综合毒性评分 is_harmful = toxicity_score > threshold # 判断是否超过阈值 return is_harmful, toxicity_score
3.3 质量过滤策略 📊 / Quality Filtering
常见质量指标:
-
文本长度 📏
- 过短(< 50 字符):可能是标题、导航、广告
- 过长(> 100,000 字符):可能是代码、日志文件
-
词元与字符比例 🔤
- 正常文本:词元数 / 字符数 ≈ 0.15-0.25
- 代码/乱码:比例异常高或低
-
特殊字符比例 ⚙️
- 高比例的非字母数字字符(如
{},<>,##)可能表示代码
- 高比例的非字母数字字符(如
-
语言识别 🌍
- 使用 langdetect 或 fasttext 识别语言
- 仅保留目标语言(如英文)
python
from langdetect import detect # 导入语言检测库 🌍
"""基于启发式规则过滤低质量文本
参数:
text: 待过滤文本,示例:"这是一段质量不错的英文文本..."
min_length: 最小长度(默认 50),示例:50 表示少于 50 字符则过滤
max_length: 最大长度(默认 100000),示例:100000 表示超过 10 万字符则过滤
返回:
is_quality: 是否高质量(布尔值),示例:True 表示通过质量检查
示例:
if is_quality_text(text): # 仅处理高质量文本
process(text)
"""
def is_quality_text(text, min_length=50, max_length=100000):
# 1️⃣ 检查文本长度是否在合理范围内
# 数据流动:text[str] → len() → length[int] → 判断范围
if len(text) < min_length or len(text) > max_length:
return False
# 2️⃣ 检查词元与字符比例(英文文本通常 0.15-0.25)
# 数据流动:text[str] → split() → word_count[int] → ratio[float]
word_count = len(text.split()) # 统计词元数量
char_count = len(text) # 统计字符数量
word_char_ratio = word_count / char_count if char_count > 0 else 0 # 计算比例
if word_char_ratio < 0.10 or word_char_ratio > 0.30:
return False
# 3️⃣ 检测是否为英文(可根据需求调整)
# 数据流动:text[str] → detect() → language_code[str] → 判断是否"en"
try:
language = detect(text) # 检测语言,返回:"en"、"zh"、"fr" 等
if language != 'en': # 如果不是英文则过滤
return False
except:
return False # 检测失败则过滤
return True
3.4 PII 脱敏 🔒 / PII Redaction
常见 PII 模式:
- 邮箱:
user@example.com - 电话号码:
+1-234-567-8900 - 身份证号/社保号:
123-45-6789 - IP 地址:
192.168.1.1
python
import re # 导入正则表达式模块 🔧
"""使用正则表达式移除文本中的个人隐私信息(PII)
参数:
text: 包含 PII 的文本,示例:"联系邮箱:user@example.com,电话:123-456-7890"
返回:
redacted_text: 移除 PII 后的文本,示例:"联系邮箱:[EMAIL_REDACTED],电话:[PHONE_REDACTED]"
示例:
clean_text = redact_pii("我的邮箱是 test@test.com")
# 返回:"我的邮箱是 [EMAIL_REDACTED]"
"""
def redact_pii(text):
# 1️⃣ 移除邮箱地址
# 数据流动:text[str] → re.sub() → text[str](邮箱被替换为 [EMAIL_REDACTED])
text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[EMAIL_REDACTED]', text)
# 2️⃣ 移除电话号码(多种格式)
# 数据流动:text[str] → re.sub() → text[str](电话号码被替换为 [PHONE_REDACTED])
text = re.sub(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE_REDACTED]', text)
# 3️⃣ 移除 IP 地址
# 数据流动:text[str] → re.sub() → text[str](IP 地址被替换为 [IP_REDACTED])
text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP_REDACTED]', text)
return text
参考资料:
- Enhancing Model Safety through Pretraining Data Filtering -- Anthropic ⭐值得阅读
- A Survey on Harmful Content Generation and Safety Mitigation of LLM -- arXiv
- Toxicity of the Commons: Curating Open-Source Pre-Training Data -- arXiv
4. 大规模数据去重 🔍 / Large-Scale Deduplication
🔍 Note: 本章讲解 MinHash-LSH 算法原理和实现,用于高效识别重复文档 / This chapter explains MinHash-LSH algorithm for efficient duplicate detection.
4.1 为什么需要去重?🤔 / Why Deduplication?
Common Crawl 中存在大量重复内容:
- 镜像网站:相同内容在不同域名下
- 转载和复制:新闻、博客被多次转载
- 模板内容:电商产品页、法律文档仅替换少量信息
- 爬虫重复:同一页面被多次爬取
不去重的后果 ⚠️:
- 模型记忆:重复内容会被模型"过度学习",导致过拟合
- 训练效率浪费:计算资源用于处理冗余数据
- 评估偏差:测试集和训练集的重复导致性能虚高
直观类比 🎨:去重就像"整理书架"
- 重复的书 = 重复的文档------占用空间但不增加知识
- 保留一本 = 保留一个文档副本------节省空间,提高效率
- 整理后的书架 = 去重后的数据集------每本书都有价值
4.2 Jaccard 相似度 📐 / Jaccard Similarity
去重的核心是衡量两个文档的相似度 。最常用的指标是 Jaccard 相似度。
定义 📝:
对于两个集合 A 和 B,Jaccard 相似度定义为:
J(A, B) = \\frac{\|A \\cap B\|}{\|A \\cup B\|}
- ( |A \cap B| ):交集大小(两个集合共有的元素)
- ( |A \cup B| ):并集大小(两个集合的所有不重复元素)
- 取值范围:0, 1,1 表示完全相同,0 表示完全不同
如何将文本转换为集合? 🤔
使用 n-gram 集合:
- 将文本拆分为连续的 n 个字符(或词)
- 例如,文本 "hello" 的 3-gram 集合:{"hel", "ell", "llo"}
示例计算 🧮:
文档 A: "猫喜欢鱼"
文档 B: "猫爱吃鱼"
使用 2-gram:
- A 的 2-gram 集合:{"猫喜", "喜欢", "欢鱼"}
- B 的 2-gram 集合:{"猫喜", "喜欢", "欢鱼", "爱吃"}
- 交集:{"猫喜", "喜欢", "欢鱼"} → 大小 3
- 并集:{"猫喜", "喜欢", "欢鱼", "爱吃"} → 大小 4
- Jaccard 相似度 = 3/4 = 0.75
4.3 朴素去重 vs 高效去重 ⚔️ / Naive vs Efficient Deduplication
朴素方法 ❌:
python
# 对每对文档计算 Jaccard 相似度 → O(n²) 复杂度
for i in range(len(documents)):
for j in range(i+1, len(documents)):
similarity = jaccard(documents[i], documents[j])
if similarity > threshold:
mark_as_duplicate(j)
问题 📊:
- 100 万文档 → 需要计算 5000 亿次 Jaccard 相似度
- 计算量随文档数平方增长,无法扩展到大规模数据
高效方法:MinHash + LSH ✅:
- MinHash:将文档压缩为紧凑签名,快速估算 Jaccard 相似度
- LSH:只对"可能相似"的文档对进行详细比较
- 复杂度从 O(n²) 降低到 接近 O(n)
4.4 MinHash 算法原理 🔐 / MinHash Algorithm
核心思想 🎯:
MinHash 基于一个关键观察:两个集合的 Jaccard 相似度 = 它们的最小哈希值相同的概率。
算法步骤 📝:
- 构建 n-gram 集合:将每个文档转换为 n-gram 集合
- 应用多个哈希函数:对每个 n-gram 计算多个哈希值
- 取最小哈希值:对每个哈希函数,取集合中所有 n-gram 的最小哈希值
- 形成签名矩阵:将所有最小哈希值组合成文档的"签名"
- 比较签名:两个文档签名相同的比例 ≈ Jaccard 相似度
直观类比 🎨:MinHash 就像"指纹识别"
- 完整文档 = "完整的人"------信息量巨大,难以直接比较
- MinHash 签名 = "指纹"------紧凑但具有代表性
- 比较指纹 = 比较签名------快速判断是否"同一个人"
python
import hashlib # 导入哈希库,用于计算哈希值 🔐
def get_ngrams(text, n=5):
"""将文本拆分为 n-gram 集合
参数:
text: 输入文本,示例:"hello world"
n: n-gram 大小(默认 5),示例:5 表示每个 gram 包含 5 个字符
返回:
ngrams: n-gram 集合,示例:{"hello", "ello ", "llo w", "lo wo", "o wor", " worl", "world"}
"""
return set(text[i:i+n] for i in range(len(text) - n + 1)) # 生成所有 n-gram 并转为集合
def minhash_signature(ngram_set, num_hashes=100):
"""计算文档的 MinHash 签名
参数:
ngram_set: n-gram 集合,示例:{"hello", "world", ...}
num_hashes: 哈希函数数量(默认 100),示例:100 表示使用 100 个不同的哈希函数
返回:
signature: MinHash 签名列表,示例:[23, 45, 12, 89, ...],长度为 num_hashes
"""
signature = [] # 存储每个哈希函数的最小值
for i in range(num_hashes): # 遍历每个哈希函数
# 使用不同的种子生成不同的哈希函数
min_hash = float('inf') # 初始化最小值为无穷大
for ngram in ngram_set: # 遍历集合中的每个 n-gram
# 计算哈希值,示例:hashlib.md5(b"hello_0").hexdigest() → "d8e8fca2dc0f896fd7cb4cb0031ba249"
hash_value = int(hashlib.md5(f"{ngram}_{i}".encode()).hexdigest(), 16)
min_hash = min(min_hash, hash_value) # 保留最小哈希值
signature.append(min_hash) # 将当前哈希函数的最小值加入签名
return signature
4.5 LSH 局部敏感哈希 🎯 / Locality-Sensitive Hashing
问题 🤔:即使有了 MinHash 签名,仍然需要比较所有文档对(O(n²))。
LSH 解决方案 🎯:
- 将 MinHash 签名分块(Band)
- 同一块内完全相同的文档对才进行详细比较
- 大幅减少需要比较的文档对数量
算法步骤 📝:
- 分割签名:将长度为 100 的签名分成 20 个 Band,每个 Band 包含 5 个哈希值
- 哈希分桶:对每个 Band 计算哈希值,将文档放入对应的"桶"
- 候选对:同一个桶中的文档对成为"候选重复对"
- 精确验证:只对候选对计算精确的 Jaccard 相似度
直观类比 🎨:LSH 就像"快递分拣"
- 所有包裹 = 所有文档------数量巨大
- 按地区分拣 = 按 Band 分桶------相似地区的包裹放在一起
- 只比较同一地区的包裹 = 只比较同一桶的文档------大幅减少工作量
python
from collections import defaultdict # 导入默认字典,用于构建哈希桶 📦
def lsh_deduplicate(documents, num_bands=20, band_size=5, threshold=0.8):
"""使用 LSH 进行大规模文档去重
参数:
documents: 文档列表,示例:["文档1内容", "文档2内容", ...]
num_bands: Band 数量(默认 20),示例:20
band_size: 每个 Band 的大小(默认 5),示例:5
threshold: Jaccard 相似度阈值(默认 0.8),示例:0.8 表示超过 80% 相似度则判定重复
返回:
unique_docs: 去重后的文档列表
duplicate_pairs: 检测到的重复对列表,示例:[(0, 5), (2, 10), ...]
"""
# 1️⃣ 计算所有文档的 MinHash 签名
# 数据流动:documents[list] → [minhash_signature() → signatures[list of lists]]
signatures = []
for doc in documents:
ngrams = get_ngrams(doc) # 提取 n-gram 集合
sig = minhash_signature(ngrams, num_hashes=num_bands * band_size) # 计算 MinHash 签名
signatures.append(sig)
# 2️⃣ 使用 LSH 分桶
# 数据流动:signatures → buckets[dict] → candidate_pairs[list]
buckets = defaultdict(list) # 哈希桶:bucket_key → [doc_index, ...]
for doc_idx, sig in enumerate(signatures): # 遍历每个文档的签名
for band_idx in range(num_bands): # 遍历每个 Band
# 提取当前 Band 的签名片段
start = band_idx * band_size # 计算起始位置,示例:band_idx=2, band_size=5 → start=10
end = start + band_size # 计算结束位置,示例:end=15
band = tuple(sig[start:end]) # 提取 Band 片段,示例:(23, 45, 12, 89, 34)
# 将文档索引放入对应的桶
buckets[(band_idx, band)].append(doc_idx) # 键:(band_idx, band_signature),值:[文档索引列表]
# 3️⃣ 收集候选对(同一桶中的文档)
candidate_pairs = set()
for bucket_docs in buckets.values(): # 遍历每个桶
if len(bucket_docs) > 1: # 桶中有多个文档才需要比较
for i in range(len(bucket_docs)): # 生成所有文档对
for j in range(i+1, len(bucket_docs)):
candidate_pairs.add((bucket_docs[i], bucket_docs[j]))
# 4️⃣ 对候选对进行精确验证(计算实际 Jaccard 相似度)
duplicate_pairs = []
unique_indices = set(range(len(documents))) # 初始假设所有文档都唯一
for doc_i, doc_j in candidate_pairs:
# 计算实际 Jaccard 相似度
ngrams_i = get_ngrams(documents[doc_i])
ngrams_j = get_ngrams(documents[doc_j])
intersection = len(ngrams_i & ngrams_j) # 交集大小
union = len(ngrams_i | ngrams_j) # 并集大小
jaccard_sim = intersection / union if union > 0 else 0 # Jaccard 相似度
if jaccard_sim > threshold: # 超过阈值则判定重复
duplicate_pairs.append((doc_i, doc_j))
unique_indices.discard(doc_j) # 标记为重复(保留第一个)
# 5️⃣ 返回去重后的文档
unique_docs = [documents[i] for i in unique_indices] # 根据唯一索引提取文档
return unique_docs, duplicate_pairs
4.6 去重效果评估 📊 / Deduplication Evaluation
关键指标:
- 去重率:重复文档数 / 总文档数
- 召回率:正确识别的重复对 / 实际重复对
- 精确率:正确识别的重复对 / 预测的重复对
- 处理速度:文档数 / 秒
典型结果 📈:
- Common Crawl 去重率:30%-50%(取决于数据集)
- MinHash-LSH 召回率:> 90%(参数合理时)
- 处理速度:单线程 ~1000 文档/秒,分布式可达数百万/秒
参考资料:
- MinHash: Jaccard Similarity, LSH, and Near-Duplicate Detection -- mbrenndoerfer ⭐值得阅读
- Data Deduplication at Trillion Scale -- Zilliz ⭐值得阅读
- 去重算法这么多,但模型训练最优解是MinHash LSH -- Zilliz ⭐值得阅读
- Finding near-duplicates with Jaccard similarity and MinHash -- nelhage
- 大模型数据工程系列:数据清洗与去噪 -- 知乎
5. 构建数据流水线 🔄 / Building Data Pipeline
🔄 Note: 本章将所有步骤整合为完整的数据处理流程 / This chapter integrates all steps into a complete data processing pipeline.
5.1 流水线架构 🏗️ / Pipeline Architecture
完整的数据处理流水线包含以下阶段:
css
原始 WET 文件 → HTML 提取 → 内容过滤 → PII 脱敏 → 数据去重 → 高质量数据集
↓ ↓ ↓ ↓ ↓ ↓
Common 提取主文本 移除有害/ 移除个人 MinHash- 用于训练
Crawl 清理噪声 低质量内容 隐私信息 LSH 去重 的最终数据
数据流动示例 📊:
假设有 100 万个网页:
- HTML 提取:100 万 → 100 万(提取文本,可能损失部分页面)
- 质量过滤:100 万 → 60 万(过滤掉 40% 低质量内容)
- PII 脱敏:60 万 → 60 万(文本数量不变,仅移除敏感信息)
- 数据去重:60 万 → 40 万(移除 33% 重复内容)
- 最终输出:40 万高质量文档
5.2 完整流水线实现 💻 / Complete Pipeline Implementation
python
import os # 导入操作系统接口模块 📁
import glob # 导入文件路径匹配模块 🔍
"""完整的数据处理流水线
参数:
input_dir: 输入目录(包含 WET 文件),示例:"./data/wet_files/"
output_file: 输出文件路径,示例:"./data/cleaned_corpus.txt"
返回:
stats: 处理统计信息,示例:{'total': 1000000, 'extracted': 950000, 'filtered': 600000, 'deduplicated': 400000}
示例:
stats = run_data_pipeline("./raw_data/", "./cleaned_data.txt")
print(f"最终文档数:{stats['deduplicated']}")
"""
def run_data_pipeline(input_dir, output_file):
stats = {
'total': 0, # 总文档数
'extracted': 0, # 成功提取的文档数
'quality_passed': 0, # 通过质量检查的文档数
'pii_redacted': 0, # PII 脱敏的文档数
'deduplicated': 0 # 去重后的文档数
}
# 1️⃣ 读取所有 WET 文件
wet_files = glob.glob(os.path.join(input_dir, "*.wet")) # 匹配所有 .wet 文件,示例:["./data/001.wet", "./data/002.wet", ...]
stats['total'] = len(wet_files)
extracted_docs = [] # 存储提取后的文档
# 2️⃣ HTML 提取 + 质量过滤 + PII 脱敏(逐文件处理)
for wet_file in wet_files: # 遍历每个 WET 文件
with open(wet_file, 'r', encoding='utf-8') as f: # 打开文件读取
html_content = f.read() # 读取 HTML 内容
# 提取主文本
text = extract_main_text(html_content) # 调用 HTML 提取函数
if not text:
continue # 提取失败则跳过
stats['extracted'] += 1 # 统计提取成功数
# 质量过滤
if not is_quality_text(text): # 检查文本质量
continue # 低质量则跳过
stats['quality_passed'] += 1 # 统计通过质量检查的文档数
# PII 脱敏
text = redact_pii(text) # 移除个人隐私信息
stats['pii_redacted'] += 1 # 统计 PII 脱敏的文档数
extracted_docs.append(text) # 添加到文档列表
# 3️⃣ 大规模数据去重
unique_docs, duplicates = lsh_deduplicate(
extracted_docs, # 输入的文档列表
num_bands=20, # Band 数量
band_size=5, # 每个 Band 的大小
threshold=0.8 # Jaccard 相似度阈值
)
stats['deduplicated'] = len(unique_docs) # 统计去重后的文档数
# 4️⃣ 保存到文件
with open(output_file, 'w', encoding='utf-8') as f: # 打开输出文件
for doc in unique_docs: # 遍历每个去重后的文档
f.write(doc + '\n\n') # 写入文档,用两个换行分隔
return stats
5.3 性能优化策略 ⚡ / Performance Optimization
处理数百 TB 级别的数据需要高效策略:
-
并行处理 🚀
- 使用多进程或多线程同时处理多个文件
- 分布式计算框架(如 Apache Spark、Dask)
-
流式处理 💧
- 不将所有数据加载到内存
- 逐批次处理,实时写入
-
增量去重 🔄
- 维护已处理文档的签名索引
- 新文档到来时只与已有文档比较
-
检查点机制 💾
- 定期保存中间结果
- 失败后可从检查点恢复
python
from multiprocessing import Pool # 导入多进程池模块 🚀
"""使用多进程并行处理文档
参数:
documents: 文档列表,示例:["文档1", "文档2", ...]
num_processes: 进程数(默认 4),示例:4 表示使用 4 个 CPU 核心并行处理
返回:
processed_docs: 处理后的文档列表
示例:
results = parallel_process(docs, num_processes=8)
"""
def parallel_process(documents, num_processes=4):
# 创建进程池,自动分配任务到多个 CPU 核心
with Pool(processes=num_processes) as pool: # 创建进程池,示例:4 个进程
# 使用 map 并行处理每个文档
results = pool.map(process_single_doc, documents) # process_single_doc 处理单个文档
return [r for r in results if r is not None] # 过滤掉处理失败的文档
def process_single_doc(doc):
"""处理单个文档的完整流程"""
# 1. 质量检查
if not is_quality_text(doc):
return None
# 2. PII 脱敏
doc = redact_pii(doc)
return doc
5.4 流水线监控 📊 / Pipeline Monitoring
python
import time # 导入时间模块,用于计算处理时间 ⏱️
"""运行流水线并输出详细统计信息
参数:
input_dir: 输入目录
output_file: 输出文件
返回:
无(打印统计信息到控制台)
示例:
run_pipeline_with_stats("./raw/", "./output.txt")
"""
def run_pipeline_with_stats(input_dir, output_file):
start_time = time.time() # 记录开始时间
print("=" * 60) # 打印分隔线
print("🚀 开始数据处理流水线") # 打印标题
print("=" * 60) # 打印分隔线
stats = run_data_pipeline(input_dir, output_file) # 运行流水线
elapsed_time = time.time() - start_time # 计算总耗时(秒)
print("\n📊 处理统计:") # 打印统计标题
print(f" 总文档数: {stats['total']:,}") # 打印总文档数,示例:1,000,000
print(f" 成功提取: {stats['extracted']:,} ({stats['extracted']/stats['total']*100:.1f}%)") # 打印提取成功数和比例
print(f" 质量通过: {stats['quality_passed']:,} ({stats['quality_passed']/stats['total']*100:.1f}%)") # 打印质量通过数
print(f" PII 脱敏: {stats['pii_redacted']:,}") # 打印 PII 脱敏数
print(f" 去重后: {stats['deduplicated']:,} ({stats['deduplicated']/stats['total']*100:.1f}%)") # 打印去重后文档数
print(f" 总耗时: {elapsed_time:.2f} 秒") # 打印总耗时
print(f" 处理速度: {stats['total']/elapsed_time:.2f} 文档/秒") # 计算并打印处理速度
print("=" * 60) # 打印分隔线
参考资料:
- Mastering LLM Techniques: Text Data Processing -- NVIDIA ⭐值得阅读
- Data Preprocessing Pipelines for LLMs -- ApX Machine Learning
- Training Data preparation for Customizing LLMs -- Medium
6. 总结 📝 / Summary
本章我们完成了 CS336 作业四的核心内容学习,回顾了从原始网页数据到高质量预训练数据集的完整流程:🎯
| 步骤 | 操作 | 关键技术 |
|---|---|---|
| 1️⃣ | HTML 转纯文本 | Trafilatura、BeautifulSoup、后处理清洗 |
| 2️⃣ | 内容过滤 | 毒性分类器、启发式规则、质量指标 |
| 3️⃣ | PII 脱敏 | 正则表达式匹配、模式替换 |
| 4️⃣ | 数据去重 | MinHash-LSH、Jaccard 相似度、n-gram |
| 5️⃣ | 流水线构建 | 多进程并行、流式处理、检查点机制 |
🔴 关键理解:
- 💡 数据质量决定模型上限 ------ "Garbage In, Garbage Out",高质量数据是训练优秀模型的前提 🏆
- ⚡ MinHash-LSH 是去重利器 ------ 将 O(n²) 复杂度降低到接近 O(n),使大规模去重成为可能 🚀
- 🛡️ 多层过滤必不可少 ------ 有害内容、低质量文本、PII 都必须严格过滤,确保数据安全合规 🔒
- 🔄 流水线设计影响效率 ------ 并行处理、流式处理、增量更新是处理 TB 级数据的关键 🔑
最后更新时间:2026-06-23