Добавлены финальные эксперименты и итоговый отчёт

This commit is contained in:
2026-10-26 13:27:53 +03:00
parent c3769b1d4a
commit 73c32d10c4
8 changed files with 1537 additions and 0 deletions

674
lab1/FINAL_REPORT.md Normal file
View File

@@ -0,0 +1,674 @@
# Итоговый отчёт по лабораторной работе
## Стилометрическая классификация реплик персонажей Stardew Valley
---
## Аннотация
Работа посвящена проверке того, можно ли стилометрически различить персонажей
игры Stardew Valley по отдельным репликам, если все реплики написаны одним
автором. Корпус содержит 2005 реплик, принадлежащих 34 персонажам; классы
неравномерны (от 15 до 139 реплик). Основная задача — многоклассовая
классификация; основная метрика — макро-усреднённая F-мера (macro-F1).
В работе построены ручные стилометрические признаки, символьные и
частеречные n-граммы, а также несколько вариантов латентно-семантического
анализа (LSA). Сравнивались логистическая регрессия, линейный метод опорных
векторов, наивный байесовский классификатор, случайный лес, сверхслучайные
деревья, метод ближайших соседей и дельта Барроуза. Использована повторная
стратифицированная перекрёстная проверка, вложенная настройка гиперпараметров,
контроль перестановкой меток и анализ ошибок.
Лучший наблюдаемый результат — мягкое голосование трёх моделей: macro-F1
около **0,304** при 5-блочной перекрёстной проверке и **0,333** на отдельной
стратифицированной отложенной выборке. Однако состав ансамбля выбирался по
результатам предыдущих серий, поэтому более консервативная оценка —
**0,275** для модели ExtraTrees с расширенным символьным словарём при
вложенном выборе конфигурации. Случайный уровень для 34 классов составляет
около **0,029**.
Главный содержательный вывод: персонажи действительно различимы, но
основной вклад дают лексико-тематические признаки и символьные n-граммы, а не
функциональные слова и синтаксис. При замене содержательных слов на
нейтральный маркер качество ExtraTrees падает с 0,248 до 0,113. Следовательно,
результат корректно интерпретировать как различимость речевых масок по
лексико-тематическому профилю, а не как доказательство «чистого» авторского
или персонажного идиолекта.
---
## 1. Введение
### 1.1. Постановка задачи
Рассматривается задача классификации коротких текстов — игровых реплик — по
принадлежности персонажу. Каждая реплика относится ровно к одному из 34
классов. Требуется:
1. построить признаки, отражающие стилометрические свойства реплик;
2. сравнить несколько семейств классификаторов;
3. оценить качество с учётом дисбаланса классов и малого объёма данных;
4. проверить, обусловлен ли результат стилем или содержанием;
5. сформулировать ограничения и направления дальнейшей работы.
### 1.2. Исследовательский вопрос
**Вопрос:** различимы ли 34 персонажа Stardew Valley по стилометрическим
признакам отдельных реплик, если все реплики написаны одним автором?
Это не авторская атрибуция. Автор один; речь идёт о классификации **речевых
масок персонажей** (character idiolect). Положительный результат означает,
что созданные автором речевые маски статистически различимы, но не означает,
что у персонажей разные авторы.
### 1.3. Гипотезы
| Обозначение | Гипотеза |
|---|---|
| H1 | Функциональные слова, частеречные признаки и символьные n-граммы различают персонажей выше случайного уровня |
| H2 | На коротких репликах частеречные и символьные признаки устойчивее словарного богатства и читаемости |
| H3 | LSA по всем словам отражает тему, а LSA по частеречным последовательностям — стиль |
| H4 | Качество зависит от длины реплики |
| H5 | Ошибки концентрируются вокруг персонажей со сходным возрастом, полом или ролью |
---
## 2. Корпус
### 2.1. Объём и разметка
| Параметр | Значение |
|---|---|
| Реплик | 2005 |
| Персонажей | 34 |
| Словоупотреблений | около 38,2 тыс. |
| Средняя длина реплики | 19,1 слова |
| Медиана | 17 слов |
| Минимум / максимум | 3 / 78 слов |
| Классы | 15–139 реплик |
| Точные дубли реплик | 0 |
Корпус хранится в кодировке cp1251; предусмотрено чтение XLSX. Разметка
содержит только текст реплики и имя персонажа. Метаданные о поле, возрасте,
виде и роли персонажей хранятся отдельно и используются только для анализа
ошибок, не как признаки классификатора.
### 2.2. Ограничения корпуса
- Корпус прошёл ручную фильтрацию: короткие и общие фразы удалены. Точные
правила очистки не сохранились, поэтому воспроизвести исходное
распределение невозможно.
- Реплики короткие; признаки словарного богатства и читаемости на них
нестабильны.
- Классы неравномерны; редкие персонажи имеют широкие доверительные
интервалы.
- Нет идентификаторов сцен и диалогов; нельзя учесть корреляцию реплик
внутри одной сцены.
- Все тексты написаны одним автором.
---
## 3. Методика
### 3.1. Предобработка
1. Приведение Unicode: многоточия, кавычки, тире, неразрывные пробелы.
2. Замена плейсхолдеров `<PLAYER>`, `<FARM>`, `<PET>`, `<SPOUSE>`, `<TIME>`,
`<NOUN>`, `<NAME>` на нейтральные слова; количество плейсхолдеров
сохраняется отдельным признаком.
3. Токенизация и разбиение на предложения средствами NLTK.
4. Частеречная разметка Penn Treebank с переводом в Universal POS.
5. Схлопывание подряд идущих знаков препинания в один тег `PUNCT` для
частеречных последовательностей.
Не выполнялись: удаление стоп-слов, удаление пунктуации, лемматизация,
удаление коротких реплик. Эти элементы рассматриваются как стилометрический
сигнал.
### 3.2. Признаковое пространство
Ручные признаки сгруппированы в 11 семейств; всего около 454 признака.
| Группа | Примеры | Статус |
|---|---|---|
| Длина | число токенов, слов, символов, предложений; длина предложения | Основная, но чувствительная к длине |
| Лексика | распределение длин слов, доля слов с апострофом, заглавных, цифр | Основная |
| Словарное богатство | TTR, Guiraud, Herdan, Yule's K, Simpson's D, hapax/dis, Honoré | Предварительная |
| Символы | частоты букв, регистр, цифры, пунктуация | Основная |
| Пунктуация | запятые, точки, восклицательные, вопросительные, многоточия | Основная |
| Функциональные слова | около 150 закрытых слов и 12 категорий | Основная |
| Местоимения | 1-е, 2-е, 3-е лицо, возвратные, притяжательные | Основная |
| Части речи | Universal POS, Penn Treebank, время, модальность, герундий | Основная |
| Синтаксис-прокси | число клауз, подчинительные союзы, вопросы, пассив-прокси | Основная |
| Читаемость | Flesch, FK grade, Gunning Fog, SMOG, Coleman-Liau, ARI | Предварительная |
| Разговорные маркеры | междометия, смех, хеджи, интенсификаторы, вежливость, сокращения | Основная |
Дополнительно используются:
- TF-IDF по символьным n-граммам (`char_wb`, 2–5, до 5000–10000 признаков);
- TF-IDF по последовательностям Universal POS (1–3, до 500 признаков);
- LSA по вариантам `word`, `word2`, `content`, `fw`, `char`, `pos`.
### 3.3. Латентно-семантический анализ
LSA реализован как последовательность:
```text
TF-IDF -> усечённое сингулярное разложение (TruncatedSVD) -> L2-нормализация
```
Варианты:
| Вариант | Что отражает |
|---|---|
| `word` | лексику и тему |
| `word2` | лексику и устойчивые сочетания |
| `content` | содержание после удаления стоп-слов |
| `fw` | функциональные слова и синтаксис |
| `char` | морфологию и пунктуацию |
| `pos` | частеречные последовательности |
Число компонент подбиралось по объяснённой дисперсии и внешней метрике.
Для оценки использовались 50–400 компонент; для `content`-LSA наблюдался
монотонный рост качества до 400 компонент.
### 3.4. Классификаторы
| Модель | Краткая характеристика |
|---|---|
| Dummy | нижняя граница; предсказывает самый частый класс |
| MultinomialNB | вероятностная модель для неотрицательных частотных признаков |
| LogisticRegression | линейная модель с L2-регуляризацией и весами классов |
| LinearSVC | линейный метод опорных векторов с максимизацией зазора |
| RandomForest | ансамбль решающих деревьев |
| ExtraTrees | ансамбль деревьев со случайными порогами |
| kNN | классификация по ближайшим объектам с косинусным расстоянием |
| Delta | дельта Барроуза по наиболее частотным словам |
Для логистической регрессии, SVM и деревьев использовались веса классов.
Для MultinomialNB LSA-блоки исключались, поскольку компоненты SVD могут быть
отрицательными.
### 3.5. Схема валидации
- Основная схема: `RepeatedStratifiedKFold(n_splits=5, n_repeats=3–5)`.
- Все обучаемые преобразования (TF-IDF, SVD, масштабирование) настраивались
только на обучающих блоках.
- Для настройки гиперпараметров использовалась вложенная перекрёстная
проверка: внешние 5 блоков, внутренние 3 блока.
- Дополнительно выполнены:
- вложенный выбор конфигурации признаков и модели;
- стратифицированная отложенная выборка 80/20;
- контроль перестановкой меток;
- анализ почти-дублей;
- маскирование содержательных слов;
- коррекция априорных вероятностей классов;
- мягкое голосование и стековое объединение.
### 3.6. Метрики
Основная метрика — macro-F1. Дополнительно использовались:
- доля правильных ответов (accuracy);
- сбалансированная доля правильных ответов;
- коэффициент корреляции Мэтьюса (MCC);
- каппа Коэна;
- точность попадания истинного класса в тройку и пятёрку лучших;
- логарифмическая потеря и оценка Брайера для вероятностных моделей;
- точность, полнота и F-мера по каждому классу;
- матрица ошибок;
- бутстрэп-интервалы;
- критерий Мак-Немара, критерий Уилкоксона, критерий Фридмана и поправка
Холма—Бонферрони.
Случайный уровень для 34 классов: macro-F1 ≈ 0,029, top-3 ≈ 0,088,
top-5 ≈ 0,147.
---
## 4. Эксперименты
### 4.1. Исходная серия
Исходная серия включала:
- нижние границы: длина, символьные n-граммы, POS, функциональные слова;
- ручную стилометрию;
- объединение ручных признаков с символьными и частеречными n-граммами;
- варианты LSA;
- сравнение классификаторов;
- поочерёдное исключение групп признаков;
- дельту Барроуза;
- верификацию «свой/чужой»;
- кластеризацию;
- анализ длины и подгрупп.
### 4.2. Follow-up серия коллеги
В follow-up серии было выполнено 68 запусков. Основные результаты:
| Конфигурация | Macro-F1 | Доля правильных ответов | Top-3 |
|---|---:|---:|---:|
| `char_max10000_rf` | 0,2583 ± 0,0066 | 0,2391 | 0,4197 |
| `model_et_default` | 0,2551 ± 0,0073 | 0,2387 | 0,4170 |
| `groups_lean2_content300` | 0,2510 ± 0,0040 | 0,2379 | 0,4111 |
| `stylometry_pos_content300` | 0,2485 ± 0,0042 | 0,2337 | 0,4175 |
| `ref_logreg` | 0,1785 ± 0,0060 | 0,1679 | 0,3322 |
| `stylometry` | 0,1051 ± 0,0038 | 0,1012 | 0,2338 |
| `lsa_content_k400` | 0,2114 ± 0,0029 | 0,2059 | 0,3767 |
| `lsa_pos_k300` | 0,0667 ± 0,0040 | 0,0663 | 0,1556 |
Контроль перестановкой меток дал 0,0270–0,0282, то есть случайный уровень.
Почти-дубликаты (13 пар с косинусной близостью ≥ 0,8) оказались внутри одного
персонажа; межклассовых почти-дублей не обнаружено.
### 4.3. Новые эксперименты
#### 4.3.1. Вложенный выбор конфигурации
Во внешнем цикле из 5 блоков и внутреннем цикле из 3 блоков сравнивались
восемь конфигураций:
- ExtraTrees на полном наборе;
- ExtraTrees без LSA;
- ExtraTrees с lean-набором и `content`-LSA (300 и 400 компонент);
- логистическая регрессия с lean-набором и `content`-LSA(300);
- случайный лес с `char_max_features` 10000 и 15000;
- ExtraTrees с `char_max_features=10000`.
Выбор выполнялся только по внутренним блокам. Результат:
- macro-F1 = **0,2746 ± 0,0233**;
- в 14 из 15 внешних блоков выбрана конфигурация ExtraTrees с
`char_max_features=10000`;
- в 1 из 15 — ExtraTrees с lean-набором и `content`-LSA(400).
Это наиболее консервативная оценка, поскольку конфигурация выбирается внутри
обучающей части каждого внешнего блока.
#### 4.3.2. Финальные кандидаты
| Конфигурация | Macro-F1 | Доля правильных ответов | Top-3 |
|---|---:|---:|---:|
| ExtraTrees, `char_max_features=10000` | **0,2757 ± 0,0219** | 0,2633 | 0,4554 |
| ExtraTrees, полный набор | 0,2499 ± 0,0209 | 0,2374 | 0,4234 |
| ExtraTrees, lean + content-LSA(300) | 0,2539 ± 0,0237 | 0,2354 | 0,4339 |
| ExtraTrees, lean + content-LSA(400) | 0,2511 ± 0,0212 | 0,2279 | 0,4219 |
| LogisticRegression, lean + content-LSA(300) | 0,2466 ± 0,0191 | 0,2349 | 0,4030 |
#### 4.3.3. Мягкое голосование и стековое объединение
Ансамбль из трёх моделей:
- ExtraTrees на полном наборе;
- ExtraTrees с `char_max_features=10000`;
- логистическая регрессия с lean-набором и `content`-LSA(300).
Результат мягкого голосования:
- macro-F1 = **0,3036 ± 0,0189**;
- доля правильных ответов = 0,2808;
- top-3 = 0,4823.
Вариант с четырьмя моделями (добавлен случайный лес с
`char_max_features=10000`) дал 0,2983 ± 0,0187; статистически он не лучше
трёхмодельного варианта.
Стековое объединение с мета-классификатором логистической регрессии дало
0,2259 ± 0,0163, то есть заметно хуже мягкого голосования. Это указывает на
переобучение мета-классификатора при малом числе объектов.
#### 4.3.4. Контроль содержания
Для проверки того, обусловлен ли результат темой, содержательные слова
(существительные, глаголы, прилагательные, наречия, числительные) были
заменены на нейтральный маркер `CONTENT`. Служебные слова, пунктуация и
частеречные теги сохранялись.
| Условие | Модель | Macro-F1 |
|---|---|---:|
| Исходный текст, стилевой набор | ExtraTrees | 0,2483 ± 0,0201 |
| Маскированный текст, стилевой набор | ExtraTrees | 0,1129 ± 0,0162 |
| Исходный текст, стилевой набор | LogisticRegression | 0,1467 ± 0,0213 |
| Маскированный текст, стилевой набор | LogisticRegression | 0,1067 ± 0,0125 |
Снижение для ExtraTrees составляет около 0,135 macro-F1, то есть более
половины качества. Стилевой сигнал после маскирования сохраняется, но он
существенно слабее содержательного.
#### 4.3.5. Коррекция априорных вероятностей
Проверена коррекция вероятностей вида
\[
p'_{ic} \propto p_{ic} \cdot \pi_c^{-\alpha},
\]
где \(\pi_c\) — априорная частота класса. Параметр \(\alpha\) выбирался по
схеме «один повтор — отложенный, остальные — обучающие».
Среднее качество:
- без коррекции: 0,2529;
- с коррекцией: 0,2507;
- среднее изменение: −0,0022.
Простая коррекция априорных вероятностей не улучшает macro-F1. Это означает,
что проблема редких классов не решается только изменением приоров; нужны
более тонкие правила принятия решений или дополнительные данные.
#### 4.3.6. Дополнительные конфигурации
| Конфигурация | Macro-F1 |
|---|---:|
| RandomForest, `char_max_features=15000` | 0,2570 ± 0,0139 |
| LogisticRegression, lean + content-LSA(400) | 0,2532 ± 0,0188 |
| LogisticRegression, lean + content-LSA(500) | 0,2542 ± 0,0180 |
| ExtraTrees с весами блоков | 0,2478 ± 0,0208 |
Увеличение символьного словаря с 10000 до 15000 не дало прироста.
Увеличение числа компонент `content`-LSA с 300 до 500 также не дало
устойчивого улучшения. Веса блоков не улучшили результат.
#### 4.3.7. Отложенная выборка
На стратифицированной отложенной выборке 80/20 (401 объект):
| Модель | Macro-F1 |
|---|---:|
| Мягкое голосование трёх моделей | **0,3333** |
| ExtraTrees, полный набор | 0,2793 |
| RandomForest, `char_max_features=10000` | 0,2554 |
| LogisticRegression, lean + content-LSA(300) | 0,2459 |
Отложенная выборка даёт более высокие значения, чем перекрёстная проверка,
но это одно разбиение; его интервал широкий, поэтому результаты нельзя
считать окончательными.
---
## 5. Результаты и обсуждение
### 5.1. Основной результат
Персонажи статистически различимы. Лучшая оценка по перекрёстной проверке —
0,304 macro-F1 для мягкого голосования; консервативная оценка с вложенным
выбором конфигурации — 0,275. Случайный уровень — 0,029.
Для интерпретации удобно перевести macro-F1 в кратность превышения
случайного уровня:
- 0,275 / 0,029 ≈ 9,5;
- 0,304 / 0,029 ≈ 10,5.
Это заметный сигнал, но не достаточный для практической системы
автоматического определения персонажа по одной реплике.
### 5.2. Что даёт основной вклад
Сравнение условий:
| Условие | Macro-F1 |
|---|---:|
| Только длина | 0,035 |
| Только ручные стилометрические признаки | 0,105 |
| Ручные + символьные и частеречные n-граммы | 0,182 |
| То же + word/pos-LSA(100) | 0,1785 |
| То же + content-LSA(300) | 0,2485 |
| ExtraTrees с расширенным символьным словарём | 0,276 |
| Мягкое голосование | 0,304 |
Основной прирост дают:
1. символьные n-граммы;
2. `content`-LSA, то есть содержание и тема;
3. нелинейная модель (деревья) для совместного использования признаков.
Функциональные слова и POS-LSA сами по себе слабы. Это важный
отрицательный результат: «чистый синтаксис» в текущем виде недостаточен.
### 5.3. Стиль или содержание
Контроль содержания показывает:
- исходный стилевой набор: 0,2483;
- после маскирования содержательных слов: 0,1129.
Следовательно, более половины качества связано с лексическим содержанием.
Остающийся сигнал 0,113 в 3,9 раза выше случайного уровня 0,029, то есть
некоторый стилевой компонент присутствует, но он существенно слабее
тематического.
Корректная формулировка:
> Персонажи различимы по лексико-тематическому профилю и символьным
> n-граммам; наличие собственно стилистического сигнала подтверждается лишь
> частично и требует дальнейшего тематического контроля.
### 5.4. Ошибки модели
Для лучшей одиночной модели ExtraTrees с `char_max_features=10000`
(первый повтор, 2005 реплик):
- Abigail получает 17,7% всех предсказаний при истинной доле 6,9%;
precision 0,099, recall 0,252, F1 0,142;
- худшие F1: Kent 0,000, Wizard 0,000, Penny 0,021, Mister Qi 0,087,
Leah 0,110, Abigail 0,142, Linus 0,146, Jodi 0,147;
- лучшие F1: Evelyn 0,646, Willy 0,581, Pam 0,522, Pierre 0,513,
Leo 0,508, Vincent 0,438.
Мягкое голосование улучшает часть редких классов: Wizard F1 возрастает до
0,30, Dwarf — до 0,30, Vincent — до 0,46. Однако Kent по-прежнему имеет
F1 = 0, а Abigail остаётся «hub»-классом.
Основные потоки ошибок: Sebastian → Abigail (31), Sam → Abigail (22),
Alex → Abigail (22), Elliott → Abigail (21). Это не демографическая
путаница, а коллапс редких классов в несколько популярных.
### 5.5. Длина реплики
Для ExtraTrees с расширенным символьным словарём:
| Длина | Macro-F1 |
|---|---:|
| 0–5 слов | 0,097 |
| 6–10 | 0,244 |
| 11–15 | 0,233 |
| 16–20 | 0,239 |
| 21–30 | **0,295** |
| 31+ | 0,271 |
Зависимость немонотонная: максимум приходится на 21–30 слов, после 30 слов
качество снижается. Гипотеза «чем длиннее, тем лучше» не подтверждается.
### 5.6. Подгруппы
Для ExtraTrees с расширенным символьным словарём:
- возраст: elder 0,440, child 0,424, adult 0,249, teen 0,201;
- пол: male 0,282, unknown 0,242, female 0,236;
- вид: monster 0,287, human 0,263, unknown 0,033.
Это F1 внутри группы персонажей, а не качество предсказания группы.
Различия описательные; они не доказывают, что модель обязана путать
персонажей внутри группы.
### 5.7. Верификация и кластеризация
- Верификация: ROC-AUC 0,630–0,633, EER 0,412–0,415, top-1 0,133–0,141.
- Кластеризация: ARI = 0, NMI 0,14–0,53 для k = 3/5/8.
Обе постановки показывают, что в признаковом пространстве нет устойчивых
кластеров, соответствующих персонажам. Это ограничивает интерпретацию
результатов классификации.
### 5.8. Статистическая надёжность
Мягкое голосование статистически лучше одиночной модели ExtraTrees с
`char_max_features=10000` на общих повторах:
| Повтор | Разница macro-F1 | McNemar p |
|---|---:|---:|
| 0 | +0,0287 | 0,012 |
| 1 | +0,0380 | < 0,00001 |
| 2 | +0,0163 | 0,0012 |
Разница положительна во всех трёх повторах. Однако состав ансамбля
выбирался по предыдущим результатам, поэтому окончательная оценка должна
быть подтверждена внешней выборкой.
При 46 парных сравнениях необходима поправка Холма—Бонферрони. Эффекты
+0,04…+0,08 её переживают; малые различия +0,003…+0,009 — нет.
---
## 6. Ограничения
1. **Один корпус.** Все результаты получены на 2005 репликах одной игры.
Внешняя валидация на другом корпусе не проводилась.
2. **Один автор.** Работа измеряет различимость речевых масок, а не
авторскую атрибуцию.
3. **Выбор признаков.** `char_max_features=10000`, `content`-LSA(k=300–400) и
lean-наборы выбирались по результатам на тестовых блоках предыдущих серий.
Вложенный выбор конфигурации снижает этот риск (0,275), но не устраняет
его полностью.
4. **Дисбаланс классов.** От 15 до 139 реплик; редкие классы имеют широкие
интервалы, а hub-классы забирают чужие предсказания.
5. **Короткие тексты.** Признаки словарного богатства и читаемости
нестабильны.
6. **Тематический конфликт.** `content`-LSA и символьные n-граммы могут
кодировать тему, сезоны, подарки, работу и имена. Маскирование содержания
подтверждает сильный тематический вклад.
7. **Нет идентификаторов сцен.** Разбиение по отдельным репликам не
учитывает корреляцию внутри сцен и диалогов.
8. **Статистическая мощность.** При 34 классах и 2005 объектах даже
доверительные интервалы по редким классам остаются широкими.
9. **Калибровка.** Простая коррекция априорных вероятностей не улучшила
macro-F1; более сложные методы (temperature scaling, оптимизация порогов)
не проверялись.
10. **Стековое объединение** показало худший результат, вероятно, из-за
переобучения мета-классификатора на малом числе объектов.
---
## 7. Выводы
1. **Гипотеза о различимости персонажей подтверждается частично.**
Консервативная оценка — 0,275 macro-F1; лучшая наблюдаемая — 0,304.
Случайный уровень — 0,029.
2. **Основной сигнал — лексико-тематический.** Символьные n-граммы и
`content`-LSA дают основной прирост; функциональные слова и POS-LSA сами
по себе слабы. Маскирование содержательных слов снижает качество более
чем вдвое.
3. **Наиболее устойчивая одиночная модель — ExtraTrees с
`char_max_features=10000`.** Она выбрана в 14 из 15 внешних блоков
вложенной процедуры и даёт 0,2757 ± 0,0219.
4. **Мягкое голосование трёх моделей — лучший наблюдаемый результат:**
0,3036 ± 0,0189 при перекрёстной проверке и 0,3333 на отложенной
выборке. Однако состав ансамбля выбирался по предыдущим данным; для
строгого вывода нужна внешняя валидация.
5. **`content`-LSA — сильный, но тематический сигнал.** Рост до k=400
устойчив, но интерпретировать его как «стиль» нельзя без тематического
контроля.
6. **Главная проблема — редкие классы и hub-коллапс.** Abigail получает
21–23% предсказаний при истинной доле 6,9%; Kent, Wizard, Penny и
Mister Qi почти не находятся. Простая коррекция приоров не помогает.
7. **Для строгого стилометрического вывода необходимы:**
внешний корпус, тематически сбалансированные подвыборки, вложенный выбор
признаков и более тонкая калибровка решающих правил.
---
## 8. Воспроизводимость
### 8.1. Основные документы
- `README.md` — установка и быстрый старт;
- `RUNBOOK.md` — пошаговая инструкция для полной серии;
- `EXPERIMENTS.md` — протокол экспериментов;
- `FEATURES.md` — описание признаков;
- `METRICS_AND_CLASSIFIERS.md` — описание метрик и классификаторов;
- `TUNING.md` — параметры и порядок настройки;
- `FOLLOWUP_EXPERIMENTS.md` — follow-up серия коллеги;
- `RESULTS_ANALYSIS.md` — анализ follow-up результатов;
- `FINAL_RESULTS.csv` — сводная таблица ключевых результатов;
- этот отчёт — `FINAL_REPORT.md`.
### 8.2. Команды
```bash
cd lab1
# базовая серия
bash scripts/run_all_experiments.sh
# follow-up серия
python scripts/run_followup_experiments.py # если доступен в ветке результатов
# или
bash scripts/run_followup_experiments.sh
# новые эксперименты
python scripts/run_final_experiments.py \
--stages nested topic calibration ensemble extra \
--out results/final
# финалисты и ансамбли
python scripts/run_finalists.py
```
### 8.3. Артефакты
- `lab1/exports/nlp_lab1_results_20261026_054117.tar.gz` — архив follow-up
серии;
- `results/final/` — новые эксперименты;
- `results/final/nested_3repeats/` — вложенный выбор конфигурации;
- `results/final/finalists/` — финалисты и ансамбли;
- `results/final/topic_control/` — контроль содержания;
- `results/final/calibration/` — коррекция приоров;
- `results/final/extra/` — дополнительные конфигурации и отложенная выборка.
---
## 9. Направления дальнейшей работы
1. **Внешняя валидация** на другом корпусе, другой игре или другом разбиении
по сценам и диалогам.
2. **Тематический контроль:** сбалансированные по темам подвыборки, удаление
тематических слов, adversarial-классификатор темы.
3. **Вложенный выбор признаков и ансамблей** внутри внешнего цикла, включая
`char_max_features`, `content`-LSA(k) и состав ансамбля.
4. **Калибровка редких классов:** temperature scaling, bias correction,
оптимизация порогов и правил декодирования по macro-F1.
5. **Ансамбли:** мягкое голосование с весами, stacking с регуляризацией,
отбор базовых моделей по внутренней проверке.
6. **Расширение корпуса:** больше реплик на редких персонажей, разметка сцен
и диалогов, возможно, другие игры или авторы для внешней проверки.
7. **Улучшение частеречной разметки:** сравнение теггеров, ручная проверка
ошибок на разговорных конструкциях.
8. **Содержательная интерпретация:** анализ тем, характерных для персонажей,
и отделение тематического сигнала от собственно стилистического.
---
## Приложение A. Сводная таблица результатов
См. `FINAL_RESULTS.csv`.
## Приложение B. Основные метрики
См. `METRICS_AND_CLASSIFIERS.md`.
## Приложение C. Признаки
См. `FEATURES.md` и `features/feature_dictionary.csv`.
## Приложение D. Протокол экспериментов
См. `EXPERIMENTS.md` и `FOLLOWUP_EXPERIMENTS.md`.

