Первичная версия: стилометрия реплик, LSA и протокол экспериментов

This commit is contained in:
2026-10-24 01:17:36 +03:00
commit c390224500
52 changed files with 10099 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
.idea/
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.DS_Store
lab1/nltk_data/
lab1/data/processed/
lab1/features/
lab1/exports/
lab1/results/*
!lab1/results/README.md
*.tar.gz
*.tar.gz.sha256

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# NLP: стилометрия реплик Stardew Valley
Репозиторий лабораторной работы по автоматическому анализу текстов.
Исследуется, можно ли стилометрически различить 34 персонажа Stardew Valley
по отдельным репликам, если все реплики написаны одним автором.
## Быстрый старт
```bash
cd lab1
cat RUNBOOK.md
```
Пошаговая инструкция для запуска полной серии экспериментов:
[`lab1/RUNBOOK.md`](lab1/RUNBOOK.md).
Краткая установка:
```bash
cd lab1
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python scripts/download_nltk_data.py
python scripts/check_environment.py
python -m pytest -q
bash scripts/run_all_experiments.sh
```
## Структура
```text
nlp/
├── lab1/
│ ├── stylometry/ # код: чтение, признаки, LSA, модели, метрики
│ ├── tests/ # тесты
│ ├── data/ # корпус и метаданные
│ ├── configs/ # конфигурация
│ ├── scripts/ # проверка окружения, запуск серии, упаковка
│ ├── RUNBOOK.md # инструкция для запуска
│ ├── EXPERIMENTS.md # протокол экспериментов
│ ├── FEATURES.md # описание признаков
│ ├── METRICS_AND_CLASSIFIERS.md
│ └── PRESENTATION_OUTLINE.md
└── .venv/ # локальное окружение, не коммитится
```
## Основные документы
- [`lab1/RUNBOOK.md`](lab1/RUNBOOK.md) — как запускать всё с нуля.
- [`lab1/EXPERIMENTS.md`](lab1/EXPERIMENTS.md) — протокол экспериментов.
- [`lab1/METRICS_AND_CLASSIFIERS.md`](lab1/METRICS_AND_CLASSIFIERS.md) —
методическое описание метрик и классификаторов.
- [`lab1/FEATURES.md`](lab1/FEATURES.md) — признаки и формулы.
- [`lab1/PRESENTATION_OUTLINE.md`](lab1/PRESENTATION_OUTLINE.md) — структура
презентации.

11
lab1/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.idea/
nltk_data/
data/processed/
features/
results/*
!results/README.md
exports/

747
lab1/EXPERIMENTS.md Normal file
View File

@@ -0,0 +1,747 @@
# Протокол экспериментов
Документ описывает постановку, признаки, схему валидации, метрики и порядок
запуска экспериментов. Он рассчитан на следующего исследователя, который будет
проводить финальные серии опытов и готовить отчёт.
Методические пояснения к метрикам и классификаторам вынесены в
`METRICS_AND_CLASSIFIERS.md`. Здесь — практическая часть: что запускать, в
каком порядке и как интерпретировать результаты.
---
## 1. Исследовательский вопрос
**Вопрос.** Различимы ли 34 персонажа Stardew Valley по стилометрическим
признакам отдельных реплик, если все реплики написаны одним автором?
Это задача **классификации речевых масок персонажей**, а не авторской
атрибуции. Автор один, но персонажи наделены разной лексикой, синтаксисом,
междометиями, длиной реплик и пунктуацией. Положительный результат означает,
что созданные автором речевые маски статистически различимы; он не означает,
что у персонажей разные авторы.
### Гипотезы
| Обозначение | Гипотеза | Как проверяем |
|---|---|---|
| H1 | Функциональные слова, POS-признаки и символьные n-граммы различают персонажей выше случайного уровня | E2, E3, E4 |
| H2 | На коротких репликах POS- и char-признаки устойчивее словарного богатства и читаемости | E3, E9 |
| H3 | LSA по всем словам отражает тему, а LSA по POS и функциональным словам — стиль | E7 |
| H4 | Более длинные реплики классифицируются лучше | E9 |
| H5 | Ошибки группируются вокруг персонажей со сходным возрастом, полом, ролью или «человечностью» | E14 |
Случайный уровень для 34 классов: доля правильных ответов около 2,9%,
macro-F1 около 2,9%, top-3 около 8,8%. Любые результаты нужно сравнивать с
этими значениями, а не с нулём.
---
## 2. Данные
### 2.1. Текущий корпус
| Параметр | Значение |
|---|---|
| Файл | `data/dialogues.csv` |
| Кодировка | cp1251 |
| Реплик | 2005 |
| Персонажей | 34 |
| Словоупотреблений | около 38,2 тыс. |
| Средняя длина реплики | 19,1 слова |
| Медиана | 17 слов |
| Минимум / максимум | 3 / 78 слов |
| Классы | 15–139 реплик |
| Точные дубли реплик | 0 |
Корпус уже прошёл ручную фильтрацию: удалены короткие и общие фразы. Точные
правила очистки не сохранились, поэтому корпус используется как есть. Это
ограничение нужно указать в отчёте: восстановить исходное распределение
коротких реплик нельзя.
Редкие классы: Mister Qi (15), Sandy (21), Dwarf (23), Wizard (24),
Vincent (26), Krobus (28). При 5-блочной кросс-валидации у Mister Qi в каждом
тестовом блоке около 3 реплик; метрики по нему будут иметь широкий
доверительный интервал.
### 2.2. Аудит
```bash
cd lab1
python -m stylometry.cli audit
```
Результаты:
- `data/processed/lines.csv` — нормализованные реплики;
- `data/processed/audit.md` — отчёт с распределением классов, длин и сверкой
с `stats_per_character.xlsx`;
- `data/processed/figures/class_distribution.png` — график.
---
## 3. Предобработка
Корпус не перечищается. Выполняются только обратимые и не меняющие смысл
операции:
1. Приведение Unicode: многоточие, кавычки, тире, неразрывные пробелы.
2. Замена плейсхолдеров `<PLAYER>`, `<FARM>`, `<PET>`, `<SPOUSE>`, `<TIME>`,
`<NOUN>`, `<NAME>` на слова `PLAYER`, `FARM` и т.д. Количество плейсхолдеров
сохраняется отдельным признаком.
3. Схлопывание повторных пробелов.
4. Токенизация NLTK: `word_tokenize`.
5. Разбиение на предложения: `sent_tokenize`.
6. Частеречная разметка: `pos_tag`; перевод тегов Penn Treebank в Universal
POS.
7. Для POS-последовательностей подряд идущие знаки препинания схлопываются в
один тег `PUNCT`.
Не выполняется:
- удаление стоп-слов (они являются стилометрическим сигналом);
- удаление пунктуации;
- лемматизация по умолчанию (инфлексия — тоже стиль);
- удаление коротких реплик;
- приведение регистра к нижнему для всех признаков.
### 3.1. Ресурсы NLTK
```bash
cd lab1
python scripts/download_nltk_data.py
```
Скачиваются `punkt`, `punkt_tab`, `averaged_perceptron_tagger`,
`averaged_perceptron_tagger_eng`, `stopwords` в `lab1/nltk_data`. Если ресурсы
недоступны, токенизатор переходит в резервный режим на регулярных выражениях,
а POS-признаки отключаются. Для финальных экспериментов нужен NLTK-режим:
проверьте, что в метаданных запуска `pos_available=true`.
---
## 4. Признаковое пространство
### 4.1. Ручные стилометрические признаки
Всего около 454 признаков. Они не обучаются на данных, поэтому могут быть
посчитаны заранее.
| Группа | Примеры | Статус |
|---|---|---|
| Длина | число токенов, слов, символов, предложений; средняя длина предложения; доля длинных слов | Основная, но чувствительна к длине |
| Лексика | распределение длин слов, доля слов с апострофом, заглавных, цифр | Основная |
| Словарное богатство | TTR, Guiraud, Herdan, Yule's K, Simpson's D, hapax/dis, Honoré | Предварительный |
| Символы | частоты букв a–z, доля заглавных, цифр, пунктуации, апострофов | Основная |
| Пунктуация | запятые, точки, восклицательные, вопросительные, многоточия, повторная пунктуация | Основная |
| Функциональные слова | около 150 закрытых слов и 12 категорий | Основная |
| Местоимения | 1-е, 2-е, 3-е лицо, возвратные, притяжательные, отношение I/you | Основная |
| Части речи | Universal POS, Penn Treebank, модальность, время, герундий, причастие | Основная |
| Синтаксис-прокси | число клауз, подчинительные союзы, вопрос/восклицание, пассив-прокси | Основная |
| Читаемость | Flesch, FK grade, Gunning Fog, SMOG, Coleman-Liau, ARI | Предварительный |
| Разговорные маркеры | плейсхолдеры, ALL CAPS, многоточия, междометия, смех, дискурсивные маркеры, хеджи, интенсификаторы, вежливость, отрицания, сокращения | Основная |
Признаки `vocabulary` и `readability` помечены как предварительные: на репликах
длиной 10–20 слов они нестабильны. Их влияние нужно проверять отдельно, а не
включать в основной вывод без оговорок.
### 4.2. Символьные n-граммы
TF-IDF по `char_wb`, диапазон 2–5, `min_df=3`, до 5000 признаков, L2-норма.
Хорошо отражают морфологию, пунктуацию и орфографические привычки.
### 4.3. POS n-граммы
TF-IDF по последовательностям Universal POS, диапазон 1–3, `min_df=3`, до 500
признаков, L2-норма. Это относительно устойчивый к теме синтаксический сигнал.
### 4.4. Словарь признаков
```bash
python -m stylometry.cli features
```
Создаёт:
- `features/stylometric.csv` — матрица 2005 × 454;
- `features/feature_dictionary.csv` — название, группа, описание, статус.
Словарь нужно использовать в отчёте: для каждой группы указать, какие
признаки и почему выбраны.
---
## 5. Латентно-семантический анализ
LSA реализован как:
```text
TF-IDF -> TruncatedSVD(k) -> L2-нормализация
```
Варианты:
| Вариант | Колонка | n-граммы | Что отражает |
|---|---|---|---|
| `word` | `text` | 1 | тему и стиль |
| `word2` | `text` | 1–2 | тему и устойчивые сочетания |
| `content` | `content_text` | 1–2 | содержание после удаления стоп-слов |
| `fw` | `fw_text` | 1–2 | синтаксис через функциональные слова |
| `char` | `text` | 2–5 (символы) | морфологию и пунктуацию |
| `pos` | `pos_text` | 1–4 (POS) | синтаксический стиль |
Основные параметры: `sublinear_tf=true`, `min_df=3`, `max_df=0.9`,
`max_features=2000–8000`, `n_components=50–300`. Все параметры настраиваются
только на обучающих блоках; для интерпретационных графиков можно обучить LSA
на всём корпусе, но это нельзя использовать для оценки качества.
### 5.1. Что проверять в LSA
1. **График объяснённой дисперсии и накопленная дисперсия.** Сколько
компонент объясняют основную часть дисперсии.
2. **Top-термины компонент.** Интерпретируемость осей: тема, эмоциональность,
формальность, длина.
3. **Внешняя оценка.** Macro-F1 классификатора при разных `k`.
4. **Сравнение вариантов.** Если `lsa_word` и `lsa_content` значительно лучше
`lsa_pos` и `lsa_fw`, классификация в существенной степени опирается на
тему, а не на стиль.
5. **Стабильность.** Bootstrap по репликам и углы между подпространствами
(опционально; при малом числе компонент полезно).
### 5.2. Команды
```bash
python -m stylometry.cli lsa --variant word --components 100
python -m stylometry.cli lsa --variant pos --components 50
python -m stylometry.cli lsa --variant char --components 50
python -m stylometry.cli lsa-sweep --variants word,content,fw,char,pos \
--components 50,100,200 --model logreg --repeats 1
```
Результаты: матрицы `features/lsa_<variant>_k<k>.csv`, наиболее
информативные термины компонент, объяснённая дисперсия, график объяснённой
дисперсии.
---
## 6. Классификаторы
В основном сравнении участвуют:
| Модель | Краткая характеристика | Вероятности |
|---|---|---|
| `dummy` | предсказывает самый частый класс | да |
| `multinomial_nb` | наивный байесовский классификатор для неотрицательных признаков | да |
| `logreg` | логистическая регрессия с L2 и балансировкой классов | да |
| `linear_svc` | линейный метод опорных векторов | нет, есть решающая функция |
| `random_forest` | случайный лес | да |
| `extra_trees` | сверхслучайные деревья | да |
| `knn` | k ближайших соседей с косинусным расстоянием | да |
Для MultinomialNB автоматически исключаются LSA-блоки (компоненты могут быть
отрицательными) и используется MinMax-масштабирование. Для остальных моделей
ручные признаки стандартизуются и L2-нормируются, а TF-IDF- и LSA-блоки уже
нормированы. Это нужно, чтобы блоки разной размерности не подавляли друг
друга.
Подробное описание моделей, их формул, гиперпараметров и ограничений — в
`METRICS_AND_CLASSIFIERS.md`.
### 6.1. Сетки гиперпараметров
| Модель | Параметры |
|---|---|
| `logreg` | `C ∈ {0.1, 1, 10}` |
| `linear_svc` | `C ∈ {0.1, 1, 10}` |
| `random_forest`, `extra_trees` | `max_depth ∈ {None, 30}`, `min_samples_leaf ∈ {1, 2}` |
| `multinomial_nb` | `alpha ∈ {0.1, 1, 3}` |
| `knn` | `n_neighbors ∈ {3, 5, 9}` |
Настройка выполняется внутри вложенной кросс-валидации. Для одиночного запуска
`run` без `--nested` гиперпараметры фиксированы значениями по умолчанию; это
быстрее, но для финального сравнения нужен `--nested`.
---
## 7. Схема валидации
### 7.1. Основная схема
```python
RepeatedStratifiedKFold(n_splits=5, n_repeats=5, random_state=42)
```
Почему 5 блоков:
- минимальный класс — 15 реплик;
- в каждом тестовом блоке около 3 реплик на класс;
- все 34 класса присутствуют и в обучении, и в тесте;
- 10 блоков дали бы 1–2 реплики на класс и ещё более шумные оценки.
Почему повторная:
- один прогон 5-блочной CV даёт одну оценку; повторность позволяет оценить
разброс;
- для финального отчёта используйте 5 повторов; для быстрых проверок — 1–3.
### 7.2. Вложенная кросс-валидация
```python
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
inner = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
```
Внешние блоки — оценка качества; внутренние — настройка гиперпараметров.
Такой протокол уменьшает оптимистическое смещение, которое возникает, если
настраивать гиперпараметры на тех же данных, где измеряется качество.
### 7.3. Контроль утечек
- TF-IDF, SVD, масштабирование и отбор признаков обучаются только на
обучающем блоке.
- Ручные признаки и POS-теги не используют метки; их можно считать заранее.
- Точных дублей реплик в корпусе нет, поэтому одна и та же реплика не может
попасть в обучение и тест.
- При появлении почти одинаковых реплик нужно проверить `StratifiedGroupKFold`
с группами по нормализованному тексту. В текущем корпусе точных дублей нет.
- Нельзя выбирать признаки или `k` LSA по тестовым метрикам. Все решения — по
внутренней кросс-валидации.
---
## 8. Метрики
Подробные определения, формулы и интерпретация — в
`METRICS_AND_CLASSIFIERS.md`. Кратко:
| Метрика | Роль |
|---|---|
| Macro-F1 | Основная метрика: каждый из 34 классов равноправен |
| Сбалансированная доля правильных ответов | Средняя полнота по классам; удобна для редких персонажей |
| Доля правильных ответов | Нужна для сравнения с литературой |
| MCC | Устойчив к дисбалансу, учитывает все клетки матрицы ошибок |
| Cohen's kappa | Согласие с поправкой на случай |
| Top-3, top-5 | Важны при 34 классах |
| Log loss, Brier | Качество вероятностей и калибровка |
| Точность, полнота и F-мера по классам | Показывают, какие персонажи проваливаются |
| Матрица ошибок | Анализ пар перепутывания |
Основной отчётный результат — **pooled out-of-fold macro-F1** с 95%
бутстрэп-интервалом и среднее ± стандартное отклонение по повторам. Среднее
macro-F1 по отдельным блокам тоже полезно, но на класс приходится около трёх
объектов, поэтому оно шумное.
### 8.1. Статистические сравнения
- **Мак-Немар** — парное сравнение двух моделей на одних и тех же тестовых
репликах.
- **Уилкоксон** — парное сравнение результатов по блокам; описательная
проверка стабильности.
- **Фридман** — сравнение трёх и более моделей.
- **Поправка Холма—Бонферрони** — при множественных сравнениях.
- **Бутстрэп-интервалы** — основной способ показать неопределённость.
Не сравнивайте модели только по доле правильных ответов: при дисбалансе и
34 классах она может вводить в заблуждение. Пример: модель, предсказывающая
только Abigail, имеет долю правильных ответов около 7%, но macro-F1 около
0,004.
---
## 9. Матрица экспериментов
Все команды выполняются из `lab1`. Перед серией полезно один раз выполнить
`audit` и `features`.
### E0. Аудит корпуса
```bash
python -m stylometry.cli audit
```
Что проверить: число реплик и классов, распределение длин, отсутствие дублей,
совпадение с `stats_per_character.xlsx`.
### E1. Базовые уровни
```bash
python -m stylometry.cli run --preset length --model logreg --repeats 5
python -m stylometry.cli run --preset char --model multinomial_nb --repeats 3
python -m stylometry.cli run --preset pos --model multinomial_nb --repeats 3
python -m stylometry.cli run --preset fw --model logreg --repeats 3
python -m stylometry.cli run --preset length --model dummy --repeats 1
```
Смысл: понять, сколько сигнала даёт только длина, только символьные n-граммы,
только POS, только функциональные слова и случайная/частотная базовая модель.
### E2. Классическая стилометрия
```bash
python -m stylometry.cli run --preset stylometry --model logreg --repeats 5
python -m stylometry.cli run --preset stylometry --model linear_svc --repeats 5
```
Проверяет H1 на ручных признаках без n-грамм и LSA.
### E3. Поочерёдное исключение групп признаков
```bash
python -m stylometry.cli ablate --preset stylometry_pos_lsa --model logreg --repeats 3
```
Для каждой группы признаков строится вариант «без этой группы». Результат —
`results/ablation_*/ablation.csv` с колонкой `delta_macro_f1`. Отрицательное
значение означает, что удаление группы ухудшило качество; положительное — что
группа мешала (возможно, из-за шума или переобучения).
### E4. POS-признаки
Сравните:
```bash
python -m stylometry.cli run --preset stylometry --model logreg --repeats 5
python -m stylometry.cli run --preset stylometry_pos --model logreg --repeats 5
```
Разница macro-F1 показывает вклад символьных и POS n-грамм.
### E5. LSA: варианты и число компонент
```bash
python -m stylometry.cli lsa-sweep \
--variants word,content,fw,char,pos \
--components 50,100,200 \
--model logreg --repeats 1
```
Затем лучшие `k` и варианты проверить с 5 повторами через `run`:
```bash
python -m stylometry.cli run --preset lsa_word --model logreg --repeats 5
```
Для `lsa_word` и `lsa_pos` число компонент задаётся в `FeatureConfig`; в
командной строке текущая версия использует значение из `presets`
(`lsa_components=100`). Если нужно другое `k`, используйте `lsa-sweep` или
измените `FeatureConfig` в сценарии.
### E6. Сравнение классификаторов
```bash
python -m stylometry.cli benchmark --preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest,multinomial_nb,knn \
--repeats 3
```
Результат: `results/benchmark_*/summary.csv`, отдельные каталоги по моделям.
### E7. Стиль или тема
Сравните LSA-варианты:
```bash
python -m stylometry.cli lsa-sweep \
--variants word,content,fw,char,pos \
--components 50,100,200 --model logreg --repeats 1
```
Ожидаемая интерпретация:
- `word` и `content` лучше — вклад темы велик;
- `pos` и `fw` сопоставимы — стиль/синтаксис достаточно информативны;
- `char` полезен для морфологии и пунктуации.
### E8. Объединение блоков
```bash
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats 5
python -m stylometry.cli run --preset all --model logreg --repeats 3
```
Сравните с E2 и E5. Если объединение не лучше лучшего отдельного блока,
проверьте веса блоков и размерности.
### E9. Влияние длины
1. Запустите `run --preset length`, `--preset stylometry`,
`--preset stylometry_pos_lsa`.
2. Для сохранённого запуска выполните:
```bash
python -m stylometry.cli length-report --run-dir results/<каталог_запуска>
```
Команда сама соединяет предсказания с `data/processed/lines.csv`, разбивает
реплики на группы 0–5, 6–10, 11–15, 16–20, 21–30, 31+ слов и считает
macro-F1 с бутстрэп-интервалом. В каждой группе macro-F1 считается по
классам, которые в ней присутствуют; поэтому сравнение групп с разным
набором персонажей нужно интерпретировать осторожно. Результат сохраняется
в `length_bins.csv` внутри каталога запуска.
Ожидание: качество может зависеть от длины, но монотонность не гарантирована.
В предварительном прогоне объединённого набора лучшие значения оказались
в группах 21–30 и 31+ слов, а группа 0–5 слов дала широкий интервал из-за
малого числа объектов. Если длина вносит существенный вклад, сравните
результаты с `--preset length` и без length-признаков (поочерёдное исключение
групп).
### E10. Дисбаланс классов
```bash
python -m stylometry.cli benchmark --preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest --repeats 3
```
Смотрите не только macro-F1, но и F-меру по классам, сбалансированную долю
правильных ответов и support. Сравните `class_weight="balanced"` (в коде
включено для logreg, SVM, RF) с
вариантом без балансировки: для этого нужно изменить `make_classifier` или
передать параметр.
### E11. Дельта Барроуза
```bash
python -m stylometry.cli delta --features 100,200,500 --metrics manhattan,cosine --repeats 3
```
Классическая стилометрическая базовая модель. Ожидается, что на коротких репликах
он уступает ML-моделям; важен как независимая проверка.
### E12. Верификация «свой/чужой»
```bash
python -m stylometry.cli verify --preset stylometry_pos_lsa
```
Для каждой тестовой реплики считается близость к центроидам всех персонажей.
Положительный класс — центроид своего персонажа, отрицательный — чужие.
Метрики: ROC-AUC, EER, TAR@FAR=1%, top-1. Это более мягкая
постановка, чем 34-классовая классификация.
### E13. Кластеризация
```bash
python -m stylometry.cli cluster --preset stylometry_pos_lsa
```
Строятся центроиды персонажей, иерархическая кластеризация и k-means.
Метрики: ARI, NMI, силуэт. Кластеризация носит предварительный характер: она показывает, какие
персонажи близки в признаковом пространстве, но не заменяет классификацию.
### E14. Анализ ошибок
После любого запуска откройте:
- `per_class_report.csv` — precision, recall, F1, support по персонажам;
- `confusion_matrix_normalized.csv` — доли перепутывания;
- `top_confusions.csv` — самые частые пары;
- `figures/confusion_matrix.png` — тепловая карта.
Что искать:
- персонажи с малым support и низким F1;
- пары с высокой взаимной ошибкой (например, дети, нелюди, персонажи с
похожей ролью);
- влияние длины: короткие реплики чаще путаются;
- признаки, важные для лучшей модели (коэффициенты логистической регрессии,
permutation importance для деревьев).
Для анализа по метаданным (пол, возраст, вид) используйте:
```bash
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_запуска> --group gender
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_запуска> --group age_group
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_запуска> --group species
```
Команда сохраняет `subgroup_<group>_metrics.csv` и
`subgroup_<group>_confusions.csv`. Метаданные лежат в
`data/metadata/characters.csv`; это экспертные пометки, а не часть корпуса,
поэтому они не используются как признаки классификатора.
### E15. Статистическое сравнение
Для двух лучших моделей:
```bash
python -m stylometry.cli compare \
--run-a results/<каталог_запуска_A> \
--run-b results/<каталог_запуска_B>
```
Команда соединяет out-of-fold предсказания по `line_id`, проверяет
совпадение истинных классов и считает точный критерий Мак-Немара и
бутстрэп-интервал разницы macro-F1. Результат сохраняется в
`comparison_with_other.json`. Для корректного парного сравнения запускайте
модели с одинаковыми `--folds` и `--seed`.
Для трёх и более моделей используйте `friedman_test` из
`stylometry/stats.py` по метрикам блоков и поправку Холма.
### E16. Вложенная кросс-валидация
```bash
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --nested
python -m stylometry.cli run --preset stylometry_pos_lsa --model linear_svc --nested
```
Это финальная проверка с настройкой гиперпараметров. Сохраняются
`best_params.json` и метрики внешних блоков.
### E17. Дополнительно: агрегация в псевдодокументы
Основной протокол — уровень реплик. Если понадобится формально закрыть
требование «не менее 30 предложений на текст», можно реализовать отдельный
сценарий: склеить реплики одного персонажа в псевдодокументы по 30 предложений
и повторить E2–E9. Этот сценарий **не включён** в основной код, потому что
меняет единицу анализа и уменьшает число объектов. Его результаты следует
рассматривать как robustness check, а не как замену основного эксперимента.
---
## 10. Контроль утечек и воспроизводимости
Перед финальным запуском проверьте:
- [ ] `data/processed/tokenized.pkl` соответствует текущему SHA-256 корпуса.
- [ ] В метаданных запуска `pos_available=true`; POS-признаки не отключились.
- [ ] TF-IDF, SVD и масштабирование обучаются внутри блоков (это обеспечивает
`Pipeline`; не выносите их наружу).
- [ ] `k` LSA и гиперпараметры выбраны по внутренней CV, а не по тесту.
- [ ] В корпусе нет точных дублей; при появлении почти одинаковых реплик
используется группировка.
- [ ] Для финальных чисел выполнено не менее 3–5 повторов.
- [ ] Сохранены `run_metadata.json`, `fold_metrics.csv`, `pooled_metrics.csv`,
`oof_predictions.csv`, `confusion_matrix.csv`.
- [ ] Указаны версии библиотек и seed.
---
## 11. Текущие технические прогоны
Ниже — предварительные результаты одного повтора 5-блочной CV. Они нужны
только для проверки работоспособности конвейера и **не являются финальными**:
один повтор даёт широкий интервал, а гиперпараметры не настраивались.
| Набор признаков | Модель | Macro-F1 | Доля правильных ответов | Top-3 |
|---|---|---:|---:|---:|
| `stylometry` | logreg | 0,1002 | 0,0988 | 0,2389 |
| `stylometry_pos` | logreg | 0,1780 | 0,1681 | 0,3367 |
| `stylometry_pos_lsa` | logreg | 0,1802 | 0,1706 | 0,3387 |
| `lsa_word`, k=50 | logreg | 0,0618 | 0,0628 | 0,1835 |
| `lsa_pos`, k=50 | logreg | 0,0443 | 0,0459 | 0,1202 |
| `char` | multinomial_nb | 0,0280 | 0,0973 | 0,2464 |
Наблюдения:
- символьные и POS n-граммы дают основной прирост относительно ручных
признаков;
- LSA по словам и POS сама по себе слабее, но в объединении даёт небольшой
прирост;
- доля правильных ответов у `char + multinomial_nb` около 9,7%, но
сбалансированная доля правильных ответов 4,7% и macro-F1 2,8%: модель
предсказывает частые классы. Это иллюстрация того, почему долю правильных
ответов нельзя использовать как единственную метрику.
Верификация для `stylometry` (5 блоков): ROC-AUC ≈ 0,648, EER ≈ 0,395,
top-1 ≈ 0,093. Это выше случайного уровня (ROC-AUC 0,5), но далеко от
практически надёжного различения.
Поочерёдное исключение групп для `stylometry` (один повтор): удаление группы `characters`
снижает macro-F1 примерно на 0,015, удаление `vocabulary` — примерно на
0,008; остальные изменения в пределах шума. Эти числа нужно перепроверить с
несколькими повторами.
---
## 12. Как интерпретировать результаты
1. **Сравнивайте с случайным уровнем.** Для 34 классов доля правильных
ответов 2,9%, macro-F1 2,9%, top-3 8,8%.
2. **Основной вывод — по macro-F1 и доверительному интервалу**, а не по
доле правильных ответов.
3. **Смотрите F-меру по классам.** Если редкие персонажи не различаются, это
важный вывод, а не техническая деталь.
4. **Сравнивайте стиль и тему.** Если `lsa_word`/`lsa_content` значительно
лучше `lsa_pos`/`lsa_fw`, классификатор опирается на содержание.
5. **Проверяйте длину.** Если `length` даёт заметный сигнал, часть результата
объясняется длиной реплик, а не синтаксисом.
6. **Не переинтерпретируйте небольшие различия.** При 2005 объектах и 34
классах разница в 1–2 процентных пункта может быть шумом; нужны
доверительные интервалы и парные тесты.
7. **Помните про одного автора.** Результат говорит о различимости персонажей,
а не о разных авторах.
---
## 13. Ограничения
- Короткие тексты: признаки словарного богатства и читаемости нестабильны.
- Один автор: это классификация речевых масок, не авторская атрибуция.
- Ручная фильтрация корпуса: короткие и общие фразы удалены, правила не
восстановлены.
- Дисбаланс классов: 15–139 реплик; редкие классы дают широкие интервалы.
- Нет идентификаторов сцен: нельзя учесть корреляцию реплик внутри одной
сцены.
- NLTK-теггер ошибается на разговорных конструкциях.
- LSA может отражать тему; сравнение вариантов обязательно.
- Кластеризация и t-SNE — предварительный анализ, не доказательство.
---
## 14. Чек-лист перед защитой
- [ ] В отчёте описаны корпус, ручная фильтрация и её ограничения.
- [ ] Указано, что задача — character idiolect, а не авторская атрибуция.
- [ ] Перечислены группы признаков и обоснован выбор.
- [ ] Описаны варианты LSA и способ выбора числа компонент.
- [ ] Указаны классификаторы, гиперпараметры и схема валидации.
- [ ] Основная метрика — macro-F1; приведены доверительные интервалы.
- [ ] Есть отчёт по классам и матрица ошибок.
- [ ] Есть сравнение стиля и темы (`lsa_word` vs `lsa_pos`/`lsa_fw`).
- [ ] Есть анализ длины и поочерёдное исключение групп признаков.
- [ ] Есть статистическое сравнение моделей.
- [ ] Сформулированы ограничения и направления улучшения.
- [ ] Код, отчёт и презентация воспроизводимы по `README.md`.
---
## 15. Куда сохраняются результаты
```text
results/
├── <preset>_<model>_<timestamp>/
│ ├── run_metadata.json
│ ├── fold_metrics.csv
│ ├── fold_summary.csv
│ ├── pooled_metrics.csv
│ ├── bootstrap_ci.json
│ ├── oof_predictions.csv
│ ├── per_class_report.csv
│ ├── confusion_matrix.csv
│ ├── confusion_matrix_normalized.csv
│ ├── top_confusions.csv
│ ├── summary.md
│ └── figures/
├── benchmark_<preset>_<timestamp>/
│ ├── summary.csv
│ └── <model>/
├── ablation_<preset>_<timestamp>/
│ └── ablation.csv
├── lsa_sweep_<timestamp>/
│ └── lsa_sweep.csv
├── delta_<timestamp>/
│ └── delta_sweep.csv
├── verification_<preset>_<timestamp>/
│ ├── fold_metrics.csv
│ ├── pair_scores.csv
│ └── summary.json
└── clustering_<preset>_<timestamp>/
├── clustering_metrics.csv
├── character_centroids.csv
└── figures/
```

294
lab1/FEATURES.md Normal file
View File

@@ -0,0 +1,294 @@
# Стилометрические признаки: состав и обоснование
Документ дополняет `EXPERIMENTS.md` и `METRICS_AND_CLASSIFIERS.md`. Здесь
описано, какие признаки вычисляются, по каким формулам и почему они выбраны.
Полный машинно-читаемый список с именами и описаниями создаётся командой:
```bash
python -m stylometry.cli features
# результат: features/feature_dictionary.csv
```
Все ручные признаки вычисляются по одной реплике. Они не используют метки
классов, поэтому могут быть посчитаны заранее, до разбиения на обучающую и
тестовую выборки.
---
## 1. Признаки длины
| Признак | Формула / смысл |
|---|---|
| `len_tokens` | Число токенов, включая пунктуацию |
| `len_words` | Число токенов, содержащих букву или цифру |
| `len_chars` | Длина строки в символах |
| `len_sentences` | Число предложений |
| `len_mean_sentence_words` | Средняя длина предложения в словах |
| `len_std_sentence_words` | Стандартное отклонение длины предложения |
| `len_min/max/median_sentence_words` | Минимум, максимум, медиана |
| `len_pct_sentences_lt5` | Доля предложений короче 5 слов |
| `len_pct_sentences_gt20` | Доля предложений длиннее 20 слов |
| `len_mean/std/median_word_chars` | Длина слова в символах |
| `len_pct_words_ge7/10/13` | Доля слов длиной не менее 7/10/13 символов |
| `len_pct_single_letter_words` | Доля односимвольных слов |
**Зачем.** Длина реплики — сильный, но не всегда желательный признак. Она
различает детей и взрослых, но может стать прокси персонажа, а не стиля.
Поэтому в протоколе предусмотрены ablation без length-признаков и анализ по
группам длины.
---
## 2. Лексические признаки
| Признак | Смысл |
|---|---|
| `lex_word_length_1` … `lex_word_length_10plus` | Доли слов соответствующей длины |
| `lex_pct_apostrophe_words` | Доля слов с апострофом (сокращения, притяжательные) |
| `lex_pct_capitalized_words` | Доля слов с заглавной буквы |
| `lex_pct_all_caps_words` | Доля слов в верхнем регистре |
| `lex_pct_hyphenated_words` | Доля слов с дефисом |
| `lex_pct_digit_words` | Доля слов с цифрами |
**Зачем.** Распределение длин слов и орфографические привычки устойчивы к теме
и хорошо дополняют частотные признаки.
---
## 3. Словарное богатство
Обозначения: \(N\) — число словоупотреблений, \(V\) — число типов,
\(V_1\) — число гапаксов (слов, встретившихся один раз), \(V_2\) — число
дис-гапаксов.
| Признак | Формула |
|---|---|
| `vocab_ttr` | \(V/N\) |
| `vocab_guiraud` | \(V/\sqrt{N}\) |
| `vocab_herdan` | \(\log V / \log N\) |
| `vocab_yule_k` | \(10^4(\sum_i i^2 V_i - N)/N^2\) |
| `vocab_simpson_d` | \(\sum_i V_i(V_i-1)/(N(N-1))\) |
| `vocab_hapax_ratio` | \(V_1/V\) |
| `vocab_dis_ratio` | \(V_2/V\) |
| `vocab_honore_r` | \(100\log N/(1 - V_1/V)\) |
| `vocab_top10_coverage` | Доля токенов, покрытая 10 самыми частыми типами |
| `vocab_mean_word_frequency` | \(N/V\) |
**Зачем.** Классические меры богатства словаря. **Ограничение:** на репликах
длиной 10–20 слов они нестабильны и сильно зависят от длины. Поэтому они
помечены как предварительные и проверяются отдельно.
---
## 4. Символьные признаки
| Признак | Смысл |
|---|---|
| `char_freq_a` … `char_freq_z` | Относительные частоты латинских букв |
| `char_upper_rate` | Доля заглавных среди букв |
| `char_digit_rate` | Доля цифр среди символов |
| `char_punctuation_rate` | Доля символов пунктуации |
| `char_apostrophe_rate` | Доля апострофов |
| `char_quote_rate` | Доля кавычек |
**Зачем.** Частоты букв и оформление слабо зависят от темы и хорошо
дополняют словарные признаки. Символьные n-граммы (2–5) вынесены в отдельный
TF-IDF-блок.
---
## 5. Пунктуация
Считаются частоты на предложение и на токен:
- запятая, точка, восклицательный и вопросительный знаки;
- двоеточие и точка с запятой;
- многоточие (последовательность из трёх и более точек);
- апостроф, кавычки, дефис, скобки;
- повторная пунктуация (`!!`, `??`, `?!`);
- доля предложений, заканчивающихся вопросом или восклицанием.
**Зачем.** Пунктуация — один из самых устойчивых авторских/персонажных
маркеров. В игровых репликах она особенно важна: многоточия, восклицания и
повторы часто отражают эмоциональность персонажа.
---
## 6. Функциональные слова
Используется фиксированный список закрытых классов:
- артикли, детерминативы, предлоги, союзы;
- личные, притяжательные и возвратные местоимения;
- вспомогательные глаголы и модальные;
- отрицания и сокращения;
- вопросительные слова и дискурсивные маркеры.
Для каждого слова считается относительная частота \(c_w/N\). Дополнительно
считаются частоты категорий.
**Зачем.** Это ядро классической стилометрии: закрытые классы слов мало
зависят от темы, но отражают синтаксические привычки. Метод дельты Барроуза
также строится на частотных словах.
---
## 7. Местоимения
| Признак | Смысл |
|---|---|
| `pron_first_singular_rate` | Я, меня, мой, myself |
| `pron_first_plural_rate` | Мы, нас, наш |
| `pron_second_rate` | Ты, вас, ваш |
| `pron_third_singular_rate` | Он, она, оно |
| `pron_third_plural_rate` | Они |
| `pron_possessive_rate` | Притяжательные |
| `pron_reflexive_rate` | Возвратные |
| `pron_i_you_log_ratio` | \(\log((n_I+1)/(n_{you}+1))\) |
| `pron_first_person_rate` | Все формы первого лица |
| `pron_third_person_rate` | Все формы третьего лица |
| `pron_wh_rate` | Вопросительные слова |
**Зачем.** Соотношение «я — ты — он» отражает ориентацию речи: на себя, на
собеседника или на третьих лиц. Это содержательно интерпретируемый признак.
---
## 8. Части речи
Используются две системы:
- **Universal POS** (16 тегов): ADJ, ADP, ADV, AUX, CONJ, DET, INTJ, NOUN,
NUM, PRON, PRT, PUNCT, SCONJ, SYM, VERB, X;
- **Penn Treebank** (36 тегов): NN, NNS, NNP, VB, VBD, VBG, VBN, VBP, VBZ,
JJ, RB, PRP, IN, DT, CC, MD и другие.
Считаются:
- доли каждого UPOS- и PTB-тега;
- отношения «существительное/глагол», «прилагательное/наречие»;
- модальность, вспомогательность, прошедшее/настоящее время;
- герундий, причастие, сравнительная и превосходная степень;
- доля местоимений и содержательных слов.
**Зачем.** Частеречные профили — стандартный стилометрический признак. Они
устойчивее к теме, чем словарные частоты, и хорошо работают на коротких
текстах. Дополнительно строятся POS n-граммы (1–3) и POS-LSA.
---
## 9. Синтаксические признаки-прокси
Полный синтаксический анализ не используется. Вместо него считаются:
- число клауз на предложение (по сочинительным и подчинительным союзам);
- число подчинительных и сочинительных союзов на предложение;
- доля предложений, начинающихся с союза;
- доля вопросов и восклицаний;
- модальность и отрицание;
- частоты `that`, `to`, окончаний `-ing`, `-ed`, `-ly`;
- пассив-прокси: форма `be` + причастие;
- вопросительные хвосты (`right?`, `isn't it?`);
- императив-прокси: предложение начинается с глагола из списка и не имеет
подлежащего-местоимения;
- средняя и максимальная длина предложения.
**Зачем.** Эти признаки приближают синтаксическую сложность без тяжёлых
зависимостей. Они менее точны, чем полноценный парсер, но устойчивы и
интерпретируемы.
---
## 10. Читаемость
| Признак | Формула |
|---|---|
| `read_syllables_per_word` | Слоги / слова |
| `read_polysyllable_rate` | Доля слов с 3+ слогами |
| `read_flesch` | \(206{,}835 - 1{,}015 \cdot W/S - 84{,}6 \cdot Sy/W\) |
| `read_fk_grade` | \(0{,}39 \cdot W/S + 11{,}8 \cdot Sy/W - 15{,}59\) |
| `read_gunning_fog` | \(0{,}4 \cdot (W/S + 100 \cdot P/W)\) |
| `read_smog` | \(1{,}043\sqrt{30P/S} + 3{,}129\) |
| `read_coleman_liau` | \(0{,}0588L - 0{,}296S_{100} - 15{,}8\) |
| `read_ari` | \(4{,}71 \cdot L/W + 0{,}5 \cdot W/S - 21{,}43\) |
где \(W\) — слова, \(S\) — предложения, \(S_{100}\) — предложений на 100 слов,
\(Sy\) — слоги, \(P\) — слова с 3+ слогами, \(L\) — буквы на 100 слов. Число
слогов оценивается эвристически.
**Зачем.** Классические метрики сложности. **Ограничение:** на коротких
разговорных репликах формулы приблизительны; признаки помечены как
предварительные.
---
## 11. Разговорные и игровые маркеры
| Признак | Смысл |
|---|---|
| `dlg_placeholder_rate` | Доля плейсхолдеров `<PLAYER>` и т.п. |
| `dlg_all_caps_rate` | Доля слов в верхнем регистре |
| `dlg_ellipsis_rate` | Многоточия на предложение |
| `dlg_repeated_punctuation_rate` | `!!`, `??`, `?!` на предложение |
| `dlg_interjection_rate` | `oh`, `hmm`, `sigh`, `giggle` и т.п. |
| `dlg_laughter_rate` | `haha`, `hehe`, `lol` |
| `dlg_discourse_marker_rate` | `well`, `just`, `like`, `so` |
| `dlg_hedge_rate` | `maybe`, `kind of`, `I guess` |
| `dlg_intensifier_rate` | `very`, `really`, `so`, `too` |
| `dlg_politeness_rate` | `please`, `thanks`, `sorry` |
| `dlg_negation_rate` | Отрицания |
| `dlg_contraction_rate` | Сокращения и апострофы |
| `dlg_question_tag_rate` | `right?`, `isn't it?` |
| `dlg_filler_rate` | `um`, `uh`, `hmm` |
| `dlg_emotion_word_rate` | Небольшой список эмоциональных слов |
| `dlg_first/second_person_rate` | Ориентация на себя/собеседника |
| `dlg_one_word_sentence_rate` | Доля однословных предложений |
**Зачем.** Это признаки, специфичные для игровых реплик: они отражают
эмоциональность, разговорность и авторские привычки оформления. Многие из
них недоступны в формальных корпусах, что делает корпус Stardew Valley
интересным для стилометрии.
---
## 12. Символьные и POS n-граммы
- **Символьные n-граммы:** TF-IDF по `char_wb`, 2–5 символов, `min_df=3`,
до 5000 признаков, L2-норма.
- **POS n-граммы:** TF-IDF по последовательностям Universal POS, 1–3 тега,
`min_df=3`, до 500 признаков, L2-норма.
**Зачем.** Символьные n-граммы ловят морфологию, пунктуацию и орфографию;
POS n-граммы — синтаксические шаблоны. Оба блока устойчивее к теме, чем
словарные n-граммы.
---
## 13. LSA
LSA — не ручной признак, а способ понижения размерности TF-IDF:
```text
TF-IDF -> TruncatedSVD(k) -> L2-нормализация
```
Варианты: `word`, `word2`, `content`, `fw`, `char`, `pos`. Подробности — в
`EXPERIMENTS.md` и `stylometry/lsa.py`.
**Зачем.** LSA сжимает разреженное пространство и может выделить латентные
оси вариабельности. Важно сравнивать варианты: LSA по всем словам может
отражать тему, а LSA по POS и функциональным словам — стиль.
---
## 14. Что не используется и почему
- **Лемматизация по умолчанию.** Инфлексия (время, число, степень) — часть
стиля; лемматизация её удаляет. Можно добавить как отдельный вариант LSA.
- **Удаление стоп-слов в основных признаках.** Функциональные слова — ядро
стилометрии; удаление допустимо только для content-LSA.
- **Удаление пунктуации.** Пунктуация — сильный стилометрический сигнал.
- **Удаление коротких реплик.** Они различают персонажей (Jas, Vincent,
Krobus) и уже прошли ручную фильтрацию.
- **SMOTE и синтез текста.** Может создать неестественные комбинации слов и
исказить стилометрический сигнал.

