Добавлены итоговый отчёт и архив лабораторной работы
3
lab1/.gitignore
vendored
@@ -18,3 +18,6 @@ __pycache__/
|
||||
/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
|
||||
```
|
||||
@@ -136,6 +136,25 @@ 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 есть все разделы.
|
||||
|
||||
## Презентация
|
||||
|
||||
Есть две версии:
|
||||
@@ -148,7 +167,7 @@ python -m stylometry.cli subgroup-report \
|
||||
- **`presentation/latex/stardew_stylometry.pdf`** — собранный PDF LaTeX-версии
|
||||
(30 страниц).
|
||||
|
||||
LaTeX-версия содержит три блока на трёх докладчиков, фон и курицу на каждом
|
||||
LaTeX-версия содержит три части на трёх докладчиков, фон и курицу на каждом
|
||||
слайде, титульную заставку, изображение персонажей, две круговые диаграммы,
|
||||
матрицы ошибок, F-меры по персонажам, приложение с полной матрицей и финальный
|
||||
мем. Формул почти нет: одна компактная запись обозначений на слайде постановки
|
||||
@@ -180,6 +199,10 @@ python -m stylometry.cli run --config configs/default.toml \
|
||||
|
||||
## Основные документы
|
||||
|
||||
- **`START_HERE.md`** — краткая инструкция: что открыть и как проверить
|
||||
окружение за три минуты.
|
||||
- **`ARCHIVE_README.md`** — подробное описание архива: состав, установка,
|
||||
запуск, структура модулей, пересборка отчёта и презентации.
|
||||
- **`RUNBOOK.md`** — пошаговая инструкция для запуска полной серии
|
||||
экспериментов: установка, проверка окружения, все команды, упаковка
|
||||
результатов и типичные ошибки.
|
||||
|
||||
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
|
||||
@@ -1,15 +1,15 @@
|
||||
# Презентация
|
||||
|
||||
Готовая презентация: `stardew_stylometry.pptx` (16:9, 32 слайда).
|
||||
Готовая презентация: `stardew_stylometry.pptx` (16:9, 30 слайдов в финальной версии; исходная автоматическая сборка — 32 слайда).
|
||||
PDF-версия для просмотра: `stardew_stylometry.pdf`.
|
||||
|
||||
## Структура
|
||||
|
||||
- **Блок 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка,
|
||||
- **Часть 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка,
|
||||
распределение реплик и длин, ограничения.
|
||||
- **Блок 2. Методология** — признаки, LSA, логистическая регрессия, SVM,
|
||||
- **Часть 2. Методология** — признаки, LSA, логистическая регрессия, SVM,
|
||||
деревья, ансамбли, перекрёстная проверка и метрики.
|
||||
- **Блок 3. Результаты** — сравнение моделей, вклад признаков, контроль
|
||||
- **Часть 3. Результаты** — сравнение моделей, вклад признаков, контроль
|
||||
содержания, матрицы ошибок, классы-аттракторы, выводы.
|
||||
|
||||
На каждом слайде есть фон `images/background.jpg` и маленькая курица
|
||||
@@ -58,7 +58,7 @@ python presentation/build_deck.py
|
||||
- `latex/stardew_stylometry.pdf` — собранный PDF, 30 страниц;
|
||||
- сборка: `cd latex && xelatex stardew_stylometry.tex` (два прохода).
|
||||
|
||||
В LaTeX-версии три блока на трёх докладчиков, фон и курица на каждом слайде,
|
||||
В LaTeX-версии три части на трёх докладчиков, фон и курица на каждом слайде,
|
||||
титульная заставка, изображение персонажей, две круговые диаграммы, матрицы
|
||||
ошибок, F-меры по персонажам, приложение с полной матрицей и финальный мем.
|
||||
Формул почти нет: одна компактная запись обозначений на слайде постановки
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
% Рисуется в конце каждой рамки поверх содержимого и не меняет вёрстку.
|
||||
\newcommand{\slidefooter}{%
|
||||
\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)
|
||||
{\colorbox{cream}{\scriptsize\color{inkbrown}%
|
||||
Слайд \insertframenumber\ из \inserttotalframenumber
|
||||
\quad·\quad осталось: \the\numexpr\inserttotalframenumber-\insertframenumber\relax}};
|
||||
\insertframenumber\slash\inserttotalframenumber
|
||||
\quad}};
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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())
|
||||