30
lab1/FINAL_RESULTS.csv Normal file
View File

@@ -0,0 +1,30 @@
stage,label,model,folds,repeats,macro_f1_mean,macro_f1_std,accuracy,top3,note
previous,ref_logreg,logreg,5,5,0.1785,0.0060,0.1679,0.3322,базовый объединённый набор
previous,ref_no_lsa,logreg,5,5,0.1815,0.0054,0.1708,0.3395,без LSA
previous,stylometry,logreg,5,5,0.1051,0.0038,0.1012,0.2338,только ручные признаки
previous,char_max10000_rf,random_forest,5,5,0.2583,0.0066,0.2391,0.4197,предыдущий максимум
previous,model_et_default,extra_trees,5,5,0.2551,0.0073,0.2387,0.4170,деревья по умолчанию
previous,groups_lean2_content300,logreg,5,5,0.2510,0.0040,0.2379,0.4111,lean + content-LSA
previous,lsa_content_k400,logreg,5,5,0.2114,0.0029,0.2059,0.3767,только content-LSA
previous,lsa_pos_k300,logreg,5,5,0.0667,0.0040,0.0663,0.1556,только POS-LSA
nested,nested_feature_selection,mixed,5,3,0.2746,0.0233,0.2603,NA,выбор конфигурации внутри outer-фолдов
finalists,et_char10000,extra_trees,5,5,0.2757,0.0219,0.2633,0.4554,char_max_features=10000
finalists,et_full,extra_trees,5,5,0.2499,0.0209,0.2374,0.4234,полный набор
finalists,et_lean_content300,extra_trees,5,5,0.2539,0.0237,0.2354,0.4339,lean + content-LSA 300
finalists,et_lean_content400,extra_trees,5,5,0.2511,0.0212,0.2279,0.4219,lean + content-LSA 400
finalists,logreg_lean_content300,logreg,5,5,0.2466,0.0191,0.2349,0.4030,lean + content-LSA 300
ensemble,voting_et_char_lean,soft_voting,5,3,0.3036,0.0189,0.2808,0.4823,ET full + ET char10000 + logreg lean content300
ensemble,voting_four,soft_voting,5,3,0.2983,0.0187,0.2823,0.4813,то же + RF char10000
ensemble,stacking,stacking,5,1,0.2259,0.0163,0.2264,0.3865,мета-классификатор logreg
topic,topic_original_et,extra_trees,5,3,0.2483,0.0201,0.2379,0.4208,исходный текст, стилевой набор
topic,topic_masked_et,extra_trees,5,3,0.1129,0.0162,0.1220,0.2683,содержательные слова заменены на CONTENT
topic,topic_original_logreg,logreg,5,3,0.1467,0.0213,0.1440,0.3017,исходный текст, стилевой набор
topic,topic_masked_logreg,logreg,5,3,0.1067,0.0125,0.1046,0.2362,содержательные слова заменены на CONTENT
extra,rf_char15000,random_forest,5,3,0.2570,0.0139,0.2468,NA,увеличение char-словаря не даёт прироста
extra,logreg_lean_content400,logreg,5,3,0.2532,0.0188,0.2389,NA,content-LSA 400
extra,logreg_lean_content500,logreg,5,3,0.2542,0.0180,0.2401,NA,content-LSA 500
extra,et_block_weights,extra_trees,5,3,0.2478,0.0208,0.2366,NA,веса блоков не улучшают
holdout,voting_holdout,soft_voting,1,1,0.3333,NA,0.2968,NA,стратифицированная отложенная выборка 80/20
holdout,et_full_holdout,extra_trees,1,1,0.2793,NA,0.2469,NA,стратифицированная отложенная выборка 80/20
holdout,rf_char10000_holdout,random_forest,1,1,0.2554,NA,0.2369,NA,стратифицированная отложенная выборка 80/20
holdout,logreg_lean_content300_holdout,logreg,1,1,0.2459,NA,0.2269,NA,стратифицированная отложенная выборка 80/20
Can't render this file because it has a wrong number of fields in line 19.

