{"spec_id":"calibration-curve","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ncalibration-curve: Calibration 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.export 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\nBRAND = \"#009E73\"  # Okabe-Ito position 1 — first series\nSECONDARY = \"#C475FD\"  # Okabe-Ito position 2 — reference line\n\n# Data - Generate realistic binary classification predictions\nnp.random.seed(42)\nn_samples = 1000\n\n# Create true labels with imbalanced classes (60/40 split)\ny_true = np.concatenate([np.zeros(600), np.ones(400)])\n\n# Generate predicted probabilities with realistic calibration issues\n# Model tends to be slightly overconfident (probabilities pushed toward extremes)\ny_prob = np.zeros(n_samples)\n\n# For true negatives: mostly low probabilities with some mid-range\ny_prob[:600] = np.clip(np.random.beta(2, 5, 600) * 0.6 + np.random.normal(0, 0.05, 600), 0, 1)\n# For true positives: mostly high probabilities but with spread\ny_prob[600:] = np.clip(np.random.beta(5, 2, 400) * 0.6 + 0.35 + np.random.normal(0, 0.08, 400), 0, 1)\n\n# Shuffle the data\nshuffle_idx = np.random.permutation(n_samples)\ny_true = y_true[shuffle_idx]\ny_prob = y_prob[shuffle_idx]\n\n# Calculate calibration curve with 10 bins\nn_bins = 10\nbin_edges = np.linspace(0, 1, n_bins + 1)\nbin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2\n\nmean_predicted = []\nfraction_positive = []\nbin_counts = []\n\nfor i in range(n_bins):\n    mask = (y_prob >= bin_edges[i]) & (y_prob < bin_edges[i + 1])\n    if i == n_bins - 1:  # Include right edge for last bin\n        mask = (y_prob >= bin_edges[i]) & (y_prob <= bin_edges[i + 1])\n\n    if mask.sum() > 0:\n        mean_predicted.append(y_prob[mask].mean())\n        fraction_positive.append(y_true[mask].mean())\n        bin_counts.append(mask.sum())\n    else:\n        mean_predicted.append(bin_centers[i])\n        fraction_positive.append(np.nan)\n        bin_counts.append(0)\n\n# Calculate Brier Score\nbrier_score = np.mean((y_prob - y_true) ** 2)\n\n# Calculate Expected Calibration Error (ECE)\nece = 0\ntotal_samples = sum(bin_counts)\nfor i in range(n_bins):\n    if bin_counts[i] > 0:\n        ece += (bin_counts[i] / total_samples) * abs(fraction_positive[i] - mean_predicted[i])\n\n# Create dataframe for calibration curve\ndf_calibration = pd.DataFrame(\n    {\"mean_predicted\": mean_predicted, \"fraction_positive\": fraction_positive, \"bin_count\": bin_counts}\n)\ndf_calibration = df_calibration.dropna()\ndf_calibration[\"tooltip\"] = df_calibration.apply(\n    lambda row: (\n        f\"Predicted: {row['mean_predicted']:.3f}\\nObserved: {row['fraction_positive']:.3f}\\nBin size: {int(row['bin_count'])}\"\n    ),\n    axis=1,\n)\n\n# Create dataframe for diagonal (perfect calibration)\ndf_diagonal = pd.DataFrame({\"x\": [0, 1], \"y\": [0, 1]})\n\n# Create dataframe for histogram of predictions\nhist_bins = 20\nhist_counts, hist_edges = np.histogram(y_prob, bins=hist_bins, range=(0, 1))\nhist_centers = (hist_edges[:-1] + hist_edges[1:]) / 2\ndf_histogram = pd.DataFrame({\"prob_center\": hist_centers, \"count\": hist_counts / hist_counts.max()})\n\n# Plot\nplot = (\n    ggplot()\n    # Perfect calibration diagonal line\n    + geom_line(aes(x=\"x\", y=\"y\"), data=df_diagonal, color=INK_SOFT, size=1.5, linetype=\"dashed\")\n    # Calibration curve\n    + geom_line(aes(x=\"mean_predicted\", y=\"fraction_positive\"), data=df_calibration, color=BRAND, size=2)\n    + geom_point(\n        aes(x=\"mean_predicted\", y=\"fraction_positive\", tooltip=\"tooltip\"),\n        data=df_calibration,\n        color=BRAND,\n        size=5,\n        alpha=0.9,\n    )\n    # Histogram bars at bottom showing prediction distribution\n    + geom_bar(\n        aes(x=\"prob_center\", y=\"count\"), data=df_histogram, stat=\"identity\", fill=INK_MUTED, alpha=0.4, width=0.045\n    )\n    # Labels and styling\n    + labs(\n        x=\"Mean Predicted Probability\",\n        y=\"Fraction of Positives\",\n        title=f\"calibration-curve · letsplot · anyplot.ai\\nBrier Score: {brier_score:.4f} | ECE: {ece:.4f}\",\n    )\n    + scale_x_continuous(limits=[0, 1], breaks=[0, 0.2, 0.4, 0.6, 0.8, 1.0])\n    + scale_y_continuous(limits=[0, 1], breaks=[0, 0.2, 0.4, 0.6, 0.8, 1.0])\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=element_line(color=RULE, size=0.4),\n        panel_grid_minor=element_blank(),\n        axis_title=element_text(size=20, color=INK),\n        axis_text=element_text(size=16, color=INK_SOFT),\n        axis_line=element_line(color=INK_SOFT, size=0.6),\n        plot_title=element_text(size=24, color=INK),\n        axis_ticks_length_x=6,\n        axis_ticks_length_y=6,\n    )\n    + ggsize(1600, 900)\n)\n\n# Save outputs\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=3)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}