数据 第 14 讲 / 共 17 讲

第 14 讲:Data Processing(下)

本讲延续上一讲的数据处理主题,重点讨论 deduplication(去重)技术和数据混合策略。去重是数据处理 pipeline 中最具技术挑战性的环节之一,直接影响模型的训练效率和最终性能。

核心问题

互联网上存在大量重复和近似重复的内容。如果不进行去重,模型会对这些重复内容进行过度记忆(memorization),不仅浪费训练计算资源,还会导致模型在某些内容上产生过拟合,降低生成多样性。

1. Deduplication 的重要性

1.1 为什么需要去重

去重对 LLM 训练的影响体现在多个方面:

1.2 重复的类型

网页数据中的重复可以分为几种类型:

2. 精确去重 (Exact Deduplication)

精确去重是最基本的去重方式,目标是找出完全相同的文档或文档片段。

2.1 基于 Hash 的去重

最直接的方法是计算每个文档的 hash 值,相同 hash 值的文档被认为是重复的:

import hashlib

def exact_dedup(documents):
    seen_hashes = set()
    unique_docs = []

    for doc in documents:
        # Normalize whitespace before hashing
        normalized = " ".join(doc.split())
        doc_hash = hashlib.sha256(normalized.encode()).hexdigest()

        if doc_hash not in seen_hashes:
            seen_hashes.add(doc_hash)
            unique_docs.append(doc)

    return unique_docs

2.2 文档级 vs 行级去重

精确去重可以在不同粒度上进行:

Suffix Array 方法

Lee et al. (2022) 提出使用 suffix array 进行高效的子字符串去重。Suffix array 可以在 O(n) 时间内找到所有重复的子字符串。这种方法可以识别任意长度的重复片段,但需要将整个语料库加载到内存中(或使用外存 suffix array),对内存要求很高。

3. 近似去重 (Fuzzy Deduplication)

近似去重是更复杂但也更实用的技术。现实中大量的重复内容并非完全相同,精确去重无法捕捉这些 near-duplicates。

3.1 Jaccard Similarity

近似去重的基础概念是集合相似度。给定两个文档,将其转化为 n-gram 集合后,使用 Jaccard similarity 来衡量相似度:

J(A, B) = |A ∩ B| / |A ∪ B|

其中 A 和 B 是两个文档的 n-gram 集合。Jaccard similarity 的值在 [0, 1] 之间,值越大表示两个文档越相似。

然而,直接计算所有文档对的 Jaccard similarity 的时间复杂度是 O(n²),对于数十亿文档的语料库来说完全不可行。这就需要 MinHash 和 LSH 技术。

4. MinHash 算法详解

MinHash 是一种 locality-sensitive hashing 技术,可以高效地估计两个集合的 Jaccard similarity。

4.1 MinHash 的核心思想

MinHash 的关键洞察是:对于一个随机 hash 函数 h,两个集合 A 和 B 的 MinHash 值相同的概率恰好等于它们的 Jaccard similarity:

P(min(h(A)) = min(h(B))) = J(A, B)

这意味着我们可以通过比较 MinHash 值来估计 Jaccard similarity,而不需要直接比较两个集合的所有元素。

4.2 MinHash Signature

为了获得更准确的估计,我们使用多个独立的 hash 函数(通常 64-256 个),为每个文档生成一个 MinHash signature(签名向量):

import numpy as np
from datasketch import MinHash

def create_minhash(text, num_perm=128):
    """Create a MinHash signature for a document."""
    m = MinHash(num_perm=num_perm)

    # Convert text to shingles (n-grams)
    shingles = set()
    words = text.split()
    for i in range(len(words) - 4):  # 5-gram shingles
        shingle = " ".join(words[i:i+5])
        shingles.add(shingle)

    # Add shingles to MinHash
    for shingle in shingles:
        m.update(shingle.encode('utf-8'))

    return m

# Estimate Jaccard similarity
m1 = create_minhash(doc1)
m2 = create_minhash(doc2)
estimated_jaccard = m1.jaccard(m2)

两个 MinHash signature 中相同位置上值相等的比例,就是 Jaccard similarity 的无偏估计。使用 k 个 hash 函数时,估计误差大约为 O(1/√k)。

4.3 Shingle 的选择

将文档转化为集合时,"shingle"(也叫 n-gram)的选择很重要:

5. Locality-Sensitive Hashing (LSH)