View File

@@ -167,6 +167,9 @@ python -m stylometry.cli run --config configs/default.toml \
- **`RESULTS_ANALYSIS.md`** — независимый анализ follow-up результатов:
ранжирование моделей, статистические сравнения, ошибки, ограничения и
выводы.
- **`FINAL_REPORT.md`** — итоговый отчёт по всей работе: методика, все серии
экспериментов, результаты, обсуждение и выводы.
- **`FINAL_RESULTS.csv`** — сводная таблица ключевых результатов.
- **`FOLLOWUP_EXPERIMENTS.md`** — протокол и результаты follow-up серии,
подготовленные коллегой.
- **`METRICS_AND_CLASSIFIERS.md`** — методическое описание метрик и

View File

@@ -2,6 +2,10 @@
Дата анализа: 26.10.2026.
> Дополнительные эксперименты, вложенный выбор конфигурации, контроль
> содержания, калибровка и ансамбли описаны в итоговом отчёте
> `FINAL_REPORT.md`; сводная таблица — в `FINAL_RESULTS.csv`.
Источник результатов:
- ветка `origin/opencode_test`, коммит `ac7c6f0`;

View File

@@ -464,6 +464,34 @@ python -m stylometry.cli compare \
---
## 6.16. Дополнительная серия для итогового отчёта
После основной и follow-up серий можно запустить дополнительные эксперименты:
вложенный выбор конфигурации, контроль содержания, калибровку, ансамбли и
дополнительные конфигурации.
```bash
python scripts/run_final_experiments.py \
--stages nested topic calibration ensemble extra \
--out results/final
python scripts/run_finalists.py
```
Что делают стадии:
- `nested` — выбирает конфигурацию признаков и модель внутри внешних блоков
перекрёстной проверки;
- `topic` — заменяет содержательные слова на нейтральный маркер и проверяет,
сохраняется ли качество;
- `calibration` — проверяет коррекцию априорных вероятностей классов;
- `ensemble` — мягкое голосование и стековое объединение;
- `extra` — дополнительные конфигурации и стратифицированная отложенная
выборка.
Результаты сохраняются в `results/final/`; сводка — `FINAL_RESULTS.csv`,
итоговый отчёт — `FINAL_REPORT.md`.
## 7. Если серия прервалась
Скрипт останавливается при первой ошибке. Уже созданные каталоги результатов

