Skip to content

Commit a220715

Browse files
feat(highcharts): implement lift-curve (#2414)
## Implementation: `lift-curve` - highcharts Implements the **highcharts** version of `lift-curve`. **File:** `plots/lift-curve/implementations/highcharts.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20543283913)* --------- 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 16b29a1 commit a220715

2 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
""" pyplots.ai
2+
lift-curve: Model Lift Chart
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-27
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 LineSeries
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: Simulated customer response prediction
22+
np.random.seed(42)
23+
n_samples = 1000
24+
25+
# Generate realistic model scores and outcomes
26+
# Good model: higher scores correlate with positive outcomes
27+
scores = np.random.beta(2, 5, n_samples) # Model probability scores
28+
noise = np.random.random(n_samples)
29+
# True positives more likely for higher scores
30+
y_true = (scores + 0.3 * noise > 0.35).astype(int)
31+
32+
# Calculate lift curve data
33+
sorted_indices = np.argsort(scores)[::-1] # Sort by score descending
34+
y_true_sorted = y_true[sorted_indices]
35+
36+
# Calculate cumulative lift at each percentile
37+
n_positive = y_true.sum()
38+
baseline_rate = n_positive / n_samples
39+
cumulative_positives = np.cumsum(y_true_sorted)
40+
population_pct = np.arange(1, n_samples + 1) / n_samples * 100
41+
42+
# Lift = (cumulative response rate) / (baseline response rate)
43+
cumulative_response_rate = cumulative_positives / np.arange(1, n_samples + 1)
44+
lift = cumulative_response_rate / baseline_rate
45+
46+
# Sample at regular intervals for smooth curve (every 1%)
47+
sample_points = list(range(0, n_samples, max(1, n_samples // 100)))
48+
if sample_points[-1] != n_samples - 1:
49+
sample_points.append(n_samples - 1)
50+
51+
pct_sampled = [population_pct[i] for i in sample_points]
52+
lift_sampled = [float(lift[i]) for i in sample_points]
53+
54+
# Create chart
55+
chart = Chart(container="container")
56+
chart.options = HighchartsOptions()
57+
58+
# Chart configuration with proper margins
59+
chart.options.chart = {
60+
"type": "line",
61+
"width": 4800,
62+
"height": 2700,
63+
"backgroundColor": "#ffffff",
64+
"marginBottom": 200,
65+
"marginLeft": 220,
66+
"marginRight": 150,
67+
"marginTop": 180,
68+
"spacingBottom": 50,
69+
}
70+
71+
# Title
72+
chart.options.title = {
73+
"text": "lift-curve \u00b7 highcharts \u00b7 pyplots.ai",
74+
"style": {"fontSize": "56px", "fontWeight": "bold"},
75+
"y": 60,
76+
}
77+
78+
# Subtitle with model info
79+
chart.options.subtitle = {
80+
"text": f"Customer Response Model | Baseline Rate: {baseline_rate:.1%}",
81+
"style": {"fontSize": "36px", "color": "#666666"},
82+
"y": 110,
83+
}
84+
85+
# X-axis
86+
chart.options.x_axis = {
87+
"title": {"text": "Population Targeted (%)", "style": {"fontSize": "40px", "fontWeight": "bold"}, "margin": 30},
88+
"labels": {"style": {"fontSize": "32px"}},
89+
"min": 0,
90+
"max": 100,
91+
"tickInterval": 10,
92+
"gridLineWidth": 1,
93+
"gridLineColor": "#e0e0e0",
94+
"lineWidth": 2,
95+
}
96+
97+
# Y-axis - start from 0.9 to better show the lift curve variation
98+
chart.options.y_axis = {
99+
"title": {"text": "Cumulative Lift", "style": {"fontSize": "40px", "fontWeight": "bold"}, "margin": 30},
100+
"labels": {"style": {"fontSize": "32px"}},
101+
"min": 0.9,
102+
"max": 1.6,
103+
"tickInterval": 0.1,
104+
"gridLineWidth": 1,
105+
"gridLineColor": "#e0e0e0",
106+
"plotLines": [
107+
{
108+
"value": 1,
109+
"color": "#888888",
110+
"width": 4,
111+
"dashStyle": "Dash",
112+
"zIndex": 5,
113+
"label": {
114+
"text": "Random Selection (Lift = 1)",
115+
"style": {"fontSize": "28px", "color": "#666666", "fontWeight": "bold"},
116+
"align": "right",
117+
"x": -20,
118+
"y": 20,
119+
},
120+
}
121+
],
122+
}
123+
124+
# Legend
125+
chart.options.legend = {
126+
"enabled": True,
127+
"itemStyle": {"fontSize": "32px"},
128+
"align": "right",
129+
"verticalAlign": "top",
130+
"layout": "vertical",
131+
"x": -30,
132+
"y": 100,
133+
"symbolRadius": 6,
134+
"symbolWidth": 30,
135+
"symbolHeight": 16,
136+
}
137+
138+
# Plot options
139+
chart.options.plot_options = {"line": {"lineWidth": 6, "marker": {"enabled": False}}, "series": {"animation": False}}
140+
141+
# Create lift curve series
142+
lift_series = LineSeries()
143+
lift_series.name = "Model Lift"
144+
lift_series.data = [[pct_sampled[i], lift_sampled[i]] for i in range(len(pct_sampled))]
145+
lift_series.color = "#306998" # Python Blue
146+
lift_series.marker = {"enabled": True, "radius": 8, "symbol": "circle"}
147+
lift_series.lineWidth = 6
148+
149+
chart.add_series(lift_series)
150+
151+
# Add key decile annotations as a single series with data labels
152+
decile_points = [10, 20, 30, 50]
153+
annotation_data = []
154+
for pct in decile_points:
155+
idx = min(int(pct * n_samples / 100) - 1, n_samples - 1)
156+
lift_val = lift[idx]
157+
annotation_data.append(
158+
{
159+
"x": pct,
160+
"y": float(lift_val),
161+
"dataLabels": {
162+
"enabled": True,
163+
"format": f"{lift_val:.2f}x",
164+
"style": {"fontSize": "28px", "fontWeight": "bold", "color": "#306998"},
165+
"y": -30,
166+
"backgroundColor": "rgba(255, 255, 255, 0.8)",
167+
"borderRadius": 5,
168+
"padding": 8,
169+
},
170+
}
171+
)
172+
173+
annotation_series = ScatterSeries()
174+
annotation_series.name = "Key Percentiles"
175+
annotation_series.data = annotation_data
176+
annotation_series.color = "#FFD43B" # Python Yellow
177+
annotation_series.marker = {
178+
"enabled": True,
179+
"radius": 14,
180+
"symbol": "diamond",
181+
"fillColor": "#FFD43B",
182+
"lineColor": "#306998",
183+
"lineWidth": 3,
184+
}
185+
186+
chart.add_series(annotation_series)
187+
188+
# Download Highcharts JS for inline embedding
189+
highcharts_url = "https://code.highcharts.com/highcharts.js"
190+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
191+
highcharts_js = response.read().decode("utf-8")
192+
193+
# Get the chart options as JS - avoid DOMContentLoaded wrapper issue
194+
js_literal = chart.to_js_literal()
195+
# Remove the DOMContentLoaded wrapper if present
196+
if "DOMContentLoaded" in js_literal:
197+
# Extract just the Highcharts.chart(...) call
198+
import re
199+
200+
match = re.search(r"(Highcharts\.chart\([^;]+\));", js_literal, re.DOTALL)
201+
if match:
202+
js_literal = match.group(1) + ";"
203+
204+
# Generate HTML with inline scripts - use window.onload for reliable rendering
205+
html_content = f"""<!DOCTYPE html>
206+
<html>
207+
<head>
208+
<meta charset="utf-8">
209+
<script>{highcharts_js}</script>
210+
</head>
211+
<body style="margin:0;">
212+
<div id="container" style="width: 4800px; height: 2700px;"></div>
213+
<script>
214+
window.onload = function() {{
215+
{js_literal}
216+
}};
217+
</script>
218+
</body>
219+
</html>"""
220+
221+
# Save HTML version
222+
with open("plot.html", "w", encoding="utf-8") as f:
223+
f.write(html_content)
224+
225+
# Export to PNG via Selenium
226+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
227+
f.write(html_content)
228+
temp_path = f.name
229+
230+
chrome_options = Options()
231+
chrome_options.add_argument("--headless")
232+
chrome_options.add_argument("--no-sandbox")
233+
chrome_options.add_argument("--disable-dev-shm-usage")
234+
chrome_options.add_argument("--disable-gpu")
235+
chrome_options.add_argument("--window-size=4800,2700")
236+
237+
driver = webdriver.Chrome(options=chrome_options)
238+
driver.get(f"file://{temp_path}")
239+
time.sleep(5) # Wait for chart to render
240+
driver.save_screenshot("plot.png")
241+
driver.quit()
242+
243+
Path(temp_path).unlink()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: highcharts
2+
specification_id: lift-curve
3+
created: '2025-12-27T19:24:09Z'
4+
updated: '2025-12-27T19:33:31Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20543283913
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/lift-curve/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/lift-curve/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/lift-curve/highcharts/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent use of Highcharts plotLines feature for the reference line with label
17+
- Effective combination of LineSeries for the curve and ScatterSeries for key percentile
18+
annotations
19+
- Clean colorblind-safe palette (Python blue/yellow)
20+
- Proper title format and informative subtitle with baseline rate
21+
- Good data labels on key percentiles showing lift multipliers
22+
weaknesses:
23+
- The Random Selection (Lift = 1) label appears to be cut off at the right edge
24+
of the plot
25+
- Lift values at 10%, 20%, and 30% are identical (1.52x), which looks artificial;
26+
the data generation could show more variation
27+
- Code complexity with regex extraction of JS literal could be simplified

0 commit comments

Comments
 (0)