Data Preprocessing: из чего 'готовят' данные для LLM

opensourceaillmdatapreprocessingit
← Back to Blog

Введение: данные — это новое нефть

LLM не лучше своих данных. Качество данных определяет качество модели. Data preprocessing — самый дорогой и важный этап.

Факт: Llama-3 обучался на 15 трлн токенов. Сбор и очистка — 80% времени проекта.


Уровень 1: Откуда берутся данные?

Источники данных

1. Веб-скрейпинг:
   - Common Crawl: 400+ млрд веб-страниц
   - Wikipedia: качественный текст
   - News APIs: статьи, новости
   - Reddit, StackOverflow: форумы

2. Книги:
   - Project Gutenberg: 70K+ книг
   - Books3 (The Pile): книги разных жанров

3. Код:
   - GitHub: 200M+ репозиториев
   - StarCoder: 80+ языков программирования

4. Диалоги:
   - Alpaca, ShareGPT: QA пары
   - Anthropic helpful-harmless: диалоги

5. Мультимодальные:
   - LAION: изображения + подписи
   - YouTube: субтитры + видео

Common Crawl — основа всего

Common Crawl:
  400+ млрд URL
  100+ PB данных
  Обновляется каждые 2 недели

Структура:
  WET (Web Entity Text):
    <http://example.com>
    <date>20230101120000</date>
    <text xml:lang=en>Example text...</text>

WARC (Web Archive):
  .warc.gz файлы
  Полные HTTP ответы
  10-50 GB каждый

Уровень 2: Tokenization — текст → числа

Что такое Tokenizer?

Текст → токены → token IDs → модель

"Hello, world!" → ["Hello", ",", " world", "!"]
                    → [15496, 11, 1917, 995]

Почему не слова?
  "unhappiness" = "un" + "happi" + "ness"
  Редкие слова → неизвестны
  Subword: баланс между vocab size и coverage

Почему не буквы?
  26 букв → 26^50 комбинаций для 50 символов
  Слишком долго, слишком много токенов

BPE (Byte-Pair Encoding)

# Алгоритм BPE:
# 1. Начинаем с символов
# 2. Итерируем: сливаем самые частые пары

# Исходные частоты:
# "h": 100, "e": 120, "l": 150, "o": 90
# "he": 80, "el": 70, "ll": 95, "lo": 60
# "lo" и "ll" - самые частые пары

# Шаг 1: сливаем "ll" -> "ll"
# Шаг 2: сливаем "lo" -> "lo"
# ... повторяем K раз (K = 50K для Llama)

# Итоговый vocab: 50K токенов
# Покрытие: 95% английского за 50K токенов
# 99.5% за 100K токенов

SentencePiece (для мультиязычности)

# SentencePiece: токенизация на уровне Unicode
import sentencepiece as spm

sp = spm.SentencePieceProcessor()
sp.train("data.txt", vocab_size=32000, model_type="bpe")

# Результат:
# "Hello, мир!" -> ["Hello", ",", "▁", "мир", "!"]
#               -> [15496, 11, 1917, 45231, 995]

# ▁ = space marker
# Работает со всеми языками

Llama Tokenizer

Llama 3: 128K токенов

Специальные токены:
  <|begin_of_text|>  = 128000
  <|end_of_text|>    = 128001
  <|finetune|>       = 128004
  <|reserved_special_token_0|> = 128255

Пример:
  "Hello, world!"
  -> [128000, 2924, 198, 10713, 732, 220]
  -> "<|begin_of_text|>Hello", ",", " world", "!"

Уровень 3: Очистка текста

Удаление мусора

def clean_text(text):
    # 1. Unicode normalization
    text = unicodedata.normalize('NFC', text)
    
    # 2. Remove control characters
    text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
    
    # 3. Remove duplicate characters
    text = re.sub(r'(.)\1{7,}', r'\1\1\1', text)
    
    # 4. Remove broken lines
    text = re.sub(r'\n{4,}', '\n\n\n', text)
    
    # 5. Remove URLs
    text = re.sub(r'http\S+|www\.\S+', '', text)
    
    # 6. Remove email addresses
    text = re.sub(r'\S+@\S+', '', text)
    
    return text

Language Detection

# Detect language - filter non-English (if needed)
from langdetect import detect

text = "Это русский текст"
lang = detect(text)  # 'ru'

# Filter:
if lang not in ['en', 'ru', 'de', 'fr']:
    skip(text)  # не хотим этот язык

# FastText language ID (быстрее):
import fasttext
model = fasttext.load_model('lid.176.bin')
lang, prob = model.predict(text)
# __label__ru, 0.99

Perplexity Filter

# Быстрая модель оценивает "качество" текста
# Низкий perplexity = хороший текст
# Высокий perplexity = мусор

from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained('gpt2')
tokenizer = AutoTokenizer.from_pretrained('gpt2')

def perplexity(text):
    inputs = tokenizer(text, return_tensors='pt')
    with torch.no_grad():
        outputs = model(**inputs, labels=inputs['input_ids'])
    loss = outputs.loss
    return torch.exp(loss)

