{"spec_id":"precision-recall","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' precision-recall: Precision-Recall Curve\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 92/100 | Created: 2026-09-05\n\nlibrary(ggplot2)\nlibrary(dplyr)\nlibrary(tidyr)\nlibrary(scales)\nlibrary(ragg)\n\nset.seed(42)\n\n# --- Theme tokens -----------------------------------------------------------\nTHEME       <- Sys.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG     <- if (THEME == \"light\") \"#FAF8F1\" else \"#1A1A17\"\nINK         <- if (THEME == \"light\") \"#1A1A17\" else \"#F0EFE8\"\nINK_SOFT    <- if (THEME == \"light\") \"#4A4A44\" else \"#B8B7B0\"\nINK_MUTED   <- if (THEME == \"light\") \"#6B6A63\" else \"#A8A79F\"\nIMPRINT_PALETTE <- c(\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\",\n                     \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# --- Data ---------------------------------------------------------------\n# Fraud detection: 5% of transactions are fraudulent (heavily imbalanced),\n# comparing a weak baseline classifier against a tuned one.\nn <- 3000\npositive_rate <- 0.05\ny_true <- rbinom(n, 1, positive_rate)\n\nbaseline_scores <- plogis(ifelse(y_true == 1, rnorm(n, 1.0, 1.0), rnorm(n, -1.0, 1.0)))\ntuned_scores    <- plogis(ifelse(y_true == 1, rnorm(n, 2.2, 1.0), rnorm(n, -2.2, 1.0)))\n\nscores_df <- bind_rows(\n  tibble(model = \"Tuned model\",    y_true = y_true, y_score = tuned_scores),\n  tibble(model = \"Baseline model\", y_true = y_true, y_score = baseline_scores)\n)\n\n# Precision/recall at every threshold, walking scores from high to low.\npr_curve <- scores_df %>%\n  arrange(model, desc(y_score)) %>%\n  group_by(model) %>%\n  mutate(\n    tp = cumsum(y_true),\n    fp = cumsum(1 - y_true),\n    precision = tp / (tp + fp),\n    recall = tp / sum(y_true),\n    recall_prev = lag(recall, default = 0)\n  )\n\n# Average precision: AP = sum_k (R_k - R_{k-1}) * P_k (sklearn convention).\nap_scores <- pr_curve %>%\n  summarise(ap = sum((recall - recall_prev) * precision), .groups = \"drop\")\n\npr_curve <- pr_curve %>%\n  ungroup() %>%\n  left_join(ap_scores, by = \"model\") %>%\n  mutate(model_label = sprintf(\"%s (AP = %.2f)\", model, ap))\n\nmodel_labels <- pr_curve %>%\n  distinct(model, model_label) %>%\n  arrange(match(model, c(\"Tuned model\", \"Baseline model\")))\n\nbaseline_df <- tibble(recall = c(0, 1), precision = positive_rate)\n\n# Iso-F1 reference curves: F1 = 2PR / (P+R) solved for P at fixed F1 levels.\nf1_levels <- c(0.2, 0.4, 0.6, 0.8)\niso_f1 <- expand_grid(f1 = f1_levels, recall = seq(0.02, 1, length.out = 300)) %>%\n  mutate(precision = f1 * recall / (2 * recall - f1)) %>%\n  filter(precision > 0, precision <= 1)\niso_f1_labels <- iso_f1 %>%\n  group_by(f1) %>%\n  slice_max(recall, n = 1) %>%\n  ungroup() %>%\n  mutate(label = sprintf(\"F1=%.1f\", f1))\n\n# --- Plot ---------------------------------------------------------------\nplot_title <- \"Fraud Detection Model Comparison · precision-recall · r · ggplot2 · anyplot.ai\"\ntitle_fontsize <- max(8, round(12 * min(1, 67 / nchar(plot_title))))\n\np <- ggplot() +\n  geom_line(\n    data = iso_f1, aes(x = recall, y = precision, group = f1),\n    color = INK_MUTED, linewidth = 0.35, linetype = \"dotted\", alpha = 0.6\n  ) +\n  geom_text(\n    data = iso_f1_labels, aes(x = recall, y = precision, label = label),\n    color = INK_MUTED, size = 2.6, hjust = 0, nudge_x = 0.015\n  ) +\n  geom_line(\n    data = baseline_df, aes(x = recall, y = precision, linetype = \"Baseline (random)\"),\n    color = INK_MUTED, linewidth = 0.7\n  ) +\n  geom_step(\n    data = pr_curve, aes(x = recall, y = precision, color = model_label),\n    linewidth = 1.1\n  ) +\n  scale_color_manual(values = IMPRINT_PALETTE[1:2], breaks = model_labels$model_label, name = NULL) +\n  scale_linetype_manual(values = c(\"Baseline (random)\" = \"dashed\"), name = NULL) +\n  scale_x_continuous(expand = expansion(mult = c(0.01, 0.1))) +\n  scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +\n  coord_cartesian(xlim = c(0, 1), ylim = c(0, 1), clip = \"off\") +\n  labs(x = \"Recall\", y = \"Precision\", title = plot_title) +\n  theme_minimal(base_size = 8) +\n  theme(\n    plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),\n    panel.background  = element_rect(fill = PAGE_BG, color = NA),\n    panel.grid.major  = element_line(color = alpha(INK, 0.15), linewidth = 0.3),\n    panel.grid.minor  = element_blank(),\n    axis.title        = element_text(color = INK, size = 10),\n    axis.text         = element_text(color = INK_SOFT, size = 8),\n    axis.line         = element_line(color = INK_SOFT),\n    plot.title        = element_text(color = INK, size = title_fontsize),\n    legend.text       = element_text(color = INK_SOFT, size = 8),\n    legend.title      = element_text(color = INK, size = 10),\n    legend.background = element_blank(),\n    legend.key        = element_rect(fill = PAGE_BG, color = NA),\n    legend.position   = \"inside\",\n    legend.position.inside = c(0.02, 0.05),\n    legend.justification = c(0, 0)\n  )\n\n# --- Save -----------------------------------------------------------------\nggsave(\n  filename = sprintf(\"plot-%s.png\", THEME),\n  plot     = p,\n  device   = ragg::agg_png,\n  width    = 8,\n  height   = 4.5,\n  units    = \"in\",\n  dpi      = 400\n)\n"}