Добавлены итоговый отчёт и архив лабораторной работы

This commit is contained in:
2026-11-01 01:25:15 +03:00
parent 09fc05c327
commit 32b61750bf
26 changed files with 2081 additions and 9 deletions

3
lab1/.gitignore vendored
View File

@@ -18,3 +18,6 @@ __pycache__/
/presentation/latex/*.toc /presentation/latex/*.toc
/presentation/latex/*.synctex.gz /presentation/latex/*.synctex.gz
/presentation/latex/preview/ /presentation/latex/preview/
/report/build/
/dist/stage/

529
lab1/ARCHIVE_README.md Normal file
View 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
```

View File

@@ -136,6 +136,25 @@ python -m stylometry.cli subgroup-report \
| `lsa_word`, `lsa_pos` | только соответствующий вариант LSA | | `lsa_word`, `lsa_pos` | только соответствующий вариант LSA |
| `length` | только признаки длины (нижняя граница) | | `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 есть все разделы.
## Презентация ## Презентация
Есть две версии: Есть две версии:
@@ -148,7 +167,7 @@ python -m stylometry.cli subgroup-report \
- **`presentation/latex/stardew_stylometry.pdf`** — собранный PDF LaTeX-версии - **`presentation/latex/stardew_stylometry.pdf`** — собранный PDF LaTeX-версии
(30 страниц). (30 страниц).
LaTeX-версия содержит три блока на трёх докладчиков, фон и курицу на каждом LaTeX-версия содержит три части на трёх докладчиков, фон и курицу на каждом
слайде, титульную заставку, изображение персонажей, две круговые диаграммы, слайде, титульную заставку, изображение персонажей, две круговые диаграммы,
матрицы ошибок, F-меры по персонажам, приложение с полной матрицей и финальный матрицы ошибок, F-меры по персонажам, приложение с полной матрицей и финальный
мем. Формул почти нет: одна компактная запись обозначений на слайде постановки мем. Формул почти нет: одна компактная запись обозначений на слайде постановки
@@ -180,6 +199,10 @@ python -m stylometry.cli run --config configs/default.toml \
## Основные документы ## Основные документы
- **`START_HERE.md`** — краткая инструкция: что открыть и как проверить
окружение за три минуты.
- **`ARCHIVE_README.md`** — подробное описание архива: состав, установка,
запуск, структура модулей, пересборка отчёта и презентации.
- **`RUNBOOK.md`** — пошаговая инструкция для запуска полной серии - **`RUNBOOK.md`** — пошаговая инструкция для запуска полной серии
экспериментов: установка, проверка окружения, все команды, упаковка экспериментов: установка, проверка окружения, все команды, упаковка
результатов и типичные ошибки. результатов и типичные ошибки.

81
lab1/START_HERE.md Normal file
View 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; корпус используется
исключительно в учебных и исследовательских целях.

Binary file not shown.

View File

@@ -0,0 +1 @@
635ed9d56653259be30733fe8b80316e8ce1f3b32ac178170f5deef0f3e39d79 nlp_lab1_submission_20261101.zip

View File

@@ -1,15 +1,15 @@
# Презентация # Презентация
Готовая презентация: `stardew_stylometry.pptx` (16:9, 32 слайда). Готовая презентация: `stardew_stylometry.pptx` (16:9, 30 слайдов в финальной версии; исходная автоматическая сборка — 32 слайда).
PDF-версия для просмотра: `stardew_stylometry.pdf`. PDF-версия для просмотра: `stardew_stylometry.pdf`.
## Структура ## Структура
- **Блок 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка, - **Часть 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка,
распределение реплик и длин, ограничения. распределение реплик и длин, ограничения.
- **Блок 2. Методология** — признаки, LSA, логистическая регрессия, SVM, - **Часть 2. Методология** — признаки, LSA, логистическая регрессия, SVM,
деревья, ансамбли, перекрёстная проверка и метрики. деревья, ансамбли, перекрёстная проверка и метрики.
- **Блок 3. Результаты** — сравнение моделей, вклад признаков, контроль - **Часть 3. Результаты** — сравнение моделей, вклад признаков, контроль
содержания, матрицы ошибок, классы-аттракторы, выводы. содержания, матрицы ошибок, классы-аттракторы, выводы.
На каждом слайде есть фон `images/background.jpg` и маленькая курица На каждом слайде есть фон `images/background.jpg` и маленькая курица
@@ -58,7 +58,7 @@ python presentation/build_deck.py
- `latex/stardew_stylometry.pdf` — собранный PDF, 30 страниц; - `latex/stardew_stylometry.pdf` — собранный PDF, 30 страниц;
- сборка: `cd latex && xelatex stardew_stylometry.tex` (два прохода). - сборка: `cd latex && xelatex stardew_stylometry.tex` (два прохода).
В LaTeX-версии три блока на трёх докладчиков, фон и курица на каждом слайде, В LaTeX-версии три части на трёх докладчиков, фон и курица на каждом слайде,
титульная заставка, изображение персонажей, две круговые диаграммы, матрицы титульная заставка, изображение персонажей, две круговые диаграммы, матрицы
ошибок, F-меры по персонажам, приложение с полной матрицей и финальный мем. ошибок, F-меры по персонажам, приложение с полной матрицей и финальный мем.
Формул почти нет: одна компактная запись обозначений на слайде постановки Формул почти нет: одна компактная запись обозначений на слайде постановки

View File

@@ -40,11 +40,11 @@
% Рисуется в конце каждой рамки поверх содержимого и не меняет вёрстку. % Рисуется в конце каждой рамки поверх содержимого и не меняет вёрстку.
\newcommand{\slidefooter}{% \newcommand{\slidefooter}{%
\begin{tikzpicture}[remember picture, overlay] \begin{tikzpicture}[remember picture, overlay]
\node[anchor=north east, xshift=-0.55cm, yshift=-0.22cm] \node[anchor=north east, xshift=-0.1cm, yshift=-0.1cm]
at (current page.north east) at (current page.north east)
{\colorbox{cream}{\scriptsize\color{inkbrown}% {\colorbox{cream}{\scriptsize\color{inkbrown}%
Слайд \insertframenumber\ из \inserttotalframenumber \insertframenumber\slash\inserttotalframenumber
\quad·\quad осталось: \the\numexpr\inserttotalframenumber-\insertframenumber\relax}}; \quad}};
\end{tikzpicture}% \end{tikzpicture}%
} }

BIN
lab1/report/REPORT.docx Normal file

Binary file not shown.

652
lab1/report/REPORT.md Normal file
View 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 |
![Распределение числа реплик по персонажам](figures/replics-count.png){width=70%}
![Медианная длина предложения в символах по персонажам](figures/median.png){width=82%}
![Персонажи корпуса](figures/characters.png){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.
![Сравнение моделей](figures/model_comparison.png){width=82%}
## 6.2. Вклад признаков
| Условие | Macro-F1 |
|---|---:|
| Только длина | 0,035 |
| Только ручные стилометрические признаки | 0,105 |
| Ручные + символьные и частеречные n-граммы | 0,182 |
| То же + `content`-LSA | 0,249 |
| ExtraTrees с расширенным символьным словарём | 0,276 |
| Мягкое голосование | 0,304 |
![Вклад признаков](figures/feature_progression.png){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 |
![Контроль содержания](figures/topic_control.png){width=68%}
Падение более чем вдвое показывает, что значительная часть качества связана
с лексическим содержанием. Остаток 0,113 всё ещё в 3,9 раза выше случайного
уровня: некоторый стилевой сигнал сохраняется, но он слабее тематического.
## 6.4. Матрицы ошибок
Строки матрицы — истинный персонаж, столбцы — предсказание модели, диагональ
— правильные ответы, вне диагонали — ошибки. Полная матрица 34×34 на доске
нечитаема; поэтому в основном тексте показаны 12 самых частых персонажей и
столбец «прочие», а также 18 самых частых пар ошибок.
![Матрица ошибок: 12 персонажей](figures/confusion_top12.png){width=92%}
![Самые частые пары ошибок](figures/confusion_pairs.png){width=92%}
Основные пары ошибок (за три повтора): Sebastian → Abigail (65),
Sebastian → Alex (61), Abigail → Sebastian (59), Abigail → Alex (56).
Крупнейший клубок — молодые персонажи Abigail, Alex, Sebastian, Haley, Sam.
Многие из них писались в одних и тех же обновлениях и сюжетных контекстах,
поэтому сходство голосов ожидаемо; это не разные авторы.
## 6.5. Ошибки по персонажам
![F-мера по персонажам](figures/per_class_f1.png){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.
![Классы-аттракторы](figures/hub_shares.png){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. Полная матрица ошибок
![Полная матрица ошибок 34×34](figures/confusion_full34.png){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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -0,0 +1,5 @@
# Дополнительные зависимости для пересборки отчёта.
# Основные зависимости проекта — в requirements.txt.
python-docx>=1.1,<2
PyMuPDF>=1.24,<2
Pillow>=10,<13

View 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())

View 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())