View File

@@ -0,0 +1,894 @@
# Метрики и классификаторы: методическое описание
Документ предназначен для отчёта и презентации. Он объясняет, что показывает
каждая метрика, как она вычисляется, как интерпретировать значения и чем
отличаются классификаторы. Аудитория может не знать деталей машинного
обучения, но предполагается, что базовые понятия выборки, признака и класса
знакомы.
Обозначения:
- \(n\) — число объектов (реплик) в выборке;
- \(C\) — число классов (в основном эксперименте \(C=34\));
- \(y_i\) — истинный класс объекта \(i\);
- \(\hat y_i\) — предсказанный класс;
- \(c\) — индекс класса;
- \(n_c\) — число объектов класса \(c\);
- \(\hat p_{i,c}\) — вероятность, которую модель приписывает объекту \(i\)
и классу \(c\).
---
## 1. Постановка задачи
Решается задача **многоклассовой классификации с одним правильным ответом**:
каждая реплика относится ровно к одному персонажу. Модель получает вектор
стилометрических признаков и должна выбрать один из 34 классов.
Особенности, которые влияют на выбор метрик:
1. **Дисбаланс классов.** Число реплик изменяется от 15 до 139. Модель, которая
всегда предсказывает самый частый класс, может иметь заметную долю
правильных ответов, но бесполезна для редких персонажей.
2. **Много классов.** Случайное угадывание даёт около \(1/34 \approx 2{,}9\%\).
Поэтому полезны top-3 и top-5.
3. **Короткие тексты.** Один объект — реплика длиной около 19 слов. Оценки
качества будут ниже, чем на длинных документах; это ожидаемо.
4. **Один автор.** Задача измеряет различимость речевых масок, а не авторскую
атрибуцию.
---
## 2. Матрица ошибок
**Матрица ошибок** (confusion matrix) — таблица \(C \times C\), где строка
соответствует истинному классу, столбец — предсказанному. Элемент \(m_{ab}\) —
число объектов класса \(a\), которые модель отнесла к классу \(b\).
Для одного класса \(c\) удобно рассматривать задачу «класс \(c\) против
остальных»:
| | Предсказан \(c\) | Предсказан не \(c\) |
|---|---:|---:|
| Истинно \(c\) | \(TP_c\) (истинно положительные) | \(FN_c\) (ложно отрицательные) |
| Истинно не \(c\) | \(FP_c\) (ложно положительные) | \(TN_c\) (истинно отрицательные) |
Диагональ матрицы — правильные ответы. Вне диагонали — ошибки. По матрице
видно не только «сколько ошибок», но и **какие персонажи перепутываются**.
Это важно для содержательной интерпретации: например, ошибки между детьми
(Jas и Vincent) или между нелюдьми (Krobus, Dwarf, Wizard) могут отражать
сходство их речевых масок.
---
## 3. Метрики для одного класса
### 3.1. Точность (precision)
\[
P_c = \frac{TP_c}{TP_c + FP_c}.
\]
Показывает, какая доля объектов, которые модель назвала классом \(c\),
действительно принадлежит этому классу. Высокая точность означает мало
ложных срабатываний.
Пример: если модель 10 раз предсказала Abigail, и 7 раз правильно, точность
равна \(7/10 = 0{,}7\).
### 3.2. Полнота (recall)
\[
R_c = \frac{TP_c}{TP_c + FN_c}.
\]
Показывает, какую долю объектов класса \(c\) модель нашла. Высокая полнота
означает мало пропусков.
Пример: если в выборке 20 реплик Abigail, и модель правильно нашла 12,
полнота равна \(12/20 = 0{,}6\).
### 3.3. F-мера (F1)
\[
F1_c = \frac{2 P_c R_c}{P_c + R_c}.
\]
Гармоническое среднее точности и полноты. Почему гармоническое, а не
арифметическое: оно сильнее наказывает за низкое значение любой из двух
метрик. Если точность 1,0, а полнота 0,1, арифметическое среднее 0,55, а
F-мера — около 0,18. Это соответствует интуиции: модель, которая находит
только десятую часть класса, нельзя считать хорошей.
F-мера полезна, когда важно и не пропускать объекты класса, и не приписывать
к нему чужие. В нашей задаче это естественная метрика для каждого персонажа.
### 3.4. Поддержка (support)
\(n_c\) — число истинных объектов класса \(c\) в выборке. Support нужен,
чтобы понимать, насколько надёжна оценка: F-мера по классу с 15 объектами
имеет гораздо больший разброс, чем по классу с 139 объектами.
---
## 4. Способы усреднения метрик
### 4.1. Macro-усреднение
\[
\text{macro-F1} = \frac{1}{C}\sum_{c=1}^{C} F1_c.
\]
Каждый класс получает одинаковый вес независимо от числа объектов. Это
**основная метрика** в нашей задаче, потому что исследовательский вопрос
касается всех 34 персонажей, включая редких.
Недостаток: редкий класс с 15 репликами влияет на итог так же сильно, как
класс с 139 репликами; поэтому macro-F1 может сильно колебаться между
блоками перекрёстной проверки.
### 4.2. Взвешенное усреднение
\[
\text{weighted-F1} = \sum_{c=1}^{C} \frac{n_c}{n} F1_c.
\]
Каждый класс взвешен по числу объектов. Метрика ближе к качеству на частых
классах; её полезно приводить как дополнительную, но не как основную.
### 4.3. Микро-усреднение
Сначала суммируются \(TP\), \(FP\), \(FN\) по всем классам, затем считается
общая F-мера. В задаче с одним правильным ответом micro-F1 **совпадает с
долей правильных ответов** (accuracy). Поэтому отдельно его считать не нужно,
но можно указать для полноты.
### 4.4. Пример влияния усреднения
Пусть модель хорошо различает Abigail, Alex и Haley, но почти не различает
редких персонажей. Тогда:
- доля правильных ответов и взвешенная F-мера будут относительно высокими;
- macro-F1 будет заметно ниже;
- F-мера по классам покажет, какие именно классы провалились.
Именно поэтому в отчёте нужны macro-F1 **и** отчёт по классам.
---
## 5. Основные метрики
### 5.1. Доля правильных ответов (accuracy)
\[
\text{Accuracy} = \frac{1}{n}\sum_{i=1}^{n} \mathbf{1}[y_i = \hat y_i].
\]
Доля объектов, для которых предсказание совпало с истинным классом.
Плюсы: проста и интуитивна. Минусы: при дисбалансе может вводить в
заблуждение. Пример из наших прогонов: модель `char + multinomial_nb` имеет
долю правильных ответов около 9,7%, но сбалансированная доля правильных
ответов 4,7% и macro-F1 0,028, потому что она часто предсказывает частые
классы и почти не находит редкие.
Случайный уровень: \(1/34 \approx 2{,}9\%\). Модель, всегда предсказывающая
самый частый класс, даёт около \(139/2005 \approx 6{,}9\%\) правильных
ответов, но
macro-F1 около 0,004.
### 5.2. Сбалансированная доля правильных ответов (balanced accuracy)
\[
\text{BalancedAccuracy} = \frac{1}{C}\sum_{c=1}^{C} R_c.
\]
Средняя полнота по классам. Каждый класс получает одинаковый вес, поэтому
метрика устойчива к дисбалансу. Случайный уровень \(1/C\).
Плюс: хорошо показывает, находит ли модель редкие классы. Минус: не учитывает
точность (ложные срабатывания), поэтому её нужно дополнять macro-F1 и MCC.
### 5.3. Macro-F1
Основная метрика. Формула приведена выше. Интерпретация:
- 0 — модель не находит ни одного класса;
- \(1/C \approx 0{,}029\) — уровень случайного угадывания;
- 0,10–0,20 — слабый, но заметный сигнал;
- 0,30–0,50 — умеренное качество;
- выше 0,50 — сильный результат для 34 классов и коротких реплик.
Пороговые значения условны: они зависят от числа классов, длины текста и
сложности задачи. В отчёте лучше сравнивать с базовой моделью и доверительными
интервалами, а не с абстрактной шкалой.
### 5.4. Коэффициент корреляции Мэтьюса (MCC)
Для двух классов:
\[
\text{MCC} = \frac{TP \cdot TN - FP \cdot FN}
{\sqrt{(TP+FP)(TP+FN)(TN+FP)(TN+FN)}}.
\]
Для многих классов используется обобщение через ковариацию матриц истинных и
предсказанных индикаторов (так реализовано в scikit-learn). MCC принимает
значения от \(-1\) до \(1\):
- \(1\) — идеальные предсказания;
- \(0\) — уровень случайного угадывания;
- \(-1\) — полная инверсия классов.
MCC удобен тем, что учитывает все четыре клетки матрицы ошибок и устойчив к
дисбалансу. В отличие от F-меры, он одинаково штрафует за ложные
срабатывания и пропуски.
### 5.5. Каппа Коэна (Cohen's kappa)
\[
\kappa = \frac{p_o - p_e}{1 - p_e},
\]
где \(p_o\) — наблюдаемая доля согласия (accuracy), \(p_e\) — ожидаемая доля
согласия при случайном предсказании с учётом распределения классов.
Интерпретация:
- \(\kappa = 0\) — согласие не лучше случайного;
- \(\kappa = 1\) — полное согласие;
- отрицательные значения — хуже случайного.
Полезна как дополнение к доле правильных ответов: показывает, насколько модель превосходит
случайное угадывание с тем же распределением классов. При сильном дисбалансе
каппа может быть нестабильной; используйте вместе с macro-F1.
### 5.6. Top-k
\[
\text{Top-}k = \frac{1}{n}\sum_{i=1}^{n}
\mathbf{1}\left[y_i \in \text{Top-}k(\hat p_{i,\cdot})\right].
\]
Доля объектов, для которых истинный класс попал в \(k\) наиболее вероятных
(или наиболее высоко оценённых) классов.
При 34 классах top-1 может быть низким, но top-3 и top-5 показывают, что
модель «чувствует» правильную область. Случайный уровень:
- top-3: \(3/34 \approx 8{,}8\%\);
- top-5: \(5/34 \approx 14{,}7\%\).
Top-k особенно полезен для вероятностных моделей и моделей с решающей
функцией. Для моделей без вероятностей ранжирование строится по значению
решающей функции; это не калиброванные вероятности, но порядок классов
корректен.
### 5.7. Логарифмическая потеря (log loss)
\[
\text{LogLoss} = -\frac{1}{n}\sum_{i=1}^{n} \log \hat p_{i,y_i}.
\]
Оценивает качество вероятностей, а не только правильность ответа. Если модель
уверена в неправильном классе, штраф растёт логарифмически.
Случайный уровень для 34 классов: \(\ln 34 \approx 3{,}53\). Значения ниже
лучше. В предварительных прогонах значения различаются: около 3,1 для
объединённого набора с n-граммами и около 7,3 для одних ручных признаков.
Чем ближе значение к 3,53, тем лучше калибровка; значения существенно выше
означают, что модель часто уверена, но ошибается. Это не обязательно плохо
для классификации, но важно для вероятностных выводов.
Log loss определён только для моделей, выдающих вероятности. Для `linear_svc`
он не считается.
### 5.8. Оценка Брайера (Brier score)
Для многоклассовой задачи:
\[
\text{Brier} = \frac{1}{n}\sum_{i=1}^{n}\sum_{c=1}^{C}
\left(\hat p_{i,c} - \mathbf{1}[y_i = c]\right)^2.
\]
Средний квадрат отклонения предсказанных вероятностей от вектора с единицей
в позиции истинного класса. Чем меньше, тем лучше. В отличие от log loss, Brier менее
чувствителен к редким уверенным ошибкам, но тоже оценивает калибровку.
Ориентир: для равномерного случайного распределения по 34 классам
\(\text{Brier} \approx (C-1)/C \approx 0{,}971\). Значения выше этого
означают, что модель хуже равномерного случайного распределения по качеству
вероятностей.
### 5.9. Матрица ошибок и отчёт по классам
Матрица ошибок — не одна метрика, а инструмент анализа. В отчёте полезны:
- **абсолютная матрица** — сколько реплик перепутано;
- **нормированная по строкам матрица** — какая доля истинного класса ушла в
каждый предсказанный;
- **самые частые пары ошибок** — какие классы чаще всего перепутываются.
Отчёт по классам содержит точность, полноту, F-меру и support для каждого
персонажа. Именно он показывает, что средние метрики могут скрывать провал
редких классов.
---
## 6. Метрики верификации «свой/чужой»
В верификации задача формулируется иначе: для тестовой реплики нужно решить,
принадлежит ли она тому же персонажу, что и эталонный центроид. Это бинарная
задача; положительный класс — «свой», отрицательный — «чужой».
### 6.1. ROC-AUC
Площадь под ROC-кривой. Интерпретация: вероятность того, что случайно взятый
положительный объект получил более высокую оценку, чем случайно взятый
отрицательный.
- 0,5 — случайное ранжирование;
- 1,0 — идеальное разделение;
- ниже 0,5 — модель систематически ставит чужие объекты выше своих.
ROC-AUC не зависит от выбранного порога, поэтому удобна для сравнения
способов построения оценок.
### 6.2. Равная ошибка (EER)
Порог, при котором доля ложных принятий (FPR) равна доле ложных отклонений
(FNR). Чем меньше EER, тем лучше. EER удобна как одна точка на ROC-кривой,
но не заменяет всю кривую.
### 6.3. TAR@FAR
Доля истинных принятий при фиксированной доле ложных принятий. Например,
TAR@FAR=1% показывает, какую долю «своих» можно принять, если разрешить не
более 1% ложных принятий. В практических системах это часто важнее, чем EER.
---
## 7. Доверительные интервалы и статистическая значимость
### 7.1. Зачем нужны интервалы
Оценка качества по одной выборке — случайная величина. Если повторить
разбиение, macro-F1 изменится. Поэтому в отчёте нужны не только точечные
значения, но и интервалы.
### 7.2. Бутстрэп (bootstrap)
Алгоритм:
1. Из \(n\) объектов случайно с возвращением выбираются \(n\) объектов.
2. На этой выборке считается метрика.
3. Шаги 1–2 повторяются \(B\) раз (обычно 1000).
4. Берутся 2,5-й и 97,5-й процентили — это 95% доверительный интервал.
Плюс: не требует предположений о распределении метрики. Минус: объекты
считаются независимыми; если реплики внутри сцен коррелируют, интервал может
быть слишком узким.
### 7.3. Критерий Мак-Немара
Сравнивает две модели на одних и тех же объектах. Пусть:
- \(b\) — число объектов, где модель A правильна, а B ошибается;
- \(c\) — число объектов, где A ошибается, а B правильна.
При нулевой гипотезе «модели одинаковы» \(b\) и \(c\) — результаты
\(b+c\) испытаний с вероятностью \(1/2\). Точный двусторонний критерий
использует биномиальное распределение. Если \(b+c\) мало, критерий
консервативен.
### 7.4. Критерий Уилкоксона
Парный непараметрический критерий для двух наборов оценок (например, macro-F1
по блокам перекрёстной проверки). Проверяет, что медиана разностей не равна
нулю. Не требует нормальности. При малом числе блоков мощность низкая.
### 7.5. Критерий Фридмана
Сравнивает три и более модели на одних и тех же блоках. Нулевая гипотеза —
все модели одинаковы. При отклонении нулевой гипотезы можно использовать
post-hoc критерий Неменьи и поправку Холма. В отчёте достаточно указать
статистику, p-значение и поправку.
### 7.6. Поправка Холма—Бонферрони
При \(m\) одновременных проверках вероятность хотя бы одного ложного
отклонения растёт. Поправка Холма упорядочивает p-значения и умножает их на
\((m - \text{ранг})\). Она менее консервативна, чем классическая поправка
Бонферрони, и широко используется.
### 7.7. Размер эффекта
p-значение отвечает на вопрос «есть ли различие», но не «насколько оно
велико». Поэтому приводите разницу macro-F1 и её доверительный интервал.
Разница в 1–2 процентных пункта при 34 классах и 2005 объектах может быть
статистически значимой, но практически незначимой.
---
## 8. Метрики для LSA и кластеризации
### 8.1. Объяснённая дисперсия
`TruncatedSVD` раскладывает матрицу TF-IDF по компонентам. Доля объяснённой
дисперсии компоненты \(j\):
\[
\text{EVR}_j = \frac{\sigma_j^2}{\sum_k \sigma_k^2},
\]
где \(\sigma_j\) — сингулярное число. Накопленная доля — сумма первых \(k\).
Она показывает, какая доля вариабельности матрицы сохраняется при усечении.
Важно: высокая объяснённая дисперсия не гарантирует полезность для
классификации. Выбор \(k\) делается по внешней метрике (macro-F1) и
интерпретируемости, а не только по графику объяснённой дисперсии.
### 8.2. Ошибка восстановления
Относительная ошибка \(\|X - X_k\|_F / \|X\|_F\), где \(X_k\) —
приближение матрицы после SVD. Показывает, насколько потеря информации велика.
Полезна как дополнительная диагностика.
### 8.3. ARI, NMI, силуэт
- **ARI** (Adjusted Rand Index) — согласие кластеризации с истинными классами
с поправкой на случай. 1 — идеальное совпадение, 0 — случайный уровень.
- **NMI** (Normalized Mutual Information) — взаимная информация между
кластеризацией и классами, нормированная в [0,1].
- **Силуэт** — насколько объект ближе к своему кластеру, чем к соседнему.
Значения от -1 до 1; выше лучше.
- **Calinski–Harabasz** — отношение межкластерной дисперсии к внутрикластерной;
выше лучше.
- **Davies–Bouldin** — среднее отношение внутрикластерного разброса к
межкластерному; ниже лучше.
Кластеризация центроидов персонажей — предварительный анализ. ARI может быть
низким даже при полезной структуре; не делайте из кластеризации сильных
выводов.
---
## 9. Классификаторы
### 9.1. Зачем несколько классификаторов
Разные модели по-разному извлекают сигнал:
- вероятностные модели (наивный Байес) хорошо работают с разреженными
частотными признаками;
- линейные модели (логистическая регрессия, SVM) устойчивы при большом числе
признаков и малом числе объектов;
- деревья и ансамбли ловят нелинейные взаимодействия;
- метод ближайших соседей использует локальную структуру.
Сравнение нескольких моделей нужно не для «гонки за процентом», а чтобы
показать, что вывод не зависит от одного алгоритма. Если разные модели дают
близкие результаты, вывод надёжнее.
### 9.2. DummyClassifier (базовая модель)
Предсказывает самый частый класс или случайный класс с учётом распределения.
Не использует признаки. Нужен как нижняя граница: если модель не лучше
Dummy, она не извлекла сигнал.
В нашей задаче Dummy с самым частым классом даёт долю правильных ответов
около 6,9%, но macro-F1 около 0,004. Это хороший пример того, почему долю
правильных ответов нельзя использовать отдельно.
### 9.3. Наивный байесовский классификатор (Multinomial Naive Bayes)
Идея: применить теорему Байеса и предположить, что признаки условно
независимы при данном классе:
\[
P(c \mid x) \propto P(c)\prod_{j=1}^{d} P(x_j \mid c).
\]
Для текстовых частот используется мультиномиальное распределение. Чтобы не
было нулевых вероятностей, применяется сглаживание Лапласа:
\[
P(w \mid c) = \frac{N_{w,c} + \alpha}{N_c + \alpha V},
\]
где \(N_{w,c}\) — число вхождений слова \(w\) в класс \(c\), \(N_c\) — общее
число слов класса, \(V\) — размер словаря, \(\alpha\) — параметр сглаживания.
Плюсы:
- очень быстрый;
- хорошо работает на разреженных TF-IDF и частотных признаках;
- устойчив при малом числе объектов;
- легко объясняется.
Минусы:
- предположение о независимости признаков почти всегда ложно;
- чувствителен к дисбалансу, если не использовать веса классов;
- требует неотрицательных признаков; поэтому LSA-компоненты (они могут быть
отрицательными) в нашей реализации исключены;
- вероятности плохо калиброваны.
В нашем конвейере для MultinomialNB используются ручные неотрицательные
признаки после MinMax-масштабирования, символьные и POS n-граммы. Это
честная базовая модель, но не основной кандидат.
### 9.4. Логистическая регрессия
Линейная модель. Для каждого класса вычисляется линейная оценка:
\[
s_c(x) = w_c^\top x + b_c.
\]
Вероятности получаются через softmax:
\[
P(c \mid x) = \frac{\exp(s_c(x))}{\sum_{k=1}^{C}\exp(s_k(x))}.
\]
Обучение минимизирует перекрёстную энтропию:
\[
\mathcal{L} = -\frac{1}{n}\sum_{i=1}^{n}\log P(y_i \mid x_i)
+ \frac{1}{2C}\|w\|_2^2.
\]
Параметр \(C\) — обратная сила регуляризации: меньше \(C\) — сильнее
регуляризация, проще модель. В нашей реализации используется L2 и
`class_weight="balanced"`, чтобы редкие классы не игнорировались.
Плюсы:
- сильная и стабильная базовая модель для текста;
- хорошо работает при \(d \gg n\);
- коэффициенты интерпретируемы: видно, какие признаки увеличивают шанс
класса;
- выдаёт вероятности.
Минусы:
- только линейные границы;
- чувствительна к масштабу признаков (поэтому стандартизация);
- при сильной мультиколлинеарности коэффициенты нестабильны;
- вероятности могут быть плохо калиброваны.
В наших прогонах логистическая регрессия — основная модель для сравнения
наборов признаков.
### 9.5. Линейный метод опорных векторов (Linear SVM)
Ищет линейную границу, максимизирующую зазор между классами. Для
неразделимых данных используется мягкий зазор:
\[
\min_{w,b,\xi} \frac{1}{2}\|w\|^2 + C\sum_{i=1}^{n}\xi_i
\]
при ограничениях \(y_i(w^\top x_i + b) \ge 1 - \xi_i\), \(\xi_i \ge 0\).
Параметр \(C\) задаёт штраф за нарушения зазора.
Плюсы:
- часто очень силён на текстовых признаках;
- устойчив к высокой размерности;
- хорошо работает с разреженными матрицами;
- класс-веса поддерживаются.
Минусы:
- не выдаёт вероятности; есть только решающая функция;
- требует калибровки, если нужны вероятности;
- чувствителен к выбору \(C\);
- для top-k используем значения решающей функции, а не вероятности.
В отчёте не сравнивайте log loss у SVM: он не определён без калибровки.
### 9.6. Случайный лес (Random Forest)
Ансамбль решающих деревьев. Каждое дерево обучается на бутстрэп-выборке, а
при разбиении рассматривается случайное подмножество признаков. Итоговый
класс — голосование деревьев (или среднее вероятностей).
Плюсы:
- ловит нелинейные взаимодействия;
- устойчив к выбросам и масштабу признаков;
- даёт permutation importance;
- поддерживает веса классов.
Минусы:
- склонен к переобучению при большом числе признаков и малом числе объектов;
- медленнее линейных моделей;
- importance смещены в пользу коррелированных признаков;
- вероятности могут быть плохо калиброваны.
В нашей задаче лес — дополнительная проверка, а не основной кандидат:
2005 объектов и 34 класса — небольшая выборка для леса.
### 9.7. Сверхслучайные деревья (ExtraTrees)
Похожи на случайный лес, но пороги разбиения выбираются случайно, а не
оптимизируются. Это уменьшает дисперсию и ускоряет обучение, но может
увеличить смещение. Полезны как альтернатива случайному лесу.
### 9.8. Метод k ближайших соседей (kNN)
Для объекта находится \(k\) ближайших объектов обучающей выборки; класс
определяется голосованием (или взвешенным голосованием). В нашей реализации
используется косинусное расстояние и веса, обратные расстоянию.
Плюсы:
- простая и интерпретируемая идея;
- не делает предположений о форме границы;
- хорошо работает, если признаки хорошо нормированы.
Минусы:
- проклятие размерности: в высоком измерении расстояния становятся похожими;
- чувствителен к масштабу и шуму;
- медленный на больших выборках;
- не выдаёт содержательных коэффициентов.
kNN полезен как локальная базовая модель, но в стилометрии обычно уступает
линейным моделям.
### 9.9. Градиентный бустинг (опционально)
Последовательно строит деревья, каждое из которых исправляет ошибки
предыдущих. Обычно силён на табличных данных, но на 2005 объектах и 34
классах легко переобучается. В основной конфигурации не используется; может
быть добавлен как дополнительная модель при аккуратной настройке.
### 9.10. Дельта Барроуза
Классический метод стилометрии, не являющийся классификатором в смысле
машинного обучения.
Алгоритм:
1. Выбрать \(m\) наиболее частотных слов обучающей выборки (MFW).
2. Для каждого документа вычислить относительные частоты \(f_{ij}\).
3. z-нормировать каждый признак по обучающей выборке:
\[
z_{ij} = \frac{f_{ij} - \mu_j}{\sigma_j}.
\]
4. Вычислить центроид каждого класса.
5. Отнести документ к классу с минимальным расстоянием:
\[
\Delta_i = \frac{1}{m}\sum_{j=1}^{m}|z_{ij} - z_{cj}|
\]
(манхэттенская дельта) или косинусным расстоянием (Cosine Delta).
Плюсы:
- стандартная и хорошо объяснимая базовая модель;
- не требует сложной настройки;
- устойчив к теме, если выбраны частотные слова;
- легко воспроизводится.
Минусы:
- на коротких репликах оценки частот шумные;
- число MFW \(m\) нужно подбирать;
- z-нормирование чувствительно к выбросам;
- уступает ML-моделям при наличии большого числа признаков.
В отчёте дельта нужна как независимая проверка: если ML-модели и дельта
показывают сходную картину, вывод надёжнее.
### 9.11. Сравнение классификаторов
| Модель | Тип | Вероятности | Работа с разреженными признаками | Веса классов | Интерпретируемость |
|---|---|---|---|---|---|
| Dummy | базовая модель | да | не использует | нет | тривиальная |
| MultinomialNB | вероятностная | да | хорошо | нет | средняя |
| LogisticRegression | линейная | да | хорошо | да | высокая |
| LinearSVC | линейная | нет | хорошо | да | средняя |
| RandomForest | ансамбль деревьев | да | удовлетворительно | да | средняя |
| ExtraTrees | ансамбль деревьев | да | удовлетворительно | да | средняя |
| kNN | локальная | да | удовлетворительно | нет | низкая |
| Delta | метрическая | нет/условно | хорошо | нет | высокая |
---
## 10. Схема валидации
### 10.1. Обучение и тест
Модель настраивается на обучающей выборке и проверяется на тестовой. Нельзя
оценивать модель на тех же объектах, на которых она обучалась: она может
запомнить их и показать завышенное качество (переобучение).
### 10.2. Перекрёстная проверка
При перекрёстной проверке (cross-validation) выборка делится на \(k\)
блоков. Модель обучается на \(k-1\) блоках и проверяется на оставшемся;
процедура повторяется \(k\) раз. Итог — среднее и разброс метрик.
### 10.3. Стратификация
При стратификации сохраняется доля классов в каждом блоке. Это важно при
дисбалансе: иначе редкий класс может вообще не попасть в тестовый блок.
В нашей задаче 5 блоков и минимум 15 реплик на класс дают около 3 тестовых
реплик на класс.
### 10.4. Повторность
Одного разбиения недостаточно: результат зависит от случайного деления.
Повторная перекрёстная проверка (repeated cross-validation) выполняет
несколько разбиений с разными seed и даёт более устойчивую оценку.
### 10.5. Вложенная перекрёстная проверка
Гиперпараметры нельзя настраивать на тестовых блоках. Вложенная схема:
- внешние блоки — оценка качества;
- внутренние блоки — настройка гиперпараметров.
Такой протокол дороже, но честнее.
### 10.6. Утечка данных
Утечка (data leakage) — ситуация, когда информация из тестовой выборки
попадает в обучение. Типичные причины:
- TF-IDF или SVD обучены на всём корпусе до кросс-валидации;
- отбор признаков выполнен по всей выборке;
- масштабирование выполнено до разделения;
- почти одинаковые тексты есть и в обучении, и в тесте.
В нашем конвейере все обучаемые преобразования находятся внутри `Pipeline` и
настраиваются только на обучающих блоках. Ручные признаки и POS-теги не
используют метки, поэтому их можно считать заранее.
### 10.7. Дисбаланс
Основные способы:
- веса классов (`class_weight="balanced"`);
- macro-F1 и сбалансированную долю правильных ответов;
- отчёт по классам;
- стратификация.
Не используйте SMOTE и другие методы синтеза текста без крайней
необходимости: они могут создать искусственные комбинации слов и исказить
стилометрический сигнал.
---
## 11. Как объяснять результаты на защите
1. **Сначала постановка.** 2005 реплик, 34 персонажа, один автор; задача —
различить речевые маски, а не авторов.
2. **Затем данные и ограничения.** Короткие реплики, дисбаланс, ручная
фильтрация, отсутствие сцен.
3. **Затем признаки.** Функциональные слова, POS, символьные n-граммы, LSA;
почему именно они.
4. **Затем валидация.** 5-блочная стратифицированная перекрёстная проверка,
повторность, вложенная настройка, контроль утечек.
5. **Затем метрики.** Macro-F1 как основная; доля правильных ответов как дополнительная;
top-3/top-5; F-мера по классам; матрица ошибок.
6. **Затем результаты.** Сравнение с базовой моделью, доверительные интервалы,
статистические тесты.
7. **Затем интерпретация.** Какие персонажи различаются, какие
перепутываются, что это говорит о речевых масках.
8. **Затем ограничения и улучшения.** Больше данных, агрегация реплик,
тематический контроль, более точный частеречный теггер, сцены.
### 11.1. Пример корректной формулировки
> «На 2005 репликах и 34 классах модель логистической регрессии по
> объединённому набору стилометрических признаков, символьных и частеречных
> n-грамм достигает macro-F1 0,18 при 5-блочной стратифицированной
> перекрёстной проверке. Случайный уровень для 34 классов
> составляет 0,029; top-3 равен 0,34 при случайном уровне 0,088. Это
> указывает на статистически различимый, но не абсолютный сигнал. Основной
> вклад дают символьные и частеречные n-граммы; LSA по всем словам может
> частично отражать тему, поэтому мы сравниваем её с LSA по POS и
> функциональным словам».
### 11.2. Чего избегать
- «Доля правильных ответов 10%, значит модель плохая» — без сравнения с
базовой моделью и macro-F1 это не вывод.
- «Модель различает авторов» — автор один; различаются персонажи.
- «LSA выявил стиль» — LSA может выявить тему; нужен контроль.
- «p < 0,05, значит различие важное» — нужен размер эффекта и интервал.
- «Кластеризация доказала группы» — кластеризация носит предварительный характер.
---
## 12. Краткая версия для слайдов
### 12.1. Метрики
| Метрика | Формулировка для слайда | Ориентир |
|---|---|---|
| Macro-F1 | Средняя F-мера по 34 классам; все персонажи важны одинаково | Случайно около 0,029 |
| Сбалансированная доля правильных ответов | Средняя полнота по классам; показывает, находит ли модель редких персонажей | Случайно около 0,029 |
| Доля правильных ответов | Доля совпавших предсказаний; при дисбалансе может вводить в заблуждение | Случайно около 0,029 |
| MCC | Корреляция истинных и предсказанных меток от -1 до 1; устойчив к дисбалансу | 0 — случайно |
| Каппа Коэна | Согласие с поправкой на случайное угадывание | 0 — случайно |
| Top-3 | Истинный персонаж попал в тройку лучших ответов модели | Случайно около 0,088 |
| Логарифмическая потеря | Качество вероятностей; штраф за уверенную ошибку | Случайно около 3,53 |
| Оценка Брайера | Квадратичная ошибка вероятностей | Случайно около 0,971 |
| ROC-AUC | Для верификации «свой/чужой»: качество ранжирования без выбора порога | 0,5 — случайно |
| EER | Порог, где доля ложных принятий равна доле ложных отклонений | Чем меньше, тем лучше |
| Бутстрэп-интервал | Диапазон, в котором с вероятностью 95% лежит метрика | Уже — устойчивее |
| Мак-Немар | Парный тест: различаются ли две модели на одних и тех же репликах | p < 0,05 — есть различие |
### 12.2. Классификаторы
| Модель | Формулировка для слайда | Сильная сторона | Ограничение |
|---|---|---|---|
| Dummy | Нижняя граница: всегда предсказывает частый класс | Простота | Не использует признаки |
| MultinomialNB | Быстрый вероятностный baseline для частотных признаков | Скорость, устойчивость | Независимость признаков, дисбаланс |
| LogisticRegression | Линейная модель с вероятностями и интерпретируемыми коэффициентами | Стабильность, интерпретируемость | Только линейные границы |
| LinearSVC | Линейная модель с максимальным зазором | Сильна на текстовых признаках | Нет калиброванных вероятностей |
| RandomForest | Ансамбль деревьев, ловит нелинейные взаимодействия | Нелинейность, importance | Переобучение на малой выборке |
| kNN | Локальная модель по близости объектов | Простая идея | Проклятие размерности |
| Delta | Классическое расстояние до центроида по частотным словам | Независимый стилометрический baseline | Шум на коротких репликах |
### 12.3. Одна фраза для вывода
> «Качество оценивалось по macro-F1 с 95% бутстрэп-интервалом; модели
> сравнивались точным критерием Мак-Немара, а не только по доле правильных
> ответов. Устойчивость вывода проверяется сравнением нескольких
> классификаторов: если разные модели дают близкие значения macro-F1 и top-3,
> результат не является артефактом одного алгоритма».
---
## 13. Глоссарий
| Русский термин | Английский термин | Краткое значение |
|---|---|---|
| Признак | feature | Измеримая характеристика объекта |
| Класс | class | Один из вариантов ответа (персонаж) |
| Обучающая выборка | training set | Объекты, на которых модель учится |
| Тестовая выборка | test set | Объекты, на которых измеряется качество |
| Перекрёстная проверка | cross-validation | Многократное деление на обучение и тест |
| Блок | fold | Одна часть при перекрёстной проверке |
| Стратификация | stratification | Сохранение долей классов в блоках |
| Гиперпараметр | hyperparameter | Параметр модели, задаваемый до обучения |
| Переобучение | overfitting | Запоминание обучающих данных вместо обобщения |
| Утечка данных | data leakage | Информация из теста попадает в обучение |
| Матрица ошибок | confusion matrix | Таблица истинных и предсказанных классов |
| Точность | precision | Доля правильных среди предсказанных классом |
| Полнота | recall | Доля найденных среди истинных класса |
| F-мера | F1 score | Гармоническое среднее точности и полноты |
| Macro-F1 | macro F1 | Среднее F1 по классам с равным весом |
| Доля правильных ответов | accuracy | Доля совпавших предсказаний |
| Сбалансированная доля | balanced accuracy | Средняя полнота по классам |
| MCC | Matthews correlation coefficient | Корреляция истинных и предсказанных меток |
| Каппа Коэна | Cohen's kappa | Согласие с поправкой на случай |
| Top-k | top-k accuracy | Попал ли истинный класс в k лучших |
| Логарифмическая потеря | log loss | Качество вероятностей |
| Оценка Брайера | Brier score | Квадратичная ошибка вероятностей |
| ROC-AUC | ROC AUC | Площадь под ROC-кривой |
| EER | equal error rate | Точка равенства FPR и FNR |
| Бутстрэп | bootstrap | Выборка с возвращением для интервалов |
| Мак-Немар | McNemar test | Парный тест двух моделей |
| Уилкоксон | Wilcoxon test | Парный непараметрический тест |
| Фридман | Friedman test | Сравнение нескольких моделей |
| LSA | latent semantic analysis | TF-IDF + сингулярное разложение |
| TF-IDF | term frequency — inverse document frequency | Взвешивание слов |
| SVD | singular value decomposition | Сингулярное разложение |
| ARI | adjusted Rand index | Согласие кластеризации с классами |
| NMI | normalized mutual information | Взаимная информация кластеризации и классов |
| Силуэт | silhouette | Качество кластерной структуры |
| Наивный Байес | naive Bayes | Вероятностная модель с независимостью признаков |
| Логистическая регрессия | logistic regression | Линейная вероятностная модель |
| Метод опорных векторов | support vector machine | Линейная модель с максимизацией зазора |
| Случайный лес | random forest | Ансамбль решающих деревьев |
| kNN | k-nearest neighbours | Классификация по ближайшим объектам |
| Дельта Барроуза | Burrows' Delta | Метрика расстояния в стилометрии |

