Загрузка данных
"""
Учебная программа для объяснения дерева решений на занятии.
Тема примера: подбор аниме на вечер.
Запуск:
python3 anime_decision_tree_demo.py
Консольный режим:
python3 anime_decision_tree_demo.py --console
"""
from dataclasses import dataclass
import sys
import textwrap
@dataclass
class DecisionNode:
"""Один узел дерева решений."""
node_id: str
title: str
explanation: str
answers: list[tuple[str, str]]
result: str = ""
@property
def is_leaf(self) -> bool:
return bool(self.result)
def build_tree() -> dict[str, DecisionNode]:
"""База знаний в виде дерева. Знания отделены от алгоритма обхода."""
return {
# ===== КОРЕНЬ =====
"root": DecisionNode(
node_id="root",
title="Какое у вас настроение?",
explanation=(
"Первый вопрос должен быть самым общим. Настроение определяет "
"тон и жанр аниме, поэтому оно хорошо подходит для корня дерева."
),
answers=[("Весёлое", "fun_time"), ("Серьёзное", "serious_genre")],
),
# ===== ВЕТКА «ВЕСЁЛОЕ» =====
"fun_time": DecisionNode(
node_id="fun_time",
title="Сколько у вас времени?",
explanation=(
"После весёлого настроения уточняем время. Один и тот же жанр "
"может быть коротким (пара серий) или длинным (сериал)."
),
answers=[("До 1 часа", "fun_short"), ("Больше 1 часа", "fun_long")],
),
"fun_short": DecisionNode(
node_id="fun_short",
title="С кем смотрите?",
explanation=(
"Компания меняет рекомендацию: для группы лучше что-то понятное "
"и заводное, для одного — более личное."
),
answers=[("Один", "fun_short_alone"), ("С компанией", "fun_short_company")],
),
"fun_long": DecisionNode(
node_id="fun_long",
title="С кем смотрите?",
explanation=(
"Если времени много, компания снова важна: сериалы по-разному "
"смотрятся в одиночку и вместе."
),
answers=[("Один", "fun_long_alone"), ("С компанией", "fun_long_company")],
),
"fun_short_alone": DecisionNode(
node_id="fun_short_alone",
title="Что важнее?",
explanation="Последний уточняющий вопрос: посмеяться или отправиться в приключение.",
answers=[
("Комедия и лёгкость", "l1_one_punch"),
("Приключения", "l2_seven_deadly"),
],
),
"fun_short_company": DecisionNode(
node_id="fun_short_company",
title="Что важнее?",
explanation="Последний уточняющий вопрос: подобрать комедию или что-то с приключением.",
answers=[
("Комедия и лёгкость", "l3_konosuba"),
("Приключения", "l4_gintama_short"),
],
),
"fun_long_alone": DecisionNode(
node_id="fun_long_alone",
title="Что важнее?",
explanation="Уточняем предпочтение: сериал-комедия или длинное приключение.",
answers=[
("Комедия и лёгкость", "l5_one_piece"),
("Приключения", "l6_hunter"),
],
),
"fun_long_company": DecisionNode(
node_id="fun_long_company",
title="Что важнее?",
explanation="Для длинного просмотра компанией важно понять, что зайдёт: комедия или приключения.",
answers=[
("Комедия и лёгкость", "l7_gintama_series"),
("Приключения", "l8_fairy_tail"),
],
),
# ===== ВЕТКА «СЕРЬЁЗНОЕ» =====
"serious_genre": DecisionNode(
node_id="serious_genre",
title="Какой жанр ближе?",
explanation=(
"Серьёзное настроение не даёт точного жанра. Драма и психология "
"требуют разного темпа и атмосферы."
),
answers=[("Драма", "serious_drama"), ("Психология", "serious_psychology")],
),
"serious_drama": DecisionNode(
node_id="serious_drama",
title="Сколько у вас времени?",
explanation=(
"Драма может быть короткой (полнометражка) или длинной (сериал). "
"Время здесь — ключевой признак."
),
answers=[
("До 1 часа", "serious_drama_short"),
("Больше 1 часа", "serious_drama_long"),
],
),
"serious_psychology": DecisionNode(
node_id="serious_psychology",
title="Сколько у вас времени?",
explanation=(
"Психологические истории тоже бывают разной длины. Это влияет "
"на глубину погружения."
),
answers=[
("До 1 часа", "serious_psychology_short"),
("Больше 1 часа", "serious_psychology_long"),
],
),
"serious_drama_short": DecisionNode(
node_id="serious_drama_short",
title="С кем смотрите?",
explanation="Короткая драма по-разному воспринимается одной и в компании. Уточняем.",
answers=[
("Один", "l9_shape_of_voice"),
("С компанией", "l10_your_lie_short"),
],
),
"serious_drama_long": DecisionNode(
node_id="serious_drama_long",
title="С кем смотрите?",
explanation="Длинная драма — эмоциональный марафон. Компания меняет ощущение.",
answers=[
("Один", "l11_your_lie_series"),
("С компанией", "l12_clannad"),
],
),
"serious_psychology_short": DecisionNode(
node_id="serious_psychology_short",
title="С кем смотрите?",
explanation="Психология короткого формата — либо наедине, либо в обсуждении с кем-то.",
answers=[
("Один", "l13_perfect_blue"),
("С компанией", "l14_monster_short"),
],
),
"serious_psychology_long": DecisionNode(
node_id="serious_psychology_long",
title="С кем смотрите?",
explanation="Долгое психологическое погружение. Один или с кем-то — принципиально разные вечера.",
answers=[
("Один", "l15_monster_series"),
("С компанией", "l16_evangelion"),
],
),
# ===== ЛИСТЬЯ =====
"l1_one_punch": DecisionNode("l1_one_punch", "Лист", "Конечный узел.",
[], "«Ванпанчмен» — 1–2 серии. Лёгкий юмор и сатира на супергероев."),
"l2_seven_deadly": DecisionNode("l2_seven_deadly", "Лист", "Конечный узел.",
[], "«Семь смертных грехов» — 1–2 серии. Лёгкие приключения."),
"l3_konosuba": DecisionNode("l3_konosuba", "Лист", "Конечный узел.",
[], "«Коносуба» — 1–2 серии. Пародийный исэкай, хорошо заходит компанией."),
"l4_gintama_short": DecisionNode("l4_gintama_short", "Лист", "Конечный узел.",
[], "«Гинтама» — 1–2 серии. Приключения с фирменным юмором."),
"l5_one_piece": DecisionNode("l5_one_piece", "Лист", "Конечный узел.",
[], "«Ван-Пис» — сериал. Много серий, надолго."),
"l6_hunter": DecisionNode("l6_hunter", "Лист", "Конечный узел.",
[], "«Хантер х Хантер» — сериал. Классические приключения."),
"l7_gintama_series": DecisionNode("l7_gintama_series", "Лист", "Конечный узел.",
[], "«Гинтама» — сериал. Комедия, идеально на компанию."),
"l8_fairy_tail": DecisionNode("l8_fairy_tail", "Лист", "Конечный узел.",
[], "«Fairy Tail» — сериал. Дружба, магия, приключения."),
"l9_shape_of_voice": DecisionNode("l9_shape_of_voice", "Лист", "Конечный узел.",
[], "«Форма голоса» — полнометражка. Трогательная драма о принятии себя."),
"l10_your_lie_short": DecisionNode("l10_your_lie_short", "Лист", "Конечный узел.",
[], "«Твоя апрельская ложь» — 1–2 серии. Драма с музыкой."),
"l11_your_lie_series": DecisionNode("l11_your_lie_series", "Лист", "Конечный узел.",
[], "«Твоя апрельская ложь» — сериал. Полная история, сильные эмоции."),
"l12_clannad": DecisionNode("l12_clannad", "Лист", "Конечный узел.",
[], "«Кланнад» — сериал. Семейная драма, много эмоций."),
"l13_perfect_blue": DecisionNode("l13_perfect_blue", "Лист", "Конечный узел.",
[], "«Идеальная грусть» — 1–2 серии. Психологическая драма."),
"l14_monster_short": DecisionNode("l14_monster_short", "Лист", "Конечный узел.",
[], "«Монстр» — 1–2 серии. Психологический триллер."),
"l15_monster_series": DecisionNode("l15_monster_series", "Лист", "Конечный узел.",
[], "«Монстр» — сериал. Глубокое психологическое погружение."),
"l16_evangelion": DecisionNode("l16_evangelion", "Лист", "Конечный узел.",
[], "«Евангелион» — сериал. Психология и философия."),
}
# Координаты узлов для GUI-режима.
NODE_POSITIONS = {
"root": (800, 60),
"fun_time": (400, 180), "serious_genre": (1200, 180),
"fun_short": (200, 310), "fun_long": (600, 310),
"serious_drama": (1000, 310), "serious_psychology": (1400, 310),
"fun_short_alone": (100, 450), "fun_short_company": (300, 450),
"fun_long_alone": (500, 450), "fun_long_company": (700, 450),
"serious_drama_short": (900, 450), "serious_drama_long": (1100, 450),
"serious_psychology_short": (1300, 450), "serious_psychology_long": (1500, 450),
"l1_one_punch": (50, 600), "l2_seven_deadly": (150, 600),
"l3_konosuba": (250, 600), "l4_gintama_short": (350, 600),
"l5_one_piece": (450, 600), "l6_hunter": (550, 600),
"l7_gintama_series": (650, 600), "l8_fairy_tail": (750, 600),
"l9_shape_of_voice": (850, 600), "l10_your_lie_short": (950, 600),
"l11_your_lie_series": (1050, 600), "l12_clannad": (1150, 600),
"l13_perfect_blue": (1250, 600), "l14_monster_short": (1350, 600),
"l15_monster_series": (1450, 600), "l16_evangelion": (1550, 600),
}
def run_console_mode() -> None:
"""Текстовый режим для онлайн-компилятора или демонстрации без GUI."""
nodes = build_tree()
current_id = "root"
path = []
print("Дерево решений: подбор аниме на вечер")
print("Отвечайте цифрой. Чтобы выйти, введите q.\n")
while True:
node = nodes[current_id]
if node.is_leaf:
print("Итоговая рекомендация:")
print(textwrap.fill(node.result, width=80))
print("\nПуть по дереву:")
for step in path:
print(" -", step)
return
print("Вопрос:", node.title)
print("Пояснение:", textwrap.fill(node.explanation, width=80))
for index, (answer, _nid) in enumerate(node.answers, start=1):
print(f"{index}. {answer}")
raw = input("Ваш выбор: ").strip().lower()
if raw == "q":
print("Выход.")
return
try:
answer_index = int(raw) - 1
answer, next_id = node.answers[answer_index]
except (ValueError, IndexError):
print("Такого варианта нет. Попробуйте ещё раз.\n")
continue
path.append(f"{node.title} → {answer}")
current_id = next_id
print()
def run_gui_mode() -> None:
"""Графический режим с визуальным деревом и подсветкой пути."""
import tkinter as tk
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
nodes = build_tree()
class DecisionTreeApp:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("Дерево решений: подбор аниме на вечер")
self.root.geometry("1620x900")
self.current_id = "root"
self.path_edges: list[tuple[str, str]] = []
self.path_text: list[str] = []
self.show_full_tree = tk.BooleanVar(value=False)
self.canvas = tk.Canvas(root, width=1600, height=700, bg="#f7fbff")
self.canvas.pack(fill=tk.BOTH, expand=True, padx=10, pady=(10, 5))
bottom = ttk.Frame(root)
bottom.pack(fill=tk.X, padx=10, pady=(0, 10))
self.info = ScrolledText(bottom, height=10, wrap=tk.WORD, font=("Arial", 11))
self.info.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
controls = ttk.Frame(bottom)
controls.pack(side=tk.RIGHT, fill=tk.Y)
self.answer_frame = ttk.LabelFrame(controls, text="Варианты ответа")
self.answer_frame.pack(fill=tk.X, pady=(0, 8))
ttk.Button(controls, text="Начать заново", command=self.reset).pack(fill=tk.X, pady=2)
ttk.Checkbutton(
controls, text="Показать все дерево",
variable=self.show_full_tree, command=self.draw_tree,
).pack(fill=tk.X, pady=2)
ttk.Button(
controls, text="План объяснения у доски",
command=self.show_board_plan,
).pack(fill=tk.X, pady=2)
self.draw_tree()
self.update_panel()
def reset(self) -> None:
self.current_id = "root"
self.path_edges.clear()
self.path_text.clear()
self.draw_tree()
self.update_panel()
def choose_answer(self, answer: str, next_id: str) -> None:
self.path_edges.append((self.current_id, next_id))
self.path_text.append(f"{nodes[self.current_id].title} → {answer}")
self.current_id = next_id
self.draw_tree()
self.update_panel()
def draw_tree(self) -> None:
self.canvas.delete("all")
self.canvas.create_text(
800, 22,
text="Визуальное построение дерева решений: подбор аниме",
font=("Arial", 18, "bold"), fill="#16324f",
)
visible_nodes = self.get_visible_nodes()
for node_id, node in nodes.items():
if node_id not in visible_nodes:
continue
x1, y1 = NODE_POSITIONS[node_id]
for answer, next_id in node.answers:
if next_id not in visible_nodes:
continue
x2, y2 = NODE_POSITIONS[next_id]
active = (node_id, next_id) in self.path_edges
preview = node_id == self.current_id and not active
color = "#1f7a4d" if active else "#b8c4d2" if preview else "#7a8ca3"
width = 4 if active else 2
self.canvas.create_line(
x1, y1 + 34, x2, y2 - 34,
arrow=tk.LAST, fill=color, width=width, smooth=True,
)
self.canvas.create_text(
(x1 + x2) / 2, (y1 + y2) / 2 - 8,
text=answer, font=("Arial", 9),
fill="#26364a", width=110,
)
for node_id, node in nodes.items():
if node_id not in visible_nodes:
continue
self.draw_node(node_id, node)
def get_visible_nodes(self) -> set:
if self.show_full_tree.get():
return set(nodes)
visible = {"root", self.current_id}
for from_id, to_id in self.path_edges:
visible.add(from_id)
visible.add(to_id)
for _a, next_id in nodes[self.current_id].answers:
visible.add(next_id)
return visible
def draw_node(self, node_id: str, node: DecisionNode) -> None:
x, y = NODE_POSITIONS[node_id]
width = 85 if node.is_leaf else 170
height = 58 if node.is_leaf else 68
x1, y1 = x - width / 2, y - height / 2
x2, y2 = x + width / 2, y + height / 2
if node_id == self.current_id:
fill, outline, lw = "#ffefb0", "#c88719", 4
elif node_id in {e[1] for e in self.path_edges} or node_id == "root":
fill = "#dff4e8" if node.is_leaf else "#dbeafe"
outline, lw = "#1f7a4d", 3
else:
fill = "#eef7ee" if node.is_leaf else "#e8f1fb"
outline, lw = "#51769c", 2
self.canvas.create_rectangle(x1, y1, x2, y2, fill=fill, outline=outline, width=lw)
text = node.result.split(".")[0] if node.is_leaf else node.title
self.canvas.create_text(
x, y, text=text, width=width - 14,
justify=tk.CENTER,
font=("Arial", 9, "bold" if not node.is_leaf else "normal"),
fill="#102033",
)
def update_panel(self) -> None:
for child in self.answer_frame.winfo_children():
child.destroy()
node = nodes[self.current_id]
self.info.delete("1.0", tk.END)
if node.is_leaf:
text = (
"Мы пришли в лист дерева.\n\n" + node.result + "\n\n"
"Как объяснить у доски:\n"
"Покажите путь от верхнего вопроса до зелёного листа. "
"Каждый переход — это ответ пользователя. Поэтому итог можно "
"объяснить конкретной цепочкой условий.\n\n"
"Путь:\n"
+ "\n".join(f"{i}. {s}" for i, s in enumerate(self.path_text, 1))
)
ttk.Label(self.answer_frame, text="Результат получен").pack(padx=8, pady=8)
else:
text = (
"Текущий узел: " + node.title + "\n\n"
+ node.explanation + "\n\n"
"Что сказать студентам:\n"
"Сейчас программа проверяет значение признака. "
"Ответ пользователя выбирает ветвь, а ветвь ведёт к следующему "
"вопросу или к результату.\n\n"
"Пройденный путь:\n"
+ ("\n".join(f"{i}. {s}" for i, s in enumerate(self.path_text, 1))
if self.path_text else "пока не выбран ни один ответ")
)
for answer, next_id in node.answers:
ttk.Button(
self.answer_frame, text=answer,
command=lambda a=answer, n=next_id: self.choose_answer(a, n),
).pack(fill=tk.X, padx=8, pady=4)
self.info.insert(tk.END, text)
def show_board_plan(self) -> None:
self.info.delete("1.0", tk.END)
self.info.insert(
tk.END,
"План объяснения у доски\n\n"
"1. Нарисуйте верхний узел и подпишите его как первый вопрос.\n"
"2. Объясните, что этот вопрос проверяет самый общий признак — настроение.\n"
"3. От каждого варианта ответа проведите отдельную ветвь.\n"
"4. На следующем уровне задайте уточняющие вопросы (время, жанр, компания).\n"
"5. Внизу покажите листья — готовые рекомендации аниме.\n"
"6. Пройдите один пример от корня до листа.\n"
"7. Скажите, что объяснимость дерева состоит в видимом пути решения.\n\n"
"Фраза для завершения:\n"
"Дерево решений превращает сложный выбор в цепочку маленьких понятных "
"проверок. Если путь от первого вопроса до результата можно объяснить, "
"значит модель построена логично."
)
root = tk.Tk()
DecisionTreeApp(root)
root.mainloop()
if __name__ == "__main__":
if "--console" in sys.argv:
run_console_mode()
else:
run_gui_mode()