即使有了 MinHash,对所有文档对进行比较仍然是 O(n²) 的。LSH 技术通过只比较"可能相似"的文档对,大幅降低计算量。

5.1 LSH 的原理

LSH 的核心思想是将 MinHash signature 分成多个 "band"(段)。如果两个文档在任意一个 band 中的值完全相同,它们就被视为候选重复对,进入进一步比较。

具体来说,假设 MinHash signature 有 k 个值,将其分为 b 个 band,每个 band 包含 r 个值(k = b × r):

def lsh_buckets(signature, num_bands, rows_per_band):
    """Split MinHash signature into bands and hash each band."""
    bands = []
    for i in range(num_bands):
        start = i * rows_per_band
        end = start + rows_per_band
        band = tuple(signature[start:end])
        band_hash = hash(band)
        bands.append(band_hash)
    return bands

# Two documents are candidate duplicates if they share
# the same hash in ANY band
def find_candidates(all_signatures, num_bands, rows_per_band):
    buckets = {}  # band_index -> hash -> list of doc_ids
    candidates = set()

    for doc_id, sig in enumerate(all_signatures):
        for band_idx in range(num_bands):
            start = band_idx * rows_per_band
            end = start + rows_per_band
            band_hash = hash(tuple(sig[start:end]))

            key = (band_idx, band_hash)
            if key in buckets:
                for other_id in buckets[key]:
                    candidates.add((other_id, doc_id))
            buckets.setdefault(key, []).append(doc_id)

    return candidates

5.2 参数调优:b 和 r 的选择

b(band 数量)和 r(每个 band 的行数)的选择决定了 LSH 的行为特征:

两个 Jaccard similarity 为 s 的文档被识别为候选对的概率为:

P = 1 - (1 - sr)b

这个函数呈现 S 形曲线特征:

工程注意事项

在处理 Common Crawl 级别的数据时,LSH 的 bucket 数据结构需要分布式存储。常用方案包括将 bucket 存储在分布式文件系统(如 HDFS)或分布式数据库中,或者使用 MapReduce / Spark 框架进行分布式 LSH 计算。

6. Train-Test Overlap 检测与移除

除了训练数据内部的去重,还需要确保训练数据与评估数据(test set)之间没有重叠。如果 benchmark 的测试样本出现在训练数据中,模型的评估结果就会虚高,无法反映真实能力。

6.1 Contamination 问题

GPT-3 论文首次系统性地研究了 benchmark contamination 问题。他们发现训练数据中确实包含一些 benchmark 数据的片段,这对部分评估结果产生了影响。

常见的 contamination 检测方法:

6.2 Contamination 移除策略

def check_contamination(train_doc, test_examples, n=13):
    """Check if a training document overlaps with test examples
    using n-gram matching."""
    train_ngrams = set(get_ngrams(train_doc, n))

    for test_ex in test_examples:
        test_ngrams = set(get_ngrams(test_ex, n))
        overlap = len(train_ngrams & test_ngrams)
        if overlap / len(test_ngrams) > 0.8:  # 80% overlap threshold
            return True  # Document is contaminated

    return False

在实践中,通常对所有主要 benchmark(如 MMLU, HellaSwag, ARC, WinoGrande, GSM8K 等)的测试集进行 contamination 检测,并移除有重叠的训练文档。

7. 数据混合策略

Pre-training 数据通常来自多个来源(如第 13 讲所述)。如何确定不同来源数据的混合比例,对模型的最终性能有显著影响。

7.1 基本原则

数据混合需要平衡多个目标:

7.2 典型的数据混合比例

数据来源 LLaMA 比例 GPT-3 比例 说明
Common Crawl(清洗后) 67% 60% 最大数据源,提供广泛覆盖
Books 4.5% 16% 长文本质量高
Wikipedia 4.5% 3% 高质量百科知识
GitHub / Code 4.5% - 代码能力
ArXiv 2.5% - 科学知识
StackExchange 2% - 技术问答
C4 15% - 清洗后的 Common Crawl 子集
WebText - 22% Reddit 高赞链接

8. DoReMi: 基于模型的数据 Reweighting

手动设定数据混合比例需要大量的实验和人工调优。DoReMi (Xie et al., 2023) 提出了一种基于模型的方法来自动学习最优的数据混合比例。

8.1 核心思想

DoReMi 使用 distributionally robust optimization (DRO) 的思路:训练一个小的 proxy model 来学习数据权重,使得模型在所有 domain 上的 worst-case loss 最小化。

