"""Сборка презентации по лабораторной работе. Требования: - 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())