{"spec_id":"dendrogram-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-basic: Basic Dendrogram\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this script's directory from shadowing the installed plotnine package.\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    coord_cartesian,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_point,\n    geom_segment,\n    geom_text,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_color_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\nfrom scipy.cluster.hierarchy import dendrogram, linkage\nfrom sklearn.datasets import load_iris\n\n\n# Theme-adaptive chrome tokens (Imprint style guide)\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 — hybrid-v3 sort, first series always #009E73\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — real iris flower measurements, 15 samples (5 per species)\niris = load_iris()\nnp.random.seed(42)\nspecies_names = [\"Setosa\", \"Versicolor\", \"Virginica\"]\nspecies_counts = dict.fromkeys(species_names, 0)\nsample_labels = []\nindices = np.concatenate([np.random.choice(np.where(iris.target == i)[0], 5, replace=False) for i in range(3)])\nfor i in indices:\n    name = species_names[iris.target[i]]\n    species_counts[name] += 1\n    sample_labels.append(f\"{name}-{species_counts[name]}\")\nfeatures = iris.data[indices]\n\n# Hierarchical clustering with Ward's method\nlinkage_matrix = linkage(features, method=\"ward\")\n\n# Color map: Imprint palette in canonical order, INK_MUTED for mixed branches\ncolor_map = {\n    \"Setosa (pure)\": IMPRINT[0],  # #009E73 — first series always\n    \"Versicolor (pure)\": IMPRINT[1],  # #C475FD\n    \"Virginica (pure)\": IMPRINT[2],  # #4467A3\n    \"Mixed species\": INK_MUTED,  # theme-adaptive for other/rest\n}\n\n# Extract dendrogram coordinates (no_plot suppresses matplotlib output)\ndend = dendrogram(linkage_matrix, labels=sample_labels, no_plot=True)\n\n# Track species composition of each node for branch coloring\nn = len(sample_labels)\nleaf_species = {lbl: lbl.rsplit(\"-\", 1)[0] for lbl in sample_labels}\nnode_species = {}\nfor i, label in enumerate(sample_labels):\n    node_species[i] = {leaf_species[label]}\nfor i, row in enumerate(linkage_matrix):\n    left, right = int(row[0]), int(row[1])\n    node_species[n + i] = node_species[left] | node_species[right]\n\n# Branch type: species name if the subtree is pure, \"Mixed species\" otherwise\nbranch_type_labels = {\"Setosa\": \"Setosa (pure)\", \"Versicolor\": \"Versicolor (pure)\", \"Virginica\": \"Virginica (pure)\"}\nmerge_branch_types = []\nfor i in range(len(linkage_matrix)):\n    sp = node_species[n + i]\n    merge_branch_types.append(branch_type_labels[next(iter(sp))] if len(sp) == 1 else \"Mixed species\")\n\n# Map dendrogram order to linkage order via merge heights\nheight_to_merge = {}\nfor i, h in enumerate(linkage_matrix[:, 2]):\n    height_to_merge.setdefault(round(h, 10), []).append(i)\n\n# Build segment dataframe (three segments per merge join)\nsegments = []\nfor xs, ys in zip(dend[\"icoord\"], dend[\"dcoord\"], strict=True):\n    h = round(max(ys), 10)\n    if h in height_to_merge and height_to_merge[h]:\n        merge_idx = height_to_merge[h].pop(0)\n        btype = merge_branch_types[merge_idx]\n    else:\n        btype = \"Mixed species\"\n    segments.append({\"x\": xs[0], \"xend\": xs[1], \"y\": ys[0], \"yend\": ys[1], \"branch_type\": btype})\n    segments.append({\"x\": xs[1], \"xend\": xs[2], \"y\": ys[1], \"yend\": ys[2], \"branch_type\": btype})\n    segments.append({\"x\": xs[2], \"xend\": xs[3], \"y\": ys[2], \"yend\": ys[3], \"branch_type\": btype})\n\nsegments_df = pd.DataFrame(segments)\n\n# Leaf labels colored by species purity\nn_leaves = len(dend[\"ivl\"])\nleaf_positions = [(i + 1) * 10 - 5 for i in range(n_leaves)]\nleaf_labels_list = dend[\"ivl\"]\nleaf_btypes = [branch_type_labels[leaf_species[lbl]] for lbl in leaf_labels_list]\nlabel_df = pd.DataFrame(\n    {\"x\": leaf_positions, \"label\": leaf_labels_list, \"y\": [0.0] * n_leaves, \"branch_type\": leaf_btypes}\n)\n\n# pd.Categorical for consistent legend ordering\ncategory_order = [\"Setosa (pure)\", \"Versicolor (pure)\", \"Virginica (pure)\", \"Mixed species\"]\nsegments_df[\"branch_type\"] = pd.Categorical(segments_df[\"branch_type\"], categories=category_order, ordered=True)\nlabel_df[\"branch_type\"] = pd.Categorical(label_df[\"branch_type\"], categories=category_order, ordered=True)\n\n# Merge node markers — highlight cluster join points\nmerge_nodes = []\nfor xs, ys, btype in zip(dend[\"icoord\"], dend[\"dcoord\"], merge_branch_types, strict=True):\n    cx = (xs[1] + xs[2]) / 2\n    cy = max(ys)\n    merge_nodes.append({\"x\": cx, \"y\": cy, \"branch_type\": btype})\nmerge_df = pd.DataFrame(merge_nodes)\nmerge_df[\"branch_type\"] = pd.Categorical(merge_df[\"branch_type\"], categories=category_order, ordered=True)\n\n# Threshold line: height where Setosa splits from Versicolor+Virginica\nsetosa_sep_height = linkage_matrix[-2, 2]\nthreshold_df = pd.DataFrame({\"yintercept\": [setosa_sep_height]})\n\n# Title: scale fontsize for title length (67-char baseline at 12pt)\ntitle = \"Iris Species Clustering · dendrogram-basic · python · plotnine · anyplot.ai\"\ntitle_fontsize = round(12 * 67 / len(title))  # ~11pt for 75-char title\n\n# Plot extents\ny_max = max(linkage_matrix[:, 2]) * 1.08\nx_min = min(segments_df[\"x\"].min(), segments_df[\"xend\"].min())\nx_max = max(segments_df[\"x\"].max(), segments_df[\"xend\"].max())\nx_pad = (x_max - x_min) * 0.06\n\nplot = (\n    ggplot()\n    # Dendrogram branches colored by species purity\n    + geom_segment(aes(x=\"x\", xend=\"xend\", y=\"y\", yend=\"yend\", color=\"branch_type\"), data=segments_df, size=1.2)\n    # Dashed threshold at Setosa separation height\n    + geom_hline(aes(yintercept=\"yintercept\"), data=threshold_df, linetype=\"dashed\", color=INK_SOFT, size=0.5)\n    # Annotation: Setosa separation — size in mm (plotnine geom_text scale)\n    + annotate(\n        \"text\",\n        x=x_max - x_pad,\n        y=setosa_sep_height + 0.35,\n        label=\"Setosa separates\",\n        size=3.0,\n        color=INK_MUTED,\n        fontstyle=\"italic\",\n        ha=\"right\",\n    )\n    # Annotation: Versicolor/Virginica intermixing\n    + annotate(\n        \"text\",\n        x=x_max - x_pad,\n        y=linkage_matrix[-1, 2] * 0.55,\n        label=\"Versicolor & Virginica intermixed\",\n        size=2.8,\n        color=INK_MUTED,\n        fontstyle=\"italic\",\n        ha=\"right\",\n    )\n    # Leaf labels rotated 45° — size in mm\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\", color=\"branch_type\"),\n        data=label_df,\n        angle=45,\n        ha=\"right\",\n        va=\"top\",\n        size=3.0,\n        nudge_y=-0.3,\n        show_legend=False,\n    )\n    # Merge node dots — emphasize join points, hidden from legend\n    + geom_point(aes(x=\"x\", y=\"y\", color=\"branch_type\"), data=merge_df, size=2.0, show_legend=False)\n    + scale_color_manual(values=color_map, name=\"Branch Type\")\n    + guides(color=guide_legend(override_aes={\"size\": 3, \"alpha\": 1}))\n    + scale_x_continuous(breaks=[], expand=(0.04, 0))\n    + scale_y_continuous(breaks=np.arange(0, y_max, 2).tolist(), expand=(0.10, 0))\n    + coord_cartesian(xlim=(x_min - x_pad, x_max + x_pad), ylim=(-2.5, y_max))\n    + labs(\n        x=\"\",\n        y=\"Ward Linkage Distance\",\n        title=title,\n        subtitle=\"Hierarchical clustering of 15 iris samples (Ward's method)\",\n    )\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        text=element_text(size=8, family=\"sans-serif\"),\n        axis_title_x=element_blank(),\n        axis_title_y=element_text(size=10, color=INK, margin={\"r\": 8}),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        axis_text_x=element_blank(),\n        axis_ticks_major_x=element_blank(),\n        plot_title=element_text(size=title_fontsize, weight=\"bold\", color=INK, margin={\"b\": 3}),\n        plot_subtitle=element_text(size=8, color=INK_SOFT, margin={\"b\": 8}),\n        plot_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        panel_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor_x=element_blank(),\n        panel_grid_minor_y=element_blank(),\n        panel_grid_major_y=element_line(color=INK, size=0.3, alpha=0.15),\n        legend_title=element_text(size=9, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.3),\n        legend_key=element_rect(fill=\"none\", color=\"none\"),\n        plot_margin=0.02,\n    )\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}