View File

@@ -0,0 +1,228 @@
# Структура презентации
Ниже — рекомендуемая последовательность слайдов. Она рассчитана на аудиторию,
которая знает базовые понятия машинного обучения, но не знает деталей
стилометрии. Формулировки можно сокращать, но логику лучше сохранить.
---
## Слайд 1. Тема и исследовательский вопрос
**Заголовок:** Различимы ли речевые маски персонажей Stardew Valley?
**Ключевые тезисы:**
- 2005 реплик, 34 персонажа, один автор.
- Задача — не авторская атрибуция, а классификация речевых масок.
- Вопрос: можно ли различить персонажей по стилометрическим признакам
отдельных реплик?
**Визуализация:** примеры реплик 3–4 контрастных персонажей (Abigail, Jas,
Krobus, Wizard).
---
## Слайд 2. Корпус и его ограничения
**Ключевые тезисы:**
- 2005 реплик, 34 класса, около 38 тыс. словоупотреблений.
- Средняя длина — 19 слов, медиана — 17.
- Классы неравномерны: 15–139 реплик.
- Корпус прошёл ручную фильтрацию коротких и общих фраз; правила не
восстановлены.
- Точных дублей реплик нет.
**Визуализация:** горизонтальная диаграмма распределения классов; гистограмма
длин реплик.
---
## Слайд 3. Почему это сложная задача
**Ключевые тезисы:**
- Один автор → нельзя использовать традиционную авторскую атрибуцию.
- Короткие тексты → признаки словарного богатства и читаемости нестабильны.
- Дисбаланс → accuracy обманчива, нужна macro-F1.
- 34 класса → случайный уровень 2,9%, top-3 — 8,8%.
- LSA может выучить тему, а не стиль.
**Визуализация:** таблица «случайный уровень vs ожидаемый сигнал».
---
## Слайд 4. Предобработка и NLTK
**Ключевые тезисы:**
- Нормализация Unicode и плейсхолдеров.
- Токенизация, разбиение на предложения, частеречная разметка NLTK.
- PTB → Universal POS.
- Не удаляем стоп-слова, пунктуацию и короткие реплики: это стилометрический
сигнал.
- Не лемматизируем по умолчанию: инфлексия — тоже стиль.
**Визуализация:** схема «реплика → токены → POS → признаки».
---
## Слайд 5. Стилометрические признаки
**Ключевые тезисы:**
- 454 ручных признака в 11 группах.
- Функциональные слова — ядро классической стилометрии.
- POS и символьные n-граммы — устойчивы к теме.
- Пунктуация и разговорные маркеры — важны для игровых реплик.
- Словарное богатство и читаемость — предварительные признаки.
**Визуализация:** таблица групп признаков с примерами; словарь признаков в
приложении.
---
## Слайд 6. LSA
**Ключевые тезисы:**
- LSA = TF-IDF → усечённое сингулярное разложение → L2-нормализация.
- Варианты: слова, содержание без стоп-слов, функциональные слова,
символьные n-граммы, POS.
- Число компонент выбирается по объяснённой дисперсии и внешней метрике.
- LSA по всем словам может отражать тему; сравнение с POS/функциональными
словами обязательно.
- Обучение LSA — только внутри блоков перекрёстной проверки.
**Визуализация:** график объяснённой дисперсии; 2D PCA/t-SNE; примеры
top-терминов компонент.
---
## Слайд 7. Схема валидации
**Ключевые тезисы:**
- 5-блочная стратифицированная перекрёстная проверка.
- Повторность: 3–5 повторов.
- Вложенная настройка гиперпараметров: внешние блоки — оценка, внутренние —
настройка.
- Все обучаемые преобразования — внутри блоков.
- Контроль утечек: TF-IDF, SVD, масштабирование, отбор признаков.
**Визуализация:** схема outer/inner CV.
---
## Слайд 8. Метрики
**Ключевые тезисы:**
- Основная метрика — macro-F1: все 34 класса равноправны.
- Дополнительно: сбалансированная доля правильных ответов, MCC, каппа,
top-3/top-5.
- Accuracy приводится, но не используется как единственная метрика.
- 95% бутстрэп-интервалы.
- Парные сравнения: Мак-Немар; несколько моделей — Фридман и Холм.
**Визуализация:** таблица метрик с формулами в приложении; пример «почему
accuracy обманывает» (Dummy: 6,9% accuracy, macro-F1 0,004).
---
## Слайд 9. Классификаторы
**Ключевые тезисы:**
- Dummy — нижняя граница.
- MultinomialNB — быстрый вероятностный baseline.
- LogisticRegression — основной кандидат: линейная модель, вероятности,
интерпретируемые коэффициенты.
- LinearSVC — сильная линейная альтернатива без вероятностей.
- RandomForest/ExtraTrees — нелинейная проверка.
- kNN — локальный baseline.
- Delta Барроуза — классическая стилометрическая проверка.
**Визуализация:** таблица «модель — сильная сторона — ограничение».
---
## Слайд 10. Результаты
**Ключевые тезисы:**
- Предварительные прогоны: macro-F1 около 0,18 для объединённого набора,
top-3 около 0,34.
- Случайный уровень: macro-F1 0,029, top-3 0,088.
- Символьные и POS n-граммы дают основной прирост.
- LSA сама по себе слабее; в объединении даёт небольшой прирост.
- Редкие классы проваливаются; смотреть F-меру по классам.
**Визуализация:** таблица результатов; матрица ошибок; диаграмма F-меры по классам.
---
## Слайд 11. Стиль или тема?
**Ключевые тезисы:**
- Сравнение `lsa_word`/`lsa_content` с `lsa_pos`/`lsa_fw`/`lsa_char`.
- Если word/content значительно лучше — вклад темы велик.
- Если POS/FW сопоставимы — стиль и синтаксис информативны.
- Маскировка содержательных слов и тематический контроль — следующий шаг.
**Визуализация:** сравнительная диаграмма macro-F1 по вариантам LSA.
---
## Слайд 12. Ошибки и интерпретация
**Ключевые тезисы:**
- Самые частые пары ошибок.
- Персонажи с малым support.
- Дети, нелюди, персонажи с похожей ролью.
- Влияние длины реплик.
- Анализ по полу, возрасту, виду.
**Визуализация:** самые частые пары ошибок; F-мера по подгруппам.
---
## Слайд 13. Ограничения
**Ключевые тезисы:**
- Один автор.
- Короткие тексты.
- Ручная фильтрация корпуса.
- Дисбаланс классов.
- Нет идентификаторов сцен.
- NLTK-теггер ошибается на разговорной речи.
- LSA может отражать тему.
- Кластеризация и t-SNE — предварительный анализ.
---
## Слайд 14. Выводы и дальнейшая работа
**Ключевые тезисы:**
- Речевые маски персонажей статистически различимы, но сигнал умеренный.
- Основной вклад — символьные и частеречные n-граммы; LSA полезна как
дополнение, но требует контроля темы.
- Для усиления: больше данных, агрегация реплик в псевдодокументы,
тематический контроль, более точный частеречный теггер, учёт сцен.
- Воспроизводимость: код, конфигурации, seed, метаданные запусков.
**Визуализация:** итоговая таблица «что сделано — что показано — что дальше».
---
## Приложение
- Полный словарь признаков.
- Формулы метрик.
- Сетки гиперпараметров.
- Примеры ошибок.
- Команды запуска.

231
lab1/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Стилометрия реплик Stardew Valley
Проект для лабораторной работы по автоматическому анализу текстов. Задача —
проверить, можно ли стилометрически различить персонажей игры, если все реплики
написаны одним автором. Это не авторская атрибуция, а классификация
**речевых масок персонажей** (character idiolect): автор намеренно наделяет
героев разной лексикой, синтаксисом, междометиями и пунктуацией.
Основная единица анализа — **одна реплика**. В текущем корпусе 2005 реплик,
34 персонажа. Классы неравномерны: от 15 реплик у Mister Qi до 139 у Abigail.
Поэтому основная метрика — macro-F1, а не доля правильных ответов.
## Быстрый старт
```bash
cd lab1
# 1. Установить зависимости (удобно в виртуальном окружении)
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# 2. Скачать ресурсы NLTK в локальный каталог lab1/nltk_data
python scripts/download_nltk_data.py
# 3. Проверить корпус
python -m stylometry.cli audit
# 4. Построить ручные стилометрические признаки
python -m stylometry.cli features
# 5. Запустить кросс-валидацию
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats 5
```
Если виртуальное окружение уже создано в корне проекта
(`/home/mermutef/PycharmProjects/nlp/.venv`), команды можно запускать так:
```bash
cd lab1
../.venv/bin/python -m stylometry.cli audit
```
Вместо `python -m stylometry.cli` можно использовать `python main.py`:
```bash
python main.py audit
python main.py run --preset stylometry_pos_lsa --model logreg --repeats 5
```
## Что уже реализовано
| Компонент | Модуль | Назначение |
|---|---|---|
| Чтение корпуса | `stylometry/io.py` | CSV в cp1251, XLSX, проверка колонок, SHA-256 |
| Нормализация | `stylometry/cleaning.py` | Unicode, плейсхолдеры, пробелы |
| Токенизация и POS | `stylometry/tokenization.py` | NLTK: `word_tokenize`, `sent_tokenize`, `pos_tag`; резервный режим |
| Ручные признаки | `stylometry/features/` | длина, лексика, словарное богатство, символы, пунктуация, функциональные слова, местоимения, POS, синтаксис, читаемость, разговорные маркеры |
| LSA | `stylometry/lsa.py` | TF-IDF + `TruncatedSVD` для word/content/fw/char/pos |
| Конвейеры | `stylometry/pipelines.py` | объединение блоков признаков, классификаторы, сетки гиперпараметров |
| Кросс-валидация | `stylometry/cv.py` | повторная стратифицированная и вложенная CV |
| Метрики | `stylometry/evaluation.py` | macro-F1, сбалансированная доля правильных ответов, MCC, каппа, top-k, логарифмическая потеря, оценка Брайера, бутстрэп |
| Статистика | `stylometry/stats.py` | Мак-Немар, Уилкоксон, Фридман, Холм |
| Дельта Барроуза | `stylometry/delta.py` | MFW, z-нормирование, манхэттенское и косинусное расстояние |
| Верификация | `stylometry/verification.py` | «тот же персонаж / другой персонаж», ROC-AUC, EER, TAR@FAR |
| Кластеризация | `stylometry/clustering.py` | иерархическая и k-means, ARI, NMI, силуэт |
| Визуализация | `stylometry/plotting.py` | распределение классов, scree, матрица ошибок, PCA, t-SNE |
## Команды
Все команды выполняются из каталога `lab1`.
```bash
# Аудит: отчёт data/processed/audit.md и таблица lines.csv
python -m stylometry.cli audit
# Матрица ручных признаков: features/stylometric.csv и словарь признаков
python -m stylometry.cli features
# LSA одного варианта
python -m stylometry.cli lsa --variant word --components 100
python -m stylometry.cli lsa --variant pos --components 50
python -m stylometry.cli lsa --variant char --components 50
# Одна модель
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats 5
python -m stylometry.cli run --preset stylometry_pos --model linear_svc --repeats 5
python -m stylometry.cli run --preset stylometry --model random_forest --repeats 3
# Сравнение классификаторов
python -m stylometry.cli benchmark --preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest,multinomial_nb,knn --repeats 3
# Вложенная кросс-валидация с настройкой гиперпараметров
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --nested
# Поочерёдное исключение групп ручных признаков
python -m stylometry.cli ablate --preset stylometry_pos_lsa --model logreg --repeats 3
# Перебор вариантов LSA и числа компонент
python -m stylometry.cli lsa-sweep --variants word,pos,char --components 50,100,200
# Дельта Барроуза
python -m stylometry.cli delta --features 100,200,500 --metrics manhattan,cosine
# Верификация «свой/чужой»
python -m stylometry.cli verify --preset stylometry_pos_lsa
# Кластеризация персонажей
python -m stylometry.cli cluster --preset stylometry_pos_lsa
# Метрики по группам длины реплик (по сохранённому запуску)
python -m stylometry.cli length-report --run-dir results/<каталог_запуска>
# Парное сравнение двух запусков: Мак-Немар и бутстрэп-разница macro-F1
python -m stylometry.cli compare \
--run-a results/<каталог_запуска_A> \
--run-b results/<каталог_запуска_B>
# Метрики по полу, возрасту и виду персонажей
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_запуска> --group gender
```
Основные готовые наборы признаков (`--preset`):
| Набор | Содержимое |
|---|---|
| `stylometry` | только ручные стилометрические признаки |
| `stylometry_pos` | ручные признаки + символьные и POS n-граммы |
| `stylometry_pos_lsa` | то же + LSA по словам и POS (основной набор) |
| `all` | все блоки, включая все варианты LSA |
| `char` | только символьные n-граммы |
| `pos` | только POS n-граммы |
| `fw` | функциональные слова и местоимения |
| `lsa_word`, `lsa_pos` | только соответствующий вариант LSA |
| `length` | только признаки длины (нижняя граница) |
## Конфигурация
Файл `configs/default.toml` задаёт параметры по умолчанию:
- `[cv]` — число блоков, повторов и seed;
- `[experiment]` — набор признаков и классификатор;
- `[features]` — диапазоны n-грамм, число компонент LSA, `min_df`,
масштабирование.
Параметры командной строки имеют приоритет над конфигурацией. Например:
```bash
python -m stylometry.cli run --config configs/default.toml \
--preset stylometry_pos_lsa --model logreg --repeats 3
```
## Основные документы
- **`RUNBOOK.md`** — пошаговая инструкция для запуска полной серии
экспериментов: установка, проверка окружения, все команды, упаковка
результатов и типичные ошибки.
- **`EXPERIMENTS.md`** — подробный протокол экспериментов: гипотезы, признаки,
схема валидации, матрица экспериментов, команды запуска, анализ ошибок,
ограничения и чек-лист для следующего исследователя.
- **`FEATURES.md`** — состав стилометрических признаков, формулы и обоснование
выбора; дополняет машинно-читаемый `features/feature_dictionary.csv`.
- **`METRICS_AND_CLASSIFIERS.md`** — методическое описание метрик и
классификаторов: что показывает каждая метрика, как она считается, как
интерпретировать значения, чем отличаются модели и как объяснять это на
защите.
- **`PRESENTATION_OUTLINE.md`** — рекомендуемая структура презентации на
14 слайдов с ключевыми тезисами и предложениями по визуализации.
## Структура проекта
```text
lab1/
├── data/
│ ├── dialogues.csv
│ ├── dialogues.xlsx
│ ├── stats_per_character.xlsx
│ ├── metadata/characters.csv # пол, возраст, вид, роль
│ └── processed/ # lines.csv, audit.md, tokenized.pkl
├── features/ # матрицы признаков и LSA
├── stylometry/ # основной пакет
├── experiments/ # место для отдельных сценариев
├── configs/default.toml
├── results/ # результаты запусков
├── tests/ # pytest
├── scripts/download_nltk_data.py
├── requirements.txt
├── README.md
├── EXPERIMENTS.md
└── METRICS_AND_CLASSIFIERS.md
```
## Воспроизводимость
- Корпус читается из `data/dialogues.csv` в кодировке cp1251. SHA-256 файла
сохраняется в отчёте и метаданных запуска.
- Кэш токенизации `data/processed/tokenized.pkl` проверяется по SHA-256
исходного файла; при изменении корпуса пересобирается.
- Все обучаемые преобразования (TF-IDF, SVD, масштабирование, отбор признаков)
настраиваются только на обучающем блоке кросс-валидации. Ручные признаки и
POS-теги не используют метки, поэтому могут считаться заранее.
- Для каждого запуска сохраняются конфигурация, метрики по блокам, pooled
out-of-fold предсказания, матрица ошибок, отчёт по классам и графики.
- Случайность фиксируется параметром `--seed` (по умолчанию 42).
## Ограничения
- Реплики короткие: в среднем около 19 слов. Признаки словарного богатства и
читаемости на таких текстах нестабильны; они помечены как предварительные.
- Все тексты написаны одним автором; результат характеризует различимость
персонажей, а не авторский стиль.
- Корпус прошёл ручную фильтрацию: короткие и общие фразы удалены. Точные
правила очистки не воспроизводимы, это ограничение зафиксировано в отчёте.
- Классы неравномерны (15–139 реплик). Основная метрика — macro-F1; по редким
классам доверительные интервалы широкие.
- NLTK-теггер обучен на внешнем корпусе и может ошибаться на разговорных
конструкциях и коротких фрагментах.
- LSA по всем словам может отражать тему, а не стиль. Поэтому в протоколе
предусмотрено сравнение `lsa_word` с `lsa_pos`, `lsa_fw` и `lsa_char`.
## Тесты
```bash
cd lab1
python -m pytest -q
```
Тесты проверяют чтение корпуса, нормализацию, NLTK-разметку, признаки, LSA,
дельту, метрики и кросс-валидацию.

686
lab1/RUNBOOK.md Normal file
View File

