Часть 2. Данные: Вики и домен
Важное предупреждение:
Весь код по моей методологии написан нейросетью DeepSeek, сам бы не осилил (я не программист).
За мной лишь отладка, исправление неточностей и ошибок.
2.1. Зачем нужен общий корпус
Для дистилляции нам нужны два вида данных:
-
Общий корпус — чтобы студент выучил широкий семантический охват. Без него модель переобучится под узкий домен и будет плохо работать на произвольных текстах.
-
Доменный корпус (психология) — чтобы модель понимала специфику целевой области и хорошо работала на моих реальных задачах.
Итоговый корпус для обучения формируем из двух источников:
-
Википедия (ruwiki) — разнообразная тематика, качественные тексты, большой объём.
-
Психологический корпус — узкий домен, но нужный для целевой задачи.
Итоговая пропорция: берём ~200K wiki и ~50K psycho. После очистки, удаления дубликатов и фильтрации коротких обрывков на выходе получается ~243.4К текста. Больше — избыточно (см. обсуждение субсэмплинга ниже).
2.2. Какой дамп Вики брать
Стандартный дамп: ruwiki-latest-pages-articles.xml.bz2 с https://dumps.wikimedia.org/ruwiki/latest/.
Проблема: полный архив весит ~5.5 ГБ, распакованный — ещё больше. Для Colab (лимит диска) и для CPU-парсинга это избыточно.
Решение — multistream-версия. Тот же дамп, но разбитый на сотни мелких файлов. Можно скачать только первые несколько частей, этого хватит с головой.
Имена файлов: ruwiki-latest-pages-articles-multistream1.xml-p1pXXX.bz2 (номер части и диапазон страниц в имени). В той же папке latest на сайте дампов.
Сколько качать: для 250K текстов нужно не больше 100-200 МБ чистого текста. Это 1 часть multistream-дампа.
В нашем случае: скачали ruwiki-latest-pages-articles-multistream1.xml-p1p224167.bz2, ~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 оставил три вида мусора, которые надо вычистить:
-
Заголовки секций вида
== История ==,=== Подраздел ===. -
Пустые скобки
()— следы удалённых шаблонов ({{lang|...}},{{formatnum}}). -
Строка-заголовок статьи внутри
<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()
Что делает скрипт:
-
Фильтрует оба корпуса:
MIN_CHARS=60/MAX_CHARS=800/MIN_ALPHA_RATIO=0.55— отсекает обрывки, куски таблиц, формулы. -
Сохраняет полный
corpus.txt— страховка, если понадобится добрать данных (перезапустить с другими квотами). -
Субсэмплирует по квотам: 200K wiki + 50K psycho, с фиксированным seed.
subsample()копирует список передshuffle, так что исходные спискиwikiиpsychoне мутируются (не изменяются). -
Дедуплицирует после смешивания через
set— wiki и psycho могут пересекаться, а также внутри квот могут быть повторы. -
Делит на 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. Итог
После этого шага у нас есть:
|
Файл |
Размер |
Что это |
|---|---|---|
|
|
835.9 MB |
Сырой вики-корпус (промежуточный) |
|
|
258.4 MB |
Сырой психо-корпус (промежуточный) |
|
|
753.8 MB |
Полный отфильтрованный корпус (страховка) |
|
|
135.2 MB |
243,412 текстов для обучения |
|
|
2.8 MB |
4,967 текстов для оценки |
|
|
0.388 MB |
индексный массив позиций доменных текстов внутри train.txt |
Автор: AntonovPsy
- Запись добавлена: 30.10.2026 в 08:01
- Оставлено в