# Примеры:
# perplexity("The cat sat on the mat") = 5.2
# perplexity("asdf qwer zxcv jkl;") = 15000
# perplexity("FREE MONEY CLICK HERE") = 500

Уровень 4: Deduplication

Почему дубликаты — это плохо?

Дубликаты в данных:
  Модель "запоминает" дубликаты
  Overfitting на конкретных примерах
  Плохая generalization
  Leak тестовых данных в training

Пример:
  Если "Как написать бинарный поиск" встречается 1000 раз
  -> Модель запомнит ответ наизусть
  -> Не научится reasoning

Exact Deduplication

# Полное удаление дубликатов
from datasketch import MinHash, MinHashLSH

lsh = MinHashLSH(threshold=0.8, num_perm=128)

# Индексируем все документы
for doc_id, doc in documents.items():
    m = MinHash(num_perm=128)
    for word in doc.split():
        m.update(word.encode('utf8'))
    lsh.insert(doc_id, m)

# Находим дубликаты
duplicates = {}
for doc_id, doc in documents.items():
    close_matches = lsh.query(m)
    if close_matches:
        duplicates[doc_id] = close_matches

# Удаляем: оставляем только один экземпляр

Fingerprint Deduplication (Llama)

# Llama использует fingerprint deduplication
# Hash of normalized whitespace

def fingerprint(text):
    # Normalize whitespace
    text = ' '.join(text.split())
    return hashlib.sha256(text.encode()).hexdigest()

# Document-level dedup:
#   hash(document) -> keep first, drop rest

# Token-level dedup (inside document):
#   128-token sliding window
#   hash(window) -> если видел, пропускаем

Уровень 5: Data Mixing

Почему смешиваем данные?

Разные типы данных = разные навыки

Wikipedia:    факты, энциклопедические знания
Books:        повествование, стиль, длинные контексты
Web:          разговорный язык, разнообразие
Code:         логика, структура, точность
Math:         reasoning, логика

Mix = балансировка разных источников

Примеры Mix'ов

Llama-3 Mix:
  85% Web text
   5% Books
   5% Code
   3% Math
   2% Dialogues

StarCoder Mix:
  70% Code
  20% Documentation
  10% Comments

RedPajama Mix:
  60% Web
  15% Books
  10% Code
  10% Wikipedia
  05% Social

Weight Balancing

# Взвешенное смешивание
# Не просто 50/50, а с весами

mix_config = {
    'wikipedia': 0.30,
    'books':    0.15,
    'web':      0.25,
    'code':     0.20,
    'math':     0.10,
}

# Sampling:
def sample_dataset(mix_config):
    weights = list(mix_config.values())
    keys = list(mix_config.keys())    return np.random.choice(keys, p=weights)

# На эпохе: перемешиваем весь датасет
# с учетом весов

Уровень 6: Filtering — что убираем

Toxicity Filter

# Убираем токсичный контент
from transformers import pipeline

classifier = pipeline("text-classification",
    model="unitary/toxic-bert")

def is_toxic(text):
    result = classifier(text)[0]
    return result[1]['toxic'] > 0.5

# Filter:
if is_toxic(text):
    skip(text)  # не хотим в training data

PII Detection

# Убираем персональные данные
import presidio_analyzer

analyzer = AnalyzerEngine()

text = "Мой телефон: +7-999-123-45-67"
results = analyzer.analyze(text, language='ru')

# results:
#   Entity: PHONE_NUMBER
#   Start: 13, End: 31
#   Score: 0.95

# Redact:
text = analyzer.redact(text, results)
# "Мой телефон: [PHONE_NUMBER]"

Quality Scoring

# Multi-factor quality score

def quality_score(text):
    score = 0
    
    # 1. Length
    if len(text) > 200: score += 1
    if len(text) > 1000: score += 1
    
    # 2. Perplexity (GPT-2)
    if perplexity(text) < 100: score += 2
    
    # 3. Language confidence
    lang, prob = detect_lang(text)
    if prob > 0.9: score += 1
    
    # 4. Readability
    if readability_score(text) > 0.5: score += 1
    
    # 5. No toxic content
    if not is_toxic(text): score += 1
    
    # Total: 0-7
    # score >= 5: keep
    # score < 3: discard
    return score

Уровень 7: Structuring и Formatting

Форматирование для обучения

# Формат для instruction tuning

# Raw data:
{"text": "Как работает градиентный спуск?"}

# Formatted:
{
    "messages": [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Как работает градиентный спуск?"},
        {"role": "assistant", "content": "Градиентный спуск - это..."}
    ]
}

# Для Chat models:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are helpful.<|eot_id|><|start_header_id|>user<|end_header_id|>
How does it work?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
It works by...<|eot_id|>

Dataset Formats

1. Raw Text:
   .txt файлы, один документ = одна строка

2. JSONL:
   {"text": "..."}
   {"text": "..."}

