Skip to content

Commit c6ad0e1

Browse files
feat(highcharts): implement precision-recall (#2329)
## Implementation: `precision-recall` - highcharts Implements the **highcharts** version of `precision-recall`. **File:** `plots/precision-recall/implementations/highcharts.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20527870416)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 6f8cd46 commit c6ad0e1

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
""" pyplots.ai
2+
precision-recall: Precision-Recall Curve
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 92/100 | Created: 2025-12-26
5+
"""
6+
7+
import tempfile
8+
import time
9+
import urllib.request
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from highcharts_core.chart import Chart
14+
from highcharts_core.options import HighchartsOptions
15+
from highcharts_core.options.series.area import AreaSeries
16+
from highcharts_core.options.series.scatter import ScatterSeries
17+
from selenium import webdriver
18+
from selenium.webdriver.chrome.options import Options
19+
20+
21+
# Data - simulate binary classification results
22+
np.random.seed(42)
23+
n_samples = 500
24+
25+
# Ground truth: imbalanced binary labels (30% positive class)
26+
positive_ratio = 0.3
27+
y_true = np.random.binomial(1, positive_ratio, n_samples)
28+
29+
# Predicted scores: realistic classifier output (correlated with true labels)
30+
# Good classifier: higher scores for positive class
31+
y_scores = np.where(
32+
y_true == 1,
33+
np.random.beta(5, 2, n_samples), # Higher scores for positives
34+
np.random.beta(2, 5, n_samples), # Lower scores for negatives
35+
)
36+
37+
38+
# Compute precision-recall curve (manual implementation)
39+
def compute_precision_recall_curve(y_true, y_scores):
40+
"""Compute precision-recall pairs for different probability thresholds."""
41+
# Sort by decreasing score
42+
sorted_indices = np.argsort(y_scores)[::-1]
43+
y_scores_sorted = y_scores[sorted_indices]
44+
45+
# Get unique thresholds
46+
thresholds = np.unique(y_scores_sorted)[::-1]
47+
48+
precisions = []
49+
recalls = []
50+
51+
total_positives = np.sum(y_true)
52+
53+
for threshold in thresholds:
54+
# Predictions at this threshold
55+
y_pred = (y_scores >= threshold).astype(int)
56+
57+
# True positives and false positives
58+
tp = np.sum((y_pred == 1) & (y_true == 1))
59+
fp = np.sum((y_pred == 1) & (y_true == 0))
60+
61+
# Precision and recall
62+
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
63+
recall = tp / total_positives if total_positives > 0 else 0.0
64+
65+
precisions.append(precision)
66+
recalls.append(recall)
67+
68+
# Add endpoint (recall=0, precision=1)
69+
precisions.append(1.0)
70+
recalls.append(0.0)
71+
72+
return np.array(precisions), np.array(recalls), thresholds
73+
74+
75+
def compute_average_precision(precision, recall):
76+
"""Compute Average Precision using the trapezoidal rule."""
77+
# Sort by recall (ascending)
78+
sorted_indices = np.argsort(recall)
79+
recall_sorted = recall[sorted_indices]
80+
precision_sorted = precision[sorted_indices]
81+
82+
# Compute AP as area under the curve using manual trapezoidal integration
83+
# (np.trapz deprecated in NumPy 2.0+)
84+
ap = 0.0
85+
for i in range(1, len(recall_sorted)):
86+
ap += (recall_sorted[i] - recall_sorted[i - 1]) * (precision_sorted[i] + precision_sorted[i - 1]) / 2
87+
return ap
88+
89+
90+
precision, recall, thresholds = compute_precision_recall_curve(y_true, y_scores)
91+
92+
# Average Precision score
93+
ap_score = compute_average_precision(precision, recall)
94+
95+
# Prepare data for Highcharts (stepped line representation)
96+
# Use recall as x, precision as y - data should go from recall=1 to recall=0
97+
pr_data = list(zip(recall.tolist(), precision.tolist(), strict=False))
98+
99+
# Create chart
100+
chart = Chart(container="container")
101+
chart.options = HighchartsOptions()
102+
103+
# Chart settings
104+
chart.options.chart = {
105+
"type": "area",
106+
"width": 4800,
107+
"height": 2700,
108+
"backgroundColor": "#ffffff",
109+
"marginBottom": 250,
110+
"marginLeft": 200,
111+
"marginRight": 120,
112+
"marginTop": 150,
113+
}
114+
115+
# Title
116+
chart.options.title = {
117+
"text": "precision-recall · highcharts · pyplots.ai",
118+
"style": {"fontSize": "48px", "fontWeight": "bold"},
119+
"y": 60,
120+
}
121+
122+
# Subtitle showing AP score
123+
chart.options.subtitle = {
124+
"text": f"Average Precision (AP) = {ap_score:.3f}",
125+
"style": {"fontSize": "32px", "color": "#666666"},
126+
"y": 100,
127+
}
128+
129+
# X-axis (Recall)
130+
chart.options.x_axis = {
131+
"title": {"text": "Recall (Sensitivity)", "style": {"fontSize": "36px", "fontWeight": "bold"}, "margin": 30},
132+
"labels": {"style": {"fontSize": "28px"}},
133+
"min": 0,
134+
"max": 1,
135+
"tickInterval": 0.1,
136+
"gridLineWidth": 1,
137+
"gridLineColor": "#e0e0e0",
138+
"lineWidth": 2,
139+
"lineColor": "#333333",
140+
}
141+
142+
# Y-axis (Precision)
143+
chart.options.y_axis = {
144+
"title": {
145+
"text": "Precision (Positive Predictive Value)",
146+
"style": {"fontSize": "36px", "fontWeight": "bold"},
147+
"margin": 30,
148+
},
149+
"labels": {"style": {"fontSize": "28px"}},
150+
"min": 0,
151+
"max": 1,
152+
"tickInterval": 0.1,
153+
"gridLineWidth": 1,
154+
"gridLineColor": "#e0e0e0",
155+
"lineWidth": 2,
156+
"lineColor": "#333333",
157+
}
158+
159+
# Legend
160+
chart.options.legend = {
161+
"enabled": True,
162+
"itemStyle": {"fontSize": "28px"},
163+
"align": "right",
164+
"verticalAlign": "top",
165+
"layout": "vertical",
166+
"x": -50,
167+
"y": 120,
168+
"backgroundColor": "rgba(255, 255, 255, 0.9)",
169+
"borderWidth": 1,
170+
"borderColor": "#cccccc",
171+
"padding": 20,
172+
}
173+
174+
# Precision-Recall curve as area series
175+
pr_series = AreaSeries()
176+
pr_series.name = f"Classifier (AP = {ap_score:.3f})"
177+
pr_series.data = pr_data
178+
pr_series.color = "#306998"
179+
pr_series.fill_opacity = 0.3
180+
pr_series.line_width = 4
181+
pr_series.step = "left" # Stepped line for PR curve
182+
pr_series.marker = {"enabled": False}
183+
184+
chart.add_series(pr_series)
185+
186+
# Baseline: random classifier (horizontal line at positive class ratio)
187+
baseline_data = [[0, positive_ratio], [1, positive_ratio]]
188+
baseline_series = ScatterSeries()
189+
baseline_series.name = f"Random Baseline (ratio = {positive_ratio:.2f})"
190+
baseline_series.data = baseline_data
191+
baseline_series.color = "#FFD43B"
192+
baseline_series.line_width = 3
193+
baseline_series.dash_style = "Dash"
194+
baseline_series.marker = {"enabled": False}
195+
baseline_series.type = "line"
196+
197+
chart.add_series(baseline_series)
198+
199+
# Plot options
200+
chart.options.plot_options = {
201+
"area": {"marker": {"enabled": False}, "lineWidth": 4, "step": "left"},
202+
"line": {"marker": {"enabled": False}, "lineWidth": 3},
203+
"series": {"animation": False},
204+
}
205+
206+
# Credits
207+
chart.options.credits = {"enabled": False}
208+
209+
# Export to PNG via Selenium
210+
highcharts_url = "https://code.highcharts.com/highcharts.js"
211+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
212+
highcharts_js = response.read().decode("utf-8")
213+
214+
# Generate JavaScript literal
215+
html_str = chart.to_js_literal()
216+
217+
html_content = f"""<!DOCTYPE html>
218+
<html>
219+
<head>
220+
<meta charset="utf-8">
221+
<script>{highcharts_js}</script>
222+
</head>
223+
<body style="margin:0; padding:0;">
224+
<div id="container" style="width: 4800px; height: 2700px;"></div>
225+
<script>{html_str}</script>
226+
</body>
227+
</html>"""
228+
229+
# Save HTML for interactive viewing
230+
with open("plot.html", "w", encoding="utf-8") as f:
231+
f.write(html_content)
232+
233+
# Write temp HTML and take screenshot
234+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
235+
f.write(html_content)
236+
temp_path = f.name
237+
238+
chrome_options = Options()
239+
chrome_options.add_argument("--headless")
240+
chrome_options.add_argument("--no-sandbox")
241+
chrome_options.add_argument("--disable-dev-shm-usage")
242+
chrome_options.add_argument("--disable-gpu")
243+
chrome_options.add_argument("--window-size=4800,2800")
244+
245+
driver = webdriver.Chrome(options=chrome_options)
246+
driver.set_window_size(4800, 2800)
247+
driver.get(f"file://{temp_path}")
248+
time.sleep(5) # Wait for chart to render
249+
250+
# Take screenshot of just the chart container
251+
container = driver.find_element("id", "container")
252+
container.screenshot("plot.png")
253+
driver.quit()
254+
255+
Path(temp_path).unlink() # Clean up temp file
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
library: highcharts
2+
specification_id: precision-recall
3+
created: '2025-12-26T19:15:17Z'
4+
updated: '2025-12-26T19:17:24Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20527870416
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/precision-recall/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/precision-recall/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/precision-recall/highcharts/plot.html
13+
quality_score: 92
14+
review:
15+
strengths:
16+
- Excellent visual presentation with clear stepped area chart representation of
17+
the PR curve
18+
- Proper colorblind-safe palette using blue and yellow
19+
- AP score prominently displayed in both subtitle and legend
20+
- Baseline reference line correctly positioned at positive class ratio
21+
- Good use of Highcharts-specific features (AreaSeries with step, custom margins)
22+
- Clean HTML export with embedded Highcharts JS for reliable rendering
23+
weaknesses:
24+
- Code contains helper functions instead of following KISS principle (inline code
25+
preferred)
26+
- Grid lines could be more subtle (lower alpha/opacity)

0 commit comments

Comments
 (0)