@@ -0,0 +1,686 @@
# Инструкция по запуску экспериментов
Документ предназначен для коллеги, который будет запускать серию
экспериментов на своей машине. Все команды выполняются из каталога `lab1`.
Результаты нужно упаковать и передать обратно одним архивом.
Если вы впервые открыли проект, начните с разделов 1–4. Если окружение уже
готово, переходите к разделу 5.
---
## 1. Что это за проект
Исследуется, можно ли стилометрически различить 34 персонажа Stardew Valley
по отдельным репликам, если все реплики написаны одним автором. Это
многоклассовая классификация речевых масок персонажей, а не авторская
атрибуция.
Основные характеристики:
- 2005 реплик;
- 34 класса;
- около 38 тыс. словоупотреблений;
- средняя длина реплики около 19 слов;
- классы неравномерны: от 15 до 139 реплик;
- основная метрика — macro-F1;
- схема валидации — повторная стратифицированная перекрёстная проверка.
Полный протокол: `EXPERIMENTS.md`.
Описание признаков: `FEATURES.md`.
Описание метрик и классификаторов: `METRICS_AND_CLASSIFIERS.md`.
Структура презентации: `PRESENTATION_OUTLINE.md`.
---
## 2. Требования к окружению
- Python 3.11 или новее (проверено на 3.14).
- Linux, macOS или Windows с WSL.
- Свободное место: около 1 ГБ с учётом виртуального окружения и ресурсов
NLTK.
- Интернет — только для установки зависимостей и скачивания ресурсов NLTK.
После этого эксперименты выполняются локально.
Проверьте версию:
```bash
python --version
```
Если версия ниже 3.11, обновите Python: в коде используется стандартный
модуль `tomllib`.
---
## 3. Первичная установка
### 3.1. Перейти в каталог лабораторной
```bash
cd nlp/lab1
```
Все дальнейшие команды выполняются из этого каталога.
### 3.2. Создать виртуальное окружение
Можно создать отдельное окружение внутри `lab1`:
```bash
python -m venv .venv
source .venv/bin/activate
```
На Windows:
```powershell
python -m venv .venv
.venv\Scripts\activate
```
Если в корне проекта уже есть окружение `.venv`, можно использовать его:
```bash
source ../.venv/bin/activate
```
### 3.3. Установить зависимости
```bash
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
```
Проверьте, что установились основные библиотеки:
```bash
python -c "import numpy, pandas, scipy, sklearn, nltk, matplotlib; print('ok')"
```
### 3.4. Скачать ресурсы NLTK
```bash
python scripts/download_nltk_data.py
```
Ресурсы сохраняются в `lab1/nltk_data`:
- `punkt`, `punkt_tab` — токенизация и разбиение на предложения;
- `averaged_perceptron_tagger`, `averaged_perceptron_tagger_eng` —
частеречная разметка;
- `stopwords` — стоп-слова для content-LSA.
Без этих ресурсов POS-признаки отключатся, и результаты будут неполными.
### 3.5. Проверить окружение
```bash
python scripts/check_environment.py
```
Скрипт печатает версии библиотек, SHA-256 корпуса, число реплик и классов,
проверяет токенизатор и POS-теггер. В конце должно быть:
```text
Окружение готово.
```
Если POS-теггер недоступен, повторите шаг 3.4.
---
## 4. Проверка перед полной серией
### 4.1. Тесты
```bash
python -m pytest -q
```
Ожидается:
```text
33 passed
```
Если тесты не проходят, не запускайте полную серию: сначала пришлите текст
ошибки.
### 4.2. Быстрый smoke-test
```bash
python -m stylometry.cli audit
python -m stylometry.cli features
python -m stylometry.cli run --preset length --model logreg --folds 3 --repeats 1
```
Проверьте, что появились:
- `data/processed/audit.md`;
- `data/processed/lines.csv`;
- `features/stylometric.csv`;
- `features/feature_dictionary.csv`;
- каталог `results/length_logreg_<timestamp>/` с `summary.md`.
Если всё создалось, окружение готово.
---
## 5. Полная серия экспериментов
### 5.1. Основной запуск
Из каталога `lab1`:
```bash
bash scripts/run_all_experiments.sh
```
Если используется окружение в корне проекта:
```bash
PYTHON=../.venv/bin/python bash scripts/run_all_experiments.sh
```
Скрипт последовательно запускает все стадии, пишет общий лог в
`results/logs/run_all_<timestamp>.log` и останавливается при первой ошибке.
### 5.2. Быстрый режим
Для проверки, что всё работает, но без финальных чисел:
```bash
QUICK=1 bash scripts/run_all_experiments.sh
```
В быстром режиме:
- все повторы равны 1;
- вложенная кросс-валидация пропускается;
- полная серия занимает меньше времени.
Результаты быстрого режима **нельзя** использовать в отчёте как финальные.
### 5.3. Настройка повторов
По умолчанию:
| Переменная | Значение | Что регулирует |
|---|---:|---|
| `REPEATS_MAIN` | 5 | основные модели |
| `REPEATS_FAST` | 3 | baseline, benchmark, delta |
| `REPEATS_LSA` | 3 | отдельные варианты LSA |
| `REPEATS_ABLATE` | 3 | поочерёдное исключение групп |
| `REPEATS_DELTA` | 2 | дельта Барроуза |
| `SKIP_NESTED` | 0 | 1 — пропустить вложенную CV |
Пример:
```bash
REPEATS_MAIN=5 REPEATS_FAST=3 SKIP_NESTED=1 \
bash scripts/run_all_experiments.sh
```
### 5.4. Долгий запуск через tmux или nohup
Полная серия может занимать несколько часов. Если подключение к машине
может прерваться, используйте `tmux`:
```bash
tmux new -s nlp
PYTHON=../.venv/bin/python bash scripts/run_all_experiments.sh
# Ctrl+B, затем D — выйти из tmux, процесс продолжит работу
```
Вернуться:
```bash
tmux attach -t nlp
```
Или `nohup`:
```bash
nohup env PYTHON=../.venv/bin/python \
bash scripts/run_all_experiments.sh >/dev/null 2>&1 &
```
Следить за логом:
```bash
tail -f results/logs/run_all_*.log
```
### 5.5. Ориентировочное время
Время зависит от процессора, числа потоков и версии библиотек. Ориентиры для
обычного ноутбука:
| Стадия | Время |
|---|---:|
| Проверка окружения и тесты | 1–2 мин |
| Аудит и ручные признаки | 1–2 мин |
| Нижние границы | 5–10 мин |
| Классическая стилометрия | 3–6 мин |
| Ручные признаки + n-граммы | 4–8 мин |
| Основной объединённый набор | 5–10 мин |
| Варианты LSA | 10–20 мин |
| Перебор числа компонент LSA | 5–10 мин |
| Сравнение классификаторов | 15–30 мин |
| Поочерёдное исключение групп | 20–40 мин |
| Дельта Барроуза | 3–5 мин |
| Верификация и кластеризация | 3–5 мин |
| Вложенная кросс-валидация | 15–40 мин |
| Анализ длины и подгрупп | 1–2 мин |
Итого: ориентировочно **1,5–3 часа**, в зависимости от машины. Если
включены все повторы и вложенная CV, может быть дольше.
---
## 6. Что делает каждая стадия
### 6.1. Аудит корпуса (E0)
```bash
python -m stylometry.cli audit
```
Создаёт:
- `data/processed/audit.md` — отчёт по корпусу;
- `data/processed/lines.csv` — нормализованные реплики;
- `data/processed/figures/class_distribution.png`.
Проверьте: 2005 реплик, 34 персонажа, нет точных дублей, распределение
классов совпадает с `stats_per_character.xlsx`.
### 6.2. Ручные признаки
```bash
python -m stylometry.cli features
```
Создаёт:
- `features/stylometric.csv` — матрица 2005 × 454;
- `features/feature_dictionary.csv` — описание признаков.
### 6.3. Нижние границы (E1)
Запускаются:
- `length` + Dummy — частотная базовая модель;
- `length` + логистическая регрессия;
- `char` + MultinomialNB;
- `pos` + MultinomialNB;
- `fw` + логистическая регрессия.
Смысл: понять, сколько сигнала даёт только длина, только символьные
n-граммы, только POS и только функциональные слова.
### 6.4. Классическая стилометрия (E2)
```bash
python -m stylometry.cli run --preset stylometry --model logreg --repeats 5
python -m stylometry.cli run --preset stylometry --model linear_svc --repeats 5
```
Проверяет гипотезу H1 на ручных признаках без n-грамм и LSA.
### 6.5. Ручные признаки + n-граммы (E4)
```bash
python -m stylometry.cli run --preset stylometry_pos --model logreg --repeats 5
```
Показывает вклад символьных и частеречных n-грамм.
### 6.6. Основной объединённый набор (E8)
```bash
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats 5
```
Основной результат: ручные признаки + char/POS n-граммы + LSA по словам и
POS.
### 6.7. Варианты LSA (E5, E7)
Запускаются отдельно:
```bash
python -m stylometry.cli run --preset lsa_word --model logreg --repeats 3
python -m stylometry.cli run --preset lsa_word2 --model logreg --repeats 3
python -m stylometry.cli run --preset lsa_content --model logreg --repeats 3
python -m stylometry.cli run --preset lsa_fw --model logreg --repeats 3
python -m stylometry.cli run --preset lsa_char --model logreg --repeats 3
python -m stylometry.cli run --preset lsa_pos --model logreg --repeats 3
```
Сравнение `lsa_word`/`lsa_content` с `lsa_pos`/`lsa_fw`/`lsa_char` — это
основной эксперимент «стиль или тема».
### 6.8. Перебор числа компонент LSA (E5)
```bash
python -m stylometry.cli lsa-sweep \
--variants word,content,fw,char,pos \
--components 50,100,200 \
--model logreg --repeats 1
```
Результат: `results/lsa_sweep_<timestamp>/lsa_sweep.csv` с macro-F1 для
каждой пары «вариант — число компонент».
### 6.9. Сравнение классификаторов (E6)
```bash
python -m stylometry.cli benchmark \
--preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest,multinomial_nb,knn \
--repeats 3
```
Результат: `results/benchmark_<preset>_<timestamp>/summary.csv` и отдельные
каталоги по моделям.
### 6.10. Поочерёдное исключение групп признаков (E3)
```bash
python -m stylometry.cli ablate \
--preset stylometry_pos_lsa --model logreg --repeats 3
```
Результат: `results/ablation_<preset>_<timestamp>/ablation.csv` с колонкой
`delta_macro_f1`. Отрицательное значение означает, что удаление группы
ухудшило качество.
### 6.11. Дельта Барроуза (E11)
```bash
python -m stylometry.cli delta \
--features 100,200,500 --metrics manhattan,cosine --repeats 2
```
Классическая стилометрическая базовая модель; результат —
`results/delta_<timestamp>/delta_sweep.csv`.
### 6.12. Верификация «свой/чужой» (E12)
```bash
python -m stylometry.cli verify --preset stylometry_pos_lsa
```
Результат: `results/verification_<preset>_<timestamp>/summary.json` с
ROC-AUC, EER, TAR@FAR=1% и top-1.
### 6.13. Кластеризация (E13)
```bash
python -m stylometry.cli cluster --preset stylometry_pos_lsa
```
Результат: `results/clustering_<preset>_<timestamp>/clustering_metrics.csv`
и `character_centroids.csv`.
### 6.14. Вложенная кросс-валидация (E16)
```bash
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --nested
python -m stylometry.cli run --preset stylometry_pos_lsa --model linear_svc --nested
```
Внешние блоки — оценка качества, внутренние — настройка гиперпараметров.
Сохраняются `best_params.json` и метрики внешних блоков.
### 6.15. Анализ длины и подгрупп (E9, E14, E15)
После основного запуска:
```bash
python -m stylometry.cli length-report \
--run-dir results/<каталог_основного_запуска>
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_основного_запуска> --group gender
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_основного_запуска> --group age_group
python -m stylometry.cli subgroup-report \
--run-dir results/<каталог_основного_запуска> --group species
python -m stylometry.cli compare \
--run-a results/<каталог_основного_запуска> \
--run-b results/<каталог_stylometry_pos> \
--out results/<каталог_основного_запуска>/comparison_main_vs_pos.json
```
`compare` корректно работает, если запуски сделаны с одинаковыми `--folds` и
`--seed`.
---
## 7. Если серия прервалась
Скрипт останавливается при первой ошибке. Уже созданные каталоги результатов
не удаляются. Порядок действий:
1. Посмотрите конец лога:
```bash
tail -n 100 results/logs/run_all_*.log
```
2. Исправьте причину (см. раздел 10).
3. Запустите пропущенные команды вручную из раздела 6. Можно продолжить с
нужной стадии, не повторяя всю серию.
4. Новые результаты появятся в отдельных каталогах с новыми метками времени.
Не удаляйте старые каталоги: по ним можно сравнить, что изменилось.
---
## 8. Упаковка результатов
После завершения серии:
```bash
bash scripts/collect_results.sh
```
Скрипт создаёт:
```text
exports/
├── nlp_lab1_results_<timestamp>.tar.gz
├── nlp_lab1_results_<timestamp>.tar.gz.sha256
└── nlp_lab1_results_<timestamp>_manifest.txt
```
В архив входят:
- `results/` — все запуски, метрики, предсказания, матрицы ошибок, графики,
логи;
- `features/` — матрицы признаков и LSA;
- `data/processed/` — очищенные реплики и аудит;
- `data/metadata/` — метаданные персонажей;
- манифест с версиями библиотек, SHA-256 корпуса и списком файлов.
Передайте коллеге **архив** и **файл `.sha256`**. Если архив большой,
можно передать его через файлообменник, но контрольную сумму нужно сохранить.
Проверка архива на стороне получателя:
```bash
sha256sum -c nlp_lab1_results_<timestamp>.tar.gz.sha256
tar -xzf nlp_lab1_results_<timestamp>.tar.gz
```
---
## 9. Что проверить перед отправкой
- [ ] `python scripts/check_environment.py` завершился без ошибок.
- [ ] `python -m pytest -q` — все тесты пройдены.
- [ ] В логе нет строк `ERROR`, `Traceback`, `FAILED`.
- [ ] Основной запуск `stylometry_pos_lsa` содержит `summary.md`,
`pooled_metrics.csv`, `per_class_report.csv`, `confusion_matrix.csv`.
- [ ] Есть запуски `stylometry`, `stylometry_pos`, `stylometry_pos_lsa`.
- [ ] Есть `results/benchmark_*`, `results/ablation_*`, `results/lsa_sweep_*`.
- [ ] Есть `results/verification_*` и `results/clustering_*`.
- [ ] Есть `results/logs/run_all_*.log`.
- [ ] `collect_results.sh` создал архив и `.sha256`.
---
## 10. Типичные проблемы
### 10.1. `ModuleNotFoundError`
Не установлены зависимости или выбран другой интерпретатор.
```bash
python -m pip install -r requirements.txt
python scripts/check_environment.py
```
Если используете PyCharm, проверьте, что выбран интерпретатор
`nlp/.venv/bin/python` или `lab1/.venv/bin/python`.
### 10.2. Ошибка NLTK: `Resource punkt_tab not found`
```bash
python scripts/download_nltk_data.py
```
Если интернет недоступен, скопируйте каталог `nltk_data` с другой машины в
`lab1/nltk_data`.
### 10.3. `UnicodeDecodeError` при чтении корпуса
Корпус хранится в cp1251. Код сам пробует cp1251, UTF-8 и koi8-r. Если вы
пересохранили CSV в другой кодировке, верните cp1251 или используйте
`dialogues.xlsx`.
### 10.4. `ValueError: empty vocabulary`
Обычно означает, что в обучающем блоке не осталось признаков. Проверьте:
- не пустой ли корпус;
- не отключены ли все блоки признаков;
- не выбран ли `multinomial_nb` с одним LSA без char/POS.
Для стандартных пресетов этой ошибки быть не должно.
### 10.5. Не хватает памяти
Уменьшите размерности:
```bash
python -m stylometry.cli run --preset stylometry_pos --model logreg --repeats 3
```
Или закройте другие приложения. Основной объединённый набор с LSA требует
больше памяти, чем `stylometry_pos`.
### 10.6. Серия идёт слишком долго
- Используйте `QUICK=1` для проверки.
- Уменьшите `REPEATS_FAST`, `REPEATS_ABLATE`, `REPEATS_LSA`.
- Пропустите вложенную CV: `SKIP_NESTED=1`.
- Для финального отчёта всё равно нужны основные повторы; не заменяйте их
быстрым режимом.
### 10.7. `tomllib` не найден
Нужен Python 3.11+. Обновите интерпретатор или создайте окружение на 3.11+.
---
## 11. Полная матрица экспериментов
| ID | Команда | Что проверяет |
|---|---|---|
| E0 | `audit` | Корпус, классы, длины, дубли |
| E1 | `run --preset length/char/pos/fw` | Нижние границы |
| E2 | `run --preset stylometry` | Ручная стилометрия |
| E3 | `ablate --preset stylometry_pos_lsa` | Вклад групп признаков |
| E4 | `run --preset stylometry_pos` | Вклад char/POS n-грамм |
| E5 | `lsa-sweep` и `run --preset lsa_*` | Варианты и число компонент LSA |
| E6 | `benchmark --preset stylometry_pos_lsa` | Сравнение классификаторов |
| E7 | `lsa-sweep` | Стиль или тема |
| E8 | `run --preset stylometry_pos_lsa` | Объединение блоков |
| E9 | `length-report` | Влияние длины |
| E10 | `benchmark` + per-class отчёт | Дисбаланс классов |
| E11 | `delta` | Дельта Барроуза |
| E12 | `verify` | Верификация «свой/чужой» |
| E13 | `cluster` | Кластеризация персонажей |
| E14 | `subgroup-report` | Пол, возраст, вид |
| E15 | `compare` | Мак-Немар и бутстрэп-разница |
| E16 | `run --nested` | Вложенная кросс-валидация |
| E17 | не реализовано | Агрегация в псевдодокументы по 30 предложений |
Подробные команды и интерпретация — в `EXPERIMENTS.md`.
---
## 12. Что именно отправлять обратно
Минимальный комплект:
1. `exports/nlp_lab1_results_<timestamp>.tar.gz`;
2. `exports/nlp_lab1_results_<timestamp>.tar.gz.sha256`;
3. краткий ответ: на какой машине запускали, сколько времени заняла серия,
какие стадии завершились, какие ошибки были.
Если серия не завершилась, отправьте:
- лог `results/logs/run_all_*.log`;
- архив с уже готовыми результатами;
- текст ошибки и команду, на которой она возникла.
---
## 13. Что нельзя менять без согласования
Чтобы результаты оставались сопоставимыми:
- не меняйте корпус и разметку классов;
- не удаляйте реплики и не переименовывайте персонажей;
- не меняйте `--folds`, `--seed` и схему стратификации в основных запусках;
- не меняйте определения метрик;
- не настраивайте гиперпараметры на тестовых блоках;
- не выбирайте признаки по тестовой выборке;
- не заменяйте macro-F1 на долю правильных ответов как основную метрику.
Если хотите добавить новый эксперимент, создайте отдельный сценарий и
сохраните его в `experiments/`, не изменяя основные модули.
---
## 14. Краткая шпаргалка
```bash
cd nlp/lab1
source ../.venv/bin/activate
python scripts/check_environment.py
python -m pytest -q
# быстрая проверка
QUICK=1 bash scripts/run_all_experiments.sh
# полная серия
bash scripts/run_all_experiments.sh
# упаковка результатов
bash scripts/collect_results.sh
# посмотреть лог
tail -f results/logs/run_all_*.log
```
Если что-то пошло не так, сначала пришлите лог и текст ошибки, а не только
вывод последней команды.

27
lab1/configs/default.toml Normal file
View File

@@ -0,0 +1,27 @@
# Конфигурация по умолчанию для экспериментов.
# Все пути указаны относительно каталога lab1.
[data]
path = "data/dialogues.csv"
cache = "data/processed/tokenized.pkl"
[cv]
folds = 5
repeats = 5
seed = 42
[experiment]
# Основной набор: ручные признаки + символьные и частеречные n-граммы + LSA.
preset = "stylometry_pos_lsa"
model = "logreg"
[features]
# Эти параметры описаны в stylometry/pipelines.py и могут переопределяться
# программно. В командной строке удобнее выбирать готовый preset.
# Параметры, которые зависят от preset (например, lsa_variants и
# stylo_normalize), здесь не задаются, чтобы не переопределять preset.
char_ngram_range = [2, 5]
pos_ngram_range = [1, 3]
lsa_components = 100
min_df = 3
stylo_scaler = "standard"

View File

@@ -0,0 +1,49 @@
=== СТАТИСТИКА ПО ПЕРСОНАЖАМ ===
реплик средняя_длина_символов средняя_длина_слов мин_символов макс_символов мин_слов макс_слов
Персонаж
Abigail 139 94.7 18.0 17 260 4 51
Alex 120 83.5 16.1 14 246 3 48
Haley 112 86.4 16.8 21 225 4 43
Sebastian 106 92.6 17.6 31 248 5 47
Emily 97 103.6 19.7 28 214 5 42
Maru 96 90.9 16.9 25 355 4 61
Sam 94 108.3 20.8 22 265 4 48
Elliott 91 88.4 16.7 23 206 4 44
Leah 89 101.7 19.3 31 306 5 56
Penny 84 112.1 21.3 26 336 6 60
Lewis 67 119.9 22.0 37 274 4 49
Pam 65 98.6 19.4 30 270 6 53
Harvey 62 106.2 19.6 35 245 5 52
Shane 62 88.0 17.1 22 204 4 40
Demetrius 56 110.0 19.6 29 214 5 38
Robin 54 111.8 20.7 46 299 9 53
Pierre 53 114.4 21.5 45 286 7 52
Linus 51 103.7 20.1 35 254 7 51
Jodi 48 118.5 23.1 33 274 8 55
Evelyn 47 108.0 20.8 37 325 7 63
Willy 42 114.0 21.8 16 333 3 61
Clint 39 115.1 21.7 31 247 6 51
Gus 37 105.8 19.5 36 228 6 43
Jas 34 60.7 11.9 13 123 4 26
George 32 108.4 20.5 34 299 6 56
Marnie 31 110.0 21.1 58 274 11 48
Kent 30 99.3 19.1 44 406 8 80
Leo 30 120.1 23.5 26 278 4 53
Krobus 28 91.5 17.2 38 239 6 40
Vincent 26 54.8 10.7 17 138 3 27
Wizard 24 119.7 21.8 50 206 9 35
Dwarf 23 100.4 17.8 25 208 4 36
Sandy 21 139.3 26.3 58 282 9 60
Mister Qi 15 114.5 21.0 33 183 6 35
=== ОБЩАЯ СТАТИСТИКА ===
Всего реплик: 2005
Персонажей: 34
Средняя длина (симв.): 100.5
Средняя длина (слов): 19.1
Медиана (симв.): 91.0
Медиана (слов): 17.0
Мин/макс (симв.): 13 / 406
Мин/макс (слов): 3 / 80

View File

@@ -0,0 +1,158 @@
=== ОТЧЁТ ПО КОРПУСУ ===
Всего реплик: 2198
Уникальных реплик: 2177
Персонажей: 36
--- Реплик у каждого персонажа ---
Abigail 148
Haley 127
Sebastian 124
Alex 123
Sam 115
Maru 112
Penny 105
Emily 103
Leah 96
Elliott 95
Lewis 71
Harvey 70
Shane 69
Pam 69
Demetrius 59
Pierre 58
Robin 58
Linus 53
Jodi 53
Evelyn 49
Clint 44
Willy 42
Jas 40
Marnie 39
Gus 38
Kent 34
George 33
Krobus 28
Vincent 26
Wizard 24
Dwarf 23
Sandy 21
LeoMainland 20
Mister Qi 15
Leo 12
Gil 2
--- Дубли внутри персонажа: 13 ---
Elliott x2 "Agh, it's still wriggling! Get that abomination away from me!"
Haley x2 "Aw, thanks!"
Haley x2 "Is that so...?"
Haley x2 "Oh! I thought you knew. This room's for developing photos."
Lewis x2 "Everyone seems a little happier on the weekends, don't you think? I'm on the job seven days a week, "
Lewis x2 "I hope things are going well for you on the farm."
Penny x2 "I see."
Pierre x2 "Hello, er... ?"
Robin x2 "Our little plan worked out well, don't you think? Pam and Penny seem really happy."
Sam x4 "Hey, you know what? That's exactly the kind of style I've been thinking about... for the band."
Sam x2 "Oh, hi! It's good to see you again. Take it easy."
Sam x2 "Well, my Dad is back. Have you met him? I'm just glad he's okay."
Sam x2 "Yeah... That's what happened!"
--- Дубли между персонажами: 6 ---
"Are you having fun, <PLAYER>? You need to remember to take breaks now and then too!"
-> Demetrius, Emily
"Don't you have work to do?"
-> Clint, Shane
"Thank you!"
-> Emily, Evelyn
"That was fun!"
-> Maru, Robin
"Wow, tastes just like golden walnuts! This is great!"
-> Leo, LeoMainland
"sigh ..."
-> Demetrius, Haley
--- Короткие реплики (<=3 слов): 84 ---
Abigail "Oh, hi."
Abigail "...You're cute."
Abigail "Hmm, interesting..."
Abigail "I see..."
Abigail "...That's true."
Abigail "<PLAYER>..."
Abigail "<PLAYER>... I'm alright."
Abigail "...okay."
Abigail "...Um, okay. Sorry."
Alex "Finally, we're here!"
Alex "Uh... never mind."
Alex "Hey, nice tan."
Alex "What's up, <PLAYER>?"
Alex "<PLAYER>! Um... Hi."
Clint "I'm hungry..."
Clint "How'd you know?"
Clint "Hahahaha. Good one."
Demetrius "sigh ..."
Elliott "Hmmph...forget it."
Emily "Thank you!"
Emily "Hi <PLAYER>!"
Emily "Hi! ...Yes?"
Evelyn "Thank you!"
George "...Hmmph."
Gil "... snore ..."
Haley "Ew... No."
Haley "Nice shoes."
Haley "sigh ..."
Haley "Aw, thanks!"
Haley "Is that so...?"
Haley "Aw, thanks!"
Haley "Is that so...?"
Haley "It's fall already?"
Harvey "Sigh ... rude."
Harvey "...You're right. ...Thanks."
Harvey "sigh"
Jas "... Hi..."
Jas "I'm scared..."
Jas "Ew, gross!"
Jas "Hi, Mr. <PLAYER>."
Jas "What? giggle ...hi."
Jas "...Hi."
Jodi "What a treat!"
Jodi "Hi. Need something?"
Leah "You pig!"
Leo "Hi... Everything okay?"
Lewis "Hi, Gus!"
Linus "Find anything good?"
Marnie "I feel warm."
Marnie "Phew... It's hot!"
Maru "That was fun!"
Maru "... <PLAYER>..."
Maru "See you later."
Pam "Hi, stranger."
Penny "Oh... No, thanks."
Penny "I see."
Penny "Oh... Uh, sure."
Penny "Oh...I'm sorry."
Penny "That's right..."
Penny "No!"
Penny "Y...You don't? ...!!!"
Penny "Me too."
Penny "...sorry."
Penny "I see."
Penny "...yes?"
Pierre "Hello, er... ?"
Pierre "Hello, er... ?"
Pierre "Hey, good luck."
Robin "That was fun!"
Sam "Hey, farmer."
Sam "Oh, I see."
Sam "What!? lying!"
Sebastian "Hey..."
Sebastian "Um... need something?"
Sebastian "Okay, that'll work."
Sebastian "Great."
Sebastian "Huh. Sounds boring."
Shane "...No."
Shane "Okay, Dad..."
Shane "Oh, wow..."
Shane "...Yeah, me too."
Vincent "Hi there, mister!"
Vincent "Hi Uncle <PLAYER>!"
Willy "Ahoy there, son."

2006
lab1/data/dialogues.csv Normal file

File diff suppressed because it is too large Load Diff

BIN
lab1/data/dialogues.xlsx Normal file

Binary file not shown.

View File

@@ -0,0 +1,35 @@
character,gender,age_group,species,role
Abigail,female,teen,human,дочь Пьера; искательница приключений
Alex,male,teen,human,спортсмен; сын Джорджа и Эвелины
Clint,male,adult,human,кузнец
Demetrius,male,adult,human,учёный; муж Робин
Dwarf,unknown,adult,monster,подземный торговец
Elliott,male,adult,human,писатель
Emily,female,adult,human,швея; работает в салуне
Evelyn,female,elder,human,бабушка Алекса
George,male,elder,human,дедушка Алекса
Gus,male,adult,human,владелец салуна
Haley,female,teen,human,фотограф; сестра Эмили
Harvey,male,adult,human,врач
Jas,female,child,human,племянница Мэрни
Jodi,female,adult,human,мать Сэма и Винсента
Kent,male,adult,human,военный; муж Джоди
Krobus,unknown,adult,monster,теневой житель канализации
Leah,female,adult,human,художница
Leo,male,child,human,ребёнок с острова
Lewis,male,elder,human,мэр
Linus,male,adult,human,отшельник
Marnie,female,adult,human,владелица ранчо
Maru,female,adult,human,изобретательница; дочь Робин и Деметриуса
Mister Qi,unknown,unknown,unknown,таинственный незнакомец
Pam,female,adult,human,мать Пенни; водитель автобуса
Penny,female,adult,human,учительница; дочь Пэм
Pierre,male,adult,human,владелец магазина
Robin,female,adult,human,плотник; жена Деметриуса
Sam,male,teen,human,музыкант; сын Джоди и Кента
Sandy,female,adult,human,продавщица в пустыне
Sebastian,male,teen,human,программист; сын Робин
Shane,male,adult,human,работник ранчо
Vincent,male,child,human,сын Джоди и Кента
Willy,male,elder,human,рыбак
Wizard,male,adult,unknown,волшебник
1 character gender age_group species role
2 Abigail female teen human дочь Пьера; искательница приключений
3 Alex male teen human спортсмен; сын Джорджа и Эвелины
4 Clint male adult human кузнец
5 Demetrius male adult human учёный; муж Робин
6 Dwarf unknown adult monster подземный торговец
7 Elliott male adult human писатель
8 Emily female adult human швея; работает в салуне
9 Evelyn female elder human бабушка Алекса
10 George male elder human дедушка Алекса
11 Gus male adult human владелец салуна
12 Haley female teen human фотограф; сестра Эмили
13 Harvey male adult human врач
14 Jas female child human племянница Мэрни
15 Jodi female adult human мать Сэма и Винсента
16 Kent male adult human военный; муж Джоди
17 Krobus unknown adult monster теневой житель канализации
18 Leah female adult human художница
19 Leo male child human ребёнок с острова
20 Lewis male elder human мэр
21 Linus male adult human отшельник
22 Marnie female adult human владелица ранчо
23 Maru female adult human изобретательница; дочь Робин и Деметриуса
24 Mister Qi unknown unknown unknown таинственный незнакомец
25 Pam female adult human мать Пенни; водитель автобуса
26 Penny female adult human учительница; дочь Пэм
27 Pierre male adult human владелец магазина
28 Robin female adult human плотник; жена Деметриуса
29 Sam male teen human музыкант; сын Джоди и Кента
30 Sandy female adult human продавщица в пустыне
31 Sebastian male teen human программист; сын Робин
32 Shane male adult human работник ранчо
33 Vincent male child human сын Джоди и Кента
34 Willy male elder human рыбак
35 Wizard male adult unknown волшебник

Binary file not shown.

View File

@@ -0,0 +1,18 @@
# Сценарии экспериментов
Основные сценарии запускаются через командный интерфейс пакета `stylometry`:
```bash
cd lab1
python -m stylometry.cli --help
```
Полный протокол и список экспериментов E0–E17 описаны в `../EXPERIMENTS.md`.
Готовая последовательность базовых запусков — в
`../scripts/run_core_experiments.sh`.
Этот каталог оставлен для отдельных сценариев, которые могут понадобиться
позже: например, агрегация реплик в псевдодокументы по 30 предложений,
тематический контроль или дополнительные признаки. Такие сценарии должны
использовать те же модули `stylometry` и сохранять результаты в
`../results/` по той же схеме.

16
lab1/main.py Normal file
View File

@@ -0,0 +1,16 @@
"""Точка входа для запуска команд стилометрии.
Пример:
python main.py audit
python main.py run --preset stylometry_pos_lsa --model logreg --repeats 5
"""
from __future__ import annotations
import sys
from stylometry.cli import main
if __name__ == "__main__":
sys.exit(main())

10
lab1/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
numpy>=2.0,<3
pandas>=2.2,<4
scipy>=1.12,<2
scikit-learn>=1.5,<2
matplotlib>=3.8,<4
openpyxl>=3.1,<4
nltk>=3.9,<4
joblib>=1.3,<2
tabulate>=0.9,<1
pytest>=8.0,<10

30
lab1/results/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Результаты запусков
Каталоги `*_<timestamp>` создаются командой `python -m stylometry.cli run`.
В каждом запуске сохраняются:
- `run_metadata.json` — конфигурация, версии библиотек, seed;
- `fold_metrics.csv` — метрики по блокам перекрёстной проверки;
- `pooled_metrics.csv` — метрики по объединённым out-of-fold предсказаниям;
- `bootstrap_ci.json` — доверительные интервалы;
- `oof_predictions.csv` — предсказания для каждой реплики;
- `per_class_report.csv` — точность, полнота и F-мера по персонажам;
- `confusion_matrix.csv` и `confusion_matrix_normalized.csv` — матрицы ошибок;
- `top_confusions.csv` — самые частые пары ошибок;
- `summary.md` — краткий отчёт;
- `figures/` — графики.
Каталоги, которые уже лежат здесь, — **предварительные технические прогоны**,
сделанные при разработке конвейера. Они не являются финальными результатами:
часть запусков выполнена с одним повтором и без вложенной настройки
гиперпараметров. Для отчёта нужно перезапустить эксперименты по протоколу
`../EXPERIMENTS.md` и использовать свежие каталоги.
Команды `length-report`, `compare` и `subgroup-report` читают сохранённый
каталог и добавляют в него новые таблицы:
```bash
python -m stylometry.cli length-report --run-dir results/<каталог>
python -m stylometry.cli compare --run-a results/<A> --run-b results/<B>
python -m stylometry.cli subgroup-report --run-dir results/<каталог> --group gender
```

View File