View File

@@ -0,0 +1,664 @@
"""Дополнительные эксперименты для итогового отчёта.
Стадии:
- nested: вложенный выбор конфигурации признаков и модели;
- topic: контроль содержания через маскирование содержательных слов;
- calibration: коррекция априорных вероятностей классов;
- ensemble: мягкое голосование и stacking;
- extra: дополнительные конфигурации и отложенная выборка.
Запуск:
python scripts/run_final_experiments.py --stages nested topic calibration ensemble extra
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import replace
from pathlib import Path
from typing import Any, Iterable, Sequence
import numpy as np
import pandas as pd
from sklearn.ensemble import VotingClassifier, StackingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, confusion_matrix, f1_score
from sklearn.model_selection import StratifiedKFold, train_test_split
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from stylometry.cv import run_repeated_cv
from stylometry.datasets import load_feature_frame
from stylometry.evaluation import (
bootstrap_ci,
classification_metrics,
confusion_matrix_frame,
per_class_report,
top_confusions,
)
from stylometry.pipelines import (
FeatureConfig,
build_pipeline,
preset_config,
)
DEFAULT_OUT = PROJECT_ROOT / "results" / "final"
def save_json(data: Any, path: Path) -> None:
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=str)
def save_cv(result, directory: Path, title: str, config: dict[str, Any]) -> None:
directory.mkdir(parents=True, exist_ok=True)
result.fold_metrics.to_csv(directory / "fold_metrics.csv", index=False, encoding="utf-8-sig")
result.oof.to_csv(directory / "oof_predictions.csv", index=False, encoding="utf-8-sig")
pooled, bootstrap = result.pooled_summary()
pooled.to_csv(directory / "pooled_metrics.csv", index=False, encoding="utf-8-sig")
save_json(bootstrap, directory / "bootstrap_ci.json")
first = result.oof[result.oof["repeat"] == result.oof["repeat"].min()]
per_class_report(first["y_true"], first["y_pred"], result.labels).to_csv(
directory / "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(directory / "confusion_matrix.csv", encoding="utf-8-sig")
top_confusions(first["y_true"], first["y_pred"], result.labels).to_csv(
directory / "top_confusions.csv", index=False, encoding="utf-8-sig"
)
summary = {
"title": title,
"config": config,
"fold_mean_macro_f1": float(result.fold_metrics["macro_f1"].mean()),
"fold_std_macro_f1": float(result.fold_metrics["macro_f1"].std(ddof=1)),
"n_splits": result.n_splits,
"n_repeats": result.n_repeats,
"bootstrap": bootstrap,
}
save_json(summary, directory / "summary.json")
(directory / "summary.md").write_text(
f"# {title}\n\n"
f"Macro-F1 по фолдам: {summary['fold_mean_macro_f1']:.4f} "
f"± {summary['fold_std_macro_f1']:.4f}\n\n"
f"Pooled:\n\n{pooled.to_string(index=False)}\n",
encoding="utf-8",
)
def lean_config(base: FeatureConfig, lsa_components: int = 300) -> FeatureConfig:
groups = tuple(
group
for group in base.groups
if group not in {"function_words", "pos", "vocabulary", "readability"}
)
return replace(
base,
groups=groups,
lsa_variants=("content",),
lsa_components=lsa_components,
use_char_ngrams=True,
use_pos_ngrams=True,
include_lsa=True,
)
def masked_frame(frame: pd.DataFrame) -> pd.DataFrame:
"""Заменяет содержательные слова на CONTENT, сохраняя служебные слова."""
content_tags = {"NOUN", "VERB", "ADJ", "ADV", "NUM"}
def _mask_row(row: pd.Series) -> str:
tokens = list(row["tokens"])
tags = list(row["upos"])
result = []
for token, tag in zip(tokens, tags):
if tag in content_tags and re.search(r"[A-Za-z]", str(token)):
result.append("CONTENT")
else:
result.append(str(token))
return " ".join(result) if result else "__EMPTY__"
result = frame.copy()
result["masked_text"] = result.apply(_mask_row, axis=1)
result["text"] = result["masked_text"]
return result
# ---------------------------------------------------------------------------
# Nested selection
# ---------------------------------------------------------------------------
def nested_selection(
frame: pd.DataFrame,
y: np.ndarray,
out_dir: Path,
folds: int = 5,
inner_folds: int = 3,
repeats: int = 1,
seed: int = 42,
) -> None:
base = preset_config("stylometry_pos_lsa")
candidates: list[tuple[str, FeatureConfig, str]] = [
("et_full", base, "extra_trees"),
("et_no_lsa", preset_config("stylometry_pos"), "extra_trees"),
("et_lean_content300", lean_config(base, 300), "extra_trees"),
("logreg_lean_content300", lean_config(base, 300), "logreg"),
("rf_char10000", replace(base, char_max_features=10000), "random_forest"),
("rf_char15000", replace(base, char_max_features=15000), "random_forest"),
("et_char10000", replace(base, char_max_features=10000), "extra_trees"),
("et_lean_content400", lean_config(base, 400), "extra_trees"),
]
labels = np.unique(y)
rows: list[dict[str, Any]] = []
oof_rows: list[dict[str, Any]] = []
selection_rows: list[dict[str, Any]] = []
for repeat in range(repeats):
outer = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed + repeat)
for fold, (train_idx, test_idx) in enumerate(outer.split(frame, y), start=1):
print(f"[nested] repeat={repeat} fold={fold}: inner selection", flush=True)
inner = StratifiedKFold(
n_splits=inner_folds,
shuffle=True,
random_state=seed + repeat * 100 + fold,
)
inner_scores: dict[str, list[float]] = {name: [] for name, _, _ in candidates}
for inner_train, inner_test in inner.split(frame.iloc[train_idx], y[train_idx]):
for name, config, model_name in candidates:
pipeline = build_pipeline(config, model_name)
pipeline.fit(
frame.iloc[train_idx].iloc[inner_train],
y[train_idx][inner_train],
)
prediction = pipeline.predict(
frame.iloc[train_idx].iloc[inner_test]
)
inner_scores[name].append(
f1_score(
y[train_idx][inner_test],
prediction,
labels=labels,
average="macro",
zero_division=0,
)
)
means = {
name: float(np.mean(values)) for name, values in inner_scores.items()
}
best_name = max(means, key=means.get)
best_config, best_model = next(
(config, model) for name, config, model in candidates if name == best_name
)
selection_rows.append(
{
"repeat": repeat,
"fold": fold,
"selected": best_name,
"inner_mean": means[best_name],
**{f"inner_{name}": value for name, value in means.items()},
}
)
pipeline = build_pipeline(best_config, best_model)
pipeline.fit(frame.iloc[train_idx], y[train_idx])
prediction = pipeline.predict(frame.iloc[test_idx])
metrics = classification_metrics(
y[test_idx], prediction, labels=labels
)
metrics.update({"repeat": repeat, "fold": fold, "selected": best_name})
rows.append(metrics)
for sample_index, predicted in zip(test_idx, prediction):
oof_rows.append(
{
"repeat": repeat,
"fold": fold,
"line_id": int(frame.iloc[sample_index]["line_id"]),
"y_true": y[sample_index],
"y_pred": predicted,
"selected": best_name,
}
)
fold_metrics = pd.DataFrame(rows)
oof = pd.DataFrame(oof_rows).sort_values(["repeat", "line_id"]).reset_index(drop=True)
out_dir.mkdir(parents=True, exist_ok=True)
fold_metrics.to_csv(out_dir / "fold_metrics.csv", index=False, encoding="utf-8-sig")
oof.to_csv(out_dir / "oof_predictions.csv", index=False, encoding="utf-8-sig")
pd.DataFrame(selection_rows).to_csv(
out_dir / "selection.csv", index=False, encoding="utf-8-sig"
)
first = oof[oof["repeat"] == oof["repeat"].min()]
per_class_report(first["y_true"], first["y_pred"], labels).to_csv(
out_dir / "per_class_report.csv", index=False, encoding="utf-8-sig"
)
confusion_matrix_frame(first["y_true"], first["y_pred"], labels).to_csv(
out_dir / "confusion_matrix.csv", encoding="utf-8-sig"
)
pooled = []
for repeat, group in oof.groupby("repeat"):
metrics = classification_metrics(group["y_true"], group["y_pred"], labels=labels)
metrics["repeat"] = repeat
pooled.append(metrics)
pooled_frame = pd.DataFrame(pooled)
pooled_frame.to_csv(out_dir / "pooled_metrics.csv", index=False, encoding="utf-8-sig")
save_json(
{
"fold_mean_macro_f1": float(fold_metrics["macro_f1"].mean()),
"fold_std_macro_f1": float(fold_metrics["macro_f1"].std(ddof=1)),
"pooled": pooled_frame.to_dict(orient="records"),
"selection_counts": pd.DataFrame(selection_rows)["selected"].value_counts().to_dict(),
},
out_dir / "summary.json",
)
print(
f"[nested] macro-F1={fold_metrics['macro_f1'].mean():.4f} "
f"± {fold_metrics['macro_f1'].std(ddof=1):.4f}"
)
# ---------------------------------------------------------------------------
# Topic control
# ---------------------------------------------------------------------------
def topic_control(
frame: pd.DataFrame,
y: np.ndarray,
out_dir: Path,
folds: int = 5,
repeats: int = 3,
seed: int = 42,
) -> None:
style_groups = (
"length",
"punctuation",
"function_words",
"pronouns",
"pos",
"syntax",
"dialogue",
)
config = FeatureConfig(
groups=style_groups,
use_char_ngrams=True,
char_ngram_range=(2, 5),
char_max_features=5000,
use_pos_ngrams=True,
pos_ngram_range=(1, 3),
pos_max_features=500,
lsa_variants=("fw", "pos"),
lsa_components=100,
stylo_normalize=True,
include_stylo=True,
include_lsa=True,
)
masked = masked_frame(frame)
for name, data in (("original", frame), ("masked", masked)):
for model in ("extra_trees", "logreg"):
print(f"[topic] {name} / {model}", flush=True)
pipeline = build_pipeline(config, model)
result = run_repeated_cv(
pipeline,
data,
y,
n_splits=folds,
n_repeats=repeats,
seed=seed,
model_name=model,
verbose=False,
)
save_cv(
result,
out_dir / f"{name}_{model}",
f"Topic control: {name} / {model}",
{"condition": name, "model": model, "groups": style_groups},
)
# ---------------------------------------------------------------------------
# Calibration
# ---------------------------------------------------------------------------
def _prior_adjust(scores: np.ndarray, priors: np.ndarray, alpha: float) -> np.ndarray:
adjusted = scores / np.power(np.maximum(priors, 1e-12), alpha)[None, :]
row_sums = adjusted.sum(axis=1, keepdims=True)
return adjusted / np.maximum(row_sums, 1e-12)
def calibration(
frame: pd.DataFrame,
y: np.ndarray,
out_dir: Path,
folds: int = 5,
repeats: int = 5,
seed: int = 42,
) -> None:
config = preset_config("stylometry_pos_lsa")
pipeline = build_pipeline(config, "extra_trees")
print("[calibration] base CV", flush=True)
result = run_repeated_cv(
pipeline,
frame,
y,
n_splits=folds,
n_repeats=repeats,
seed=seed,
model_name="extra_trees",
verbose=False,
)
save_cv(
result,
out_dir / "base_extra_trees",
"Calibration base: extra_trees",
{"model": "extra_trees", "preset": "stylometry_pos_lsa"},
)
labels = result.labels
label_to_index = {label: index for index, label in enumerate(labels)}
priors = np.bincount(
np.array([label_to_index[value] for value in y]), minlength=len(labels)
).astype(float)
priors /= priors.sum()
alphas = [0.0, 0.25, 0.5, 0.75, 1.0]
matrix: dict[int, np.ndarray] = {}
truths: dict[int, np.ndarray] = {}
for repeat, group in result.oof.groupby("repeat"):
scores = result.scores_by_repeat.get(int(repeat))
if scores is None or scores.shape[0] != len(group):
continue
matrix[int(repeat)] = scores
truths[int(repeat)] = group["y_true"].to_numpy()
rows = []
for repeat in sorted(matrix):
other = [value for value in matrix if value != repeat]
table: dict[float, float] = {}
for alpha in alphas:
values = []
for other_repeat in other:
prediction_indices = np.argmax(
_prior_adjust(matrix[other_repeat], priors, alpha), axis=1
)
values.append(
f1_score(
truths[other_repeat],
labels[prediction_indices],
labels=labels,
average="macro",
zero_division=0,
)
)
table[alpha] = float(np.mean(values))
best_alpha = max(table, key=table.get)
baseline_pred = labels[np.argmax(matrix[repeat], axis=1)]
corrected_pred = labels[
np.argmax(_prior_adjust(matrix[repeat], priors, best_alpha), axis=1)
]
baseline = f1_score(
truths[repeat], baseline_pred, labels=labels, average="macro", zero_division=0
)
corrected = f1_score(
truths[repeat], corrected_pred, labels=labels, average="macro", zero_division=0
)
rows.append(
{
"repeat": repeat,
"selected_alpha": best_alpha,
"baseline_macro_f1": baseline,
"corrected_macro_f1": corrected,
"delta": corrected - baseline,
**{f"alpha_{alpha}": table[alpha] for alpha in alphas},
}
)
table = pd.DataFrame(rows)
out_dir.mkdir(parents=True, exist_ok=True)
table.to_csv(out_dir / "calibration.csv", index=False, encoding="utf-8-sig")
save_json(
{
"mean_baseline": float(table["baseline_macro_f1"].mean()),
"mean_corrected": float(table["corrected_macro_f1"].mean()),
"mean_delta": float(table["delta"].mean()),
"selected_alpha": table["selected_alpha"].tolist(),
},
out_dir / "summary.json",
)
print(table.to_string(index=False))
# ---------------------------------------------------------------------------
# Ensemble
# ---------------------------------------------------------------------------
def _base_estimators() -> list[tuple[str, Any]]:
base = preset_config("stylometry_pos_lsa")
return [
("et_full", build_pipeline(base, "extra_trees")),
(
"rf_char10000",
build_pipeline(replace(base, char_max_features=10000), "random_forest"),
),
(
"logreg_lean_content300",
build_pipeline(lean_config(base, 300), "logreg"),
),
]
def ensemble(
frame: pd.DataFrame,
y: np.ndarray,
out_dir: Path,
folds: int = 5,
repeats: int = 3,
seed: int = 42,
with_stacking: bool = True,
) -> None:
estimators = _base_estimators()
voting = VotingClassifier(estimators=estimators, voting="soft", n_jobs=1)
print("[ensemble] soft voting", flush=True)
result = run_repeated_cv(
voting,
frame,
y,
n_splits=folds,
n_repeats=repeats,
seed=seed,
model_name="soft_voting",
verbose=False,
)
save_cv(
result,
out_dir / "soft_voting",
"Ensemble: soft voting",
{"estimators": [name for name, _ in estimators], "voting": "soft"},
)
if with_stacking:
print("[ensemble] stacking", flush=True)
stacking = StackingClassifier(
estimators=estimators,
final_estimator=LogisticRegression(
max_iter=3000, class_weight="balanced"
),
stack_method="predict_proba",
cv=3,
n_jobs=1,
passthrough=False,
)
stacked = run_repeated_cv(
stacking,
frame,
y,
n_splits=folds,
n_repeats=1,
seed=seed,
model_name="stacking",
verbose=False,
)
save_cv(
stacked,
out_dir / "stacking",
"Ensemble: stacking",
{"estimators": [name for name, _ in estimators], "meta": "logreg"},
)
# ---------------------------------------------------------------------------
# Extra configurations
# ---------------------------------------------------------------------------
def extra(
frame: pd.DataFrame,
y: np.ndarray,
out_dir: Path,
folds: int = 5,
repeats: int = 3,
seed: int = 42,
) -> None:
base = preset_config("stylometry_pos_lsa")
configs: list[tuple[str, FeatureConfig, str]] = [
("rf_char15000", replace(base, char_max_features=15000), "random_forest"),
("logreg_lean_content400", lean_config(base, 400), "logreg"),
("logreg_lean_content500", lean_config(base, 500), "logreg"),
(
"et_block_weights",
replace(
base,
block_weights={
"stylo": 1.0,
"char": 0.7,
"pos": 0.7,
"lsa_word": 0.3,
"lsa_pos": 0.3,
},
),
"extra_trees",
),
]
for name, config, model in configs:
print(f"[extra] {name}", flush=True)
pipeline = build_pipeline(config, model)
result = run_repeated_cv(
pipeline,
frame,
y,
n_splits=folds,
n_repeats=repeats,
seed=seed,
model_name=model,
verbose=False,
)
save_cv(result, out_dir / name, f"Extra: {name}", {"model": model})
# Отложенная выборка: одно стратифицированное разбиение 80/20.
train_idx, test_idx = train_test_split(
np.arange(len(frame)), test_size=0.2, stratify=y, random_state=seed
)
holdout_rows = []
for name, config, model in (
("et_full_holdout", base, "extra_trees"),
(
"rf_char10000_holdout",
replace(base, char_max_features=10000),
"random_forest",
),
("logreg_lean_content300_holdout", lean_config(base, 300), "logreg"),
):
print(f"[extra] holdout {name}", flush=True)
pipeline = build_pipeline(config, model)
pipeline.fit(frame.iloc[train_idx], y[train_idx])
prediction = pipeline.predict(frame.iloc[test_idx])
metrics = classification_metrics(y[test_idx], prediction, labels=np.unique(y))
metrics["name"] = name
metrics["train_n"] = len(train_idx)
metrics["test_n"] = len(test_idx)
holdout_rows.append(metrics)
pd.DataFrame(holdout_rows).to_csv(
out_dir / "holdout_metrics.csv", index=False, encoding="utf-8-sig"
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--stages",
nargs="+",
default=["nested", "topic", "calibration", "ensemble", "extra"],
choices=["nested", "topic", "calibration", "ensemble", "extra"],
)
parser.add_argument("--out", default=str(DEFAULT_OUT))
parser.add_argument("--folds", type=int, default=5)
parser.add_argument("--repeats", type=int, default=3)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--no-stacking", action="store_true")
args = parser.parse_args()
frame = load_feature_frame(verbose=True)
y = frame["character"].to_numpy()
output = Path(args.out)
output.mkdir(parents=True, exist_ok=True)
if "nested" in args.stages:
nested_selection(
frame,
y,
output / "nested_selection",
folds=args.folds,
inner_folds=3,
repeats=args.repeats,
seed=args.seed,
)
if "topic" in args.stages:
topic_control(
frame,
y,
output / "topic_control",
folds=args.folds,
repeats=args.repeats,
seed=args.seed,
)
if "calibration" in args.stages:
calibration(
frame,
y,
output / "calibration",
folds=args.folds,
repeats=5,
seed=args.seed,
)
if "ensemble" in args.stages:
ensemble(
frame,
y,
output / "ensemble",
folds=args.folds,
repeats=args.repeats,
seed=args.seed,
with_stacking=not args.no_stacking,
)
if "extra" in args.stages:
extra(
frame,
y,
output / "extra",
folds=args.folds,
repeats=args.repeats,
seed=args.seed,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,130 @@
"""Сравнение финалистов и вариантов мягкого голосования."""
from __future__ import annotations
import json
import sys
from dataclasses import replace
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.ensemble import VotingClassifier
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from stylometry.cv import run_repeated_cv
from stylometry.datasets import load_feature_frame
from stylometry.pipelines import FeatureConfig, build_pipeline, preset_config
OUT = PROJECT_ROOT / "results" / "final" / "finalists"
def lean(base: FeatureConfig, k: int) -> FeatureConfig:
groups = tuple(
group for group in base.groups
if group not in {"function_words", "pos", "vocabulary", "readability"}
)
return replace(base, groups=groups, lsa_variants=("content",), lsa_components=k)
def save(result, name: str, config: dict) -> None:
directory = OUT / name
directory.mkdir(parents=True, exist_ok=True)
result.fold_metrics.to_csv(directory / "fold_metrics.csv", index=False, encoding="utf-8-sig")
result.oof.to_csv(directory / "oof_predictions.csv", index=False, encoding="utf-8-sig")
pooled, bootstrap = result.pooled_summary()
pooled.to_csv(directory / "pooled_metrics.csv", index=False, encoding="utf-8-sig")
with (directory / "summary.json").open("w", encoding="utf-8") as stream:
json.dump(
{
"config": config,
"fold_mean_macro_f1": float(result.fold_metrics["macro_f1"].mean()),
"fold_std_macro_f1": float(result.fold_metrics["macro_f1"].std(ddof=1)),
"pooled": pooled.to_dict(orient="records"),
"bootstrap": bootstrap,
},
stream,
ensure_ascii=False,
indent=2,
default=str,
)
def main() -> int:
frame = load_feature_frame(verbose=True)
y = frame["character"].to_numpy()
base = preset_config("stylometry_pos_lsa")
configs = [
("et_full", base, "extra_trees"),
("et_char10000", replace(base, char_max_features=10000), "extra_trees"),
("et_lean_content300", lean(base, 300), "extra_trees"),
("et_lean_content400", lean(base, 400), "extra_trees"),
("logreg_lean_content300", lean(base, 300), "logreg"),
]
rows = []
for name, config, model in configs:
print(f"[finalists] {name}", flush=True)
result = run_repeated_cv(
build_pipeline(config, model), frame, y,
n_splits=5, n_repeats=5, seed=42, model_name=model, verbose=False,
)
save(result, name, {"name": name, "model": model})
pooled, bootstrap = result.pooled_summary()
first = pooled.iloc[0].to_dict()
rows.append({
"name": name,
"model": model,
"macro_f1": first.get("macro_f1"),
"accuracy": first.get("accuracy"),
"balanced_accuracy": first.get("balanced_accuracy"),
"top3_accuracy": first.get("top3_accuracy"),
"macro_f1_ci_lower": bootstrap["macro_f1"]["lower"],
"macro_f1_ci_upper": bootstrap["macro_f1"]["upper"],
})
voting_sets = {
"voting_et_char_lean": [
("et_full", build_pipeline(base, "extra_trees")),
("et_char10000", build_pipeline(replace(base, char_max_features=10000), "extra_trees")),
("logreg_lean_content300", build_pipeline(lean(base, 300), "logreg")),
],
"voting_four": [
("et_full", build_pipeline(base, "extra_trees")),
("rf_char10000", build_pipeline(replace(base, char_max_features=10000), "random_forest")),
("et_char10000", build_pipeline(replace(base, char_max_features=10000), "extra_trees")),
("logreg_lean_content300", build_pipeline(lean(base, 300), "logreg")),
],
}
for name, estimators in voting_sets.items():
print(f"[finalists] {name}", flush=True)
voting = VotingClassifier(estimators=estimators, voting="soft", n_jobs=1)
result = run_repeated_cv(
voting, frame, y, n_splits=5, n_repeats=3, seed=42,
model_name=name, verbose=False,
)
save(result, name, {"name": name, "estimators": [key for key, _ in estimators]})
pooled, bootstrap = result.pooled_summary()
first = pooled.iloc[0].to_dict()
rows.append({
"name": name,
"model": "soft_voting",
"macro_f1": first.get("macro_f1"),
"accuracy": first.get("accuracy"),
"balanced_accuracy": first.get("balanced_accuracy"),
"top3_accuracy": first.get("top3_accuracy"),
"macro_f1_ci_lower": bootstrap["macro_f1"]["lower"],
"macro_f1_ci_upper": bootstrap["macro_f1"]["upper"],
})
table = pd.DataFrame(rows).sort_values("macro_f1", ascending=False)
OUT.mkdir(parents=True, exist_ok=True)
table.to_csv(OUT / "finalists.csv", index=False, encoding="utf-8-sig")
print(table.to_string(index=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())