3. Parquet:
   columnar format, сжатие
   faster I/O для больших датасетов

4. WebDataset:
   .tar файлы
   для distributed training

Уровень 8: Pipeline — полный цикл

Полный preprocessing pipeline

Input (100 TB raw data)
    |
    v
[1] Collection  -----> 10 TB (filtered sources)
    |
    v
[2] Dedup       -----> 8 TB (duplicates removed)
    |
    v
[3] Clean       -----> 6 TB (toxic, PII removed)
    |
    v
[4] Tokenize    -----> 6 TB (token IDs)
    |
    v
[5] Quality     -----> 3 TB (low quality removed)
    |
    v
[6] Mix         -----> 3 TB (balanced mix)
    |
    v
[7] Format      -----> 3 TB (ready for training)

Пример: свой pipeline

import multiprocessing as mp

def preprocess_pipeline(raw_dir, output_dir):
    # Step 1: Collect
    raw_files = collect(raw_dir)
    print(f"Collected: {len(raw_files)} files")
    
    # Step 2: Clean (parallel)
    cleaned = mp.Pool().map(clean_text, raw_files)
    print(f"Cleaned: {len(cleaned)} documents")
    
    # Step 3: Dedup
    cleaned = minhash_dedup(cleaned)
    print(f"After dedup: {len(cleaned)} documents")
    
    # Step 4: Quality filter
    cleaned = [t for t in cleaned if quality_score(t) >= 5]
    print(f"After quality: {len(cleaned)} documents")
    
    # Step 5: Tokenize
    tokenized = [tokenizer.encode(t) for t in cleaned]
    print(f"Tokenized: {sum(len(t) for t in tokenized)} tokens")
    
    # Step 6: Save
    save(tokenized, output_dir)
    print("Done!")


Уровень 9: Metrics — как оцениваем?

Метрики качества данных

1. Coverage:
   - Сколько уникальных токенов в vocab
   - Llama-3: 128K tokens

2. Dedup rate:
   - Сколько % удалили как дубликаты
   - Llama-3: ~30% dedup

3. Quality score:
   - Средний perplexity
   - Средний quality score

4. Token efficiency:
   - Сколько % токенов — stop words
   - Цель: < 20% stop words

5. Language distribution:
   - % каждого языка
   - Цель: баланс

Dataset Cards

# Llama-3 Dataset Card

Total tokens: 15 trillion
Documents: 15 trillion / ~4 = 3.75 trillion tokens / doc

Sources:
  Web: 85%
  Books: 5%
  Code: 5%
  Math: 3%
  Dialogues: 2%

Languages: 30+ languages
Top 5: EN, DE, FR, ES, IT

Dedup:
  Document-level: yes
  Token-level: yes (128-token windows)

Quality:
  Perplexity filter: yes
  Toxicity filter: yes
  PII removal: yes

Уровень 10: Tools и фреймворки

Популярные инструменты

1. Apache Spark:
   - Distributed processing
   - 100 TB + данные
   - Spark NLP для text processing

2. Ray:
   - Distributed Python
   - Ray Data для preprocessing
   - Easy to use

3. DataBox:
   - Amazon DataBox
   - Physical data transfer
   - 100 TB per device

4. Apache Beam:
   - Unified programming model
   - Runs on Spark, Flink, etc.

5. DuckDB:
   - SQL on JSON/Parquet
   - Super fast local queries

Быстрый старт

# Минимальный pipeline за 50 строк

import duckdb
from datasketch import MinHashLSH
from transformers import pipeline

# 1. Load
con = duckdb.connect()
con.execute("""
    CREATE TABLE docs AS
    SELECT * FROM read_json_auto('data/*.json')
""")

# 2. Clean
con.execute("""
    UPDATE docs
    SET text = regexp_replace(text, '[\x00-\x1F]', '')
    WHERE length(text) > 100
""")

# 3. Dedup
docs = con.execute("SELECT * FROM docs").fetchall()
lsh = MinHashLSH(threshold=0.9)
for i, doc in enumerate(docs):
    m = MinHash()
    for word in doc[1].split():
        m.update(word.encode())
    lsh.insert(f'doc_{i}', m)

# 4. Save
clean_docs = [d for i, d in enumerate(docs)
              if i not in duplicates]
with open('cleaned.jsonl', 'w') as f:
    for doc in clean_docs:
        f.write(json.dumps(doc) + '\n')

Summary

Data Preprocessing = 80% работы над LLM

10 уровней:
  1. Collection  - откуда берем данные
  2. Tokenization - текст -> числа
  3. Cleaning    - убираем мусор
  4. Dedup       - удаляем дубликаты
  5. Mixing      - балансируем источники
  6. Filtering   - toxicity, PII, quality
  7. Structuring - формат для обучения
  8. Pipeline    - полный цикл
  9. Metrics     - как оцениваем
  10. Tools      - чем делаем

Главное правило:
  Garbage In = Garbage Out

Качество данных > Количество данных