Добавлены финальные эксперименты и итоговый отчёт
This commit is contained in:
@@ -55,6 +55,10 @@ nlp/
|
||||
- [`lab1/TUNING.md`](lab1/TUNING.md) — настройка параметров и перебор.
|
||||
- [`lab1/RESULTS_ANALYSIS.md`](lab1/RESULTS_ANALYSIS.md) — анализ результатов
|
||||
follow-up серии и выводы.
|
||||
- [`lab1/FINAL_REPORT.md`](lab1/FINAL_REPORT.md) — итоговый отчёт по всей
|
||||
работе и экспериментам.
|
||||
- [`lab1/FINAL_RESULTS.csv`](lab1/FINAL_RESULTS.csv) — сводная таблица
|
||||
результатов.
|
||||
- [`lab1/FOLLOWUP_EXPERIMENTS.md`](lab1/FOLLOWUP_EXPERIMENTS.md) — протокол
|
||||
follow-up экспериментов.
|
||||
- [`lab1/PRESENTATION_OUTLINE.md`](lab1/PRESENTATION_OUTLINE.md) — структура
|
||||
|
||||
674
lab1/FINAL_REPORT.md
Normal file
674
lab1/FINAL_REPORT.md
Normal 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
30
lab1/FINAL_RESULTS.csv
Normal 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.
|
@@ -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`** — методическое описание метрик и
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Дата анализа: 26.10.2026.
|
||||
|
||||
> Дополнительные эксперименты, вложенный выбор конфигурации, контроль
|
||||
> содержания, калибровка и ансамбли описаны в итоговом отчёте
|
||||
> `FINAL_REPORT.md`; сводная таблица — в `FINAL_RESULTS.csv`.
|
||||
|
||||
Источник результатов:
|
||||
|
||||
- ветка `origin/opencode_test`, коммит `ac7c6f0`;
|
||||
|
||||
@@ -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. Если серия прервалась
|
||||
|
||||
Скрипт останавливается при первой ошибке. Уже созданные каталоги результатов
|
||||
|
||||
664
lab1/scripts/run_final_experiments.py
Normal file
664
lab1/scripts/run_final_experiments.py
Normal 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())
|
||||
130
lab1/scripts/run_finalists.py
Normal file
130
lab1/scripts/run_finalists.py
Normal 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())
|
||||
Reference in New Issue
Block a user