{"spec_id":"tree-decision","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\ntree-decision: Decision Tree Visualization with Probabilities\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-02\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.pyplot as plt\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme tokens — Imprint palette + theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette (canonical order — position 1 always first series)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\nDECISION_COLOR = IMPRINT_PALETTE[0]  # #009E73 brand green — first series / decision nodes\nCHANCE_COLOR = IMPRINT_PALETTE[1]  # #C475FD lavender — chance nodes\nTERMINAL_POS = IMPRINT_PALETTE[2]  # #4467A3 blue — positive payoff terminals\nTERMINAL_NEG = IMPRINT_PALETTE[4]  # #AE3030 matte red — semantic anchor for loss/negative\nPRUNE_COLOR = IMPRINT_PALETTE[4]  # #AE3030 for X marks on pruned branches\n\nPRUNED_ALPHA = 0.30\nNODE_TEXT = \"#FFFDF6\"  # warm white — legible inside colored node markers\n\nsns.set_theme(\n    style=\"white\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Decision tree data — two-stage R&D investment decision (go / no-go)\ntree = [\n    {\n        \"id\": \"D1\",\n        \"type\": \"decision\",\n        \"parent\": None,\n        \"label\": \"\",\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 130,\n        \"pruned\": False,\n        \"x\": 1.5,\n        \"y\": 3.0,\n    },\n    {\n        \"id\": \"C1\",\n        \"type\": \"chance\",\n        \"parent\": \"D1\",\n        \"label\": \"Invest\",\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 130,\n        \"pruned\": False,\n        \"x\": 6.0,\n        \"y\": 4.8,\n    },\n    {\n        \"id\": \"C2\",\n        \"type\": \"chance\",\n        \"parent\": \"D1\",\n        \"label\": \"Partner\",\n        \"prob\": None,\n        \"payoff\": None,\n        \"emv\": 80,\n        \"pruned\": True,\n        \"x\": 6.0,\n        \"y\": 1.2,\n    },\n    {\n        \"id\": \"T1\",\n        \"type\": \"terminal\",\n        \"parent\": \"C1\",\n        \"label\": \"High Demand\",\n        \"prob\": 0.6,\n        \"payoff\": 300,\n        \"emv\": None,\n        \"pruned\": False,\n        \"x\": 11.5,\n        \"y\": 5.8,\n    },\n    {\n        \"id\": \"T2\",\n        \"type\": \"terminal\",\n        \"parent\": \"C1\",\n        \"label\": \"Low Demand\",\n        \"prob\": 0.4,\n        \"payoff\": -125,\n        \"emv\": None,\n        \"pruned\": False,\n        \"x\": 11.5,\n        \"y\": 3.8,\n    },\n    {\n        \"id\": \"T3\",\n        \"type\": \"terminal\",\n        \"parent\": \"C2\",\n        \"label\": \"High Demand\",\n        \"prob\": 0.6,\n        \"payoff\": 150,\n        \"emv\": None,\n        \"pruned\": True,\n        \"x\": 11.5,\n        \"y\": 2.0,\n    },\n    {\n        \"id\": \"T4\",\n        \"type\": \"terminal\",\n        \"parent\": \"C2\",\n        \"label\": \"Low Demand\",\n        \"prob\": 0.4,\n        \"payoff\": -25,\n        \"emv\": None,\n        \"pruned\": True,\n        \"x\": 11.5,\n        \"y\": 0.4,\n    },\n]\n\nnode_map = {n[\"id\"]: n for n in tree}\n\n# Branch DataFrame — sns.lineplot with units draws one polyline per segment (idiomatic seaborn)\nbranch_rows = []\nfor node in tree:\n    if node[\"parent\"] is None:\n        continue\n    parent = node_map[node[\"parent\"]]\n    px, py = parent[\"x\"], parent[\"y\"]\n    nx, ny = node[\"x\"], node[\"y\"]\n    mid_x = px + (nx - px) * 0.45\n    seg_id = f\"{parent['id']}-{node['id']}\"\n    style = \"pruned\" if node[\"pruned\"] else \"optimal\"\n    branch_rows.append({\"seg\": seg_id, \"x\": px, \"y\": py, \"style\": style})\n    branch_rows.append({\"seg\": seg_id, \"x\": mid_x, \"y\": ny, \"style\": style})\n    branch_rows.append({\"seg\": seg_id, \"x\": nx, \"y\": ny, \"style\": style})\n\nbranch_df = pd.DataFrame(branch_rows)\n# seaborn size aesthetic maps to linewidth — encodes branch importance in single call\nbranch_df[\"lw\"] = branch_df[\"style\"].map({\"optimal\": 2.5, \"pruned\": 1.8})\n\n# Node DataFrame — sns.scatterplot with hue/style gives categorical shape + color encoding\nnode_rows = []\nfor node in tree:\n    if node[\"type\"] == \"decision\":\n        category = \"Decision\"\n    elif node[\"type\"] == \"chance\":\n        category = \"Chance\"\n    else:\n        category = \"Positive Payoff\" if node[\"payoff\"] >= 0 else \"Negative Payoff\"\n    node_rows.append({\"x\": node[\"x\"], \"y\": node[\"y\"], \"category\": category, \"pruned\": node[\"pruned\"]})\n\nnode_df = pd.DataFrame(node_rows)\n\nmarker_map = {\n    \"Decision\": \"s\",  # square — decision nodes\n    \"Chance\": \"o\",  # circle — chance nodes\n    \"Positive Payoff\": \">\",  # right-pointing triangle — positive terminal\n    \"Negative Payoff\": \">\",  # right-pointing triangle — negative terminal\n}\ncolor_map = {\n    \"Decision\": DECISION_COLOR,\n    \"Chance\": CHANCE_COLOR,\n    \"Positive Payoff\": TERMINAL_POS,\n    \"Negative Payoff\": TERMINAL_NEG,\n}\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Branches — single seaborn call with hue+style+size+units (all four seaborn aesthetics)\n# Combines branch type, line style, and line weight encoding in one idiomatic call\nn_lines_before = len(ax.lines)\nsns.lineplot(\n    data=branch_df,\n    x=\"x\",\n    y=\"y\",\n    hue=\"style\",\n    style=\"style\",\n    size=\"lw\",\n    dashes={\"optimal\": (1, 0), \"pruned\": (4, 2)},\n    palette={\"optimal\": INK_SOFT, \"pruned\": INK_MUTED},\n    sizes=(1.8, 2.5),\n    units=\"seg\",\n    estimator=None,\n    sort=False,\n    ax=ax,\n    legend=False,\n)\n# Dim pruned lines — hue draws \"optimal\" before \"pruned\" (alphabetical), so pruned lines are last\nn_optimal_segs = branch_df[branch_df[\"style\"] == \"optimal\"][\"seg\"].nunique()\nfor line in ax.lines[n_lines_before + n_optimal_segs :]:\n    line.set_alpha(0.65)\n\n# Nodes — active at full opacity, pruned faded\nactive_nodes = node_df[~node_df[\"pruned\"]]\npruned_nodes = node_df[node_df[\"pruned\"]]\n\nsns.scatterplot(\n    data=active_nodes,\n    x=\"x\",\n    y=\"y\",\n    hue=\"category\",\n    style=\"category\",\n    markers=marker_map,\n    palette=color_map,\n    s=1800,\n    edgecolor=PAGE_BG,\n    linewidth=1.5,\n    ax=ax,\n    legend=False,\n    zorder=3,\n)\nsns.scatterplot(\n    data=pruned_nodes,\n    x=\"x\",\n    y=\"y\",\n    hue=\"category\",\n    style=\"category\",\n    markers=marker_map,\n    palette=color_map,\n    s=1800,\n    edgecolor=PAGE_BG,\n    linewidth=1.5,\n    alpha=PRUNED_ALPHA,\n    ax=ax,\n    legend=False,\n    zorder=3,\n)\n\n# EMV labels inside active nodes; above pruned nodes\nfor node in tree:\n    nx, ny = node[\"x\"], node[\"y\"]\n    al = PRUNED_ALPHA if node[\"pruned\"] else 1.0\n\n    if node[\"type\"] in (\"decision\", \"chance\"):\n        node_color = DECISION_COLOR if node[\"type\"] == \"decision\" else CHANCE_COLOR\n        if node[\"pruned\"]:\n            ax.text(\n                nx,\n                ny + 0.58,\n                f\"EMV ${node['emv']}K\",\n                fontsize=8,\n                fontweight=\"bold\",\n                ha=\"center\",\n                va=\"bottom\",\n                color=node_color,\n                alpha=max(al, 0.55),\n                zorder=4,\n            )\n        else:\n            ax.text(\n                nx,\n                ny,\n                f\"EMV\\n${node['emv']}K\",\n                fontsize=9,\n                fontweight=\"bold\",\n                ha=\"center\",\n                va=\"center\",\n                color=NODE_TEXT,\n                zorder=4,\n            )\n    elif node[\"type\"] == \"terminal\":\n        tc = TERMINAL_POS if node[\"payoff\"] >= 0 else TERMINAL_NEG\n        sign = \"+\" if node[\"payoff\"] >= 0 else \"\"\n        ax.text(\n            nx + 0.55,\n            ny,\n            f\"${sign}{node['payoff']}K\",\n            fontsize=10,\n            fontweight=\"bold\",\n            ha=\"left\",\n            va=\"center\",\n            color=tc,\n            alpha=al,\n            zorder=4,\n        )\n\n# Branch labels + pruned X marks\nfor node in tree:\n    if node[\"parent\"] is None:\n        continue\n    parent = node_map[node[\"parent\"]]\n    px, py = parent[\"x\"], parent[\"y\"]\n    nx, ny = node[\"x\"], node[\"y\"]\n    al = PRUNED_ALPHA if node[\"pruned\"] else 1.0\n    mid_x = px + (nx - px) * 0.45\n\n    label_text = node[\"label\"]\n    if node[\"prob\"] is not None:\n        label_text = f\"{node['label']}\\n(p={node['prob']:.1f})\"\n        # Place on horizontal segment past the elbow — avoids overlapping parent chance node\n        label_x = mid_x + (nx - mid_x) * 0.4\n        label_y = ny + (0.28 if ny >= py else -0.28)\n    else:\n        label_x = (px + mid_x) / 2 + 0.1\n        label_y = (py + ny) / 2\n    ax.text(\n        label_x,\n        label_y,\n        label_text,\n        fontsize=9,\n        fontweight=\"bold\",\n        ha=\"center\",\n        va=\"center\",\n        color=INK,\n        alpha=al,\n        bbox={\"boxstyle\": \"round,pad=0.2\", \"facecolor\": ELEVATED_BG, \"edgecolor\": \"none\", \"alpha\": 0.90 * al},\n    )\n\n    # Double-strike X mark for pruned branches\n    if node[\"pruned\"]:\n        mark_x = mid_x - 0.1\n        mark_y = ny\n        ax.plot(\n            [mark_x - 0.10, mark_x + 0.10],\n            [mark_y - 0.16, mark_y + 0.16],\n            color=PRUNE_COLOR,\n            linewidth=2.2,\n            alpha=0.85,\n            zorder=5,\n        )\n        ax.plot(\n            [mark_x - 0.10, mark_x + 0.10],\n            [mark_y + 0.16, mark_y - 0.16],\n            color=PRUNE_COLOR,\n            linewidth=2.2,\n            alpha=0.85,\n            zorder=5,\n        )\n\n# Style\nlegend_elements = [\n    mpatches.Patch(facecolor=DECISION_COLOR, edgecolor=PAGE_BG, label=\"Decision Node\"),\n    mpatches.Patch(facecolor=CHANCE_COLOR, edgecolor=PAGE_BG, label=\"Chance Node\"),\n    mpatches.Patch(facecolor=TERMINAL_POS, edgecolor=PAGE_BG, label=\"Positive Payoff\"),\n    mpatches.Patch(facecolor=TERMINAL_NEG, edgecolor=PAGE_BG, label=\"Negative Payoff\"),\n    plt.Line2D([0], [0], color=INK_MUTED, linestyle=\"--\", linewidth=2, alpha=0.7, label=\"Pruned Branch\"),\n]\nax.legend(\n    handles=legend_elements,\n    loc=\"lower right\",\n    fontsize=8,\n    framealpha=0.9,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    fancybox=False,\n)\n\ntitle = \"tree-decision · python · seaborn · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", pad=12, color=INK)\nax.set_xlim(-0.5, 14.5)\nax.set_ylim(-0.4, 6.8)\nax.axis(\"off\")\n\n# Save — no bbox_inches=\"tight\" (seaborn canvas rule: figsize × dpi must land exactly)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}