{"spec_id":"precision-recall","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nprecision-recall: Precision-Recall Curve\nLibrary: letsplot 4.9.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-10\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\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\"\nRULE = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\n# Okabe-Ito palette (first series always #009E73)\nBRAND = \"#009E73\"\nSECONDARY = \"#C475FD\"\n\n# Generate synthetic classification data with imbalanced classes\nnp.random.seed(42)\nn_samples = 500\n\n# Simulate imbalanced dataset: 20% positive, 80% negative\npositive_ratio = 0.2\nn_positive = int(n_samples * positive_ratio)\nn_negative = n_samples - n_positive\n\n# True labels\ny_true = np.concatenate([np.ones(n_positive), np.zeros(n_negative)])\n\n# Simulate classifier scores - good classifier gives higher scores to positives\npositive_scores = np.random.beta(5, 2, n_positive)  # Skewed higher\nnegative_scores = np.random.beta(2, 5, n_negative)  # Skewed lower\ny_scores = np.concatenate([positive_scores, negative_scores])\n\n# Calculate precision-recall curve\n# Sort by scores descending\ndesc_score_indices = np.argsort(y_scores)[::-1]\ny_scores_sorted = y_scores[desc_score_indices]\ny_true_sorted = y_true[desc_score_indices]\n\n# Get unique thresholds\ndistinct_value_indices = np.where(np.diff(y_scores_sorted))[0]\nthreshold_idxs = np.concatenate([[0], distinct_value_indices + 1])\n\n# Calculate TP, FP cumulative sums\ntps = np.cumsum(y_true_sorted)\nfps = np.cumsum(1 - y_true_sorted)\n\n# Calculate precision and recall at each threshold\nprecision_vals = tps / (tps + fps)\nrecall_vals = tps / tps[-1]\n\n# Use thresholds at distinct values (add starting point: recall=0, precision=1)\nprecision = np.concatenate([[1], precision_vals[threshold_idxs]])\nrecall = np.concatenate([[0], recall_vals[threshold_idxs]])\n\n# Calculate Average Precision (area under curve)\nrecall_diff = np.diff(recall)\naverage_precision = np.sum(precision[1:] * recall_diff)\n\n# Create step-wise data for proper step visualization\nrecall_step = []\nprecision_step = []\nfor i in range(len(recall) - 1):\n    recall_step.extend([recall[i], recall[i + 1]])\n    precision_step.extend([precision[i], precision[i]])\nrecall_step.append(recall[-1])\nprecision_step.append(precision[-1])\n\ndf_curve = pd.DataFrame(\n    {\"recall\": recall_step, \"precision\": precision_step, \"model\": f\"Classifier (AP = {average_precision:.3f})\"}\n)\n\n# Create iso-F1 curves data\nf1_scores = [0.2, 0.4, 0.6, 0.8]\nf1_data = []\nfor f1_score in f1_scores:\n    x = np.linspace(f1_score / 2 + 0.01, 1, 100)\n    y = f1_score * x / (2 * x - f1_score)\n    mask = (y >= 0) & (y <= 1)\n    for xi, yi in zip(x[mask], y[mask], strict=False):\n        f1_data.append({\"recall\": xi, \"precision\": yi, \"f1\": f\"F1={f1_score:.1f}\"})\n\ndf_f1 = pd.DataFrame(f1_data)\n\n# Labels for iso-F1 curves (at the end of each curve)\nf1_labels = []\nfor f1_score in f1_scores:\n    x = np.linspace(f1_score / 2 + 0.01, 1, 100)\n    y = f1_score * x / (2 * x - f1_score)\n    mask = (y >= 0) & (y <= 1)\n    if np.any(mask):\n        f1_labels.append({\"recall\": x[mask][-1] + 0.01, \"precision\": y[mask][-1], \"label\": f\"F1={f1_score:.1f}\"})\n\ndf_f1_labels = pd.DataFrame(f1_labels)\n\n# Build the plot\nplot = (\n    ggplot()\n    # Iso-F1 curves (background)\n    + geom_line(\n        data=df_f1,\n        mapping=aes(x=\"recall\", y=\"precision\", group=\"f1\"),\n        color=INK_SOFT,\n        alpha=0.3,\n        size=1,\n        linetype=\"dotted\",\n    )\n    # F1 labels\n    + geom_text(\n        data=df_f1_labels, mapping=aes(x=\"recall\", y=\"precision\", label=\"label\"), color=INK_MUTED, size=10, alpha=0.6\n    )\n    # Main precision-recall curve\n    + geom_area(data=df_curve, mapping=aes(x=\"recall\", y=\"precision\"), fill=BRAND, alpha=0.2)\n    + geom_line(data=df_curve, mapping=aes(x=\"recall\", y=\"precision\", color=\"model\"), size=1.5)\n    # Baseline: random classifier\n    + geom_hline(yintercept=positive_ratio, color=SECONDARY, size=1.2, linetype=\"dashed\")\n    + geom_text(\n        data=pd.DataFrame(\n            {\"x\": [0.85], \"y\": [positive_ratio + 0.03], \"label\": [f\"Random Baseline ({positive_ratio:.0%})\"]}\n        ),\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        color=INK_SOFT,\n        size=12,\n    )\n    # Labels and title\n    + labs(x=\"Recall (Sensitivity)\", y=\"Precision (PPV)\", title=\"precision-recall · letsplot · anyplot.ai\", color=\"\")\n    + scale_x_continuous(limits=[0, 1.0])\n    + scale_y_continuous(limits=[0, 1.05])\n    + scale_color_manual(values=[BRAND])\n    # Size for 4800x2700 at scale=3\n    + ggsize(1600, 900)\n    # Theme\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_grid_major_y=element_line(color=RULE, size=0.3),\n        axis_title=element_text(size=20, color=INK),\n        axis_text=element_text(size=16, color=INK_SOFT),\n        plot_title=element_text(size=24, color=INK),\n        legend_text=element_text(size=16, color=INK_SOFT),\n        legend_title=element_text(color=INK),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_position=\"top\",\n    )\n)\n\n# Save as PNG (scale 3x = 4800 x 2700 px) and HTML\nggsave(plot, f\"plot-{THEME}.png\", scale=3, path=\".\")\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}