- BrainTools - https://www.braintools.ru -

От учителя к студенту: путь к изотропным эмбеддингам в 29M модели. Часть 2-10

Часть 2. Данные: Вики и домен

Важное предупреждение:
Весь код по моей методологии написан нейросетью DeepSeek, сам бы не осилил (я не программист).
За мной лишь отладка, исправление неточностей и ошибок.

2.1. Зачем нужен общий корпус

Для дистилляции нам нужны два вида данных:

  1. Общий корпус — чтобы студент выучил широкий семантический охват. Без него модель переобучится под узкий домен и будет плохо работать на произвольных текстах.

  2. Доменный корпус (психология) — чтобы модель понимала специфику целевой области и хорошо работала на моих реальных задачах.

Итоговый корпус для обучения [1] формируем из двух источников:

  • Википедия (ruwiki) — разнообразная тематика, качественные тексты, большой объём.

  • Психологический корпус — узкий домен, но нужный для целевой задачи.

Итоговая пропорция: берём ~200K wiki и ~50K psycho. После очистки, удаления дубликатов и фильтрации коротких обрывков на выходе получается ~243.4К текста. Больше — избыточно (см. обсуждение субсэмплинга ниже).

2.2. Какой дамп Вики брать

Стандартный дамп: ruwiki-latest-pages-articles.xml.bz [2]2 с https://dumps.wikimedia.org/ruwiki/latest/ [3].

Проблема: полный архив весит ~5.5 ГБ, распакованный — ещё больше. Для Colab (лимит диска) и для CPU-парсинга это избыточно.

Решение — multistream-версия. Тот же дамп, но разбитый на сотни мелких файлов. Можно скачать только первые несколько частей, этого хватит с головой.

Имена файлов: ruwiki-latest-pages-articles-multistream1.xml-p1pXXX.bz [4]2 (номер части и диапазон страниц в имени). В той же папке latest на сайте дампов.

Сколько качать: для 250K текстов нужно не больше 100-200 МБ чистого текста. Это 1 часть multistream-дампа.

В нашем случае: скачали ruwiki-latest-pages-articles-multistream1.xml-p1p224167.bz [5]2, ~380 МБ. Из неё получили ~1.2M абзацев — этого хватило с большим запасом.

2.3. Парсинг через WikiExtractor

WikiExtractor от attardi — стандарт де-факто для извлечения чистого текста из дампов Википедии. Написан на Python, работает через CLI.

Установка и запуск:

git clone https://github.com/attardi/wikiextractor.git
cd wikiextractor
python3 -m wikiextractor.WikiExtractor 
    ../data/ruwiki-latest-pages-articles-multistream1.xml-p1p224167.bz2 
    -o ../data/wiki/ 
    --no-templates 
    --processes 4

cd ../

Разбор флагов:

  • -o ../data/wiki/ — папка для результата.

  • --no-templates — не включать вики-шаблоны (нам они не нужны, только шум).

  • --processes 4 — параллелизм. На CPU можно поставить 4-8 потоков (если позволяет архитектура).

  • Путь к дампу — позиционный аргумент.

Что на выходе: папка с .txt файлами. Каждая статья обёрнута в тег <doc id="..." url="..." title="...">...</doc>. Внутри — чистый текст с сохранённой пунктуацией и регистром.

Скорость: на нашем железе — ~64 секунды на ~76K статей (1185 статей/сек). Быстро.

Пример вывода:

<doc id="7" url="https://ru.wikipedia.org/wiki?curid=7" title="Литва">
Литва

Литва́ (), официальное название — Лито́вская Респу́блика () — государство,
расположенное в Северной Европе. Площадь — км². Протяжённость с севера
на юг — 280 км, а с запада на восток — 370 км. Население составляет
человек (октябрь, 2024)...
</doc>

2.4. Очистка вывода WikiExtractor

WikiExtractor оставил три вида мусора, которые надо вычистить:

  1. Заголовки секций вида == История ==, === Подраздел ===.

  2. Пустые скобки () — следы удалённых шаблонов ({{lang|...}}, {{formatnum}}).

  3. Строка-заголовок статьи внутри <doc> — дублирует атрибут title.

Плюс нужно распарсить обёртку <doc>...</doc> и разбить по абзацам (1 абзац = 1 строка).

Файл: scripts/2.1_parse_wiki.py

"""Парсинг вывода WikiExtractor -> чистые абзацы."""
import re
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
DATA.mkdir(parents=True, exist_ok=True)

INPUT_DIR = DATA / "wiki"
OUTPUT = DATA / "wiki_clean.txt"