@@ -0,0 +1,77 @@
"""Проверка окружения перед серией экспериментов."""
from __future__ import annotations
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from stylometry.io import DEFAULT_DATA_PATH, corpus_fingerprint, read_corpus, validate_corpus # noqa: E402
from stylometry.tokenization import Tokenizer # noqa: E402
def package_versions() -> dict[str, str]:
versions: dict[str, str] = {}
for name in ("numpy", "pandas", "scipy", "sklearn", "nltk", "matplotlib"):
try:
module = __import__(name)
versions[name] = getattr(module, "__version__", "unknown")
except Exception:
versions[name] = "missing"
return versions
def main() -> int:
print("=== Проверка окружения ===")
print(f"Python: {sys.version.split()[0]} ({sys.executable})")
print(f"Каталог проекта: {PROJECT_ROOT}")
versions = package_versions()
print("Версии библиотек:")
for name, version in versions.items():
print(f" {name}: {version}")
missing = [name for name, version in versions.items() if version == "missing"]
if missing:
print(f"ОШИБКА: не установлены библиотеки: {missing}")
print("Выполните: pip install -r requirements.txt")
return 1
if not DEFAULT_DATA_PATH.exists():
print(f"ОШИБКА: нет файла корпуса {DEFAULT_DATA_PATH}")
return 1
frame = read_corpus(DEFAULT_DATA_PATH)
stats = validate_corpus(frame)
print("Корпус:")
print(f" файл: {DEFAULT_DATA_PATH}")
print(f" SHA-256: {corpus_fingerprint(DEFAULT_DATA_PATH)}")
print(f" реплик: {stats['n_lines']}")
print(f" персонажей: {stats['n_characters']}")
print(f" словоупотреблений: {stats['n_words']}")
try:
tokenizer = Tokenizer(use_nltk=True, strict=True)
except Exception as error:
print(f"ОШИБКА NLTK: {error}")
print("Выполните: python scripts/download_nltk_data.py")
return 1
analyzed = tokenizer.analyze(frame.iloc[0]["text"])
print("NLTK:")
print(f" токенизатор: {'NLTK' if tokenizer.use_nltk else 'резервный'}")
print(f" POS-теггер: {'доступен' if tokenizer.pos_available else 'недоступен'}")
print(f" пример токенов: {analyzed.tokens[:8]}")
print(f" пример POS: {analyzed.upos[:8]}")
if not tokenizer.pos_available:
print("ОШИБКА: POS-теггер недоступен; эксперименты с POS будут неполными.")
return 1
print("\nОкружение готово.")
print(json.dumps({"versions": versions, "n_lines": stats["n_lines"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

56
lab1/scripts/collect_results.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Упаковка результатов для передачи коллеге.
#
# Запуск из каталога lab1:
# bash scripts/collect_results.sh
#
# Создаёт exports/nlp_lab1_results_<timestamp>.tar.gz и файл SHA-256.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
PYTHON="${PYTHON:-python}"
RUN_ID="${RUN_ID:-$(date +%Y%m%d_%H%M%S)}"
OUT_DIR="${OUT_DIR:-${PROJECT_ROOT}/exports}"
ARCHIVE="${OUT_DIR}/nlp_lab1_results_${RUN_ID}.tar.gz"
MANIFEST="${OUT_DIR}/nlp_lab1_results_${RUN_ID}_manifest.txt"
mkdir -p "${OUT_DIR}"
{
echo "Дата: $(date)"
echo "Каталог: ${PROJECT_ROOT}"
echo "Интерпретатор: ${PYTHON}"
echo
"${PYTHON}" scripts/check_environment.py || true
echo
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Git commit: $(git rev-parse HEAD 2>/dev/null || echo 'нет коммита')"
else
echo "Git: репозиторий не инициализирован"
fi
echo
echo "Содержимое results:"
find results -maxdepth 2 -type f | sort | head -n 500
} > "${MANIFEST}"
INCLUDE=()
for path in results features data/processed data/metadata; do
if [[ -e "${path}" ]]; then
INCLUDE+=("${path}")
fi
done
INCLUDE+=("${MANIFEST#"${PROJECT_ROOT}/"}")
tar -czf "${ARCHIVE}" "${INCLUDE[@]}"
sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256"
echo "Архив: ${ARCHIVE}"
echo "Контрольная сумма: ${ARCHIVE}.sha256"
echo "Манифест: ${MANIFEST}"
echo
echo "Передайте коллеге архив и файл .sha256."

View File

@@ -0,0 +1,29 @@
"""Скачивает ресурсы NLTK в локальный каталог lab1/nltk_data."""
from __future__ import annotations
from pathlib import Path
import nltk
PACKAGES = [
"punkt",
"punkt_tab",
"averaged_perceptron_tagger",
"averaged_perceptron_tagger_eng",
"stopwords",
]
def main() -> int:
target = Path(__file__).resolve().parents[1] / "nltk_data"
target.mkdir(parents=True, exist_ok=True)
for package in PACKAGES:
print(f"Скачивание {package} ...")
nltk.download(package, download_dir=str(target), quiet=False)
print(f"Готово: {target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Полная серия экспериментов для лабораторной работы.
#
# Запуск из каталога lab1:
# bash scripts/run_all_experiments.sh
#
# С другим интерпретатором:
# PYTHON=../.venv/bin/python bash scripts/run_all_experiments.sh
#
# Быстрая проверка (1 повтор, без вложенной кросс-валидации):
# QUICK=1 bash scripts/run_all_experiments.sh
#
# Все команды пишутся в results/logs/run_all_<timestamp>.log.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
PYTHON="${PYTHON:-python}"
if [[ "${PYTHON}" == */* ]]; then
PYTHON_DIR="$(cd "$(dirname "${PYTHON}")" && pwd)"
PYTHON="${PYTHON_DIR}/$(basename "${PYTHON}")"
fi
if ! "${PYTHON}" -c "import sklearn, pandas, nltk" >/dev/null 2>&1; then
echo "ОШИБКА: в интерпретаторе ${PYTHON} не установлены зависимости."
echo "Выполните: ${PYTHON} -m pip install -r requirements.txt"
exit 1
fi
REPEATS_MAIN="${REPEATS_MAIN:-5}"
REPEATS_FAST="${REPEATS_FAST:-3}"
REPEATS_LSA="${REPEATS_LSA:-3}"
REPEATS_ABLATE="${REPEATS_ABLATE:-3}"
REPEATS_DELTA="${REPEATS_DELTA:-2}"
SKIP_NESTED="${SKIP_NESTED:-0}"
if [[ "${QUICK:-0}" == "1" ]]; then
REPEATS_MAIN=1
REPEATS_FAST=1
REPEATS_LSA=1
REPEATS_ABLATE=1
REPEATS_DELTA=1
SKIP_NESTED=1
fi
LOG_DIR="${PROJECT_ROOT}/results/logs"
mkdir -p "${LOG_DIR}"
RUN_ID="${RUN_ID:-$(date +%Y%m%d_%H%M%S)}"
LOG_FILE="${LOG_DIR}/run_all_${RUN_ID}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
step() {
echo
echo "============================================================"
echo "== $*"
echo "============================================================"
}
run() {
echo
echo ">>> $*"
"$@"
}
latest_dir() {
local prefix="$1"
ls -1dt results/"${prefix}"_* 2>/dev/null | head -n 1 || true
}
echo "Начало серии: $(date)"
echo "Интерпретатор: ${PYTHON}"
echo "Повторы: main=${REPEATS_MAIN}, fast=${REPEATS_FAST}, lsa=${REPEATS_LSA}, ablate=${REPEATS_ABLATE}, delta=${REPEATS_DELTA}"
echo "Вложенная кросс-валидация: ${SKIP_NESTED} (1 — пропустить)"
echo "Лог: ${LOG_FILE}"
step "0. Проверка окружения и тесты"
run "${PYTHON}" scripts/check_environment.py
run "${PYTHON}" -m pytest -q
step "1. Аудит корпуса и ручные признаки (E0)"
run "${PYTHON}" -m stylometry.cli audit
run "${PYTHON}" -m stylometry.cli features
step "2. Нижние границы (E1)"
run "${PYTHON}" -m stylometry.cli run --preset length --model dummy --repeats 1
run "${PYTHON}" -m stylometry.cli run --preset length --model logreg --repeats "${REPEATS_MAIN}"
run "${PYTHON}" -m stylometry.cli run --preset char --model multinomial_nb --repeats "${REPEATS_FAST}"
run "${PYTHON}" -m stylometry.cli run --preset pos --model multinomial_nb --repeats "${REPEATS_FAST}"
run "${PYTHON}" -m stylometry.cli run --preset fw --model logreg --repeats "${REPEATS_FAST}"
step "3. Классическая стилометрия (E2)"
run "${PYTHON}" -m stylometry.cli run --preset stylometry --model logreg --repeats "${REPEATS_MAIN}"
run "${PYTHON}" -m stylometry.cli run --preset stylometry --model linear_svc --repeats "${REPEATS_MAIN}"
step "4. Ручные признаки + n-граммы (E4)"
run "${PYTHON}" -m stylometry.cli run --preset stylometry_pos --model logreg --repeats "${REPEATS_MAIN}"
step "5. Основной объединённый набор (E8)"
run "${PYTHON}" -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats "${REPEATS_MAIN}"
step "6. Варианты LSA по отдельности (E5, E7)"
for variant in word word2 content fw char pos; do
run "${PYTHON}" -m stylometry.cli run --preset "lsa_${variant}" --model logreg --repeats "${REPEATS_LSA}"
done
step "7. Перебор числа компонент LSA (E5)"
run "${PYTHON}" -m stylometry.cli lsa-sweep \
--variants word,content,fw,char,pos \
--components 50,100,200 \
--model logreg --repeats 1
step "8. Сравнение классификаторов (E6)"
run "${PYTHON}" -m stylometry.cli benchmark \
--preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest,multinomial_nb,knn \
--repeats "${REPEATS_FAST}"
step "9. Поочерёдное исключение групп признаков (E3)"
run "${PYTHON}" -m stylometry.cli ablate \
--preset stylometry_pos_lsa --model logreg --repeats "${REPEATS_ABLATE}"
step "10. Дельта Барроуза (E11)"
run "${PYTHON}" -m stylometry.cli delta \
--features 100,200,500 --metrics manhattan,cosine --repeats "${REPEATS_DELTA}"
step "11. Верификация «свой/чужой» (E12)"
run "${PYTHON}" -m stylometry.cli verify --preset stylometry_pos_lsa
step "12. Кластеризация персонажей (E13)"
run "${PYTHON}" -m stylometry.cli cluster --preset stylometry_pos_lsa
if [[ "${SKIP_NESTED}" != "1" ]]; then
step "13. Вложенная кросс-валидация (E16)"
run "${PYTHON}" -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --nested
run "${PYTHON}" -m stylometry.cli run --preset stylometry_pos_lsa --model linear_svc --nested
else
echo
echo "Вложенная кросс-валидация пропущена (SKIP_NESTED=1 или QUICK=1)."
fi
step "14. Анализ длины, подгрупп и сравнение запусков (E9, E14, E15)"
MAIN_DIR="$(latest_dir stylometry_pos_lsa_logreg)"
POS_DIR="$(latest_dir stylometry_pos_logreg)"
if [[ -n "${MAIN_DIR}" ]]; then
run "${PYTHON}" -m stylometry.cli length-report --run-dir "${MAIN_DIR}"
for group in gender age_group species; do
run "${PYTHON}" -m stylometry.cli subgroup-report \
--run-dir "${MAIN_DIR}" --group "${group}"
done
if [[ -n "${POS_DIR}" ]]; then
run "${PYTHON}" -m stylometry.cli compare \
--run-a "${MAIN_DIR}" --run-b "${POS_DIR}" \
--out "${MAIN_DIR}/comparison_main_vs_pos.json"
fi
else
echo "Не найден каталог основного запуска; анализ пропущен."
fi
step "Готово"
echo "Конец серии: $(date)"
echo
echo "Основные каталоги результатов:"
ls -1dt results/*/ 2>/dev/null | head -n 30 || true
echo
echo "Полный лог: ${LOG_FILE}"
echo "Для упаковки результатов: bash scripts/collect_results.sh"

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Базовая серия экспериментов. Запускать из каталога lab1.
# Для финального отчёта увеличьте --repeats до 5.
set -euo pipefail
python -m stylometry.cli audit
python -m stylometry.cli features
# E1: нижние границы
python -m stylometry.cli run --preset length --model dummy --repeats 1
python -m stylometry.cli run --preset length --model logreg --repeats 3
python -m stylometry.cli run --preset char --model multinomial_nb --repeats 3
python -m stylometry.cli run --preset pos --model multinomial_nb --repeats 3
python -m stylometry.cli run --preset fw --model logreg --repeats 3
# E2-E4: ручные признаки, POS и объединение
python -m stylometry.cli run --preset stylometry --model logreg --repeats 3
python -m stylometry.cli run --preset stylometry_pos --model logreg --repeats 3
python -m stylometry.cli run --preset stylometry_pos_lsa --model logreg --repeats 3
python -m stylometry.cli ablate --preset stylometry_pos_lsa --model logreg --repeats 2
# E5-E6: LSA и классификаторы
python -m stylometry.cli lsa-sweep --variants word,pos,char --components 50,100,200 --model logreg --repeats 1
python -m stylometry.cli benchmark --preset stylometry_pos_lsa \
--models logreg,linear_svc,random_forest,multinomial_nb,knn --repeats 2
# E11-E13: дельта, верификация, кластеризация
python -m stylometry.cli delta --features 100,200,500 --metrics manhattan,cosine --repeats 2
python -m stylometry.cli verify --preset stylometry_pos_lsa
python -m stylometry.cli cluster --preset stylometry_pos_lsa

View File

@@ -0,0 +1,13 @@
"""Стилометрия реплик Stardew Valley.
Основные модули:
- ``io`` — чтение и валидация корпуса;
- ``cleaning`` — нормализация текста;
- ``tokenization`` — NLTK-токенизация, Universal POS, резервный режим;
- ``features`` — ручные стилометрические признаки;
- ``lsa`` — TF-IDF и латентно-семантический анализ;
- ``pipelines`` — сборка признаковых пространств и классификаторов;
- ``cv``, ``evaluation``, ``stats`` — кросс-валидация, метрики и статистика.
"""
__version__ = "1.0.0"

123
lab1/stylometry/cleaning.py Normal file
View File

@@ -0,0 +1,123 @@
"""Нормализация текста реплик.
Модуль намеренно не выполняет агрессивную очистку: корпус уже отфильтрован
вручную. Здесь только приводятся к единому виду Unicode, плейсхолдеры и
пробельные символы. Пунктуация, регистр и разговорные элементы сохраняются,
поскольку они являются стилометрическими признаками.
"""
from __future__ import annotations
import re
import unicodedata
from typing import Iterable
# Плейсхолдеры игрового движка. Они не являются авторским текстом, поэтому
# заменяются на нейтральные слова, а их количество учитывается отдельным
# признаком.
PLACEHOLDER_PATTERN = re.compile(r"<([A-Z][A-Z0-9_]*)>")
PLACEHOLDER_MAP: dict[str, str] = {
"PLAYER": "PLAYER",
"FARM": "FARM",
"PET": "PET",
"SPOUSE": "SPOUSE",
"TIME": "TIME",
"NOUN": "NOUN",
"FIRST_LETTER": "FIRST_LETTER",
"NAME": "NAME",
}
# Символы, которые в корпусе могут встречаться в разных типографских
# вариантах. Приводим их к ASCII-эквивалентам, чтобы признаки были
# сопоставимы между репликами.
_UNICODE_REPLACEMENTS: dict[str, str] = {
"\u2026": "...", # многоточие
"\u2018": "'",
"\u2019": "'",
"\u201a": "'",
"\u201b": "'",
"\u201c": '"',
"\u201d": '"',
"\u201e": '"',
"\u2013": "-", # короткое тире
"\u2014": "-", # длинное тире
"\u2212": "-", # математический минус
"\u00a0": " ", # неразрывный пробел
"\u2009": " ", # тонкий пробел
"\u200a": " ",
"\u200b": "", # нулевой пробел
"\u200c": "",
"\u200d": "",
"\ufeff": "",
}
def normalize_unicode(text: str) -> str:
"""Приводит Unicode к предсказуемому виду, не меняя смысл текста."""
text = unicodedata.normalize("NFKC", str(text))
for source, target in _UNICODE_REPLACEMENTS.items():
text = text.replace(source, target)
return text
def normalize_placeholders(text: str) -> str:
"""Заменяет ``<PLAYER>`` и аналогичные метки на слова без угловых скобок."""
def _replace(match: re.Match[str]) -> str:
name = match.group(1)
return PLACEHOLDER_MAP.get(name, "ENTITY")
return PLACEHOLDER_PATTERN.sub(_replace, text)
def normalize_whitespace(text: str) -> str:
"""Схлопывает повторные пробелы и переводы строк."""
text = re.sub(r"[ \t\f\v]+", " ", text)
text = re.sub(r"\s*\n\s*", " ", text)
return text.strip()
def clean_text(text: str) -> str:
"""Основная функция нормализации одной реплики."""
text = normalize_unicode(text)
text = normalize_placeholders(text)
text = normalize_whitespace(text)
return text
def normalized_key(text: str) -> str:
"""Ключ для поиска точных дублей.
Регистр, пунктуация и плейсхолдеры игнорируются. Такой ключ использовался
при аудите корпуса; для обучения он не применяется.
"""
text = text.lower()
text = PLACEHOLDER_PATTERN.sub("", text)
text = re.sub(r"[^a-z0-9' ]+", " ", text)
return normalize_whitespace(text)
def count_placeholders(text: str) -> int:
"""Возвращает число плейсхолдеров в исходной реплике."""
return len(PLACEHOLDER_PATTERN.findall(text))
def placeholder_types(text: str) -> list[str]:
"""Возвращает типы плейсхолдеров в порядке появления."""
return [match.group(1) for match in PLACEHOLDER_PATTERN.finditer(text)]
def is_valid_line(text: str) -> bool:
"""Отбрасывает пустые строки и строки без латинских букв."""
return bool(re.search(r"[A-Za-z]", text))
def tokenize_for_audit(text: str) -> list[str]:
"""Простая токенизация для аудита; не заменяет NLTK-токенизатор."""
return re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?|\d+|[^\w\s]", text)
def join_tokens(tokens: Iterable[str]) -> str:
"""Склеивает токены через пробел, сохраняя их как отдельные единицы."""
return " ".join(str(token) for token in tokens)

866
lab1/stylometry/cli.py Normal file
View File

@@ -0,0 +1,866 @@
"""Командный интерфейс для аудита, признаков, LSA и экспериментов."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Sequence
import numpy as np
import pandas as pd
from .clustering import run_clustering
from .cv import CVResult, run_nested_cv, run_repeated_cv
from .datasets import DEFAULT_CACHE_PATH, load_feature_frame
from .delta import DeltaClassifier
from .evaluation import (
bootstrap_ci,
classification_metrics,
confusion_matrix_frame,
per_class_report,
top_confusions,
)
from .features import StylometricVectorizer, feature_dictionary
from .io import (
DEFAULT_DATA_PATH,
compare_with_stats,
corpus_fingerprint,
read_corpus,
read_stats,
validate_corpus,
write_csv,
)
from .lsa import LSA_VARIANTS, make_lsa_vectorizer
from .metadata import group_confusions, load_character_metadata, subgroup_report
from .pipelines import (
MODEL_NAMES,
FeatureConfig,
build_pipeline,
preset_config,
)
from .plotting import (
plot_class_distribution,
plot_confusion_matrix,
plot_pca,
plot_scree,
)
from .stats import bootstrap_difference, mcnemar_exact
from .verification import run_verification
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_RESULTS_DIR = PROJECT_ROOT / "results"
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "configs" / "default.toml"
def _package_versions() -> dict[str, str]:
versions = {}
for name in ("numpy", "pandas", "scipy", "sklearn", "nltk", "matplotlib"):
try:
module = __import__(name)
versions[name] = getattr(module, "__version__", "unknown")
except Exception:
versions[name] = "missing"
return versions
def _json_default(value: Any) -> Any:
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating,)):
return float(value)
if isinstance(value, (np.ndarray,)):
return value.tolist()
if isinstance(value, Path):
return str(value)
return str(value)
def save_json(data: Any, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream:
json.dump(data, stream, ensure_ascii=False, indent=2, default=_json_default)
return path
def _run_id(prefix: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{prefix}_{timestamp}"
def _safe_name(value: str) -> str:
return "".join(char if char.isalnum() or char in "-_." else "_" for char in value)
def _markdown_table(frame: pd.DataFrame) -> str:
try:
return frame.to_markdown(index=False)
except Exception:
return "```\n" + frame.to_string(index=False) + "\n```"
def _load_config(path: str | Path | None) -> dict[str, Any]:
if path is None:
path = DEFAULT_CONFIG_PATH
path = Path(path)
if not path.exists():
return {}
try:
import tomllib
with path.open("rb") as stream:
return tomllib.load(stream)
except Exception as error:
print(f"[config] не удалось прочитать {path}: {error}")
return {}
def _feature_config(preset: str, overrides: dict[str, Any] | None = None) -> FeatureConfig:
config = preset_config(preset)
if overrides:
for key, value in overrides.items():
if hasattr(config, key):
setattr(config, key, value)
return config
def _frame(args: argparse.Namespace) -> pd.DataFrame:
data_path = Path(args.data) if getattr(args, "data", None) else DEFAULT_DATA_PATH
cache_path = Path(args.cache) if getattr(args, "cache", None) else DEFAULT_CACHE_PATH
return load_feature_frame(
data_path=data_path,
cache_path=cache_path,
force=getattr(args, "no_cache", False),
verbose=True,
)
def _first_repeat(oof: pd.DataFrame) -> pd.DataFrame:
if oof.empty:
return oof
repeat = oof["repeat"].min()
return oof[oof["repeat"] == repeat].copy()
def _save_cv_result(
result: CVResult,
output_dir: Path,
config: dict[str, Any],
title: str,
) -> pd.DataFrame:
output_dir.mkdir(parents=True, exist_ok=True)
config.setdefault("package_versions", _package_versions())
result.fold_metrics.to_csv(output_dir / "fold_metrics.csv", index=False, encoding="utf-8-sig")
result.oof.to_csv(output_dir / "oof_predictions.csv", index=False, encoding="utf-8-sig")
fold_summary = result.summary()
fold_summary.to_csv(output_dir / "fold_summary.csv", index=False, encoding="utf-8-sig")
pooled, bootstrap = result.pooled_summary()
pooled.to_csv(output_dir / "pooled_metrics.csv", index=False, encoding="utf-8-sig")
save_json(bootstrap, output_dir / "bootstrap_ci.json")
first = _first_repeat(result.oof)
per_class = per_class_report(first["y_true"], first["y_pred"], result.labels)
per_class.to_csv(output_dir / "per_class_report.csv", index=False, encoding="utf-8-sig")
matrix = confusion_matrix_frame(first["y_true"], first["y_pred"], result.labels)
matrix.to_csv(output_dir / "confusion_matrix.csv", encoding="utf-8-sig")
normalized = confusion_matrix_frame(
first["y_true"], first["y_pred"], result.labels, normalize="true"
)
normalized.to_csv(output_dir / "confusion_matrix_normalized.csv", encoding="utf-8-sig")
top_confusions(first["y_true"], first["y_pred"], result.labels).to_csv(
output_dir / "top_confusions.csv", index=False, encoding="utf-8-sig"
)
plot_confusion_matrix(
matrix, output_dir / "figures" / "confusion_matrix.png", title="Матрица ошибок"
)
test_distribution = pd.DataFrame({"character": first["y_true"].values})
plot_class_distribution(
test_distribution,
character_column="character",
path=output_dir / "figures" / "test_class_distribution.png",
)
summary_row = pooled.iloc[0].to_dict() if not pooled.empty else {}
summary_row.update(
{
"model": config.get("model", ""),
"preset": config.get("preset", ""),
"folds": result.n_splits,
"repeats": result.n_repeats,
"n_samples": int(len(result.oof) // max(result.n_repeats, 1)),
"n_classes": int(len(result.labels)),
}
)
save_json({"title": title, "config": config, "metrics": summary_row, "bootstrap": bootstrap}, output_dir / "run_metadata.json")
lines = [
f"# {title}",
"",
f"- Модель: `{config.get('model', '')}`",
f"- Набор признаков: `{config.get('preset', '')}`",
f"- Кросс-валидация: {result.n_splits}-fold, повторов: {result.n_repeats}",
f"- Объектов: {summary_row.get('n_samples', '')}",
f"- Классов: {summary_row.get('n_classes', '')}",
"",
"## Основные метрики",
"",
_markdown_table(fold_summary),
"",
"## Pooled out-of-fold",
"",
_markdown_table(pooled),
"",
"## Доверительные интервалы (bootstrap)",
"",
"```json",
json.dumps(bootstrap, ensure_ascii=False, indent=2, default=_json_default),
"```",
"",
]
(output_dir / "summary.md").write_text("\n".join(lines), encoding="utf-8")
return fold_summary
def cmd_audit(args: argparse.Namespace) -> int:
data_path = Path(args.data) if args.data else DEFAULT_DATA_PATH
frame = read_corpus(data_path)
stats = validate_corpus(frame)
counts = pd.DataFrame(
sorted(stats["class_counts"].items(), key=lambda item: item[1], reverse=True),
columns=["Персонаж", "Реплик"],
)
word_lengths = frame["text"].str.findall(r"[A-Za-z0-9']+").map(len)
bins = pd.cut(
word_lengths,
bins=[0, 5, 10, 15, 20, 30, 1000],
labels=["0–5", "6–10", "11–15", "16–20", "21–30", "31+"],
include_lowest=True,
).value_counts().sort_index()
bin_frame = pd.DataFrame({"Длина реплики (слов)": bins.index.astype(str), "Реплик": bins.values})
comparison = compare_with_stats(frame, read_stats())
output_dir = Path(args.out) if args.out else PROJECT_ROOT / "data" / "processed"
output_dir.mkdir(parents=True, exist_ok=True)
write_csv(frame, output_dir / "lines.csv")
lines = [
"# Аудит корпуса",
"",
f"- Файл: `{data_path}`",
f"- SHA-256: `{corpus_fingerprint(data_path)}`",
f"- Реплик: {stats['n_lines']}",
f"- Персонажей: {stats['n_characters']}",
f"- Словоупотреблений: {stats['n_words']}",
f"- Средняя длина: {stats['mean_words_per_line']:.2f} слова",
f"- Медиана: {stats['median_words_per_line']:.1f} слова",
f"- Минимум/максимум: {stats['min_words_per_line']} / {stats['max_words_per_line']} слов",
f"- Точных дублей текста: {stats['n_duplicate_texts']}",
f"- Реплик с плейсхолдерами: {stats['n_placeholder_lines']}",
f"- Всего плейсхолдеров: {stats['n_placeholders']}",
"",
"## Распределение по персонажам",
"",
_markdown_table(counts),
"",
"## Распределение длин реплик",
"",
_markdown_table(bin_frame),
"",
]
if not comparison.empty:
lines.extend(
[
"## Сверка с stats_per_character.xlsx",
"",
_markdown_table(comparison.reset_index()),
"",
]
)
(output_dir / "audit.md").write_text("\n".join(lines), encoding="utf-8")
plot_class_distribution(frame, path=output_dir / "figures" / "class_distribution.png")
print(f"[audit] реплик: {stats['n_lines']}, персонажей: {stats['n_characters']}")
print(f"[audit] отчёт: {output_dir / 'audit.md'}")
return 0
def cmd_features(args: argparse.Namespace) -> int:
frame = _frame(args)
output_dir = Path(args.out) if args.out else PROJECT_ROOT / "features"
output_dir.mkdir(parents=True, exist_ok=True)
vectorizer = StylometricVectorizer()
matrix = vectorizer.fit_transform(frame)
names = vectorizer.get_feature_names_out()
result = pd.DataFrame(matrix, columns=names)
result.insert(0, "character", frame["character"].values)
result.insert(0, "line_id", frame["line_id"].values)
write_csv(result, output_dir / "stylometric.csv")
dictionary = feature_dictionary(names)
write_csv(dictionary, output_dir / "feature_dictionary.csv")
print(f"[features] матрица: {matrix.shape}; словарь: {output_dir / 'feature_dictionary.csv'}")
return 0
def cmd_lsa(args: argparse.Namespace) -> int:
frame = _frame(args)
output_dir = Path(args.out) if args.out else PROJECT_ROOT / "features"
output_dir.mkdir(parents=True, exist_ok=True)
variant = args.variant
components = args.components
vectorizer = make_lsa_vectorizer(variant, n_components=components)
matrix = vectorizer.fit_transform(frame)
result = pd.DataFrame(matrix, columns=vectorizer.get_feature_names_out())
result.insert(0, "character", frame["character"].values)
result.insert(0, "line_id", frame["line_id"].values)
path = output_dir / f"lsa_{variant}_k{vectorizer.n_components_}.csv"
write_csv(result, path)
top_terms = []
for component_index, terms in enumerate(vectorizer.top_terms(15), start=1):
for rank, (term, weight) in enumerate(terms, start=1):
top_terms.append(
{
"component": component_index,
"rank": rank,
"term": term,
"weight": weight,
}
)
write_csv(pd.DataFrame(top_terms), output_dir / f"lsa_{variant}_top_terms.csv")
variance = pd.DataFrame(
{
"component": np.arange(1, vectorizer.n_components_ + 1),
"explained_variance_ratio": vectorizer.explained_variance_ratio_,
"cumulative_explained_variance": vectorizer.cumulative_explained_variance_,
}
)
write_csv(variance, output_dir / f"lsa_{variant}_variance.csv")
plot_scree(
vectorizer.explained_variance_ratio_,
output_dir / f"lsa_{variant}_scree.png",
title=f"LSA ({variant}): объяснённая дисперсия",
)
print(
f"[lsa] вариант={variant}, компонент={vectorizer.n_components_}, "
f"накопленная дисперсия={vectorizer.cumulative_explained_variance_[-1]:.3f}"
)
print(f"[lsa] матрица: {path}")
return 0
def cmd_run(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
config_data = _load_config(args.config)
preset = args.preset or config_data.get("experiment", {}).get("preset", "stylometry_pos_lsa")
model = args.model or config_data.get("experiment", {}).get("model", "logreg")
folds = (
args.folds if args.folds is not None
else config_data.get("cv", {}).get("folds", 5)
)
repeats = (
args.repeats if args.repeats is not None
else config_data.get("cv", {}).get("repeats", 5)
)
seed = (
args.seed if args.seed is not None
else config_data.get("cv", {}).get("seed", 42)
)
overrides = dict(config_data.get("features", {}))
for key in ("char_ngram_range", "pos_ngram_range"):
if key in overrides and isinstance(overrides[key], list):
overrides[key] = tuple(overrides[key])
if "lsa_variants" in overrides and isinstance(overrides["lsa_variants"], list):
overrides["lsa_variants"] = tuple(overrides["lsa_variants"])
feature_config = _feature_config(preset, overrides)
pipeline = build_pipeline(feature_config, model)
run_config = {
"preset": preset,
"model": model,
"folds": folds,
"repeats": repeats,
"seed": seed,
"n_samples": int(len(frame)),
"n_classes": int(len(np.unique(y))),
"corpus_sha256": frame.attrs.get("corpus_sha256", ""),
"feature_config": dict(feature_config.__dict__),
"package_versions": _package_versions(),
}
if args.nested:
result = run_nested_cv(
pipeline, frame, y, model_name=model,
outer_splits=folds, inner_splits=3, seed=seed, verbose=True,
)
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"nested_{preset}_{model}")
_save_cv_result(result, output_dir, run_config, f"Вложенная CV: {preset} / {model}")
save_json(result.best_params, output_dir / "best_params.json")
else:
result = run_repeated_cv(
pipeline, frame, y, n_splits=folds, n_repeats=repeats,
seed=seed, model_name=model, verbose=True,
)
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"{preset}_{model}")
_save_cv_result(result, output_dir, run_config, f"CV: {preset} / {model}")
print(f"[run] результаты: {output_dir}")
print(result.summary().to_string(index=False))
return 0
def cmd_benchmark(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
preset = args.preset
models = args.models.split(",") if args.models else [
"logreg", "linear_svc", "random_forest", "multinomial_nb", "knn"
]
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"benchmark_{preset}")
output_dir.mkdir(parents=True, exist_ok=True)
rows = []
for model in models:
model = model.strip()
if model not in MODEL_NAMES:
print(f"[benchmark] пропускаю неизвестную модель {model}")
continue
print(f"\n[benchmark] модель: {model}")
pipeline = build_pipeline(_feature_config(preset), model)
result = run_repeated_cv(
pipeline, frame, y, n_splits=args.folds, n_repeats=args.repeats,
seed=args.seed, model_name=model, verbose=False,
)
pooled, bootstrap = result.pooled_summary()
first = pooled.iloc[0].to_dict() if not pooled.empty else {}
rows.append(
{
"model": model,
"macro_f1": first.get("macro_f1"),
"balanced_accuracy": first.get("balanced_accuracy"),
"accuracy": first.get("accuracy"),
"mcc": first.get("mcc"),
"cohen_kappa": first.get("cohen_kappa"),
"top3_accuracy": first.get("top3_accuracy"),
"top5_accuracy": first.get("top5_accuracy"),
"macro_f1_ci_lower": bootstrap["macro_f1"]["lower"],
"macro_f1_ci_upper": bootstrap["macro_f1"]["upper"],
}
)
model_dir = output_dir / _safe_name(model)
_save_cv_result(
result,
model_dir,
{
"preset": preset,
"model": model,
"corpus_sha256": frame.attrs.get("corpus_sha256", ""),
},
f"Benchmark: {preset} / {model}",
)
summary = pd.DataFrame(rows).sort_values("macro_f1", ascending=False)
write_csv(summary, output_dir / "summary.csv")
print("\n" + summary.to_string(index=False))
return 0
def cmd_ablate(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
base = _feature_config(args.preset)
groups = list(base.groups)
if not groups:
print("[ablate] в выбранном наборе нет ручных групп признаков.")
return 1
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"ablation_{args.preset}")
output_dir.mkdir(parents=True, exist_ok=True)
rows = []
variants = [("full", groups)] + [
(f"without_{group}", [value for value in groups if value != group])
for group in groups
]
for name, selected in variants:
config = FeatureConfig(
groups=tuple(selected),
use_char_ngrams=base.use_char_ngrams,
use_pos_ngrams=base.use_pos_ngrams,
lsa_variants=base.lsa_variants,
lsa_components=base.lsa_components,
stylo_scaler=base.stylo_scaler,
stylo_normalize=base.stylo_normalize,
include_stylo=True,
include_lsa=base.include_lsa,
char_max_features=base.char_max_features,
pos_max_features=base.pos_max_features,
char_ngram_range=base.char_ngram_range,
pos_ngram_range=base.pos_ngram_range,
min_df=base.min_df,
random_state=base.random_state,
)
pipeline = build_pipeline(config, args.model)
result = run_repeated_cv(
pipeline, frame, y, n_splits=args.folds, n_repeats=args.repeats,
seed=args.seed, model_name=args.model, verbose=False,
)
pooled, _ = result.pooled_summary()
first = pooled.iloc[0].to_dict() if not pooled.empty else {}
rows.append({"variant": name, "macro_f1": first.get("macro_f1"), "balanced_accuracy": first.get("balanced_accuracy"), "accuracy": first.get("accuracy")})
print(f"[ablate] {name}: macro-F1={first.get('macro_f1', float('nan')):.4f}")
table = pd.DataFrame(rows)
full_value = table.loc[table["variant"] == "full", "macro_f1"].iloc[0]
table["delta_macro_f1"] = table["macro_f1"] - full_value
write_csv(table, output_dir / "ablation.csv")
print(f"[ablate] результаты: {output_dir / 'ablation.csv'}")
return 0
def cmd_lsa_sweep(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
variants = args.variants.split(",")
components = [int(value) for value in args.components.split(",")]
output_dir = DEFAULT_RESULTS_DIR / _run_id("lsa_sweep")
output_dir.mkdir(parents=True, exist_ok=True)
rows = []
for variant in variants:
for k in components:
print(f"[lsa-sweep] {variant}, k={k}")
config = FeatureConfig(
groups=(),
use_char_ngrams=False,
use_pos_ngrams=False,
lsa_variants=(variant,),
lsa_components=k,
include_stylo=False,
include_lsa=True,
)
pipeline = build_pipeline(config, args.model)
result = run_repeated_cv(
pipeline, frame, y, n_splits=args.folds, n_repeats=args.repeats,
seed=args.seed, model_name=args.model, verbose=False,
)
pooled, _ = result.pooled_summary()
first = pooled.iloc[0].to_dict() if not pooled.empty else {}
rows.append({"variant": variant, "n_components": k, "macro_f1": first.get("macro_f1"), "accuracy": first.get("accuracy"), "top3_accuracy": first.get("top3_accuracy")})
table = pd.DataFrame(rows).sort_values(["macro_f1"], ascending=False)
write_csv(table, output_dir / "lsa_sweep.csv")
print(table.to_string(index=False))
return 0
def cmd_delta(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
output_dir = DEFAULT_RESULTS_DIR / _run_id("delta")
output_dir.mkdir(parents=True, exist_ok=True)
rows = []
for n_features in [int(value) for value in args.features.split(",")]:
for metric in args.metrics.split(","):
print(f"[delta] MFW={n_features}, metric={metric}")
classifier = DeltaClassifier(n_features=n_features, metric=metric)
result = run_repeated_cv(
classifier, frame, y, n_splits=args.folds, n_repeats=args.repeats,
seed=args.seed, model_name=f"delta_{metric}", verbose=False,
)
pooled, _ = result.pooled_summary()
first = pooled.iloc[0].to_dict() if not pooled.empty else {}
rows.append({"n_features": n_features, "metric": metric, "macro_f1": first.get("macro_f1"), "accuracy": first.get("accuracy"), "balanced_accuracy": first.get("balanced_accuracy")})
table = pd.DataFrame(rows).sort_values("macro_f1", ascending=False)
write_csv(table, output_dir / "delta_sweep.csv")
print(table.to_string(index=False))
return 0
def cmd_verify(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
feature_config = _feature_config(args.preset)
result = run_verification(frame, y, feature_config, n_splits=args.folds, seed=args.seed)
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"verification_{args.preset}")
output_dir.mkdir(parents=True, exist_ok=True)
write_csv(result.fold_metrics, output_dir / "fold_metrics.csv")
result.scores.to_csv(output_dir / "pair_scores.csv", index=False, encoding="utf-8-sig")
save_json(result.summary, output_dir / "summary.json")
print(json.dumps(result.summary, ensure_ascii=False, indent=2))
return 0
def cmd_cluster(args: argparse.Namespace) -> int:
frame = _frame(args)
y = frame["character"].values
feature_config = _feature_config(args.preset)
result = run_clustering(frame, y, feature_config, n_clusters=args.clusters, seed=args.seed)
output_dir = DEFAULT_RESULTS_DIR / _run_id(f"clustering_{args.preset}")
output_dir.mkdir(parents=True, exist_ok=True)
write_csv(result.metrics, output_dir / "clustering_metrics.csv")
result.centroids.to_csv(output_dir / "character_centroids.csv", encoding="utf-8-sig")
plot_pca(
result.centroids.to_numpy(),
result.centroids.index.tolist(),
output_dir / "figures" / "character_centroids_pca.png",
title="PCA центроидов персонажей",
)
print(result.metrics.to_string(index=False))
return 0
def cmd_length_report(args: argparse.Namespace) -> int:
import pandas as pd
run_dir = Path(args.run_dir)
oof_path = run_dir / "oof_predictions.csv"
if not oof_path.exists():
raise FileNotFoundError(f"Нет файла предсказаний: {oof_path}")
oof = pd.read_csv(oof_path)
lines_path = Path(args.lines) if args.lines else PROJECT_ROOT / "data" / "processed" / "lines.csv"
if not lines_path.exists():
raise FileNotFoundError(
f"Нет файла {lines_path}. Сначала выполните python -m stylometry.cli audit."
)
lines = pd.read_csv(lines_path)
if "n_words" not in lines.columns:
lines["n_words"] = lines["text"].astype(str).str.findall(r"[A-Za-z0-9']+").map(len)
first = oof[oof["repeat"] == oof["repeat"].min()].copy()
first = first.merge(lines[["line_id", "n_words"]], on="line_id", how="left")
first["length_bin"] = pd.cut(
first["n_words"],
bins=[0, 5, 10, 15, 20, 30, 1000],
labels=["0–5", "6–10", "11–15", "16–20", "21–30", "31+"],
include_lowest=True,
)
labels = sorted(first["y_true"].unique())
rows = []
for bin_name, group in first.groupby("length_bin", observed=True):
# В узком диапазоне длины могут отсутствовать некоторые персонажи.
# Macro-F1 считаем только по классам, реально присутствующим в группе.
local_labels = sorted(group["y_true"].unique())
metrics = classification_metrics(
group["y_true"].values, group["y_pred"].values, labels=local_labels
)
interval = bootstrap_ci(
group["y_true"].values, group["y_pred"].values, local_labels,
metric="macro_f1", n_boot=500, seed=args.seed,
)
rows.append(
{
"length_bin": str(bin_name),
"n_samples": int(len(group)),
"macro_f1": metrics["macro_f1"],
"macro_f1_lower": interval["lower"],
"macro_f1_upper": interval["upper"],
"balanced_accuracy": metrics["balanced_accuracy"],
"accuracy": metrics["accuracy"],
"top3_accuracy": metrics.get("top3_accuracy"),
}
)
table = pd.DataFrame(rows)
output_path = run_dir / "length_bins.csv"
write_csv(table, output_path)
print(table.to_string(index=False))
print(f"[length-report] результат: {output_path}")
return 0
def cmd_compare(args: argparse.Namespace) -> int:
import pandas as pd
def _read_first(path: str | Path) -> pd.DataFrame:
frame = pd.read_csv(Path(path) / "oof_predictions.csv")
return frame[frame["repeat"] == frame["repeat"].min()].copy()
first = _read_first(args.run_a).rename(columns={"y_pred": "pred_a"})
second = _read_first(args.run_b).rename(columns={"y_pred": "pred_b"})
merged = first.merge(
second[["line_id", "y_true", "pred_b"]],
on=["line_id", "y_true"],
how="inner",
)
if merged.empty:
raise ValueError("Не удалось сопоставить предсказания по line_id и y_true.")
labels = sorted(merged["y_true"].unique())
metrics_a = classification_metrics(merged["y_true"], merged["pred_a"], labels=labels)
metrics_b = classification_metrics(merged["y_true"], merged["pred_b"], labels=labels)
comparison = {
"n_matched": int(len(merged)),
"model_a": {
"macro_f1": metrics_a["macro_f1"],
"accuracy": metrics_a["accuracy"],
"balanced_accuracy": metrics_a["balanced_accuracy"],
},
"model_b": {
"macro_f1": metrics_b["macro_f1"],
"accuracy": metrics_b["accuracy"],
"balanced_accuracy": metrics_b["balanced_accuracy"],
},
"mcnemar": mcnemar_exact(merged["y_true"], merged["pred_a"], merged["pred_b"]),
"bootstrap_difference_macro_f1": bootstrap_difference(
merged["y_true"], merged["pred_a"], merged["pred_b"],
metric="macro_f1", labels=labels, n_boot=1000, seed=args.seed,
),
}
output = {
"run_a": str(args.run_a),
"run_b": str(args.run_b),
"comparison": comparison,
}
path = Path(args.out) if args.out else Path(args.run_a) / "comparison_with_other.json"
save_json(output, path)
print(json.dumps(comparison, ensure_ascii=False, indent=2, default=_json_default))
print(f"[compare] результат: {path}")
return 0
def cmd_subgroup_report(args: argparse.Namespace) -> int:
import pandas as pd
run_dir = Path(args.run_dir)
oof_path = run_dir / "oof_predictions.csv"
if not oof_path.exists():
raise FileNotFoundError(f"Нет файла предсказаний: {oof_path}")
oof = pd.read_csv(oof_path)
first = oof[oof["repeat"] == oof["repeat"].min()].copy()
labels = sorted(first["y_true"].unique())
metadata = load_character_metadata(args.metadata)
metrics = subgroup_report(
first["y_true"], first["y_pred"], labels, metadata, args.group
)
confusions = group_confusions(
first["y_true"], first["y_pred"], metadata, args.group
)
write_csv(metrics, run_dir / f"subgroup_{args.group}_metrics.csv")
write_csv(confusions, run_dir / f"subgroup_{args.group}_confusions.csv")
print(metrics.to_string(index=False))
print(f"[subgroup-report] результаты: {run_dir}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Стилометрия реплик Stardew Valley: аудит, признаки, LSA и эксперименты."
)
subparsers = parser.add_subparsers(dest="command", required=True)
def add_common(sub: argparse.ArgumentParser) -> None:
sub.add_argument("--data", default=None, help="Путь к dialogues.csv или .xlsx")
sub.add_argument("--cache", default=None, help="Путь к кэшу токенизации")
sub.add_argument("--no-cache", action="store_true", help="Пересобрать кэш")
sub.add_argument("--out", default=None, help="Каталог для результатов")
audit = subparsers.add_parser("audit", help="Аудит корпуса")
add_common(audit)
audit.set_defaults(func=cmd_audit)
features = subparsers.add_parser("features", help="Ручные стилометрические признаки")
add_common(features)
features.set_defaults(func=cmd_features)
lsa = subparsers.add_parser("lsa", help="LSA одного варианта")
add_common(lsa)
lsa.add_argument("--variant", default="word", choices=LSA_VARIANTS)
lsa.add_argument("--components", type=int, default=100)
lsa.set_defaults(func=cmd_lsa)
run = subparsers.add_parser("run", help="Кросс-валидация одной модели")
add_common(run)
run.add_argument("--config", default=None)
run.add_argument("--preset", default=None)
run.add_argument("--model", default=None, choices=MODEL_NAMES)
run.add_argument("--folds", type=int, default=None)
run.add_argument("--repeats", type=int, default=None)
run.add_argument("--seed", type=int, default=None)
run.add_argument("--nested", action="store_true", help="Вложенная кросс-валидация")
run.set_defaults(func=cmd_run)
benchmark = subparsers.add_parser("benchmark", help="Сравнение классификаторов")
add_common(benchmark)
benchmark.add_argument("--preset", default="stylometry_pos_lsa")
benchmark.add_argument("--models", default=None, help="Список через запятую")
benchmark.add_argument("--folds", type=int, default=5)
benchmark.add_argument("--repeats", type=int, default=3)
benchmark.add_argument("--seed", type=int, default=42)
benchmark.set_defaults(func=cmd_benchmark)
ablate = subparsers.add_parser("ablate", help="Поочерёдное исключение групп признаков")
add_common(ablate)
ablate.add_argument("--preset", default="stylometry_pos_lsa")
ablate.add_argument("--model", default="logreg", choices=MODEL_NAMES)
ablate.add_argument("--folds", type=int, default=5)
ablate.add_argument("--repeats", type=int, default=2)
ablate.add_argument("--seed", type=int, default=42)
ablate.set_defaults(func=cmd_ablate)
sweep = subparsers.add_parser("lsa-sweep", help="Перебор вариантов и числа компонент LSA")
add_common(sweep)
sweep.add_argument("--variants", default="word,pos,char")
sweep.add_argument("--components", default="50,100,200")
sweep.add_argument("--model", default="logreg", choices=MODEL_NAMES)
sweep.add_argument("--folds", type=int, default=5)
sweep.add_argument("--repeats", type=int, default=1)
sweep.add_argument("--seed", type=int, default=42)
sweep.set_defaults(func=cmd_lsa_sweep)
delta = subparsers.add_parser("delta", help="Дельта Барроуза")
add_common(delta)
delta.add_argument("--features", default="100,200,500")
delta.add_argument("--metrics", default="manhattan,cosine")
delta.add_argument("--folds", type=int, default=5)
delta.add_argument("--repeats", type=int, default=2)
delta.add_argument("--seed", type=int, default=42)
delta.set_defaults(func=cmd_delta)
verify = subparsers.add_parser("verify", help="Верификация «свой/чужой»")
add_common(verify)
verify.add_argument("--preset", default="stylometry_pos_lsa")
verify.add_argument("--folds", type=int, default=5)
verify.add_argument("--seed", type=int, default=42)
verify.set_defaults(func=cmd_verify)
cluster = subparsers.add_parser("cluster", help="Кластеризация персонажей")
add_common(cluster)
cluster.add_argument("--preset", default="stylometry_pos_lsa")
cluster.add_argument("--clusters", type=int, default=None)
cluster.add_argument("--seed", type=int, default=42)
cluster.set_defaults(func=cmd_cluster)
length_report = subparsers.add_parser(
"length-report", help="Метрики по группам длины реплик"
)
length_report.add_argument("--run-dir", required=True)
length_report.add_argument("--lines", default=None)
length_report.add_argument("--seed", type=int, default=42)
length_report.set_defaults(func=cmd_length_report)
compare = subparsers.add_parser(
"compare", help="Парное сравнение двух запусков"
)
compare.add_argument("--run-a", required=True)
compare.add_argument("--run-b", required=True)
compare.add_argument("--out", default=None)
compare.add_argument("--seed", type=int, default=42)
compare.set_defaults(func=cmd_compare)
subgroup = subparsers.add_parser(
"subgroup-report", help="Метрики по полу, возрасту и виду персонажей"
)
subgroup.add_argument("--run-dir", required=True)
subgroup.add_argument("--metadata", default=None)
subgroup.add_argument(
"--group", default="gender", choices=["gender", "age_group", "species"]
)
subgroup.set_defaults(func=cmd_subgroup_report)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,90 @@
"""Кластеризация персонажей в стилометрическом пространстве."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Sequence
import numpy as np
import pandas as pd
from scipy.sparse import issparse
from sklearn.cluster import AgglomerativeClustering, KMeans
from sklearn.metrics import (
adjusted_rand_score,
calinski_harabasz_score,
davies_bouldin_score,
normalized_mutual_info_score,
silhouette_score,
)
from sklearn.preprocessing import normalize
from .pipelines import FeatureConfig, build_feature_union
@dataclass
class ClusteringResult:
metrics: pd.DataFrame
centroids: pd.DataFrame
def _centroid_matrix(matrix: np.ndarray, labels: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
classes = np.unique(labels)
centroids = np.vstack([matrix[labels == label].mean(axis=0) for label in classes])
return classes, centroids
def run_clustering(
X: pd.DataFrame,
y: Sequence,
feature_config: FeatureConfig,
n_clusters: int | None = None,
seed: int = 42,
) -> ClusteringResult:
"""Кластеризует центроиды персонажей и считает внешние метрики."""
y = np.asarray(y)
features = build_feature_union(feature_config)
matrix = features.fit_transform(X)
matrix = matrix.toarray() if issparse(matrix) else np.asarray(matrix)
matrix = normalize(matrix, norm="l2", copy=False)
classes, centroids = _centroid_matrix(matrix, y)
# Если число кластеров равно числу персонажей, кластеризация тривиальна,
# а силуэт не определён. По умолчанию берём примерно корень из числа
# классов: для 34 персонажей это 6 групп.
n_clusters = int(n_clusters or max(2, round(len(classes) ** 0.5)))
n_clusters = min(n_clusters, len(classes) - 1)
rows: list[dict[str, Any]] = []
for name, model in (
(
"agglomerative_cosine",
AgglomerativeClustering(
n_clusters=n_clusters, metric="cosine", linkage="average"
),
),
(
"kmeans",
KMeans(n_clusters=n_clusters, random_state=seed, n_init=20),
),
):
predicted = model.fit_predict(centroids)
n_predicted = len(set(predicted))
valid_partition = 2 <= n_predicted < len(centroids)
rows.append(
{
"method": name,
"n_clusters": n_clusters,
"adjusted_rand_index": float(adjusted_rand_score(classes, predicted)),
"normalized_mutual_info": float(normalized_mutual_info_score(classes, predicted)),
"silhouette_cosine": float(
silhouette_score(centroids, predicted, metric="cosine")
) if valid_partition else 0.0,
"calinski_harabasz": float(calinski_harabasz_score(centroids, predicted))
if valid_partition else 0.0,
"davies_bouldin": float(davies_bouldin_score(centroids, predicted))
if valid_partition else 0.0,
}
)
centroid_frame = pd.DataFrame(centroids, index=classes)
centroid_frame.index.name = "character"
return ClusteringResult(metrics=pd.DataFrame(rows), centroids=centroid_frame)

293
lab1/stylometry/cv.py Normal file
View File

@@ -0,0 +1,293 @@
"""Схемы кросс-валидации и сбор out-of-fold предсказаний."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Sequence
import numpy as np
import pandas as pd
from sklearn.base import clone
from sklearn.model_selection import StratifiedKFold
from .evaluation import (
align_scores,
classification_metrics,
metrics_summary,
summarize_pooled_predictions,
)
from .pipelines import make_grid_search
@dataclass
class CVResult:
"""Результат повторной стратифицированной кросс-валидации."""
fold_metrics: pd.DataFrame
oof: pd.DataFrame
labels: np.ndarray
scores_by_repeat: dict[int, np.ndarray] = field(default_factory=dict)
n_splits: int = 5
n_repeats: int = 1
model_name: str = ""
def summary(self) -> pd.DataFrame:
return metrics_summary(self.fold_metrics)
def pooled_summary(self) -> tuple[pd.DataFrame, dict[str, float]]:
return summarize_pooled_predictions(
self.oof, self.labels, self.scores_by_repeat
)
@dataclass
class NestedCVResult(CVResult):
"""Результат вложенной кросс-валидации с настройкой гиперпараметров."""
best_params: list[dict[str, Any]] = field(default_factory=list)
def _predict_scores(pipeline: Any, X: pd.DataFrame) -> np.ndarray | None:
if hasattr(pipeline, "predict_proba"):
try:
return np.asarray(pipeline.predict_proba(X), dtype=float)
except Exception:
pass
if hasattr(pipeline, "decision_function"):
try:
scores = np.asarray(pipeline.decision_function(X), dtype=float)
if scores.ndim == 1:
scores = np.column_stack([-scores, scores])
return scores
except Exception:
pass
return None
def _model_classes(pipeline: Any) -> np.ndarray:
if hasattr(pipeline, "named_steps") and "model" in pipeline.named_steps:
return np.asarray(pipeline.named_steps["model"].classes_)
return np.asarray(pipeline.classes_)
def validate_folds(
y: Sequence,
splits: Iterable[tuple[np.ndarray, np.ndarray]],
labels: Sequence,
) -> None:
"""Проверяет, что каждый класс есть и в обучении, и в тесте."""
y = np.asarray(y)
labels_set = set(labels)
for fold_index, (train_idx, test_idx) in enumerate(splits):
train_labels = set(y[train_idx])
test_labels = set(y[test_idx])
missing_train = labels_set - train_labels
missing_test = labels_set - test_labels
if missing_train or missing_test:
raise ValueError(
f"Фолд {fold_index}: в обучении нет классов {sorted(missing_train)}, "
f"в тесте нет классов {sorted(missing_test)}. "
"Увеличьте размер выборки или уменьшите число фолдов."
)
def _line_ids(X: pd.DataFrame, indices: np.ndarray) -> np.ndarray:
if "line_id" in X.columns:
return X.iloc[indices]["line_id"].to_numpy()
return np.asarray(indices)
def _order_scores_like_oof(X: pd.DataFrame, rows: list[dict[str, Any]]) -> np.ndarray:
"""Возвращает порядок строк матрицы score-ов, соответствующий OOF-таблице."""
if "line_id" in X.columns:
positions = {
int(value): index for index, value in enumerate(X["line_id"].to_numpy())
}
return np.asarray([positions[int(row["line_id"])] for row in rows], dtype=int)
return np.asarray([int(row["line_id"]) for row in rows], dtype=int)
def run_repeated_cv(
pipeline: Any,
X: pd.DataFrame,
y: Sequence,
n_splits: int = 5,
n_repeats: int = 5,
seed: int = 42,
model_name: str = "",
verbose: bool = True,
) -> CVResult:
"""Повторная стратифицированная кросс-валидация.
Все преобразования внутри ``pipeline`` обучаются только на обучающем
блоке. Out-of-fold предсказания собираются для каждого повтора.
"""
y = np.asarray(y)
labels = np.unique(y)
n_samples = len(y)
fold_rows: list[dict[str, Any]] = []
oof_rows: list[dict[str, Any]] = []
scores_by_repeat: dict[int, np.ndarray] = {}
for repeat in range(n_repeats):
splitter = StratifiedKFold(
n_splits=n_splits, shuffle=True, random_state=seed + repeat
)
splits = list(splitter.split(X, y))
validate_folds(y, splits, labels)
scores_oof = np.full((n_samples, len(labels)), np.nan, dtype=float)
oof_start = len(oof_rows)
for fold, (train_idx, test_idx) in enumerate(splits, start=1):
model = clone(pipeline)
model.fit(X.iloc[train_idx], y[train_idx])
y_pred = model.predict(X.iloc[test_idx])
raw_scores = _predict_scores(model, X.iloc[test_idx])
if raw_scores is not None:
scores = align_scores(
raw_scores, _model_classes(model), labels, fill_value=0.0
)
scores_oof[test_idx] = scores
else:
scores = None
metrics = classification_metrics(
y[test_idx], y_pred, y_score=scores, labels=labels
)
metrics.update(
{
"repeat": repeat,
"fold": fold,
"n_train": int(len(train_idx)),
"n_test": int(len(test_idx)),
}
)
fold_rows.append(metrics)
for row_index, sample_index in enumerate(test_idx):
oof_rows.append(
{
"repeat": repeat,
"fold": fold,
"line_id": int(_line_ids(X, np.asarray([sample_index]))[0]),
"character": str(X.iloc[sample_index].get("character", "")),
"y_true": y[sample_index],
"y_pred": y_pred[row_index],
}
)
if verbose:
print(
f"[cv] повтор {repeat + 1}/{n_repeats}, "
f"фолд {fold}/{n_splits}: macro-F1={metrics['macro_f1']:.4f}"
)
if not np.isnan(scores_oof).any():
repeat_rows = sorted(oof_rows[oof_start:], key=lambda row: int(row["line_id"]))
order = _order_scores_like_oof(X, repeat_rows)
scores_by_repeat[repeat] = scores_oof[order]
oof = pd.DataFrame(oof_rows)
if not oof.empty:
oof = oof.sort_values(["repeat", "line_id"]).reset_index(drop=True)
return CVResult(
fold_metrics=pd.DataFrame(fold_rows),
oof=oof,
labels=labels,
scores_by_repeat=scores_by_repeat,
n_splits=n_splits,
n_repeats=n_repeats,
model_name=model_name,
)
def run_nested_cv(
pipeline: Any,
X: pd.DataFrame,
y: Sequence,
model_name: str,
outer_splits: int = 5,
inner_splits: int = 3,
seed: int = 42,
verbose: bool = True,
) -> NestedCVResult:
"""Вложенная кросс-валидация: настройка гиперпараметров внутри outer-фолдов."""
y = np.asarray(y)
labels = np.unique(y)
n_samples = len(y)
outer = StratifiedKFold(
n_splits=outer_splits, shuffle=True, random_state=seed
)
splits = list(outer.split(X, y))
validate_folds(y, splits, labels)
fold_rows: list[dict[str, Any]] = []
oof_rows: list[dict[str, Any]] = []
best_params: list[dict[str, Any]] = []
scores_oof = np.full((n_samples, len(labels)), np.nan, dtype=float)
for fold, (train_idx, test_idx) in enumerate(splits, start=1):
search = make_grid_search(
pipeline, model_name, cv=inner_splits, scoring="f1_macro", n_jobs=-1
)
search.fit(X.iloc[train_idx], y[train_idx])
model = search.best_estimator_
y_pred = model.predict(X.iloc[test_idx])
raw_scores = _predict_scores(model, X.iloc[test_idx])
if raw_scores is not None:
scores = align_scores(
raw_scores, _model_classes(model), labels, fill_value=0.0
)
scores_oof[test_idx] = scores
else:
scores = None
metrics = classification_metrics(
y[test_idx], y_pred, y_score=scores, labels=labels
)
metrics.update(
{
"repeat": 0,
"fold": fold,
"n_train": int(len(train_idx)),
"n_test": int(len(test_idx)),
}
)
fold_rows.append(metrics)
best_params.append(dict(search.best_params_))
for row_index, sample_index in enumerate(test_idx):
oof_rows.append(
{
"repeat": 0,
"fold": fold,
"line_id": int(_line_ids(X, np.asarray([sample_index]))[0]),
"character": str(X.iloc[sample_index].get("character", "")),
"y_true": y[sample_index],
"y_pred": y_pred[row_index],
}
)
if verbose:
print(
f"[nested-cv] фолд {fold}/{outer_splits}: "
f"macro-F1={metrics['macro_f1']:.4f}, "
f"best={search.best_params_}"
)
scores_by_repeat = {}
if not np.isnan(scores_oof).any():
ordered_rows = sorted(oof_rows, key=lambda row: int(row["line_id"]))
order = _order_scores_like_oof(X, ordered_rows)
scores_by_repeat[0] = scores_oof[order]
oof = pd.DataFrame(oof_rows)
if not oof.empty:
oof = oof.sort_values(["repeat", "line_id"]).reset_index(drop=True)
return NestedCVResult(
fold_metrics=pd.DataFrame(fold_rows),
oof=oof,
labels=labels,
scores_by_repeat=scores_by_repeat,
n_splits=outer_splits,
n_repeats=1,
model_name=model_name,
best_params=best_params,
)

View File

@@ -0,0 +1,86 @@
"""Подготовка таблицы реплик с токенами, тегами и текстами для LSA."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from .io import DEFAULT_DATA_PATH, corpus_fingerprint, read_corpus
from .tokenization import Tokenizer
DEFAULT_CACHE_PATH = Path(__file__).resolve().parents[1] / "data" / "processed" / "tokenized.pkl"
REQUIRED_COLUMNS = {
"line_id",
"character",
"text",
"tokens",
"ptb",
"upos",
"sentences",
"pos_text",
"fw_text",
"content_text",
}
def build_feature_frame(
data_path: str | Path | None = None,
tokenizer: Tokenizer | None = None,
verbose: bool = True,
) -> pd.DataFrame:
"""Читает корпус и добавляет результаты токенизации и разметки."""
data_path = Path(data_path) if data_path is not None else DEFAULT_DATA_PATH
frame = read_corpus(data_path)
tokenizer = tokenizer or Tokenizer(use_nltk=True)
features = tokenizer.analyze_frame(frame, verbose=verbose)
features.attrs["corpus_path"] = str(data_path)
features.attrs["corpus_sha256"] = corpus_fingerprint(data_path)
features.attrs["nltk_used"] = tokenizer.use_nltk
features.attrs["pos_available"] = tokenizer.pos_available
return features
def save_feature_frame(frame: pd.DataFrame, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
frame.to_pickle(path)
return path
def load_feature_frame(
data_path: str | Path | None = None,
cache_path: str | Path | None = None,
force: bool = False,
verbose: bool = True,
) -> pd.DataFrame:
"""Загружает кэш токенизации или строит его заново.
Кэш проверяется по SHA-256 исходного файла. Если файл корпуса изменился,
токенизация выполняется повторно.
"""
data_path = Path(data_path) if data_path is not None else DEFAULT_DATA_PATH
cache_path = Path(cache_path) if cache_path is not None else DEFAULT_CACHE_PATH
if cache_path.exists() and not force:
try:
frame = pd.read_pickle(cache_path)
if REQUIRED_COLUMNS <= set(frame.columns):
cached_hash = frame.attrs.get("corpus_sha256")
current_hash = corpus_fingerprint(data_path)
if cached_hash == current_hash:
if verbose:
print(f"[datasets] загружен кэш: {cache_path}")
return frame
if verbose:
print("[datasets] корпус изменился, пересобираю признаки.")
except Exception as error:
if verbose:
print(f"[datasets] не удалось прочитать кэш ({error}), пересобираю.")
frame = build_feature_frame(data_path, verbose=verbose)
save_feature_frame(frame, cache_path)
if verbose:
print(f"[datasets] кэш сохранён: {cache_path}")
return frame

148
lab1/stylometry/delta.py Normal file
View File

@@ -0,0 +1,148 @@
"""Классическая дельта Барроуза и её косинусный вариант.
Дельта Барроуза — стандартный метод стилометрии. Он использует частоты
наиболее частотных слов, z-нормирование по обучающей выборке и расстояние до
центроида класса. Мы реализуем два варианта: манхэттенское расстояние
(классическая дельта) и косинусное расстояние (Cosine Delta).
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any, Sequence
import numpy as np
import pandas as pd
from scipy.special import softmax
from scipy.spatial.distance import cdist
from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
_TOKEN_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?")
def _texts(X: Any) -> list[str]:
if isinstance(X, pd.DataFrame):
if "text" not in X.columns:
raise KeyError("Для Delta нужна колонка 'text'.")
return X["text"].astype(str).tolist()
return [str(value) for value in X]
def _tokens(text: str, lowercase: bool = True) -> list[str]:
tokens = _TOKEN_RE.findall(text)
return [token.lower() for token in tokens] if lowercase else tokens
class MFWVectorizer(BaseEstimator, TransformerMixin):
"""Векторизатор по наиболее частотным словам (Most Frequent Words)."""
def __init__(
self,
n_features: int = 100,
lowercase: bool = True,
zscore: bool = True,
) -> None:
self.n_features = n_features
self.lowercase = lowercase
self.zscore = zscore
def fit(self, X: Any, y=None) -> "MFWVectorizer":
texts = _texts(X)
counter: Counter[str] = Counter()
for text in texts:
counter.update(_tokens(text, self.lowercase))
self.vocabulary_ = [word for word, _ in counter.most_common(self.n_features)]
if not self.vocabulary_:
raise ValueError("Не удалось выделить слова для Delta.")
index = {word: position for position, word in enumerate(self.vocabulary_)}
raw = np.zeros((len(texts), len(self.vocabulary_)), dtype=float)
for row, text in enumerate(texts):
tokens = _tokens(text, self.lowercase)
counts = Counter(tokens)
total = max(len(tokens), 1)
for word, count in counts.items():
position = index.get(word)
if position is not None:
raw[row, position] = count / total
self.mean_ = raw.mean(axis=0)
std = raw.std(axis=0, ddof=0)
self.std_ = np.where(std == 0, 1.0, std)
return self
def transform(self, X: Any) -> np.ndarray:
texts = _texts(X)
index = {word: position for position, word in enumerate(self.vocabulary_)}
raw = np.zeros((len(texts), len(self.vocabulary_)), dtype=float)
for row, text in enumerate(texts):
tokens = _tokens(text, self.lowercase)
counts = Counter(tokens)
total = max(len(tokens), 1)
for word, count in counts.items():
position = index.get(word)
if position is not None:
raw[row, position] = count / total
if self.zscore:
return (raw - self.mean_) / self.std_
return raw
def get_feature_names_out(self, input_features=None) -> np.ndarray:
return np.asarray(self.vocabulary_, dtype=object)
class DeltaClassifier(BaseEstimator, ClassifierMixin):
"""Классификатор на основе расстояния до центроида класса."""
def __init__(
self,
n_features: int = 100,
metric: str = "manhattan",
zscore: bool = True,
lowercase: bool = True,
) -> None:
self.n_features = n_features
self.metric = metric
self.zscore = zscore
self.lowercase = lowercase
def _make_vectorizer(self) -> MFWVectorizer:
return MFWVectorizer(
n_features=self.n_features,
lowercase=self.lowercase,
zscore=self.zscore,
)
def fit(self, X: Any, y: Sequence) -> "DeltaClassifier":
y = np.asarray(y)
self.vectorizer_ = self._make_vectorizer()
matrix = self.vectorizer_.fit_transform(X)
self.classes_ = np.unique(y)
self.centroids_ = np.vstack(
[matrix[y == label].mean(axis=0) for label in self.classes_]
)
return self
def _distances(self, X: Any) -> np.ndarray:
matrix = self.vectorizer_.transform(X)
if self.metric == "manhattan":
return cdist(matrix, self.centroids_, metric="cityblock") / max(matrix.shape[1], 1)
if self.metric == "euclidean":
return cdist(matrix, self.centroids_, metric="euclidean")
if self.metric == "cosine":
return cdist(matrix, self.centroids_, metric="cosine")
raise ValueError(f"Неизвестная метрика Delta: {self.metric}")
def decision_function(self, X: Any) -> np.ndarray:
return -self._distances(X)
def predict(self, X: Any) -> np.ndarray:
distances = self._distances(X)
return self.classes_[np.argmin(distances, axis=1)]
def predict_proba(self, X: Any) -> np.ndarray:
distances = self._distances(X)
scale = np.maximum(distances.std(axis=1, keepdims=True), 1e-6)
return softmax(-distances / scale, axis=1)
def get_feature_names_out(self) -> np.ndarray:
return self.vectorizer_.get_feature_names_out()

View File

@@ -0,0 +1,273 @@
"""Метрики качества классификации и их агрегация."""
from __future__ import annotations
from typing import Iterable, Sequence
import numpy as np
import pandas as pd
from sklearn.metrics import (
accuracy_score,
balanced_accuracy_score,
cohen_kappa_score,
confusion_matrix,
f1_score,
log_loss,
matthews_corrcoef,
precision_recall_fscore_support,
top_k_accuracy_score,
)
def _labels_or_default(y_true: Sequence, y_pred: Sequence, labels: Sequence | None) -> np.ndarray:
if labels is not None:
return np.asarray(labels)
return np.unique(np.concatenate([np.asarray(y_true), np.asarray(y_pred)]))
def brier_score_multiclass(y_true: Sequence, y_score: np.ndarray, labels: Sequence) -> float:
"""Многоклассовая оценка Брайера: средний квадрат отклонения вероятностей."""
label_to_index = {label: index for index, label in enumerate(labels)}
y_true = np.asarray(y_true)
one_hot = np.zeros_like(y_score, dtype=float)
for row, label in enumerate(y_true):
one_hot[row, label_to_index[label]] = 1.0
return float(np.mean(np.sum((y_score - one_hot) ** 2, axis=1)))
def align_scores(
y_score: np.ndarray,
model_classes: Sequence,
labels: Sequence,
fill_value: float = 0.0,
) -> np.ndarray:
"""Приводит столбцы вероятностей/решений к единому порядку классов."""
labels = list(labels)
model_classes = list(model_classes)
result = np.full((y_score.shape[0], len(labels)), fill_value, dtype=float)
for source_index, label in enumerate(model_classes):
if label in labels:
result[:, labels.index(label)] = y_score[:, source_index]
return result
def classification_metrics(
y_true: Sequence,
y_pred: Sequence,
y_score: np.ndarray | None = None,
labels: Sequence | None = None,
top_k: Iterable[int] = (3, 5),
) -> dict[str, float]:
"""Считает набор метрик для одного разбиения."""
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
labels_array = _labels_or_default(y_true, y_pred, labels)
n_classes = len(labels_array)
result: dict[str, float] = {
"n_samples": int(len(y_true)),
"n_classes": int(n_classes),
"accuracy": float(accuracy_score(y_true, y_pred)),
"balanced_accuracy": float(
balanced_accuracy_score(y_true, y_pred)
),
"macro_f1": float(
f1_score(y_true, y_pred, labels=labels_array, average="macro", zero_division=0)
),
"weighted_f1": float(
f1_score(y_true, y_pred, labels=labels_array, average="weighted", zero_division=0)
),
"micro_f1": float(
f1_score(y_true, y_pred, labels=labels_array, average="micro", zero_division=0)
),
"mcc": float(matthews_corrcoef(y_true, y_pred)),
"cohen_kappa": float(cohen_kappa_score(y_true, y_pred, labels=labels_array)),
}
if y_score is not None:
y_score = np.asarray(y_score, dtype=float)
if y_score.ndim == 2 and y_score.shape[1] == n_classes:
for k in top_k:
if 1 < k < n_classes:
result[f"top{k}_accuracy"] = float(
top_k_accuracy_score(y_true, y_score, k=k, labels=labels_array)
)
row_sums = y_score.sum(axis=1)
if np.all(y_score >= 0) and np.allclose(row_sums, 1.0, atol=1e-5):
result["log_loss"] = float(log_loss(y_true, y_score, labels=labels_array))
result["brier_score"] = brier_score_multiclass(y_true, y_score, labels_array)
return result
def per_class_report(
y_true: Sequence,
y_pred: Sequence,
labels: Sequence,
) -> pd.DataFrame:
"""Точность, полнота и F-мера по каждому классу."""
precision, recall, f1, support = precision_recall_fscore_support(
y_true, y_pred, labels=labels, zero_division=0
)
return pd.DataFrame(
{
"class": list(labels),
"precision": precision,
"recall": recall,
"f1": f1,
"support": support,
}
)
def confusion_matrix_frame(
y_true: Sequence,
y_pred: Sequence,
labels: Sequence,
normalize: str | None = None,
) -> pd.DataFrame:
"""Матрица ошибок в виде таблицы с подписями классов."""
matrix = confusion_matrix(y_true, y_pred, labels=labels, normalize=normalize)
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
def top_confusions(
y_true: Sequence,
y_pred: Sequence,
labels: Sequence,
top_n: int = 30,
) -> pd.DataFrame:
"""Самые частые пары «истинный класс — предсказанный класс»."""
frame = confusion_matrix_frame(y_true, y_pred, labels)
rows = []
for true_label in frame.index:
for predicted_label in frame.columns:
if true_label == predicted_label:
continue
count = int(frame.loc[true_label, predicted_label])
if count > 0:
rows.append(
{
"true_class": true_label,
"predicted_class": predicted_label,
"count": count,
"share_of_true_class": count / max(int(frame.loc[true_label].sum()), 1),
}
)
result = pd.DataFrame(rows)
if result.empty:
return result
return result.sort_values(["count", "share_of_true_class"], ascending=False).head(top_n)
def bootstrap_ci(
y_true: Sequence,
y_pred: Sequence,
labels: Sequence,
metric: str = "macro_f1",
n_boot: int = 1000,
seed: int = 42,
) -> dict[str, float]:
"""Перцентильный доверительный интервал метрики по репликам."""
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
labels_array = np.asarray(labels)
rng = np.random.default_rng(seed)
def _metric(true_values: np.ndarray, predicted_values: np.ndarray) -> float:
if metric == "macro_f1":
return float(
f1_score(
true_values,
predicted_values,
labels=labels_array,
average="macro",
zero_division=0,
)
)
if metric == "accuracy":
return float(accuracy_score(true_values, predicted_values))
if metric == "balanced_accuracy":
return float(balanced_accuracy_score(true_values, predicted_values))
if metric == "mcc":
return float(matthews_corrcoef(true_values, predicted_values))
raise ValueError(f"Неизвестная метрика для bootstrap: {metric}")
values = []
n_samples = len(y_true)
for _ in range(n_boot):
indices = rng.integers(0, n_samples, n_samples)
values.append(_metric(y_true[indices], y_pred[indices]))
values_array = np.asarray(values, dtype=float)
return {
"mean": float(np.mean(values_array)),
"lower": float(np.percentile(values_array, 2.5)),
"upper": float(np.percentile(values_array, 97.5)),
"n_boot": int(n_boot),
}
def metrics_summary(
metrics: pd.DataFrame,
columns: Iterable[str] | None = None,
) -> pd.DataFrame:
"""Среднее и стандартное отклонение метрик по фолдам/повторам."""
if columns is None:
columns = [
column
for column in metrics.columns
if column not in {"repeat", "fold", "n_train", "n_test", "n_samples", "n_classes"}
]
rows = []
for column in columns:
if column not in metrics.columns:
continue
values = pd.to_numeric(metrics[column], errors="coerce").dropna()
if values.empty:
continue
rows.append(
{
"metric": column,
"mean": float(values.mean()),
"std": float(values.std(ddof=1)) if len(values) > 1 else 0.0,
"min": float(values.min()),
"max": float(values.max()),
"n": int(len(values)),
}
)
return pd.DataFrame(rows)
def summarize_pooled_predictions(
oof: pd.DataFrame,
labels: Sequence,
scores_by_repeat: dict[int, np.ndarray] | None = None,
top_k: Iterable[int] = (3, 5),
) -> tuple[pd.DataFrame, dict[str, float]]:
"""Считает метрики по pooled out-of-fold предсказаниям.
Возвращает таблицу метрик по повторам и итоговую bootstrap-оценку по
первому повтору (для доверительного интервала).
"""
rows = []
for repeat, group in oof.groupby("repeat"):
scores = None
if scores_by_repeat is not None and repeat in scores_by_repeat:
scores = scores_by_repeat[repeat]
metrics = classification_metrics(
group["y_true"].values,
group["y_pred"].values,
y_score=scores,
labels=labels,
top_k=top_k,
)
metrics["repeat"] = int(repeat)
rows.append(metrics)
per_repeat = pd.DataFrame(rows)
first_repeat = oof[oof["repeat"] == oof["repeat"].min()]
bootstrap = {
metric: bootstrap_ci(first_repeat["y_true"].values, first_repeat["y_pred"].values, labels, metric)
for metric in ("macro_f1", "accuracy", "balanced_accuracy", "mcc")
}
return per_repeat, bootstrap

183
lab1/stylometry/io.py Normal file
View File

@@ -0,0 +1,183 @@
"""Чтение и валидация корпуса реплик Stardew Valley."""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import pandas as pd
from .cleaning import clean_text, count_placeholders, is_valid_line
# Русские названия колонок в исходном CSV/XLSX.
COLUMN_RENAMES = {
"Реплика": "text",
"Персонаж": "character",
"Text": "text",
"Character": "character",
}
DEFAULT_DATA_PATH = Path(__file__).resolve().parents[1] / "data" / "dialogues.csv"
DEFAULT_STATS_PATH = Path(__file__).resolve().parents[1] / "data" / "stats_per_character.xlsx"
class CorpusError(ValueError):
"""Ошибка структуры или кодировки корпуса."""
def _read_csv_robust(path: Path) -> pd.DataFrame:
"""Пробует несколько кодировок и проверяет наличие нужных колонок.
Основная кодировка корпуса — cp1251. UTF-8 добавлен на случай, если файл
будет пересохранён; koi8-r — как редкий запасной вариант.
"""
last_error: Exception | None = None
for encoding in ("cp1251", "utf-8-sig", "utf-8", "koi8-r"):
try:
frame = pd.read_csv(path, sep=";", encoding=encoding, dtype=str)
except (UnicodeDecodeError, UnicodeError) as error:
last_error = error
continue
except pd.errors.ParserError as error:
last_error = error
continue
columns = set(frame.columns)
if {"Реплика", "Персонаж"} <= columns or {"text", "character"} <= columns:
return frame
# Файл прочитался, но это не тот формат. Пробуем следующую кодировку.
last_error = CorpusError(
f"В файле {path} не найдены колонки 'Реплика'/'Персонаж' "
f"или 'text'/'character'. Найдены: {list(frame.columns)}"
)
raise CorpusError(f"Не удалось прочитать CSV {path}: {last_error}")
def _read_excel(path: Path) -> pd.DataFrame:
"""Читает первый лист Excel. Второй лист в исходном файле пустой."""
try:
return pd.read_excel(path, sheet_name=0, dtype=str)
except Exception as error: # openpyxl поднимает разные типы исключений
raise CorpusError(f"Не удалось прочитать Excel {path}: {error}") from error
def read_corpus(path: str | Path | None = None) -> pd.DataFrame:
"""Читает корпус и возвращает таблицу с колонками ``text`` и ``character``.
Дополнительно сохраняются:
- ``text_raw`` — исходная реплика после приведения к строке;
- ``line_id`` — сквозной номер реплики;
- ``n_chars`` — длина нормализованного текста;
- ``placeholder_count`` — число плейсхолдеров.
"""
path = Path(path) if path is not None else DEFAULT_DATA_PATH
if not path.exists():
raise FileNotFoundError(f"Файл корпуса не найден: {path}")
suffix = path.suffix.lower()
if suffix == ".csv":
frame = _read_csv_robust(path)
elif suffix in {".xlsx", ".xls"}:
frame = _read_excel(path)
else:
raise CorpusError(f"Неподдерживаемый формат файла: {suffix}")
frame = frame.rename(columns=COLUMN_RENAMES)
required = {"text", "character"}
if not required <= set(frame.columns):
raise CorpusError(
f"Ожидались колонки {sorted(required)}, найдены {list(frame.columns)}"
)
frame = frame[["text", "character"]].copy()
frame = frame.dropna(subset=["text", "character"])
frame["text"] = frame["text"].astype(str).str.strip()
frame["character"] = frame["character"].astype(str).str.strip()
frame = frame[(frame["text"] != "") & (frame["character"] != "")]
frame["text_raw"] = frame["text"]
frame["text"] = frame["text_raw"].map(clean_text)
frame = frame[frame["text"].map(is_valid_line)].copy()
frame = frame.reset_index(drop=True)
frame.insert(0, "line_id", range(len(frame)))
frame["n_chars"] = frame["text"].str.len().astype(int)
frame["n_words"] = frame["text"].str.findall(r"[A-Za-z0-9']+").map(len).astype(int)
frame["placeholder_count"] = frame["text_raw"].map(count_placeholders).astype(int)
if frame.empty:
raise CorpusError("После нормализации корпус оказался пустым.")
return frame
def read_stats(path: str | Path | None = None) -> pd.DataFrame:
"""Читает таблицу статистики по персонажам, если она есть."""
path = Path(path) if path is not None else DEFAULT_STATS_PATH
if not path.exists():
return pd.DataFrame()
return pd.read_excel(path, sheet_name=0)
def validate_corpus(frame: pd.DataFrame) -> dict[str, Any]:
"""Считает основные характеристики корпуса для отчёта и проверок."""
counts = frame["character"].value_counts().sort_values(ascending=False)
word_counts = frame["text"].str.findall(r"[A-Za-z0-9']+").map(len)
char_counts = frame["text"].str.len()
return {
"n_lines": int(len(frame)),
"n_characters": int(frame["character"].nunique()),
"n_words": int(word_counts.sum()),
"mean_words_per_line": float(word_counts.mean()),
"median_words_per_line": float(word_counts.median()),
"min_words_per_line": int(word_counts.min()),
"max_words_per_line": int(word_counts.max()),
"mean_chars_per_line": float(char_counts.mean()),
"median_chars_per_line": float(char_counts.median()),
"class_counts": counts.to_dict(),
"n_duplicate_texts": int(frame["text"].duplicated().sum()),
"n_placeholder_lines": int((frame["placeholder_count"] > 0).sum()),
"n_placeholders": int(frame["placeholder_count"].sum()),
}
def compare_with_stats(frame: pd.DataFrame, stats: pd.DataFrame) -> pd.DataFrame:
"""Сравнивает фактические counts с таблицей stats_per_character.xlsx.
Возвращает таблицу с колонками ``character``, ``actual``, ``reported``,
``difference``. Если таблица статистики пуста, возвращается пустая таблица.
"""
if stats.empty or "Персонаж" not in stats.columns:
return pd.DataFrame()
reported_column = "реплик" if "реплик" in stats.columns else stats.columns[1]
reported = (
stats[["Персонаж", reported_column]]
.rename(columns={"Персонаж": "character", reported_column: "reported"})
.set_index("character")["reported"]
)
actual = frame["character"].value_counts()
comparison = pd.DataFrame({"actual": actual, "reported": reported})
comparison["difference"] = comparison["actual"] - comparison["reported"]
return comparison.fillna(0).astype({"actual": int, "reported": int, "difference": int})
def corpus_fingerprint(path: str | Path | None = None) -> str:
"""SHA-256 файла корпуса для журнала воспроизводимости."""
path = Path(path) if path is not None else DEFAULT_DATA_PATH
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_csv(frame: pd.DataFrame, path: str | Path) -> Path:
"""Сохраняет таблицу в UTF-8 с BOM, чтобы её открывал Excel."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
frame.to_csv(path, index=False, encoding="utf-8-sig")
return path

175
lab1/stylometry/lexicons.py Normal file
View File

@@ -0,0 +1,175 @@
"""Закрытые классы слов и небольшие лексиконы для стилометрии.
Списки фиксированы заранее и не зависят от корпуса. Это важно для
воспроизводимости: признаки не подбираются по тестовой выборке.
"""
from __future__ import annotations
ARTICLES = {"a", "an", "the"}
DETERMINERS = {
"this", "that", "these", "those",
"my", "your", "his", "her", "its", "our", "their",
"some", "any", "no", "every", "each", "either", "neither",
"both", "all", "few", "many", "much", "more", "most",
"other", "another", "such", "what", "which", "whose",
}
PREPOSITIONS = {
"about", "above", "across", "after", "against", "along", "among",
"around", "at", "before", "behind", "below", "beneath", "beside",
"between", "beyond", "by", "despite", "down", "during", "except",
"for", "from", "in", "inside", "into", "like", "near", "of", "off",
"on", "onto", "out", "outside", "over", "past", "since", "through",
"throughout", "to", "toward", "towards", "under", "until", "up",
"upon", "with", "within", "without",
}
COORDINATORS = {"and", "but", "or", "nor", "for", "so", "yet"}
SUBORDINATORS = {
"because", "although", "though", "while", "whereas", "if", "unless",
"since", "as", "than", "whether", "after", "before", "until", "when",
"where", "once", "whenever", "wherever",
}
WH_WORDS = {
"who", "whom", "whose", "what", "which", "when", "where", "why", "how",
}
PERSONAL_PRONOUNS = {
"i", "me", "my", "mine", "myself",
"you", "your", "yours", "yourself", "yourselves",
"he", "him", "his", "himself",
"she", "her", "hers", "herself",
"it", "its", "itself",
"we", "us", "our", "ours", "ourselves",
"they", "them", "their", "theirs", "themselves",
}
PRONOUN_CATEGORIES: dict[str, set[str]] = {
"first_singular": {"i", "me", "my", "mine", "myself"},
"first_plural": {"we", "us", "our", "ours", "ourselves"},
"second": {"you", "your", "yours", "yourself", "yourselves"},
"third_singular": {"he", "him", "his", "himself", "she", "her", "hers", "herself", "it", "its", "itself"},
"third_plural": {"they", "them", "their", "theirs", "themselves"},
"possessive": {"my", "mine", "your", "yours", "his", "her", "hers", "its", "our", "ours", "their", "theirs"},
"reflexive": {"myself", "yourself", "yourselves", "himself", "herself", "itself", "ourselves", "themselves"},
}
AUXILIARIES = {
"am", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "having",
"do", "does", "did", "doing", "done",
"'m", "'re", "'s", "'ve", "'ll", "'d",
}
MODALS = {
"can", "could", "may", "might", "must", "shall", "should", "will",
"would", "ought", "need", "dare",
}
NEGATIONS = {
"not", "no", "never", "none", "nothing", "nobody", "nowhere",
"neither", "nor", "n't", "cannot",
"can't", "won't", "don't", "doesn't", "didn't", "isn't", "aren't",
"wasn't", "weren't", "haven't", "hasn't", "hadn't", "couldn't",
"wouldn't", "shouldn't", "mustn't", "ain't",
}
CONTRACTIONS = {
"i'm", "i've", "i'll", "i'd",
"you're", "you've", "you'll", "you'd",
"he's", "he'll", "he'd",
"she's", "she'll", "she'd",
"it's", "it'll",
"we're", "we've", "we'll", "we'd",
"they're", "they've", "they'll", "they'd",
"that's", "there's", "what's", "who's", "where's", "how's",
"can't", "won't", "don't", "doesn't", "didn't", "isn't", "aren't",
"wasn't", "weren't", "haven't", "hasn't", "hadn't", "couldn't",
"wouldn't", "shouldn't", "mustn't", "ain't",
}
DISCOURSE_MARKERS = {
"oh", "ah", "eh", "hey", "hi", "hello", "huh", "hmm", "um", "uh",
"well", "so", "like", "just", "really", "actually", "anyway",
"maybe", "perhaps", "please", "thanks", "thank", "sorry", "wow",
"yeah", "yes", "okay", "ok", "right", "still", "even", "only",
"quite", "rather", "pretty", "too", "very",
}
INTERJECTIONS = {
"oh", "ah", "eh", "hey", "hi", "hello", "huh", "hmm", "um", "uh",
"wow", "yeah", "yes", "okay", "ok", "sigh", "giggle", "sniff",
"heh", "haha", "mmm", "phew", "ugh", "ouch", "oops", "yay",
"hooray", "bye", "goodbye",
}
HEDGES = {
"maybe", "perhaps", "probably", "possibly", "guess", "suppose",
"think", "kind", "sort", "like", "somewhat", "rather", "fairly",
"quite",
}
INTENSIFIERS = {
"very", "really", "so", "too", "pretty", "quite", "absolutely",
"totally", "completely", "extremely", "incredibly", "terribly",
"awfully", "highly",
}
POLITENESS = {
"please", "thanks", "thank", "sorry", "excuse", "pardon", "welcome",
"kindly",
}
# Объединённый список функциональных слов. Он используется и для
# относительных частот, и для отбора функциональных слов в LSA.
FUNCTION_WORDS: set[str] = set().union(
ARTICLES,
DETERMINERS,
PREPOSITIONS,
COORDINATORS,
SUBORDINATORS,
WH_WORDS,
PERSONAL_PRONOUNS,
AUXILIARIES,
MODALS,
NEGATIONS,
CONTRACTIONS,
)
FUNCTION_WORD_CATEGORIES: dict[str, set[str]] = {
"articles": ARTICLES,
"determiners": DETERMINERS,
"prepositions": PREPOSITIONS,
"coordinators": COORDINATORS,
"subordinators": SUBORDINATORS,
"wh_words": WH_WORDS,
"pronouns": PERSONAL_PRONOUNS,
"auxiliaries": AUXILIARIES,
"modals": MODALS,
"negations": NEGATIONS,
"contractions": CONTRACTIONS,
"discourse_markers": DISCOURSE_MARKERS,
}
# Запасной список стоп-слов, если NLTK-ресурс stopwords недоступен.
STOPWORD_FALLBACK = set().union(
ARTICLES,
DETERMINERS,
PREPOSITIONS,
COORDINATORS,
SUBORDINATORS,
WH_WORDS,
PERSONAL_PRONOUNS,
AUXILIARIES,
MODALS,
NEGATIONS,
{"also", "just", "then", "there", "here", "now", "very", "too", "only"},
)
# Слова, которые в NLTK помечаются как IN, но синтаксически являются
# подчинительными союзами. Используются при переходе к Universal POS.
SUBORDINATING_IN = SUBORDINATORS

317
lab1/stylometry/lsa.py Normal file
View File

@@ -0,0 +1,317 @@
"""Векторизация текста и латентно-семантический анализ (LSA).
LSA реализован как последовательность:
TF-IDF -> усечённое сингулярное разложение (TruncatedSVD) -> L2-нормализация.
Все параметры, включая число компонент, должны настраиваться внутри
кросс-валидации, иначе возникает утечка информации из тестовой выборки.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import normalize
LSA_VARIANTS = ("word", "word2", "content", "fw", "char", "pos")
_DEFAULT_CONFIGS: dict[str, dict[str, Any]] = {
"word": {
"column": "text",
"analyzer": "word",
"ngram_range": (1, 1),
"token_pattern": r"(?u)\b\w[\w']*\b",
"lowercase": True,
"min_df": 3,
"max_df": 0.9,
"max_features": 5000,
},
"word2": {
"column": "text",
"analyzer": "word",
"ngram_range": (1, 2),
"token_pattern": r"(?u)\b\w[\w']*\b",
"lowercase": True,
"min_df": 3,
"max_df": 0.9,
"max_features": 8000,
},
"content": {
"column": "content_text",
"analyzer": "word",
"ngram_range": (1, 2),
"token_pattern": r"\S+",
"lowercase": True,
"min_df": 3,
"max_df": 0.9,
"max_features": 5000,
},
"fw": {
"column": "fw_text",
"analyzer": "word",
"ngram_range": (1, 2),
"token_pattern": r"\S+",
"lowercase": True,
"min_df": 3,
"max_df": 0.95,
"max_features": 2000,
},
"char": {
"column": "text",
"analyzer": "char_wb",
"ngram_range": (2, 5),
"token_pattern": None,
"lowercase": True,
"min_df": 3,
"max_df": 0.95,
"max_features": 5000,
},
"pos": {
"column": "pos_text",
"analyzer": "word",
"ngram_range": (1, 4),
"token_pattern": r"\S+",
"lowercase": False,
"min_df": 3,
"max_df": 0.95,
"max_features": 3000,
},
}
def _as_texts(X: Any, column: str) -> list[str]:
if isinstance(X, pd.DataFrame):
if column not in X.columns:
raise KeyError(f"Нет колонки {column!r} для векторизации.")
return X[column].astype(str).tolist()
return [str(value) for value in X]
class ColumnTfidfVectorizer(BaseEstimator, TransformerMixin):
"""TF-IDF по одной колонке таблицы признаков."""
def __init__(
self,
column: str = "text",
analyzer: str = "word",
ngram_range: tuple[int, int] = (1, 1),
token_pattern: str | None = r"(?u)\b\w[\w']*\b",
lowercase: bool = True,
min_df: int | float = 1,
max_df: int | float = 1.0,
max_features: int | None = None,
sublinear_tf: bool = True,
stop_words: str | None = None,
) -> None:
self.column = column
self.analyzer = analyzer
self.ngram_range = ngram_range
self.token_pattern = token_pattern
self.lowercase = lowercase
self.min_df = min_df
self.max_df = max_df
self.max_features = max_features
self.sublinear_tf = sublinear_tf
self.stop_words = stop_words
def _make_vectorizer(self) -> TfidfVectorizer:
kwargs: dict[str, Any] = {
"analyzer": self.analyzer,
"ngram_range": self.ngram_range,
"lowercase": self.lowercase,
"min_df": self.min_df,
"max_df": self.max_df,
"max_features": self.max_features,
"sublinear_tf": self.sublinear_tf,
"stop_words": self.stop_words,
}
if self.analyzer == "word" and self.token_pattern is not None:
kwargs["token_pattern"] = self.token_pattern
return TfidfVectorizer(**kwargs)
def fit(self, X: Any, y=None) -> "ColumnTfidfVectorizer":
texts = _as_texts(X, self.column)
if not texts:
raise ValueError("Пустая выборка для TF-IDF.")
self.vectorizer_ = self._make_vectorizer()
self.vectorizer_.fit(texts)
self.n_features_in_ = len(self.vectorizer_.get_feature_names_out())
return self
def transform(self, X: Any) -> Any:
if not hasattr(self, "vectorizer_"):
raise RuntimeError("Сначала вызовите fit.")
return self.vectorizer_.transform(_as_texts(X, self.column))
def get_feature_names_out(self, input_features=None) -> np.ndarray:
if not hasattr(self, "vectorizer_"):
raise RuntimeError("Сначала вызовите fit.")
return self.vectorizer_.get_feature_names_out()
class LSAVectorizer(BaseEstimator, TransformerMixin):
"""TF-IDF + TruncatedSVD + L2-нормализация."""
def __init__(
self,
column: str = "text",
analyzer: str = "word",
ngram_range: tuple[int, int] = (1, 1),
token_pattern: str | None = r"(?u)\b\w[\w']*\b",
lowercase: bool = True,
min_df: int | float = 3,
max_df: int | float = 0.9,
max_features: int | None = 5000,
n_components: int = 100,
sublinear_tf: bool = True,
stop_words: str | None = None,
normalize_rows: bool = True,
random_state: int = 42,
) -> None:
self.column = column
self.analyzer = analyzer
self.ngram_range = ngram_range
self.token_pattern = token_pattern
self.lowercase = lowercase
self.min_df = min_df
self.max_df = max_df
self.max_features = max_features
self.n_components = n_components
self.sublinear_tf = sublinear_tf
self.stop_words = stop_words
self.normalize_rows = normalize_rows
self.random_state = random_state
def fit(self, X: Any, y=None) -> "LSAVectorizer":
self.tfidf_ = ColumnTfidfVectorizer(
column=self.column,
analyzer=self.analyzer,
ngram_range=self.ngram_range,
token_pattern=self.token_pattern,
lowercase=self.lowercase,
min_df=self.min_df,
max_df=self.max_df,
max_features=self.max_features,
sublinear_tf=self.sublinear_tf,
stop_words=self.stop_words,
)
matrix = self.tfidf_.fit_transform(X)
max_components = min(matrix.shape[0] - 1, matrix.shape[1] - 1)
if max_components < 1:
raise ValueError(
"Слишком маленькая матрица для LSA: нужно хотя бы два документа "
"и два признака."
)
self.n_components_ = int(min(self.n_components, max_components))
self.svd_ = TruncatedSVD(
n_components=self.n_components_,
algorithm="randomized",
n_iter=10,
random_state=self.random_state,
)
self.svd_.fit(matrix)
self.explained_variance_ratio_ = self.svd_.explained_variance_ratio_
self.cumulative_explained_variance_ = np.cumsum(self.explained_variance_ratio_)
self.n_features_in_ = matrix.shape[1]
return self
def transform(self, X: Any) -> np.ndarray:
if not hasattr(self, "svd_"):
raise RuntimeError("Сначала вызовите fit.")
matrix = self.tfidf_.transform(X)
result = self.svd_.transform(matrix)
if self.normalize_rows:
result = normalize(result, norm="l2", copy=False)
return np.asarray(result)
def fit_transform(self, X: Any, y=None, **fit_params) -> np.ndarray:
return self.fit(X, y).transform(X)
def get_feature_names_out(self, input_features=None) -> np.ndarray:
return np.array([f"lsa_{index}" for index in range(self.n_components_)], dtype=object)
def top_terms(self, n: int = 10) -> list[list[tuple[str, float]]]:
"""Возвращает топ-``n`` терминов для каждой компоненты.
Знак компоненты не интерпретируется: он произволен. Для отчёта важнее
набор терминов и их относительный вклад.
"""
if not hasattr(self, "svd_"):
raise RuntimeError("Сначала вызовите fit.")
terms = self.tfidf_.get_feature_names_out()
result: list[list[tuple[str, float]]] = []
for component in self.svd_.components_:
order = np.argsort(component)[::-1][:n]
result.append([(str(terms[index]), float(component[index])) for index in order])
return result
def reconstruction_error(self, X: Any) -> float:
"""Относительная ошибка восстановления TF-IDF матрицы."""
matrix = self.tfidf_.transform(X)
reconstructed = self.svd_.inverse_transform(self.svd_.transform(matrix))
denominator = np.linalg.norm(matrix.toarray())
if denominator == 0:
return 0.0
return float(np.linalg.norm(matrix.toarray() - reconstructed) / denominator)
def make_lsa_vectorizer(
variant: str = "word",
n_components: int = 100,
**overrides: Any,
) -> LSAVectorizer:
"""Создаёт LSA-векторизатор одного из заранее определённых вариантов."""
if variant not in _DEFAULT_CONFIGS:
raise ValueError(
f"Неизвестный вариант LSA: {variant}. Доступны: {LSA_VARIANTS}"
)
config = dict(_DEFAULT_CONFIGS[variant])
config.update(overrides)
return LSAVectorizer(n_components=n_components, **config)
def make_text_vectorizer(kind: str = "word", **overrides: Any) -> ColumnTfidfVectorizer:
"""Создаёт TF-IDF-векторизатор без понижения размерности."""
if kind not in _DEFAULT_CONFIGS:
raise ValueError(f"Неизвестный вариант TF-IDF: {kind}")
config = dict(_DEFAULT_CONFIGS[kind])
config.update(overrides)
return ColumnTfidfVectorizer(**config)
def make_pos_ngram_vectorizer(**overrides: Any) -> ColumnTfidfVectorizer:
"""TF-IDF по последовательностям Universal POS."""
config = {
"column": "pos_text",
"analyzer": "word",
"ngram_range": (1, 3),
"token_pattern": r"\S+",
"lowercase": False,
"min_df": 3,
"max_df": 0.95,
"max_features": 500,
"sublinear_tf": True,
}
config.update(overrides)
return ColumnTfidfVectorizer(**config)
def make_char_ngram_vectorizer(**overrides: Any) -> ColumnTfidfVectorizer:
"""TF-IDF по символьным n-граммам."""
config = {
"column": "text",
"analyzer": "char_wb",
"ngram_range": (2, 5),
"token_pattern": None,
"lowercase": True,
"min_df": 3,
"max_df": 0.95,
"max_features": 5000,
"sublinear_tf": True,
}
config.update(overrides)
return ColumnTfidfVectorizer(**config)

View File

@@ -0,0 +1,99 @@
"""Анализ ошибок по метаданным персонажей."""
from __future__ import annotations
from pathlib import Path
from typing import Sequence
import pandas as pd
from .evaluation import per_class_report
DEFAULT_METADATA_PATH = Path(__file__).resolve().parents[1] / "data" / "metadata" / "characters.csv"
METADATA_COLUMNS = ("gender", "age_group", "species", "role")
def load_character_metadata(path: str | Path | None = None) -> pd.DataFrame:
"""Читает таблицу пола, возраста, вида и роли персонажей."""
path = Path(path) if path is not None else DEFAULT_METADATA_PATH
if not path.exists():
raise FileNotFoundError(f"Нет файла метаданных: {path}")
frame = pd.read_csv(path)
required = {"character", *METADATA_COLUMNS}
missing = required - set(frame.columns)
if missing:
raise ValueError(f"В метаданных нет колонок: {sorted(missing)}")
frame = frame.copy()
frame["character"] = frame["character"].astype(str).str.strip()
return frame
def subgroup_report(
y_true: Sequence,
y_pred: Sequence,
labels: Sequence,
metadata: pd.DataFrame,
group_column: str = "gender",
) -> pd.DataFrame:
"""Сводка F-меры по подгруппам персонажей."""
if group_column not in metadata.columns:
raise ValueError(f"Нет колонки {group_column!r} в метаданных.")
per_class = per_class_report(y_true, y_pred, labels)
per_class = per_class.merge(
metadata[["character", group_column]],
left_on="class",
right_on="character",
how="left",
)
rows = []
for value, group in per_class.groupby(group_column, dropna=False):
support = int(group["support"].sum())
weighted = float((group["f1"] * group["support"]).sum() / max(support, 1))
rows.append(
{
"group_column": group_column,
"group": str(value),
"n_characters": int(len(group)),
"n_samples": support,
"weighted_f1": weighted,
"mean_class_f1": float(group["f1"].mean()),
"min_class_f1": float(group["f1"].min()),
"max_class_f1": float(group["f1"].max()),
}
)
return pd.DataFrame(rows).sort_values(["weighted_f1"], ascending=False)
def group_confusions(
y_true: Sequence,
y_pred: Sequence,
metadata: pd.DataFrame,
group_column: str = "gender",
) -> pd.DataFrame:
"""Считает, как часто ошибки происходят внутри и между группами."""
frame = pd.DataFrame({"true_character": list(y_true), "pred_character": list(y_pred)})
frame = frame.merge(
metadata[["character", group_column]].rename(
columns={"character": "true_character", group_column: "true_group"}
),
on="true_character",
how="left",
).merge(
metadata[["character", group_column]].rename(
columns={"character": "pred_character", group_column: "pred_group"}
),
on="pred_character",
how="left",
)
errors = frame[frame["true_character"] != frame["pred_character"]]
table = (
errors.groupby(["true_group", "pred_group"], dropna=False)
.size()
.reset_index(name="count")
.sort_values("count", ascending=False)
)
total = max(int(table["count"].sum()), 1)
table["share_of_errors"] = table["count"] / total
table["same_group"] = table["true_group"] == table["pred_group"]
return table

View File

@@ -0,0 +1,365 @@
"""Сборка признаковых пространств и классификаторов."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.naive_bayes import MultinomialNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import FeatureUnion, Pipeline
from sklearn.preprocessing import MinMaxScaler, Normalizer, StandardScaler
from sklearn.svm import LinearSVC
from .features import FEATURE_GROUPS, StylometricVectorizer
from .lsa import (
LSA_VARIANTS,
make_char_ngram_vectorizer,
make_lsa_vectorizer,
make_pos_ngram_vectorizer,
)
MODEL_NAMES = (
"dummy",
"multinomial_nb",
"logreg",
"linear_svc",
"random_forest",
"extra_trees",
"knn",
)
@dataclass
class FeatureConfig:
"""Описание признакового пространства."""
groups: tuple[str, ...] = field(default_factory=lambda: tuple(FEATURE_GROUPS))
use_char_ngrams: bool = True
use_pos_ngrams: bool = True
lsa_variants: tuple[str, ...] = ("word", "pos")
lsa_components: int = 100
stylo_scaler: str = "standard" # standard | minmax | none
stylo_normalize: bool = True
include_stylo: bool = True
include_lsa: bool = True
char_max_features: int = 5000
pos_max_features: int = 500
char_ngram_range: tuple[int, int] = (2, 5)
pos_ngram_range: tuple[int, int] = (1, 3)
min_df: int = 3
random_state: int = 42
def _stylo_branch(config: FeatureConfig) -> Pipeline:
if config.stylo_scaler == "standard":
scaler: Any = StandardScaler()
elif config.stylo_scaler == "minmax":
scaler = MinMaxScaler()
elif config.stylo_scaler == "none":
scaler = "passthrough"
else:
raise ValueError(f"Неизвестный масштабировщик: {config.stylo_scaler}")
steps: list[tuple[str, Any]] = [
("extract", StylometricVectorizer(groups=config.groups)),
("scale", scaler),
]
if config.stylo_normalize:
# L2-нормировка уравнивает вклад блоков разной размерности.
steps.append(("norm", Normalizer()))
return Pipeline(steps)
def build_feature_union(config: FeatureConfig) -> Any:
"""Собирает блоки признаков в единое пространство."""
branches: list[tuple[str, Any]] = []
if config.include_stylo:
branches.append(("stylo", _stylo_branch(config)))
if config.use_char_ngrams:
branches.append(
(
"char",
Pipeline(
[
(
"tfidf",
make_char_ngram_vectorizer(
ngram_range=config.char_ngram_range,
max_features=config.char_max_features,
min_df=config.min_df,
),
),
("norm", Normalizer()),
]
),
)
)
if config.use_pos_ngrams:
branches.append(
(
"pos",
Pipeline(
[
(
"tfidf",
make_pos_ngram_vectorizer(
ngram_range=config.pos_ngram_range,
max_features=config.pos_max_features,
min_df=config.min_df,
),
),
("norm", Normalizer()),
]
),
)
)
if config.include_lsa:
for variant in config.lsa_variants:
if variant not in LSA_VARIANTS:
raise ValueError(f"Неизвестный вариант LSA: {variant}")
branches.append(
(
f"lsa_{variant}",
Pipeline(
[
(
"lsa",
make_lsa_vectorizer(
variant=variant,
n_components=config.lsa_components,
min_df=config.min_df,
random_state=config.random_state,
),
),
("norm", Normalizer()),
]
),
)
)
if not branches:
raise ValueError("Не выбран ни один блок признаков.")
if len(branches) == 1:
return branches[0][1]
return FeatureUnion(branches)
def make_classifier(name: str, **params: Any) -> Any:
"""Создаёт классификатор по короткому имени."""
if name == "dummy":
strategy = params.pop("strategy", "most_frequent")
return DummyClassifier(strategy=strategy)
if name == "multinomial_nb":
return MultinomialNB(alpha=params.pop("alpha", 1.0))
if name == "logreg":
return LogisticRegression(
C=params.pop("C", 1.0),
solver="lbfgs",
max_iter=5000,
class_weight="balanced",
)
if name == "linear_svc":
return LinearSVC(
C=params.pop("C", 1.0),
class_weight="balanced",
max_iter=20_000,
dual="auto",
)
if name == "random_forest":
return RandomForestClassifier(
n_estimators=params.pop("n_estimators", 400),
max_depth=params.pop("max_depth", None),
min_samples_leaf=params.pop("min_samples_leaf", 1),
class_weight="balanced_subsample",
n_jobs=-1,
random_state=params.pop("random_state", 42),
)
if name == "extra_trees":
return ExtraTreesClassifier(
n_estimators=params.pop("n_estimators", 400),
max_depth=params.pop("max_depth", None),
min_samples_leaf=params.pop("min_samples_leaf", 1),
class_weight="balanced",
n_jobs=-1,
random_state=params.pop("random_state", 42),
)
if name == "knn":
return KNeighborsClassifier(
n_neighbors=params.pop("n_neighbors", 5),
metric=params.pop("metric", "cosine"),
weights=params.pop("weights", "distance"),
n_jobs=-1,
)
raise ValueError(f"Неизвестный классификатор: {name}. Доступны: {MODEL_NAMES}")
def build_pipeline(
feature_config: FeatureConfig,
model_name: str,
**model_params: Any,
) -> Pipeline:
"""Собирает полный конвейер: признаки -> классификатор.
Для MultinomialNB исключаются LSA-компоненты (они могут быть
отрицательными) и используется MinMax-масштабирование, чтобы матрица
оставалась неотрицательной.
"""
if model_name == "dummy":
return Pipeline([("model", make_classifier(model_name, **model_params))])
if model_name == "multinomial_nb":
config = FeatureConfig(
groups=feature_config.groups,
use_char_ngrams=feature_config.use_char_ngrams,
use_pos_ngrams=feature_config.use_pos_ngrams,
lsa_variants=(),
lsa_components=feature_config.lsa_components,
stylo_scaler="minmax",
stylo_normalize=feature_config.stylo_normalize,
include_stylo=feature_config.include_stylo,
include_lsa=False,
char_max_features=feature_config.char_max_features,
pos_max_features=feature_config.pos_max_features,
char_ngram_range=feature_config.char_ngram_range,
pos_ngram_range=feature_config.pos_ngram_range,
min_df=feature_config.min_df,
random_state=feature_config.random_state,
)
features = build_feature_union(config)
return Pipeline([("features", features), ("model", make_classifier(model_name, **model_params))])
features = build_feature_union(feature_config)
return Pipeline([("features", features), ("model", make_classifier(model_name, **model_params))])
def preset_config(name: str = "stylometry_pos_lsa") -> FeatureConfig:
"""Готовые наборы признаков для типовых экспериментов."""
all_groups = tuple(FEATURE_GROUPS)
if name == "stylometry":
return FeatureConfig(
groups=all_groups,
use_char_ngrams=False,
use_pos_ngrams=False,
lsa_variants=(),
stylo_normalize=False,
include_lsa=False,
)
if name == "stylometry_pos":
return FeatureConfig(
groups=all_groups,
use_char_ngrams=True,
use_pos_ngrams=True,
lsa_variants=(),
include_lsa=False,
)
if name in {"stylometry_lsa", "stylometry_pos_lsa"}:
return FeatureConfig(
groups=all_groups,
use_char_ngrams=True,
use_pos_ngrams=True,
lsa_variants=("word", "pos"),
include_lsa=True,
)
if name == "all":
return FeatureConfig(
groups=all_groups,
use_char_ngrams=True,
use_pos_ngrams=True,
lsa_variants=("word", "word2", "content", "fw", "char", "pos"),
include_lsa=True,
)
if name == "char":
return FeatureConfig(
groups=(),
use_char_ngrams=True,
use_pos_ngrams=False,
lsa_variants=(),
include_stylo=False,
include_lsa=False,
)
if name == "pos":
return FeatureConfig(
groups=(),
use_char_ngrams=False,
use_pos_ngrams=True,
lsa_variants=(),
include_stylo=False,
include_lsa=False,
)
if name == "fw":
return FeatureConfig(
groups=("function_words", "pronouns"),
use_char_ngrams=False,
use_pos_ngrams=False,
lsa_variants=(),
stylo_normalize=False,
include_lsa=False,
)
if name in {
"lsa_word", "lsa_word2", "lsa_content", "lsa_fw", "lsa_char", "lsa_pos"
}:
variant = name.removeprefix("lsa_")
return FeatureConfig(
groups=(),
use_char_ngrams=False,
use_pos_ngrams=False,
lsa_variants=(variant,),
include_stylo=False,
include_lsa=True,
)
if name == "length":
return FeatureConfig(
groups=("length",),
use_char_ngrams=False,
use_pos_ngrams=False,
lsa_variants=(),
stylo_normalize=False,
include_lsa=False,
)
raise ValueError(f"Неизвестный набор признаков: {name}")
PARAM_GRIDS: dict[str, dict[str, list[Any]]] = {
"logreg": {"model__C": [0.1, 1.0, 10.0]},
"linear_svc": {"model__C": [0.1, 1.0, 10.0]},
"random_forest": {
"model__max_depth": [None, 30],
"model__min_samples_leaf": [1, 2],
},
"extra_trees": {
"model__max_depth": [None, 30],
"model__min_samples_leaf": [1, 2],
},
"multinomial_nb": {"model__alpha": [0.1, 1.0, 3.0]},
"knn": {"model__n_neighbors": [3, 5, 9]},
}
def make_grid_search(
pipeline: Pipeline,
model_name: str,
cv: int = 3,
scoring: str = "f1_macro",
n_jobs: int = -1,
) -> GridSearchCV:
"""GridSearchCV с сеткой, специфичной для модели."""
if model_name not in PARAM_GRIDS:
raise ValueError(f"Для модели {model_name} нет сетки гиперпараметров.")
return GridSearchCV(
pipeline,
PARAM_GRIDS[model_name],
cv=cv,
scoring=scoring,
n_jobs=n_jobs,
refit=True,
return_train_score=False,
)

167
lab1/stylometry/plotting.py Normal file
View File

@@ -0,0 +1,167 @@
"""Построение графиков для отчёта и презентации."""
from __future__ import annotations
from pathlib import Path
from typing import Sequence
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
from sklearn.decomposition import PCA # noqa: E402
from sklearn.manifold import TSNE # noqa: E402
def _save(fig: plt.Figure, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(path, dpi=200, bbox_inches="tight")
plt.close(fig)
return path
def plot_class_distribution(
frame: pd.DataFrame,
character_column: str = "character",
path: str | Path = "results/figures/class_distribution.png",
) -> Path:
counts = frame[character_column].value_counts().sort_values()
fig, ax = plt.subplots(figsize=(8, max(6, len(counts) * 0.25)))
ax.barh(counts.index, counts.values)
ax.set_xlabel("Число реплик")
ax.set_ylabel("Персонаж")
ax.set_title("Распределение реплик по персонажам")
return _save(fig, path)
def plot_scree(
explained_variance_ratio: Sequence[float],
path: str | Path = "results/figures/lsa_scree.png",
title: str = "Доля объяснённой дисперсии компонент LSA",
) -> Path:
values = np.asarray(explained_variance_ratio, dtype=float)
cumulative = np.cumsum(values)
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(np.arange(1, len(values) + 1), cumulative, marker="o", markersize=3)
ax.set_xlabel("Число компонент")
ax.set_ylabel("Накопленная доля дисперсии")
ax.set_title(title)
ax.grid(alpha=0.3)
return _save(fig, path)
def plot_confusion_matrix(
matrix: pd.DataFrame,
path: str | Path = "results/figures/confusion_matrix.png",
title: str = "Матрица ошибок",
annotate: bool | None = None,
) -> Path:
values = matrix.to_numpy(dtype=float)
n_classes = values.shape[0]
if annotate is None:
annotate = n_classes <= 20
fig, ax = plt.subplots(figsize=(max(8, n_classes * 0.35), max(7, n_classes * 0.32)))
image = ax.imshow(values, cmap="Blues", aspect="auto")
ax.set_xticks(np.arange(n_classes))
ax.set_yticks(np.arange(n_classes))
ax.set_xticklabels(matrix.columns, rotation=90, fontsize=7)
ax.set_yticklabels(matrix.index, fontsize=7)
ax.set_xlabel("Предсказанный класс")
ax.set_ylabel("Истинный класс")
ax.set_title(title)
if annotate:
for row in range(n_classes):
for column in range(n_classes):
value = values[row, column]
if value:
ax.text(
column, row, f"{value:.2f}" if value < 1 else f"{int(value)}",
ha="center", va="center", fontsize=6,
color="white" if value > values.max() * 0.5 else "black",
)
fig.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
return _save(fig, path)
def plot_pca(
matrix: np.ndarray,
labels: Sequence[str],
path: str | Path = "results/figures/pca.png",
title: str = "PCA признакового пространства",
) -> Path:
values = np.asarray(matrix, dtype=float)
if values.shape[0] < 2:
raise ValueError("Для PCA нужно хотя бы два объекта.")
coordinates = PCA(n_components=2, random_state=42).fit_transform(values)
fig, ax = plt.subplots(figsize=(9, 7))
unique_labels = sorted(set(labels))
cmap = plt.get_cmap("tab20")
color_map = {label: cmap(index % 20) for index, label in enumerate(unique_labels)}
for label in unique_labels:
mask = np.asarray(labels) == label
ax.scatter(
coordinates[mask, 0], coordinates[mask, 1],
s=12, alpha=0.7, label=label, color=color_map[label],
)
ax.set_xlabel("Первая главная компонента")
ax.set_ylabel("Вторая главная компонента")
ax.set_title(title)
ax.legend(fontsize=6, ncol=2, loc="best", frameon=False)
return _save(fig, path)
def plot_tsne(
matrix: np.ndarray,
labels: Sequence[str],
path: str | Path = "results/figures/tsne.png",
title: str = "t-SNE признакового пространства",
perplexity: float = 30.0,
) -> Path:
values = np.asarray(matrix, dtype=float)
if values.shape[0] < 10:
raise ValueError("Для t-SNE нужно хотя бы десять объектов.")
perplexity = min(perplexity, max(5.0, values.shape[0] / 4.0))
coordinates = TSNE(
n_components=2,
perplexity=perplexity,
init="pca",
learning_rate="auto",
random_state=42,
).fit_transform(values)
fig, ax = plt.subplots(figsize=(9, 7))
unique_labels = sorted(set(labels))
cmap = plt.get_cmap("tab20")
color_map = {label: cmap(index % 20) for index, label in enumerate(unique_labels)}
for label in unique_labels:
mask = np.asarray(labels) == label
ax.scatter(
coordinates[mask, 0], coordinates[mask, 1],
s=10, alpha=0.7, label=label, color=color_map[label],
)
ax.set_xlabel("t-SNE 1")
ax.set_ylabel("t-SNE 2")
ax.set_title(title)
ax.legend(fontsize=6, ncol=2, loc="best", frameon=False)
return _save(fig, path)
def plot_feature_importance(
names: Sequence[str],
values: Sequence[float],
path: str | Path = "results/figures/feature_importance.png",
title: str = "Важность признаков",
top_n: int = 25,
) -> Path:
frame = pd.DataFrame({"feature": list(names), "value": np.asarray(values, dtype=float)})
frame["abs_value"] = frame["value"].abs()
frame = frame.sort_values("abs_value", ascending=False).head(top_n).iloc[::-1]
fig, ax = plt.subplots(figsize=(9, max(5, len(frame) * 0.28)))
ax.barh(frame["feature"], frame["value"])
ax.set_xlabel("Вклад признака")
ax.set_ylabel("Признак")
ax.set_title(title)
return _save(fig, path)

143
lab1/stylometry/stats.py Normal file
View File

@@ -0,0 +1,143 @@
"""Статистические критерии для сравнения моделей."""
from __future__ import annotations
from typing import Sequence
import numpy as np
from scipy.stats import binomtest, friedmanchisquare, wilcoxon
from sklearn.metrics import accuracy_score, f1_score
def mcnemar_exact(
y_true: Sequence,
predictions_a: Sequence,
predictions_b: Sequence,
) -> dict[str, float]:
"""Точный критерий Мак-Немара для двух парных предсказаний."""
y_true = np.asarray(y_true)
correct_a = np.asarray(predictions_a) == y_true
correct_b = np.asarray(predictions_b) == y_true
b = int(np.sum(correct_a & ~correct_b))
c = int(np.sum(~correct_a & correct_b))
n = b + c
p_value = 1.0 if n == 0 else float(binomtest(min(b, c), n, 0.5).pvalue)
return {
"b": b,
"c": c,
"n_discordant": n,
"statistic": float(min(b, c)),
"p_value": p_value,
}
def wilcoxon_signed_rank(
scores_a: Sequence[float],
scores_b: Sequence[float],
) -> dict[str, float]:
"""Парный критерий Уилкоксона для результатов по фолдам."""
scores_a = np.asarray(scores_a, dtype=float)
scores_b = np.asarray(scores_b, dtype=float)
differences = scores_a - scores_b
if np.allclose(differences, 0):
return {"statistic": 0.0, "p_value": 1.0, "n": int(len(scores_a))}
statistic, p_value = wilcoxon(scores_a, scores_b, alternative="two-sided")
return {
"statistic": float(statistic),
"p_value": float(p_value),
"n": int(len(scores_a)),
}
def friedman_test(*score_arrays: Sequence[float]) -> dict[str, float]:
"""Критерий Фридмана для сравнения нескольких моделей."""
arrays = [np.asarray(values, dtype=float) for values in score_arrays]
if len(arrays) < 3:
raise ValueError("Для критерия Фридмана нужно хотя бы три модели.")
if len({len(values) for values in arrays}) != 1:
raise ValueError("Все массивы должны быть одинаковой длины.")
statistic, p_value = friedmanchisquare(*arrays)
return {"statistic": float(statistic), "p_value": float(p_value)}
def holm_bonferroni(p_values: Sequence[float]) -> list[float]:
"""Поправка Холма—Бонферрони для множественных сравнений."""
values = np.asarray(p_values, dtype=float)
n = len(values)
order = np.argsort(values)
adjusted = np.empty(n, dtype=float)
previous = 0.0
for rank, index in enumerate(order):
value = min(1.0, values[index] * (n - rank))
previous = max(previous, value)
adjusted[index] = previous
return adjusted.tolist()
def bootstrap_difference(
y_true: Sequence,
predictions_a: Sequence,
predictions_b: Sequence,
metric: str = "macro_f1",
labels: Sequence | None = None,
n_boot: int = 1000,
seed: int = 42,
) -> dict[str, float]:
"""Доверительный интервал разницы метрик двух моделей."""
y_true = np.asarray(y_true)
predictions_a = np.asarray(predictions_a)
predictions_b = np.asarray(predictions_b)
labels_array = np.asarray(labels) if labels is not None else np.unique(y_true)
def _metric(true_values: np.ndarray, predicted_values: np.ndarray) -> float:
if metric == "macro_f1":
return float(
f1_score(
true_values,
predicted_values,
labels=labels_array,
average="macro",
zero_division=0,
)
)
if metric == "accuracy":
return float(accuracy_score(true_values, predicted_values))
raise ValueError(f"Неизвестная метрика: {metric}")
rng = np.random.default_rng(seed)
differences = []
n_samples = len(y_true)
for _ in range(n_boot):
indices = rng.integers(0, n_samples, n_samples)
differences.append(
_metric(y_true[indices], predictions_a[indices])
- _metric(y_true[indices], predictions_b[indices])
)
values = np.asarray(differences)
return {
"mean_difference": float(values.mean()),
"lower": float(np.percentile(values, 2.5)),
"upper": float(np.percentile(values, 97.5)),
"n_boot": int(n_boot),
}
def cochran_q(correctness_matrix: np.ndarray) -> dict[str, float]:
"""Критерий Кокрана для сравнения нескольких бинарных классификаторов.
``correctness_matrix`` имеет форму (объекты, модели), значения 0/1.
"""
matrix = np.asarray(correctness_matrix, dtype=float)
if matrix.ndim != 2:
raise ValueError("Ожидается матрица (объекты, модели).")
k = matrix.shape[1]
column_sums = matrix.sum(axis=0)
row_sums = matrix.sum(axis=1)
total = matrix.sum()
numerator = (k - 1) * (k * np.sum(column_sums**2) - total**2)
denominator = k * total - np.sum(row_sums**2)
statistic = 0.0 if denominator == 0 else float(numerator / denominator)
from scipy.stats import chi2
p_value = float(chi2.sf(statistic, df=k - 1))
return {"statistic": statistic, "p_value": p_value, "df": k - 1}

View File

@@ -0,0 +1,363 @@
"""Токенизация, разбиение на предложения и частеречная разметка.
По умолчанию используется NLTK: ``word_tokenize``, ``sent_tokenize`` и
``pos_tag``. Если ресурсы NLTK недоступны, включается упрощённый резервный
режим на регулярных выражениях, а POS-признаки отключаются. Это позволяет
запускать аудит и часть признаков даже без скачанных моделей.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Sequence
import pandas as pd
from .lexicons import (
AUXILIARIES,
FUNCTION_WORDS,
MODALS,
NEGATIONS,
PERSONAL_PRONOUNS,
SUBORDINATORS,
STOPWORD_FALLBACK,
)
DEFAULT_NLTK_DIR = Path(__file__).resolve().parents[1] / "nltk_data"
_WORD_RE = re.compile(r"[A-Za-z]+(?:['’][A-Za-z]+)?|\d+|[^\w\s]")
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
_PUNCT_TAGS = {".", ",", ":", "``", "''", "-LRB-", "-RRB-", "HYPH", "NFP"}
_PTB_TAGS = [
"CC", "CD", "DT", "EX", "FW", "IN", "JJ", "JJR", "JJS", "LS", "MD",
"NN", "NNS", "NNP", "NNPS", "PDT", "POS", "PRP", "PRP$", "RB",
"RBR", "RBS", "RP", "SYM", "TO", "UH", "VB", "VBD", "VBG", "VBN",
"VBP", "VBZ", "WDT", "WP", "WP$", "WRB",
]
UPOS_TAGS = [
"ADJ", "ADP", "ADV", "AUX", "CONJ", "DET", "INTJ", "NOUN", "NUM",
"PRON", "PRT", "PUNCT", "SCONJ", "SYM", "VERB", "X",
]
_AUX_WORDS = AUXILIARIES | MODALS | {"be", "been", "being", "am", "is", "are", "was", "were"}
_NEGATION_WORDS = NEGATIONS | {"not", "n't"}
def _check_nltk_resource(resource: str) -> bool:
try:
import nltk
nltk.data.find(resource)
return True
except Exception:
return False
def ensure_nltk_data(
nltk_data_dir: str | Path | None = None,
download: bool = False,
) -> bool:
"""Добавляет локальный каталог NLTK в путь поиска и проверяет ресурсы.
Возвращает ``True``, если доступны и токенизатор, и POS-теггер.
"""
import nltk
directory = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
if directory.exists():
path = str(directory.resolve())
if path not in nltk.data.path:
nltk.data.path.insert(0, path)
required = {
"tokenizer": "tokenizers/punkt_tab",
"tagger": "taggers/averaged_perceptron_tagger_eng",
}
if download:
for package in (
"punkt",
"punkt_tab",
"averaged_perceptron_tagger",
"averaged_perceptron_tagger_eng",
"stopwords",
):
nltk.download(package, download_dir=str(directory), quiet=True)
return all(_check_nltk_resource(resource) for resource in required.values())
def load_stopwords(nltk_data_dir: str | Path | None = None) -> set[str]:
"""Загружает английские стоп-слова NLTK или использует запасной список."""
try:
import nltk
directory = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
if directory.exists():
path = str(directory.resolve())
if path not in nltk.data.path:
nltk.data.path.insert(0, path)
return set(nltk.corpus.stopwords.words("english"))
except Exception:
return set(STOPWORD_FALLBACK)
def penn_to_upos(token: str, tag: str) -> str:
"""Переводит тег Penn Treebank в Universal POS.
Часть решений зависит не только от тега, но и от самого слова: например,
``do`` может быть смысловым глаголом или вспомогательным, а ``as`` —
предлогом или подчинительным союзом.
"""
lower = token.lower()
if tag in _PUNCT_TAGS or token in {".", ",", "!", "?", ";", ":", "...", "…"}:
return "PUNCT"
if tag == "SYM":
return "SYM"
if tag == "CC":
return "CONJ"
if tag == "IN":
return "SCONJ" if lower in SUBORDINATORS else "ADP"
if tag == "TO":
return "PRT"
if tag == "RP" or tag == "POS":
return "PRT"
if tag == "MD":
return "AUX"
if tag == "EX":
return "PRON"
if tag in {"WDT", "WP", "WP$"}:
return "PRON"
if tag == "WRB":
return "ADV"
if tag == "UH":
return "INTJ"
if tag == "CD":
return "NUM"
if tag in {"DT", "PDT"}:
return "DET"
if tag in {"PRP", "PRP$"}:
return "PRON"
if tag.startswith("JJ"):
return "ADJ"
if tag.startswith("RB"):
return "ADV"
if tag.startswith("NN"):
return "NOUN"
if tag.startswith("VB"):
if lower in _AUX_WORDS:
return "AUX"
return "VERB"
if tag == "FW":
return "X"
if tag == "LS":
return "X"
# Уточнения по слову для распространённых разговорных форм.
if lower in _NEGATION_WORDS:
return "PRT"
if lower in _AUX_WORDS:
return "AUX"
if lower in PERSONAL_PRONOUNS:
return "PRON"
return "X"
def collapse_punctuation(tags: Sequence[str]) -> list[str]:
"""Схлопывает подряд идущие знаки препинания в один тег PUNCT."""
result: list[str] = []
for tag in tags:
if tag == "PUNCT" and result and result[-1] == "PUNCT":
continue
result.append(tag)
return result
def is_function_word(token: str, upos: str | None = None) -> bool:
"""Определяет, относится ли слово к закрытому классу."""
lower = token.lower()
if lower in FUNCTION_WORDS:
return True
if upos in {"ADP", "AUX", "CONJ", "DET", "PRON", "PRT", "SCONJ"}:
return True
return False
def is_content_word(token: str, upos: str | None = None, stopwords: set[str] | None = None) -> bool:
"""Определяет, является ли слово содержательным для content-LSA."""
lower = token.lower()
if not re.search(r"[A-Za-z]", token):
return False
if is_function_word(token, upos):
return False
if stopwords and lower in stopwords:
return False
return True
@dataclass
class AnalyzedLine:
"""Результат токенизации и разметки одной реплики."""
tokens: list[str] = field(default_factory=list)
ptb: list[str] = field(default_factory=list)
upos: list[str] = field(default_factory=list)
sentences: list[list[str]] = field(default_factory=list)
pos_text: str = ""
fw_text: str = ""
content_text: str = ""
class Tokenizer:
"""Токенизатор с NLTK и резервным режимом."""
def __init__(
self,
use_nltk: bool = True,
nltk_data_dir: str | Path | None = None,
strict: bool = False,
) -> None:
self.use_nltk = bool(use_nltk)
self.nltk_data_dir = Path(nltk_data_dir) if nltk_data_dir is not None else DEFAULT_NLTK_DIR
self.strict = strict
self.stopwords = load_stopwords(self.nltk_data_dir)
if self.use_nltk:
available = ensure_nltk_data(self.nltk_data_dir)
if not available:
message = (
"Ресурсы NLTK не найдены. Скачайте punkt_tab и "
"averaged_perceptron_tagger_eng или запустите с --no-nltk."
)
if self.strict:
raise RuntimeError(message)
print(f"[tokenization] {message} Включаю резервный режим.")
self.use_nltk = False
self._pos_available = self.use_nltk and _check_nltk_resource(
"taggers/averaged_perceptron_tagger_eng"
)
self._word_tokenize = None
self._sent_tokenize = None
self._pos_tag = None
if self.use_nltk:
from nltk import pos_tag
from nltk.tokenize import sent_tokenize, word_tokenize
self._word_tokenize = word_tokenize
self._sent_tokenize = sent_tokenize
self._pos_tag = pos_tag
@property
def pos_available(self) -> bool:
return self._pos_available
def tokenize(self, text: str) -> list[str]:
if self._word_tokenize is not None:
try:
return [token for token in self._word_tokenize(text) if token.strip()]
except Exception:
pass
return _WORD_RE.findall(text)
def sentence_tokenize(self, text: str) -> list[str]:
if self._sent_tokenize is not None:
try:
sentences = [sentence.strip() for sentence in self._sent_tokenize(text)]
return [sentence for sentence in sentences if sentence]
except Exception:
pass
return [part.strip() for part in _SENTENCE_RE.split(text) if part.strip()]
def pos_tag(self, tokens: Sequence[str]) -> list[tuple[str, str]]:
if self._pos_tag is not None:
try:
return list(self._pos_tag(list(tokens)))
except Exception:
pass
return [(token, "X") for token in tokens]
def analyze(self, text: str) -> AnalyzedLine:
tokens = self.tokenize(text)
tagged = self.pos_tag(tokens)
ptb = [tag for _, tag in tagged]
upos = [penn_to_upos(token, tag) for token, tag in tagged]
sentences: list[list[str]] = []
for sentence in self.sentence_tokenize(text):
sentence_tokens = self.tokenize(sentence)
if sentence_tokens:
sentences.append(sentence_tokens)
if not sentences and tokens:
sentences = [tokens]
pos_sequence = collapse_punctuation(upos)
pos_text = " ".join(pos_sequence) if pos_sequence else "__EMPTY__"
fw_tokens = [
token
for token, tag in zip(tokens, upos)
if is_function_word(token, tag)
]
fw_text = " ".join(fw_tokens) if fw_tokens else "__EMPTY__"
content_tokens = [
token
for token, tag in zip(tokens, upos)
if is_content_word(token, tag, self.stopwords)
]
content_text = " ".join(content_tokens) if content_tokens else "__EMPTY__"
return AnalyzedLine(
tokens=tokens,
ptb=ptb,
upos=upos,
sentences=sentences,
pos_text=pos_text,
fw_text=fw_text,
content_text=content_text,
)
def analyze_frame(
self,
frame: pd.DataFrame,
text_column: str = "text",
verbose: bool = False,
) -> pd.DataFrame:
"""Добавляет к таблице колонки с токенами, тегами и текстами для LSA."""
if text_column not in frame.columns:
raise KeyError(f"Нет колонки {text_column!r}")
records: list[dict[str, object]] = []
total = len(frame)
for position, (_, row) in enumerate(frame.iterrows(), start=1):
analyzed = self.analyze(str(row[text_column]))
records.append(
{
"tokens": analyzed.tokens,
"ptb": analyzed.ptb,
"upos": analyzed.upos,
"sentences": analyzed.sentences,
"pos_text": analyzed.pos_text,
"fw_text": analyzed.fw_text,
"content_text": analyzed.content_text,
}
)
if verbose and position % 500 == 0:
print(f"[tokenization] обработано {position} из {total}")
additions = pd.DataFrame(records, index=frame.index)
result = pd.concat([frame.copy(), additions], axis=1)
return result
def upos_tag_list() -> list[str]:
"""Список универсальных частеречных тегов, используемых в признаках."""
return list(UPOS_TAGS)
def ptb_tag_list() -> list[str]:
"""Список тегов Penn Treebank, используемых в признаках."""
return list(_PTB_TAGS)

View File

@@ -0,0 +1,129 @@
"""Верификация «тот же персонаж / другой персонаж» по центроидам классов."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Sequence
import numpy as np
import pandas as pd
from scipy.sparse import issparse
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.model_selection import StratifiedKFold
from sklearn.base import clone
from .pipelines import FeatureConfig, build_feature_union
def _to_dense(matrix: Any) -> np.ndarray:
return matrix.toarray() if issparse(matrix) else np.asarray(matrix)
@dataclass
class VerificationResult:
fold_metrics: pd.DataFrame
scores: pd.DataFrame
summary: dict[str, float]
def equal_error_rate(y_true: Sequence[int], scores: Sequence[float]) -> float:
"""Равная ошибка: точка, где FPR и FNR совпадают."""
fpr, tpr, _ = roc_curve(y_true, scores)
fnr = 1.0 - tpr
index = int(np.argmin(np.abs(fpr - fnr)))
return float((fpr[index] + fnr[index]) / 2.0)
def tar_at_far(
y_true: Sequence[int], scores: Sequence[float], far: float = 0.01
) -> float:
"""Доля принятых «своих» при заданной доле ложных принятий."""
fpr, tpr, _ = roc_curve(y_true, scores)
return float(np.interp(far, fpr, tpr))
def _centroids(matrix: np.ndarray, labels: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
classes = np.unique(labels)
centroids = np.vstack([matrix[labels == label].mean(axis=0) for label in classes])
return classes, centroids
def run_verification(
X: pd.DataFrame,
y: Sequence,
feature_config: FeatureConfig,
n_splits: int = 5,
seed: int = 42,
verbose: bool = True,
) -> VerificationResult:
"""Строит оценки «свой/чужой» для каждой реплики и каждого персонажа.
На каждом фолде центроиды персонажей считаются только по обучающим
репликам. Для тестовой реплики косинусная близость к центроиду своего
персонажа образует положительный класс, к остальным — отрицательный.
"""
y = np.asarray(y)
splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
fold_rows: list[dict[str, Any]] = []
score_rows: list[dict[str, Any]] = []
for fold, (train_idx, test_idx) in enumerate(splitter.split(X, y), start=1):
features = build_feature_union(feature_config)
features = clone(features)
train_matrix = _to_dense(features.fit_transform(X.iloc[train_idx]))
test_matrix = _to_dense(features.transform(X.iloc[test_idx]))
train_labels = y[train_idx]
classes, centroids = _centroids(train_matrix, train_labels)
similarities = cosine_similarity(test_matrix, centroids)
binary_true: list[int] = []
binary_scores: list[float] = []
top1_correct = 0
for row, sample_index in enumerate(test_idx):
true_label = y[sample_index]
for class_index, class_label in enumerate(classes):
score = float(similarities[row, class_index])
is_same = int(class_label == true_label)
binary_true.append(is_same)
binary_scores.append(score)
score_rows.append(
{
"fold": fold,
"line_id": int(X.iloc[sample_index]["line_id"]) if "line_id" in X.columns else int(sample_index),
"true_character": true_label,
"candidate_character": class_label,
"score": score,
"is_same": is_same,
}
)
predicted_label = classes[int(np.argmax(similarities[row]))]
top1_correct += int(predicted_label == true_label)
binary_true_array = np.asarray(binary_true)
binary_scores_array = np.asarray(binary_scores)
auc = float(roc_auc_score(binary_true_array, binary_scores_array))
eer = equal_error_rate(binary_true_array, binary_scores_array)
fold_rows.append(
{
"fold": fold,
"roc_auc": auc,
"eer": eer,
"tar_at_far_1pct": tar_at_far(binary_true_array, binary_scores_array, 0.01),
"top1_accuracy": top1_correct / max(len(test_idx), 1),
"n_test": int(len(test_idx)),
}
)
if verbose:
print(
f"[verification] фолд {fold}/{n_splits}: "
f"ROC-AUC={auc:.4f}, EER={eer:.4f}, top1={top1_correct / max(len(test_idx), 1):.4f}"
)
fold_metrics = pd.DataFrame(fold_rows)
scores = pd.DataFrame(score_rows)
summary = {
column: float(fold_metrics[column].mean())
for column in ("roc_auc", "eer", "tar_at_far_1pct", "top1_accuracy")
}
return VerificationResult(fold_metrics=fold_metrics, scores=scores, summary=summary)

View File

@@ -0,0 +1,33 @@
"""Тесты нормализации текста."""
from __future__ import annotations
from stylometry.cleaning import (
clean_text,
count_placeholders,
normalize_placeholders,
normalize_unicode,
normalized_key,
)
def test_placeholders_are_replaced() -> None:
text = "Hello, <PLAYER>! Your <FARM> is nice."
assert normalize_placeholders(text) == "Hello, PLAYER! Your FARM is nice."
assert count_placeholders(text) == 2
def test_unknown_placeholder_becomes_entity() -> None:
assert normalize_placeholders("Look, <UNKNOWN>!") == "Look, ENTITY!"
def test_unicode_normalization() -> None:
assert normalize_unicode("Oh… really—yes") == "Oh... really-yes"
def test_whitespace_collapse() -> None:
assert clean_text(" Hello\n\n world ") == "Hello world"
def test_normalized_key_ignores_case_and_punctuation() -> None:
assert normalized_key("Oh, <PLAYER>!") == normalized_key("oh player")

35
lab1/tests/test_cv.py Normal file
View File

@@ -0,0 +1,35 @@
"""Тесты кросс-валидации на синтетических данных."""
from __future__ import annotations
import numpy as np
import pandas as pd
from stylometry.cv import run_repeated_cv
def test_repeated_cv_runs_and_has_all_predictions() -> None:
rng = np.random.default_rng(0)
n_samples = 60
n_classes = 3
X = pd.DataFrame(
{
"feature_1": rng.normal(size=n_samples),
"feature_2": rng.normal(size=n_samples),
}
)
y = np.repeat([f"c{index}" for index in range(n_classes)], 20)
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
pipeline = Pipeline(
[("scale", StandardScaler()), ("model", LogisticRegression(max_iter=1000))]
)
result = run_repeated_cv(
pipeline, X, y, n_splits=3, n_repeats=2, seed=42, verbose=False
)
assert len(result.oof) == n_samples * 2
assert set(result.oof["y_true"]) == set(y)
assert result.fold_metrics["macro_f1"].between(0, 1).all()
assert result.labels.tolist() == ["c0", "c1", "c2"]

41
lab1/tests/test_delta.py Normal file
View File

@@ -0,0 +1,41 @@
"""Тесты дельты Барроуза."""
from __future__ import annotations
import numpy as np
import pandas as pd
from stylometry.delta import DeltaClassifier, MFWVectorizer
def _toy_frame() -> pd.DataFrame:
texts = [
"the cat sat on the mat",
"the cat sat on the mat again",
"the dog sat on the log",
"the dog sat on the log again",
"a cat and a dog",
"a cat and a dog again",
]
labels = ["cat", "cat", "dog", "dog", "cat", "dog"]
return pd.DataFrame({"text": texts, "character": labels})
def test_mfw_vectorizer_shape() -> None:
frame = _toy_frame()
vectorizer = MFWVectorizer(n_features=5)
matrix = vectorizer.fit_transform(frame)
assert matrix.shape == (6, 5)
assert np.isfinite(matrix).all()
def test_delta_fit_predict() -> None:
frame = _toy_frame()
classifier = DeltaClassifier(n_features=5, metric="manhattan")
classifier.fit(frame, frame["character"].values)
predictions = classifier.predict(frame)
assert len(predictions) == len(frame)
assert set(predictions) <= {"cat", "dog"}
probabilities = classifier.predict_proba(frame)
assert probabilities.shape == (len(frame), 2)
assert np.allclose(probabilities.sum(axis=1), 1.0)

View File

@@ -0,0 +1,38 @@
"""Тесты ручных стилометрических признаков."""
from __future__ import annotations
import numpy as np
from stylometry.datasets import load_feature_frame
from stylometry.features import StylometricVectorizer, feature_dictionary
def test_feature_matrix_shape_and_finiteness() -> None:
frame = load_feature_frame(verbose=False).head(20)
vectorizer = StylometricVectorizer()
matrix = vectorizer.fit_transform(frame)
names = vectorizer.get_feature_names_out()
assert matrix.shape == (20, len(names))
assert np.isfinite(matrix).all()
assert len(set(names)) == len(names)
def test_feature_dictionary() -> None:
frame = load_feature_frame(verbose=False).head(2)
vectorizer = StylometricVectorizer()
vectorizer.fit(frame)
dictionary = feature_dictionary(vectorizer.get_feature_names_out())
assert len(dictionary) == len(vectorizer.get_feature_names_out())
assert {"feature", "group", "description", "status"} <= set(dictionary.columns)
def test_group_subset() -> None:
frame = load_feature_frame(verbose=False).head(5)
vectorizer = StylometricVectorizer(groups=["length", "punctuation"])
matrix = vectorizer.fit_transform(frame)
assert matrix.shape[0] == 5
assert all(
name.startswith("len_") or name.startswith("punct_")
for name in vectorizer.get_feature_names_out()
)

52
lab1/tests/test_io.py Normal file
View File

@@ -0,0 +1,52 @@
"""Тесты чтения и валидации корпуса."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from stylometry.io import (
DEFAULT_DATA_PATH,
compare_with_stats,
corpus_fingerprint,
read_corpus,
read_stats,
validate_corpus,
)
def test_read_corpus_shape() -> None:
frame = read_corpus()
assert len(frame) == 2005
assert frame["character"].nunique() == 34
assert {"line_id", "text", "character", "text_raw", "placeholder_count"} <= set(frame.columns)
assert frame["text"].str.len().gt(0).all()
def test_corpus_is_cp1251() -> None:
raw = DEFAULT_DATA_PATH.read_bytes()
assert raw.decode("cp1251").startswith("Реплика;Персонаж")
def test_validate_corpus() -> None:
frame = read_corpus()
stats = validate_corpus(frame)
assert stats["n_lines"] == 2005
assert stats["n_characters"] == 34
assert stats["n_duplicate_texts"] == 0
assert stats["n_words"] > 30_000
def test_compare_with_stats() -> None:
frame = read_corpus()
comparison = compare_with_stats(frame, read_stats())
assert not comparison.empty
assert (comparison["difference"] == 0).all()
def test_fingerprint_is_stable() -> None:
first = corpus_fingerprint()
second = corpus_fingerprint()
assert first == second
assert len(first) == 64

45
lab1/tests/test_lsa.py Normal file
View File

@@ -0,0 +1,45 @@
"""Тесты TF-IDF и LSA."""
from __future__ import annotations
import numpy as np
from stylometry.datasets import load_feature_frame
from stylometry.lsa import (
make_char_ngram_vectorizer,
make_lsa_vectorizer,
make_pos_ngram_vectorizer,
make_text_vectorizer,
)
def test_lsa_word_shape_and_variance() -> None:
frame = load_feature_frame(verbose=False).head(100)
vectorizer = make_lsa_vectorizer("word", n_components=10)
matrix = vectorizer.fit_transform(frame)
assert matrix.shape == (100, 10)
assert np.isfinite(matrix).all()
assert 0 <= vectorizer.cumulative_explained_variance_[-1] <= 1.0
assert len(vectorizer.top_terms(5)) == 10
def test_lsa_pos_and_char() -> None:
frame = load_feature_frame(verbose=False).head(100)
for variant in ("pos", "char"):
vectorizer = make_lsa_vectorizer(variant, n_components=5)
matrix = vectorizer.fit_transform(frame)
assert matrix.shape[0] == 100
assert matrix.shape[1] == 5
assert np.isfinite(matrix).all()
def test_text_vectorizers() -> None:
frame = load_feature_frame(verbose=False).head(50)
for vectorizer in (
make_text_vectorizer("word"),
make_pos_ngram_vectorizer(),
make_char_ngram_vectorizer(),
):
matrix = vectorizer.fit_transform(frame)
assert matrix.shape[0] == 50
assert matrix.shape[1] > 0

View File

@@ -0,0 +1,36 @@
"""Тесты анализа по метаданным персонажей."""
from __future__ import annotations
import pandas as pd
from stylometry.metadata import (
group_confusions,
load_character_metadata,
subgroup_report,
)
def test_metadata_table() -> None:
metadata = load_character_metadata()
assert len(metadata) == 34
assert {"character", "gender", "age_group", "species", "role"} <= set(metadata.columns)
def test_subgroup_report() -> None:
metadata = load_character_metadata()
y_true = ["Abigail", "Abigail", "Alex", "Alex"]
y_pred = ["Abigail", "Alex", "Alex", "Alex"]
labels = ["Abigail", "Alex"]
report = subgroup_report(y_true, y_pred, labels, metadata, "gender")
assert {"group", "weighted_f1", "n_samples"} <= set(report.columns)
assert report["n_samples"].sum() == 4
def test_group_confusions() -> None:
metadata = load_character_metadata()
y_true = ["Abigail", "Abigail", "Alex", "Alex"]
y_pred = ["Abigail", "Alex", "Alex", "Abigail"]
table = group_confusions(y_true, y_pred, metadata, "gender")
assert not table.empty
assert {"true_group", "pred_group", "count"} <= set(table.columns)

View File

@@ -0,0 +1,72 @@
"""Тесты метрик и статистических критериев."""
from __future__ import annotations
import numpy as np
import pandas as pd
from stylometry.evaluation import (
bootstrap_ci,
classification_metrics,
confusion_matrix_frame,
per_class_report,
)
from stylometry.stats import (
bootstrap_difference,
holm_bonferroni,
mcnemar_exact,
wilcoxon_signed_rank,
)
def test_classification_metrics_perfect() -> None:
y_true = np.array(["a", "b", "c", "a", "b", "c"])
y_pred = y_true.copy()
metrics = classification_metrics(y_true, y_pred)
assert metrics["accuracy"] == 1.0
assert metrics["macro_f1"] == 1.0
assert metrics["balanced_accuracy"] == 1.0
def test_confusion_matrix_and_report() -> None:
y_true = np.array(["a", "a", "b", "b"])
y_pred = np.array(["a", "b", "b", "b"])
matrix = confusion_matrix_frame(y_true, y_pred, ["a", "b"])
assert matrix.loc["a", "a"] == 1
report = per_class_report(y_true, y_pred, ["a", "b"])
assert {"precision", "recall", "f1", "support"} <= set(report.columns)
def test_bootstrap_ci() -> None:
y_true = np.array(["a", "b", "a", "b", "a", "b"])
y_pred = np.array(["a", "b", "a", "a", "a", "b"])
interval = bootstrap_ci(y_true, y_pred, ["a", "b"], n_boot=50, seed=1)
assert interval["lower"] <= interval["upper"]
def test_mcnemar() -> None:
y_true = np.array([1, 1, 0, 0, 1, 0])
pred_a = np.array([1, 1, 0, 0, 1, 0])
pred_b = np.array([1, 0, 0, 1, 1, 0])
result = mcnemar_exact(y_true, pred_a, pred_b)
assert result["b"] >= 0 and result["c"] >= 0
assert 0 <= result["p_value"] <= 1
def test_holm() -> None:
adjusted = holm_bonferroni([0.01, 0.02, 0.03])
assert len(adjusted) == 3
assert all(0 <= value <= 1 for value in adjusted)
def test_wilcoxon() -> None:
result = wilcoxon_signed_rank([0.5, 0.6, 0.7], [0.4, 0.6, 0.5])
assert 0 <= result["p_value"] <= 1
def test_bootstrap_difference() -> None:
y_true = np.array([0, 1, 2, 0, 1, 2])
pred_a = np.array([0, 1, 2, 0, 1, 2])
pred_b = np.array([0, 1, 1, 0, 1, 1])
result = bootstrap_difference(y_true, pred_a, pred_b, labels=[0, 1, 2], n_boot=50)
assert result["lower"] <= result["upper"]

View File

@@ -0,0 +1,40 @@
"""Тесты NLTK-токенизации и частеречной разметки."""
from __future__ import annotations
from stylometry.tokenization import Tokenizer, penn_to_upos
def test_penn_to_upos() -> None:
assert penn_to_upos("the", "DT") == "DET"
assert penn_to_upos("run", "VB") == "VERB"
assert penn_to_upos("is", "VBZ") == "AUX"
assert penn_to_upos("because", "IN") == "SCONJ"
assert penn_to_upos("quickly", "RB") == "ADV"
def test_analyze_line() -> None:
tokenizer = Tokenizer(use_nltk=True)
analyzed = tokenizer.analyze("Oh... hi. I don't know, Mr. Qi.")
assert analyzed.tokens
assert len(analyzed.tokens) == len(analyzed.upos) == len(analyzed.ptb)
assert analyzed.sentences
assert analyzed.pos_text
assert analyzed.fw_text
assert analyzed.content_text
def test_placeholders_are_tagged_as_words() -> None:
tokenizer = Tokenizer(use_nltk=True)
analyzed = tokenizer.analyze("Hello, PLAYER! You are nice.")
assert "PLAYER" in analyzed.tokens
assert len(analyzed.upos) == len(analyzed.tokens)
def test_fallback_regex_tokenizer() -> None:
tokenizer = Tokenizer(use_nltk=False)
analyzed = tokenizer.analyze("Hello, world! It's fine.")
assert analyzed.tokens == ["Hello", ",", "world", "!", "It's", "fine", "."]
assert len(analyzed.upos) == len(analyzed.tokens)
assert analyzed.upos[0] == "X"
assert "PUNCT" in analyzed.upos