Compare commits
9 Commits
c3769b1d4a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b61750bf | |||
| 09fc05c327 | |||
| 60b198f78e | |||
| 800da29fe7 | |||
| db843d33c5 | |||
| d8b6f07f26 | |||
| 8830008d0c | |||
| 9b17e37a38 | |||
| 73c32d10c4 |
@@ -55,7 +55,13 @@ 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) — структура
|
||||
презентации.
|
||||
- [`lab1/presentation/README.md`](lab1/presentation/README.md) — готовая
|
||||
презентация, сборка и исходные изображения.
|
||||
|
||||
14
lab1/.gitignore
vendored
@@ -7,3 +7,17 @@ __pycache__/
|
||||
/features/
|
||||
/results/*
|
||||
!/results/README.md
|
||||
|
||||
/presentation/preview/
|
||||
|
||||
/presentation/latex/*.aux
|
||||
/presentation/latex/*.log
|
||||
/presentation/latex/*.nav
|
||||
/presentation/latex/*.out
|
||||
/presentation/latex/*.snm
|
||||
/presentation/latex/*.toc
|
||||
/presentation/latex/*.synctex.gz
|
||||
/presentation/latex/preview/
|
||||
|
||||
/report/build/
|
||||
/dist/stage/
|
||||
|
||||
529
lab1/ARCHIVE_README.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Архив лабораторной работы №1
|
||||
|
||||
**Тема:** классификация текстов на основе стилометрических характеристик.
|
||||
**Материал:** реплики персонажей игры Stardew Valley.
|
||||
**Задача:** по одной реплике определить, какому из 34 персонажей она
|
||||
принадлежит, и проверить, какие стилометрические признаки несут этот сигнал.
|
||||
|
||||
Это не авторская атрибуция: все реплики написаны одним автором. Мы различаем
|
||||
**речевые маски персонажей** — намеренно созданные автором различия в лексике,
|
||||
синтаксисе, пунктуации и разговорных маркерах.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что в архиве
|
||||
|
||||
| Путь | Что это |
|
||||
|---|---|
|
||||
| `lab1/report/REPORT.pdf` | Итоговый отчёт, 21 страница. Главный документ. |
|
||||
| `lab1/report/REPORT.docx` | Редактируемая версия отчёта. |
|
||||
| `lab1/report/REPORT.md` | Исходник отчёта в Markdown. |
|
||||
| `lab1/report/figures/` | Рисунки отчёта. |
|
||||
| `lab1/presentation/stardew_stylometry.pdf` | Презентация для защиты. |
|
||||
| `lab1/presentation/stardew_stylometry.pptx` | Редактируемая презентация. |
|
||||
| `lab1/presentation/latex/` | LaTeX-исходник и PDF презентации. |
|
||||
| `lab1/presentation/images/`, `generated/` | Исходные изображения и диаграммы. |
|
||||
| `lab1/stylometry/` | Основной код проекта. |
|
||||
| `lab1/scripts/` | Проверка окружения, запуск серий, сборка отчёта, упаковка. |
|
||||
| `lab1/tests/` | 33 теста (`pytest`). |
|
||||
| `lab1/configs/default.toml` | Конфигурация по умолчанию. |
|
||||
| `lab1/data/dialogues.csv` | Корпус: 2005 реплик, cp1251, разделитель `;`. |
|
||||
| `lab1/data/dialogues.xlsx` | Тот же корпус в Excel. |
|
||||
| `lab1/data/stats_per_character.xlsx` | Статистика по персонажам. |
|
||||
| `lab1/data/metadata/` | Метаданные персонажей для анализа ошибок. |
|
||||
| `lab1/data/corpus_report_*.txt` | Отчёты о состоянии корпуса до/после очистки. |
|
||||
| `lab1/FINAL_RESULTS.csv` | Сводная таблица финальных результатов. |
|
||||
| `lab1/FINAL_REPORT.md` | Расширенная версия отчёта с деталями экспериментов. |
|
||||
| `lab1/EXPERIMENTS.md` | Протокол экспериментов и команд. |
|
||||
| `lab1/FEATURES.md` | Признаки, формулы, обоснование. |
|
||||
| `lab1/METRICS_AND_CLASSIFIERS.md` | Метрики и классификаторы. |
|
||||
| `lab1/TUNING.md` | Настройка гиперпараметров. |
|
||||
| `lab1/RUNBOOK.md` | Подробная инструкция по запуску полной серии. |
|
||||
| `lab1/RESULTS_ANALYSIS.md` | Анализ результатов и ошибок. |
|
||||
| `lab1/FOLLOWUP_EXPERIMENTS.md` | Дополнительные эксперименты. |
|
||||
| `lab1/PRESENTATION_OUTLINE.md` | Структура презентации. |
|
||||
| `lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz` | Финальные результаты в компактном виде. |
|
||||
| `lab1/requirements.txt` | Основные зависимости. |
|
||||
| `lab1/requirements-report.txt` | Зависимости для пересборки отчёта. |
|
||||
|
||||
Полный список файлов с размерами и SHA-256 — в `MANIFEST.txt`.
|
||||
Контрольные суммы ключевых файлов — в `CHECKSUMS.sha256`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Требования
|
||||
|
||||
### 2.1. Обязательные
|
||||
|
||||
- Python **3.11+** (проверено на 3.14);
|
||||
- Linux, macOS или Windows с WSL;
|
||||
- около 1 ГБ свободного места с виртуальным окружением и NLTK;
|
||||
- интернет — только для установки пакетов и скачивания NLTK.
|
||||
|
||||
### 2.2. Для пересборки отчёта
|
||||
|
||||
- `pandoc` 3.x;
|
||||
- LibreOffice (`libreoffice --headless`);
|
||||
- Python-пакеты `python-docx`, `PyMuPDF` (см. `requirements-report.txt`);
|
||||
- шрифты с кириллицей: Times New Roman или Liberation Serif, Liberation Mono.
|
||||
|
||||
### 2.3. Для пересборки LaTeX-презентации
|
||||
|
||||
- XeLaTeX;
|
||||
- пакеты Beamer, tcolorbox, TikZ, fontspec, babel-russian;
|
||||
- шрифт Liberation Sans.
|
||||
|
||||
---
|
||||
|
||||
## 3. Установка
|
||||
|
||||
Все команды выполняются из каталога `lab1`:
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
```
|
||||
|
||||
### 3.1. Виртуальное окружение
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
```
|
||||
|
||||
Если окружение уже есть в корне проекта, можно использовать его:
|
||||
|
||||
```bash
|
||||
source ../.venv/bin/activate
|
||||
```
|
||||
|
||||
### 3.2. Зависимости
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Для пересборки отчёта дополнительно:
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements-report.txt
|
||||
```
|
||||
|
||||
### 3.3. Ресурсы NLTK
|
||||
|
||||
```bash
|
||||
python scripts/download_nltk_data.py
|
||||
```
|
||||
|
||||
Скрипт скачивает `punkt`, `punkt_tab`, `averaged_perceptron_tagger`,
|
||||
`averaged_perceptron_tagger_eng`, `stopwords` в `lab1/nltk_data`.
|
||||
|
||||
Без этих ресурсов POS-признаки отключатся, а результаты будут неполными.
|
||||
Если машина без интернета, скопируйте готовый каталог `nltk_data` в `lab1/`.
|
||||
|
||||
### 3.4. Проверка
|
||||
|
||||
```bash
|
||||
python scripts/check_environment.py
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
Окружение готово.
|
||||
33 passed
|
||||
```
|
||||
|
||||
`check_environment.py` печатает версии библиотек, SHA-256 корпуса, число
|
||||
реплик, персонажей и словоупотреблений, проверяет токенизатор и POS-теггер.
|
||||
|
||||
---
|
||||
|
||||
## 4. Быстрый запуск
|
||||
|
||||
### 4.1. Аудит корпуса
|
||||
|
||||
```bash
|
||||
python -m stylometry.cli audit
|
||||
```
|
||||
|
||||
Создаёт:
|
||||
|
||||
- `data/processed/audit.md` — отчёт по корпусу;
|
||||
- `data/processed/lines.csv` — нормализованные реплики;
|
||||
- `data/processed/figures/class_distribution.png`.
|
||||
|
||||
Проверьте: 2005 реплик, 34 персонажа, 38 207 словоупотреблений, точных
|
||||
дублей нет.
|
||||
|
||||
### 4.2. Ручные признаки
|
||||
|
||||
```bash
|
||||
python -m stylometry.cli features
|
||||
```
|
||||
|
||||
Создаёт:
|
||||
|
||||
- `features/stylometric.csv` — матрица 2005 × 454;
|
||||
- `features/feature_dictionary.csv` — описание признаков.
|
||||
|
||||
### 4.3. Одна модель
|
||||
|
||||
```bash
|
||||
python -m stylometry.cli run \
|
||||
--preset stylometry_pos_lsa \
|
||||
--model logreg \
|
||||
--folds 5 \
|
||||
--repeats 5
|
||||
```
|
||||
|
||||
Результаты появятся в `results/<preset>_<model>_<timestamp>/`:
|
||||
`summary.md`, `summary.json`, `fold_metrics.csv`, `pooled_metrics.csv`,
|
||||
`per_class_report.csv`, `confusion_matrix.csv`, `oof_predictions.csv`.
|
||||
|
||||
### 4.4. Полная серия
|
||||
|
||||
```bash
|
||||
bash scripts/run_all_experiments.sh
|
||||
```
|
||||
|
||||
Скрипт последовательно запускает все стадии и пишет лог в
|
||||
`results/logs/run_all_<timestamp>.log`. Ориентировочное время — 1,5–3 часа
|
||||
на обычном ноутбуке. Быстрый режим без финальных чисел:
|
||||
|
||||
```bash
|
||||
QUICK=1 bash scripts/run_all_experiments.sh
|
||||
```
|
||||
|
||||
### 4.5. Финальные серии
|
||||
|
||||
```bash
|
||||
python scripts/run_final_experiments.py \
|
||||
--stages nested topic calibration ensemble extra \
|
||||
--out results/final
|
||||
```
|
||||
|
||||
Эта команда воспроизводит вложенный выбор конфигурации, контроль содержания,
|
||||
коррекцию приоров, ансамбли и дополнительные конфигурации. Именно эти
|
||||
результаты сведены в `FINAL_RESULTS.csv` и в отчёте.
|
||||
|
||||
---
|
||||
|
||||
## 5. Что делает каждый модуль
|
||||
|
||||
| Модуль | Назначение |
|
||||
|---|---|
|
||||
| `stylometry/io.py` | Чтение CSV/XLSX, проверка колонок, SHA-256 корпуса. |
|
||||
| `stylometry/cleaning.py` | Unicode-нормализация, плейсхолдеры, пробелы. |
|
||||
| `stylometry/tokenization.py` | NLTK: токенизация, предложения, POS; резервный режим. |
|
||||
| `stylometry/features/` | 454 ручных признака в 11 группах. |
|
||||
| `stylometry/lsa.py` | TF-IDF + TruncatedSVD для word/content/fw/char/pos. |
|
||||
| `stylometry/pipelines.py` | Объединение блоков признаков, модели, сетки параметров. |
|
||||
| `stylometry/cv.py` | Повторная стратифицированная и вложенная кросс-валидация. |
|
||||
| `stylometry/evaluation.py` | Macro-F1, accuracy, balanced accuracy, MCC, каппа, top-k, log loss, Brier, бутстрэп. |
|
||||
| `stylometry/stats.py` | Мак-Немар, Уилкоксон, Фридман, поправка Холма. |
|
||||
| `stylometry/delta.py` | Дельта Барроуза: MFW, z-нормирование, манхэттенское и косинусное расстояние. |
|
||||
| `stylometry/verification.py` | Верификация «свой/чужой»: ROC-AUC, EER, TAR@FAR. |
|
||||
| `stylometry/clustering.py` | Иерархическая и k-means кластеризация, ARI, NMI, силуэт. |
|
||||
| `stylometry/plotting.py` | Диаграммы классов, scree, матрицы ошибок, PCA, t-SNE. |
|
||||
| `stylometry/metadata.py` | Метаданные персонажей, анализ подгрупп. |
|
||||
| `stylometry/cli.py` | Единая точка входа для всех команд. |
|
||||
| `main.py` | Короткая обёртка над `stylometry.cli`. |
|
||||
|
||||
### Группы ручных признаков
|
||||
|
||||
1. Длина.
|
||||
2. Лексика.
|
||||
3. Словарное богатство.
|
||||
4. Символы.
|
||||
5. Пунктуация.
|
||||
6. Функциональные слова.
|
||||
7. Местоимения.
|
||||
8. Части речи.
|
||||
9. Синтаксис.
|
||||
10. Читаемость.
|
||||
11. Разговорные маркеры.
|
||||
|
||||
Формулы и обоснование — в `FEATURES.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Данные и разметка
|
||||
|
||||
### 6.1. Формат
|
||||
|
||||
`data/dialogues.csv`:
|
||||
|
||||
- кодировка cp1251;
|
||||
- разделитель `;`;
|
||||
- колонки `Реплика` и `Персонаж`;
|
||||
- 2005 строк, 34 класса.
|
||||
|
||||
`data/dialogues.xlsx` — тот же корпус для Excel.
|
||||
`data/stats_per_character.xlsx` — число реплик и длины по персонажам.
|
||||
`data/metadata/` — пол, возраст, вид и роль для анализа ошибок; в признаки
|
||||
классификатора эти поля не подаются.
|
||||
|
||||
### 6.2. Правила разметки
|
||||
|
||||
1. Одна реплика — один объект.
|
||||
2. Метка — имя говорящего.
|
||||
3. Один объект относится ровно к одному классу.
|
||||
4. Соответствие реплики и метки проверено вручную.
|
||||
5. Служебные метки `<PLAYER>`, `<FARM>`, `<PET>`, `<SPOUSE>`, `<TIME>`,
|
||||
`<NOUN>`, `<NAME>` заменены нейтральными словами; их число сохранено
|
||||
отдельным признаком.
|
||||
|
||||
### 6.3. Очистка и шум
|
||||
|
||||
Нормализуется только Unicode: варианты кавычек, тире, многоточий и пробелов.
|
||||
Пунктуация, регистр и стоп-слова **сохраняются** — это стилометрический
|
||||
сигнал.
|
||||
|
||||
Шумом считались точные дубликаты, технические артефакты, общие короткие
|
||||
формулы и плейсхолдеры. Корпус прошёл ручную фильтрацию; точные правила
|
||||
фильтрации не сохранились, поэтому исходное распределение восстановить
|
||||
нельзя. Это ограничение зафиксировано в отчёте.
|
||||
|
||||
### 6.4. Ограничение по объёму
|
||||
|
||||
Формальные требования: не менее 90 текстов по 30 предложений и примерно
|
||||
равные классы. В этом корпусе единица анализа — реплика (в среднем 19 слов),
|
||||
классы неравномерны (15–139 реплик). По согласованию с преподавателем
|
||||
эксперимент выполнен на уровне реплик; в отчёте раздел 2.5 честно описывает
|
||||
это отклонение. Для компенсации используются веса классов и macro-F1.
|
||||
|
||||
---
|
||||
|
||||
## 7. Метрики и результаты
|
||||
|
||||
Основная метрика — **macro-F1**: средняя F-мера по 34 классам, каждый
|
||||
персонаж весит одинаково. Дополнительно считаются accuracy, balanced
|
||||
accuracy, top-3/top-5, MCC, каппа Коэна, log loss, Brier, матрица ошибок,
|
||||
бутстрэп-интервалы.
|
||||
|
||||
| Конфигурация | Macro-F1 |
|
||||
|---|---:|
|
||||
| Мягкое голосование трёх моделей | **0,3036 ± 0,0189** |
|
||||
| ExtraTrees, `char_max_features=10000` | 0,2757 ± 0,0219 |
|
||||
| Вложенный выбор конфигурации | 0,2746 ± 0,0233 |
|
||||
| ExtraTrees, полный набор | 0,2499 ± 0,0209 |
|
||||
| Логистическая регрессия, lean + content-LSA | 0,2466 ± 0,0191 |
|
||||
| Случайный лес, `char_max_features=10000` | 0,2583 ± 0,0066 |
|
||||
| Только ручные признаки | 0,1051 ± 0,0038 |
|
||||
| Случайный уровень | 0,029 |
|
||||
|
||||
На отложенной выборке 80/20 мягкое голосование дало 0,3333.
|
||||
|
||||
Контроль содержания: после замены существительных, глаголов, прилагательных,
|
||||
наречий и числительных маркером `CONTENT` macro-F1 падает с 0,2483 до 0,1129.
|
||||
Остаток всё ещё выше случайного уровня, но основной сигнал —
|
||||
лексико-тематический.
|
||||
|
||||
---
|
||||
|
||||
## 8. Как пересобрать отчёт
|
||||
|
||||
Отчёт собирается из `report/REPORT.md`:
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
python scripts/build_report.py
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
|
||||
1. вызывает `pandoc` и делает `report/build/REPORT_raw.docx`;
|
||||
2. оформляет стили, поля, колонтитулы, таблицы и рисунки;
|
||||
3. сохраняет `report/REPORT.docx`;
|
||||
4. конвертирует DOCX в `report/REPORT.pdf` через LibreOffice;
|
||||
5. проверяет, что в PDF есть все основные разделы.
|
||||
|
||||
Только DOCX:
|
||||
|
||||
```bash
|
||||
python scripts/build_report.py --skip-pdf
|
||||
```
|
||||
|
||||
Требования: `pandoc`, `libreoffice`, `python-docx`, `PyMuPDF`.
|
||||
Рисунки лежат в `report/figures/`; Markdown ссылается на них относительно
|
||||
`report/`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Как пересобрать презентацию
|
||||
|
||||
### 9.1. PPTX
|
||||
|
||||
В архиве лежит **финальная** версия `stardew_stylometry.pptx` (30 слайдов)
|
||||
после ручных правок. Скрипт `build_deck.py` — генератор исходной версии
|
||||
презентации; он создаёт 32 слайда и **перезапишет** финальный файл, если его
|
||||
запустить. Не запускайте скрипт, если хотите сохранить ручные правки: для
|
||||
изменений открывайте PPTX в PowerPoint или LibreOffice.
|
||||
|
||||
Если нужно пересобрать исходную версию с нуля:
|
||||
|
||||
```bash
|
||||
cd lab1/presentation
|
||||
python build_deck.py
|
||||
```
|
||||
|
||||
Скрипт использует `python-pptx`, `Pillow` и `matplotlib`. Диаграммы уже лежат
|
||||
в `presentation/generated/`; для их перегенерации нужны результаты серий
|
||||
(`results/final/...`), которых нет в архиве — сначала запустите эксперименты
|
||||
из раздела 4.
|
||||
|
||||
### 9.2. LaTeX/PDF
|
||||
|
||||
```bash
|
||||
cd lab1/presentation/latex
|
||||
xelatex stardew_stylometry.tex
|
||||
xelatex stardew_stylometry.tex
|
||||
```
|
||||
|
||||
Нужны XeLaTeX, Beamer, tcolorbox, TikZ и шрифт Liberation Sans. Готовый PDF —
|
||||
`stardew_stylometry.pdf`. Изображения подтягиваются из `../images/` и
|
||||
`../generated/`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Где смотреть результаты
|
||||
|
||||
- `lab1/report/REPORT.pdf` — итоговый отчёт со всеми числами и выводами.
|
||||
- `lab1/FINAL_RESULTS.csv` — сводная таблица всех финальных конфигураций.
|
||||
- `lab1/FINAL_REPORT.md` — расширенное описание экспериментов.
|
||||
- `lab1/RESULTS_ANALYSIS.md` — анализ ошибок, классов-аттракторов и длины.
|
||||
- `lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz` — исходные
|
||||
`summary.json`, `fold_metrics.csv`, `confusion_matrix.csv` и предсказания
|
||||
финальных серий.
|
||||
- `lab1/presentation/generated/` — диаграммы для отчёта и презентации.
|
||||
|
||||
---
|
||||
|
||||
## 11. Частые проблемы
|
||||
|
||||
### `check_environment.py` не находит POS-теггер
|
||||
|
||||
Повторите:
|
||||
|
||||
```bash
|
||||
python scripts/download_nltk_data.py
|
||||
```
|
||||
|
||||
Проверьте, что каталог `lab1/nltk_data` существует и не пуст. Если сеть
|
||||
недоступна, скопируйте `nltk_data` с другой машины.
|
||||
|
||||
### `pytest` не устанавливается
|
||||
|
||||
Убедитесь, что активировано виртуальное окружение и версия Python не ниже
|
||||
3.11:
|
||||
|
||||
```bash
|
||||
python --version
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### `python -m stylometry.cli` не находит модуль
|
||||
|
||||
Команда должна выполняться из каталога `lab1`, а не из корня репозитория:
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
python -m stylometry.cli audit
|
||||
```
|
||||
|
||||
### LibreOffice не собирает PDF
|
||||
|
||||
Проверьте:
|
||||
|
||||
```bash
|
||||
libreoffice --version
|
||||
```
|
||||
|
||||
На сервере без графической сессии используйте именно `--headless`
|
||||
(это уже делает `build_report.py`). Если LibreOffice нет, отчёт можно
|
||||
открыть в Word/LibreOffice вручную и экспортировать в PDF.
|
||||
|
||||
### Кириллица в PDF выглядит квадратами
|
||||
|
||||
Установите шрифты Liberation Serif/Sans и Liberation Mono. В отчёте
|
||||
используются Times New Roman (с запасным Liberation Serif) и Liberation Mono.
|
||||
|
||||
### Полная серия идёт слишком долго
|
||||
|
||||
Запускайте в `tmux` или `nohup`, см. `RUNBOOK.md`, раздел 5.4. Для проверки
|
||||
работоспособности используйте `QUICK=1`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Что исключено из архива
|
||||
|
||||
Чтобы архив оставался компактным и воспроизводимым, в него не включены:
|
||||
|
||||
- `.venv/` — виртуальное окружение;
|
||||
- `.git/` — история репозитория;
|
||||
- `nltk_data/` — скачивается скриптом;
|
||||
- `data/processed/` — аудит и промежуточные таблицы;
|
||||
- `features/` — матрица признаков (создаётся `features`);
|
||||
- `results/` — промежуточные результаты серий;
|
||||
- большие `exports/*.tar.gz` — кроме компактного финального архива;
|
||||
- `presentation/preview/` — PNG-превью слайдов;
|
||||
- `__pycache__/`, `.pytest_cache/` — кэши;
|
||||
- `presentation/stardew_stylometry.bak` — резервная копия;
|
||||
- служебные файлы LaTeX (`.aux`, `.log`, `.nav`, `.out`, `.snm`, `.toc`).
|
||||
|
||||
Всё это восстанавливается командами из разделов 3–4.
|
||||
|
||||
---
|
||||
|
||||
## 13. Проверка целостности архива
|
||||
|
||||
```bash
|
||||
sha256sum -c CHECKSUMS.sha256
|
||||
```
|
||||
|
||||
`MANIFEST.txt` содержит список всех файлов архива с размерами и SHA-256.
|
||||
Если контрольные суммы совпали, архив распакован полностью.
|
||||
|
||||
Минимальная проверка после распаковки:
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
python scripts/check_environment.py
|
||||
python -m pytest -q
|
||||
python -m stylometry.cli audit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Атрибуция
|
||||
|
||||
Диалоги Stardew Valley принадлежат ConcernedApe. Корпус используется
|
||||
исключительно в учебных и исследовательских целях. Код проекта можно
|
||||
использовать и изменять для учебных задач.
|
||||
|
||||
---
|
||||
|
||||
## 15. Шпаргалка команд
|
||||
|
||||
```bash
|
||||
# из lab1
|
||||
python scripts/check_environment.py # проверить окружение
|
||||
python -m pytest -q # 33 теста
|
||||
python -m stylometry.cli audit # аудит корпуса
|
||||
python -m stylometry.cli features # 454 ручных признака
|
||||
python -m stylometry.cli run \
|
||||
--preset stylometry_pos_lsa --model logreg --repeats 5
|
||||
bash scripts/run_all_experiments.sh # полная серия
|
||||
python scripts/run_final_experiments.py \
|
||||
--stages nested topic calibration ensemble extra --out results/final
|
||||
python scripts/build_report.py # отчёт DOCX + PDF
|
||||
```
|
||||
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
@@ -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
|
||||
|
@@ -136,6 +136,51 @@ python -m stylometry.cli subgroup-report \
|
||||
| `lsa_word`, `lsa_pos` | только соответствующий вариант LSA |
|
||||
| `length` | только признаки длины (нижняя граница) |
|
||||
|
||||
## Отчёт
|
||||
|
||||
Итоговый отчёт:
|
||||
|
||||
- **`report/REPORT.pdf`** — основной документ, 21 страница;
|
||||
- **`report/REPORT.docx`** — редактируемая версия;
|
||||
- **`report/REPORT.md`** — исходник Markdown;
|
||||
- **`report/figures/`** — рисунки.
|
||||
|
||||
Пересборка:
|
||||
|
||||
```bash
|
||||
python scripts/build_report.py
|
||||
```
|
||||
|
||||
Для сборки нужны `pandoc`, LibreOffice и пакеты из
|
||||
`requirements-report.txt`. Скрипт сам оформляет титул, колонтитулы, таблицы,
|
||||
нумерацию рисунков и проверяет, что в PDF есть все разделы.
|
||||
|
||||
## Презентация
|
||||
|
||||
Есть две версии:
|
||||
|
||||
- **`presentation/stardew_stylometry.pptx`** — редактируемая PPTX-версия
|
||||
(30 слайдов после ручных правок);
|
||||
- **`presentation/stardew_stylometry.pdf`** — PDF этой версии;
|
||||
- **`presentation/latex/stardew_stylometry.tex`** — академическая
|
||||
Beamer-версия;
|
||||
- **`presentation/latex/stardew_stylometry.pdf`** — собранный PDF LaTeX-версии
|
||||
(30 страниц).
|
||||
|
||||
LaTeX-версия содержит три части на трёх докладчиков, фон и курицу на каждом
|
||||
слайде, титульную заставку, изображение персонажей, две круговые диаграммы,
|
||||
матрицы ошибок, F-меры по персонажам, приложение с полной матрицей и финальный
|
||||
мем. Формул почти нет: одна компактная запись обозначений на слайде постановки
|
||||
задачи.
|
||||
|
||||
Сборка LaTeX:
|
||||
|
||||
```bash
|
||||
cd presentation/latex
|
||||
xelatex stardew_stylometry.tex
|
||||
xelatex stardew_stylometry.tex
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Файл `configs/default.toml` задаёт параметры по умолчанию:
|
||||
@@ -154,6 +199,10 @@ python -m stylometry.cli run --config configs/default.toml \
|
||||
|
||||
## Основные документы
|
||||
|
||||
- **`START_HERE.md`** — краткая инструкция: что открыть и как проверить
|
||||
окружение за три минуты.
|
||||
- **`ARCHIVE_README.md`** — подробное описание архива: состав, установка,
|
||||
запуск, структура модулей, пересборка отчёта и презентации.
|
||||
- **`RUNBOOK.md`** — пошаговая инструкция для запуска полной серии
|
||||
экспериментов: установка, проверка окружения, все команды, упаковка
|
||||
результатов и типичные ошибки.
|
||||
@@ -167,6 +216,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. Если серия прервалась
|
||||
|
||||
Скрипт останавливается при первой ошибке. Уже созданные каталоги результатов
|
||||
|
||||
81
lab1/START_HERE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# С чего начать
|
||||
|
||||
Это архив лабораторной работы №1 по курсу «Автоматический анализ текстов»:
|
||||
стилометрическая классификация реплик персонажей Stardew Valley.
|
||||
|
||||
## Что внутри
|
||||
|
||||
- `lab1/report/REPORT.pdf` — **главный отчёт** (21 страница).
|
||||
- `lab1/report/REPORT.docx` — редактируемая версия отчёта.
|
||||
- `lab1/report/REPORT.md` — исходник отчёта.
|
||||
- `lab1/presentation/stardew_stylometry.pdf` — презентация для защиты.
|
||||
- `lab1/presentation/stardew_stylometry.pptx` — редактируемая презентация.
|
||||
- `lab1/presentation/latex/` — LaTeX-исходник презентации.
|
||||
- `lab1/stylometry/` — код: чтение корпуса, признаки, LSA, модели,
|
||||
кросс-валидация, метрики, верификация.
|
||||
- `lab1/data/` — корпус и метаданные.
|
||||
- `lab1/tests/` — 33 теста.
|
||||
- `lab1/FINAL_RESULTS.csv` — сводная таблица результатов.
|
||||
- `lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz` — компактный
|
||||
архив финальных результатов (без промежуточных матриц).
|
||||
|
||||
## Проверка за три минуты
|
||||
|
||||
Все команды выполняются из каталога `lab1`:
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements.txt
|
||||
|
||||
python scripts/download_nltk_data.py
|
||||
python scripts/check_environment.py
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Ожидаемый результат:
|
||||
|
||||
- `check_environment.py` заканчивается строкой `Окружение готово.`;
|
||||
- `pytest` печатает `33 passed`;
|
||||
- в корпусе 2005 реплик и 34 персонажа.
|
||||
|
||||
Если всё совпало, отчёт и презентацию можно открывать сразу. Для повторного
|
||||
запуска экспериментов смотрите `ARCHIVE_README.md`.
|
||||
|
||||
## Главный результат
|
||||
|
||||
| Что | Значение |
|
||||
|---|---:|
|
||||
| Корпус | 2005 реплик, 34 персонажа |
|
||||
| Основная метрика | macro-F1 |
|
||||
| Мягкое голосование трёх моделей | **0,3036 ± 0,0189** |
|
||||
| Консервативная оценка (вложенный выбор) | **0,2746 ± 0,0233** |
|
||||
| Случайный уровень | **0,029** |
|
||||
|
||||
Основной сигнал — лексико-тематический. После замены содержательных слов
|
||||
нейтральным маркером macro-F1 падает с 0,248 до 0,113. Поэтому в отчёте
|
||||
результат сформулирован как различимость **речевых масок по
|
||||
лексико-тематическому профилю**, а не как доказательство «чистого» стиля.
|
||||
|
||||
## Что читать дальше
|
||||
|
||||
1. `ARCHIVE_README.md` — подробная инструкция: установка, запуск, структура
|
||||
проекта, что делает каждый модуль, как пересобрать отчёт и презентацию.
|
||||
2. `lab1/RUNBOOK.md` — пошаговый запуск полной серии экспериментов.
|
||||
3. `lab1/README.md` — краткое описание проекта и команд.
|
||||
4. `lab1/FINAL_REPORT.md` — расширенная версия отчёта с деталями.
|
||||
5. `lab1/EXPERIMENTS.md` — протокол экспериментов.
|
||||
6. `lab1/FEATURES.md` — формулы и обоснование признаков.
|
||||
7. `lab1/METRICS_AND_CLASSIFIERS.md` — метрики и классификаторы.
|
||||
|
||||
## Важно
|
||||
|
||||
- Ресурсы NLTK (`nltk_data`, около 80 МБ) в архив не включены: они
|
||||
скачиваются командой `python scripts/download_nltk_data.py`.
|
||||
- Виртуальное окружение, промежуточные матрицы признаков и сырые результаты
|
||||
серий в архив не включены: они восстанавливаются скриптами.
|
||||
- Диалоги Stardew Valley принадлежат ConcernedApe; корпус используется
|
||||
исключительно в учебных и исследовательских целях.
|
||||
BIN
lab1/dist/nlp_lab1_submission_20261101.zip
vendored
Normal file
1
lab1/dist/nlp_lab1_submission_20261101.zip.sha256
vendored
Normal file
@@ -0,0 +1 @@
|
||||
635ed9d56653259be30733fe8b80316e8ce1f3b32ac178170f5deef0f3e39d79 nlp_lab1_submission_20261101.zip
|
||||
BIN
lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz
Normal file
@@ -0,0 +1 @@
|
||||
fd4aecdf5f7ecfe74b55324f0b4700e25adaa9f6bdf035edbd3017e58152838f exports/nlp_lab1_final_results_20261026_120000.tar.gz
|
||||
@@ -0,0 +1,8 @@
|
||||
Дата: 26.10.2026
|
||||
Git commit: 73c32d10c4ef7689704d8114aa22fd6f4a2e870f
|
||||
Архив: exports/nlp_lab1_final_results_20261026_120000.tar.gz
|
||||
Содержимое: results/final
|
||||
Серии: nested (вложенный выбор конфигурации), topic (контроль содержания),
|
||||
calibration (коррекция приоров), ensemble (мягкое голосование и stacking),
|
||||
extra (дополнительные конфигурации и отложенная выборка), finalists.
|
||||
Ключевые результаты: см. FINAL_RESULTS.csv и FINAL_REPORT.md.
|
||||
65
lab1/presentation/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Презентация
|
||||
|
||||
Готовая презентация: `stardew_stylometry.pptx` (16:9, 30 слайдов в финальной версии; исходная автоматическая сборка — 32 слайда).
|
||||
PDF-версия для просмотра: `stardew_stylometry.pdf`.
|
||||
|
||||
## Структура
|
||||
|
||||
- **Часть 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка,
|
||||
распределение реплик и длин, ограничения.
|
||||
- **Часть 2. Методология** — признаки, LSA, логистическая регрессия, SVM,
|
||||
деревья, ансамбли, перекрёстная проверка и метрики.
|
||||
- **Часть 3. Результаты** — сравнение моделей, вклад признаков, контроль
|
||||
содержания, матрицы ошибок, классы-аттракторы, выводы.
|
||||
|
||||
На каждом слайде есть фон `images/background.jpg` и маленькая курица
|
||||
`images/chicken.png` как пасхалка. На титульном слайде — `images/title.jpg`;
|
||||
на слайде о персонажах — `images/characters.webp`; на последнем слайде — мем
|
||||
`images/meme.webp`. Круговые диаграммы — `images/replics-count.png` и
|
||||
`images/median.png`.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
python -m pip install -r presentation/requirements.txt
|
||||
python presentation/make_slide_figures.py
|
||||
python presentation/build_deck.py
|
||||
```
|
||||
|
||||
`make_slide_figures.py` строит матрицы ошибок и сравнительные диаграммы из
|
||||
`results/final/finalists/voting_et_char_lean/oof_predictions.csv` и
|
||||
`FINAL_RESULTS.csv`. `build_deck.py` собирает PPTX, добавляет заметки
|
||||
докладчика, фон, курицу и изображения.
|
||||
|
||||
## Редактирование
|
||||
|
||||
Текст и порядок слайдов задаются в `build_deck.py`. После изменения текста
|
||||
достаточно повторно запустить сборку. Готовый PPTX можно править и напрямую
|
||||
в PowerPoint/LibreOffice; заметки докладчика находятся в заметках слайдов.
|
||||
|
||||
Изображения игры используются в учебных целях; права принадлежат их
|
||||
правообладателям.
|
||||
|
||||
## Текущая версия и автоматическая сборка
|
||||
|
||||
Актуальная версия — `stardew_stylometry.pptx`: она правится вручную и содержит
|
||||
имена докладчиков и сокращённый набор слайдов. Скрипт `build_deck.py` — это
|
||||
первоначальная автоматическая сборка; повторный запуск перезапишет файл и
|
||||
потеряет ручные правки. Для точечной переработки первого блока использован
|
||||
`rework_block1.py`; перед повторным применением скрипта сделайте резервную
|
||||
копию PPTX.
|
||||
|
||||
## LaTeX-версия
|
||||
|
||||
Академическая Beamer-презентация:
|
||||
|
||||
- `latex/stardew_stylometry.tex` — исходник;
|
||||
- `latex/stardew_stylometry.pdf` — собранный PDF, 30 страниц;
|
||||
- сборка: `cd latex && xelatex stardew_stylometry.tex` (два прохода).
|
||||
|
||||
В LaTeX-версии три части на трёх докладчиков, фон и курица на каждом слайде,
|
||||
титульная заставка, изображение персонажей, две круговые диаграммы, матрицы
|
||||
ошибок, F-меры по персонажам, приложение с полной матрицей и финальный мем.
|
||||
Формул почти нет: одна компактная запись обозначений на слайде постановки
|
||||
задачи.
|
||||
809
lab1/presentation/build_deck.py
Normal file
@@ -0,0 +1,809 @@
|
||||
"""Сборка презентации по лабораторной работе.
|
||||
|
||||
Требования:
|
||||
- 16:9, фон на каждом слайде;
|
||||
- три содержательных блока на трёх рассказчиков;
|
||||
- курица на каждом слайде;
|
||||
- титульная картинка, персонажи, две круговые диаграммы, матрицы ошибок,
|
||||
мем на последнем слайде;
|
||||
- тёмный текст на светлых панелях.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMAGES = ROOT / "presentation" / "images"
|
||||
GEN = ROOT / "presentation" / "generated"
|
||||
OUT = ROOT / "presentation" / "stardew_stylometry.pptx"
|
||||
|
||||
SW, SH = 13.333, 7.5
|
||||
FONT = "Arial"
|
||||
|
||||
CREAM = RGBColor(248, 241, 220)
|
||||
CREAM_LIGHT = RGBColor(255, 252, 240)
|
||||
DARK = RGBColor(30, 45, 33)
|
||||
GREEN = RGBColor(49, 93, 58)
|
||||
GREEN_DARK = RGBColor(28, 63, 47)
|
||||
GOLD = RGBColor(204, 153, 51)
|
||||
BROWN = RGBColor(126, 80, 42)
|
||||
RED = RGBColor(166, 55, 43)
|
||||
WHITE = RGBColor(255, 255, 255)
|
||||
BORDER = RGBColor(181, 158, 111)
|
||||
|
||||
|
||||
def add_background(slide) -> None:
|
||||
slide.shapes.add_picture(
|
||||
str(IMAGES / "background.jpg"), 0, 0, width=Inches(SW), height=Inches(SH)
|
||||
)
|
||||
|
||||
|
||||
def add_panel(slide, x, y, w, h, fill=CREAM, line=BORDER, radius: float = 0.06):
|
||||
shape = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)
|
||||
)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = fill
|
||||
shape.line.color.rgb = line
|
||||
shape.line.width = Pt(1.1)
|
||||
shape.shadow.inherit = False
|
||||
try:
|
||||
shape.adjustments[0] = radius
|
||||
except Exception:
|
||||
pass
|
||||
return shape
|
||||
|
||||
|
||||
def add_text(
|
||||
slide,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
text: str,
|
||||
size: int = 18,
|
||||
color=DARK,
|
||||
bold: bool = False,
|
||||
align=PP_ALIGN.LEFT,
|
||||
anchor=MSO_ANCHOR.TOP,
|
||||
margin: float = 0.08,
|
||||
):
|
||||
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
frame = box.text_frame
|
||||
frame.word_wrap = True
|
||||
frame.vertical_anchor = anchor
|
||||
frame.margin_left = Inches(margin)
|
||||
frame.margin_right = Inches(margin)
|
||||
frame.margin_top = Inches(0.04)
|
||||
frame.margin_bottom = Inches(0.04)
|
||||
paragraph = frame.paragraphs[0]
|
||||
paragraph.alignment = align
|
||||
run = paragraph.add_run()
|
||||
run.text = text
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color
|
||||
return box
|
||||
|
||||
|
||||
def add_bullets(slide, x, y, w, h, items, size: int = 16, color=DARK, title: str | None = None):
|
||||
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
frame = box.text_frame
|
||||
frame.word_wrap = True
|
||||
frame.margin_left = Inches(0.08)
|
||||
frame.margin_right = Inches(0.08)
|
||||
frame.margin_top = Inches(0.04)
|
||||
first = True
|
||||
if title:
|
||||
paragraph = frame.paragraphs[0]
|
||||
paragraph.space_after = Pt(7)
|
||||
run = paragraph.add_run()
|
||||
run.text = title
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(size + 3)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = GREEN_DARK
|
||||
first = False
|
||||
for item in items:
|
||||
if isinstance(item, tuple):
|
||||
text, level = item
|
||||
else:
|
||||
text, level = item, 0
|
||||
paragraph = frame.paragraphs[0] if first else frame.add_paragraph()
|
||||
first = False
|
||||
prefix = "• " if level == 0 else "– "
|
||||
paragraph.text = prefix + text
|
||||
paragraph.level = level
|
||||
paragraph.space_after = Pt(5)
|
||||
paragraph.line_spacing = 1.0
|
||||
for run in paragraph.runs:
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(size if level == 0 else size - 1)
|
||||
run.font.color.rgb = color
|
||||
return box
|
||||
|
||||
|
||||
def add_table(slide, x, y, w, h, rows, col_widths=None, font_size: int = 11, header: bool = True):
|
||||
shape = slide.shapes.add_table(len(rows), len(rows[0]), Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
table = shape.table
|
||||
if col_widths:
|
||||
total = sum(col_widths)
|
||||
for index, value in enumerate(col_widths):
|
||||
table.columns[index].width = Inches(w * value / total)
|
||||
for row_index, row in enumerate(rows):
|
||||
for col_index, value in enumerate(row):
|
||||
cell = table.cell(row_index, col_index)
|
||||
cell.margin_left = Inches(0.05)
|
||||
cell.margin_right = Inches(0.05)
|
||||
cell.margin_top = Inches(0.02)
|
||||
cell.margin_bottom = Inches(0.02)
|
||||
cell.fill.solid()
|
||||
if header and row_index == 0:
|
||||
cell.fill.fore_color.rgb = GREEN
|
||||
else:
|
||||
cell.fill.fore_color.rgb = CREAM_LIGHT if row_index % 2 else CREAM
|
||||
paragraph = cell.text_frame.paragraphs[0]
|
||||
paragraph.alignment = PP_ALIGN.LEFT if col_index == 0 else PP_ALIGN.CENTER
|
||||
run = paragraph.add_run()
|
||||
run.text = str(value)
|
||||
run.font.name = FONT
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = header and row_index == 0
|
||||
run.font.color.rgb = WHITE if header and row_index == 0 else DARK
|
||||
return table
|
||||
|
||||
|
||||
def add_image_fit(slide, path: Path, x, y, max_w, max_h, panel: bool = False):
|
||||
with Image.open(path) as image:
|
||||
width, height = image.size
|
||||
aspect = width / height
|
||||
if max_w / max_h > aspect:
|
||||
draw_h = max_h
|
||||
draw_w = draw_h * aspect
|
||||
else:
|
||||
draw_w = max_w
|
||||
draw_h = draw_w / aspect
|
||||
draw_x = x + (max_w - draw_w) / 2
|
||||
draw_y = y + (max_h - draw_h) / 2
|
||||
if panel:
|
||||
add_panel(slide, draw_x - 0.07, draw_y - 0.07, draw_w + 0.14, draw_h + 0.14, fill=CREAM_LIGHT)
|
||||
slide.shapes.add_picture(str(path), Inches(draw_x), Inches(draw_y), width=Inches(draw_w), height=Inches(draw_h))
|
||||
|
||||
|
||||
def add_chicken(slide, x: float = 12.96, y: float = 7.12, size: float = 0.24):
|
||||
slide.shapes.add_picture(
|
||||
str(IMAGES / "chicken.png"), Inches(x), Inches(y), width=Inches(size), height=Inches(size)
|
||||
)
|
||||
|
||||
|
||||
def add_notes(slide, text: str) -> None:
|
||||
slide.notes_slide.notes_text_frame.text = text
|
||||
|
||||
|
||||
def new_slide():
|
||||
slide = Presentation().slides.add_slide # placeholder, replaced below
|
||||
return slide
|
||||
|
||||
|
||||
def add_title_bar(slide, title: str, block: str = "", subtitle: str | None = None):
|
||||
add_panel(slide, 0.42, 0.28, 12.49, 0.86, fill=CREAM)
|
||||
add_text(slide, 0.58, 0.34, 10.2, 0.45, title, size=25, bold=True, color=GREEN_DARK)
|
||||
if block:
|
||||
add_text(slide, 10.0, 0.42, 2.65, 0.35, block, size=13, color=BROWN, align=PP_ALIGN.RIGHT)
|
||||
if subtitle:
|
||||
add_text(slide, 0.58, 0.76, 11.8, 0.3, subtitle, size=12, color=BROWN)
|
||||
|
||||
|
||||
def add_footer(slide, block: str, number: int):
|
||||
add_text(slide, 0.45, 7.12, 7.8, 0.24, block, size=10, color=DARK)
|
||||
add_text(slide, 12.15, 7.12, 0.55, 0.24, str(number), size=10, color=DARK, align=PP_ALIGN.RIGHT)
|
||||
|
||||
|
||||
def content_slide(prs, title: str, block: str, number: int, subtitle: str | None = None):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_background(slide)
|
||||
add_title_bar(slide, title, block, subtitle)
|
||||
add_footer(slide, block, number)
|
||||
add_chicken(slide)
|
||||
return slide
|
||||
|
||||
|
||||
def section_slide(prs, number: int, block: str, title: str, subtitle: str, questions: list[str], notes: str, slide_number: int):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_background(slide)
|
||||
add_panel(slide, 0.65, 0.9, 12.0, 5.6, fill=CREAM)
|
||||
add_text(slide, 0.95, 1.15, 2.6, 1.0, block, size=22, bold=True, color=GOLD)
|
||||
add_text(slide, 0.95, 2.05, 11.2, 0.9, title, size=38, bold=True, color=GREEN_DARK)
|
||||
add_text(slide, 0.95, 3.05, 11.0, 0.6, subtitle, size=20, color=BROWN)
|
||||
add_bullets(slide, 0.95, 3.9, 11.0, 1.9, questions, size=17, color=DARK)
|
||||
add_text(slide, 0.95, 5.9, 7.0, 0.35, f"Рассказчик {number}", size=15, bold=True, color=RED)
|
||||
add_footer(slide, block, slide_number)
|
||||
add_chicken(slide)
|
||||
add_notes(slide, notes)
|
||||
return slide
|
||||
|
||||
|
||||
def main() -> int:
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(SW)
|
||||
prs.slide_height = Inches(SH)
|
||||
slide_no = 0
|
||||
|
||||
# 1. Титульный слайд
|
||||
slide_no += 1
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_background(slide)
|
||||
add_panel(slide, 0.55, 1.15, 6.15, 4.25, fill=CREAM)
|
||||
add_image_fit(slide, IMAGES / "title.jpg", 0.7, 1.3, 5.85, 3.9, panel=False)
|
||||
add_panel(slide, 6.95, 1.15, 5.85, 5.45, fill=CREAM)
|
||||
add_text(slide, 7.2, 1.45, 5.3, 0.35, "Лабораторная работа по анализу текстов", size=13, color=BROWN)
|
||||
add_text(slide, 7.2, 1.9, 5.3, 1.55, "Можно ли различить голоса персонажей?", size=32, bold=True, color=GREEN_DARK)
|
||||
add_text(slide, 7.2, 3.45, 5.25, 1.4, "Стилометрическая классификация 34 персонажей Stardew Valley по 2005 репликам", size=18, color=DARK)
|
||||
add_text(slide, 7.2, 4.9, 5.3, 0.6, "Три блока · три рассказчика", size=16, bold=True, color=RED)
|
||||
add_text(slide, 7.2, 5.28, 5.3, 0.45, "Корпус → методология → результаты", size=15, color=BROWN)
|
||||
add_panel(slide, 0.55, 5.75, 12.25, 0.8, fill=CREAM_LIGHT)
|
||||
add_text(slide, 0.75, 5.9, 11.9, 0.45, "2005 реплик · 34 персонажа · один автор · 5-блочная перекрёстная проверка", size=14, color=DARK, align=PP_ALIGN.CENTER)
|
||||
add_chicken(slide, 12.95, 7.12, 0.26)
|
||||
add_notes(slide, "Приветствие. Тема — стилометрия реплик Stardew Valley. Сразу уточнить: автор один, задача — различить речевые маски персонажей, а не установить авторство. Три блока: корпус, методология, результаты.")
|
||||
|
||||
# 2. Структура
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Структура доклада", "Введение", slide_no)
|
||||
blocks = [
|
||||
("01", "Корпус", "Рассказчик 1", ["2005 реплик", "34 персонажа", "сбор, очистка, разметка", "ограничения данных"]),
|
||||
("02", "Методология", "Рассказчик 2", ["признаки и LSA", "классификаторы", "перекрёстная проверка", "метрики"]),
|
||||
("03", "Результаты", "Рассказчик 3", ["лучшие модели", "матрицы ошибок", "кого путает модель", "выводы"]),
|
||||
]
|
||||
for index, (num, title, narrator, lines) in enumerate(blocks):
|
||||
x = 0.45 + index * 4.27
|
||||
add_panel(slide, x, 1.5, 4.0, 5.15, fill=CREAM)
|
||||
add_text(slide, x + 0.25, 1.7, 0.9, 0.5, num, size=30, bold=True, color=GOLD)
|
||||
add_text(slide, x + 0.25, 2.3, 3.5, 0.5, title, size=24, bold=True, color=GREEN_DARK)
|
||||
add_text(slide, x + 0.25, 2.9, 3.5, 0.35, narrator, size=13, color=RED)
|
||||
add_bullets(slide, x + 0.25, 3.45, 3.5, 2.7, lines, size=14, color=DARK)
|
||||
add_notes(slide, "Показать три блока и передать слово первому рассказчику. Подчеркнуть: сначала данные, затем методы, затем интерпретация.")
|
||||
|
||||
# 3. Блок 1
|
||||
slide_no += 1
|
||||
section_slide(
|
||||
prs, 1, "Блок 1", "Корпус: как собрали и разметили",
|
||||
"Что именно мы классифицируем и почему это сложная задача",
|
||||
["Откуда данные и какова единица анализа", "Кто в корпусе и как распределены реплики", "Длина реплик и очистка", "Паспорт корпуса и ограничения"],
|
||||
"Передать слово рассказчику 1. Блок отвечает на вопрос: что представляет собой выборка и можно ли ей доверять.",
|
||||
slide_no,
|
||||
)
|
||||
|
||||
# 4. Что анализируем
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Что анализируем", "Блок 1 · Корпус", slide_no)
|
||||
add_panel(slide, 0.45, 1.4, 5.95, 5.4, fill=CREAM)
|
||||
add_bullets(slide, 0.7, 1.6, 5.45, 4.9, [
|
||||
"Единица анализа — одна реплика персонажа.",
|
||||
"Всего 2005 реплик; 34 персонажа; один автор.",
|
||||
"Класс — имя персонажа; один объект относится ровно к одному классу.",
|
||||
"Реплики короткие: в среднем 19 слов, медиана 17.",
|
||||
"Корпус англоязычный; разметка — только текст и персонаж.",
|
||||
], size=17, title="Постановка задачи")
|
||||
add_panel(slide, 6.65, 1.4, 6.2, 5.4, fill=CREAM_LIGHT)
|
||||
add_text(slide, 6.85, 1.55, 5.8, 0.35, "Примеры разных голосов", size=16, bold=True, color=GREEN_DARK)
|
||||
quotes = [
|
||||
("Abigail (139 реплик)", "Oh, that's right... I heard someone new was moving onto that old farm."),
|
||||
("Jas (34 реплики)", "Wow, a birthday present! I love it!"),
|
||||
("Krobus (28 реплик)", "I wanted to give you this as a 'thank you' for letting me live here with you."),
|
||||
("Wizard (24 реплики)", "Ah, yes. I have predicted your arrival a long time ago, young PLAYER."),
|
||||
]
|
||||
y = 1.98
|
||||
for name, quote in quotes:
|
||||
add_text(slide, 6.85, y, 5.8, 0.3, name, size=13, bold=True, color=BROWN)
|
||||
add_text(slide, 6.85, y + 0.28, 5.75, 0.55, f"«{quote}»", size=13, color=DARK)
|
||||
y += 1.08
|
||||
add_notes(slide, "Объяснить: одна реплика — один объект. Показать четыре голоса: длинная речь Abigail, короткая детская реплика Jas, нечеловеческая вежливость Krobus, архаичный Wizard. Это иллюстрирует, что сигнал может быть в лексике, длине, пунктуации и синтаксисе.")
|
||||
|
||||
# 5. Кто в корпусе
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Кто в корпусе", "Блок 1 · Корпус", slide_no)
|
||||
add_panel(slide, 0.4, 1.35, 8.8, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "characters.png", 0.5, 1.42, 8.6, 5.4)
|
||||
add_panel(slide, 9.3, 1.35, 3.6, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.48, 1.55, 3.25, 5.1, [
|
||||
"34 персонажа: подростки, взрослые, пожилые, дети и нелюди.",
|
||||
"Все реплики написаны одним разработчиком; многие — в одних и тех же обновлениях и сюжетных контекстах.",
|
||||
"Классы сильно неравномерны: от 15 до 139 реплик.",
|
||||
"Метаданные о поле, возрасте и виде используются только для анализа ошибок, не как признаки.",
|
||||
], size=14, title="Состав")
|
||||
add_notes(slide, "Показать портреты. Подчеркнуть: один автор и общие сюжетные контексты — поэтому сходство голосов ожидаемо. Метаданные не подаются классификатору; они нужны только для интерпретации ошибок.")
|
||||
|
||||
# 6. Количество реплик
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Сколько реплик у каждого", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: число реплик по персонажам")
|
||||
add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, IMAGES / "replics-count.png", 0.5, 1.42, 7.0, 5.4)
|
||||
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [
|
||||
"Максимум — Abigail: 139 реплик; минимум — Mister Qi: 15.",
|
||||
"Разница между классами почти десятикратная.",
|
||||
"При 34 классах случайное угадывание даёт около 2,9%.",
|
||||
"Поэтому доля правильных ответов обманчива: модель может выбирать частые классы.",
|
||||
"Основная метрика — макро-усреднённая F-мера: каждый персонаж весит одинаково.",
|
||||
], size=16, title="Дисбаланс классов")
|
||||
add_notes(slide, "Диаграмма показывает дисбаланс. Сразу объяснить, почему accuracy не подходит: модель, всегда выбирающая Abigail, получит около 7% правильных ответов, но почти нулевую macro-F1 по остальным 33 классам.")
|
||||
|
||||
# 7. Длина реплик
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Длина реплик", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: медианная длина предложения в символах")
|
||||
add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, IMAGES / "median.png", 0.5, 1.42, 7.0, 5.4)
|
||||
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [
|
||||
"Средняя длина — 19,1 слова; медиана — 17 слов.",
|
||||
"Диапазон — от 3 до 78 слов.",
|
||||
"Медианная длина предложения в символах: от 52 у Vincent до 114 у Sandy.",
|
||||
"Короткие реплики дают мало свидетельств; стилометрические оценки на них нестабильны.",
|
||||
"Длина сама по себе — сильный, но нежелательный признак: она может стать прокси персонажа.",
|
||||
], size=15, title="Короткие тексты")
|
||||
add_notes(slide, "Числа у подписей — медианная длина предложения в символах. Показать контраст Vincent/Jas и Sandy/Wizard. Сказать, что длина — законный признак, но её вклад нужно проверять отдельно, иначе модель будет различать не стиль, а «кто говорит длиннее».")
|
||||
|
||||
# 8. Очистка и разметка
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Очистка и разметка", "Блок 1 · Корпус", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [
|
||||
"Источник — игровые реплики; CSV в cp1251, XLSX как резерв.",
|
||||
"Нормализация Unicode: многоточия, кавычки, тире, неразрывные пробелы.",
|
||||
"Плейсхолдеры <PLAYER>, <FARM> и другие заменены нейтральными словами; их количество сохранено отдельным признаком.",
|
||||
"Токенизация и разбиение на предложения — NLTK; частеречная разметка — Penn Treebank → Universal POS.",
|
||||
"Стоп-слова, пунктуация и регистр не удалялись: это стилометрический сигнал.",
|
||||
], size=15, title="Что сделали")
|
||||
add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [
|
||||
"Шум: дубликаты, технические артефакты, общие короткие фразы.",
|
||||
"Ручная фильтрация уже удалила короткие и общие реплики; правила не сохранились.",
|
||||
"В текущем CSV 0 точных дублей; 13 пар с косинусной близостью ≥ 0,8 — все внутри одного персонажа.",
|
||||
"Нет идентификаторов сцен и диалогов; это ограничение анализа.",
|
||||
"Разметка — только имя персонажа; никакой ручной стилистической разметки нет.",
|
||||
], size=15, title="Что считаем шумом")
|
||||
add_notes(slide, "Объяснить различие между шумом и стилем. Удалять пунктуацию или стоп-слова нельзя: это носители стиля. Ручная фильтрация — ограничение: она могла удалить как раз короткие характерные реплики. Отметить, что почти-дубликаты не пересекают классы, поэтому утечки через них нет.")
|
||||
|
||||
# 9. Паспорт корпуса
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Паспорт корпуса", "Блок 1 · Корпус", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 7.1, 5.55, fill=CREAM_LIGHT)
|
||||
rows = [
|
||||
["Показатель", "Значение"],
|
||||
["Реплик", "2005"],
|
||||
["Персонажей", "34"],
|
||||
["Словоупотреблений", "38 207"],
|
||||
["Средняя длина", "19,1 слова"],
|
||||
["Медиана длины", "17 слов"],
|
||||
["Минимум / максимум", "3 / 78 слов"],
|
||||
["Классы", "15–139 реплик"],
|
||||
["Точные дубли", "0"],
|
||||
["Почти-дубли (cos ≥ 0,8)", "13 пар, все внутри класса"],
|
||||
]
|
||||
add_table(slide, 0.7, 1.6, 6.6, 4.9, rows, col_widths=[3.4, 3.2], font_size=13)
|
||||
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 8.02, 1.6, 4.7, 5.0, [
|
||||
"Корпус пригоден для классификации, но не для сильных выводов о «чистом» стиле.",
|
||||
"Ограничения: короткие тексты, дисбаланс, один автор, отсутствие сцен.",
|
||||
"Все дальнейшие результаты нужно сравнивать с случайным уровнем 2,9%, а не с нулём.",
|
||||
], size=16, title="Что важно")
|
||||
add_notes(slide, "Этот слайд — «паспорт» данных. Подчеркнуть: 2005 объектов и 34 класса — небольшая выборка; 15 реплик у Mister Qi дают очень широкий интервал. Поэтому дальше используем повторную перекрёстную проверку и доверительные интервалы.")
|
||||
|
||||
# 10. Блок 2
|
||||
slide_no += 1
|
||||
section_slide(
|
||||
prs, 2, "Блок 2", "Методология: как измеряем стиль",
|
||||
"Признаки, модели и проверка — на понятном языке",
|
||||
["Какие свойства текста считаем", "Как работает LSA", "Как устроены классификаторы", "Как не обмануть себя при оценке"],
|
||||
"Передать слово рассказчику 2. Блок объясняет, что именно подаётся модели и почему выбранным методам можно доверять.",
|
||||
slide_no,
|
||||
)
|
||||
|
||||
# 11. Общая схема
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Общая схема", "Блок 2 · Методология", slide_no)
|
||||
steps = [
|
||||
("Реплика", "текст персонажа"),
|
||||
("Очистка", "Unicode, плейсхолдеры, пробелы"),
|
||||
("Токены и POS", "NLTK, Universal POS"),
|
||||
("Признаки", "ручные, n-граммы, LSA"),
|
||||
("Модель", "вероятности по 34 классам"),
|
||||
("Проверка", "5 блоков, повторы, метрики"),
|
||||
]
|
||||
x = 0.55
|
||||
for index, (title, subtitle) in enumerate(steps):
|
||||
add_panel(slide, x, 2.0, 1.85, 2.35, fill=CREAM)
|
||||
add_text(slide, x + 0.12, 2.18, 1.6, 0.45, title, size=15, bold=True, color=GREEN_DARK, align=PP_ALIGN.CENTER)
|
||||
add_text(slide, x + 0.1, 2.7, 1.65, 1.1, subtitle, size=12, color=DARK, align=PP_ALIGN.CENTER)
|
||||
if index < len(steps) - 1:
|
||||
add_text(slide, x + 1.82, 2.7, 0.35, 0.4, "→", size=26, bold=True, color=RED, align=PP_ALIGN.CENTER)
|
||||
x += 2.12
|
||||
add_panel(slide, 0.55, 4.75, 12.15, 1.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 0.75, 4.9, 11.75, 1.2, [
|
||||
"Обучение и преобразования выполняются только внутри обучающих блоков; тест не участвует в настройке.",
|
||||
"Вектор признаков — это не «понимание» текста, а числовое описание его статистических свойств.",
|
||||
], size=15)
|
||||
add_notes(slide, "Провести по схеме слева направо. Главная мысль: модель не «читает» реплику как человек, а считает частоты и комбинации признаков. Отдельно подчеркнуть, что все преобразования обучаются внутри блоков, иначе возникает утечка.")
|
||||
|
||||
# 12. Признаки
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Признаки: что именно считаем", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.1, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.7, 5.1, [
|
||||
"454 ручных признака в 11 группах.",
|
||||
"Длина: слова, символы, предложения, длина слова.",
|
||||
"Лексика и орфография: распределение длин, апострофы, заглавные, цифры.",
|
||||
"Словарное богатство: TTR, Guiraud, Yule's K, hapax; на коротких текстах нестабильно.",
|
||||
"Пунктуация: запятые, многоточия, восклицания, вопросы, повторы.",
|
||||
"Разговорные маркеры: междометия, смех, хеджи, вежливость, сокращения.",
|
||||
], size=14, title="Ручные признаки")
|
||||
add_panel(slide, 6.75, 1.35, 6.1, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 6.95, 1.55, 5.7, 5.1, [
|
||||
"Функциональные слова: около 150 закрытых слов и 12 категорий.",
|
||||
"Местоимения: 1-е, 2-е, 3-е лицо; отношение «я/ты».",
|
||||
"Части речи: Universal POS и Penn Treebank, время, модальность, герундий.",
|
||||
"Синтаксис-прокси: клаузы, подчинительные союзы, пассив, вопросы.",
|
||||
"Символьные n-граммы 2–5 и POS n-граммы 1–3.",
|
||||
"LSA: сжатые векторы по словам, содержанию, POS и символам.",
|
||||
], size=14, title="Синтаксис и векторные блоки")
|
||||
add_notes(slide, "Не перечислять все 454 признака. Объяснить логику: закрытые классы слов и пунктуация — классическая стилометрия; n-граммы — морфология и синтаксис; LSA — сжатое представление. Отметить, что словарное богатство и читаемость помечены как предварительные из-за коротких текстов.")
|
||||
|
||||
# 13. LSA
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "LSA простыми словами", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.6, 5.1, [
|
||||
"Строим таблицу «документ × термин» и взвешиваем частоты через TF-IDF.",
|
||||
"Сжимаем таблицу сингулярным разложением до k скрытых осей.",
|
||||
"Каждая реплика превращается в вектор из k чисел.",
|
||||
"Оси не обязаны быть темами; их знак и порядок произвольны.",
|
||||
"k выбирается по объяснённой дисперсии и внешней метрике.",
|
||||
], size=16, title="Идея")
|
||||
add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [
|
||||
"word — лексика и тема.",
|
||||
"content — содержание после удаления стоп-слов.",
|
||||
"fw — функциональные слова и синтаксис.",
|
||||
"char — морфология и пунктуация.",
|
||||
"pos — частеречные последовательности.",
|
||||
"В работе k = 50–400; для content-LSA качество росло до 400.",
|
||||
], size=15, title="Варианты")
|
||||
add_panel(slide, 0.65, 5.65, 11.9, 0.8, fill=CREAM_LIGHT)
|
||||
add_text(slide, 0.8, 5.78, 11.6, 0.5, "Ключевой вопрос: LSA по содержательным словам ловит тему, а не только стиль.", size=15, bold=True, color=RED, align=PP_ALIGN.CENTER)
|
||||
add_notes(slide, "Объяснить SVD без формул: было много столбцов, стало несколько скрытых осей; это сжатие, а не «понимание». Вариант content может кодировать тему, поэтому его нельзя автоматически называть стилем. В конце блока результатов покажем контроль содержания.")
|
||||
|
||||
# 14. Логистическая регрессия
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Логистическая регрессия", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM)
|
||||
add_text(slide, 0.7, 1.55, 5.5, 0.35, "Как работает", size=18, bold=True, color=GREEN_DARK)
|
||||
add_bullets(slide, 0.7, 1.95, 5.5, 4.7, [
|
||||
"Для каждого класса считает линейную оценку: сумма «признак × вес» плюс сдвиг.",
|
||||
"Преобразует оценки в вероятности через softmax: они неотрицательны и суммируются в 1.",
|
||||
"Обучение минимизирует перекрёстную энтропию — штраф за низкую вероятность истинного класса.",
|
||||
"L2-регуляризация удерживает веса малыми; параметр C задаёт силу регуляризации.",
|
||||
"class_weight='balanced' повышает вес редких классов.",
|
||||
], size=15)
|
||||
add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [
|
||||
"Плюсы: устойчива при большом числе признаков, даёт вероятности, коэффициенты интерпретируемы.",
|
||||
"Минусы: только линейные границы; при сильной корреляции признаков веса нестабильны.",
|
||||
"В работе — основная линейная базовая модель.",
|
||||
"Сравнение C: 0,03–10; балансировка классов критична.",
|
||||
], size=15, title="Что важно")
|
||||
add_notes(slide, "Объяснить на пальцах: модель складывает голоса признаков с весами; softmax превращает их в вероятности. C — обратная сила штрафа: меньше C — проще модель. Отметить, что без балансировки редкие персонажи почти не находятся; это было видно в эксперименте.")
|
||||
|
||||
# 15. SVM и деревья
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "SVM, деревья и ансамбли", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.05, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.65, 5.1, [
|
||||
"Линейный SVM ищет границу с максимальным зазором; C штрафует ошибки.",
|
||||
"SVM не даёт вероятностей: только решающую функцию; тройка лучших считается по её значениям.",
|
||||
"Случайный лес: много деревьев, каждое на своей подвыборке; голосование.",
|
||||
"Сверхслучайные деревья (ExtraTrees): пороги в узлах выбираются случайно; меньше дисперсии, быстрее.",
|
||||
"Мягкое голосование усредняет вероятности нескольких моделей.",
|
||||
"Стековое объединение обучает мета-классификатор на предсказаниях базовых моделей.",
|
||||
], size=14, title="Идеи моделей")
|
||||
add_panel(slide, 6.7, 1.35, 6.15, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 6.9, 1.55, 5.75, 5.1, [
|
||||
"Деревья ловят нелинейные взаимодействия: «если многоточий и короткая реплика — это Jas».",
|
||||
"Легко переобучаются на 2005 объектах; нужны ограничения глубины и листьев.",
|
||||
"Дельта Барроуза: расстояние до центроида по частотным словам — классическая проверка.",
|
||||
"Ансамбль полезен, если ошибки моделей не полностью совпадают.",
|
||||
"Стековое объединение в наших данных переобучилось и оказалось хуже голосования.",
|
||||
], size=14, title="Сильные и слабые стороны")
|
||||
add_notes(slide, "Не углубляться в оптимизацию. Главное: SVM — линейная граница с зазором; деревья — нелинейные правила; ExtraTrees — случайные пороги; голосование усредняет вероятности. Дельта Барроуза — независимая классическая проверка, а не ещё одна нейросеть.")
|
||||
|
||||
# 16. Проверка
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Как проверяем", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.25, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [
|
||||
"Стратифицированная перекрёстная проверка: 5 блоков, доли классов сохраняются.",
|
||||
"Повторяем 3–5 раз с разными разбиениями — оцениваем разброс.",
|
||||
"Вложенная проверка: внешние блоки — оценка, внутренние — настройка.",
|
||||
"Отложенная выборка 80/20 — независимая проверка финальных кандидатов.",
|
||||
], size=16, title="Схема")
|
||||
add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [
|
||||
"TF-IDF, SVD, масштабирование и отбор признаков обучаются только на обучающей выборке.",
|
||||
"Тестовая выборка не участвует в выборе гиперпараметров или признаков.",
|
||||
"Проверка перестановкой меток дала 0,027–0,028 — случайный уровень.",
|
||||
"Почти-дубликаты внутри одного класса не пересекают обучение и тест.",
|
||||
"Метрики: макро-F1, сбалансированная доля, тройка лучших, MCC, каппа, матрица ошибок.",
|
||||
], size=15, title="Контроль утечек")
|
||||
add_notes(slide, "Объяснить, почему нельзя настраивать на тесте. Привести аналогию: если подсматривать ответы, оценка будет завышена. Перестановка меток — контроль: если модель на случайных метках даёт 0,027, пайплайн не течёт.")
|
||||
|
||||
# 17. Метрики
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Метрики: что и зачем", "Блок 2 · Методология", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM_LIGHT)
|
||||
rows = [
|
||||
["Метрика", "Что показывает"],
|
||||
["Макро-F1", "Средняя F-мера по 34 классам; все персонажи равны"],
|
||||
["Доля правильных", "Доля совпавших ответов; обманчива при дисбалансе"],
|
||||
["Сбалансированная доля", "Средняя полнота по классам"],
|
||||
["Тройка лучших", "Истинный класс в тройке лучших"],
|
||||
["MCC / каппа", "Согласие с поправкой на случай"],
|
||||
["Матрица ошибок", "Кто с кем путается"],
|
||||
]
|
||||
add_table(slide, 0.65, 1.55, 5.75, 4.9, rows, col_widths=[2.2, 3.6], font_size=12)
|
||||
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
|
||||
"Случайный уровень при 34 классах: macro-F1 ≈ 0,029, top-3 ≈ 0,088.",
|
||||
"Модель, всегда выбирающая Abigail: доля правильных ответов ≈ 6,9%, macro-F1 ≈ 0,004.",
|
||||
"Поэтому основной вывод делаем по macro-F1 и доверительным интервалам.",
|
||||
"Дополнительно смотрим F-меру по каждому персонажу и матрицу ошибок.",
|
||||
], size=16, title="Почему не доля правильных ответов")
|
||||
add_notes(slide, "Главный методический слайд: объяснить, что accuracy при 34 классах может вводить в заблуждение. Пример Dummy: 6,9% accuracy, но почти нулевая macro-F1. Поэтому все выводы — по macro-F1, а per-class F1 показывает, кого именно модель не находит.")
|
||||
|
||||
# 18. Блок 3
|
||||
slide_no += 1
|
||||
section_slide(
|
||||
prs, 3, "Блок 3", "Результаты и интерпретация",
|
||||
"Что получилось, кого модель путает и что это значит",
|
||||
["Лучшие модели и масштаб качества", "Стиль или содержание", "Матрицы ошибок и пары персонажей", "Ограничения и выводы"],
|
||||
"Передать слово рассказчику 3. Здесь важно не только назвать число, но и объяснить, что оно означает и чего оно не означает.",
|
||||
slide_no,
|
||||
)
|
||||
|
||||
# 19. Главный результат
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Главный результат", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "model_comparison.png", 0.5, 1.45, 8.8, 5.35)
|
||||
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
|
||||
"Мягкое голосование: 0,304.",
|
||||
"ExtraTrees, char=10000: 0,276.",
|
||||
"Вложенный выбор: 0,275.",
|
||||
"Случайный уровень: 0,029.",
|
||||
"На отложенной выборке: 0,333.",
|
||||
], size=15, title="Макро-F1")
|
||||
add_notes(slide, "Назвать числа и сразу масштаб: 0,30 при 34 классах — это примерно в 10 раз выше случайного. Не говорить «низкая точность» без сравнения с 0,029. Оговорить, что 0,333 на отложенной выборке — одно разбиение и широкий интервал.")
|
||||
|
||||
# 20. Что добавляет качество
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Что добавляет качество", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.4, 1.35, 8.95, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "feature_progression.png", 0.5, 1.45, 8.75, 5.35)
|
||||
add_panel(slide, 9.5, 1.35, 3.4, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.67, 1.55, 3.1, 5.1, [
|
||||
"Ручные признаки: 0,105.",
|
||||
"+ char/POS n-граммы: 0,182.",
|
||||
"+ content-LSA: 0,249.",
|
||||
"ExtraTrees + char=10000: 0,276.",
|
||||
"Мягкое голосование: 0,304.",
|
||||
"Основной прирост — лексика и n-граммы, не «чистый синтаксис».",
|
||||
], size=14, title="По шагам")
|
||||
add_notes(slide, "Показать, что качество растёт не от «магии модели», а от добавления лексических и символьных признаков. Отметить: функциональные слова и POS- LSA сами по себе дают только 0,06–0,07.")
|
||||
|
||||
# 21. Стиль или содержание
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Стиль или содержание?", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.4, 1.35, 8.4, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "topic_control.png", 0.5, 1.45, 8.2, 5.35)
|
||||
add_panel(slide, 8.95, 1.35, 3.95, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.12, 1.55, 3.65, 5.1, [
|
||||
"Исходный текст: 0,248.",
|
||||
"После замены содержательных слов: 0,113.",
|
||||
"Падение более чем вдвое.",
|
||||
"Остаток 0,113 всё ещё в 3,9 раза выше случайного.",
|
||||
"Значит, стиль есть, но содержание доминирует.",
|
||||
], size=15, title="Контроль")
|
||||
add_notes(slide, "Это ключевой слайд для честной интерпретации. Содержательные слова заменили на CONTENT, оставив служебные слова, POS и пунктуацию. Качество упало с 0,248 до 0,113. Значит, нельзя утверждать, что модель выучила «чистый идиолект»: она во многом различает, о чём персонаж говорит.")
|
||||
|
||||
# 22. Как читать матрицу
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Как читать матрицу ошибок", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
|
||||
add_text(slide, 0.7, 1.55, 5.7, 0.4, "Маленький пример", size=17, bold=True, color=GREEN_DARK)
|
||||
mini = [
|
||||
["истина \\ прогноз", "A", "B", "C"],
|
||||
["A", "70", "20", "10"],
|
||||
["B", "15", "75", "10"],
|
||||
["C", "5", "25", "70"],
|
||||
]
|
||||
add_table(slide, 0.7, 2.05, 5.6, 2.5, mini, col_widths=[2.2, 1.1, 1.1, 1.1], font_size=14)
|
||||
add_text(slide, 0.7, 4.7, 5.7, 1.8, "Строка — истинный персонаж. Столбец — предсказание модели. Диагональ — правильные ответы. Вне диагонали — ошибки. Числа — проценты от строки.", size=14, color=DARK)
|
||||
add_panel(slide, 6.85, 1.35, 6.0, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 7.05, 1.55, 5.6, 5.1, [
|
||||
"Полная матрица 34×34 на доске нечитаема.",
|
||||
"Поэтому на слайде — 12 самых частых персонажей и столбец «прочие».",
|
||||
"Отдельно показываем 18 самых частых пар ошибок.",
|
||||
"Полная матрица 34×34 — в раздатке/приложении.",
|
||||
"На доске смотрим диагональ, крупные внедиагональные клетки и «прочие».",
|
||||
], size=15, title="Как показываем")
|
||||
add_notes(slide, "Объяснить матрицу на маленьком примере. Сказать: полная матрица 34×34 на доске превращается в кашу; поэтому используем три уровня: top-12 матрица, top-18 пар, полная матрица в раздатке. Это методически честно и читаемо.")
|
||||
|
||||
# 23. Матрица top12
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Матрица ошибок: 12 персонажей", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.35, 1.35, 9.05, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "confusion_top12.png", 0.45, 1.45, 8.85, 5.35)
|
||||
add_panel(slide, 9.6, 1.35, 3.3, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.77, 1.55, 3.0, 5.1, [
|
||||
"Диагональ: доля правильных ответов внутри класса.",
|
||||
"Abigail: 21% верно, 18% уходит в «прочие».",
|
||||
"Alex: 37% верно, 11% → Abigail.",
|
||||
"Sebastian: 19% верно, 20% → Abigail.",
|
||||
"У многих строк крупная доля «прочих»: модель не находит редкие классы.",
|
||||
], size=13, title="Что видно")
|
||||
add_notes(slide, "Провести по строкам: Abigail, Alex, Sebastian. Обратить внимание на столбец «прочие»: он большой, потому что 22 класса не поместились. Это не «мусор», а важная часть ошибок. Сказать, что диагональ далеко не 100%, но и не 0.")
|
||||
|
||||
# 24. Кого путает
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Кого модель путает", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.35, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "confusion_pairs.png", 0.45, 1.45, 8.8, 5.35)
|
||||
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
|
||||
"Sebastian → Abigail: 65.",
|
||||
"Sebastian → Alex: 61.",
|
||||
"Abigail → Sebastian: 59.",
|
||||
"Abigail → Alex: 56.",
|
||||
"Основной клубок — подростки/молодые взрослые: Abigail, Alex, Sebastian, Haley, Sam.",
|
||||
], size=13, title="Частые пары")
|
||||
add_notes(slide, "Показать, что ошибки не случайны: они концентрируются в одном круге персонажей. Это ожидаемо: один автор, общие темы, сходная эмоциональная окраска, короткие реплики. Это не доказательство «автор не старался», а наоборот — свидетельство близких речевых масок внутри группы.")
|
||||
|
||||
# 25. Классы-аттракторы
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Классы-аттракторы", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "hub_shares.png", 0.5, 1.45, 8.8, 5.35)
|
||||
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
|
||||
"Alex: 11,4% предсказаний при истинной доле 6,0%.",
|
||||
"Abigail: 10,9% при 6,9%.",
|
||||
"Редкие классы недопредставлены.",
|
||||
"Это не «демографическая» путаница, а стягивание к популярным голосам.",
|
||||
"Коррекция приоров не улучшила macro-F1.",
|
||||
], size=13, title="Кто перетягивает")
|
||||
add_notes(slide, "Объяснить «класс-аттрактор»: модель чаще выбирает Alex и Abigail, потому что их признаки часто встречаются и они похожи на многих. Редкие персонажи проигрывают. Простая коррекция приоров не помогла; нужны более тонкие правила или больше данных.")
|
||||
|
||||
# 26. Per-class F1
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Персонажи: кто различается", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.3, 1.35, 9.25, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "per_class_f1.png", 0.4, 1.45, 9.05, 5.35)
|
||||
add_panel(slide, 9.75, 1.35, 3.15, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 9.9, 1.55, 2.85, 5.1, [
|
||||
"Лучшие: Willy, Evelyn, Pierre, Leo, Pam.",
|
||||
"Худшие: Kent, Mister Qi, Penny, Abigail, Leah.",
|
||||
"Редкие классы — широкие интервалы.",
|
||||
"Мягкое голосование улучшает Wizard, Dwarf, Vincent.",
|
||||
], size=13, title="Факты")
|
||||
add_notes(slide, "Показать, что качество по персонажам неоднородно. Evelyn/Willy/Pierre различаются лучше; Kent/Mister Qi/Penny — хуже. Это связано и с числом реплик, и с их речевой функцией. Не делать вывод, что персонаж «плохой»: у редких классов просто мало данных.")
|
||||
|
||||
# 27. Почему путаются
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Почему персонажи путаются", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [
|
||||
"Один автор и общие сюжетные контексты: персонажи говорят об одних и тех же событиях.",
|
||||
"Сходные возраст и роль: подростки/молодые взрослые используют похожие темы и интонации.",
|
||||
"Короткие реплики: мало слов, чтобы проявить индивидуальный синтаксис.",
|
||||
"Дисбаланс: редкие классы реже выбираются моделью.",
|
||||
"Общие формулы вежливости, шутки, междометия.",
|
||||
], size=15, title="Причины")
|
||||
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
|
||||
"Клубок: Abigail, Alex, Sebastian, Haley, Sam.",
|
||||
"Нелюди (Krobus, Dwarf, Wizard) различаются лучше, но Wizard и Dwarf редки.",
|
||||
"Дети (Jas, Vincent) имеют короткие реплики; их легче узнать по длине.",
|
||||
"Это не доказательство сходства «характеров», а свойство текстовых признаков.",
|
||||
"Нужны сцены и больше данных, чтобы отделить стиль от контекста.",
|
||||
], size=15, title="Что это значит")
|
||||
add_notes(slide, "Ответить на вопрос «почему одни и те же персонажи путаются»: один автор, общие темы, сходный возраст/роль, короткие реплики, дисбаланс. Подчеркнуть: мы не делаем психологических выводов о персонажах; мы объясняем статистику ошибок.")
|
||||
|
||||
# 28. Что означают оценки
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Что означают оценки", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.7, 1.55, 11.9, 5.1, [
|
||||
"0,30 макро-F1 при 34 классах — это примерно в 10 раз выше случайного уровня 0,029.",
|
||||
"Абсолютное значение умеренное: по одной короткой реплике персонажа действительно трудно определить.",
|
||||
"Это не означает, что автор «не старался» или что стиль неразличим. Наоборот, устойчивое превышение случайного уровня говорит о намеренно различных голосах.",
|
||||
"Умеренность объясняется короткими текстами, дисбалансом классов, общими темами и формулами, а не отсутствием стиля.",
|
||||
"Мы не решаем задачу «он или не он писал»: все реплики одного автора. Мы решаем, какой персонаж говорит.",
|
||||
], size=17, title="Уточнение интерпретации")
|
||||
add_notes(slide, "Здесь важно поправить исходную формулировку. Нельзя говорить «низкие оценки хорошие, потому что автор не старался». Корректно: оценки выше случайного уровня, но умеренные; это свидетельствует о частичной различимости голосов. Один автор — значит, различия созданы намеренно, а не разными авторами. Низкие абсолютные значения — следствие трудности задачи, а не приговор стилю.")
|
||||
|
||||
# 29. Ограничения
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Ограничения", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [
|
||||
"Один корпус, одна игра, один автор.",
|
||||
"Нет внешней валидации на другом корпусе.",
|
||||
"Короткие реплики и дисбаланс классов.",
|
||||
"Ручная фильтрация корпуса не воспроизводится.",
|
||||
"Нет идентификаторов сцен и диалогов.",
|
||||
], size=16, title="Данные")
|
||||
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT)
|
||||
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
|
||||
"Выбор признаков и ансамбля опирался на предыдущие результаты.",
|
||||
"Вложенная проверка снижает, но не устраняет оптимизм.",
|
||||
"Содержательные слова и тема вносят большой вклад.",
|
||||
"Простая калибровка приоров не помогла.",
|
||||
"Стековое объединение переобучается на малом числе объектов.",
|
||||
], size=16, title="Методология")
|
||||
add_notes(slide, "Перечислить ограничения честно. Главное: нельзя переносить выводы на другие игры/авторов без внешней проверки. И нельзя называть результат «чистым стилем», пока не отделена тема.")
|
||||
|
||||
# 30. Выводы
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Выводы", "Блок 3 · Результаты", slide_no)
|
||||
add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 0.75, 1.55, 11.8, 5.1, [
|
||||
"Персонажи статистически различимы: лучшая модель — 0,304 макро-F1, консервативная оценка — 0,275 при случайном уровне 0,029.",
|
||||
"Основной вклад дают лексика, символьные n-граммы и LSA по содержательным словам; функциональные слова и синтаксис сами по себе слабы.",
|
||||
"После замены содержательных слов качество падает с 0,248 до 0,113: содержание доминирует, но стилевой сигнал сохраняется.",
|
||||
"Главная проблема — редкие классы и классы-аттракторы; мягкое голосование помогает, но не решает её полностью.",
|
||||
"Корректный вывод: различимы речевые маски по лексико-тематическому профилю; для утверждения о «чистом стиле» нужны внешняя валидация и тематический контроль.",
|
||||
], size=17, title="Пять тезисов")
|
||||
add_notes(slide, "Сформулировать выводы без переубеждения аудитории. Главное: не «мы всё доказали», а «мы получили устойчивый выше случайного уровня сигнал; основной вклад — лексика/тема; для стиля нужны дальнейшие проверки».")
|
||||
|
||||
# 31. Приложение: полная матрица
|
||||
slide_no += 1
|
||||
slide = content_slide(prs, "Приложение: полная матрица 34×34", "Приложение", slide_no)
|
||||
add_panel(slide, 0.35, 1.35, 7.4, 5.55, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "confusion_full34.png", 0.45, 1.45, 7.2, 5.35)
|
||||
add_panel(slide, 7.95, 1.35, 4.95, 5.55, fill=CREAM)
|
||||
add_bullets(slide, 8.12, 1.55, 4.6, 5.1, [
|
||||
"Полная матрица 34×34 — для раздаточного материала.",
|
||||
"На проекторе она слишком плотная; в докладе используем top-12 и top-18 пар.",
|
||||
"Строки — истинные персонажи; столбцы — предсказания; цвет — доля строки.",
|
||||
"Диагональ — правильные ответы; тёмные внедиагональные клетки — систематические ошибки.",
|
||||
], size=15, title="Как использовать")
|
||||
add_notes(slide, "Этот слайд можно не показывать, а раздать. Если показывать, не читать все клетки: показать диагональ и несколько тёмных пятен.")
|
||||
|
||||
# 32. Финал: мем
|
||||
slide_no += 1
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_background(slide)
|
||||
add_panel(slide, 0.55, 1.2, 6.2, 5.45, fill=CREAM)
|
||||
add_text(slide, 0.85, 1.5, 5.6, 1.0, "Спасибо за внимание!", size=34, bold=True, color=GREEN_DARK)
|
||||
add_text(slide, 0.85, 2.55, 5.5, 1.5, "Вопросы, обсуждение и предложения по экспериментам", size=18, color=DARK)
|
||||
add_text(slide, 0.85, 4.2, 5.5, 1.2, "«Один автор — разные голоса»", size=20, bold=True, color=RED)
|
||||
add_panel(slide, 7.0, 0.9, 5.8, 6.0, fill=CREAM_LIGHT)
|
||||
add_image_fit(slide, GEN / "meme.png", 7.15, 1.05, 5.5, 5.7)
|
||||
add_chicken(slide, 0.25, 7.05, 0.28)
|
||||
add_notes(slide, "Финальный слайд с мемом. Поблагодарить, перейти к вопросам. Курица спрятана слева внизу — пасхалка.")
|
||||
|
||||
prs.save(OUT)
|
||||
print(f"Готово: {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
lab1/presentation/generated/characters.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
lab1/presentation/generated/confusion_full34.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
lab1/presentation/generated/confusion_pairs.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
lab1/presentation/generated/confusion_top12.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
lab1/presentation/generated/feature_progression.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
lab1/presentation/generated/hub_shares.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
lab1/presentation/generated/meme.png
Normal file
|
After Width: | Height: | Size: 678 KiB |
BIN
lab1/presentation/generated/model_comparison.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
lab1/presentation/generated/per_class_f1.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
lab1/presentation/generated/topic_control.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
lab1/presentation/images/background.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
lab1/presentation/images/characters.webp
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
lab1/presentation/images/chicken.png
Normal file
|
After Width: | Height: | Size: 411 B |
BIN
lab1/presentation/images/median.png
Normal file
|
After Width: | Height: | Size: 680 KiB |
BIN
lab1/presentation/images/meme.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
lab1/presentation/images/replics-count.png
Normal file
|
After Width: | Height: | Size: 795 KiB |
BIN
lab1/presentation/images/title.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
lab1/presentation/latex/stardew_stylometry.pdf
Normal file
985
lab1/presentation/latex/stardew_stylometry.tex
Normal file
@@ -0,0 +1,985 @@
|
||||
% !TeX program = xelatex
|
||||
% Стилометрическая классификация реплик Stardew Valley — академическая Beamer-презентация.
|
||||
% Сборка: xelatex stardew_stylometry.tex (два прохода) в каталоге latex/.
|
||||
\documentclass[aspectratio=169,10pt]{beamer}
|
||||
|
||||
\usepackage{fontspec}
|
||||
\usepackage[russian]{babel}
|
||||
\babelfont{rm}{Liberation Sans}
|
||||
\babelfont{sf}{Liberation Sans}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{tikz}
|
||||
\usetikzlibrary{calc}
|
||||
\usepackage[most]{tcolorbox}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{etoolbox}
|
||||
|
||||
\graphicspath{{../images/}{../generated/}}
|
||||
|
||||
% ---------- Цвета: сдержанная академическая гамма ----------
|
||||
\definecolor{cream}{RGB}{253,246,231}
|
||||
\definecolor{inkbrown}{RGB}{62,42,26}
|
||||
\definecolor{accent}{RGB}{140,95,45}
|
||||
|
||||
% ---------- Общий вид ----------
|
||||
\usefonttheme{professionalfonts}
|
||||
\setbeamertemplate{navigation symbols}{}
|
||||
\setbeamertemplate{caption}{\raggedright\insertcaption\par}
|
||||
\setbeamercolor{normal text}{fg=inkbrown}
|
||||
\setbeamercolor{frametitle}{fg=inkbrown}
|
||||
\setbeamercolor{structure}{fg=accent}
|
||||
\setbeamercolor{itemize item}{fg=accent}
|
||||
\setbeamercolor{itemize subitem}{fg=accent}
|
||||
\setbeamercolor{itemize/enumerate body}{fg=inkbrown}
|
||||
\setbeamercolor{enumerate item}{fg=accent}
|
||||
\setbeamersize{text margin left=0.55cm, text margin right=0.55cm}
|
||||
\setbeamertemplate{itemize item}{\textbullet}
|
||||
\setbeamertemplate{itemize subitem}{--}
|
||||
|
||||
% ---------- Нижний колонтитул: номер слайда и сколько осталось ----------
|
||||
% Рисуется в конце каждой рамки поверх содержимого и не меняет вёрстку.
|
||||
\newcommand{\slidefooter}{%
|
||||
\begin{tikzpicture}[remember picture, overlay]
|
||||
\node[anchor=north east, xshift=-0.1cm, yshift=-0.1cm]
|
||||
at (current page.north east)
|
||||
{\colorbox{cream}{\scriptsize\color{inkbrown}%
|
||||
\insertframenumber\slash\inserttotalframenumber
|
||||
\quad}};
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
|
||||
% ---------- Фон: изображение + маленькая курица на каждом слайде ----------
|
||||
\usebackgroundtemplate{%
|
||||
\begin{tikzpicture}[remember picture, overlay]
|
||||
\node at (current page.center)
|
||||
{\includegraphics[width=\paperwidth,height=\paperheight]{background.jpg}};
|
||||
\node[anchor=south east, xshift=-2.5mm, yshift=1.5mm]
|
||||
at (current page.south east)
|
||||
{\includegraphics[height=0.65cm]{chicken.png}};
|
||||
\end{tikzpicture}}
|
||||
|
||||
% ---------- Заголовок слайда: кремовая полоса во всю ширину ----------
|
||||
\newlength{\sidemargin}
|
||||
\setlength{\sidemargin}{0.55cm}
|
||||
\setbeamertemplate{frametitle}{%
|
||||
\nointerlineskip
|
||||
\vspace*{-0.2mm}%
|
||||
\hspace*{-\sidemargin}%
|
||||
\begin{tcolorbox}[enhanced, width=\paperwidth, colback=cream, colframe=cream,
|
||||
opacityback=0.90, arc=0mm, boxrule=0pt,
|
||||
left=\sidemargin, right=\sidemargin, top=1.2mm, bottom=1.2mm,
|
||||
before skip=0pt, after skip=2mm]
|
||||
{\usebeamerfont{frametitle}\usebeamercolor[fg]{frametitle}\large\bfseries\insertframetitle}
|
||||
\end{tcolorbox}}
|
||||
|
||||
% ---------- Кремовые панели для содержимого ----------
|
||||
\tcbset{panelstyle/.style={enhanced, colback=cream, opacityback=0.90,
|
||||
colframe=accent, opacityframe=0.65, boxrule=0.6pt, arc=1.4mm,
|
||||
boxsep=1.2mm, top=1.4mm, bottom=1.4mm, left=2.4mm, right=2.4mm,
|
||||
before skip=1.6mm, after skip=1.6mm}}
|
||||
\newtcolorbox{panel}[1][]{panelstyle, #1}
|
||||
|
||||
% ---------- Кремовый бокс с изображением ----------
|
||||
\newtcolorbox{imgbox}[1][]{enhanced, colback=cream, opacityback=0.90,
|
||||
colframe=accent, opacityframe=0.65, boxrule=0.6pt, arc=1.4mm,
|
||||
boxsep=0.8mm, top=0.8mm, bottom=0.8mm, left=0.8mm, right=0.8mm,
|
||||
before skip=1.6mm, after skip=1.6mm, halign=center, #1}
|
||||
|
||||
% ---------- Титульный шаблон ----------
|
||||
\title[Стилометрия реплик Stardew Valley]{Стилометрическая классификация реплик персонажей Stardew Valley}
|
||||
\author{А.~А.~Аладьина \and В.~Н.~Копнин \and М.~В.~Савостиков}
|
||||
\institute{ПИЭ-13МО}
|
||||
|
||||
\setbeamerfont{note page}{size=\footnotesize}
|
||||
|
||||
\begin{document}
|
||||
|
||||
% ==================== СЛАЙД: ТИТУЛ ====================
|
||||
\begin{frame}[plain]
|
||||
\begin{tikzpicture}[remember picture, overlay]
|
||||
\node at (current page.center)
|
||||
{\includegraphics[width=\paperwidth,height=\paperheight]{title.jpg}};
|
||||
\node[anchor=south east, xshift=-2.5mm, yshift=1.5mm]
|
||||
at (current page.south east)
|
||||
{\includegraphics[height=0.65cm]{chicken.png}};
|
||||
\end{tikzpicture}
|
||||
\vspace*{-2.2cm}
|
||||
\begin{center}
|
||||
\begin{tcolorbox}[enhanced, width=0.92\paperwidth, colback=cream, opacityback=0.92,
|
||||
colframe=cream, boxrule=0pt, arc=2mm, halign=center, boxsep=2mm]
|
||||
{\large\bfseries Стилометрическая классификация реплик персонажей Stardew Valley}\\[2mm]
|
||||
{\normalsize Можно ли различить голоса персонажей, \\ если все реплики написаны одним автором?}\\[2.4mm]
|
||||
А.~А.~Аладьина \quad·\quad В.~Н.~Копнин \quad·\quad М.~В.~Савостиков\\[0.8mm]
|
||||
\footnotesize ПИЭ-13МО
|
||||
\end{tcolorbox}
|
||||
\end{center}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
% ==================== БЛОК 1 ====================
|
||||
\begin{frame}{Часть 1\ \ ·\ \ Задача и корпус}
|
||||
\begin{center}
|
||||
\begin{tcolorbox}[enhanced, width=0.86\paperwidth, colback=cream, opacityback=0.9,
|
||||
colframe=cream, boxrule=0pt, arc=2mm, halign=center, boxsep=3mm]
|
||||
{\large Часть 1}\\[1.5mm]
|
||||
{\Large\bfseries Решаемая задача и корпус}\\[2mm]
|
||||
Постановка задачи · сбор и очистка данных · характеристики корпуса\\[2.5mm]
|
||||
{\bfseries Докладчик: А.~А.~Аладьина}
|
||||
\end{tcolorbox}
|
||||
\end{center}
|
||||
\note{Часть\,1. Говорит А. А. Аладьина. План части: что за задача мы решаем,
|
||||
её формальная постановка, как собирали и чистили корпус, и какими
|
||||
характеристиками он обладает.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Задача и постановка}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.57\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\textbf{Решаемая задача}
|
||||
\begin{itemize}
|
||||
\item Дано: 2005 коротких реплик, 34 персонажа, один автор.
|
||||
\item Требуется: по одной реплике определить персонажа.
|
||||
\item Каждый объект относится ровно к одному из 34 классов.
|
||||
\item Это не авторская атрибуция: мы различаем \emph{речевые маски персонажей}, а не авторов.
|
||||
\item Проверяется, статистически различимы ли эти маски.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.41\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\textbf{Обозначения}\\[1mm]
|
||||
$D=\{(x_i,\,y_i)\}_{i=1}^{n}$,\quad $n=2005$;\\
|
||||
$x_i$ — реплика;\\
|
||||
$y_i\in Y$, \ $|Y|=34$ — персонаж;\\
|
||||
$f$ — классификатор.\\[1.5mm]
|
||||
Критерий качества — макро-F1: средняя F-мера по 34 классам.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит А. А. Аладьина. Задача — многоклассовая классификация коротких
|
||||
текстов. Формула одна: D — корпус, x — реплика, y — персонаж, f —
|
||||
классификатор. Дальше все определения даются словами. Подчеркнуть: это не
|
||||
авторская атрибуция, автор один; мы различаем речевые маски.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Корпус: сбор, разметка, очистка}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.33\textwidth}
|
||||
\begin{panel}
|
||||
\footnotesize\textbf{1. Сбор}
|
||||
\begin{itemize}
|
||||
\item Реплики из диалогов игры.
|
||||
\item Единица анализа — одна реплика.
|
||||
\item 34 персонажа, тексты на английском.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.33\textwidth}
|
||||
\begin{panel}
|
||||
\footnotesize\textbf{2. Разметка}
|
||||
\begin{itemize}
|
||||
\item Метка — имя говорящего персонажа.
|
||||
\item Ручная проверка реплики и метки.
|
||||
\item Метаданные (пол, возраст, вид) хранятся отдельно и не подаются модели.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.33\textwidth}
|
||||
\begin{panel}
|
||||
\footnotesize\textbf{3. Очистка}
|
||||
\begin{itemize}
|
||||
\item \textbf{Unicode-очистка}: разные варианты кавычек, тире,
|
||||
многоточий и пробелов приводятся к единому виду.
|
||||
\item Плейсхолдеры вида \texttt{<PLAYER>} заменены нейтральными словами.
|
||||
\item Пунктуация и регистр сохранены: это стилометрический сигнал.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\begin{panel}
|
||||
\footnotesize \textbf{Ограничения данных:} ручная фильтрация; нет идентификаторов сцен.
|
||||
\end{panel}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Игровые персонажи}
|
||||
\begin{center}
|
||||
\begin{imgbox}
|
||||
\includegraphics[height=0.65\textheight]{characters.png}
|
||||
\end{imgbox}
|
||||
\end{center}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Характеристики корпуса: классы}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.42\textwidth}
|
||||
\begin{center}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth]{replics-count.png}
|
||||
\end{imgbox}
|
||||
\end{center}
|
||||
\end{column}
|
||||
\begin{column}{0.57\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\begin{tabular}{@{}p{3.2cm}p{3.6cm}@{}}
|
||||
\textbf{Реплик} & 2005\\[0.8mm]
|
||||
\textbf{Персонажей} & 34\\[0.8mm]
|
||||
\textbf{Максимум класса} & Abigail, 139\\[0.8mm]
|
||||
\textbf{Минимум класса} & Mister Qi, 15\\[0.8mm]
|
||||
\textbf{Точные дубли} & 0\\[0.8mm]
|
||||
\textbf{Почти-дубли} & 13 пар, но только внутри класса\\
|
||||
\end{tabular}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
Дисбаланс почти десятикратный: от 15 до 139 реплик. Поэтому
|
||||
доля правильных ответов обманчива; основная метрика — макро усреднённая
|
||||
F-мера по классам.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Характеристики корпуса: длина реплик}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.42\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth]{median.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\begin{column}{0.57\textwidth}
|
||||
\begin{panel}
|
||||
\begin{tabular}{@{}p{4.2cm}p{2.9cm}@{}}
|
||||
\textbf{Средняя длина} & 19,1 слова\\[0.8mm]
|
||||
\textbf{Медиана} & 17 слов\\[0.8mm]
|
||||
\textbf{Диапазон} & 3–78 слов\\
|
||||
\end{tabular}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
\begin{itemize}
|
||||
\item Короткие реплики дают мало информации для стилометрических характеристик.
|
||||
\item Длина сама по себе — сильный, но опасный признак: она может
|
||||
подменять «стиль» просто узнаванием персонажа.
|
||||
\item Поэтому её вклад проверялся отдельно: с исключением
|
||||
признаков длины и по группам длины.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит А. А. Аладьина. Средняя реплика — всего 19 слов, медиана 17,
|
||||
диапазон 3–78. На таких коротких текстах признаки словарного богатства и
|
||||
читаемости нестабильны — это одна из причин низких абсолютных значений
|
||||
качества, о которых расскажет третий докладчик.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
% ==================== БЛОК 2 ====================
|
||||
\begin{frame}{Часть 2\ \ ·\ \ Методология}
|
||||
\begin{center}
|
||||
\begin{tcolorbox}[enhanced, width=0.86\paperwidth, colback=cream, opacityback=0.9,
|
||||
colframe=cream, boxrule=0pt, arc=2mm, halign=center, boxsep=3mm]
|
||||
{\large Часть 2}\\[1.5mm]
|
||||
{\Large\bfseries Методология: как измеряем стиль}\\[2mm]
|
||||
Признаки · LSA · классификаторы · схема проверки · метрики\\[2.5mm]
|
||||
{\bfseries Докладчик: В.~Н.~Копнин}
|
||||
\end{tcolorbox}
|
||||
\end{center}
|
||||
\note{Часть\,2. Говорит В. Н. Копнин. План части: общая схема конвейера,
|
||||
какие признаки строим, как работает LSA, какие модели сравнивали и как устроена
|
||||
проверка, чтобы не обмануть себя.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Термины, которые встретятся дальше}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\footnotesize
|
||||
\textbf{Фолд} — одна из частей разбиения выборки при перекрёстной
|
||||
проверке. При 5 фолдах модель обучается на 4 и проверяется на 1,
|
||||
затем разбиение меняется.\\[0.8mm]
|
||||
\textbf{POS (part of speech)} — часть речи; POS-тег — её
|
||||
обозначение; POS-грамма — последовательность тегов.\\[0.8mm]
|
||||
\textbf{Universal POS} — универсальный набор тегов: NOUN, VERB,
|
||||
ADJ, ADV, PRON, DET, ADP, CONJ, NUM, PRT, INTJ, PUNCT, X.
|
||||
Единый набор для разных языков.\\[0.8mm]
|
||||
\textbf{Клауза} — синтаксическая единица с подлежащим и
|
||||
сказуемым; в репликах оценивается приблизительно.\\[0.8mm]
|
||||
\textbf{Синтаксис-прокси} — приблизительная оценка синтаксиса
|
||||
без полного разбора: число союзов, длина предложения, порядок слов.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\footnotesize
|
||||
\textbf{n-грамм} — последовательность $n$ соседних единиц:
|
||||
символов, слов или POS-тегов.\\[0.8mm]
|
||||
\textbf{LSA} — сжатие таблицы «документ $\times$ признак» в
|
||||
несколько скрытых компонент.\\[0.8mm]
|
||||
\textbf{Макро-F1} — средняя F-мера по классам; каждый персонаж
|
||||
весит одинаково.\\[0.8mm]
|
||||
\textbf{Unicode-очистка} — приведение разных вариантов записи
|
||||
символов к единому виду: кавычки, тире, многоточия, пробелы.\\[0.8mm]
|
||||
\textbf{Класс-аттрактор} — класс, к которому модель ошибочно
|
||||
относит чужие реплики.\\[0.8mm]
|
||||
\textbf{Фолд и группа признаков} — разные вещи: фолд — часть
|
||||
проверки, группа — набор признаков.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит В. Н. Копнин. Этот слайд — словарь терминов. Важно различать
|
||||
фолд (часть кросс-валидации) и группу признаков (набор характеристик текста).
|
||||
Universal POS — не «ещё одна модель», а универсальный набор частеречных
|
||||
тегов; POS-граммы — последовательности этих тегов. Клауза и
|
||||
синтаксис-прокси — приблизительные, но интерпретируемые оценки синтаксиса.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
|
||||
\begin{frame}{Общая схема}
|
||||
\begin{panel}
|
||||
Реплика (текст персонажа) $\to$ очистка (Unicode, плейсхолдеры)
|
||||
$\to$ токены и частеречная разметка (NLTK, Universal POS)
|
||||
$\to$ признаки (ручные, n-граммы, LSA)
|
||||
$\to$ модель (вероятности по 34 классам)
|
||||
$\to$ проверка (5 фолдов, повторы, метрики).
|
||||
\end{panel}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Что важно про схему}
|
||||
\begin{itemize}
|
||||
\item Все обучаемые преобразования настраиваются только внутри
|
||||
обучающих фолдах.
|
||||
\item Тестовые фолды не участвуют ни в настройке признаков, ни в
|
||||
выборе параметров модели.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Что такое «вектор признаков»}
|
||||
\begin{itemize}
|
||||
\item Это не «понимание» текста, а числовое описание его
|
||||
статистических свойств: длины, частоты, пунктуация, темы.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит В. Н. Копнин. Общий конвейер: текст превращается в цепочку
|
||||
преобразований и в конце — в вектор чисел, по которому модель выбирает
|
||||
класс. Ключевое правило: всё, что «учится» на данных, учится только на
|
||||
обучающей части каждой проверки.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
|
||||
\begin{frame}{454 ручных признака: 11 групп}
|
||||
\begin{panel}
|
||||
\begin{tabular}{@{}p{4cm}p{9.7cm}@{}}
|
||||
\textbf{Группа} & \textbf{Примеры признаков} \\ \hline
|
||||
Длина & число слов, символов, предложений; длина предложения \\
|
||||
Лексика & длины слов, апострофы, заглавные буквы, цифры \\
|
||||
Словарное богатство & TTR, Guiraud, Yule's K, hapax; на коротких текстах нестабильно \\
|
||||
Символы & частоты букв, регистр, пунктуация \\
|
||||
Пунктуация & запятые, многоточия, вопросы, восклицания, повторы \\
|
||||
Функциональные слова & артикли, предлоги, союзы, местоимения \\
|
||||
Местоимения & 1-е, 2-е, 3-е лицо; отношение «я/ты» \\
|
||||
Части речи & Universal POS, Penn Treebank, время, модальность \\
|
||||
Синтаксис & клаузы, подчинительные союзы, пассивные конструкции \\
|
||||
Читаемость & Flesch, FK, SMOG; на коротких репликах приблизительна \\
|
||||
Разговорные маркеры & междометия, смех, хеджи, вежливость, сокращения \\
|
||||
\end{tabular}
|
||||
\end{panel}
|
||||
\note{Говорит В. Н. Копнин. Это расшифровка одиннадцати групп. Не нужно
|
||||
запоминать все 454 признака: важнее логика — длина, лексика, пунктуация,
|
||||
служебные слова, части речи, синтаксис, читаемость и разговорные маркеры.
|
||||
Словарное богатство и читаемость на коротких репликах нестабильны.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Автоматические признаки: n-граммы и LSA}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{n-граммы}
|
||||
\begin{itemize}
|
||||
\item \textbf{n-грамм} — последовательность $n$ соседних
|
||||
единиц: символов, слов или POS-тегов.
|
||||
\item Символьные n-граммы 2–5: морфология, пунктуация,
|
||||
орфография.
|
||||
\item n-граммы POS-тегов 1–3: последовательности частей речи,
|
||||
синтаксические шаблоны.
|
||||
\item TF-IDF: вес признака с учётом частоты в реплике и
|
||||
редкости в корпусе.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{LSA}
|
||||
\begin{itemize}
|
||||
\item Сжатие таблицы «реплика $\times$ признак» в $k$ скрытых
|
||||
компонент.
|
||||
\item Варианты: \texttt{word}, \texttt{content}, \texttt{fw},
|
||||
\texttt{char}, \texttt{pos}.
|
||||
\item $k=50$–400; для \texttt{content}-LSA качество росло до 400.
|
||||
\item LSA — сжатое представление, а не «понимание» текста.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\begin{panel}
|
||||
Символьные n-граммы чувствительны к морфологии и пунктуации;
|
||||
n-граммы POS-тегов — к порядку частей речи; LSA сжимает разреженные
|
||||
признаки в несколько чисел. Это три разных взгляда на стиль.
|
||||
\end{panel}
|
||||
\note{Говорит В. Н. Копнин. n-грамма — это просто «окно» из n соседних
|
||||
единиц. Символьные n-граммы ловят морфологию и пунктуацию, n-граммы POS-тегов —
|
||||
синтаксические шаблоны. LSA не «понимает» текст: он сжимает таблицу
|
||||
признаков в несколько скрытых компонент.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{LSA простыми словами}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Идея}
|
||||
\begin{itemize}
|
||||
\item Строим таблицу «реплика $\times$ слово», взвешиваем
|
||||
частоты через TF-IDF.
|
||||
\item Сжимаем таблицу до $k$ скрытых осей: каждая реплика
|
||||
превращается в короткий вектор.
|
||||
\item Оси не обязаны быть «темами»: их порядок и знак произвольны.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Варианты LSA}
|
||||
\begin{itemize}
|
||||
\item \texttt{word} — лексика и тема.
|
||||
\item \texttt{content} — содержание без служебных слов.
|
||||
\item \texttt{fw} — функциональные слова и синтаксис.
|
||||
\item \texttt{char} — морфология и пунктуация.
|
||||
\item \texttt{pos} — частеречные последовательности.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\begin{panel}
|
||||
$k$ подбирался в диапазоне 50–400; для \texttt{content}-LSA качество
|
||||
устойчиво росло до 400.
|
||||
Ключевой вопрос: ловит ли LSA по содержательным словам \emph{тему}, а не
|
||||
только стиль?
|
||||
\end{panel}
|
||||
\note{Говорит В. Н. Копнин. LSA объясняем без формул: таблица частот —
|
||||
взвешивание — сжатие до k осей — короткий вектор для каждой реплики.
|
||||
Важно ограничение метода: оси формально не интерпретируются, а LSA по
|
||||
содержательным словам может кодировать тему. Для этого мы и делали
|
||||
несколько вариантов LSA, чтобы сравнить тематический и стилистический
|
||||
вклад.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Классификаторы}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Линейные модели}
|
||||
\begin{itemize}
|
||||
\item Логистическая регрессия: «признак $\times$ вес», затем
|
||||
вероятности; редкие классы получают повышенные веса.
|
||||
\item Линейный SVM: граница с максимальным зазором; часто силён
|
||||
на тексте, но вероятностей не выдаёт.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Деревья и ансамбли}
|
||||
\begin{itemize}
|
||||
\item Случайный лес и ExtraTrees: сотни деревьев; у ExtraTrees
|
||||
пороги выбираются случайно — меньше разброс.
|
||||
\item Мягкое голосование: усредняем вероятности моделей.
|
||||
\item Объединение через мета-классификатор (стекинг) у нас переобучилось и оказалось хуже.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\begin{panel}
|
||||
Сравнивали несколько семейств, чтобы вывод не зависел от одного
|
||||
алгоритма: близкие результаты у разных моделей делают вывод надёжнее.
|
||||
\end{panel}
|
||||
\note{Говорит В. Н. Копнин. Логистическая регрессия — интерпретируемая
|
||||
линейная база; SVM — сила на текстовых признаках, но без вероятностей;
|
||||
деревья ловят нелинейные комбинации («многоточие + короткая реплика»), но
|
||||
легко переобучаются на 2005 объектах. Лучший результат дало мягкое
|
||||
голосование трёх моделей; объединение через мета-классификатор было хуже — переобучилось.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Как проверяем}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Схема оценки}
|
||||
\begin{itemize}
|
||||
\item Стратифицированная перекрёстная проверка: 5 фолдов
|
||||
(частей разбиения); на 4 фолдах обучаемся, на 1 проверяем.
|
||||
Доли классов внутри фолдов сохраняются.
|
||||
\item Повторяем 3–5 раз с разными разбиениями — оцениваем разброс.
|
||||
\item Вложенная схема: внешние фолды — оценка, внутренние —
|
||||
настройка параметров модели.
|
||||
\item Отдельная отложенная выборка 80/20 для финальных кандидатов.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Контроль ошибок исследователя}
|
||||
\begin{itemize}
|
||||
\item TF-IDF, сжатие и масштабирование обучаются только на
|
||||
обучающих фолдах — борьба с утечкой данных.
|
||||
\item Контроль перестановкой меток дал 0,027–0,028 — воспроизведён
|
||||
случайный уровень.
|
||||
\item Почти-дубликаты находятся только внутри одного класса и
|
||||
не пересекают обучение и тест.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\vspace{-1mm}
|
||||
\note{Говорит В. Н. Копнин. Схема: 5-фолдовая стратифицированная
|
||||
перекрёстная проверка с повторами; вложенная — для честной настройки
|
||||
параметров; отложенная выборка — дополнительная проверка. Отдельно
|
||||
переставляли метки: качество упало до случайного уровня, значит сигнал
|
||||
не в «утечке».}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Метрики: что и зачем}
|
||||
\begin{panel}
|
||||
|
||||
\begin{tabular}{@{}p{3.9cm}p{9.6cm}@{}}
|
||||
\textbf{Метрика} & \textbf{Что показывает}\\[1mm]
|
||||
\textbf{Macro-F1} & Средняя F-мера по 34 классам: каждый персонаж важен одинаково, включая редких. Основная метрика.\\[1mm]
|
||||
\textbf{Доля правильных ответов} & Доля совпавших предсказаний; при дисбалансе обманчива: модель, всегда выбирающая частый класс, выглядит неплохо.\\[1mm]
|
||||
\textbf{Сбалансированная доля} & Средняя полнота по классам: находит ли модель редких персонажей.\\[1mm]
|
||||
\textbf{Тройка лучших (top-3)} & Истинный персонаж оказался в тройке наиболее вероятных ответов.\\[1mm]
|
||||
\textbf{MCC / каппа} & Согласие с поправкой на случайное угадывание.\\[1mm]
|
||||
\end{tabular}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
Ориентиры при 34 классах: случайный уровень macro-F1 $\approx$ 0,029,
|
||||
top-3 $\approx$ 0,088.
|
||||
\end{panel}
|
||||
\note{Говорит В. Н. Копнин. Главная метрика — macro-F1: она равно
|
||||
взвешивает все 34 класса, включая редких. Доля правильных ответов
|
||||
обманчива при дисбалансе. Смысл всех остальных метрик — словесный, без
|
||||
формул. Ориентиры случайного уровня уже озвучены — они пригодятся в
|
||||
части 3.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Гипотезы исследования}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.57\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{H1.} Функциональные слова, части речи и символьные
|
||||
n-граммы (последовательности соседних символов) различают
|
||||
персонажей выше случайного уровня.\\[0.7mm]
|
||||
\textbf{H2.} На коротких репликах части речи и символьные
|
||||
n-граммы устойчивее словарного богатства и читаемости.\\[0.7mm]
|
||||
\textbf{H3.} LSA по всем словам отражает тему, а LSA по частям
|
||||
речи и функциональным словам — стиль.\\[0.7mm]
|
||||
\textbf{H4.} Качество зависит от длины реплики.\\[0.7mm]
|
||||
\textbf{H5.} Ошибки группируются вокруг персонажей со сходным
|
||||
возрастом, полом, ролью или видом.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.41\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Как проверяем}
|
||||
\begin{itemize}
|
||||
\item H1 — сравнение моделей и поочерёдное исключение групп.
|
||||
\item H2 — исключение групп и анализ по длине.
|
||||
\item H3 — варианты LSA и контроль содержания.
|
||||
\item H4 — метрики по группам длины.
|
||||
\item H5 — анализ ошибок по метаданным.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит А. А. Аладьина. Гипотезы сформулированы до экспериментов.
|
||||
H1 и H2 проверяют, есть ли стилометрический сигнал; H3 — стиль или тему;
|
||||
H4 — влияние длины; H5 — социальные подгруппы. Термин «фолд» появится в
|
||||
методологической части: это часть разбиения при перекрёстной проверке.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
% ==================== БЛОК 3 ====================
|
||||
\begin{frame}{Часть 3\ \ ·\ \ Результаты}
|
||||
\begin{center}
|
||||
\begin{tcolorbox}[enhanced, width=0.86\paperwidth, colback=cream, opacityback=0.9,
|
||||
colframe=cream, boxrule=0pt, arc=2mm, halign=center, boxsep=3mm]
|
||||
{\large Часть 3}\\[1.5mm]
|
||||
{\Large\bfseries Результаты и интерпретация}\\[2mm]
|
||||
Оценки качества · вклад признаков · матрицы ошибок · ограничения · выводы\\[2.5mm]
|
||||
{\bfseries Докладчик: М.~В.~Савостиков}
|
||||
\end{tcolorbox}
|
||||
\end{center}
|
||||
\note{Часть\,3. Говорит М. В. Савостиков. План части: главные числа, что даёт
|
||||
качество, стиль или содержание, кого модель путает, как корректно читать
|
||||
оценки, ограничения и выводы.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Главный результат}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.47\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\begin{tabular}{@{}p{5cm}r@{}}
|
||||
\textbf{Мягкое голосование (5 фолдов)} & 0,304\\
|
||||
\textbf{ExtraTrees, char = 10\,000} & 0,276\\
|
||||
\textbf{Вложенный выбор} & 0,275\\
|
||||
\textbf{Отложенная выборка} & 0,333\\
|
||||
\textbf{Перестановка меток} & 0,029\\
|
||||
\end{tabular}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
Числа — macro-F1. Случайный уровень для 34 классов — 0,029;
|
||||
контроль перестановкой меток вышел на этот уровень, значит в извлеченных характеристиках есть информация.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.52\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth]{model_comparison.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Главная цифра — macro-F1 0,304 для мягкого
|
||||
голосования при 5-фолдовой проверке. Более консервативная оценка — 0,275:
|
||||
там конфигурация выбиралась честно, внутри обучающей части каждого фолда.
|
||||
На отложенной выборке 0,333, но это одно разбиение. Суть: персонажи
|
||||
действительно различимы.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Что добавляет качество}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.52\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.55\textheight, keepaspectratio]{feature_progression.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\begin{column}{0.47\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\begin{itemize}
|
||||
\item Только длина: 0,035.
|
||||
\item Ручные стилометрические признаки: 0,105.
|
||||
\item $+$ символьные и частеречные n-граммы: 0,182.
|
||||
\item $+$ content-LSA: 0,249.
|
||||
\item ExtraTrees, char = 10\,000: 0,276.
|
||||
\item Мягкое голосование: 0,304.
|
||||
\end{itemize}
|
||||
Итого: основной прирост — лексика, n-граммы и content-LSA;
|
||||
«чистый синтаксис» (функциональные слова и POS-LSA) сам по себе
|
||||
слаб — важный отрицательный результат.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Прогрессия: вручную посчитанные признаки
|
||||
дают 0,105, добавляются символьные n-граммы — 0,182, LSA по содержанию —
|
||||
0,249, лучшая одиночная модель — 0,276, ансамбль — 0,304. Обратный вывод
|
||||
важен тоже: признаки «чистого синтаксиса» сами по себе почти ничего не
|
||||
дают.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Стиль или содержание?}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.5\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.55\textheight, keepaspectratio]{topic_control.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
|
||||
\begin{itemize}
|
||||
\item Проверка: содержательные слова (существительные, глаголы,
|
||||
прилагательные, наречия) заменены нейтральным маркером.
|
||||
\item Исходный текст: 0,248; после маскирования: 0,113.
|
||||
\item Падение более чем вдвое, но остаток 0,113 — в 3,9 раза
|
||||
выше случайного уровня.
|
||||
\end{itemize}
|
||||
\textbf{Вывод контроля:} более половины качества даёт лексическое
|
||||
\emph{содержание}; стилевой сигнал сохраняется, но слабее
|
||||
тематического.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Ключевой эксперимент: заменяем все
|
||||
содержательные слова нейтральным маркером. Качество резко падает (0,248
|
||||
становится 0,113). Тем не менее остаток — почти в четыре раза выше
|
||||
случайного: какой-то стилевой компонент есть. Из этого следует главная
|
||||
оговорка работы: мы не можем утверждать, что модель выучила «чистый
|
||||
стиль» персонажей — основной вклад даёт тематический профиль.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Матрица ошибок: как читать}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.55\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.66\textheight, keepaspectratio]{confusion_top12.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\begin{column}{0.44\textwidth}
|
||||
\begin{panel}
|
||||
\begin{itemize}
|
||||
\item \textbf{Строки} — истинный персонаж, \textbf{столбцы} —
|
||||
предсказание; \textbf{диагональ} — верные ответы,
|
||||
вне диагонали — ошибки.
|
||||
\item Полная матрица 34$\times$34 нечитаема: показываем top-12.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
\textbf{Что видно:} крупная доля вне диагонали у многих строк —
|
||||
редкие классы «утекают» в популярные.
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Обязательно проговорить, как читать матрицу:
|
||||
строка — кто говорил на самом деле, столбец — кого предсказала модель,
|
||||
диагональ — верные ответы, всё вне диагонали — ошибки. Полные 34 строки не
|
||||
читаются на проекторе, поэтому top-12 + пары + полная матрица в приложении.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Кого модель путает чаще всего}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.52\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.45\textheight, keepaspectratio]{confusion_pairs.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\begin{column}{0.47\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Частые пары ошибок (число перепутанных реплик)}
|
||||
\begin{itemize}
|
||||
\item Sebastian $\to$ Abigail: 65
|
||||
\item Sebastian $\to$ Alex: 61
|
||||
\item Abigail $\to$ Sebastian: 59
|
||||
\item Abigail $\to$ Alex: 56
|
||||
\end{itemize}
|
||||
Основной клубок — молодые персонажи: Abigail, Alex, Sebastian,
|
||||
Haley, Sam. Один автор и общие сюжетные контексты делают сходство
|
||||
голосов ожидаемым. Это коллапс редких классов
|
||||
в классы-аттракторы.
|
||||
\end{panel}
|
||||
\vspace{-1mm}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Самые частые пары: Sebastian и Abigail
|
||||
путаются в обе стороны (65 и 59), Sebastian и Alex (61), Abigail и Alex
|
||||
(56). Ошибки сконцентрированы вокруг группы молодых персонажей. Важно: это
|
||||
стягивание предсказаний к популярным классам, а не доказательство, что эти
|
||||
персонажи «говорят одинаково» — хотя перекрёстные путаницы Sebastian —
|
||||
Abigail — Alex согласуются с их сходными масками.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Классы-аттракторы}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.47\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Классы-аттракторы}
|
||||
\begin{itemize}
|
||||
\item Alex и Abigail собирают заметную долю \emph{чужих}
|
||||
предсказаний: на разных повторах Abigail получает
|
||||
порядка 10–18\,\% всех ответов при истинной доле
|
||||
класса $\approx$ 6,9\,\%.
|
||||
\item Коррекция априорных вероятностей классов
|
||||
macro-F1 не улучшила и проблему не сняла.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.52\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.55\textheight, keepaspectratio]{hub_shares.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Проблема редких классов: модель
|
||||
предсказывает популярные классы слишком часто — Abigail получает десятки
|
||||
процентов всех ответов при истинной доле около 7\,\%. Простая коррекция
|
||||
приоров не помогла.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Персонажи: кто различается}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.47\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Лучшие и худшие по F-мере}
|
||||
\begin{itemize}
|
||||
\item Лучшие: Evelyn, Willy, Pam, Pierre, Leo.
|
||||
\item Худшие: Kent, Wizard, Penny, Mister Qi, Leah, Abigail.
|
||||
\item У редких классов — широкие интервалы оценок.
|
||||
\item Мягкое голосование улучшает часть редких классов
|
||||
(Wizard, Dwarf, Vincent), но Kent остаётся F1 = 0.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.52\textwidth}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.6\textheight, keepaspectratio]{per_class_f1.png}
|
||||
\end{imgbox}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Диаграмма: F-мера по каждому персонажу.
|
||||
Лучшие — Evelyn, Willy, Pam, Pierre, Leo; худшие — Kent, Wizard, Penny,
|
||||
Mister Qi, Leah, Abigail. Редкие классы имеют широкие доверительные
|
||||
интервалы: для 15 реплик разброс оценок велик. Ансамбль прибавляет
|
||||
отдельным редким классам, но Kent не находит никто.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Что означают полученные оценки}
|
||||
\begin{panel}
|
||||
|
||||
\begin{itemize}
|
||||
\item 0,30 macro-F1 при 34 классах — примерно \textbf{в 10 раз выше
|
||||
случайного уровня} 0,029: устойчивый сигнал различимости.
|
||||
\item Значение умеренное и это ожидаемо: реплики по $\sim$19 слов,
|
||||
дисбаланс классов и общие темы не позволяют «узнать» персонажа
|
||||
по одной фразе.
|
||||
\item Умеренность объясняется \emph{условиями задачи}, а не тем, что
|
||||
автор «не старался»: превышение случайного уровня как раз
|
||||
говорит о намеренно созданных речевых масках.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\begin{panel}
|
||||
|
||||
\begin{itemize}
|
||||
\item Мы решаем задачу «\emph{какой персонаж} говорит», а не
|
||||
«\emph{он или не он} писал»: автор один.
|
||||
\item И о «чистых» идиолектах говорить нельзя: основной вклад дают
|
||||
лексико-тематический профиль и символьные n-граммы, а после
|
||||
маскирования содержательных слов качество падает.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\note{Говорит М. В. Савостиков — ключевой слайд по интерпретации. Проговорить
|
||||
три пункта. (1) 0,304 при случайном 0,029 — это примерно десятикратное
|
||||
превышение, то есть сигнал реальный. (2) Абсолютная величина умеренная из-за
|
||||
коротких реплик, дисбаланса и общих тем — это свойства задачи, а не
|
||||
«автор не пытался»; напротив, различимость масок подтверждает, что автор
|
||||
сознательно делал разные голоса. (3) Мы определяем персонажа, а не
|
||||
авторскую принадлежность: автор один. И мы не утверждаем, что выучили
|
||||
«чистый стиль»: при маскировании содержания качество сильно падает.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Ограничения}
|
||||
\begin{columns}[T]
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Данные}
|
||||
\begin{itemize}
|
||||
\item Один корпус, одна игра, один автор; внешняя валидация
|
||||
не проводилась.
|
||||
\item Короткие реплики и сильный дисбаланс классов.
|
||||
\item Ручная фильтрация корпуса невоспроизводима.
|
||||
\item Нет идентификаторов сцен и диалогов.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\begin{column}{0.49\textwidth}
|
||||
\begin{panel}
|
||||
\textbf{Методология}
|
||||
\begin{itemize}
|
||||
\item Выбор признаков и ансамбля опирался на прошлые серии;
|
||||
вложенная проверка снижает этот риск, но не устраняет.
|
||||
\item Основной вклад — содержательные слова и тема.
|
||||
\item Простая коррекция приоров не помогла редким классам.
|
||||
\item Объединение через мета-классификатор переобучается на малой выборке.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\end{column}
|
||||
\end{columns}
|
||||
\note{Говорит М. В. Савостиков. Ограничения делим на две группы: свойства
|
||||
данных (один корпус и один автор, дисбаланс, короткие реплики,
|
||||
невоспроизводимая ручная фильтрация, нет сцен) и свойства метода
|
||||
(частичный выбор конфигураций по прошлым сериям, тематический сигнал в
|
||||
признаках, неудачи простых калибровок). Честное перечисление — часть
|
||||
верификации, а не признание провала.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Выводы}
|
||||
\begin{panel}
|
||||
\begin{itemize}
|
||||
\item \textbf{Персонажи статистически различимы:} лучшая модель — 0,304
|
||||
macro-F1, консервативная оценка — 0,275; случайный уровень — 0,029.
|
||||
\item \textbf{Основной сигнал — лексико-тематический:} символьные
|
||||
n-граммы и content-LSA дают основной прирост; функциональные слова
|
||||
и частеречная LSA сами по себе слабы.
|
||||
\item \textbf{После замены содержательных слов} качество падает с 0,248
|
||||
до 0,113: содержание доминирует, но стилевой сигнал сохраняется.
|
||||
\item \textbf{Главная проблема — редкие классы и классы-аттракторы;}
|
||||
мягкое голосование помогает, но не решает её полностью.
|
||||
\item \textbf{Корректная формулировка результата:} различимы речевые
|
||||
маски по лексико-тематическому профилю; утверждение о «чистом
|
||||
стиле» требует внешней валидации и тематического контроля.
|
||||
\end{itemize}
|
||||
\end{panel}
|
||||
\note{Говорит М. В. Савостиков. Пять итоговых тезисов. Финальную формулировку
|
||||
оставить точной: мы показали статистическую различимость речевых масок по
|
||||
лексико-тематическому профилю; для более сильных утверждений — разные идиолекты,
|
||||
применимость как инструмент — нужны внешний корпус, тематический контроль
|
||||
и разметка сцен.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
% ==================== ПРИЛОЖЕНИЕ ====================
|
||||
\begin{frame}{Приложение: полная матрица ошибок 34$\times$34}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.8\textheight, keepaspectratio]{confusion_full34.png}
|
||||
\end{imgbox}
|
||||
\note{Приложение для вопросов и раздаточного материала. Если спросят про
|
||||
конкретную пару персонажей — искать её в полной матрице: строки сортированы
|
||||
по размеру класса, цвет кодирует долю строки. Кто говорит: отвечает
|
||||
М. В. Савостиков (можно подключить В. Н. Копнина по вопросам валидации).}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}{Спасибо за внимание}
|
||||
\begin{imgbox}
|
||||
\includegraphics[width=\linewidth, height=0.8\textheight, keepaspectratio]{meme.png}
|
||||
\end{imgbox}
|
||||
\begin{tikzpicture}[remember picture, overlay]
|
||||
\node[anchor=south east, xshift=-2.5mm, yshift=1.5mm]
|
||||
at (current page.south east)
|
||||
{\includegraphics[height=0.65cm]{chicken.png}};
|
||||
\end{tikzpicture}
|
||||
\note{Финальный слайд; мем как пасхалка. Говорят по очереди все:
|
||||
А. А. Аладьина благодарит и передаёт слово для вопросов; по вопросам
|
||||
корпуса отвечает Аладьина, по методологии — Копнин, по результатам —
|
||||
Савостиков.}
|
||||
\slidefooter
|
||||
\end{frame}
|
||||
|
||||
\end{document}
|
||||
263
lab1/presentation/make_slide_figures.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Графики для презентации: матрицы ошибок, сравнение моделей, ошибки по классам."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from matplotlib.colors import LinearSegmentedColormap
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "presentation" / "generated"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
OOF = ROOT / "results" / "final" / "finalists" / "voting_et_char_lean" / "oof_predictions.csv"
|
||||
METRICS = ROOT / "FINAL_RESULTS.csv"
|
||||
|
||||
plt.rcParams.update({
|
||||
"font.family": "DejaVu Sans",
|
||||
"font.size": 13,
|
||||
"axes.titlesize": 18,
|
||||
"axes.labelsize": 13,
|
||||
"savefig.facecolor": "#F8F1DC",
|
||||
"figure.facecolor": "#F8F1DC",
|
||||
})
|
||||
|
||||
CREAM = "#F8F1DC"
|
||||
DARK = "#203A2B"
|
||||
GREEN = "#4D7C4A"
|
||||
BROWN = "#8B5A2B"
|
||||
RED = "#B23A2E"
|
||||
GOLD = "#D8A23A"
|
||||
|
||||
CMAP = LinearSegmentedColormap.from_list("stardew_blue", ["#FFFDF4", "#BFD9C0", "#2E6B57", "#173A34"])
|
||||
|
||||
|
||||
def load_oof() -> pd.DataFrame:
|
||||
frame = pd.read_csv(OOF)
|
||||
frame["y_true"] = frame["y_true"].astype(str)
|
||||
frame["y_pred"] = frame["y_pred"].astype(str)
|
||||
return frame
|
||||
|
||||
|
||||
def top_confusions(frame: pd.DataFrame, n: int = 18) -> pd.Series:
|
||||
errors = frame[frame["y_true"] != frame["y_pred"]]
|
||||
pairs = errors.groupby(["y_true", "y_pred"]).size().sort_values(ascending=False).head(n)
|
||||
pairs.index = [f"{a} → {b}" for a, b in pairs.index]
|
||||
return pairs
|
||||
|
||||
|
||||
def confusion_top12(frame: pd.DataFrame) -> None:
|
||||
labels = frame["y_true"].value_counts().head(12).index.tolist()
|
||||
matrix = pd.crosstab(frame["y_true"], frame["y_pred"]).reindex(index=labels, fill_value=0)
|
||||
shown = matrix[[c for c in labels if c in matrix.columns]].copy()
|
||||
shown["прочие"] = matrix.drop(columns=[c for c in labels if c in matrix.columns]).sum(axis=1)
|
||||
shown = shown.div(shown.sum(axis=1), axis=0) * 100
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12.5, 7.4))
|
||||
image = ax.imshow(shown.to_numpy(), cmap=CMAP, vmin=0, vmax=60, aspect="auto")
|
||||
ax.set_xticks(np.arange(shown.shape[1]))
|
||||
ax.set_yticks(np.arange(shown.shape[0]))
|
||||
ax.set_xticklabels(shown.columns, rotation=42, ha="right", fontsize=10)
|
||||
ax.set_yticklabels(shown.index, fontsize=11)
|
||||
ax.set_xlabel("Предсказанный персонаж", fontsize=14, color=DARK)
|
||||
ax.set_ylabel("Истинный персонаж", fontsize=14, color=DARK)
|
||||
ax.set_title("Матрица ошибок: 12 самых частых персонажей, % строки", fontsize=18, color=DARK, pad=12)
|
||||
for row in range(shown.shape[0]):
|
||||
for col in range(shown.shape[1]):
|
||||
value = shown.iloc[row, col]
|
||||
if value >= 0.5:
|
||||
color = "white" if value > 32 else DARK
|
||||
ax.text(col, row, f"{value:.0f}", ha="center", va="center", fontsize=9, color=color)
|
||||
cbar = fig.colorbar(image, ax=ax, fraction=0.035, pad=0.02)
|
||||
cbar.set_label("% строки", color=DARK)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "confusion_top12.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def confusion_full(frame: pd.DataFrame) -> None:
|
||||
labels = sorted(frame["y_true"].unique())
|
||||
matrix = pd.crosstab(frame["y_true"], frame["y_pred"]).reindex(index=labels, columns=labels, fill_value=0)
|
||||
matrix = matrix.div(matrix.sum(axis=1), axis=0) * 100
|
||||
fig, ax = plt.subplots(figsize=(13.5, 11.5))
|
||||
image = ax.imshow(matrix.to_numpy(), cmap=CMAP, vmin=0, vmax=35, aspect="auto")
|
||||
ax.set_xticks(np.arange(len(labels)))
|
||||
ax.set_yticks(np.arange(len(labels)))
|
||||
ax.set_xticklabels(labels, rotation=90, fontsize=5.5)
|
||||
ax.set_yticklabels(labels, fontsize=5.5)
|
||||
ax.set_xlabel("Предсказанный персонаж", fontsize=12, color=DARK)
|
||||
ax.set_ylabel("Истинный персонаж", fontsize=12, color=DARK)
|
||||
ax.set_title("Полная матрица ошибок, % строки (раздаточный материал)", fontsize=16, color=DARK)
|
||||
fig.colorbar(image, ax=ax, fraction=0.03, pad=0.015).set_label("% строки")
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "confusion_full34.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def pairs_chart(frame: pd.DataFrame) -> None:
|
||||
pairs = top_confusions(frame, 18).sort_values()
|
||||
fig, ax = plt.subplots(figsize=(12.6, 7.2))
|
||||
colors = [RED if i >= len(pairs) - 5 else BROWN for i in range(len(pairs))]
|
||||
ax.barh(pairs.index, pairs.values, color=colors, edgecolor="white", linewidth=0.6)
|
||||
for index, value in enumerate(pairs.values):
|
||||
ax.text(value + 0.6, index, str(value), va="center", fontsize=10, color=DARK)
|
||||
ax.set_xlabel("Число ошибок за 3 повтора", fontsize=13, color=DARK)
|
||||
ax.set_title("Самые частые пары «истинный → предсказанный»", fontsize=18, color=DARK)
|
||||
ax.tick_params(axis="y", labelsize=10)
|
||||
ax.grid(axis="x", alpha=0.2, color=DARK)
|
||||
ax.set_xlim(0, pairs.max() * 1.16)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "confusion_pairs.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def per_class_chart(frame: pd.DataFrame) -> None:
|
||||
first = frame[frame["repeat"] == frame["repeat"].min()]
|
||||
labels = sorted(first["y_true"].unique())
|
||||
rows = []
|
||||
for label in labels:
|
||||
mask = first["y_true"] == label
|
||||
tp = int(((first["y_true"] == label) & (first["y_pred"] == label)).sum())
|
||||
fp = int(((first["y_true"] != label) & (first["y_pred"] == label)).sum())
|
||||
fn = int(((first["y_true"] == label) & (first["y_pred"] != label)).sum())
|
||||
precision = tp / (tp + fp) if tp + fp else 0.0
|
||||
recall = tp / (tp + fn) if tp + fn else 0.0
|
||||
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
||||
rows.append((label, f1, int(mask.sum())))
|
||||
table = pd.DataFrame(rows, columns=["class", "f1", "support"]).sort_values("f1")
|
||||
worst = table.head(12).sort_values("f1", ascending=False)
|
||||
best = table.tail(12).sort_values("f1")
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(13.2, 7.0), gridspec_kw={"width_ratios": [1, 1]})
|
||||
axes[0].barh(worst["class"], worst["f1"], color=RED, edgecolor="white")
|
||||
axes[0].set_title("Худшие 12 персонажей", fontsize=15, color=DARK)
|
||||
axes[0].set_xlim(0, 0.72)
|
||||
axes[0].grid(axis="x", alpha=0.2)
|
||||
for index, (value, support) in enumerate(zip(worst["f1"], worst["support"])):
|
||||
axes[0].text(value + 0.008, index, f"{value:.2f} (n={support})", va="center", fontsize=9, color=DARK)
|
||||
axes[1].barh(best["class"], best["f1"], color=GREEN, edgecolor="white")
|
||||
axes[1].set_title("Лучшие 12 персонажей", fontsize=15, color=DARK)
|
||||
axes[1].set_xlim(0, 0.72)
|
||||
axes[1].grid(axis="x", alpha=0.2)
|
||||
for index, (value, support) in enumerate(zip(best["f1"], best["support"])):
|
||||
axes[1].text(value + 0.008, index, f"{value:.2f} (n={support})", va="center", fontsize=9, color=DARK)
|
||||
fig.suptitle("F-мера по персонажам: мягкое голосование, первый повтор", fontsize=17, color=DARK, y=0.99)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "per_class_f1.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def model_comparison() -> None:
|
||||
table = pd.read_csv(METRICS)
|
||||
wanted = [
|
||||
("voting_et_char_lean", "Мягкое голосование"),
|
||||
("et_char10000", "ExtraTrees, char=10000"),
|
||||
("nested_feature_selection", "Вложенный выбор"),
|
||||
("et_full", "ExtraTrees, полный"),
|
||||
("logreg_lean_content300", "Logreg, lean + content"),
|
||||
("stacking", "Стековое объединение"),
|
||||
]
|
||||
rows = []
|
||||
for key, label in wanted:
|
||||
row = table[table["label"] == key]
|
||||
if not row.empty:
|
||||
rows.append((label, float(row.iloc[0]["macro_f1_mean"]), float(row.iloc[0]["macro_f1_std"])))
|
||||
rows.append(("Случайный уровень", 0.029, 0.0))
|
||||
frame = pd.DataFrame(rows, columns=["label", "mean", "std"]).sort_values("mean")
|
||||
fig, ax = plt.subplots(figsize=(12.4, 6.8))
|
||||
colors = [GOLD if label == "Мягкое голосование" else (RED if label == "Случайный уровень" else GREEN) for label in frame["label"]]
|
||||
ax.barh(frame["label"], frame["mean"], xerr=frame["std"], color=colors, edgecolor="white", capsize=4)
|
||||
for index, (value, std) in enumerate(zip(frame["mean"], frame["std"])):
|
||||
offset = value + max(std, 0.0) + 0.008
|
||||
ax.text(offset, index, f"{value:.3f}", va="center", fontsize=11, color=DARK)
|
||||
ax.set_xlim(0, 0.38)
|
||||
ax.set_xlabel("Макро-усреднённая F-мера", fontsize=13, color=DARK)
|
||||
ax.set_title("Сравнение моделей: среднее и стандартное отклонение", fontsize=18, color=DARK)
|
||||
ax.grid(axis="x", alpha=0.2)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "model_comparison.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def feature_progression() -> None:
|
||||
labels = [
|
||||
"Только длина",
|
||||
"Ручные признаки",
|
||||
"Ручные + char/POS",
|
||||
"+ content-LSA",
|
||||
"ExtraTrees, char=10000",
|
||||
"Мягкое голосование",
|
||||
]
|
||||
values = [0.035, 0.105, 0.182, 0.249, 0.276, 0.304]
|
||||
fig, ax = plt.subplots(figsize=(12.4, 6.6))
|
||||
colors = [BROWN, BROWN, GREEN, GREEN, GOLD, RED]
|
||||
ax.bar(labels, values, color=colors, edgecolor="white")
|
||||
for index, value in enumerate(values):
|
||||
ax.text(index, value + 0.008, f"{value:.3f}", ha="center", fontsize=12, color=DARK)
|
||||
ax.set_ylim(0, 0.35)
|
||||
ax.set_ylabel("Макро-усреднённая F-мера", fontsize=13, color=DARK)
|
||||
ax.set_title("Что добавляет качество", fontsize=18, color=DARK)
|
||||
ax.tick_params(axis="x", rotation=18, labelsize=11)
|
||||
ax.grid(axis="y", alpha=0.2)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "feature_progression.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def topic_control() -> None:
|
||||
labels = ["Исходный\nExtraTrees", "Маскированный\nExtraTrees", "Исходный\nLogreg", "Маскированный\nLogreg"]
|
||||
values = [0.2483, 0.1129, 0.1467, 0.1067]
|
||||
fig, ax = plt.subplots(figsize=(10.8, 6.2))
|
||||
colors = [GREEN, RED, GREEN, RED]
|
||||
ax.bar(labels, values, color=colors, edgecolor="white")
|
||||
for index, value in enumerate(values):
|
||||
ax.text(index, value + 0.007, f"{value:.3f}", ha="center", fontsize=12, color=DARK)
|
||||
ax.set_ylim(0, 0.30)
|
||||
ax.set_ylabel("Макро-усреднённая F-мера", fontsize=13, color=DARK)
|
||||
ax.set_title("Контроль содержания: замена содержательных слов на CONTENT", fontsize=17, color=DARK)
|
||||
ax.tick_params(axis="x", labelsize=11)
|
||||
ax.grid(axis="y", alpha=0.2)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "topic_control.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def hub_shares(frame: pd.DataFrame) -> None:
|
||||
true = frame["y_true"].value_counts(normalize=True)
|
||||
pred = frame["y_pred"].value_counts(normalize=True)
|
||||
labels = pred.head(10).index.tolist()
|
||||
x = np.arange(len(labels))
|
||||
fig, ax = plt.subplots(figsize=(12.4, 6.2))
|
||||
ax.bar(x - 0.2, [true.get(label, 0) * 100 for label in labels], width=0.4, label="Истинная доля", color=GREEN)
|
||||
ax.bar(x + 0.2, [pred.get(label, 0) * 100 for label in labels], width=0.4, label="Доля предсказаний", color=RED)
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(labels, rotation=25, ha="right", fontsize=11)
|
||||
ax.set_ylabel("% реплик", fontsize=13, color=DARK)
|
||||
ax.set_title("Классы-аттракторы: кого модель выбирает слишком часто", fontsize=17, color=DARK)
|
||||
ax.legend(frameon=False)
|
||||
ax.grid(axis="y", alpha=0.2)
|
||||
fig.tight_layout()
|
||||
fig.savefig(OUT / "hub_shares.png", dpi=220, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
frame = load_oof()
|
||||
confusion_top12(frame)
|
||||
confusion_full(frame)
|
||||
pairs_chart(frame)
|
||||
per_class_chart(frame)
|
||||
model_comparison()
|
||||
feature_progression()
|
||||
topic_control()
|
||||
hub_shares(frame)
|
||||
print("Готово:", OUT)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
3
lab1/presentation/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
python-pptx>=1.0,<2
|
||||
Pillow>=10
|
||||
matplotlib>=3.8
|
||||
251
lab1/presentation/rework_block1.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Переработка первого блока текущей презентации.
|
||||
|
||||
Скрипт не пересобирает весь файл: он читает уже отредактированный PPTX,
|
||||
удаляет старые слайды первого блока и вставляет новую академическую
|
||||
последовательность: задача -> строгая постановка -> корпус -> очистка ->
|
||||
характеристики.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.util import Inches
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
if str(HERE) not in sys.path:
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from build_deck import ( # noqa: E402
|
||||
DARK,
|
||||
GEN,
|
||||
GREEN_DARK,
|
||||
RED,
|
||||
add_background,
|
||||
add_bullets,
|
||||
add_chicken,
|
||||
add_footer,
|
||||
add_image_fit,
|
||||
add_notes,
|
||||
add_panel,
|
||||
add_table,
|
||||
add_text,
|
||||
add_title_bar,
|
||||
)
|
||||
|
||||
|
||||
PPTX = HERE / "stardew_stylometry.pptx"
|
||||
BLOCK = "Блок 1 · Корпус"
|
||||
NARRATOR = "А. А. Аладьина"
|
||||
|
||||
|
||||
def delete_slide(prs, index: int) -> None:
|
||||
slide_id = prs.slides._sldIdLst[index]
|
||||
rel_id = slide_id.rId
|
||||
prs.part.drop_rel(rel_id)
|
||||
del prs.slides._sldIdLst[index]
|
||||
|
||||
|
||||
def move_slide(prs, old_index: int, new_index: int) -> None:
|
||||
slide_id = prs.slides._sldIdLst[old_index]
|
||||
prs.slides._sldIdLst.remove(slide_id)
|
||||
prs.slides._sldIdLst.insert(new_index, slide_id)
|
||||
|
||||
|
||||
def base_slide(prs, title: str, subtitle: str | None = None):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_background(slide)
|
||||
add_title_bar(slide, title, NARRATOR, subtitle)
|
||||
add_footer(slide, BLOCK, 0)
|
||||
add_chicken(slide)
|
||||
return slide
|
||||
|
||||
|
||||
def slide_task(prs):
|
||||
slide = base_slide(prs, "Решаемая задача")
|
||||
add_panel(slide, 0.45, 1.35, 6.15, 5.55)
|
||||
add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [
|
||||
"Дано: 2005 коротких реплик, 34 персонажа, один автор.",
|
||||
"Требуется: по одной реплике определить персонажа.",
|
||||
"Класс — имя персонажа; один объект относится ровно к одному классу.",
|
||||
"Реплики короткие: в среднем 19 слов, медиана 17.",
|
||||
"Классы неравномерны: от 15 до 139 реплик.",
|
||||
], size=16, title="Постановка")
|
||||
add_panel(slide, 6.8, 1.35, 6.05, 5.55)
|
||||
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
|
||||
"Проверяется, различимы ли речевые маски персонажей статистически.",
|
||||
"Это не авторская атрибуция: все реплики написаны одним автором.",
|
||||
"Ошибки между персонажами ожидаемы: общий автор, общие темы и контексты.",
|
||||
"Практическая ценность — методика стилометрии для коротких текстов.",
|
||||
"Ограничение: один корпус, одна игра, один автор.",
|
||||
], size=16, title="Что именно проверяется")
|
||||
add_notes(slide, "Рассказчик 1: Аладьина Анна Андреевна. Сформулировать задачу без рекламных формулировок: есть корпус коротких реплик, нужно предсказать персонажа. Сразу отделить это от авторской атрибуции: автор один, задача — различимость речевых масок.")
|
||||
|
||||
|
||||
def slide_formal(prs):
|
||||
slide = base_slide(prs, "Строгая постановка задачи", "Формальная запись и протокол оценки")
|
||||
add_panel(slide, 0.45, 1.35, 7.55, 5.55)
|
||||
add_bullets(slide, 0.65, 1.55, 7.15, 5.1, [
|
||||
"D = {(x_i, y_i)}, i = 1..n; n = 2005.",
|
||||
"x_i — одна реплика (короткий текст).",
|
||||
"y_i ∈ Y, |Y| = 34 — имя персонажа.",
|
||||
"φ(x_i) ∈ R^d — вектор признаков реплики.",
|
||||
"f_θ: R^d → R^34 — классификатор с параметрами θ.",
|
||||
"ŷ_i = argmax_{c ∈ Y} f_θ(φ(x_i))_c — предсказание.",
|
||||
], size=16, title="Дано")
|
||||
add_panel(slide, 8.2, 1.35, 4.65, 5.55)
|
||||
add_bullets(slide, 8.38, 1.55, 4.3, 5.1, [
|
||||
"Обучение: минимизация перекрёстной энтропии + L2-регуляризация.",
|
||||
"Веса классов: balanced, чтобы редкие персонажи не игнорировались.",
|
||||
"Оценка: macro-F1 = (1/|Y|) Σ_c 2P_cR_c / (P_c + R_c).",
|
||||
"Схема: стратифицированная 5-блочная CV, повторы, вложенная настройка.",
|
||||
"Случайный уровень: 0,029; top-3: 0,088.",
|
||||
"Один объект — одна реплика; обучение и оценка разделены.",
|
||||
], size=14, title="Критерий и проверка")
|
||||
add_notes(slide, "Рассказчик 1. Это ключевой слайд строгой постановки. Проговорить обозначения: объект — реплика, метка — персонаж, признаковое описание — φ(x), классификатор — f. Отдельно сказать, что оцениваем не accuracy, а macro-F1, потому что классов 34 и они неравномерны.")
|
||||
|
||||
|
||||
def slide_corpus(prs):
|
||||
slide = base_slide(prs, "Корпус: сбор и разметка")
|
||||
add_panel(slide, 0.45, 1.35, 7.05, 5.55)
|
||||
add_text(slide, 0.68, 1.55, 6.6, 0.3, "1. Сбор", size=17, bold=True, color=GREEN_DARK)
|
||||
add_bullets(slide, 0.68, 1.9, 6.55, 1.35, [
|
||||
"Реплики извлечены из игровых диалогов Stardew Valley.",
|
||||
"Единица анализа — одна реплика; всего 2005 объектов.",
|
||||
"Корпус охватывает 34 персонажа; все тексты на английском языке.",
|
||||
], size=14)
|
||||
add_text(slide, 0.68, 3.2, 6.6, 0.3, "2. Разметка", size=17, bold=True, color=GREEN_DARK)
|
||||
add_bullets(slide, 0.68, 3.55, 6.55, 1.35, [
|
||||
"Метка — имя говорящего персонажа; один объект — один класс.",
|
||||
"Ручная проверка соответствия реплики и персонажа.",
|
||||
"Метаданные (пол, возраст, вид) хранятся отдельно и не подаются модели.",
|
||||
], size=14)
|
||||
add_text(slide, 0.68, 4.85, 6.6, 0.3, "3. Ограничения", size=17, bold=True, color=GREEN_DARK)
|
||||
add_bullets(slide, 0.68, 5.18, 6.55, 1.4, [
|
||||
"Нет идентификаторов сцен и диалогов.",
|
||||
"Классы неравномерны: 15–139 реплик.",
|
||||
"Часть коротких и общих фраз удалена вручную; правила не сохранились.",
|
||||
], size=14)
|
||||
add_panel(slide, 7.7, 1.35, 5.15, 5.55)
|
||||
add_image_fit(slide, GEN / "characters.png", 7.85, 1.5, 4.85, 3.9, panel=False)
|
||||
add_text(slide, 7.9, 5.55, 4.75, 1.1, "34 персонажа: подростки, взрослые, пожилые, дети и нелюди. Все реплики написаны одним разработчиком.", size=13, color=DARK, align=PP_ALIGN.CENTER)
|
||||
add_notes(slide, "Рассказчик 1. Описать три шага: сбор, разметка, ограничения. Не утверждать, что корпус идеален: ручная фильтрация и отсутствие сцен — существенные ограничения. Метаданные не являются признаками.")
|
||||
|
||||
|
||||
def slide_cleaning(prs):
|
||||
slide = base_slide(prs, "Очистка и шум")
|
||||
add_panel(slide, 0.45, 1.35, 6.1, 5.55)
|
||||
add_bullets(slide, 0.65, 1.55, 5.7, 5.1, [
|
||||
"Нормализация Unicode: многоточия, кавычки, тире, неразрывные пробелы.",
|
||||
"Плейсхолдеры <PLAYER>, <FARM> и другие заменены нейтральными словами.",
|
||||
"Количество плейсхолдеров сохранено отдельным признаком.",
|
||||
"Токенизация, предложения и POS-разметка — NLTK; Universal POS.",
|
||||
"Пунктуация, регистр и стоп-слова сохранены: это стилометрический сигнал.",
|
||||
], size=15, title="Что сделали")
|
||||
add_panel(slide, 6.75, 1.35, 6.1, 5.55)
|
||||
add_bullets(slide, 6.95, 1.55, 5.7, 5.1, [
|
||||
"Шум: дубликаты, технические артефакты, общие короткие формулы.",
|
||||
"Ручная фильтрация удалила короткие и общие фразы; правила не сохранены.",
|
||||
"В текущем CSV 0 точных дублей; 13 пар с косинусной близостью ≥ 0,8 — внутри класса.",
|
||||
"Нет сцен и диалогов: нельзя учесть корреляцию реплик внутри сцены.",
|
||||
"Разметка только по имени; ручной стилистической разметки нет.",
|
||||
], size=15, title="Что считаем шумом")
|
||||
add_notes(slide, "Рассказчик 1. Объяснить, что очистка не должна удалять стиль: пунктуация, регистр и служебные слова сохранены. Ручная фильтрация — ограничение; почти-дубликаты не пересекают классы, поэтому утечки через них нет. Отметить отсутствие сцен.")
|
||||
|
||||
|
||||
def slide_class_stats(prs):
|
||||
slide = base_slide(prs, "Характеристики корпуса: классы", "Распределение реплик по персонажам")
|
||||
add_panel(slide, 0.4, 1.35, 7.2, 5.55)
|
||||
add_image_fit(slide, GEN.parent / "images" / "replics-count.png", 0.5, 1.45, 7.0, 5.35)
|
||||
add_panel(slide, 7.8, 1.35, 5.1, 5.55)
|
||||
rows = [
|
||||
["Показатель", "Значение"],
|
||||
["Реплик", "2005"],
|
||||
["Персонажей", "34"],
|
||||
["Максимум", "Abigail, 139"],
|
||||
["Минимум", "Mister Qi, 15"],
|
||||
["Разброс классов", "15–139"],
|
||||
["Случайный уровень", "2,9%"],
|
||||
]
|
||||
add_table(slide, 8.0, 1.55, 4.7, 3.2, rows, col_widths=[2.4, 2.3], font_size=12)
|
||||
add_bullets(slide, 8.0, 4.9, 4.7, 1.8, [
|
||||
"Дисбаланс почти десятикратный.",
|
||||
"Поэтому основная метрика — macro-F1, а не доля правильных ответов.",
|
||||
], size=13)
|
||||
add_notes(slide, "Рассказчик 1. Показать дисбаланс: максимум Abigail 139, минимум Mister Qi 15. При 34 классах случайное угадывание — 2,9%. Отсюда macro-F1 и веса классов; accuracy может обманывать.")
|
||||
|
||||
|
||||
def slide_length_stats(prs):
|
||||
slide = base_slide(prs, "Характеристики корпуса: длина реплик", "Медианная длина предложения в символах")
|
||||
add_panel(slide, 0.4, 1.35, 7.2, 5.55)
|
||||
add_image_fit(slide, GEN.parent / "images" / "median.png", 0.5, 1.45, 7.0, 5.35)
|
||||
add_panel(slide, 7.8, 1.35, 5.1, 5.55)
|
||||
rows = [
|
||||
["Показатель", "Значение"],
|
||||
["Средняя длина", "19,1 слова"],
|
||||
["Медиана", "17 слов"],
|
||||
["Диапазон", "3–78 слов"],
|
||||
["Медиана в символах", "52–114"],
|
||||
]
|
||||
add_table(slide, 8.0, 1.55, 4.7, 2.5, rows, col_widths=[2.4, 2.3], font_size=12)
|
||||
add_bullets(slide, 8.0, 4.25, 4.7, 2.35, [
|
||||
"Короткие реплики дают мало стилометрических свидетельств.",
|
||||
"Длина — сильный признак, но может стать прокси персонажа.",
|
||||
"Её вклад проверяется отдельно и по группам длины.",
|
||||
], size=13)
|
||||
add_notes(slide, "Рассказчик 1. Числа у подписей — медианная длина предложения в символах. Контраст Vincent/Jas и Sandy/Wizard. Длина — законный, но опасный признак: она может отражать не стиль, а привычную длину реплик персонажа.")
|
||||
|
||||
|
||||
def renumber(prs) -> None:
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
for shape in slide.shapes:
|
||||
if not shape.has_text_frame:
|
||||
continue
|
||||
text = shape.text_frame.text.strip()
|
||||
if text.isdigit() and shape.top > Inches(6.9) and shape.width < Inches(0.8):
|
||||
shape.text_frame.text = str(index)
|
||||
|
||||
|
||||
def replace_narrators(prs) -> None:
|
||||
mapping = {
|
||||
"Рассказчик 2": "В. Н. Копнин",
|
||||
"Рассказчик 3": "М. В. Савостиков",
|
||||
}
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame and shape.text_frame.text.strip() in mapping:
|
||||
shape.text_frame.text = mapping[shape.text_frame.text.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
prs = Presentation(PPTX)
|
||||
# Сначала создаём новые слайды в конце, чтобы их части получили
|
||||
# уникальные имена и не конфликтовали с удаляемыми слайдами.
|
||||
base = len(prs.slides)
|
||||
slide_task(prs)
|
||||
slide_formal(prs)
|
||||
slide_corpus(prs)
|
||||
slide_cleaning(prs)
|
||||
slide_class_stats(prs)
|
||||
slide_length_stats(prs)
|
||||
|
||||
# Переносим новые слайды сразу после титульного.
|
||||
for offset in range(6):
|
||||
move_slide(prs, base + offset, 1 + offset)
|
||||
|
||||
# Теперь старые слайды первого блока находятся на позициях 7–12.
|
||||
for _ in range(6):
|
||||
delete_slide(prs, 7)
|
||||
|
||||
replace_narrators(prs)
|
||||
renumber(prs)
|
||||
prs.save(PPTX)
|
||||
print(f"Готово: {PPTX}; слайдов: {len(prs.slides)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
lab1/presentation/stardew_stylometry.pdf
Normal file
BIN
lab1/presentation/stardew_stylometry.pptx
Normal file
BIN
lab1/report/REPORT.docx
Normal file
652
lab1/report/REPORT.md
Normal file
@@ -0,0 +1,652 @@
|
||||
---
|
||||
title: "Отчёт по лабораторной работе №1"
|
||||
subtitle: "Классификация текстов на основе стилометрических характеристик"
|
||||
author: "А. А. Аладьина, В. Н. Копнин, М. В. Савостиков"
|
||||
date: "01.11.2026"
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Аннотация
|
||||
|
||||
Работа посвящена стилометрической классификации реплик персонажей игры
|
||||
Stardew Valley. Корпус содержит 2005 реплик, принадлежащих 34 персонажам;
|
||||
все реплики написаны одним автором. Проверяется, различимы ли речевые маски
|
||||
персонажей по отдельной реплике, если исключить авторскую атрибуцию как
|
||||
таковую.
|
||||
|
||||
Построены 454 ручных стилометрических признака, символьные и частеречные
|
||||
n-граммы, а также несколько вариантов латентно-семантического анализа.
|
||||
Сравнивались логистическая регрессия, линейный метод опорных векторов,
|
||||
наивный байесовский классификатор, случайный лес, сверхслучайные деревья,
|
||||
метод ближайших соседей и дельта Барроуза. Использована повторная
|
||||
стратифицированная перекрёстная проверка, вложенная настройка параметров и
|
||||
отдельная отложенная выборка.
|
||||
|
||||
Лучший наблюдаемый результат — мягкое голосование трёх моделей: macro-F1
|
||||
около **0,304** при перекрёстной проверке и **0,333** на отложенной выборке.
|
||||
Консервативная оценка при вложенном выборе конфигурации — **0,275**.
|
||||
Случайный уровень для 34 классов — **0,029**. Основной вклад дают
|
||||
лексико-тематические признаки и символьные n-граммы; после замены
|
||||
содержательных слов нейтральным маркером качество падает с 0,248 до 0,113.
|
||||
Следовательно, результат корректно интерпретировать как различимость речевых
|
||||
масок по лексико-тематическому профилю, а не как доказательство «чистого»
|
||||
идиолекта.
|
||||
|
||||
# 1. Постановка задачи
|
||||
|
||||
## 1.1. Направление исследования
|
||||
|
||||
Направление работы — классификация текстов по стилометрическим
|
||||
характеристикам. В отличие от классической авторской атрибуции, все тексты
|
||||
корпуса принадлежат одному автору. Исследуется другой вопрос:
|
||||
|
||||
> различаются ли речевые маски 34 персонажей по стилометрическим признакам
|
||||
> отдельных реплик, если автор у них общий?
|
||||
|
||||
Такая постановка позволяет проверить, насколько автор намеренно создаёт
|
||||
разные голоса персонажей, и какие признаки несут этот сигнал.
|
||||
|
||||
## 1.2. Гипотезы
|
||||
|
||||
| Обозначение | Гипотеза |
|
||||
|---|---|
|
||||
| H1 | Функциональные слова, части речи и символьные n-граммы различают персонажей выше случайного уровня |
|
||||
| H2 | На коротких репликах частеречные и символьные признаки устойчивее словарного богатства и читаемости |
|
||||
| H3 | LSA по всем словам отражает тему, а LSA по частям речи и функциональным словам — стиль |
|
||||
| H4 | Качество зависит от длины реплики |
|
||||
| H5 | Ошибки группируются вокруг персонажей со сходным возрастом, полом, ролью или видом |
|
||||
|
||||
## 1.3. Формальная постановка
|
||||
|
||||
Пусть \(D=\{(x_i,y_i)\}_{i=1}^{n}\) — корпус, где \(n=2005\),
|
||||
\(x_i\) — одна реплика, \(y_i\in Y\) — имя персонажа,
|
||||
\(|Y|=34\). Каждый объект относится ровно к одному классу.
|
||||
|
||||
Требуется построить классификатор
|
||||
|
||||
\[
|
||||
f\colon \Phi(x_i)\to Y,
|
||||
\]
|
||||
|
||||
где \(\Phi(x_i)\) — вектор признаков реплики. Качество оценивается
|
||||
макро-усреднённой F-мерой:
|
||||
|
||||
\[
|
||||
\operatorname{macroF1}=\frac{1}{|Y|}\sum_{c\in Y}
|
||||
\frac{2P_cR_c}{P_c+R_c}.
|
||||
\]
|
||||
|
||||
Макро-усреднение выбрано потому, что все 34 персонажа важны одинаково,
|
||||
а классы неравномерны.
|
||||
|
||||
# 2. Корпус
|
||||
|
||||
## 2.1. Источник и единица анализа
|
||||
|
||||
Корпус собран из диалогов игры Stardew Valley. Единица анализа — одна
|
||||
реплика персонажа; тексты на английском языке. Метка — имя говорящего,
|
||||
определённое по игровым диалогам и проверенное вручную.
|
||||
|
||||
Метаданные о поле, возрасте, виде и роли персонажа хранятся отдельно и
|
||||
используются только для анализа ошибок. В признаки классификатора они не
|
||||
подаются, чтобы не подменять стилометрическую задачу демографической.
|
||||
|
||||
## 2.2. Сбор и разметка
|
||||
|
||||
Правила формирования корпуса:
|
||||
|
||||
1. Из игровых диалогов извлекаются реплики персонажей.
|
||||
2. Каждая реплика становится отдельным объектом.
|
||||
3. Метка — имя говорящего; один объект относится ровно к одному классу.
|
||||
4. Проводится ручная проверка соответствия реплики и метки.
|
||||
5. Метаданные персонажей ведутся отдельной таблицей.
|
||||
6. Служебные метки игрового движка (`<PLAYER>`, `<FARM>`, `<PET>`,
|
||||
`<SPOUSE>`, `<TIME>`, `<NOUN>`, `<NAME>`) заменяются нейтральными словами;
|
||||
их количество сохраняется отдельным признаком.
|
||||
|
||||
Корпус хранится в CSV с разделителем `;` в кодировке cp1251; обязательные
|
||||
колонки — «Реплика» и «Персонаж». Метаданные персонажей ведутся отдельным
|
||||
XLSX-файлом. Такое разделение позволяет менять признаки, не затрагивая
|
||||
разметку, и проверять корпус независимо от моделей.
|
||||
|
||||
## 2.3. Очистка и шум
|
||||
|
||||
Выполнялась минимальная нормализация:
|
||||
|
||||
- Unicode-очистка: разные варианты кавычек, тире, многоточий и пробелов
|
||||
приводятся к единому виду;
|
||||
- замена плейсхолдеров нейтральными словами;
|
||||
- токенизация и разбиение на предложения средствами NLTK;
|
||||
- частеречная разметка Penn Treebank с переводом в Universal POS.
|
||||
|
||||
Не удалялись:
|
||||
|
||||
- пунктуация;
|
||||
- регистр;
|
||||
- стоп-слова;
|
||||
- короткие реплики, оставшиеся после ручной фильтрации.
|
||||
|
||||
Пунктуация, регистр и служебные слова рассматриваются как стилометрический
|
||||
сигнал, а не как шум.
|
||||
|
||||
Шумом считались:
|
||||
|
||||
- точные дубликаты;
|
||||
- технические артефакты;
|
||||
- общие короткие формулы;
|
||||
- плейсхолдеры игрового движка.
|
||||
|
||||
Корпус прошёл ручную фильтрацию: короткие и общие фразы удалены. Точные
|
||||
правила этой фильтрации не сохранились, поэтому исходное распределение
|
||||
восстановить нельзя. Это ограничение зафиксировано в выводах.
|
||||
|
||||
## 2.4. Характеристики корпуса
|
||||
|
||||
| Показатель | Значение |
|
||||
|---|---:|
|
||||
| Реплик | 2005 |
|
||||
| Персонажей | 34 |
|
||||
| Словоупотреблений | 38 207 |
|
||||
| Средняя длина реплики | 19,1 слова |
|
||||
| Медиана длины | 17 слов |
|
||||
| Минимум / максимум | 3 / 78 слов |
|
||||
| Классы | 15–139 реплик |
|
||||
| Точные дубли реплик | 0 |
|
||||
| Почти-дубли (косинусная близость ≥ 0,8) | 13 пар, все внутри одного класса |
|
||||
| Реплик с плейсхолдерами | 172 |
|
||||
|
||||
{width=70%}
|
||||
|
||||
{width=82%}
|
||||
|
||||
{width=92%}
|
||||
|
||||
Классы неравномерны: максимум — Abigail (139 реплик), минимум — Mister Qi
|
||||
(15 реплик). Разница почти десятикратная. Поэтому доля правильных ответов
|
||||
может вводить в заблуждение: модель, всегда выбирающая частый класс, получает
|
||||
заметную accuracy, но низкую macro-F1.
|
||||
|
||||
## 2.5. Соответствие формальным требованиям
|
||||
|
||||
Задание предусматривает не менее 90 текстов объёмом не менее 30 предложений
|
||||
и примерно равные классы. В данном корпусе единица анализа — реплика; она
|
||||
короче формального «текста» и содержит в среднем 19 слов. По согласованию с
|
||||
преподавателем эксперимент выполнен на уровне реплик: это позволяет
|
||||
использовать все 2005 объектов и корректно поставить задачу для коротких
|
||||
игровых реплик. Классы не равны по объёму; для компенсации использованы
|
||||
веса классов и macro-F1.
|
||||
|
||||
Это ограничение важно учитывать при сравнении с работами, где документы
|
||||
содержат десятки предложений.
|
||||
|
||||
# 3. Стилометрические признаки
|
||||
|
||||
## 3.1. Ручные признаки: 454 признака в 11 группах
|
||||
|
||||
| Группа | Примеры |
|
||||
|---|---|
|
||||
| Длина | число слов, символов, предложений; длина предложения |
|
||||
| Лексика | длины слов, апострофы, заглавные буквы, цифры |
|
||||
| Словарное богатство | TTR (доля уникальных слов), Guiraud, Herdan, Yule's K, Simpson's D, доля гапаксов и дислегомен |
|
||||
| Символы | частоты букв, регистр, цифры, пунктуация |
|
||||
| Пунктуация | запятые, точки, многоточия, вопросы, восклицания, повторы |
|
||||
| Функциональные слова | артикли, предлоги, союзы, местоимения, вспомогательные глаголы |
|
||||
| Местоимения | 1-е, 2-е, 3-е лицо; отношение «я/ты» |
|
||||
| Части речи | Universal POS, Penn Treebank, время, модальность, герундий |
|
||||
| Синтаксис | клаузы (элементарные предложения), подчинительные союзы, пассивные конструкции, вопросы |
|
||||
| Читаемость | Flesch, Flesch—Kincaid, Gunning Fog, SMOG, Coleman—Liau, ARI |
|
||||
| Разговорные маркеры | междометия, смех, осторожные формулировки (хеджи), интенсификаторы, вежливость, сокращения |
|
||||
|
||||
Словарное богатство и читаемость помечены как предварительные: на репликах
|
||||
длиной 10–20 слов они нестабильны. Основные группы для коротких текстов —
|
||||
функциональные слова, части речи, пунктуация, символьные n-граммы и
|
||||
разговорные маркеры.
|
||||
|
||||
### Обоснование выбора
|
||||
|
||||
- Функциональные слова — классическое ядро стилометрии: закрытый класс мало
|
||||
зависит от темы.
|
||||
- Части речи и синтаксические прокси отражают структуру фразы.
|
||||
- Символьные n-граммы устойчивы к теме и фиксируют морфологию, пунктуацию и
|
||||
орфографию.
|
||||
- Разговорные маркеры важны для игровых реплик: междометия, многоточия,
|
||||
смех и вежливость различают персонажей.
|
||||
- LSA сжимает разреженное пространство и позволяет сравнить тематический и
|
||||
стилистический вклады.
|
||||
|
||||
## 3.2. Символьные и частеречные n-граммы
|
||||
|
||||
- Символьные n-граммы: TF-IDF по символьным n-граммам в границах слов
|
||||
(`char_wb`), диапазон 2–5, `min_df=3`, до 5000–10 000 признаков,
|
||||
L2-нормализация.
|
||||
- Частеречные n-граммы: TF-IDF по последовательностям Universal POS,
|
||||
диапазон 1–3, до 500 признаков.
|
||||
|
||||
TF-IDF — вес признака, который растёт с частотой внутри реплики и падает с
|
||||
частотой в корпусе: частые общие n-граммы получают меньший вес, редкие
|
||||
характерные — больший.
|
||||
|
||||
Символьные n-граммы фиксируют морфологию и оформление; n-граммы частей речи —
|
||||
синтаксические шаблоны. Оба блока устойчивее к теме, чем словарные n-граммы.
|
||||
|
||||
## 3.3. Латентно-семантический анализ
|
||||
|
||||
LSA реализован как:
|
||||
|
||||
```text
|
||||
TF-IDF -> усечённое сингулярное разложение (TruncatedSVD) -> L2-нормализация
|
||||
```
|
||||
|
||||
Варианты:
|
||||
|
||||
| Вариант | Что отражает |
|
||||
|---|---|
|
||||
| `word` | лексику и тему |
|
||||
| `word2` | лексику и устойчивые сочетания |
|
||||
| `content` | содержание после удаления стоп-слов |
|
||||
| `fw` | функциональные слова и синтаксис |
|
||||
| `char` | морфологию и пунктуацию |
|
||||
| `pos` | частеречные последовательности |
|
||||
|
||||
Число компонент подбиралось в диапазоне 50–400 по объяснённой дисперсии и
|
||||
внешней метрике. Для `content`-LSA качество росло до 400 компонент.
|
||||
Ключевое ограничение: LSA по содержательным словам может отражать тему, а не
|
||||
только стиль. Поэтому варианты сравнивались между собой, а не выбирались
|
||||
заранее.
|
||||
|
||||
# 4. Классификаторы
|
||||
|
||||
В работе использовано шесть семейств ML-классификаторов и классическая дельта
|
||||
Барроуза — всего семь подходов; требование «не менее трёх» выполнено с
|
||||
запасом.
|
||||
|
||||
## 4.1. Логистическая регрессия
|
||||
|
||||
Линейная модель: для каждого класса вычисляется оценка
|
||||
\(s_c(x)=w_c^\top x+b_c\), затем вероятности через softmax. Обучение
|
||||
минимизирует перекрёстную энтропию с L2-регуляризацией. Параметр \(C\)
|
||||
управляет силой регуляризации; `class_weight="balanced"` повышает вес редких
|
||||
классов.
|
||||
|
||||
Сильные стороны: устойчивость при большом числе признаков, вероятности,
|
||||
интерпретируемые коэффициенты. Ограничение: только линейные границы.
|
||||
|
||||
## 4.2. Линейный метод опорных векторов
|
||||
|
||||
Ищет линейную границу с максимальным зазором; параметр \(C\) штрафует
|
||||
нарушения зазора. Не выдаёт вероятностей: для тройки лучших используется
|
||||
решающая функция. Силён на текстовых признаках, но требует настройки \(C\).
|
||||
|
||||
## 4.3. Случайный лес
|
||||
|
||||
Ансамбль решающих деревьев: каждое дерево обучается на подвыборке,
|
||||
разбиения используют случайное подмножество признаков. Класс определяется
|
||||
голосованием. Лес ловит нелинейные взаимодействия, но склонен к
|
||||
переобучению на 2005 объектах; ограничивались глубина и размер листьев.
|
||||
|
||||
## 4.4. Сверхслучайные деревья (ExtraTrees)
|
||||
|
||||
Похожи на случайный лес, но пороги разбиения выбираются случайно. Это
|
||||
уменьшает дисперсию и ускоряет обучение. В работе ExtraTrees с расширенным
|
||||
символьным словарём показали лучший результат среди одиночных моделей.
|
||||
|
||||
## 4.5. Наивный байесовский классификатор
|
||||
|
||||
MultinomialNB использует теорему Байеса и предположение об условной
|
||||
независимости признаков; сглаживание Лапласа задаётся параметром \(\alpha\).
|
||||
Быстрый baseline для частотных признаков; требует неотрицательных значений,
|
||||
поэтому LSA-компоненты для него исключались.
|
||||
|
||||
## 4.6. Метод ближайших соседей
|
||||
|
||||
Класс определяется по \(k\) ближайшим объектам; использовалось косинусное
|
||||
расстояние и веса, обратные расстоянию. Простая локальная модель; чувствительна
|
||||
к размерности и масштабу признаков.
|
||||
|
||||
## 4.7. Дельта Барроуза
|
||||
|
||||
Классический стилометрический метод: выбираются наиболее частотные слова,
|
||||
частоты z-нормируются по обучающей выборке, документ относится к классу с
|
||||
минимальным расстоянием до центроида. Использовались манхэттенское и
|
||||
косинусное расстояния. Дельта — независимая проверка, не заменяющая
|
||||
ML-классификаторы.
|
||||
|
||||
## 4.8. Ансамбли
|
||||
|
||||
- **Мягкое голосование** усредняет вероятности нескольких моделей.
|
||||
- **Объединение через мета-классификатор (стекинг)** обучает второй уровень
|
||||
на предсказаниях базовых моделей. В наших данных оно переобучилось и
|
||||
оказалось хуже мягкого голосования.
|
||||
|
||||
# 5. Схема верификации
|
||||
|
||||
## 5.1. Перекрёстная проверка
|
||||
|
||||
Основная схема:
|
||||
|
||||
```text
|
||||
RepeatedStratifiedKFold(n_splits=5, n_repeats=3–5)
|
||||
```
|
||||
|
||||
При 5 фолдах модель обучается на 4 фолдах и проверяется на 1; доли классов
|
||||
сохраняются внутри фолдов. Повторность позволяет оценить разброс.
|
||||
|
||||
## 5.2. Вложенная проверка
|
||||
|
||||
Внешние фолды используются для оценки качества, внутренние — для настройки
|
||||
гиперпараметров. Дополнительно выполнен вложенный выбор конфигурации
|
||||
признаков и модели: в каждом внешнем фолде конфигурация выбиралась только по
|
||||
внутренним фолдам.
|
||||
|
||||
## 5.3. Контроль утечек
|
||||
|
||||
- TF-IDF, SVD, масштабирование и отбор признаков обучаются только на
|
||||
обучающих фолдах.
|
||||
- Тестовая выборка не участвует в выборе гиперпараметров или признаков.
|
||||
- Контроль перестановкой меток дал macro-F1 0,0270–0,0282 — случайный
|
||||
уровень.
|
||||
- Все 13 почти-дубликатов находятся внутри одного класса; межклассовых
|
||||
почти-дублей не обнаружено, поэтому прямой утечки метки нет. При более
|
||||
строгой оценке такие пары всё равно следует держать в одном фолде.
|
||||
|
||||
## 5.4. Метрики
|
||||
|
||||
| Метрика | Что показывает |
|
||||
|---|---|
|
||||
| Macro-F1 | Основная метрика: средняя F-мера по 34 классам |
|
||||
| Доля правильных ответов | Доля совпавших ответов; обманчива при дисбалансе |
|
||||
| Сбалансированная доля | Средняя полнота по классам |
|
||||
| Top-3 | Истинный класс попал в тройку лучших |
|
||||
| MCC (коэффициент корреляции Мэтьюза) | Корреляция истинных и предсказанных меток |
|
||||
| Каппа Коэна | Согласие с поправкой на случай |
|
||||
| Log loss / Brier | Качество вероятностей |
|
||||
| Матрица ошибок | Какие персонажи перепутываются |
|
||||
|
||||
Дополнительно использованы бутстрэп-интервалы, критерий Мак-Немара для
|
||||
парных сравнений, критерий Уилкоксона и поправка Холма—Бонферрони.
|
||||
|
||||
# 6. Результаты
|
||||
|
||||
## 6.1. Сравнение моделей
|
||||
|
||||
| Конфигурация | Macro-F1 | Доля правильных ответов | Top-3 |
|
||||
|---|---:|---:|---:|
|
||||
| Мягкое голосование трёх моделей | **0,3036 ± 0,0189** | 0,2808 | 0,4823 |
|
||||
| ExtraTrees, `char_max_features=10000` | 0,2757 ± 0,0219 | 0,2633 | 0,4554 |
|
||||
| Вложенный выбор конфигурации | 0,2746 ± 0,0233 | 0,2603 | — |
|
||||
| Случайный лес, `char_max_features=10000` | 0,2583 ± 0,0066 | 0,2391 | 0,4197 |
|
||||
| ExtraTrees, полный набор | 0,2499 ± 0,0209 | 0,2374 | 0,4234 |
|
||||
| Логистическая регрессия, lean + content-LSA | 0,2466 ± 0,0191 | 0,2349 | 0,4030 |
|
||||
| Логистическая регрессия, `stylometry_pos_lsa` | 0,1785 ± 0,0060 | 0,1679 | 0,3322 |
|
||||
| Только ручные признаки | 0,1051 ± 0,0038 | 0,1012 | 0,2338 |
|
||||
| Случайный уровень | 0,029 | 0,029 | 0,088 |
|
||||
|
||||
На отложенной выборке 80/20 мягкое голосование дало **0,3333**; ExtraTrees
|
||||
на полном наборе — 0,2793; случайный лес с `char_max_features=10000` —
|
||||
0,2554; логистическая регрессия с content-LSA — 0,2459.
|
||||
|
||||
{width=82%}
|
||||
|
||||
## 6.2. Вклад признаков
|
||||
|
||||
| Условие | Macro-F1 |
|
||||
|---|---:|
|
||||
| Только длина | 0,035 |
|
||||
| Только ручные стилометрические признаки | 0,105 |
|
||||
| Ручные + символьные и частеречные n-граммы | 0,182 |
|
||||
| То же + `content`-LSA | 0,249 |
|
||||
| ExtraTrees с расширенным символьным словарём | 0,276 |
|
||||
| Мягкое голосование | 0,304 |
|
||||
|
||||
{width=72%}
|
||||
|
||||
Основной прирост дают символьные n-граммы, лексика и `content`-LSA.
|
||||
Функциональные слова и POS-LSA сами по себе слабы (0,06–0,07): это важный
|
||||
отрицательный результат.
|
||||
|
||||
## 6.3. Стиль или содержание
|
||||
|
||||
Содержательные слова (существительные, глаголы, прилагательные, наречия,
|
||||
числительные) заменялись нейтральным маркером `CONTENT`; служебные слова,
|
||||
пунктуация и частеречные теги сохранялись.
|
||||
|
||||
| Условие | Macro-F1 |
|
||||
|---|---:|
|
||||
| Исходный текст, стилевой набор, ExtraTrees | 0,2483 |
|
||||
| Маскированный текст, стилевой набор, ExtraTrees | 0,1129 |
|
||||
| Исходный текст, стилевой набор, LogReg | 0,1467 |
|
||||
| Маскированный текст, стилевой набор, LogReg | 0,1067 |
|
||||
|
||||
{width=68%}
|
||||
|
||||
Падение более чем вдвое показывает, что значительная часть качества связана
|
||||
с лексическим содержанием. Остаток 0,113 всё ещё в 3,9 раза выше случайного
|
||||
уровня: некоторый стилевой сигнал сохраняется, но он слабее тематического.
|
||||
|
||||
## 6.4. Матрицы ошибок
|
||||
|
||||
Строки матрицы — истинный персонаж, столбцы — предсказание модели, диагональ
|
||||
— правильные ответы, вне диагонали — ошибки. Полная матрица 34×34 на доске
|
||||
нечитаема; поэтому в основном тексте показаны 12 самых частых персонажей и
|
||||
столбец «прочие», а также 18 самых частых пар ошибок.
|
||||
|
||||
{width=92%}
|
||||
|
||||
{width=92%}
|
||||
|
||||
Основные пары ошибок (за три повтора): Sebastian → Abigail (65),
|
||||
Sebastian → Alex (61), Abigail → Sebastian (59), Abigail → Alex (56).
|
||||
Крупнейший клубок — молодые персонажи Abigail, Alex, Sebastian, Haley, Sam.
|
||||
Многие из них писались в одних и тех же обновлениях и сюжетных контекстах,
|
||||
поэтому сходство голосов ожидаемо; это не разные авторы.
|
||||
|
||||
## 6.5. Ошибки по персонажам
|
||||
|
||||
{width=92%}
|
||||
|
||||
Лучшие результаты по F-мере: Willy, Evelyn, Pierre, Pam, Leo, Vincent.
|
||||
Худшие: Kent, Mister Qi, Penny, Abigail, Leah, Jodi. Редкие классы имеют
|
||||
широкие интервалы; у Mister Qi всего 15 реплик.
|
||||
|
||||
Abigail и Alex являются классами-аттракторами: модель ошибочно относит к ним
|
||||
чужие реплики. Abigail получает 10–18% всех предсказаний при истинной доле
|
||||
около 6,9%. Простая коррекция априорных вероятностей классов не улучшила
|
||||
macro-F1.
|
||||
|
||||
{width=88%}
|
||||
|
||||
## 6.6. Длина реплики
|
||||
|
||||
| Длина | 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 слов
|
||||
качество снижается. Гипотеза «чем длиннее, тем лучше» не подтверждается.
|
||||
|
||||
## 6.7. Верификация и кластеризация
|
||||
|
||||
- Верификация «свой/чужой»: ROC-AUC 0,630–0,633; EER (равная ошибка
|
||||
ложного принятия и ложного отказа) 0,412–0,415; top-1 0,133–0,141.
|
||||
Выше случайного, но практически слабо.
|
||||
- Кластеризация: ARI (скорректированный индекс Рэнда) = 0 для k = 3/5/8;
|
||||
NMI (нормированная взаимная информация) 0,14–0,53. Устойчивых кластеров,
|
||||
соответствующих персонажам, нет.
|
||||
|
||||
## 6.8. Калибровка и ансамбли
|
||||
|
||||
Коррекция априорных вероятностей вида
|
||||
\(p'_{ic}\propto p_{ic}\pi_c^{-\alpha}\) проверялась по схеме
|
||||
«один повтор — отложенный, остальные — обучающие». Среднее изменение
|
||||
macro-F1 составило **−0,0022**: простой пересчёт приоров не помог.
|
||||
|
||||
Мягкое голосование трёх моделей (ExtraTrees на полном наборе, ExtraTrees с
|
||||
`char_max_features=10000`, логистическая регрессия с lean + content-LSA)
|
||||
дало **0,3036 ± 0,0189**. Объединение через мета-классификатор оказалось хуже
|
||||
(0,2259 ± 0,0163) из-за переобучения на малом числе объектов.
|
||||
|
||||
# 7. Обсуждение
|
||||
|
||||
1. **Персонажи статистически различимы, но сигнал умеренный.** 0,304
|
||||
macro-F1 при 34 классах — примерно в 10 раз выше случайного уровня 0,029.
|
||||
Абсолютное значение ограничено короткими репликами, дисбалансом и общими
|
||||
темами.
|
||||
|
||||
2. **Основной вклад — лексико-тематический.** Символьные n-граммы и
|
||||
`content`-LSA дают основной прирост; функциональные слова и POS-LSA сами
|
||||
по себе слабы. После маскирования содержательных слов качество падает
|
||||
более чем вдвое.
|
||||
|
||||
3. **Наиболее устойчивая одиночная модель — ExtraTrees с
|
||||
`char_max_features=10000`.** Она выбрана в 14 из 15 внешних фолдов
|
||||
вложенной процедуры и даёт 0,2757 ± 0,0219.
|
||||
|
||||
4. **Мягкое голосование — лучший наблюдаемый результат**, но состав
|
||||
ансамбля выбирался по предыдущим сериям. Для строгого вывода нужна
|
||||
внешняя валидация.
|
||||
|
||||
5. **Ошибки — это коллапс редких классов в классы-аттракторы**, а не
|
||||
доказательство сходства характеров. Abigail, Alex и ещё несколько
|
||||
популярных классов забирают чужие реплики.
|
||||
|
||||
6. **Один автор и общие контексты** делают путаницу между персонажами
|
||||
ожидаемой. Это не означает, что автор «не старался»: превышение
|
||||
случайного уровня как раз говорит о намеренно созданных речевых масках.
|
||||
|
||||
# 8. Ограничения
|
||||
|
||||
- Один корпус, одна игра, один автор; внешняя валидация не проводилась.
|
||||
- Единица анализа — реплика, а не текст из 30 предложений; это согласовано,
|
||||
но ограничивает сравнение с документными корпусами.
|
||||
- Классы неравномерны; редкие классы имеют широкие интервалы.
|
||||
- Ручная фильтрация корпуса невоспроизводима.
|
||||
- Нет идентификаторов сцен и диалогов.
|
||||
- Выбор признаков и ансамбля частично опирался на предыдущие серии;
|
||||
вложенная проверка снижает, но не устраняет оптимизм.
|
||||
- Содержательные слова и тема вносят большой вклад; «чистый стиль» не
|
||||
доказан.
|
||||
- Простая коррекция приоров не помогла; калибровка редких классов требует
|
||||
дальнейшей работы.
|
||||
|
||||
# 9. Выводы
|
||||
|
||||
1. Речевые маски 34 персонажей статистически различимы по отдельным
|
||||
репликам: лучшая модель — 0,304 macro-F1, консервативная оценка — 0,275
|
||||
при случайном уровне 0,029.
|
||||
2. Основной сигнал — лексико-тематический: символьные n-граммы и
|
||||
`content`-LSA дают основной прирост; функциональные слова и частеречная
|
||||
LSA сами по себе слабы.
|
||||
3. После замены содержательных слов качество падает с 0,248 до 0,113:
|
||||
содержание доминирует, но стилевой сигнал сохраняется.
|
||||
4. Главная проблема — редкие классы и классы-аттракторы; мягкое голосование
|
||||
помогает, но не решает её полностью.
|
||||
5. Корректная формулировка результата: различимы речевые маски по
|
||||
лексико-тематическому профилю; утверждение о «чистом стиле» требует
|
||||
внешней валидации и тематического контроля.
|
||||
|
||||
# 10. Воспроизводимость
|
||||
|
||||
## 10.1. Структура проекта
|
||||
|
||||
```text
|
||||
lab1/
|
||||
├── stylometry/ # чтение, признаки, LSA, модели, метрики
|
||||
├── scripts/ # проверка окружения, запуск серий, упаковка
|
||||
├── tests/ # pytest
|
||||
├── configs/ # конфигурация
|
||||
├── data/ # корпус и метаданные
|
||||
├── report/ # этот отчёт
|
||||
├── presentation/ # PPTX/PDF и LaTeX-версия
|
||||
├── README.md
|
||||
├── RUNBOOK.md
|
||||
├── EXPERIMENTS.md
|
||||
├── FEATURES.md
|
||||
├── METRICS_AND_CLASSIFIERS.md
|
||||
├── TUNING.md
|
||||
├── FINAL_REPORT.md
|
||||
├── FINAL_RESULTS.csv
|
||||
├── requirements.txt
|
||||
└── requirements-report.txt
|
||||
```
|
||||
|
||||
Краткая и подробная инструкции к архиву (`START_HERE.md`,
|
||||
`ARCHIVE_README.md`) лежат в его корне, рядом с каталогом `lab1`.
|
||||
|
||||
## 10.2. Установка и запуск
|
||||
|
||||
```bash
|
||||
cd lab1
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install -r requirements.txt
|
||||
python scripts/download_nltk_data.py
|
||||
|
||||
python scripts/check_environment.py
|
||||
python -m pytest -q
|
||||
|
||||
# аудит и признаки
|
||||
python -m stylometry.cli audit
|
||||
python -m stylometry.cli features
|
||||
|
||||
# основной эксперимент
|
||||
python -m stylometry.cli run \
|
||||
--preset stylometry_pos_lsa --model logreg --repeats 5
|
||||
|
||||
# полная серия
|
||||
bash scripts/run_all_experiments.sh
|
||||
|
||||
# дополнительные эксперименты
|
||||
python scripts/run_final_experiments.py \
|
||||
--stages nested topic calibration ensemble extra \
|
||||
--out results/final
|
||||
|
||||
# пересборка отчёта (pandoc + LibreOffice)
|
||||
python scripts/build_report.py
|
||||
```
|
||||
|
||||
## 10.3. Программное обеспечение
|
||||
|
||||
- Python 3.11+;
|
||||
- NumPy, pandas, SciPy, scikit-learn, matplotlib;
|
||||
- NLTK (`punkt`, `punkt_tab`, `averaged_perceptron_tagger_eng`, `stopwords`);
|
||||
- python-pptx и Pillow — для презентации;
|
||||
- pandoc, LibreOffice, python-docx и PyMuPDF — для сборки отчёта;
|
||||
- XeLaTeX и Beamer — для LaTeX-версии презентации.
|
||||
|
||||
# Приложение A. Полная матрица ошибок
|
||||
|
||||
{width=92%}
|
||||
|
||||
# Приложение B. Сводная таблица результатов
|
||||
|
||||
См. `FINAL_RESULTS.csv` в корне проекта.
|
||||
|
||||
# Приложение C. Состав архива
|
||||
|
||||
В архив включены код, тесты, конфигурации, корпус, метаданные, документация,
|
||||
отчёт, презентация (PPTX и LaTeX), сводные результаты и инструкции запуска.
|
||||
Виртуальное окружение, кэш NLTK и промежуточные матрицы признаков в архив не
|
||||
включены: они восстанавливаются скриптами.
|
||||
|
||||
# Приложение D. Источники и программные средства
|
||||
|
||||
1. Burrows, J. F. «Delta»: a Measure of Stylistic Difference and a Guide to
|
||||
Likely Authorship // Literary and Linguistic Computing. 2002. Vol. 17,
|
||||
no. 3. P. 267–287.
|
||||
2. Eder, M., Rybicki, J., Kestemont, M. Stylometry with R: A Package for
|
||||
Computational Text Analysis // The R Journal. 2016. Vol. 8, no. 1.
|
||||
P. 107–121.
|
||||
3. Mosteller, F., Wallace, D. L. Inference and Disputed Authorship:
|
||||
The Federalist. Reading, MA: Addison-Wesley, 1964.
|
||||
4. Stamatatos, E. A Survey of Modern Authorship Attribution Methods // Journal of the American Society for Information Science and Technology. 2009. Vol. 60, no. 3. P. 538–556.
|
||||
5. Pedregosa, F. et al. Scikit-learn: Machine Learning in Python //
|
||||
Journal of Machine Learning Research. 2011. Vol. 12. P. 2825–2830.
|
||||
6. Bird, S., Klein, E., Loper, E. Natural Language Processing with Python.
|
||||
Sebastopol, CA: O'Reilly Media, 2009.
|
||||
7. ConcernedApe. Stardew Valley. Video game. 2016.
|
||||
|
||||
BIN
lab1/report/REPORT.pdf
Normal file
BIN
lab1/report/figures/characters.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
lab1/report/figures/confusion_full34.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
lab1/report/figures/confusion_pairs.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
lab1/report/figures/confusion_top12.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
lab1/report/figures/feature_progression.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
lab1/report/figures/hub_shares.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
lab1/report/figures/median.png
Normal file
|
After Width: | Height: | Size: 680 KiB |
BIN
lab1/report/figures/model_comparison.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
lab1/report/figures/per_class_f1.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
lab1/report/figures/replics-count.png
Normal file
|
After Width: | Height: | Size: 795 KiB |
BIN
lab1/report/figures/topic_control.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
5
lab1/requirements-report.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Дополнительные зависимости для пересборки отчёта.
|
||||
# Основные зависимости проекта — в requirements.txt.
|
||||
python-docx>=1.1,<2
|
||||
PyMuPDF>=1.24,<2
|
||||
Pillow>=10,<13
|
||||
@@ -8,3 +8,4 @@ nltk>=3.9,<4
|
||||
joblib>=1.3,<2
|
||||
tabulate>=0.9,<1
|
||||
pytest>=8.0,<10
|
||||
python-pptx>=1.0,<2
|
||||
|
||||
570
lab1/scripts/build_report.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сборка отчёта: Markdown -> DOCX -> PDF.
|
||||
|
||||
Скрипт рассчитан на запуск из каталога lab1:
|
||||
|
||||
python scripts/build_report.py
|
||||
|
||||
Требуются pandoc, python-docx и libreoffice. PDF собирается через
|
||||
LibreOffice, чтобы не зависеть от LaTeX-шаблона и корректно передать
|
||||
кириллицу, таблицы и рисунки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import docx
|
||||
from docx.enum.section import WD_SECTION
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPORT_MD = ROOT / "report" / "REPORT.md"
|
||||
BUILD = ROOT / "report" / "build"
|
||||
DOCX_OUT = ROOT / "report" / "REPORT.docx"
|
||||
PDF_OUT = ROOT / "report" / "REPORT.pdf"
|
||||
|
||||
BODY_FONT = "Times New Roman"
|
||||
HEAD_FONT = "Times New Roman"
|
||||
MONO_FONT = "Liberation Mono"
|
||||
INK = RGBColor(0x1A, 0x1A, 0x1A)
|
||||
MUTED = RGBColor(0x55, 0x55, 0x55)
|
||||
ACCENT = RGBColor(0x5B, 0x3A, 0x1E)
|
||||
|
||||
TABLE_WIDTHS = [
|
||||
(2.8, 13.8),
|
||||
(11.6, 5.0),
|
||||
(4.6, 12.0),
|
||||
(3.4, 13.2),
|
||||
(5.6, 11.0),
|
||||
(7.6, 3.4, 3.4, 2.2),
|
||||
(9.6, 7.0),
|
||||
(10.0, 6.6),
|
||||
(11.6, 5.0),
|
||||
]
|
||||
|
||||
TABLE_CAPTIONS = [
|
||||
"Гипотезы исследования и способ их проверки",
|
||||
"Характеристики корпуса",
|
||||
"Группы ручных стилометрических признаков",
|
||||
"Варианты латентно-семантического анализа",
|
||||
"Метрики качества и их назначение",
|
||||
"Сравнение конфигураций классификаторов",
|
||||
"Вклад групп признаков",
|
||||
"Контроль содержания: исходный и маскированный текст",
|
||||
"Качество классификации по длине реплики",
|
||||
]
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path | None = None) -> None:
|
||||
print("+", " ".join(cmd))
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
|
||||
|
||||
def set_run_font(run, name: str, size: float | None = None, bold: bool | None = None,
|
||||
italic: bool | None = None, color: RGBColor | None = None) -> None:
|
||||
run.font.name = name
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
|
||||
if size is not None:
|
||||
run.font.size = Pt(size)
|
||||
if bold is not None:
|
||||
run.bold = bold
|
||||
if italic is not None:
|
||||
run.italic = italic
|
||||
if color is not None:
|
||||
run.font.color.rgb = color
|
||||
|
||||
|
||||
def set_style_font(style, name: str, size: float, bold: bool = False,
|
||||
color: RGBColor = INK, italic: bool | None = None) -> None:
|
||||
style.font.name = name
|
||||
style.font.size = Pt(size)
|
||||
style.font.bold = bold
|
||||
style.font.color.rgb = color
|
||||
if italic is not None:
|
||||
style.font.italic = italic
|
||||
rpr = style.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
for attr in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"):
|
||||
rfonts.set(qn(attr), name)
|
||||
|
||||
|
||||
def set_paragraph_spacing(style, before: float = 0, after: float = 0,
|
||||
line: float = 1.5) -> None:
|
||||
pf = style.paragraph_format
|
||||
pf.space_before = Pt(before)
|
||||
pf.space_after = Pt(after)
|
||||
pf.line_spacing = line
|
||||
|
||||
|
||||
def shade(cell, fill: str) -> None:
|
||||
tc_pr = cell._tc.get_or_add_tcPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), fill)
|
||||
tc_pr.append(shd)
|
||||
|
||||
|
||||
def set_cell_margins(table, top=60, bottom=60, left=90, right=90) -> None:
|
||||
tbl_pr = table._tbl.tblPr
|
||||
mar = OxmlElement("w:tblCellMar")
|
||||
for edge, value in (("top", top), ("left", left), ("bottom", bottom), ("right", right)):
|
||||
el = OxmlElement(f"w:{edge}")
|
||||
el.set(qn("w:w"), str(value))
|
||||
el.set(qn("w:type"), "dxa")
|
||||
mar.append(el)
|
||||
tbl_pr.append(mar)
|
||||
|
||||
|
||||
def set_repeat_header(row) -> None:
|
||||
tr_pr = row._tr.get_or_add_trPr()
|
||||
el = OxmlElement("w:tblHeader")
|
||||
el.set(qn("w:val"), "true")
|
||||
tr_pr.append(el)
|
||||
cant_split = OxmlElement("w:cantSplit")
|
||||
cant_split.set(qn("w:val"), "true")
|
||||
tr_pr.append(cant_split)
|
||||
|
||||
|
||||
def set_table_borders(table, color: str = "B8A88F", size: int = 4) -> None:
|
||||
tbl_pr = table._tbl.tblPr
|
||||
borders = OxmlElement("w:tblBorders")
|
||||
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
el = OxmlElement(f"w:{edge}")
|
||||
el.set(qn("w:val"), "single")
|
||||
el.set(qn("w:sz"), str(size))
|
||||
el.set(qn("w:space"), "0")
|
||||
el.set(qn("w:color"), color)
|
||||
borders.append(el)
|
||||
tbl_pr.append(borders)
|
||||
|
||||
|
||||
def add_page_field(paragraph, total: bool = False) -> None:
|
||||
run = paragraph.add_run()
|
||||
fld_char_begin = OxmlElement("w:fldChar")
|
||||
fld_char_begin.set(qn("w:fldCharType"), "begin")
|
||||
instr_text = OxmlElement("w:instrText")
|
||||
instr_text.set(qn("xml:space"), "preserve")
|
||||
instr_text.text = "NUMPAGES" if total else "PAGE"
|
||||
fld_char_end = OxmlElement("w:fldChar")
|
||||
fld_char_end.set(qn("w:fldCharType"), "end")
|
||||
run._r.append(fld_char_begin)
|
||||
run._r.append(instr_text)
|
||||
run._r.append(fld_char_end)
|
||||
|
||||
|
||||
def add_bottom_border(paragraph, color: str = "5B3A1E", size: int = 6) -> None:
|
||||
p_pr = paragraph._p.get_or_add_pPr()
|
||||
borders = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), str(size))
|
||||
bottom.set(qn("w:space"), "4")
|
||||
bottom.set(qn("w:color"), color)
|
||||
borders.append(bottom)
|
||||
p_pr.append(borders)
|
||||
|
||||
|
||||
def add_left_border(paragraph, color: str = "B8A88F", size: int = 18) -> None:
|
||||
p_pr = paragraph._p.get_or_add_pPr()
|
||||
borders = OxmlElement("w:pBdr")
|
||||
left = OxmlElement("w:left")
|
||||
left.set(qn("w:val"), "single")
|
||||
left.set(qn("w:sz"), str(size))
|
||||
left.set(qn("w:space"), "8")
|
||||
left.set(qn("w:color"), color)
|
||||
borders.append(left)
|
||||
p_pr.append(borders)
|
||||
|
||||
|
||||
def add_page_break(paragraph) -> None:
|
||||
run = paragraph.add_run()
|
||||
run.add_break(WD_BREAK.PAGE)
|
||||
|
||||
|
||||
def style_document(doc) -> None:
|
||||
section = doc.sections[0]
|
||||
section.page_width = Cm(21.0)
|
||||
section.page_height = Cm(29.7)
|
||||
section.left_margin = Cm(2.6)
|
||||
section.right_margin = Cm(1.8)
|
||||
section.top_margin = Cm(2.0)
|
||||
section.bottom_margin = Cm(2.0)
|
||||
section.header_distance = Cm(1.0)
|
||||
section.footer_distance = Cm(1.0)
|
||||
|
||||
styles = doc.styles
|
||||
|
||||
normal = styles["Normal"]
|
||||
set_style_font(normal, BODY_FONT, 12)
|
||||
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
normal.paragraph_format.first_line_indent = Cm(1.25)
|
||||
normal.paragraph_format.line_spacing = 1.5
|
||||
normal.paragraph_format.space_before = Pt(0)
|
||||
normal.paragraph_format.space_after = Pt(0)
|
||||
|
||||
for name in ("Body Text", "First Paragraph"):
|
||||
st = styles[name]
|
||||
set_style_font(st, BODY_FONT, 12)
|
||||
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
st.paragraph_format.first_line_indent = Cm(1.25)
|
||||
st.paragraph_format.line_spacing = 1.5
|
||||
st.paragraph_format.space_before = Pt(0)
|
||||
st.paragraph_format.space_after = Pt(6)
|
||||
|
||||
styles["First Paragraph"].paragraph_format.space_before = Pt(0)
|
||||
|
||||
title = styles["Title"]
|
||||
set_style_font(title, HEAD_FONT, 24, bold=True, color=ACCENT)
|
||||
title.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
title.paragraph_format.space_before = Pt(150)
|
||||
title.paragraph_format.space_after = Pt(12)
|
||||
title.paragraph_format.line_spacing = 1.1
|
||||
|
||||
subtitle = styles["Subtitle"]
|
||||
set_style_font(subtitle, HEAD_FONT, 15, bold=False, color=INK)
|
||||
subtitle.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
subtitle.paragraph_format.space_before = Pt(0)
|
||||
subtitle.paragraph_format.space_after = Pt(28)
|
||||
subtitle.paragraph_format.line_spacing = 1.2
|
||||
|
||||
author = styles["Author"]
|
||||
set_style_font(author, BODY_FONT, 13, color=INK)
|
||||
author.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
author.paragraph_format.space_before = Pt(0)
|
||||
author.paragraph_format.space_after = Pt(6)
|
||||
|
||||
date = styles["Date"]
|
||||
set_style_font(date, BODY_FONT, 13, color=MUTED)
|
||||
date.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
date.paragraph_format.space_before = Pt(0)
|
||||
date.paragraph_format.space_after = Pt(0)
|
||||
|
||||
for name, size, before, after in (
|
||||
("Heading 1", 17, 0, 10),
|
||||
("Heading 2", 14, 14, 6),
|
||||
("Heading 3", 12.5, 12, 4),
|
||||
):
|
||||
st = styles[name]
|
||||
set_style_font(st, HEAD_FONT, size, bold=True, color=INK)
|
||||
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
st.paragraph_format.first_line_indent = Cm(0)
|
||||
st.paragraph_format.space_before = Pt(before)
|
||||
st.paragraph_format.space_after = Pt(after)
|
||||
st.paragraph_format.line_spacing = 1.15
|
||||
st.paragraph_format.keep_with_next = True
|
||||
|
||||
styles["Heading 1"].paragraph_format.page_break_before = False
|
||||
styles["Heading 1"].paragraph_format.space_before = Pt(20)
|
||||
|
||||
block = styles["Block Text"]
|
||||
set_style_font(block, BODY_FONT, 11.5, italic=False)
|
||||
block.paragraph_format.left_indent = Cm(1.0)
|
||||
block.paragraph_format.right_indent = Cm(0.5)
|
||||
block.paragraph_format.first_line_indent = Cm(0)
|
||||
block.paragraph_format.line_spacing = 1.3
|
||||
block.paragraph_format.space_before = Pt(6)
|
||||
block.paragraph_format.space_after = Pt(8)
|
||||
block.paragraph_format.keep_together = True
|
||||
|
||||
source = styles["Source Code"]
|
||||
set_style_font(source, MONO_FONT, 9)
|
||||
source.paragraph_format.left_indent = Cm(0.6)
|
||||
source.paragraph_format.first_line_indent = Cm(0)
|
||||
source.paragraph_format.line_spacing = 1.0
|
||||
source.paragraph_format.space_before = Pt(4)
|
||||
source.paragraph_format.space_after = Pt(4)
|
||||
source.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
source.paragraph_format.keep_together = True
|
||||
|
||||
try:
|
||||
verbatim = styles["Verbatim Char"]
|
||||
set_style_font(verbatim, MONO_FONT, 9.5)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for name in ("Image Caption", "Caption"):
|
||||
st = styles[name]
|
||||
set_style_font(st, BODY_FONT, 10, italic=True, color=MUTED)
|
||||
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
st.paragraph_format.first_line_indent = Cm(0)
|
||||
st.paragraph_format.line_spacing = 1.15
|
||||
st.paragraph_format.space_before = Pt(4)
|
||||
st.paragraph_format.space_after = Pt(12)
|
||||
|
||||
for name in ("Table Caption",):
|
||||
st = styles[name]
|
||||
set_style_font(st, BODY_FONT, 10, italic=False, color=INK)
|
||||
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
st.paragraph_format.first_line_indent = Cm(0)
|
||||
st.paragraph_format.line_spacing = 1.15
|
||||
st.paragraph_format.space_before = Pt(4)
|
||||
st.paragraph_format.space_after = Pt(12)
|
||||
|
||||
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
st.paragraph_format.first_line_indent = Cm(0)
|
||||
st.paragraph_format.line_spacing = 1.15
|
||||
st.paragraph_format.space_before = Pt(10)
|
||||
st.paragraph_format.space_after = Pt(4)
|
||||
st.paragraph_format.keep_with_next = True
|
||||
|
||||
|
||||
def add_header_footer(doc) -> None:
|
||||
section = doc.sections[0]
|
||||
|
||||
header = section.header
|
||||
header.is_linked_to_previous = False
|
||||
hp = header.paragraphs[0]
|
||||
hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
hp.paragraph_format.space_after = Pt(0)
|
||||
run = hp.add_run("Стилометрия реплик Stardew Valley · отчёт по лабораторной работе №1")
|
||||
set_run_font(run, BODY_FONT, 8.5, color=MUTED)
|
||||
add_bottom_border(hp, color="D8CBB8", size=4)
|
||||
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
fp = footer.paragraphs[0]
|
||||
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
fp.paragraph_format.space_before = Pt(0)
|
||||
fp.paragraph_format.space_after = Pt(0)
|
||||
run = fp.add_run("Стр. ")
|
||||
set_run_font(run, BODY_FONT, 9, color=MUTED)
|
||||
add_page_field(fp)
|
||||
for r in fp.runs:
|
||||
set_run_font(r, BODY_FONT, 9, color=MUTED)
|
||||
run = fp.add_run(" из ")
|
||||
set_run_font(run, BODY_FONT, 9, color=MUTED)
|
||||
add_page_field(fp, total=True)
|
||||
for r in fp.runs:
|
||||
set_run_font(r, BODY_FONT, 9, color=MUTED)
|
||||
|
||||
|
||||
def add_title_page_notes(doc) -> None:
|
||||
"""Добавляет подпись направления под титульным блоком и разрыв страницы."""
|
||||
date_para = None
|
||||
for p in doc.paragraphs:
|
||||
if p.style.name == "Date":
|
||||
date_para = p
|
||||
break
|
||||
if date_para is None:
|
||||
return
|
||||
anchor = date_para
|
||||
note = anchor.insert_paragraph_before()
|
||||
note.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
note.paragraph_format.first_line_indent = Cm(0)
|
||||
note.paragraph_format.space_before = Pt(18)
|
||||
run = note.add_run("Направление: автоматический анализ текстов\n")
|
||||
set_run_font(run, BODY_FONT, 11, color=MUTED)
|
||||
run = note.add_run("Корпус: Stardew Valley · 2005 реплик · 34 персонажа")
|
||||
set_run_font(run, BODY_FONT, 11, color=MUTED)
|
||||
|
||||
spacer = doc.add_paragraph()
|
||||
spacer.paragraph_format.space_after = Pt(0)
|
||||
add_page_break(spacer)
|
||||
date_para._p.addnext(spacer._p)
|
||||
|
||||
|
||||
def number_figures(doc) -> None:
|
||||
n = 0
|
||||
for p in doc.paragraphs:
|
||||
if p.style.name == "Image Caption" and p.text.strip():
|
||||
n += 1
|
||||
p.text = f"Рисунок {n}. {p.text.strip()}"
|
||||
for run in p.runs:
|
||||
set_run_font(run, BODY_FONT, 10, italic=True, color=MUTED)
|
||||
|
||||
|
||||
def add_table_captions(doc) -> None:
|
||||
for idx, table in enumerate(doc.tables):
|
||||
if idx >= len(TABLE_CAPTIONS):
|
||||
break
|
||||
para = doc.add_paragraph(style="Table Caption")
|
||||
table._tbl.addprevious(para._p)
|
||||
run = para.add_run(f"Таблица {idx + 1}. {TABLE_CAPTIONS[idx]}")
|
||||
set_run_font(run, BODY_FONT, 10, bold=True, color=INK)
|
||||
para.paragraph_format.first_line_indent = Cm(0)
|
||||
para.paragraph_format.space_before = Pt(10)
|
||||
para.paragraph_format.space_after = Pt(4)
|
||||
para.paragraph_format.keep_with_next = True
|
||||
|
||||
|
||||
def set_column_widths(table, widths_cm: tuple[float, ...]) -> None:
|
||||
table.autofit = False
|
||||
tbl_pr = table._tbl.tblPr
|
||||
layout = OxmlElement("w:tblLayout")
|
||||
layout.set(qn("w:type"), "fixed")
|
||||
tbl_pr.append(layout)
|
||||
grid = table._tbl.tblGrid
|
||||
for idx, width in enumerate(widths_cm):
|
||||
if idx < len(grid.gridCol_lst):
|
||||
grid.gridCol_lst[idx].set(qn("w:w"), str(int(Cm(width).twips)))
|
||||
for row in table.rows:
|
||||
for idx, width in enumerate(widths_cm):
|
||||
if idx < len(row.cells):
|
||||
row.cells[idx].width = Cm(width)
|
||||
|
||||
|
||||
def style_tables(doc) -> None:
|
||||
for t_idx, table in enumerate(doc.tables):
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
table.autofit = True
|
||||
set_table_borders(table)
|
||||
set_cell_margins(table)
|
||||
for r_idx, row in enumerate(table.rows):
|
||||
if r_idx == 0:
|
||||
set_repeat_header(row)
|
||||
else:
|
||||
tr_pr = row._tr.get_or_add_trPr()
|
||||
cant_split = OxmlElement("w:cantSplit")
|
||||
cant_split.set(qn("w:val"), "true")
|
||||
tr_pr.append(cant_split)
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
p.paragraph_format.first_line_indent = Cm(0)
|
||||
p.paragraph_format.space_before = Pt(1)
|
||||
p.paragraph_format.space_after = Pt(1)
|
||||
p.paragraph_format.line_spacing = 1.0
|
||||
if r_idx < len(table.rows) - 1:
|
||||
p.paragraph_format.keep_with_next = True
|
||||
for run in p.runs:
|
||||
set_run_font(
|
||||
run, BODY_FONT, 9.5 if len(table.columns) >= 5 else 10,
|
||||
bold=(r_idx == 0),
|
||||
)
|
||||
if r_idx == 0:
|
||||
shade(cell, "F1E8D8")
|
||||
# Ширина таблицы по ширине полей.
|
||||
tbl_pr = table._tbl.tblPr
|
||||
tbl_w = OxmlElement("w:tblW")
|
||||
tbl_w.set(qn("w:type"), "pct")
|
||||
tbl_w.set(qn("w:w"), "100%")
|
||||
tbl_pr.append(tbl_w)
|
||||
if t_idx < len(TABLE_WIDTHS) and len(TABLE_WIDTHS[t_idx]) == len(table.columns):
|
||||
set_column_widths(table, TABLE_WIDTHS[t_idx])
|
||||
# Небольшой отступ между таблицей и следующим абзацем.
|
||||
spacer = doc.add_paragraph()
|
||||
spacer.paragraph_format.space_before = Pt(0)
|
||||
spacer.paragraph_format.space_after = Pt(0)
|
||||
spacer.paragraph_format.line_spacing = 1.0
|
||||
sr = spacer.add_run("")
|
||||
sr.font.size = Pt(4)
|
||||
table._tbl.addnext(spacer._p)
|
||||
|
||||
|
||||
def style_blockquotes(doc) -> None:
|
||||
for p in doc.paragraphs:
|
||||
if p.style.name == "Block Text":
|
||||
add_left_border(p)
|
||||
|
||||
|
||||
def add_math_font_note(doc) -> None:
|
||||
"""OMML-формулы наследуют Cambria Math; явно задаём её для совместимости."""
|
||||
for r in doc.element.body.iter(qn("m:r")):
|
||||
rpr = r.find(qn("w:rPr"))
|
||||
if rpr is None:
|
||||
rpr = OxmlElement("w:rPr")
|
||||
r.insert(0, rpr)
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), "Cambria Math")
|
||||
rfonts.set(qn("w:hAnsi"), "Cambria Math")
|
||||
|
||||
|
||||
def build_docx() -> None:
|
||||
BUILD.mkdir(parents=True, exist_ok=True)
|
||||
raw = BUILD / "REPORT_raw.docx"
|
||||
run([
|
||||
"pandoc", str(REPORT_MD),
|
||||
"-f", "markdown+tex_math_dollars+tex_math_single_backslash+raw_tex",
|
||||
"-t", "docx",
|
||||
"-o", str(raw),
|
||||
f"--resource-path={REPORT_MD.parent}",
|
||||
])
|
||||
doc = docx.Document(raw)
|
||||
style_document(doc)
|
||||
section = doc.sections[0]
|
||||
section.different_first_page_header_footer = True
|
||||
add_header_footer(doc)
|
||||
# Титульный лист без колонтитулов.
|
||||
for part in (section.first_page_header, section.first_page_footer):
|
||||
part.is_linked_to_previous = False
|
||||
for paragraph in part.paragraphs:
|
||||
paragraph.text = ""
|
||||
add_title_page_notes(doc)
|
||||
number_figures(doc)
|
||||
add_table_captions(doc)
|
||||
style_tables(doc)
|
||||
style_blockquotes(doc)
|
||||
add_math_font_note(doc)
|
||||
doc.core_properties.title = "Отчёт по лабораторной работе №1"
|
||||
doc.core_properties.subject = "Классификация текстов на основе стилометрических характеристик"
|
||||
doc.core_properties.author = "А. А. Аладьина, В. Н. Копнин, М. В. Савостиков"
|
||||
doc.core_properties.language = "ru-RU"
|
||||
doc.save(DOCX_OUT)
|
||||
print(f"DOCX: {DOCX_OUT}")
|
||||
|
||||
|
||||
def build_pdf() -> None:
|
||||
if shutil.which("libreoffice") is None:
|
||||
raise SystemExit("libreoffice не найден: PDF не собран")
|
||||
run([
|
||||
"libreoffice", "--headless", "--convert-to", "pdf",
|
||||
"--outdir", str(PDF_OUT.parent), str(DOCX_OUT),
|
||||
])
|
||||
produced = PDF_OUT.parent / (DOCX_OUT.stem + ".pdf")
|
||||
if produced != PDF_OUT and produced.exists():
|
||||
produced.replace(PDF_OUT)
|
||||
print(f"PDF: {PDF_OUT}")
|
||||
|
||||
|
||||
def check_pdf() -> None:
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
print("PyMuPDF не установлен: пропускаю проверку PDF")
|
||||
return
|
||||
doc = fitz.open(PDF_OUT)
|
||||
print(f"PDF страниц: {doc.page_count}")
|
||||
text = "\n".join(page.get_text() for page in doc)
|
||||
required = [
|
||||
"Аннотация", "Постановка задачи", "Корпус", "Стилометрические признаки",
|
||||
"Классификаторы", "Схема верификации", "Результаты", "Обсуждение",
|
||||
"Ограничения", "Выводы", "Воспроизводимость",
|
||||
]
|
||||
missing = [item for item in required if item.lower() not in text.lower()]
|
||||
if missing:
|
||||
print("Не найдены разделы:", ", ".join(missing))
|
||||
raise SystemExit(1)
|
||||
print("Все основные разделы найдены в PDF")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--skip-pdf", action="store_true", help="только DOCX")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not REPORT_MD.exists():
|
||||
raise SystemExit(f"нет файла {REPORT_MD}")
|
||||
build_docx()
|
||||
if not args.skip_pdf:
|
||||
build_pdf()
|
||||
check_pdf()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
208
lab1/scripts/package_submission.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Собирает итоговый ZIP-архив лабораторной работы.
|
||||
|
||||
Запуск из каталога lab1:
|
||||
|
||||
python scripts/package_submission.py
|
||||
|
||||
Скрипт копирует только нужные файлы во временный каталог, формирует
|
||||
MANIFEST.txt и CHECKSUMS.sha256, затем упаковывает всё в
|
||||
dist/nlp_lab1_submission_YYYYMMDD.zip. Рядом кладётся .sha256 самого архива.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
DEFAULT_NAME = f"nlp_lab1_submission_{date.today():%Y%m%d}"
|
||||
|
||||
EXCLUDE_PREFIXES = (
|
||||
"data/processed/",
|
||||
"features/",
|
||||
"results/",
|
||||
"nltk_data/",
|
||||
".venv/",
|
||||
".git/",
|
||||
"presentation/preview/",
|
||||
"report/build/",
|
||||
".pytest_cache/",
|
||||
"dist/",
|
||||
"exports/nlp_lab1_results_",
|
||||
"exports/nlp_lab1_final_results_20261026_120000.tar.gz.sha256",
|
||||
)
|
||||
|
||||
EXCLUDE_NAMES = {
|
||||
"stardew_stylometry.bak",
|
||||
"START_HERE.md",
|
||||
"ARCHIVE_README.md",
|
||||
}
|
||||
|
||||
LATEX_AUX_SUFFIXES = {".aux", ".log", ".nav", ".out", ".snm", ".toc"}
|
||||
|
||||
KEY_FILES = [
|
||||
"lab1/report/REPORT.pdf",
|
||||
"lab1/report/REPORT.docx",
|
||||
"lab1/report/REPORT.md",
|
||||
"lab1/presentation/stardew_stylometry.pdf",
|
||||
"lab1/presentation/stardew_stylometry.pptx",
|
||||
"lab1/presentation/latex/stardew_stylometry.pdf",
|
||||
"lab1/presentation/latex/stardew_stylometry.tex",
|
||||
"lab1/FINAL_RESULTS.csv",
|
||||
"lab1/data/dialogues.csv",
|
||||
"lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz",
|
||||
]
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def should_copy(rel: Path) -> bool:
|
||||
posix = rel.as_posix()
|
||||
if rel.name in EXCLUDE_NAMES:
|
||||
return False
|
||||
if "__pycache__" in rel.parts or rel.suffix == ".pyc":
|
||||
return False
|
||||
if rel.suffix in LATEX_AUX_SUFFIXES:
|
||||
return False
|
||||
if any(posix == prefix.rstrip("/") or posix.startswith(prefix)
|
||||
for prefix in EXCLUDE_PREFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def copy_tree(stage: Path) -> int:
|
||||
target_root = stage / "lab1"
|
||||
count = 0
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT)
|
||||
if not should_copy(rel):
|
||||
continue
|
||||
dst = target_root / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dst)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def write_manifest(stage: Path) -> int:
|
||||
files = sorted(p for p in stage.rglob("*") if p.is_file())
|
||||
lines = [
|
||||
"Архив: " + stage.name,
|
||||
f"Файлов в манифесте: {len(files)} (сам MANIFEST.txt не включён)",
|
||||
"",
|
||||
f"{'SHA-256':64} {'Размер':>12} Путь",
|
||||
"-" * 100,
|
||||
]
|
||||
total = 0
|
||||
for path in files:
|
||||
size = path.stat().st_size
|
||||
total += size
|
||||
rel = path.relative_to(stage).as_posix()
|
||||
lines.append(f"{sha256(path)} {size:>12} {rel}")
|
||||
lines.append("-" * 100)
|
||||
lines.append(f"Итого байт: {total}")
|
||||
(stage / "MANIFEST.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return len(files)
|
||||
|
||||
|
||||
def write_checksums(stage: Path) -> None:
|
||||
lines = []
|
||||
for rel_name in KEY_FILES:
|
||||
path = stage / rel_name
|
||||
if path.exists():
|
||||
lines.append(f"{sha256(path)} {rel_name}")
|
||||
else:
|
||||
print(f"ВНИМАНИЕ: нет ключевого файла {rel_name}")
|
||||
(stage / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def zip_stage(stage: Path, archive: Path) -> None:
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6) as zf:
|
||||
for path in sorted(stage.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(stage).as_posix()
|
||||
arcname = f"{stage.name}/{rel}"
|
||||
info = zipfile.ZipInfo.from_file(path, arcname)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
with path.open("rb") as src, zf.open(info, "w") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
||||
if n < 1024 or unit == "ГБ":
|
||||
return f"{n:.1f} {unit}" if unit != "Б" else f"{n} {unit}"
|
||||
n /= 1024
|
||||
return f"{n} Б"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--name", default=DEFAULT_NAME, help="имя каталога и ZIP")
|
||||
parser.add_argument("--keep-stage", action="store_true",
|
||||
help="не удалять промежуточный каталог dist/stage")
|
||||
args = parser.parse_args()
|
||||
|
||||
name = args.name
|
||||
stage = DIST / "stage" / name
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"1/4. Копирование файлов в {stage} ...")
|
||||
copied = copy_tree(stage)
|
||||
shutil.copy2(ROOT / "START_HERE.md", stage / "START_HERE.md")
|
||||
shutil.copy2(ROOT / "ARCHIVE_README.md", stage / "ARCHIVE_README.md")
|
||||
|
||||
print("2/4. Формирование CHECKSUMS.sha256 ...")
|
||||
write_checksums(stage)
|
||||
|
||||
print("3/4. Формирование MANIFEST.txt ...")
|
||||
total = write_manifest(stage)
|
||||
|
||||
archive = DIST / f"{name}.zip"
|
||||
print(f"4/4. Упаковка {archive} ...")
|
||||
zip_stage(stage, archive)
|
||||
|
||||
digest = sha256(archive)
|
||||
(DIST / f"{name}.zip.sha256").write_text(
|
||||
f"{digest} {archive.name}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"Файлов в lab1: {copied}")
|
||||
print(f"Всего файлов в архиве: {total + 1}")
|
||||
print(f"Размер ZIP: {human(archive.stat().st_size)}")
|
||||
print(f"SHA-256 ZIP: {digest}")
|
||||
print(f"Архив: {archive}")
|
||||
print(f"Контрольная сумма: {archive}.sha256")
|
||||
|
||||
if not args.keep_stage:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
(DIST / "stage").rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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
@@ -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())
|
||||