# Минимальная длина абзаца (символов). Короче — мусор (подобрано эмпирически).
MIN_LEN = 40


def extract_docs(text: str):
    """Вытащить все <doc>...</doc> блоки."""
    return re.findall(r"<doc[^>]*>(.*?)</doc>", text, re.DOTALL)


def clean_doc(doc: str):
    # Убираем первую непустую строку — это заголовок статьи (дублирует title)
    lines = doc.split("n")
    for i, line in enumerate(lines):
        if line.strip():
            lines = lines[i + 1:]
            break
    doc = "n".join(lines)

    # Секции: == Заголовок ==, === Заголовок ===
    doc = re.sub(r"={2,}s*[^=n]+?s*={2,}", "", doc)

    # Пустые скобки от удалённых шаблонов
    doc = re.sub(r"(s*)", "", doc)

    # Схлопываем пробелы и табы
    # переводы строк не трогаем — по ним разбиваем на абзацы
    doc = re.sub(r"[ t]+", " ", doc)

    # Разбиваем по одиночным переводам строки (WikiExtractor: 1 абзац = 1 строка)
    paragraphs = []
    for p in doc.split("n"):
        p = p.strip()
        if len(p) >= MIN_LEN:
            paragraphs.append(p)
    return paragraphs


def main():
    all_paragraphs = []
    for path in sorted(INPUT_DIR.rglob("*")):
        if not path.is_file():
            continue
        text = path.read_text(encoding="utf-8")
        for doc in extract_docs(text):
            all_paragraphs.extend(clean_doc(doc))

    print(f"Извлечено абзацев: {len(all_paragraphs):,}")
    # на 1.2M абзацев это ~800 MB в памяти — если RAM мало, пишите потоково
    OUTPUT.write_text("n".join(all_paragraphs), encoding="utf-8")
    print(f"Записано: {OUTPUT} ({OUTPUT.stat().st_size / 1e6:.1f} MB)")


if __name__ == "__main__":
    main()

Ключевые моменты:

  • Регулярка r"<doc[^>]*>(.*?)</doc>" с флагом DOTALL — извлекает содержимое всех <doc> блоков.

  • Первая непустая строка (заголовок статьи) удаляется.

  • Секции (== ... ==) вырезаются.

  • Пустые скобки удаляются.

  • Фильтр MIN_LEN = 40 — отсекает короткие обрывки.

  • Все абзацы объединяются в один файл wiki_clean.txt.

Ожидаемый вывод (для нашего дампа):

Извлечено абзацев: 1,241,815
Записано: data/wiki_clean.txt (835.9 MB)

Важно: удалённые шаблоны {{formatnum}} оставляют “дыры” в тексте — Площадь — км² вместо Площадь — 65 300 км². Для семантики это некритично: точные цифры часто искажаются при сабтокен-токенизации, а для эмбеддингов важнее общий контекст предложения, а не конкретные числа.

2.5. Психологический корпус

Источник: книги по психологии, психотерапии, психиатрии, плюс тексты по математической статистике для психологии, анатомии и физиологии в контексте домена.

Собираем корпус из исходных .md файлов с сохранением пунктуации и регистра. Один абзац — одна строка.

Формат: .txt файл, где каждая строка — отдельный абзац из книги. Без пустых строк.

Пример после сборки:

С помощью импринтинга мозга и научения сознание человека настраивается на оптимальное выживание в физическом мире. Выдающийся нейролог ХХ века, гарвардский д-р психологии Тимоти Лири выделил семь типов импринтов (в дальнейшем идею этих импринтов подхватил и развил д-р Роберт Антон Уилсон).
Лири считает, что большим скачком в истории человеческого знания стало открытие последовательного импринтирования нервной системы и выявление следствий двухполушарной асимметрии коры головного мозга.
Человеческая нервная система последовательно проходит семь стадий эволюционного развития. На каждой стадии появляет­ся новый контур нервной системы. Вот эти семь контуров по Т. Лири:

Статистика после сборки:

  • 382,327 строк (абзацев).

  • ~18,9M слов.

Процесс сборки из .md несложен (на чистых регулярных выражениях). Папка raw_texts/ в проекте отсутствует — это личная коллекция книг по домену, не для распространения в сети.

Файл: scripts/2.2_psycho_corpus.py

#!/usr/bin/env python3
"""
Скрипт для подготовки обучающего корпуса.
Читает все .txt и .md файлы из папки raw_texts/ (рекурсивно),
очищает текст от мусора, сохраняет естественные границы абзацев.
Результат сохраняет в psycho_clean.txt.
raw_texts/ — папка с исходными .md (вне data/, в git не входит)
"""
import os
import re
from pathlib import Path

