Files
NLP/lab1/presentation/build_deck.py

810 lines
61 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сборка презентации по лабораторной работе.
Требования:
- 16:9, фон на каждом слайде;
- три содержательных блока на трёх рассказчиков;
- курица на каждом слайде;
- титульная картинка, персонажи, две круговые диаграммы, матрицы ошибок,
мем на последнем слайде;
- тёмный текст на светлых панелях.
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
ROOT = Path(__file__).resolve().parents[1]
IMAGES = ROOT / "presentation" / "images"
GEN = ROOT / "presentation" / "generated"
OUT = ROOT / "presentation" / "stardew_stylometry.pptx"
SW, SH = 13.333, 7.5
FONT = "Arial"
CREAM = RGBColor(248, 241, 220)
CREAM_LIGHT = RGBColor(255, 252, 240)
DARK = RGBColor(30, 45, 33)
GREEN = RGBColor(49, 93, 58)
GREEN_DARK = RGBColor(28, 63, 47)
GOLD = RGBColor(204, 153, 51)
BROWN = RGBColor(126, 80, 42)
RED = RGBColor(166, 55, 43)
WHITE = RGBColor(255, 255, 255)
BORDER = RGBColor(181, 158, 111)
def add_background(slide) -> None:
slide.shapes.add_picture(
str(IMAGES / "background.jpg"), 0, 0, width=Inches(SW), height=Inches(SH)
)
def add_panel(slide, x, y, w, h, fill=CREAM, line=BORDER, radius: float = 0.06):
shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)
)
shape.fill.solid()
shape.fill.fore_color.rgb = fill
shape.line.color.rgb = line
shape.line.width = Pt(1.1)
shape.shadow.inherit = False
try:
shape.adjustments[0] = radius
except Exception:
pass
return shape
def add_text(
slide,
x,
y,
w,
h,
text: str,
size: int = 18,
color=DARK,
bold: bool = False,
align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP,
margin: float = 0.08,
):
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
frame = box.text_frame
frame.word_wrap = True
frame.vertical_anchor = anchor
frame.margin_left = Inches(margin)
frame.margin_right = Inches(margin)
frame.margin_top = Inches(0.04)
frame.margin_bottom = Inches(0.04)
paragraph = frame.paragraphs[0]
paragraph.alignment = align
run = paragraph.add_run()
run.text = text
run.font.name = FONT
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
return box
def add_bullets(slide, x, y, w, h, items, size: int = 16, color=DARK, title: str | None = None):
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
frame = box.text_frame
frame.word_wrap = True
frame.margin_left = Inches(0.08)
frame.margin_right = Inches(0.08)
frame.margin_top = Inches(0.04)
first = True
if title:
paragraph = frame.paragraphs[0]
paragraph.space_after = Pt(7)
run = paragraph.add_run()
run.text = title
run.font.name = FONT
run.font.size = Pt(size + 3)
run.font.bold = True
run.font.color.rgb = GREEN_DARK
first = False
for item in items:
if isinstance(item, tuple):
text, level = item
else:
text, level = item, 0
paragraph = frame.paragraphs[0] if first else frame.add_paragraph()
first = False
prefix = "• " if level == 0 else "– "
paragraph.text = prefix + text
paragraph.level = level
paragraph.space_after = Pt(5)
paragraph.line_spacing = 1.0
for run in paragraph.runs:
run.font.name = FONT
run.font.size = Pt(size if level == 0 else size - 1)
run.font.color.rgb = color
return box
def add_table(slide, x, y, w, h, rows, col_widths=None, font_size: int = 11, header: bool = True):
shape = slide.shapes.add_table(len(rows), len(rows[0]), Inches(x), Inches(y), Inches(w), Inches(h))
table = shape.table
if col_widths:
total = sum(col_widths)
for index, value in enumerate(col_widths):
table.columns[index].width = Inches(w * value / total)
for row_index, row in enumerate(rows):
for col_index, value in enumerate(row):
cell = table.cell(row_index, col_index)
cell.margin_left = Inches(0.05)
cell.margin_right = Inches(0.05)
cell.margin_top = Inches(0.02)
cell.margin_bottom = Inches(0.02)
cell.fill.solid()
if header and row_index == 0:
cell.fill.fore_color.rgb = GREEN
else:
cell.fill.fore_color.rgb = CREAM_LIGHT if row_index % 2 else CREAM
paragraph = cell.text_frame.paragraphs[0]
paragraph.alignment = PP_ALIGN.LEFT if col_index == 0 else PP_ALIGN.CENTER
run = paragraph.add_run()
run.text = str(value)
run.font.name = FONT
run.font.size = Pt(font_size)
run.font.bold = header and row_index == 0
run.font.color.rgb = WHITE if header and row_index == 0 else DARK
return table
def add_image_fit(slide, path: Path, x, y, max_w, max_h, panel: bool = False):
with Image.open(path) as image:
width, height = image.size
aspect = width / height
if max_w / max_h > aspect:
draw_h = max_h
draw_w = draw_h * aspect
else:
draw_w = max_w
draw_h = draw_w / aspect
draw_x = x + (max_w - draw_w) / 2
draw_y = y + (max_h - draw_h) / 2
if panel:
add_panel(slide, draw_x - 0.07, draw_y - 0.07, draw_w + 0.14, draw_h + 0.14, fill=CREAM_LIGHT)
slide.shapes.add_picture(str(path), Inches(draw_x), Inches(draw_y), width=Inches(draw_w), height=Inches(draw_h))
def add_chicken(slide, x: float = 12.96, y: float = 7.12, size: float = 0.24):
slide.shapes.add_picture(
str(IMAGES / "chicken.png"), Inches(x), Inches(y), width=Inches(size), height=Inches(size)
)
def add_notes(slide, text: str) -> None:
slide.notes_slide.notes_text_frame.text = text
def new_slide():
slide = Presentation().slides.add_slide # placeholder, replaced below
return slide
def add_title_bar(slide, title: str, block: str = "", subtitle: str | None = None):
add_panel(slide, 0.42, 0.28, 12.49, 0.86, fill=CREAM)
add_text(slide, 0.58, 0.34, 10.2, 0.45, title, size=25, bold=True, color=GREEN_DARK)
if block:
add_text(slide, 10.0, 0.42, 2.65, 0.35, block, size=13, color=BROWN, align=PP_ALIGN.RIGHT)
if subtitle:
add_text(slide, 0.58, 0.76, 11.8, 0.3, subtitle, size=12, color=BROWN)
def add_footer(slide, block: str, number: int):
add_text(slide, 0.45, 7.12, 7.8, 0.24, block, size=10, color=DARK)
add_text(slide, 12.15, 7.12, 0.55, 0.24, str(number), size=10, color=DARK, align=PP_ALIGN.RIGHT)
def content_slide(prs, title: str, block: str, number: int, subtitle: str | None = None):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_background(slide)
add_title_bar(slide, title, block, subtitle)
add_footer(slide, block, number)
add_chicken(slide)
return slide
def section_slide(prs, number: int, block: str, title: str, subtitle: str, questions: list[str], notes: str, slide_number: int):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_background(slide)
add_panel(slide, 0.65, 0.9, 12.0, 5.6, fill=CREAM)
add_text(slide, 0.95, 1.15, 2.6, 1.0, block, size=22, bold=True, color=GOLD)
add_text(slide, 0.95, 2.05, 11.2, 0.9, title, size=38, bold=True, color=GREEN_DARK)
add_text(slide, 0.95, 3.05, 11.0, 0.6, subtitle, size=20, color=BROWN)
add_bullets(slide, 0.95, 3.9, 11.0, 1.9, questions, size=17, color=DARK)
add_text(slide, 0.95, 5.9, 7.0, 0.35, f"Рассказчик {number}", size=15, bold=True, color=RED)
add_footer(slide, block, slide_number)
add_chicken(slide)
add_notes(slide, notes)
return slide
def main() -> int:
prs = Presentation()
prs.slide_width = Inches(SW)
prs.slide_height = Inches(SH)
slide_no = 0
# 1. Титульный слайд
slide_no += 1
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_background(slide)
add_panel(slide, 0.55, 1.15, 6.15, 4.25, fill=CREAM)
add_image_fit(slide, IMAGES / "title.jpg", 0.7, 1.3, 5.85, 3.9, panel=False)
add_panel(slide, 6.95, 1.15, 5.85, 5.45, fill=CREAM)
add_text(slide, 7.2, 1.45, 5.3, 0.35, "Лабораторная работа по анализу текстов", size=13, color=BROWN)
add_text(slide, 7.2, 1.9, 5.3, 1.55, "Можно ли различить голоса персонажей?", size=32, bold=True, color=GREEN_DARK)
add_text(slide, 7.2, 3.45, 5.25, 1.4, "Стилометрическая классификация 34 персонажей Stardew Valley по 2005 репликам", size=18, color=DARK)
add_text(slide, 7.2, 4.9, 5.3, 0.6, "Три блока · три рассказчика", size=16, bold=True, color=RED)
add_text(slide, 7.2, 5.28, 5.3, 0.45, "Корпус → методология → результаты", size=15, color=BROWN)
add_panel(slide, 0.55, 5.75, 12.25, 0.8, fill=CREAM_LIGHT)
add_text(slide, 0.75, 5.9, 11.9, 0.45, "2005 реплик · 34 персонажа · один автор · 5-блочная перекрёстная проверка", size=14, color=DARK, align=PP_ALIGN.CENTER)
add_chicken(slide, 12.95, 7.12, 0.26)
add_notes(slide, "Приветствие. Тема — стилометрия реплик Stardew Valley. Сразу уточнить: автор один, задача — различить речевые маски персонажей, а не установить авторство. Три блока: корпус, методология, результаты.")
# 2. Структура
slide_no += 1
slide = content_slide(prs, "Структура доклада", "Введение", slide_no)
blocks = [
("01", "Корпус", "Рассказчик 1", ["2005 реплик", "34 персонажа", "сбор, очистка, разметка", "ограничения данных"]),
("02", "Методология", "Рассказчик 2", ["признаки и LSA", "классификаторы", "перекрёстная проверка", "метрики"]),
("03", "Результаты", "Рассказчик 3", ["лучшие модели", "матрицы ошибок", "кого путает модель", "выводы"]),
]
for index, (num, title, narrator, lines) in enumerate(blocks):
x = 0.45 + index * 4.27
add_panel(slide, x, 1.5, 4.0, 5.15, fill=CREAM)
add_text(slide, x + 0.25, 1.7, 0.9, 0.5, num, size=30, bold=True, color=GOLD)
add_text(slide, x + 0.25, 2.3, 3.5, 0.5, title, size=24, bold=True, color=GREEN_DARK)
add_text(slide, x + 0.25, 2.9, 3.5, 0.35, narrator, size=13, color=RED)
add_bullets(slide, x + 0.25, 3.45, 3.5, 2.7, lines, size=14, color=DARK)
add_notes(slide, "Показать три блока и передать слово первому рассказчику. Подчеркнуть: сначала данные, затем методы, затем интерпретация.")
# 3. Блок 1
slide_no += 1
section_slide(
prs, 1, "Блок 1", "Корпус: как собрали и разметили",
"Что именно мы классифицируем и почему это сложная задача",
["Откуда данные и какова единица анализа", "Кто в корпусе и как распределены реплики", "Длина реплик и очистка", "Паспорт корпуса и ограничения"],
"Передать слово рассказчику 1. Блок отвечает на вопрос: что представляет собой выборка и можно ли ей доверять.",
slide_no,
)
# 4. Что анализируем
slide_no += 1
slide = content_slide(prs, "Что анализируем", "Блок 1 · Корпус", slide_no)
add_panel(slide, 0.45, 1.4, 5.95, 5.4, fill=CREAM)
add_bullets(slide, 0.7, 1.6, 5.45, 4.9, [
"Единица анализа — одна реплика персонажа.",
"Всего 2005 реплик; 34 персонажа; один автор.",
"Класс — имя персонажа; один объект относится ровно к одному классу.",
"Реплики короткие: в среднем 19 слов, медиана 17.",
"Корпус англоязычный; разметка — только текст и персонаж.",
], size=17, title="Постановка задачи")
add_panel(slide, 6.65, 1.4, 6.2, 5.4, fill=CREAM_LIGHT)
add_text(slide, 6.85, 1.55, 5.8, 0.35, "Примеры разных голосов", size=16, bold=True, color=GREEN_DARK)
quotes = [
("Abigail (139 реплик)", "Oh, that's right... I heard someone new was moving onto that old farm."),
("Jas (34 реплики)", "Wow, a birthday present! I love it!"),
("Krobus (28 реплик)", "I wanted to give you this as a 'thank you' for letting me live here with you."),
("Wizard (24 реплики)", "Ah, yes. I have predicted your arrival a long time ago, young PLAYER."),
]
y = 1.98
for name, quote in quotes:
add_text(slide, 6.85, y, 5.8, 0.3, name, size=13, bold=True, color=BROWN)
add_text(slide, 6.85, y + 0.28, 5.75, 0.55, f"«{quote}»", size=13, color=DARK)
y += 1.08
add_notes(slide, "Объяснить: одна реплика — один объект. Показать четыре голоса: длинная речь Abigail, короткая детская реплика Jas, нечеловеческая вежливость Krobus, архаичный Wizard. Это иллюстрирует, что сигнал может быть в лексике, длине, пунктуации и синтаксисе.")
# 5. Кто в корпусе
slide_no += 1
slide = content_slide(prs, "Кто в корпусе", "Блок 1 · Корпус", slide_no)
add_panel(slide, 0.4, 1.35, 8.8, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "characters.png", 0.5, 1.42, 8.6, 5.4)
add_panel(slide, 9.3, 1.35, 3.6, 5.55, fill=CREAM)
add_bullets(slide, 9.48, 1.55, 3.25, 5.1, [
"34 персонажа: подростки, взрослые, пожилые, дети и нелюди.",
"Все реплики написаны одним разработчиком; многие — в одних и тех же обновлениях и сюжетных контекстах.",
"Классы сильно неравномерны: от 15 до 139 реплик.",
"Метаданные о поле, возрасте и виде используются только для анализа ошибок, не как признаки.",
], size=14, title="Состав")
add_notes(slide, "Показать портреты. Подчеркнуть: один автор и общие сюжетные контексты — поэтому сходство голосов ожидаемо. Метаданные не подаются классификатору; они нужны только для интерпретации ошибок.")
# 6. Количество реплик
slide_no += 1
slide = content_slide(prs, "Сколько реплик у каждого", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: число реплик по персонажам")
add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, IMAGES / "replics-count.png", 0.5, 1.42, 7.0, 5.4)
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [
"Максимум — Abigail: 139 реплик; минимум — Mister Qi: 15.",
"Разница между классами почти десятикратная.",
"При 34 классах случайное угадывание даёт около 2,9%.",
"Поэтому доля правильных ответов обманчива: модель может выбирать частые классы.",
"Основная метрика — макро-усреднённая F-мера: каждый персонаж весит одинаково.",
], size=16, title="Дисбаланс классов")
add_notes(slide, "Диаграмма показывает дисбаланс. Сразу объяснить, почему accuracy не подходит: модель, всегда выбирающая Abigail, получит около 7% правильных ответов, но почти нулевую macro-F1 по остальным 33 классам.")
# 7. Длина реплик
slide_no += 1
slide = content_slide(prs, "Длина реплик", "Блок 1 · Корпус", slide_no, "Радиальная диаграмма: медианная длина предложения в символах")
add_panel(slide, 0.4, 1.35, 7.2, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, IMAGES / "median.png", 0.5, 1.42, 7.0, 5.4)
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
add_bullets(slide, 8.02, 1.55, 4.7, 5.0, [
"Средняя длина — 19,1 слова; медиана — 17 слов.",
"Диапазон — от 3 до 78 слов.",
"Медианная длина предложения в символах: от 52 у Vincent до 114 у Sandy.",
"Короткие реплики дают мало свидетельств; стилометрические оценки на них нестабильны.",
"Длина сама по себе — сильный, но нежелательный признак: она может стать прокси персонажа.",
], size=15, title="Короткие тексты")
add_notes(slide, "Числа у подписей — медианная длина предложения в символах. Показать контраст Vincent/Jas и Sandy/Wizard. Сказать, что длина — законный признак, но её вклад нужно проверять отдельно, иначе модель будет различать не стиль, а «кто говорит длиннее».")
# 8. Очистка и разметка
slide_no += 1
slide = content_slide(prs, "Очистка и разметка", "Блок 1 · Корпус", slide_no)
add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [
"Источник — игровые реплики; CSV в cp1251, XLSX как резерв.",
"Нормализация Unicode: многоточия, кавычки, тире, неразрывные пробелы.",
"Плейсхолдеры <PLAYER>, <FARM> и другие заменены нейтральными словами; их количество сохранено отдельным признаком.",
"Токенизация и разбиение на предложения — NLTK; частеречная разметка — Penn Treebank → Universal POS.",
"Стоп-слова, пунктуация и регистр не удалялись: это стилометрический сигнал.",
], size=15, title="Что сделали")
add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [
"Шум: дубликаты, технические артефакты, общие короткие фразы.",
"Ручная фильтрация уже удалила короткие и общие реплики; правила не сохранились.",
"В текущем CSV 0 точных дублей; 13 пар с косинусной близостью ≥ 0,8 — все внутри одного персонажа.",
"Нет идентификаторов сцен и диалогов; это ограничение анализа.",
"Разметка — только имя персонажа; никакой ручной стилистической разметки нет.",
], size=15, title="Что считаем шумом")
add_notes(slide, "Объяснить различие между шумом и стилем. Удалять пунктуацию или стоп-слова нельзя: это носители стиля. Ручная фильтрация — ограничение: она могла удалить как раз короткие характерные реплики. Отметить, что почти-дубликаты не пересекают классы, поэтому утечки через них нет.")
# 9. Паспорт корпуса
slide_no += 1
slide = content_slide(prs, "Паспорт корпуса", "Блок 1 · Корпус", slide_no)
add_panel(slide, 0.45, 1.35, 7.1, 5.55, fill=CREAM_LIGHT)
rows = [
["Показатель", "Значение"],
["Реплик", "2005"],
["Персонажей", "34"],
["Словоупотреблений", "38 207"],
["Средняя длина", "19,1 слова"],
["Медиана длины", "17 слов"],
["Минимум / максимум", "3 / 78 слов"],
["Классы", "15–139 реплик"],
["Точные дубли", "0"],
["Почти-дубли (cos ≥ 0,8)", "13 пар, все внутри класса"],
]
add_table(slide, 0.7, 1.6, 6.6, 4.9, rows, col_widths=[3.4, 3.2], font_size=13)
add_panel(slide, 7.85, 1.35, 5.05, 5.55, fill=CREAM)
add_bullets(slide, 8.02, 1.6, 4.7, 5.0, [
"Корпус пригоден для классификации, но не для сильных выводов о «чистом» стиле.",
"Ограничения: короткие тексты, дисбаланс, один автор, отсутствие сцен.",
"Все дальнейшие результаты нужно сравнивать с случайным уровнем 2,9%, а не с нулём.",
], size=16, title="Что важно")
add_notes(slide, "Этот слайд — «паспорт» данных. Подчеркнуть: 2005 объектов и 34 класса — небольшая выборка; 15 реплик у Mister Qi дают очень широкий интервал. Поэтому дальше используем повторную перекрёстную проверку и доверительные интервалы.")
# 10. Блок 2
slide_no += 1
section_slide(
prs, 2, "Блок 2", "Методология: как измеряем стиль",
"Признаки, модели и проверка — на понятном языке",
["Какие свойства текста считаем", "Как работает LSA", "Как устроены классификаторы", "Как не обмануть себя при оценке"],
"Передать слово рассказчику 2. Блок объясняет, что именно подаётся модели и почему выбранным методам можно доверять.",
slide_no,
)
# 11. Общая схема
slide_no += 1
slide = content_slide(prs, "Общая схема", "Блок 2 · Методология", slide_no)
steps = [
("Реплика", "текст персонажа"),
("Очистка", "Unicode, плейсхолдеры, пробелы"),
("Токены и POS", "NLTK, Universal POS"),
("Признаки", "ручные, n-граммы, LSA"),
("Модель", "вероятности по 34 классам"),
("Проверка", "5 блоков, повторы, метрики"),
]
x = 0.55
for index, (title, subtitle) in enumerate(steps):
add_panel(slide, x, 2.0, 1.85, 2.35, fill=CREAM)
add_text(slide, x + 0.12, 2.18, 1.6, 0.45, title, size=15, bold=True, color=GREEN_DARK, align=PP_ALIGN.CENTER)
add_text(slide, x + 0.1, 2.7, 1.65, 1.1, subtitle, size=12, color=DARK, align=PP_ALIGN.CENTER)
if index < len(steps) - 1:
add_text(slide, x + 1.82, 2.7, 0.35, 0.4, "→", size=26, bold=True, color=RED, align=PP_ALIGN.CENTER)
x += 2.12
add_panel(slide, 0.55, 4.75, 12.15, 1.55, fill=CREAM_LIGHT)
add_bullets(slide, 0.75, 4.9, 11.75, 1.2, [
"Обучение и преобразования выполняются только внутри обучающих блоков; тест не участвует в настройке.",
"Вектор признаков — это не «понимание» текста, а числовое описание его статистических свойств.",
], size=15)
add_notes(slide, "Провести по схеме слева направо. Главная мысль: модель не «читает» реплику как человек, а считает частоты и комбинации признаков. Отдельно подчеркнуть, что все преобразования обучаются внутри блоков, иначе возникает утечка.")
# 12. Признаки
slide_no += 1
slide = content_slide(prs, "Признаки: что именно считаем", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.1, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.7, 5.1, [
"454 ручных признака в 11 группах.",
"Длина: слова, символы, предложения, длина слова.",
"Лексика и орфография: распределение длин, апострофы, заглавные, цифры.",
"Словарное богатство: TTR, Guiraud, Yule's K, hapax; на коротких текстах нестабильно.",
"Пунктуация: запятые, многоточия, восклицания, вопросы, повторы.",
"Разговорные маркеры: междометия, смех, хеджи, вежливость, сокращения.",
], size=14, title="Ручные признаки")
add_panel(slide, 6.75, 1.35, 6.1, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 6.95, 1.55, 5.7, 5.1, [
"Функциональные слова: около 150 закрытых слов и 12 категорий.",
"Местоимения: 1-е, 2-е, 3-е лицо; отношение «я/ты».",
"Части речи: Universal POS и Penn Treebank, время, модальность, герундий.",
"Синтаксис-прокси: клаузы, подчинительные союзы, пассив, вопросы.",
"Символьные n-граммы 2–5 и POS n-граммы 1–3.",
"LSA: сжатые векторы по словам, содержанию, POS и символам.",
], size=14, title="Синтаксис и векторные блоки")
add_notes(slide, "Не перечислять все 454 признака. Объяснить логику: закрытые классы слов и пунктуация — классическая стилометрия; n-граммы — морфология и синтаксис; LSA — сжатое представление. Отметить, что словарное богатство и читаемость помечены как предварительные из-за коротких текстов.")
# 13. LSA
slide_no += 1
slide = content_slide(prs, "LSA простыми словами", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.6, 5.1, [
"Строим таблицу «документ × термин» и взвешиваем частоты через TF-IDF.",
"Сжимаем таблицу сингулярным разложением до k скрытых осей.",
"Каждая реплика превращается в вектор из k чисел.",
"Оси не обязаны быть темами; их знак и порядок произвольны.",
"k выбирается по объяснённой дисперсии и внешней метрике.",
], size=16, title="Идея")
add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [
"word — лексика и тема.",
"content — содержание после удаления стоп-слов.",
"fw — функциональные слова и синтаксис.",
"char — морфология и пунктуация.",
"pos — частеречные последовательности.",
"В работе k = 50–400; для content-LSA качество росло до 400.",
], size=15, title="Варианты")
add_panel(slide, 0.65, 5.65, 11.9, 0.8, fill=CREAM_LIGHT)
add_text(slide, 0.8, 5.78, 11.6, 0.5, "Ключевой вопрос: LSA по содержательным словам ловит тему, а не только стиль.", size=15, bold=True, color=RED, align=PP_ALIGN.CENTER)
add_notes(slide, "Объяснить SVD без формул: было много столбцов, стало несколько скрытых осей; это сжатие, а не «понимание». Вариант content может кодировать тему, поэтому его нельзя автоматически называть стилем. В конце блока результатов покажем контроль содержания.")
# 14. Логистическая регрессия
slide_no += 1
slide = content_slide(prs, "Логистическая регрессия", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.0, 5.55, fill=CREAM)
add_text(slide, 0.7, 1.55, 5.5, 0.35, "Как работает", size=18, bold=True, color=GREEN_DARK)
add_bullets(slide, 0.7, 1.95, 5.5, 4.7, [
"Для каждого класса считает линейную оценку: сумма «признак × вес» плюс сдвиг.",
"Преобразует оценки в вероятности через softmax: они неотрицательны и суммируются в 1.",
"Обучение минимизирует перекрёстную энтропию — штраф за низкую вероятность истинного класса.",
"L2-регуляризация удерживает веса малыми; параметр C задаёт силу регуляризации.",
"class_weight='balanced' повышает вес редких классов.",
], size=15)
add_panel(slide, 6.65, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 6.85, 1.55, 5.8, 5.1, [
"Плюсы: устойчива при большом числе признаков, даёт вероятности, коэффициенты интерпретируемы.",
"Минусы: только линейные границы; при сильной корреляции признаков веса нестабильны.",
"В работе — основная линейная базовая модель.",
"Сравнение C: 0,03–10; балансировка классов критична.",
], size=15, title="Что важно")
add_notes(slide, "Объяснить на пальцах: модель складывает голоса признаков с весами; softmax превращает их в вероятности. C — обратная сила штрафа: меньше C — проще модель. Отметить, что без балансировки редкие персонажи почти не находятся; это было видно в эксперименте.")
# 15. SVM и деревья
slide_no += 1
slide = content_slide(prs, "SVM, деревья и ансамбли", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.05, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.65, 5.1, [
"Линейный SVM ищет границу с максимальным зазором; C штрафует ошибки.",
"SVM не даёт вероятностей: только решающую функцию; тройка лучших считается по её значениям.",
"Случайный лес: много деревьев, каждое на своей подвыборке; голосование.",
"Сверхслучайные деревья (ExtraTrees): пороги в узлах выбираются случайно; меньше дисперсии, быстрее.",
"Мягкое голосование усредняет вероятности нескольких моделей.",
"Стековое объединение обучает мета-классификатор на предсказаниях базовых моделей.",
], size=14, title="Идеи моделей")
add_panel(slide, 6.7, 1.35, 6.15, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 6.9, 1.55, 5.75, 5.1, [
"Деревья ловят нелинейные взаимодействия: «если многоточий и короткая реплика — это Jas».",
"Легко переобучаются на 2005 объектах; нужны ограничения глубины и листьев.",
"Дельта Барроуза: расстояние до центроида по частотным словам — классическая проверка.",
"Ансамбль полезен, если ошибки моделей не полностью совпадают.",
"Стековое объединение в наших данных переобучилось и оказалось хуже голосования.",
], size=14, title="Сильные и слабые стороны")
add_notes(slide, "Не углубляться в оптимизацию. Главное: SVM — линейная граница с зазором; деревья — нелинейные правила; ExtraTrees — случайные пороги; голосование усредняет вероятности. Дельта Барроуза — независимая классическая проверка, а не ещё одна нейросеть.")
# 16. Проверка
slide_no += 1
slide = content_slide(prs, "Как проверяем", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.25, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.85, 5.1, [
"Стратифицированная перекрёстная проверка: 5 блоков, доли классов сохраняются.",
"Повторяем 3–5 раз с разными разбиениями — оцениваем разброс.",
"Вложенная проверка: внешние блоки — оценка, внутренние — настройка.",
"Отложенная выборка 80/20 — независимая проверка финальных кандидатов.",
], size=16, title="Схема")
add_panel(slide, 6.9, 1.35, 5.95, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 7.1, 1.55, 5.55, 5.1, [
"TF-IDF, SVD, масштабирование и отбор признаков обучаются только на обучающей выборке.",
"Тестовая выборка не участвует в выборе гиперпараметров или признаков.",
"Проверка перестановкой меток дала 0,027–0,028 — случайный уровень.",
"Почти-дубликаты внутри одного класса не пересекают обучение и тест.",
"Метрики: макро-F1, сбалансированная доля, тройка лучших, MCC, каппа, матрица ошибок.",
], size=15, title="Контроль утечек")
add_notes(slide, "Объяснить, почему нельзя настраивать на тесте. Привести аналогию: если подсматривать ответы, оценка будет завышена. Перестановка меток — контроль: если модель на случайных метках даёт 0,027, пайплайн не течёт.")
# 17. Метрики
slide_no += 1
slide = content_slide(prs, "Метрики: что и зачем", "Блок 2 · Методология", slide_no)
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM_LIGHT)
rows = [
["Метрика", "Что показывает"],
["Макро-F1", "Средняя F-мера по 34 классам; все персонажи равны"],
["Доля правильных", "Доля совпавших ответов; обманчива при дисбалансе"],
["Сбалансированная доля", "Средняя полнота по классам"],
["Тройка лучших", "Истинный класс в тройке лучших"],
["MCC / каппа", "Согласие с поправкой на случай"],
["Матрица ошибок", "Кто с кем путается"],
]
add_table(slide, 0.65, 1.55, 5.75, 4.9, rows, col_widths=[2.2, 3.6], font_size=12)
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM)
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
"Случайный уровень при 34 классах: macro-F1 ≈ 0,029, top-3 ≈ 0,088.",
"Модель, всегда выбирающая Abigail: доля правильных ответов ≈ 6,9%, macro-F1 ≈ 0,004.",
"Поэтому основной вывод делаем по macro-F1 и доверительным интервалам.",
"Дополнительно смотрим F-меру по каждому персонажу и матрицу ошибок.",
], size=16, title="Почему не доля правильных ответов")
add_notes(slide, "Главный методический слайд: объяснить, что accuracy при 34 классах может вводить в заблуждение. Пример Dummy: 6,9% accuracy, но почти нулевая macro-F1. Поэтому все выводы — по macro-F1, а per-class F1 показывает, кого именно модель не находит.")
# 18. Блок 3
slide_no += 1
section_slide(
prs, 3, "Блок 3", "Результаты и интерпретация",
"Что получилось, кого модель путает и что это значит",
["Лучшие модели и масштаб качества", "Стиль или содержание", "Матрицы ошибок и пары персонажей", "Ограничения и выводы"],
"Передать слово рассказчику 3. Здесь важно не только назвать число, но и объяснить, что оно означает и чего оно не означает.",
slide_no,
)
# 19. Главный результат
slide_no += 1
slide = content_slide(prs, "Главный результат", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "model_comparison.png", 0.5, 1.45, 8.8, 5.35)
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
"Мягкое голосование: 0,304.",
"ExtraTrees, char=10000: 0,276.",
"Вложенный выбор: 0,275.",
"Случайный уровень: 0,029.",
"На отложенной выборке: 0,333.",
], size=15, title="Макро-F1")
add_notes(slide, "Назвать числа и сразу масштаб: 0,30 при 34 классах — это примерно в 10 раз выше случайного. Не говорить «низкая точность» без сравнения с 0,029. Оговорить, что 0,333 на отложенной выборке — одно разбиение и широкий интервал.")
# 20. Что добавляет качество
slide_no += 1
slide = content_slide(prs, "Что добавляет качество", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.4, 1.35, 8.95, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "feature_progression.png", 0.5, 1.45, 8.75, 5.35)
add_panel(slide, 9.5, 1.35, 3.4, 5.55, fill=CREAM)
add_bullets(slide, 9.67, 1.55, 3.1, 5.1, [
"Ручные признаки: 0,105.",
"+ char/POS n-граммы: 0,182.",
"+ content-LSA: 0,249.",
"ExtraTrees + char=10000: 0,276.",
"Мягкое голосование: 0,304.",
"Основной прирост — лексика и n-граммы, не «чистый синтаксис».",
], size=14, title="По шагам")
add_notes(slide, "Показать, что качество растёт не от «магии модели», а от добавления лексических и символьных признаков. Отметить: функциональные слова и POS- LSA сами по себе дают только 0,06–0,07.")
# 21. Стиль или содержание
slide_no += 1
slide = content_slide(prs, "Стиль или содержание?", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.4, 1.35, 8.4, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "topic_control.png", 0.5, 1.45, 8.2, 5.35)
add_panel(slide, 8.95, 1.35, 3.95, 5.55, fill=CREAM)
add_bullets(slide, 9.12, 1.55, 3.65, 5.1, [
"Исходный текст: 0,248.",
"После замены содержательных слов: 0,113.",
"Падение более чем вдвое.",
"Остаток 0,113 всё ещё в 3,9 раза выше случайного.",
"Значит, стиль есть, но содержание доминирует.",
], size=15, title="Контроль")
add_notes(slide, "Это ключевой слайд для честной интерпретации. Содержательные слова заменили на CONTENT, оставив служебные слова, POS и пунктуацию. Качество упало с 0,248 до 0,113. Значит, нельзя утверждать, что модель выучила «чистый идиолект»: она во многом различает, о чём персонаж говорит.")
# 22. Как читать матрицу
slide_no += 1
slide = content_slide(prs, "Как читать матрицу ошибок", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.45, 1.35, 6.2, 5.55, fill=CREAM_LIGHT)
add_text(slide, 0.7, 1.55, 5.7, 0.4, "Маленький пример", size=17, bold=True, color=GREEN_DARK)
mini = [
["истина \\ прогноз", "A", "B", "C"],
["A", "70", "20", "10"],
["B", "15", "75", "10"],
["C", "5", "25", "70"],
]
add_table(slide, 0.7, 2.05, 5.6, 2.5, mini, col_widths=[2.2, 1.1, 1.1, 1.1], font_size=14)
add_text(slide, 0.7, 4.7, 5.7, 1.8, "Строка — истинный персонаж. Столбец — предсказание модели. Диагональ — правильные ответы. Вне диагонали — ошибки. Числа — проценты от строки.", size=14, color=DARK)
add_panel(slide, 6.85, 1.35, 6.0, 5.55, fill=CREAM)
add_bullets(slide, 7.05, 1.55, 5.6, 5.1, [
"Полная матрица 34×34 на доске нечитаема.",
"Поэтому на слайде — 12 самых частых персонажей и столбец «прочие».",
"Отдельно показываем 18 самых частых пар ошибок.",
"Полная матрица 34×34 — в раздатке/приложении.",
"На доске смотрим диагональ, крупные внедиагональные клетки и «прочие».",
], size=15, title="Как показываем")
add_notes(slide, "Объяснить матрицу на маленьком примере. Сказать: полная матрица 34×34 на доске превращается в кашу; поэтому используем три уровня: top-12 матрица, top-18 пар, полная матрица в раздатке. Это методически честно и читаемо.")
# 23. Матрица top12
slide_no += 1
slide = content_slide(prs, "Матрица ошибок: 12 персонажей", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.35, 1.35, 9.05, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "confusion_top12.png", 0.45, 1.45, 8.85, 5.35)
add_panel(slide, 9.6, 1.35, 3.3, 5.55, fill=CREAM)
add_bullets(slide, 9.77, 1.55, 3.0, 5.1, [
"Диагональ: доля правильных ответов внутри класса.",
"Abigail: 21% верно, 18% уходит в «прочие».",
"Alex: 37% верно, 11% → Abigail.",
"Sebastian: 19% верно, 20% → Abigail.",
"У многих строк крупная доля «прочих»: модель не находит редкие классы.",
], size=13, title="Что видно")
add_notes(slide, "Провести по строкам: Abigail, Alex, Sebastian. Обратить внимание на столбец «прочие»: он большой, потому что 22 класса не поместились. Это не «мусор», а важная часть ошибок. Сказать, что диагональ далеко не 100%, но и не 0.")
# 24. Кого путает
slide_no += 1
slide = content_slide(prs, "Кого модель путает", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.35, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "confusion_pairs.png", 0.45, 1.45, 8.8, 5.35)
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
"Sebastian → Abigail: 65.",
"Sebastian → Alex: 61.",
"Abigail → Sebastian: 59.",
"Abigail → Alex: 56.",
"Основной клубок — подростки/молодые взрослые: Abigail, Alex, Sebastian, Haley, Sam.",
], size=13, title="Частые пары")
add_notes(slide, "Показать, что ошибки не случайны: они концентрируются в одном круге персонажей. Это ожидаемо: один автор, общие темы, сходная эмоциональная окраска, короткие реплики. Это не доказательство «автор не старался», а наоборот — свидетельство близких речевых масок внутри группы.")
# 25. Классы-аттракторы
slide_no += 1
slide = content_slide(prs, "Классы-аттракторы", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.4, 1.35, 9.0, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "hub_shares.png", 0.5, 1.45, 8.8, 5.35)
add_panel(slide, 9.55, 1.35, 3.35, 5.55, fill=CREAM)
add_bullets(slide, 9.72, 1.55, 3.05, 5.1, [
"Alex: 11,4% предсказаний при истинной доле 6,0%.",
"Abigail: 10,9% при 6,9%.",
"Редкие классы недопредставлены.",
"Это не «демографическая» путаница, а стягивание к популярным голосам.",
"Коррекция приоров не улучшила macro-F1.",
], size=13, title="Кто перетягивает")
add_notes(slide, "Объяснить «класс-аттрактор»: модель чаще выбирает Alex и Abigail, потому что их признаки часто встречаются и они похожи на многих. Редкие персонажи проигрывают. Простая коррекция приоров не помогла; нужны более тонкие правила или больше данных.")
# 26. Per-class F1
slide_no += 1
slide = content_slide(prs, "Персонажи: кто различается", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.3, 1.35, 9.25, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "per_class_f1.png", 0.4, 1.45, 9.05, 5.35)
add_panel(slide, 9.75, 1.35, 3.15, 5.55, fill=CREAM)
add_bullets(slide, 9.9, 1.55, 2.85, 5.1, [
"Лучшие: Willy, Evelyn, Pierre, Leo, Pam.",
"Худшие: Kent, Mister Qi, Penny, Abigail, Leah.",
"Редкие классы — широкие интервалы.",
"Мягкое голосование улучшает Wizard, Dwarf, Vincent.",
], size=13, title="Факты")
add_notes(slide, "Показать, что качество по персонажам неоднородно. Evelyn/Willy/Pierre различаются лучше; Kent/Mister Qi/Penny — хуже. Это связано и с числом реплик, и с их речевой функцией. Не делать вывод, что персонаж «плохой»: у редких классов просто мало данных.")
# 27. Почему путаются
slide_no += 1
slide = content_slide(prs, "Почему персонажи путаются", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [
"Один автор и общие сюжетные контексты: персонажи говорят об одних и тех же событиях.",
"Сходные возраст и роль: подростки/молодые взрослые используют похожие темы и интонации.",
"Короткие реплики: мало слов, чтобы проявить индивидуальный синтаксис.",
"Дисбаланс: редкие классы реже выбираются моделью.",
"Общие формулы вежливости, шутки, междометия.",
], size=15, title="Причины")
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
"Клубок: Abigail, Alex, Sebastian, Haley, Sam.",
"Нелюди (Krobus, Dwarf, Wizard) различаются лучше, но Wizard и Dwarf редки.",
"Дети (Jas, Vincent) имеют короткие реплики; их легче узнать по длине.",
"Это не доказательство сходства «характеров», а свойство текстовых признаков.",
"Нужны сцены и больше данных, чтобы отделить стиль от контекста.",
], size=15, title="Что это значит")
add_notes(slide, "Ответить на вопрос «почему одни и те же персонажи путаются»: один автор, общие темы, сходный возраст/роль, короткие реплики, дисбаланс. Подчеркнуть: мы не делаем психологических выводов о персонажах; мы объясняем статистику ошибок.")
# 28. Что означают оценки
slide_no += 1
slide = content_slide(prs, "Что означают оценки", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM)
add_bullets(slide, 0.7, 1.55, 11.9, 5.1, [
"0,30 макро-F1 при 34 классах — это примерно в 10 раз выше случайного уровня 0,029.",
"Абсолютное значение умеренное: по одной короткой реплике персонажа действительно трудно определить.",
"Это не означает, что автор «не старался» или что стиль неразличим. Наоборот, устойчивое превышение случайного уровня говорит о намеренно различных голосах.",
"Умеренность объясняется короткими текстами, дисбалансом классов, общими темами и формулами, а не отсутствием стиля.",
"Мы не решаем задачу «он или не он писал»: все реплики одного автора. Мы решаем, какой персонаж говорит.",
], size=17, title="Уточнение интерпретации")
add_notes(slide, "Здесь важно поправить исходную формулировку. Нельзя говорить «низкие оценки хорошие, потому что автор не старался». Корректно: оценки выше случайного уровня, но умеренные; это свидетельствует о частичной различимости голосов. Один автор — значит, различия созданы намеренно, а не разными авторами. Низкие абсолютные значения — следствие трудности задачи, а не приговор стилю.")
# 29. Ограничения
slide_no += 1
slide = content_slide(prs, "Ограничения", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.45, 1.35, 6.15, 5.55, fill=CREAM)
add_bullets(slide, 0.65, 1.55, 5.75, 5.1, [
"Один корпус, одна игра, один автор.",
"Нет внешней валидации на другом корпусе.",
"Короткие реплики и дисбаланс классов.",
"Ручная фильтрация корпуса не воспроизводится.",
"Нет идентификаторов сцен и диалогов.",
], size=16, title="Данные")
add_panel(slide, 6.8, 1.35, 6.05, 5.55, fill=CREAM_LIGHT)
add_bullets(slide, 7.0, 1.55, 5.65, 5.1, [
"Выбор признаков и ансамбля опирался на предыдущие результаты.",
"Вложенная проверка снижает, но не устраняет оптимизм.",
"Содержательные слова и тема вносят большой вклад.",
"Простая калибровка приоров не помогла.",
"Стековое объединение переобучается на малом числе объектов.",
], size=16, title="Методология")
add_notes(slide, "Перечислить ограничения честно. Главное: нельзя переносить выводы на другие игры/авторов без внешней проверки. И нельзя называть результат «чистым стилем», пока не отделена тема.")
# 30. Выводы
slide_no += 1
slide = content_slide(prs, "Выводы", "Блок 3 · Результаты", slide_no)
add_panel(slide, 0.45, 1.35, 12.4, 5.55, fill=CREAM)
add_bullets(slide, 0.75, 1.55, 11.8, 5.1, [
"Персонажи статистически различимы: лучшая модель — 0,304 макро-F1, консервативная оценка — 0,275 при случайном уровне 0,029.",
"Основной вклад дают лексика, символьные n-граммы и LSA по содержательным словам; функциональные слова и синтаксис сами по себе слабы.",
"После замены содержательных слов качество падает с 0,248 до 0,113: содержание доминирует, но стилевой сигнал сохраняется.",
"Главная проблема — редкие классы и классы-аттракторы; мягкое голосование помогает, но не решает её полностью.",
"Корректный вывод: различимы речевые маски по лексико-тематическому профилю; для утверждения о «чистом стиле» нужны внешняя валидация и тематический контроль.",
], size=17, title="Пять тезисов")
add_notes(slide, "Сформулировать выводы без переубеждения аудитории. Главное: не «мы всё доказали», а «мы получили устойчивый выше случайного уровня сигнал; основной вклад — лексика/тема; для стиля нужны дальнейшие проверки».")
# 31. Приложение: полная матрица
slide_no += 1
slide = content_slide(prs, "Приложение: полная матрица 34×34", "Приложение", slide_no)
add_panel(slide, 0.35, 1.35, 7.4, 5.55, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "confusion_full34.png", 0.45, 1.45, 7.2, 5.35)
add_panel(slide, 7.95, 1.35, 4.95, 5.55, fill=CREAM)
add_bullets(slide, 8.12, 1.55, 4.6, 5.1, [
"Полная матрица 34×34 — для раздаточного материала.",
"На проекторе она слишком плотная; в докладе используем top-12 и top-18 пар.",
"Строки — истинные персонажи; столбцы — предсказания; цвет — доля строки.",
"Диагональ — правильные ответы; тёмные внедиагональные клетки — систематические ошибки.",
], size=15, title="Как использовать")
add_notes(slide, "Этот слайд можно не показывать, а раздать. Если показывать, не читать все клетки: показать диагональ и несколько тёмных пятен.")
# 32. Финал: мем
slide_no += 1
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_background(slide)
add_panel(slide, 0.55, 1.2, 6.2, 5.45, fill=CREAM)
add_text(slide, 0.85, 1.5, 5.6, 1.0, "Спасибо за внимание!", size=34, bold=True, color=GREEN_DARK)
add_text(slide, 0.85, 2.55, 5.5, 1.5, "Вопросы, обсуждение и предложения по экспериментам", size=18, color=DARK)
add_text(slide, 0.85, 4.2, 5.5, 1.2, "«Один автор — разные голоса»", size=20, bold=True, color=RED)
add_panel(slide, 7.0, 0.9, 5.8, 6.0, fill=CREAM_LIGHT)
add_image_fit(slide, GEN / "meme.png", 7.15, 1.05, 5.5, 5.7)
add_chicken(slide, 0.25, 7.05, 0.28)
add_notes(slide, "Финальный слайд с мемом. Поблагодарить, перейти к вопросам. Курица спрятана слева внизу — пасхалка.")
prs.save(OUT)
print(f"Готово: {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())