diff --git a/lab1/presentation/README.md b/lab1/presentation/README.md new file mode 100644 index 0000000..d1bd5a0 --- /dev/null +++ b/lab1/presentation/README.md @@ -0,0 +1,42 @@ +# Презентация + +Готовая презентация: `stardew_stylometry.pptx` (16:9, 32 слайда). +PDF-версия для просмотра: `stardew_stylometry.pdf`. + +## Структура + +- **Блок 1. Корпус** — 2005 реплик, 34 персонажа, сбор, очистка, разметка, + распределение реплик и длин, ограничения. +- **Блок 2. Методология** — признаки, LSA, логистическая регрессия, SVM, + деревья, ансамбли, перекрёстная проверка и метрики. +- **Блок 3. Результаты** — сравнение моделей, вклад признаков, контроль + содержания, матрицы ошибок, классы-аттракторы, выводы. + +На каждом слайде есть фон `images/background.jpg` и маленькая курица +`images/chicken.png` как пасхалка. На титульном слайде — `images/title.jpg`; +на слайде о персонажах — `images/characters.webp`; на последнем слайде — мем +`images/meme.webp`. Круговые диаграммы — `images/replics-count.png` и +`images/median.png`. + +## Сборка + +```bash +cd lab1 +python -m pip install -r presentation/requirements.txt +python presentation/make_slide_figures.py +python presentation/build_deck.py +``` + +`make_slide_figures.py` строит матрицы ошибок и сравнительные диаграммы из +`results/final/finalists/voting_et_char_lean/oof_predictions.csv` и +`FINAL_RESULTS.csv`. `build_deck.py` собирает PPTX, добавляет заметки +докладчика, фон, курицу и изображения. + +## Редактирование + +Текст и порядок слайдов задаются в `build_deck.py`. После изменения текста +достаточно повторно запустить сборку. Готовый PPTX можно править и напрямую +в PowerPoint/LibreOffice; заметки докладчика находятся в заметках слайдов. + +Изображения игры используются в учебных целях; права принадлежат их +правообладателям. diff --git a/lab1/presentation/build_deck.py b/lab1/presentation/build_deck.py new file mode 100644 index 0000000..216a853 --- /dev/null +++ b/lab1/presentation/build_deck.py @@ -0,0 +1,809 @@ +"""Сборка презентации по лабораторной работе. + +Требования: +- 16:9, фон на каждом слайде; +- три содержательных блока на трёх рассказчиков; +- курица на каждом слайде; +- титульная картинка, персонажи, две круговые диаграммы, матрицы ошибок, + мем на последнем слайде; +- тёмный текст на светлых панелях. +""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt + +ROOT = Path(__file__).resolve().parents[1] +IMAGES = ROOT / "presentation" / "images" +GEN = ROOT / "presentation" / "generated" +OUT = ROOT / "presentation" / "stardew_stylometry.pptx" + +SW, SH = 13.333, 7.5 +FONT = "Arial" + +CREAM = RGBColor(248, 241, 220) +CREAM_LIGHT = RGBColor(255, 252, 240) +DARK = RGBColor(30, 45, 33) +GREEN = RGBColor(49, 93, 58) +GREEN_DARK = RGBColor(28, 63, 47) +GOLD = RGBColor(204, 153, 51) +BROWN = RGBColor(126, 80, 42) +RED = RGBColor(166, 55, 43) +WHITE = RGBColor(255, 255, 255) +BORDER = RGBColor(181, 158, 111) + + +def add_background(slide) -> None: + slide.shapes.add_picture( + str(IMAGES / "background.jpg"), 0, 0, width=Inches(SW), height=Inches(SH) + ) + + +def add_panel(slide, x, y, w, h, fill=CREAM, line=BORDER, radius: float = 0.06): + shape = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = fill + shape.line.color.rgb = line + shape.line.width = Pt(1.1) + shape.shadow.inherit = False + try: + shape.adjustments[0] = radius + except Exception: + pass + return shape + + +def add_text( + slide, + x, + y, + w, + h, + text: str, + size: int = 18, + color=DARK, + bold: bool = False, + align=PP_ALIGN.LEFT, + anchor=MSO_ANCHOR.TOP, + margin: float = 0.08, +): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + frame = box.text_frame + frame.word_wrap = True + frame.vertical_anchor = anchor + frame.margin_left = Inches(margin) + frame.margin_right = Inches(margin) + frame.margin_top = Inches(0.04) + frame.margin_bottom = Inches(0.04) + paragraph = frame.paragraphs[0] + paragraph.alignment = align + run = paragraph.add_run() + run.text = text + run.font.name = FONT + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + return box + + +def add_bullets(slide, x, y, w, h, items, size: int = 16, color=DARK, title: str | None = None): + box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) + frame = box.text_frame + frame.word_wrap = True + frame.margin_left = Inches(0.08) + frame.margin_right = Inches(0.08) + frame.margin_top = Inches(0.04) + first = True + if title: + paragraph = frame.paragraphs[0] + paragraph.space_after = Pt(7) + run = paragraph.add_run() + run.text = title + run.font.name = FONT + run.font.size = Pt(size + 3) + run.font.bold = True + run.font.color.rgb = GREEN_DARK + first = False + for item in items: + if isinstance(item, tuple): + text, level = item + else: + text, level = item, 0 + paragraph = frame.paragraphs[0] if first else frame.add_paragraph() + first = False + prefix = "• " if level == 0 else "– " + paragraph.text = prefix + text + paragraph.level = level + paragraph.space_after = Pt(5) + paragraph.line_spacing = 1.0 + for run in paragraph.runs: + run.font.name = FONT + run.font.size = Pt(size if level == 0 else size - 1) + run.font.color.rgb = color + return box + + +def add_table(slide, x, y, w, h, rows, col_widths=None, font_size: int = 11, header: bool = True): + shape = slide.shapes.add_table(len(rows), len(rows[0]), Inches(x), Inches(y), Inches(w), Inches(h)) + table = shape.table + if col_widths: + total = sum(col_widths) + for index, value in enumerate(col_widths): + table.columns[index].width = Inches(w * value / total) + for row_index, row in enumerate(rows): + for col_index, value in enumerate(row): + cell = table.cell(row_index, col_index) + cell.margin_left = Inches(0.05) + cell.margin_right = Inches(0.05) + cell.margin_top = Inches(0.02) + cell.margin_bottom = Inches(0.02) + cell.fill.solid() + if header and row_index == 0: + cell.fill.fore_color.rgb = GREEN + else: + cell.fill.fore_color.rgb = CREAM_LIGHT if row_index % 2 else CREAM + paragraph = cell.text_frame.paragraphs[0] + paragraph.alignment = PP_ALIGN.LEFT if col_index == 0 else PP_ALIGN.CENTER + run = paragraph.add_run() + run.text = str(value) + run.font.name = FONT + run.font.size = Pt(font_size) + run.font.bold = header and row_index == 0 + run.font.color.rgb = WHITE if header and row_index == 0 else DARK + return table + + +def add_image_fit(slide, path: Path, x, y, max_w, max_h, panel: bool = False): + with Image.open(path) as image: + width, height = image.size + aspect = width / height + if max_w / max_h > aspect: + draw_h = max_h + draw_w = draw_h * aspect + else: + draw_w = max_w + draw_h = draw_w / aspect + draw_x = x + (max_w - draw_w) / 2 + draw_y = y + (max_h - draw_h) / 2 + if panel: + add_panel(slide, draw_x - 0.07, draw_y - 0.07, draw_w + 0.14, draw_h + 0.14, fill=CREAM_LIGHT) + slide.shapes.add_picture(str(path), Inches(draw_x), Inches(draw_y), width=Inches(draw_w), height=Inches(draw_h)) + + +def add_chicken(slide, x: float = 12.96, y: float = 7.12, size: float = 0.24): + slide.shapes.add_picture( + str(IMAGES / "chicken.png"), Inches(x), Inches(y), width=Inches(size), height=Inches(size) + ) + + +def add_notes(slide, text: str) -> None: + slide.notes_slide.notes_text_frame.text = text + + +def new_slide(): + slide = Presentation().slides.add_slide # placeholder, replaced below + return slide + + +def add_title_bar(slide, title: str, block: str = "", subtitle: str | None = None): + add_panel(slide, 0.42, 0.28, 12.49, 0.86, fill=CREAM) + add_text(slide, 0.58, 0.34, 10.2, 0.45, title, size=25, bold=True, color=GREEN_DARK) + if block: + add_text(slide, 10.0, 0.42, 2.65, 0.35, block, size=13, color=BROWN, align=PP_ALIGN.RIGHT) + if subtitle: + add_text(slide, 0.58, 0.76, 11.8, 0.3, subtitle, size=12, color=BROWN) + + +def add_footer(slide, block: str, number: int): + add_text(slide, 0.45, 7.12, 7.8, 0.24, block, size=10, color=DARK) + add_text(slide, 12.15, 7.12, 0.55, 0.24, str(number), size=10, color=DARK, align=PP_ALIGN.RIGHT) + + +def content_slide(prs, title: str, block: str, number: int, subtitle: str | None = None): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_background(slide) + add_title_bar(slide, title, block, subtitle) + add_footer(slide, block, number) + add_chicken(slide) + return slide + + +def section_slide(prs, number: int, block: str, title: str, subtitle: str, questions: list[str], notes: str, slide_number: int): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_background(slide) + add_panel(slide, 0.65, 0.9, 12.0, 5.6, fill=CREAM) + add_text(slide, 0.95, 1.15, 2.6, 1.0, block, size=22, bold=True, color=GOLD) + add_text(slide, 0.95, 2.05, 11.2, 0.9, title, size=38, bold=True, color=GREEN_DARK) + add_text(slide, 0.95, 3.05, 11.0, 0.6, subtitle, size=20, color=BROWN) + add_bullets(slide, 0.95, 3.9, 11.0, 1.9, questions, size=17, color=DARK) + add_text(slide, 0.95, 5.9, 7.0, 0.35, f"Рассказчик {number}", size=15, bold=True, color=RED) + add_footer(slide, block, slide_number) + add_chicken(slide) + add_notes(slide, notes) + return slide + + +def main() -> int: + prs = Presentation() + prs.slide_width = Inches(SW) + prs.slide_height = Inches(SH) + slide_no = 0 + + # 1. Титульный слайд + slide_no += 1 + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_background(slide) + add_panel(slide, 0.55, 1.15, 6.15, 4.25, fill=CREAM) + add_image_fit(slide, IMAGES / "title.jpg", 0.7, 1.3, 5.85, 3.9, panel=False) + add_panel(slide, 6.95, 1.15, 5.85, 5.45, fill=CREAM) + add_text(slide, 7.2, 1.45, 5.3, 0.35, "Лабораторная работа по анализу текстов", size=13, color=BROWN) + add_text(slide, 7.2, 1.9, 5.3, 1.55, "Можно ли различить голоса персонажей?", size=32, bold=True, color=GREEN_DARK) + add_text(slide, 7.2, 3.45, 5.25, 1.4, "Стилометрическая классификация 34 персонажей Stardew Valley по 2005 репликам", size=18, color=DARK) + add_text(slide, 7.2, 4.9, 5.3, 0.6, "Три блока · три рассказчика", size=16, bold=True, color=RED) + add_text(slide, 7.2, 5.28, 5.3, 0.45, "Корпус → методология → результаты", size=15, color=BROWN) + add_panel(slide, 0.55, 5.75, 12.25, 0.8, fill=CREAM_LIGHT) + add_text(slide, 0.75, 5.9, 11.9, 0.45, "2005 реплик · 34 персонажа · один автор · 5-блочная перекрёстная проверка", size=14, color=DARK, align=PP_ALIGN.CENTER) + add_chicken(slide, 12.95, 7.12, 0.26) + add_notes(slide, "Приветствие. Тема — стилометрия реплик Stardew Valley. Сразу уточнить: автор один, задача — различить речевые маски персонажей, а не установить авторство. Три блока: корпус, методология, результаты.") + + # 2. Структура + slide_no += 1 + slide = content_slide(prs, "Структура доклада", "Введение", slide_no) + blocks = [ + ("01", "Корпус", "Рассказчик 1", ["2005 реплик", "34 персонажа", "сбор, очистка, разметка", "ограничения данных"]), + ("02", "Методология", "Рассказчик 2", ["признаки и LSA", "классификаторы", "перекрёстная проверка", "метрики"]), + ("03", "Результаты", "Рассказчик 3", ["лучшие модели", "матрицы ошибок", "кого путает модель", "выводы"]), + ] + for index, (num, title, narrator, lines) in enumerate(blocks): + x = 0.45 + index * 4.27 + add_panel(slide, x, 1.5, 4.0, 5.15, fill=CREAM) + add_text(slide, x + 0.25, 1.7, 0.9, 0.5, num, size=30, bold=True, color=GOLD) + add_text(slide, x + 0.25, 2.3, 3.5, 0.5, title, size=24, bold=True, color=GREEN_DARK) + add_text(slide, x + 0.25, 2.9, 3.5, 0.35, narrator, size=13, color=RED) + add_bullets(slide, x + 0.25, 3.45, 3.5, 2.7, lines, size=14, color=DARK) + add_notes(slide, "Показать три блока и передать слово первому рассказчику. Подчеркнуть: сначала данные, затем методы, затем интерпретация.") + + # 3. Блок 1 + slide_no += 1 + section_slide( + prs, 1, "Блок 1", "Корпус: как собрали и разметили", + "Что именно мы классифицируем и почему это сложная задача", + ["Откуда данные и какова единица анализа", "Кто в корпусе и как распределены реплики", "Длина реплик и очистка", "Паспорт корпуса и ограничения"], + "Передать слово рассказчику 1. Блок отвечает на вопрос: что представляет собой выборка и можно ли ей доверять.", + slide_no, + ) + + # 4. Что анализируем + slide_no += 1 + slide = content_slide(prs, "Что анализируем", "Блок 1 · Корпус", slide_no) + add_panel(slide, 0.45, 1.4, 5.95, 5.4, fill=CREAM) + add_bullets(slide, 0.7, 1.6, 5.45, 4.9, [ + "Единица анализа — одна реплика персонажа.", + "Всего 2005 реплик; 34 персонажа; один автор.", + "Класс — имя персонажа; один объект относится ровно к одному классу.", + "Реплики короткие: в среднем 19 слов, медиана 17.", + "Корпус англоязычный; разметка — только текст и персонаж.", + ], size=17, title="Постановка задачи") + add_panel(slide, 6.65, 1.4, 6.2, 5.4, fill=CREAM_LIGHT) + add_text(slide, 6.85, 1.55, 5.8, 0.35, "Примеры разных голосов", size=16, bold=True, color=GREEN_DARK) + quotes = [ + ("Abigail (139 реплик)", "Oh, that's right... I heard someone new was moving onto that old farm."), + ("Jas (34 реплики)", "Wow, a birthday present! I love it!"), + ("Krobus (28 реплик)", "I wanted to give you this as a 'thank you' for letting me live here with you."), + ("Wizard (24 реплики)", "Ah, yes. I have predicted your arrival a long time ago, young PLAYER."), + ] + y = 1.98 + for name, quote in quotes: + add_text(slide, 6.85, y, 5.8, 0.3, name, size=13, bold=True, color=BROWN) + add_text(slide, 6.85, y + 0.28, 5.75, 0.55, f"«{quote}»", size=13, color=DARK) + y += 1.08 + add_notes(slide, "Объяснить: одна реплика — один объект. Показать четыре голоса: длинная речь Abigail, короткая детская реплика Jas, нечеловеческая вежливость Krobus, архаичный Wizard. Это иллюстрирует, что сигнал может быть в лексике, длине, пунктуации и синтаксисе.") + + # 5. Кто в корпусе + slide_no += 1 + slide = content_slide(prs, "Кто в корпусе", "Блок 1 · Корпус", slide_no) + add_panel(slide, 0.4, 1.35, 8.8, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "characters.png", 0.5, 1.42, 8.6, 5.4) + add_panel(slide, 9.3, 1.35, 3.6, 5.55, fill=CREAM) + add_bullets(slide, 9.48, 1.55, 3.25, 5.1, [ + "34 персонажа: подростки, взрослые, пожилые, дети и нелюди.", + "Все реплики написаны одним разработчиком; многие — в одних и тех же обновлениях и сюжетных контекстах.", + "Классы сильно неравномерны: от 15 до 139 реплик.", + "Метаданные о поле, возрасте и виде используются только для анализа ошибок, не как признаки.", + ], size=14, title="Состав") + add_notes(slide, "Показать портреты. Подчеркнуть: один автор и общие сюжетные контексты — поэтому сходство голосов ожидаемо. Метаданные не подаются классификатору; они нужны только для интерпретации ошибок.") + + # 6. Количество реплик + slide_no += 1 + slide = content_slide(prs, "Сколько реплик у каждого", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: число реплик по персонажам") + add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, IMAGES / "replics-count.png", 0.5, 1.42, 7.0, 5.4) + add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM) + add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [ + "Максимум — Abigail: 139 реплик; минимум — Mister Qi: 15.", + "Разница между классами почти десятикратная.", + "При 34 классах случайное угадывание даёт около 2,9%.", + "Поэтому доля правильных ответов обманчива: модель может выбирать частые классы.", + "Основная метрика — макро-усреднённая F-мера: каждый персонаж весит одинаково.", + ], size=16, title="Дисбаланс классов") + add_notes(slide, "Диаграмма показывает дисбаланс. Сразу объяснить, почему accuracy не подходит: модель, всегда выбирающая Abigail, получит около 7% правильных ответов, но почти нулевую macro-F1 по остальным 33 классам.") + + # 7. Длина реплик + slide_no += 1 + slide = content_slide(prs, "Длина реплик", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: медианная длина предложения в символах") + add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, IMAGES / "median.png", 0.5, 1.42, 7.0, 5.4) + add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM) + add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [ + "Средняя длина — 19,1 слова; медиана — 17 слов.", + "Диапазон — от 3 до 78 слов.", + "Медианная длина предложения в символах: от 52 у Vincent до 114 у Sandy.", + "Короткие реплики дают мало свидетельств; стилометрические оценки на них нестабильны.", + "Длина сама по себе — сильный, но нежелательный признак: она может стать прокси персонажа.", + ], size=15, title="Короткие тексты") + add_notes(slide, "Числа у подписей — медианная длина предложения в символах. Показать контраст Vincent/Jas и Sandy/Wizard. Сказать, что длина — законный признак, но её вклад нужно проверять отдельно, иначе модель будет различать не стиль, а «кто говорит длиннее».") + + # 8. Очистка и разметка + slide_no += 1 + slide = content_slide(prs, "Очистка и разметка", "Блок 1 · Корпус", slide_no) + add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [ + "Источник — игровые реплики; CSV в cp1251, XLSX как резерв.", + "Нормализация Unicode: многоточия, кавычки, тире, неразрывные пробелы.", + "Плейсхолдеры , и другие заменены нейтральными словами; их количество сохранено отдельным признаком.", + "Токенизация и разбиение на предложения — NLTK; частеречная разметка — Penn Treebank → Universal POS.", + "Стоп-слова, пунктуация и регистр не удалялись: это стилометрический сигнал.", + ], size=15, title="Что сделали") + add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [ + "Шум: дубликаты, технические артефакты, общие короткие фразы.", + "Ручная фильтрация уже удалила короткие и общие реплики; правила не сохранились.", + "В текущем CSV 0 точных дублей; 13 пар с косинусной близостью ≥ 0,8 — все внутри одного персонажа.", + "Нет идентификаторов сцен и диалогов; это ограничение анализа.", + "Разметка — только имя персонажа; никакой ручной стилистической разметки нет.", + ], size=15, title="Что считаем шумом") + add_notes(slide, "Объяснить различие между шумом и стилем. Удалять пунктуацию или стоп-слова нельзя: это носители стиля. Ручная фильтрация — ограничение: она могла удалить как раз короткие характерные реплики. Отметить, что почти-дубликаты не пересекают классы, поэтому утечки через них нет.") + + # 9. Паспорт корпуса + slide_no += 1 + slide = content_slide(prs, "Паспорт корпуса", "Блок 1 · Корпус", slide_no) + add_panel(slide, 0.45, 1.35, 7.1, 5.55, fill=CREAM_LIGHT) + rows = [ + ["Показатель", "Значение"], + ["Реплик", "2005"], + ["Персонажей", "34"], + ["Словоупотреблений", "38 207"], + ["Средняя длина", "19,1 слова"], + ["Медиана длины", "17 слов"], + ["Минимум / максимум", "3 / 78 слов"], + ["Классы", "15–139 реплик"], + ["Точные дубли", "0"], + ["Почти-дубли (cos ≥ 0,8)", "13 пар, все внутри класса"], + ] + add_table(slide, 0.7, 1.6, 6.6, 4.9, rows, col_widths=[3.4, 3.2], font_size=13) + add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM) + add_bullets(slide, 8.02, 1.6, 4.7, 5.0, [ + "Корпус пригоден для классификации, но не для сильных выводов о «чистом» стиле.", + "Ограничения: короткие тексты, дисбаланс, один автор, отсутствие сцен.", + "Все дальнейшие результаты нужно сравнивать с случайным уровнем 2,9%, а не с нулём.", + ], size=16, title="Что важно") + add_notes(slide, "Этот слайд — «паспорт» данных. Подчеркнуть: 2005 объектов и 34 класса — небольшая выборка; 15 реплик у Mister Qi дают очень широкий интервал. Поэтому дальше используем повторную перекрёстную проверку и доверительные интервалы.") + + # 10. Блок 2 + slide_no += 1 + section_slide( + prs, 2, "Блок 2", "Методология: как измеряем стиль", + "Признаки, модели и проверка — на понятном языке", + ["Какие свойства текста считаем", "Как работает LSA", "Как устроены классификаторы", "Как не обмануть себя при оценке"], + "Передать слово рассказчику 2. Блок объясняет, что именно подаётся модели и почему выбранным методам можно доверять.", + slide_no, + ) + + # 11. Общая схема + slide_no += 1 + slide = content_slide(prs, "Общая схема", "Блок 2 · Методология", slide_no) + steps = [ + ("Реплика", "текст персонажа"), + ("Очистка", "Unicode, плейсхолдеры, пробелы"), + ("Токены и POS", "NLTK, Universal POS"), + ("Признаки", "ручные, n-граммы, LSA"), + ("Модель", "вероятности по 34 классам"), + ("Проверка", "5 блоков, повторы, метрики"), + ] + x = 0.55 + for index, (title, subtitle) in enumerate(steps): + add_panel(slide, x, 2.0, 1.85, 2.35, fill=CREAM) + add_text(slide, x + 0.12, 2.18, 1.6, 0.45, title, size=15, bold=True, color=GREEN_DARK, align=PP_ALIGN.CENTER) + add_text(slide, x + 0.1, 2.7, 1.65, 1.1, subtitle, size=12, color=DARK, align=PP_ALIGN.CENTER) + if index < len(steps) - 1: + add_text(slide, x + 1.82, 2.7, 0.35, 0.4, "→", size=26, bold=True, color=RED, align=PP_ALIGN.CENTER) + x += 2.12 + add_panel(slide, 0.55, 4.75, 12.15, 1.55, fill=CREAM_LIGHT) + add_bullets(slide, 0.75, 4.9, 11.75, 1.2, [ + "Обучение и преобразования выполняются только внутри обучающих блоков; тест не участвует в настройке.", + "Вектор признаков — это не «понимание» текста, а числовое описание его статистических свойств.", + ], size=15) + add_notes(slide, "Провести по схеме слева направо. Главная мысль: модель не «читает» реплику как человек, а считает частоты и комбинации признаков. Отдельно подчеркнуть, что все преобразования обучаются внутри блоков, иначе возникает утечка.") + + # 12. Признаки + slide_no += 1 + slide = content_slide(prs, "Признаки: что именно считаем", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.1, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.7, 5.1, [ + "454 ручных признака в 11 группах.", + "Длина: слова, символы, предложения, длина слова.", + "Лексика и орфография: распределение длин, апострофы, заглавные, цифры.", + "Словарное богатство: TTR, Guiraud, Yule's K, hapax; на коротких текстах нестабильно.", + "Пунктуация: запятые, многоточия, восклицания, вопросы, повторы.", + "Разговорные маркеры: междометия, смех, хеджи, вежливость, сокращения.", + ], size=14, title="Ручные признаки") + add_panel(slide, 6.75, 1.35, 6.1, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 6.95, 1.55, 5.7, 5.1, [ + "Функциональные слова: около 150 закрытых слов и 12 категорий.", + "Местоимения: 1-е, 2-е, 3-е лицо; отношение «я/ты».", + "Части речи: Universal POS и Penn Treebank, время, модальность, герундий.", + "Синтаксис-прокси: клаузы, подчинительные союзы, пассив, вопросы.", + "Символьные n-граммы 2–5 и POS n-граммы 1–3.", + "LSA: сжатые векторы по словам, содержанию, POS и символам.", + ], size=14, title="Синтаксис и векторные блоки") + add_notes(slide, "Не перечислять все 454 признака. Объяснить логику: закрытые классы слов и пунктуация — классическая стилометрия; n-граммы — морфология и синтаксис; LSA — сжатое представление. Отметить, что словарное богатство и читаемость помечены как предварительные из-за коротких текстов.") + + # 13. LSA + slide_no += 1 + slide = content_slide(prs, "LSA простыми словами", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.6, 5.1, [ + "Строим таблицу «документ × термин» и взвешиваем частоты через TF-IDF.", + "Сжимаем таблицу сингулярным разложением до k скрытых осей.", + "Каждая реплика превращается в вектор из k чисел.", + "Оси не обязаны быть темами; их знак и порядок произвольны.", + "k выбирается по объяснённой дисперсии и внешней метрике.", + ], size=16, title="Идея") + add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [ + "word — лексика и тема.", + "content — содержание после удаления стоп-слов.", + "fw — функциональные слова и синтаксис.", + "char — морфология и пунктуация.", + "pos — частеречные последовательности.", + "В работе k = 50–400; для content-LSA качество росло до 400.", + ], size=15, title="Варианты") + add_panel(slide, 0.65, 5.65, 11.9, 0.8, fill=CREAM_LIGHT) + add_text(slide, 0.8, 5.78, 11.6, 0.5, "Ключевой вопрос: LSA по содержательным словам ловит тему, а не только стиль.", size=15, bold=True, color=RED, align=PP_ALIGN.CENTER) + add_notes(slide, "Объяснить SVD без формул: было много столбцов, стало несколько скрытых осей; это сжатие, а не «понимание». Вариант content может кодировать тему, поэтому его нельзя автоматически называть стилем. В конце блока результатов покажем контроль содержания.") + + # 14. Логистическая регрессия + slide_no += 1 + slide = content_slide(prs, "Логистическая регрессия", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM) + add_text(slide, 0.7, 1.55, 5.5, 0.35, "Как работает", size=18, bold=True, color=GREEN_DARK) + add_bullets(slide, 0.7, 1.95, 5.5, 4.7, [ + "Для каждого класса считает линейную оценку: сумма «признак × вес» плюс сдвиг.", + "Преобразует оценки в вероятности через softmax: они неотрицательны и суммируются в 1.", + "Обучение минимизирует перекрёстную энтропию — штраф за низкую вероятность истинного класса.", + "L2-регуляризация удерживает веса малыми; параметр C задаёт силу регуляризации.", + "class_weight='balanced' повышает вес редких классов.", + ], size=15) + add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [ + "Плюсы: устойчива при большом числе признаков, даёт вероятности, коэффициенты интерпретируемы.", + "Минусы: только линейные границы; при сильной корреляции признаков веса нестабильны.", + "В работе — основная линейная базовая модель.", + "Сравнение C: 0,03–10; балансировка классов критична.", + ], size=15, title="Что важно") + add_notes(slide, "Объяснить на пальцах: модель складывает голоса признаков с весами; softmax превращает их в вероятности. C — обратная сила штрафа: меньше C — проще модель. Отметить, что без балансировки редкие персонажи почти не находятся; это было видно в эксперименте.") + + # 15. SVM и деревья + slide_no += 1 + slide = content_slide(prs, "SVM, деревья и ансамбли", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.05, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.65, 5.1, [ + "Линейный SVM ищет границу с максимальным зазором; C штрафует ошибки.", + "SVM не даёт вероятностей: только решающую функцию; тройка лучших считается по её значениям.", + "Случайный лес: много деревьев, каждое на своей подвыборке; голосование.", + "Сверхслучайные деревья (ExtraTrees): пороги в узлах выбираются случайно; меньше дисперсии, быстрее.", + "Мягкое голосование усредняет вероятности нескольких моделей.", + "Стековое объединение обучает мета-классификатор на предсказаниях базовых моделей.", + ], size=14, title="Идеи моделей") + add_panel(slide, 6.7, 1.35, 6.15, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 6.9, 1.55, 5.75, 5.1, [ + "Деревья ловят нелинейные взаимодействия: «если многоточий и короткая реплика — это Jas».", + "Легко переобучаются на 2005 объектах; нужны ограничения глубины и листьев.", + "Дельта Барроуза: расстояние до центроида по частотным словам — классическая проверка.", + "Ансамбль полезен, если ошибки моделей не полностью совпадают.", + "Стековое объединение в наших данных переобучилось и оказалось хуже голосования.", + ], size=14, title="Сильные и слабые стороны") + add_notes(slide, "Не углубляться в оптимизацию. Главное: SVM — линейная граница с зазором; деревья — нелинейные правила; ExtraTrees — случайные пороги; голосование усредняет вероятности. Дельта Барроуза — независимая классическая проверка, а не ещё одна нейросеть.") + + # 16. Проверка + slide_no += 1 + slide = content_slide(prs, "Как проверяем", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.25, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [ + "Стратифицированная перекрёстная проверка: 5 блоков, доли классов сохраняются.", + "Повторяем 3–5 раз с разными разбиениями — оцениваем разброс.", + "Вложенная проверка: внешние блоки — оценка, внутренние — настройка.", + "Отложенная выборка 80/20 — независимая проверка финальных кандидатов.", + ], size=16, title="Схема") + add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [ + "TF-IDF, SVD, масштабирование и отбор признаков обучаются только на обучающей выборке.", + "Тестовая выборка не участвует в выборе гиперпараметров или признаков.", + "Проверка перестановкой меток дала 0,027–0,028 — случайный уровень.", + "Почти-дубликаты внутри одного класса не пересекают обучение и тест.", + "Метрики: макро-F1, сбалансированная доля, тройка лучших, MCC, каппа, матрица ошибок.", + ], size=15, title="Контроль утечек") + add_notes(slide, "Объяснить, почему нельзя настраивать на тесте. Привести аналогию: если подсматривать ответы, оценка будет завышена. Перестановка меток — контроль: если модель на случайных метках даёт 0,027, пайплайн не течёт.") + + # 17. Метрики + slide_no += 1 + slide = content_slide(prs, "Метрики: что и зачем", "Блок 2 · Методология", slide_no) + add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM_LIGHT) + rows = [ + ["Метрика", "Что показывает"], + ["Макро-F1", "Средняя F-мера по 34 классам; все персонажи равны"], + ["Доля правильных", "Доля совпавших ответов; обманчива при дисбалансе"], + ["Сбалансированная доля", "Средняя полнота по классам"], + ["Тройка лучших", "Истинный класс в тройке лучших"], + ["MCC / каппа", "Согласие с поправкой на случай"], + ["Матрица ошибок", "Кто с кем путается"], + ] + add_table(slide, 0.65, 1.55, 5.75, 4.9, rows, col_widths=[2.2, 3.6], font_size=12) + add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM) + add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [ + "Случайный уровень при 34 классах: macro-F1 ≈ 0,029, top-3 ≈ 0,088.", + "Модель, всегда выбирающая Abigail: доля правильных ответов ≈ 6,9%, macro-F1 ≈ 0,004.", + "Поэтому основной вывод делаем по macro-F1 и доверительным интервалам.", + "Дополнительно смотрим F-меру по каждому персонажу и матрицу ошибок.", + ], size=16, title="Почему не доля правильных ответов") + add_notes(slide, "Главный методический слайд: объяснить, что accuracy при 34 классах может вводить в заблуждение. Пример Dummy: 6,9% accuracy, но почти нулевая macro-F1. Поэтому все выводы — по macro-F1, а per-class F1 показывает, кого именно модель не находит.") + + # 18. Блок 3 + slide_no += 1 + section_slide( + prs, 3, "Блок 3", "Результаты и интерпретация", + "Что получилось, кого модель путает и что это значит", + ["Лучшие модели и масштаб качества", "Стиль или содержание", "Матрицы ошибок и пары персонажей", "Ограничения и выводы"], + "Передать слово рассказчику 3. Здесь важно не только назвать число, но и объяснить, что оно означает и чего оно не означает.", + slide_no, + ) + + # 19. Главный результат + slide_no += 1 + slide = content_slide(prs, "Главный результат", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "model_comparison.png", 0.5, 1.45, 8.8, 5.35) + add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM) + add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [ + "Мягкое голосование: 0,304.", + "ExtraTrees, char=10000: 0,276.", + "Вложенный выбор: 0,275.", + "Случайный уровень: 0,029.", + "На отложенной выборке: 0,333.", + ], size=15, title="Макро-F1") + add_notes(slide, "Назвать числа и сразу масштаб: 0,30 при 34 классах — это примерно в 10 раз выше случайного. Не говорить «низкая точность» без сравнения с 0,029. Оговорить, что 0,333 на отложенной выборке — одно разбиение и широкий интервал.") + + # 20. Что добавляет качество + slide_no += 1 + slide = content_slide(prs, "Что добавляет качество", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.4, 1.35, 8.95, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "feature_progression.png", 0.5, 1.45, 8.75, 5.35) + add_panel(slide, 9.5, 1.35, 3.4, 5.55, fill=CREAM) + add_bullets(slide, 9.67, 1.55, 3.1, 5.1, [ + "Ручные признаки: 0,105.", + "+ char/POS n-граммы: 0,182.", + "+ content-LSA: 0,249.", + "ExtraTrees + char=10000: 0,276.", + "Мягкое голосование: 0,304.", + "Основной прирост — лексика и n-граммы, не «чистый синтаксис».", + ], size=14, title="По шагам") + add_notes(slide, "Показать, что качество растёт не от «магии модели», а от добавления лексических и символьных признаков. Отметить: функциональные слова и POS- LSA сами по себе дают только 0,06–0,07.") + + # 21. Стиль или содержание + slide_no += 1 + slide = content_slide(prs, "Стиль или содержание?", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.4, 1.35, 8.4, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "topic_control.png", 0.5, 1.45, 8.2, 5.35) + add_panel(slide, 8.95, 1.35, 3.95, 5.55, fill=CREAM) + add_bullets(slide, 9.12, 1.55, 3.65, 5.1, [ + "Исходный текст: 0,248.", + "После замены содержательных слов: 0,113.", + "Падение более чем вдвое.", + "Остаток 0,113 всё ещё в 3,9 раза выше случайного.", + "Значит, стиль есть, но содержание доминирует.", + ], size=15, title="Контроль") + add_notes(slide, "Это ключевой слайд для честной интерпретации. Содержательные слова заменили на CONTENT, оставив служебные слова, POS и пунктуацию. Качество упало с 0,248 до 0,113. Значит, нельзя утверждать, что модель выучила «чистый идиолект»: она во многом различает, о чём персонаж говорит.") + + # 22. Как читать матрицу + slide_no += 1 + slide = content_slide(prs, "Как читать матрицу ошибок", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM_LIGHT) + add_text(slide, 0.7, 1.55, 5.7, 0.4, "Маленький пример", size=17, bold=True, color=GREEN_DARK) + mini = [ + ["истина \\ прогноз", "A", "B", "C"], + ["A", "70", "20", "10"], + ["B", "15", "75", "10"], + ["C", "5", "25", "70"], + ] + add_table(slide, 0.7, 2.05, 5.6, 2.5, mini, col_widths=[2.2, 1.1, 1.1, 1.1], font_size=14) + add_text(slide, 0.7, 4.7, 5.7, 1.8, "Строка — истинный персонаж. Столбец — предсказание модели. Диагональ — правильные ответы. Вне диагонали — ошибки. Числа — проценты от строки.", size=14, color=DARK) + add_panel(slide, 6.85, 1.35, 6.0, 5.55, fill=CREAM) + add_bullets(slide, 7.05, 1.55, 5.6, 5.1, [ + "Полная матрица 34×34 на доске нечитаема.", + "Поэтому на слайде — 12 самых частых персонажей и столбец «прочие».", + "Отдельно показываем 18 самых частых пар ошибок.", + "Полная матрица 34×34 — в раздатке/приложении.", + "На доске смотрим диагональ, крупные внедиагональные клетки и «прочие».", + ], size=15, title="Как показываем") + add_notes(slide, "Объяснить матрицу на маленьком примере. Сказать: полная матрица 34×34 на доске превращается в кашу; поэтому используем три уровня: top-12 матрица, top-18 пар, полная матрица в раздатке. Это методически честно и читаемо.") + + # 23. Матрица top12 + slide_no += 1 + slide = content_slide(prs, "Матрица ошибок: 12 персонажей", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.35, 1.35, 9.05, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "confusion_top12.png", 0.45, 1.45, 8.85, 5.35) + add_panel(slide, 9.6, 1.35, 3.3, 5.55, fill=CREAM) + add_bullets(slide, 9.77, 1.55, 3.0, 5.1, [ + "Диагональ: доля правильных ответов внутри класса.", + "Abigail: 21% верно, 18% уходит в «прочие».", + "Alex: 37% верно, 11% → Abigail.", + "Sebastian: 19% верно, 20% → Abigail.", + "У многих строк крупная доля «прочих»: модель не находит редкие классы.", + ], size=13, title="Что видно") + add_notes(slide, "Провести по строкам: Abigail, Alex, Sebastian. Обратить внимание на столбец «прочие»: он большой, потому что 22 класса не поместились. Это не «мусор», а важная часть ошибок. Сказать, что диагональ далеко не 100%, но и не 0.") + + # 24. Кого путает + slide_no += 1 + slide = content_slide(prs, "Кого модель путает", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.35, 1.35, 9.0, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "confusion_pairs.png", 0.45, 1.45, 8.8, 5.35) + add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM) + add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [ + "Sebastian → Abigail: 65.", + "Sebastian → Alex: 61.", + "Abigail → Sebastian: 59.", + "Abigail → Alex: 56.", + "Основной клубок — подростки/молодые взрослые: Abigail, Alex, Sebastian, Haley, Sam.", + ], size=13, title="Частые пары") + add_notes(slide, "Показать, что ошибки не случайны: они концентрируются в одном круге персонажей. Это ожидаемо: один автор, общие темы, сходная эмоциональная окраска, короткие реплики. Это не доказательство «автор не старался», а наоборот — свидетельство близких речевых масок внутри группы.") + + # 25. Классы-аттракторы + slide_no += 1 + slide = content_slide(prs, "Классы-аттракторы", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "hub_shares.png", 0.5, 1.45, 8.8, 5.35) + add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM) + add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [ + "Alex: 11,4% предсказаний при истинной доле 6,0%.", + "Abigail: 10,9% при 6,9%.", + "Редкие классы недопредставлены.", + "Это не «демографическая» путаница, а стягивание к популярным голосам.", + "Коррекция приоров не улучшила macro-F1.", + ], size=13, title="Кто перетягивает") + add_notes(slide, "Объяснить «класс-аттрактор»: модель чаще выбирает Alex и Abigail, потому что их признаки часто встречаются и они похожи на многих. Редкие персонажи проигрывают. Простая коррекция приоров не помогла; нужны более тонкие правила или больше данных.") + + # 26. Per-class F1 + slide_no += 1 + slide = content_slide(prs, "Персонажи: кто различается", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.3, 1.35, 9.25, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "per_class_f1.png", 0.4, 1.45, 9.05, 5.35) + add_panel(slide, 9.75, 1.35, 3.15, 5.55, fill=CREAM) + add_bullets(slide, 9.9, 1.55, 2.85, 5.1, [ + "Лучшие: Willy, Evelyn, Pierre, Leo, Pam.", + "Худшие: Kent, Mister Qi, Penny, Abigail, Leah.", + "Редкие классы — широкие интервалы.", + "Мягкое голосование улучшает Wizard, Dwarf, Vincent.", + ], size=13, title="Факты") + add_notes(slide, "Показать, что качество по персонажам неоднородно. Evelyn/Willy/Pierre различаются лучше; Kent/Mister Qi/Penny — хуже. Это связано и с числом реплик, и с их речевой функцией. Не делать вывод, что персонаж «плохой»: у редких классов просто мало данных.") + + # 27. Почему путаются + slide_no += 1 + slide = content_slide(prs, "Почему персонажи путаются", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [ + "Один автор и общие сюжетные контексты: персонажи говорят об одних и тех же событиях.", + "Сходные возраст и роль: подростки/молодые взрослые используют похожие темы и интонации.", + "Короткие реплики: мало слов, чтобы проявить индивидуальный синтаксис.", + "Дисбаланс: редкие классы реже выбираются моделью.", + "Общие формулы вежливости, шутки, междометия.", + ], size=15, title="Причины") + add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [ + "Клубок: Abigail, Alex, Sebastian, Haley, Sam.", + "Нелюди (Krobus, Dwarf, Wizard) различаются лучше, но Wizard и Dwarf редки.", + "Дети (Jas, Vincent) имеют короткие реплики; их легче узнать по длине.", + "Это не доказательство сходства «характеров», а свойство текстовых признаков.", + "Нужны сцены и больше данных, чтобы отделить стиль от контекста.", + ], size=15, title="Что это значит") + add_notes(slide, "Ответить на вопрос «почему одни и те же персонажи путаются»: один автор, общие темы, сходный возраст/роль, короткие реплики, дисбаланс. Подчеркнуть: мы не делаем психологических выводов о персонажах; мы объясняем статистику ошибок.") + + # 28. Что означают оценки + slide_no += 1 + slide = content_slide(prs, "Что означают оценки", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM) + add_bullets(slide, 0.7, 1.55, 11.9, 5.1, [ + "0,30 макро-F1 при 34 классах — это примерно в 10 раз выше случайного уровня 0,029.", + "Абсолютное значение умеренное: по одной короткой реплике персонажа действительно трудно определить.", + "Это не означает, что автор «не старался» или что стиль неразличим. Наоборот, устойчивое превышение случайного уровня говорит о намеренно различных голосах.", + "Умеренность объясняется короткими текстами, дисбалансом классов, общими темами и формулами, а не отсутствием стиля.", + "Мы не решаем задачу «он или не он писал»: все реплики одного автора. Мы решаем, какой персонаж говорит.", + ], size=17, title="Уточнение интерпретации") + add_notes(slide, "Здесь важно поправить исходную формулировку. Нельзя говорить «низкие оценки хорошие, потому что автор не старался». Корректно: оценки выше случайного уровня, но умеренные; это свидетельствует о частичной различимости голосов. Один автор — значит, различия созданы намеренно, а не разными авторами. Низкие абсолютные значения — следствие трудности задачи, а не приговор стилю.") + + # 29. Ограничения + slide_no += 1 + slide = content_slide(prs, "Ограничения", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM) + add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [ + "Один корпус, одна игра, один автор.", + "Нет внешней валидации на другом корпусе.", + "Короткие реплики и дисбаланс классов.", + "Ручная фильтрация корпуса не воспроизводится.", + "Нет идентификаторов сцен и диалогов.", + ], size=16, title="Данные") + add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT) + add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [ + "Выбор признаков и ансамбля опирался на предыдущие результаты.", + "Вложенная проверка снижает, но не устраняет оптимизм.", + "Содержательные слова и тема вносят большой вклад.", + "Простая калибровка приоров не помогла.", + "Стековое объединение переобучается на малом числе объектов.", + ], size=16, title="Методология") + add_notes(slide, "Перечислить ограничения честно. Главное: нельзя переносить выводы на другие игры/авторов без внешней проверки. И нельзя называть результат «чистым стилем», пока не отделена тема.") + + # 30. Выводы + slide_no += 1 + slide = content_slide(prs, "Выводы", "Блок 3 · Результаты", slide_no) + add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM) + add_bullets(slide, 0.75, 1.55, 11.8, 5.1, [ + "Персонажи статистически различимы: лучшая модель — 0,304 макро-F1, консервативная оценка — 0,275 при случайном уровне 0,029.", + "Основной вклад дают лексика, символьные n-граммы и LSA по содержательным словам; функциональные слова и синтаксис сами по себе слабы.", + "После замены содержательных слов качество падает с 0,248 до 0,113: содержание доминирует, но стилевой сигнал сохраняется.", + "Главная проблема — редкие классы и классы-аттракторы; мягкое голосование помогает, но не решает её полностью.", + "Корректный вывод: различимы речевые маски по лексико-тематическому профилю; для утверждения о «чистом стиле» нужны внешняя валидация и тематический контроль.", + ], size=17, title="Пять тезисов") + add_notes(slide, "Сформулировать выводы без переубеждения аудитории. Главное: не «мы всё доказали», а «мы получили устойчивый выше случайного уровня сигнал; основной вклад — лексика/тема; для стиля нужны дальнейшие проверки».") + + # 31. Приложение: полная матрица + slide_no += 1 + slide = content_slide(prs, "Приложение: полная матрица 34×34", "Приложение", slide_no) + add_panel(slide, 0.35, 1.35, 7.4, 5.55, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "confusion_full34.png", 0.45, 1.45, 7.2, 5.35) + add_panel(slide, 7.95, 1.35, 4.95, 5.55, fill=CREAM) + add_bullets(slide, 8.12, 1.55, 4.6, 5.1, [ + "Полная матрица 34×34 — для раздаточного материала.", + "На проекторе она слишком плотная; в докладе используем top-12 и top-18 пар.", + "Строки — истинные персонажи; столбцы — предсказания; цвет — доля строки.", + "Диагональ — правильные ответы; тёмные внедиагональные клетки — систематические ошибки.", + ], size=15, title="Как использовать") + add_notes(slide, "Этот слайд можно не показывать, а раздать. Если показывать, не читать все клетки: показать диагональ и несколько тёмных пятен.") + + # 32. Финал: мем + slide_no += 1 + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_background(slide) + add_panel(slide, 0.55, 1.2, 6.2, 5.45, fill=CREAM) + add_text(slide, 0.85, 1.5, 5.6, 1.0, "Спасибо за внимание!", size=34, bold=True, color=GREEN_DARK) + add_text(slide, 0.85, 2.55, 5.5, 1.5, "Вопросы, обсуждение и предложения по экспериментам", size=18, color=DARK) + add_text(slide, 0.85, 4.2, 5.5, 1.2, "«Один автор — разные голоса»", size=20, bold=True, color=RED) + add_panel(slide, 7.0, 0.9, 5.8, 6.0, fill=CREAM_LIGHT) + add_image_fit(slide, GEN / "meme.png", 7.15, 1.05, 5.5, 5.7) + add_chicken(slide, 0.25, 7.05, 0.28) + add_notes(slide, "Финальный слайд с мемом. Поблагодарить, перейти к вопросам. Курица спрятана слева внизу — пасхалка.") + + prs.save(OUT) + print(f"Готово: {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab1/presentation/generated/characters.png b/lab1/presentation/generated/characters.png new file mode 100644 index 0000000..977724a Binary files /dev/null and b/lab1/presentation/generated/characters.png differ diff --git a/lab1/presentation/generated/confusion_full34.png b/lab1/presentation/generated/confusion_full34.png new file mode 100644 index 0000000..14fc794 Binary files /dev/null and b/lab1/presentation/generated/confusion_full34.png differ diff --git a/lab1/presentation/generated/confusion_pairs.png b/lab1/presentation/generated/confusion_pairs.png new file mode 100644 index 0000000..d280e6c Binary files /dev/null and b/lab1/presentation/generated/confusion_pairs.png differ diff --git a/lab1/presentation/generated/confusion_top12.png b/lab1/presentation/generated/confusion_top12.png new file mode 100644 index 0000000..434c13c Binary files /dev/null and b/lab1/presentation/generated/confusion_top12.png differ diff --git a/lab1/presentation/generated/feature_progression.png b/lab1/presentation/generated/feature_progression.png new file mode 100644 index 0000000..8aa2379 Binary files /dev/null and b/lab1/presentation/generated/feature_progression.png differ diff --git a/lab1/presentation/generated/hub_shares.png b/lab1/presentation/generated/hub_shares.png new file mode 100644 index 0000000..be3674a Binary files /dev/null and b/lab1/presentation/generated/hub_shares.png differ diff --git a/lab1/presentation/generated/meme.png b/lab1/presentation/generated/meme.png new file mode 100644 index 0000000..9c891b5 Binary files /dev/null and b/lab1/presentation/generated/meme.png differ diff --git a/lab1/presentation/generated/model_comparison.png b/lab1/presentation/generated/model_comparison.png new file mode 100644 index 0000000..10221ef Binary files /dev/null and b/lab1/presentation/generated/model_comparison.png differ diff --git a/lab1/presentation/generated/per_class_f1.png b/lab1/presentation/generated/per_class_f1.png new file mode 100644 index 0000000..4a9baba Binary files /dev/null and b/lab1/presentation/generated/per_class_f1.png differ diff --git a/lab1/presentation/generated/topic_control.png b/lab1/presentation/generated/topic_control.png new file mode 100644 index 0000000..fae002d Binary files /dev/null and b/lab1/presentation/generated/topic_control.png differ diff --git a/lab1/presentation/images/background.jpg b/lab1/presentation/images/background.jpg new file mode 100644 index 0000000..a0f9b7b Binary files /dev/null and b/lab1/presentation/images/background.jpg differ diff --git a/lab1/presentation/images/characters.webp b/lab1/presentation/images/characters.webp new file mode 100644 index 0000000..d82f258 Binary files /dev/null and b/lab1/presentation/images/characters.webp differ diff --git a/lab1/presentation/images/chicken.png b/lab1/presentation/images/chicken.png new file mode 100644 index 0000000..f7b4978 Binary files /dev/null and b/lab1/presentation/images/chicken.png differ diff --git a/lab1/presentation/images/median.png b/lab1/presentation/images/median.png new file mode 100644 index 0000000..a3b3dd6 Binary files /dev/null and b/lab1/presentation/images/median.png differ diff --git a/lab1/presentation/images/meme.webp b/lab1/presentation/images/meme.webp new file mode 100644 index 0000000..a19a695 Binary files /dev/null and b/lab1/presentation/images/meme.webp differ diff --git a/lab1/presentation/images/replics-count.png b/lab1/presentation/images/replics-count.png new file mode 100644 index 0000000..4beab8b Binary files /dev/null and b/lab1/presentation/images/replics-count.png differ diff --git a/lab1/presentation/images/title.jpg b/lab1/presentation/images/title.jpg new file mode 100644 index 0000000..8c195e5 Binary files /dev/null and b/lab1/presentation/images/title.jpg differ diff --git a/lab1/presentation/make_slide_figures.py b/lab1/presentation/make_slide_figures.py new file mode 100644 index 0000000..22e10fd --- /dev/null +++ b/lab1/presentation/make_slide_figures.py @@ -0,0 +1,263 @@ +"""Графики для презентации: матрицы ошибок, сравнение моделей, ошибки по классам.""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from matplotlib.colors import LinearSegmentedColormap + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "presentation" / "generated" +OUT.mkdir(parents=True, exist_ok=True) +OOF = ROOT / "results" / "final" / "finalists" / "voting_et_char_lean" / "oof_predictions.csv" +METRICS = ROOT / "FINAL_RESULTS.csv" + +plt.rcParams.update({ + "font.family": "DejaVu Sans", + "font.size": 13, + "axes.titlesize": 18, + "axes.labelsize": 13, + "savefig.facecolor": "#F8F1DC", + "figure.facecolor": "#F8F1DC", +}) + +CREAM = "#F8F1DC" +DARK = "#203A2B" +GREEN = "#4D7C4A" +BROWN = "#8B5A2B" +RED = "#B23A2E" +GOLD = "#D8A23A" + +CMAP = LinearSegmentedColormap.from_list("stardew_blue", ["#FFFDF4", "#BFD9C0", "#2E6B57", "#173A34"]) + + +def load_oof() -> pd.DataFrame: + frame = pd.read_csv(OOF) + frame["y_true"] = frame["y_true"].astype(str) + frame["y_pred"] = frame["y_pred"].astype(str) + return frame + + +def top_confusions(frame: pd.DataFrame, n: int = 18) -> pd.Series: + errors = frame[frame["y_true"] != frame["y_pred"]] + pairs = errors.groupby(["y_true", "y_pred"]).size().sort_values(ascending=False).head(n) + pairs.index = [f"{a} → {b}" for a, b in pairs.index] + return pairs + + +def confusion_top12(frame: pd.DataFrame) -> None: + labels = frame["y_true"].value_counts().head(12).index.tolist() + matrix = pd.crosstab(frame["y_true"], frame["y_pred"]).reindex(index=labels, fill_value=0) + shown = matrix[[c for c in labels if c in matrix.columns]].copy() + shown["прочие"] = matrix.drop(columns=[c for c in labels if c in matrix.columns]).sum(axis=1) + shown = shown.div(shown.sum(axis=1), axis=0) * 100 + + fig, ax = plt.subplots(figsize=(12.5, 7.4)) + image = ax.imshow(shown.to_numpy(), cmap=CMAP, vmin=0, vmax=60, aspect="auto") + ax.set_xticks(np.arange(shown.shape[1])) + ax.set_yticks(np.arange(shown.shape[0])) + ax.set_xticklabels(shown.columns, rotation=42, ha="right", fontsize=10) + ax.set_yticklabels(shown.index, fontsize=11) + ax.set_xlabel("Предсказанный персонаж", fontsize=14, color=DARK) + ax.set_ylabel("Истинный персонаж", fontsize=14, color=DARK) + ax.set_title("Матрица ошибок: 12 самых частых персонажей, % строки", fontsize=18, color=DARK, pad=12) + for row in range(shown.shape[0]): + for col in range(shown.shape[1]): + value = shown.iloc[row, col] + if value >= 0.5: + color = "white" if value > 32 else DARK + ax.text(col, row, f"{value:.0f}", ha="center", va="center", fontsize=9, color=color) + cbar = fig.colorbar(image, ax=ax, fraction=0.035, pad=0.02) + cbar.set_label("% строки", color=DARK) + fig.tight_layout() + fig.savefig(OUT / "confusion_top12.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def confusion_full(frame: pd.DataFrame) -> None: + labels = sorted(frame["y_true"].unique()) + matrix = pd.crosstab(frame["y_true"], frame["y_pred"]).reindex(index=labels, columns=labels, fill_value=0) + matrix = matrix.div(matrix.sum(axis=1), axis=0) * 100 + fig, ax = plt.subplots(figsize=(13.5, 11.5)) + image = ax.imshow(matrix.to_numpy(), cmap=CMAP, vmin=0, vmax=35, aspect="auto") + ax.set_xticks(np.arange(len(labels))) + ax.set_yticks(np.arange(len(labels))) + ax.set_xticklabels(labels, rotation=90, fontsize=5.5) + ax.set_yticklabels(labels, fontsize=5.5) + ax.set_xlabel("Предсказанный персонаж", fontsize=12, color=DARK) + ax.set_ylabel("Истинный персонаж", fontsize=12, color=DARK) + ax.set_title("Полная матрица ошибок, % строки (раздаточный материал)", fontsize=16, color=DARK) + fig.colorbar(image, ax=ax, fraction=0.03, pad=0.015).set_label("% строки") + fig.tight_layout() + fig.savefig(OUT / "confusion_full34.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def pairs_chart(frame: pd.DataFrame) -> None: + pairs = top_confusions(frame, 18).sort_values() + fig, ax = plt.subplots(figsize=(12.6, 7.2)) + colors = [RED if i >= len(pairs) - 5 else BROWN for i in range(len(pairs))] + ax.barh(pairs.index, pairs.values, color=colors, edgecolor="white", linewidth=0.6) + for index, value in enumerate(pairs.values): + ax.text(value + 0.6, index, str(value), va="center", fontsize=10, color=DARK) + ax.set_xlabel("Число ошибок за 3 повтора", fontsize=13, color=DARK) + ax.set_title("Самые частые пары «истинный → предсказанный»", fontsize=18, color=DARK) + ax.tick_params(axis="y", labelsize=10) + ax.grid(axis="x", alpha=0.2, color=DARK) + ax.set_xlim(0, pairs.max() * 1.16) + fig.tight_layout() + fig.savefig(OUT / "confusion_pairs.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def per_class_chart(frame: pd.DataFrame) -> None: + first = frame[frame["repeat"] == frame["repeat"].min()] + labels = sorted(first["y_true"].unique()) + rows = [] + for label in labels: + mask = first["y_true"] == label + tp = int(((first["y_true"] == label) & (first["y_pred"] == label)).sum()) + fp = int(((first["y_true"] != label) & (first["y_pred"] == label)).sum()) + fn = int(((first["y_true"] == label) & (first["y_pred"] != label)).sum()) + precision = tp / (tp + fp) if tp + fp else 0.0 + recall = tp / (tp + fn) if tp + fn else 0.0 + f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 + rows.append((label, f1, int(mask.sum()))) + table = pd.DataFrame(rows, columns=["class", "f1", "support"]).sort_values("f1") + worst = table.head(12).sort_values("f1", ascending=False) + best = table.tail(12).sort_values("f1") + + fig, axes = plt.subplots(1, 2, figsize=(13.2, 7.0), gridspec_kw={"width_ratios": [1, 1]}) + axes[0].barh(worst["class"], worst["f1"], color=RED, edgecolor="white") + axes[0].set_title("Худшие 12 персонажей", fontsize=15, color=DARK) + axes[0].set_xlim(0, 0.72) + axes[0].grid(axis="x", alpha=0.2) + for index, (value, support) in enumerate(zip(worst["f1"], worst["support"])): + axes[0].text(value + 0.008, index, f"{value:.2f} (n={support})", va="center", fontsize=9, color=DARK) + axes[1].barh(best["class"], best["f1"], color=GREEN, edgecolor="white") + axes[1].set_title("Лучшие 12 персонажей", fontsize=15, color=DARK) + axes[1].set_xlim(0, 0.72) + axes[1].grid(axis="x", alpha=0.2) + for index, (value, support) in enumerate(zip(best["f1"], best["support"])): + axes[1].text(value + 0.008, index, f"{value:.2f} (n={support})", va="center", fontsize=9, color=DARK) + fig.suptitle("F-мера по персонажам: мягкое голосование, первый повтор", fontsize=17, color=DARK, y=0.99) + fig.tight_layout() + fig.savefig(OUT / "per_class_f1.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def model_comparison() -> None: + table = pd.read_csv(METRICS) + wanted = [ + ("voting_et_char_lean", "Мягкое голосование"), + ("et_char10000", "ExtraTrees, char=10000"), + ("nested_feature_selection", "Вложенный выбор"), + ("et_full", "ExtraTrees, полный"), + ("logreg_lean_content300", "Logreg, lean + content"), + ("stacking", "Стековое объединение"), + ] + rows = [] + for key, label in wanted: + row = table[table["label"] == key] + if not row.empty: + rows.append((label, float(row.iloc[0]["macro_f1_mean"]), float(row.iloc[0]["macro_f1_std"]))) + rows.append(("Случайный уровень", 0.029, 0.0)) + frame = pd.DataFrame(rows, columns=["label", "mean", "std"]).sort_values("mean") + fig, ax = plt.subplots(figsize=(12.4, 6.8)) + colors = [GOLD if label == "Мягкое голосование" else (RED if label == "Случайный уровень" else GREEN) for label in frame["label"]] + ax.barh(frame["label"], frame["mean"], xerr=frame["std"], color=colors, edgecolor="white", capsize=4) + for index, (value, std) in enumerate(zip(frame["mean"], frame["std"])): + offset = value + max(std, 0.0) + 0.008 + ax.text(offset, index, f"{value:.3f}", va="center", fontsize=11, color=DARK) + ax.set_xlim(0, 0.38) + ax.set_xlabel("Макро-усреднённая F-мера", fontsize=13, color=DARK) + ax.set_title("Сравнение моделей: среднее и стандартное отклонение", fontsize=18, color=DARK) + ax.grid(axis="x", alpha=0.2) + fig.tight_layout() + fig.savefig(OUT / "model_comparison.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def feature_progression() -> None: + labels = [ + "Только длина", + "Ручные признаки", + "Ручные + char/POS", + "+ content-LSA", + "ExtraTrees, char=10000", + "Мягкое голосование", + ] + values = [0.035, 0.105, 0.182, 0.249, 0.276, 0.304] + fig, ax = plt.subplots(figsize=(12.4, 6.6)) + colors = [BROWN, BROWN, GREEN, GREEN, GOLD, RED] + ax.bar(labels, values, color=colors, edgecolor="white") + for index, value in enumerate(values): + ax.text(index, value + 0.008, f"{value:.3f}", ha="center", fontsize=12, color=DARK) + ax.set_ylim(0, 0.35) + ax.set_ylabel("Макро-усреднённая F-мера", fontsize=13, color=DARK) + ax.set_title("Что добавляет качество", fontsize=18, color=DARK) + ax.tick_params(axis="x", rotation=18, labelsize=11) + ax.grid(axis="y", alpha=0.2) + fig.tight_layout() + fig.savefig(OUT / "feature_progression.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def topic_control() -> None: + labels = ["Исходный\nExtraTrees", "Маскированный\nExtraTrees", "Исходный\nLogreg", "Маскированный\nLogreg"] + values = [0.2483, 0.1129, 0.1467, 0.1067] + fig, ax = plt.subplots(figsize=(10.8, 6.2)) + colors = [GREEN, RED, GREEN, RED] + ax.bar(labels, values, color=colors, edgecolor="white") + for index, value in enumerate(values): + ax.text(index, value + 0.007, f"{value:.3f}", ha="center", fontsize=12, color=DARK) + ax.set_ylim(0, 0.30) + ax.set_ylabel("Макро-усреднённая F-мера", fontsize=13, color=DARK) + ax.set_title("Контроль содержания: замена содержательных слов на CONTENT", fontsize=17, color=DARK) + ax.tick_params(axis="x", labelsize=11) + ax.grid(axis="y", alpha=0.2) + fig.tight_layout() + fig.savefig(OUT / "topic_control.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def hub_shares(frame: pd.DataFrame) -> None: + true = frame["y_true"].value_counts(normalize=True) + pred = frame["y_pred"].value_counts(normalize=True) + labels = pred.head(10).index.tolist() + x = np.arange(len(labels)) + fig, ax = plt.subplots(figsize=(12.4, 6.2)) + ax.bar(x - 0.2, [true.get(label, 0) * 100 for label in labels], width=0.4, label="Истинная доля", color=GREEN) + ax.bar(x + 0.2, [pred.get(label, 0) * 100 for label in labels], width=0.4, label="Доля предсказаний", color=RED) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=25, ha="right", fontsize=11) + ax.set_ylabel("% реплик", fontsize=13, color=DARK) + ax.set_title("Классы-аттракторы: кого модель выбирает слишком часто", fontsize=17, color=DARK) + ax.legend(frameon=False) + ax.grid(axis="y", alpha=0.2) + fig.tight_layout() + fig.savefig(OUT / "hub_shares.png", dpi=220, bbox_inches="tight") + plt.close(fig) + + +def main() -> int: + frame = load_oof() + confusion_top12(frame) + confusion_full(frame) + pairs_chart(frame) + per_class_chart(frame) + model_comparison() + feature_progression() + topic_control() + hub_shares(frame) + print("Готово:", OUT) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lab1/presentation/requirements.txt b/lab1/presentation/requirements.txt new file mode 100644 index 0000000..c4ae616 --- /dev/null +++ b/lab1/presentation/requirements.txt @@ -0,0 +1,3 @@ +python-pptx>=1.0,<2 +Pillow>=10 +matplotlib>=3.8 diff --git a/lab1/presentation/stardew_stylometry.pdf b/lab1/presentation/stardew_stylometry.pdf new file mode 100644 index 0000000..2d4b79a Binary files /dev/null and b/lab1/presentation/stardew_stylometry.pdf differ diff --git a/lab1/presentation/stardew_stylometry.pptx b/lab1/presentation/stardew_stylometry.pptx new file mode 100644 index 0000000..87df534 Binary files /dev/null and b/lab1/presentation/stardew_stylometry.pptx differ