def clean_text(text: str) -> str:
    """
    Очистка текста для обучения семантических эмбеддингов
    """
    # 1. Удаление markdown-разметки
    text = re.sub(r'```[sS]*?```', ' ', text)  # блоки кода
    text = re.sub(r'`[^`]*`', ' ', text)         # инлайн код
    text = re.sub(r'^#+s*', '', text, flags=re.MULTILINE)  # заголовки (#)
    text = re.sub(r'**([^*]+)**', r'1', text) # жирный шрифт
    text = re.sub(r'*([^*]+)*', r'1', text)     # курсив
    text = re.sub(r'^>s*', '', text, flags=re.MULTILINE)  # цитаты (>)
    text = re.sub(r'^[-*+]s+', '', text, flags=re.MULTILINE)  # маркеры списков
    
    # 2. Удаление HTML-тегов
    text = re.sub(r'<[^>]*>', ' ', text)
    
    # 3. Убираем висячий дефис в конце слова, если после него пробел или конец строки
    text = re.sub(r'([а-яёa-z0-9])-(?=s|$)', r'1', text)
    
    # 4. Очистка пробелов и пустых строк
    lines = text.split('n')
    cleaned_lines = []
    for line in lines:
        # Схлопываем множественные пробелы в один и убираем пробелы по краям
        line = re.sub(r's+', ' ', line).strip()
        if line:
            cleaned_lines.append(line)
    
    return 'n'.join(cleaned_lines)

def process_file(filepath: str) -> list:
    """
    Читает файл, очищает и возвращает список строк (абзацев).
    Отсекает слишком короткие строки, которые являются мусором или заголовками без контекста.
    """
    with open(filepath, 'r', encoding='utf-8') as f:
        raw = f.read()
    
    cleaned = clean_text(raw)
    lines = cleaned.split('n')
    
    filtered_lines = []
    for line in lines:
        words = line.split()
        # Оставляем строки, где есть хотя бы 10 слов. 
        # Это отсекает номера страниц, колонтитулы и одиночные заголовки, 
        # но сохраняет короткие смысловые абзацы.
        if len(words) >= 10:
            filtered_lines.append(line)
    
    return filtered_lines

def main():
    input_dir = 'raw_texts'
    if not os.path.isdir(input_dir):
        print(f"Ошибка: Папка '{input_dir}' не найдена.")
        return

    all_lines = []
    total_files = 0
    
    print("Начинаю обработку файлов...")
    for root, dirs, files in os.walk(input_dir):
        for fname in files:
            if fname.endswith('.txt') or fname.endswith('.md'):
                full_path = os.path.join(root, fname)
                lines = process_file(full_path)
                all_lines.extend(lines)
                total_files += 1

    # Сохраняем итоговый корпус
    output_file = 'psycho_clean.txt'
    with open(output_file, 'w', encoding='utf-8') as f:
        for line in all_lines:
            f.write(line + 'n')

    # Статистика
    total_words = sum(len(line.split()) for line in all_lines)
    print("-" * 40)
    print(f"Обработка завершена!")
    print(f"Обработано файлов: {total_files}")
    print(f"Всего строк (абзацев) для обучения: {len(all_lines)}")
    print(f"Примерное общее количество слов: {total_words:,}")
    print(f"Результат сохранен в: {output_file}")
    print(f"Размер: ({Path(output_file).stat().st_size / 1e6:.1f} MB)")

if __name__ == '__main__':
    main()

Пример вывода скрипта:

Начинаю обработку файлов...
----------------------------------------
Обработка завершена!
Обработано файлов: 587
Всего строк (абзацев) для обучения: 382327
Примерное общее количество слов: 18,897,900
Результат сохранен в: psycho_clean.txt
Размер: (258.4 MB)

2.6. Сборка корпуса и субсэмплинг

Проблема: для 3-слойного студента на T4 1.33M примеров избыточно:

  • Одна эпоха на GPU T4 с batch_size=64 — это ~15–20 минут. При 3–10 эпохах — несколько часов Colab.

  • Учителю нужно сгенерировать эмбеддинги для всех 1.33M текстов — это главная статья расходов. Даже на GPU — десятки минут.

Решение: соберём полный отфильтрованный корпус (corpus.txt — страховка, если понадобится добрать данные), а train.txt / val.txt сразу сформируем субсэмплингом: 200K wiki + 50K psycho. После дедупликации останется ~248K — этого хватит для первого цикла.

