Первичная версия: стилометрия реплик, LSA и протокол экспериментов
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
56
README.md
Normal 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
11
lab1/.gitignore
vendored
Normal 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
747
lab1/EXPERIMENTS.md
Normal 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
294
lab1/FEATURES.md
Normal 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 и синтез текста.** Может создать неестественные комбинации слов и
|
||||
исказить стилометрический сигнал.
|
||||
894
lab1/METRICS_AND_CLASSIFIERS.md
Normal file
894
lab1/METRICS_AND_CLASSIFIERS.md
Normal 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 | Метрика расстояния в стилометрии |
|
||||
228
lab1/PRESENTATION_OUTLINE.md
Normal file
228
lab1/PRESENTATION_OUTLINE.md
Normal 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
231
lab1/README.md
Normal 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
686
lab1/RUNBOOK.md
Normal 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
27
lab1/configs/default.toml
Normal 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"
|
||||
49
lab1/data/corpus_report_after.txt
Normal file
49
lab1/data/corpus_report_after.txt
Normal 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
|
||||
158
lab1/data/corpus_report_before_cleaning.txt
Normal file
158
lab1/data/corpus_report_before_cleaning.txt
Normal 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
2006
lab1/data/dialogues.csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
lab1/data/dialogues.xlsx
Normal file
BIN
lab1/data/dialogues.xlsx
Normal file
Binary file not shown.
35
lab1/data/metadata/characters.csv
Normal file
35
lab1/data/metadata/characters.csv
Normal 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,волшебник
|
||||
|
BIN
lab1/data/stats_per_character.xlsx
Normal file
BIN
lab1/data/stats_per_character.xlsx
Normal file
Binary file not shown.
18
lab1/experiments/README.md
Normal file
18
lab1/experiments/README.md
Normal 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
16
lab1/main.py
Normal 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
10
lab1/requirements.txt
Normal 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
30
lab1/results/README.md
Normal 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
|
||||
```
|
||||
77
lab1/scripts/check_environment.py
Normal file
77
lab1/scripts/check_environment.py
Normal 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
56
lab1/scripts/collect_results.sh
Executable 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."
|
||||
29
lab1/scripts/download_nltk_data.py
Normal file
29
lab1/scripts/download_nltk_data.py
Normal 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())
|
||||
169
lab1/scripts/run_all_experiments.sh
Executable file
169
lab1/scripts/run_all_experiments.sh
Executable 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"
|
||||
31
lab1/scripts/run_core_experiments.sh
Executable file
31
lab1/scripts/run_core_experiments.sh
Executable 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
|
||||
13
lab1/stylometry/__init__.py
Normal file
13
lab1/stylometry/__init__.py
Normal 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
123
lab1/stylometry/cleaning.py
Normal 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
866
lab1/stylometry/cli.py
Normal 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())
|
||||
90
lab1/stylometry/clustering.py
Normal file
90
lab1/stylometry/clustering.py
Normal 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
293
lab1/stylometry/cv.py
Normal 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,
|
||||
)
|
||||
86
lab1/stylometry/datasets.py
Normal file
86
lab1/stylometry/datasets.py
Normal 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
148
lab1/stylometry/delta.py
Normal 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()
|
||||
273
lab1/stylometry/evaluation.py
Normal file
273
lab1/stylometry/evaluation.py
Normal 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
183
lab1/stylometry/io.py
Normal 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
175
lab1/stylometry/lexicons.py
Normal 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
317
lab1/stylometry/lsa.py
Normal 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)
|
||||
99
lab1/stylometry/metadata.py
Normal file
99
lab1/stylometry/metadata.py
Normal 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
|
||||
365
lab1/stylometry/pipelines.py
Normal file
365
lab1/stylometry/pipelines.py
Normal 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
167
lab1/stylometry/plotting.py
Normal 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
143
lab1/stylometry/stats.py
Normal 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}
|
||||
363
lab1/stylometry/tokenization.py
Normal file
363
lab1/stylometry/tokenization.py
Normal 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)
|
||||
129
lab1/stylometry/verification.py
Normal file
129
lab1/stylometry/verification.py
Normal 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)
|
||||
33
lab1/tests/test_cleaning.py
Normal file
33
lab1/tests/test_cleaning.py
Normal 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
35
lab1/tests/test_cv.py
Normal 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
41
lab1/tests/test_delta.py
Normal 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)
|
||||
38
lab1/tests/test_features.py
Normal file
38
lab1/tests/test_features.py
Normal 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
52
lab1/tests/test_io.py
Normal 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
45
lab1/tests/test_lsa.py
Normal 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
|
||||
36
lab1/tests/test_metadata.py
Normal file
36
lab1/tests/test_metadata.py
Normal 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)
|
||||
72
lab1/tests/test_metrics.py
Normal file
72
lab1/tests/test_metrics.py
Normal 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"]
|
||||
40
lab1/tests/test_tokenization.py
Normal file
40
lab1/tests/test_tokenization.py
Normal 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
|
||||
BIN
lab1/О первой практической работе (ААТ, ИВТ).pdf
Normal file
BIN
lab1/О первой практической работе (ААТ, ИВТ).pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user