直觉上,如果模型在某个 domain 上的 loss 较高,DoReMi 会增加该 domain 的采样权重,让模型花更多时间学习这个"薄弱"领域。

8.2 算法流程

  1. 训练 Reference Model:用均匀混合比例训练一个小的 reference model
  2. 训练 DRO Proxy Model:用 reference model 作为参考,通过 group DRO 优化数据权重。每一步更新中:
    • 计算 proxy model 在每个 domain 上的 excess loss(相对于 reference model 的额外 loss)
    • 增加 excess loss 较高的 domain 的权重
    • 使用更新后的权重采样数据,更新 proxy model
  3. 提取最终权重:从训练过程中收集的 domain 权重中取平均
  4. 训练大模型:使用学到的权重比例训练最终的大型模型

DoReMi 的效果

实验表明,DoReMi 学到的数据权重与人工调优的权重有显著差异。例如,DoReMi 通常会给 Wikipedia 更高的权重,并大幅降低低质量网页数据的比重。使用 DoReMi 权重训练的 280M 模型在 The Pile benchmark 上的 perplexity 优于使用默认权重训练的模型。

9. 面向特定能力的 Continued Pre-training

除了通用的 pre-training,有时需要通过 continued pre-training(在已有 checkpoint 上继续训练)来增强模型的特定能力。

9.1 长上下文 (Long Context)

标准 pre-training 通常使用较短的 context length(如 2048 或 4096 token)。要支持更长的上下文窗口(如 32K, 128K 甚至更长),需要:

9.2 代码能力增强

要显著提升模型的代码能力,可以在代码数据上进行 continued pre-training:

9.3 数学能力增强

数学推理能力的提升同样依赖专门的数据:

10. 数据处理 Pipeline 的工程实践

在工程层面,构建一个高效的数据处理 pipeline 面临诸多挑战。

10.1 典型 Pipeline 架构

一个完整的 pre-training 数据处理 pipeline 通常包含以下阶段:

# Typical data processing pipeline stages
pipeline = [
    "1. Download raw data (WARC files from Common Crawl)",
    "2. HTML to text extraction (trafilatura / resiliparse)",
    "3. Language detection (fasttext lid)",
    "4. Rule-based quality filtering",
    "5. Perplexity filtering (KenLM)",
    "6. Harmful content filtering",
    "7. PII removal",
    "8. Exact deduplication (hash-based)",
    "9. Fuzzy deduplication (MinHash + LSH)",
    "10. Train-test contamination removal",
    "11. Data mixing and sampling",
    "12. Tokenization and packing",
    "13. Shuffle and save to binary format",
]

10.2 规模挑战

处理 Common Crawl 级别的数据面临巨大的工程挑战:

10.3 Tokenization 和数据打包

处理后的文本数据需要 tokenize 并打包成适合训练的格式:

import numpy as np
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b")

def pack_documents(documents, max_length=2048):
    """Pack multiple documents into fixed-length sequences
    with end-of-document tokens."""
    all_tokens = []
    for doc in documents:
        tokens = tokenizer.encode(doc)
        all_tokens.extend(tokens)
        all_tokens.append(tokenizer.eos_token_id)  # Document separator

    # Split into fixed-length chunks
    packed = []
    for i in range(0, len(all_tokens) - max_length, max_length):
        packed.append(all_tokens[i:i + max_length])

    return np.array(packed, dtype=np.uint16)  # Save memory with uint16

打包后的数据通常以 memory-mapped binary 格式存储(如 NumPy .npy 或自定义的 binary format),以支持高效的随机访问和 data loading。

10.4 开源数据处理工具

目前有多个开源的数据处理框架可供使用:

本讲小结

本讲覆盖了数据处理的下半部分,核心内容包括:

  1. Deduplication 的重要性:减少 memorization,提升多样性和训练效率
  2. 精确去重:基于 hash 的方法,简单但无法处理 near-duplicates
  3. MinHash 算法:通过随机 hash 函数高效估计 Jaccard similarity
  4. LSH:通过 banding 技术将候选对发现从 O(n²) 降低到近线性
  5. Train-Test 去污染:确保评估结果的可靠性
  6. 数据混合策略:人工设定比例 vs DoReMi 自动优化
  7. Continued Pre-training:针对长上下文、代码、数学等特定能力的数据增强
  8. Pipeline 工程:分布式处理、容错、tokenization 打包等实践问题

至此,数据处理模块结束。下一讲将进入 Alignment 模块,讨论 SFT 和 RLHF。