Файл: scripts/2.3_build_corpus.py

"""Сборка корпуса: фильтрация, субсэмплинг, дедупликация, train/val split.

Читает data/wiki_clean.txt и data/psycho_clean.txt.
Сохраняет:
- data/corpus.txt        — полный отфильтрованный корпус (страховка)
- data/train.txt         — ~243K текстов для обучения
- data/val.txt           — ~5K текстов для оценки
- data/domain_indices.npy — индексы psycho-строк в train.txt
- data/meta/2.3_build_corpus_meta.json — параметры и статистика
"""
import json
import random
import time
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
DATA.mkdir(parents=True, exist_ok=True)

WIKI = DATA / "wiki_clean.txt"
PSYCHO = DATA / "psycho_clean.txt"
CORPUS = DATA / "corpus.txt"
TRAIN = DATA / "train.txt"
VAL = DATA / "val.txt"
DOMAIN_IDX = DATA / "domain_indices.npy"
META_DIR = DATA / "meta"
META = META_DIR / "2.3_build_corpus_meta.json"

N_WIKI = 200_000
N_PSYCHO = 50_000
VAL_RATIO = 0.02
SEED = 42

MIN_CHARS = 60
MAX_CHARS = 800
MIN_ALPHA_RATIO = 0.55


def read_lines(path: Path):
    return [l.strip() for l in path.read_text(encoding="utf-8").split("n") if l.strip()]


def filter_chunks(chunks):
    out = []
    for c in chunks:
        if not (MIN_CHARS <= len(c) <= MAX_CHARS):
            continue
        alpha = sum(ch.isalpha() for ch in c)
        if alpha / len(c) < MIN_ALPHA_RATIO:
            continue
        out.append(c)
    return out


def subsample(lines, n, seed):
    rng = random.Random(seed)
    shuffled = lines.copy()
    rng.shuffle(shuffled)
    return shuffled[:n]


def dedup_with_src(pairs):
    """Дедуп точных совпадений с сохранением источника.

    При совпадении wiki и psycho — приоритет psycho (домен важнее).
    """
    # psycho первыми — при совпадении останется psycho
    pairs = sorted(pairs, key=lambda x: 0 if x[1] == "psycho" else 1)
    seen = set()
    out = []
    for t, src in pairs:
        if t not in seen:
            seen.add(t)
            out.append((t, src))
    return out


def main():
    t_start = time.time()
    META_DIR.mkdir(parents=True, exist_ok=True)

    # --- фильтрация ---
    wiki = filter_chunks(read_lines(WIKI))
    psycho = filter_chunks(read_lines(PSYCHO))
    print(f"wiki:   {len(wiki):,} после фильтра")
    print(f"psycho: {len(psycho):,} после фильтра")

    # --- полный корпус (страховка) ---
    full = list(set(wiki + psycho))
    CORPUS.write_text("n".join(full), encoding="utf-8")
    print(f"corpus: {len(full):,} уникальных, {CORPUS.stat().st_size / 1e6:.1f} MB")

    # --- субсэмплинг ---
    wiki_s = subsample(wiki, N_WIKI, SEED)
    psycho_s = subsample(psycho, N_PSYCHO, SEED + 1)

    # --- дедуп с сохранением источника ---
    pairs = [(t, "wiki") for t in wiki_s] + [(t, "psycho") for t in psycho_s]
    unique = dedup_with_src(pairs)
    random.Random(SEED + 2).shuffle(unique)
    n_psycho_after_dedup = sum(1 for _, src in unique if src == "psycho")
    print(f"sampled: {len(unique):,} уникальных (psycho: {n_psycho_after_dedup:,})")

    # --- train/val split ---
    n_val = int(len(unique) * VAL_RATIO)
    val_pairs = unique[:n_val]
    train_pairs = unique[n_val:]

    train_texts = [t for t, _ in train_pairs]
    val_texts = [t for t, _ in val_pairs]

    # --- domain_indices: индексы psycho-строк в train.txt ---
    domain_indices = np.array(
        [i for i, (_, src) in enumerate(train_pairs) if src == "psycho"],
        dtype=np.int64,
    )

    # --- сохранение ---
    TRAIN.write_text("n".join(train_texts), encoding="utf-8")
    VAL.write_text("n".join(val_texts), encoding="utf-8")
    np.save(DOMAIN_IDX, domain_indices)

    # --- meta ---
    elapsed = time.time() - t_start
    meta = {
        "script": "2.3_build_corpus",
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "params": {
            "N_WIKI": N_WIKI, "N_PSYCHO": N_PSYCHO,
            "VAL_RATIO": VAL_RATIO, "SEED": SEED,
            "MIN_CHARS": MIN_CHARS, "MAX_CHARS": MAX_CHARS,
            "MIN_ALPHA_RATIO": MIN_ALPHA_RATIO,
        },
        "counts": {
            "wiki_after_filter": len(wiki),
            "psycho_after_filter": len(psycho),
            "corpus_unique": len(full),
            "sampled_unique": len(unique),
            "psycho_after_dedup": n_psycho_after_dedup,
            "train": len(train_texts),
            "val": len(val_texts),
            "domain_in_train": len(domain_indices),
        },
        "files": {
            "corpus.txt": round(CORPUS.stat().st_size / 1e6, 1),
            "train.txt": round(TRAIN.stat().st_size / 1e6, 1),
            "val.txt": round(VAL.stat().st_size / 1e6, 1),
            "domain_indices.npy": round(DOMAIN_IDX.stat().st_size / 1e6, 3),
        },
        "elapsed_sec": round(elapsed, 1),
    }
    META.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")

    print(f"ntrain:  {len(train_texts):,}  ({TRAIN.stat().st_size / 1e6:.1f} MB)")
    print(f"val:    {len(val_texts):,}")
    print(f"domain: {len(domain_indices):,} индексов в train.txt")
    print(f"meta:   {META}  ({elapsed:.1f}s)")


if __name__ == "__main__":
    main()

Что делает скрипт:

  1. Фильтрует оба корпуса: MIN_CHARS=60 / MAX_CHARS=800 / MIN_ALPHA_RATIO=0.55 — отсекает обрывки, куски таблиц, формулы.

  2. Сохраняет полный corpus.txt — страховка, если понадобится добрать данных (перезапустить с другими квотами).

  3. Субсэмплирует по квотам: 200K wiki + 50K psycho, с фиксированным seed. subsample() копирует список перед shuffle, так что исходные списки wiki и psycho не мутируются (не изменяются).

  4. Дедуплицирует после смешивания через set — wiki и psycho могут пересекаться, а также внутри квот могут быть повторы.

  5. Делит на train/val (2% на валидацию).

Ожидаемый вывод:

wiki:   1,056,981 после фильтра
psycho: 330,714 после фильтра
corpus: 1,359,942 уникальных, 753.8 MB
sampled: 248,379 уникальных (psycho: 49,484)
train:  243,412  (135.2 MB)
val:    4,967
domain: 48,477 индексов в train.txt
meta:   data/meta/2.3_build_corpus_meta.json  (28.0s)

Проверка:

import numpy as np
idx = np.load("data/domain_indices.npy")
print(idx.shape, idx[:5], idx[-5:])
# должны быть убывающие/возрастающие? нет, любые — главное, что это индексы в train.txt
train = open("data/train.txt").read().split("n")
print(train[idx[0]][:80])  # психо-текст

Полный corpus.txt 718.9 MB остаётся “страховкой”: если понадобится добрать данных или пересэмплировать с другими квотами — не надо заново парсить Википедию.

2.7. Итог

После этого шага у нас есть:

Файл

Размер

Что это

data/wiki_clean.txt

835.9 MB

Сырой вики-корпус (промежуточный)

data/psycho_clean.txt

258.4 MB

Сырой психо-корпус (промежуточный)

data/corpus.txt

753.8 MB

Полный отфильтрованный корпус (страховка)

data/train.txt

135.2 MB

243,412 текстов для обучения

data/val.txt

2.8 MB

4,967 текстов для оценки

domain_indices.npy

0.388 MB

индексный массив позиций доменных текстов внутри train.txt

Автор: AntonovPsy

Источник [6]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37770

URLs in this post:

[1] обучения: http://www.braintools.ru/article/5125

[2] ruwiki-latest-pages-articles.xml.bz: http://ruwiki-latest-pages-articles.xml.bz

[3] https://dumps.wikimedia.org/ruwiki/latest/: https://dumps.wikimedia.org/ruwiki/latest/

[4] ruwiki-latest-pages-articles-multistream1.xml-p1pXXX.bz: http://ruwiki-latest-pages-articles-multistream1.xml-p1pXXX.bz

[5] ruwiki-latest-pages-articles-multistream1.xml-p1p224167.bz: http://ruwiki-latest-pages-articles-multistream1.xml-p1p224167.bz

[6] Источник: https://habr.com/ru/articles/1100878/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1100878

www.BrainTools.ru

Rambler's Top100