Skip to content

Commit faf5fe5

Browse files
feat(pygal): implement contour-decision-boundary (#3156)
## Implementation: `contour-decision-boundary` - pygal Implements the **pygal** version of `contour-decision-boundary`. **File:** `plots/contour-decision-boundary/implementations/pygal.py` **Parent Issue:** #2921 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20645829976)* --------- 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 ed169b0 commit faf5fe5

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
""" pyplots.ai
2+
contour-decision-boundary: Decision Boundary Classifier Visualization
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-01
5+
"""
6+
7+
import sys
8+
from pathlib import Path
9+
10+
11+
# Remove script directory from path to avoid name collision with pygal package
12+
_script_dir = str(Path(__file__).parent)
13+
sys.path = [p for p in sys.path if p != _script_dir]
14+
15+
import cairosvg # noqa: E402
16+
import numpy as np # noqa: E402
17+
import pygal # noqa: E402
18+
from pygal.style import Style # noqa: E402
19+
from sklearn.datasets import make_moons # noqa: E402
20+
from sklearn.svm import SVC # noqa: E402
21+
22+
23+
# Data: Generate synthetic classification data (moon shapes)
24+
np.random.seed(42)
25+
X, y = make_moons(n_samples=150, noise=0.25, random_state=42)
26+
27+
# Train SVM classifier
28+
clf = SVC(kernel="rbf", C=1.0, gamma="scale")
29+
clf.fit(X, y)
30+
31+
# Create mesh grid for decision boundary
32+
h = 0.02 # Step size
33+
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
34+
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
35+
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
36+
37+
# Get predictions on mesh grid
38+
Z = clf.predict(np.c_[xx.ravel(), yy.ravel()])
39+
Z = Z.reshape(xx.shape)
40+
41+
# Colors for classes (colorblind-safe)
42+
class_colors = ["#306998", "#FFD43B"] # Python Blue and Yellow
43+
class_colors_light = ["#6699CC", "#FFE680"] # Lighter versions for regions
44+
45+
# Style for 4800x2700 canvas
46+
custom_style = Style(
47+
background="white",
48+
plot_background="white",
49+
foreground="#333333",
50+
foreground_strong="#333333",
51+
foreground_subtle="#666666",
52+
colors=("#306998",),
53+
title_font_size=72,
54+
legend_font_size=48,
55+
label_font_size=42,
56+
value_font_size=36,
57+
font_family="sans-serif",
58+
)
59+
60+
# Create base XY chart
61+
chart = pygal.XY(
62+
width=4800,
63+
height=2700,
64+
style=custom_style,
65+
title="contour-decision-boundary · pygal · pyplots.ai",
66+
show_legend=False,
67+
margin=120,
68+
margin_top=200,
69+
margin_bottom=200,
70+
margin_left=300,
71+
margin_right=450,
72+
show_x_labels=False,
73+
show_y_labels=False,
74+
show_x_guides=False,
75+
show_y_guides=False,
76+
x_title="",
77+
y_title="",
78+
)
79+
80+
# Plot dimensions (matching chart margins)
81+
plot_x = 300
82+
plot_y = 200
83+
plot_width = 4800 - 300 - 450
84+
plot_height = 2700 - 200 - 200
85+
86+
87+
# Helper function to map data coordinates to SVG coordinates
88+
def data_to_svg(data_x, data_y):
89+
svg_x = plot_x + (data_x - x_min) / (x_max - x_min) * plot_width
90+
svg_y = plot_y + plot_height - (data_y - y_min) / (y_max - y_min) * plot_height
91+
return svg_x, svg_y
92+
93+
94+
# Build SVG content
95+
svg_parts = []
96+
97+
# Draw decision boundary regions (filled cells)
98+
n_rows, n_cols = Z.shape
99+
cell_w = plot_width / (n_cols - 1)
100+
cell_h = plot_height / (n_rows - 1)
101+
102+
for i in range(n_rows - 1):
103+
for j in range(n_cols - 1):
104+
# Use the class prediction for this cell
105+
cell_class = Z[i, j]
106+
color = class_colors_light[int(cell_class)]
107+
cx = plot_x + j * cell_w
108+
cy = plot_y + plot_height - (i + 1) * cell_h
109+
svg_parts.append(
110+
f'<rect x="{cx:.1f}" y="{cy:.1f}" width="{cell_w + 0.5:.1f}" '
111+
f'height="{cell_h + 0.5:.1f}" fill="{color}" stroke="none" opacity="0.7"/>'
112+
)
113+
114+
# Draw decision boundary line (where classes meet)
115+
for i in range(n_rows - 1):
116+
for j in range(n_cols - 1):
117+
z00, z01 = Z[i, j], Z[i, j + 1]
118+
z10, z11 = Z[i + 1, j], Z[i + 1, j + 1]
119+
120+
# Check if this cell contains a boundary
121+
if z00 == z01 == z10 == z11:
122+
continue
123+
124+
cx = plot_x + j * cell_w
125+
cy = plot_y + plot_height - (i + 1) * cell_h
126+
127+
# Simple boundary detection - draw lines where classes differ
128+
if z00 != z01: # Top edge
129+
svg_parts.append(
130+
f'<line x1="{cx:.1f}" y1="{cy:.1f}" x2="{cx + cell_w:.1f}" y2="{cy:.1f}" '
131+
f'stroke="#333333" stroke-width="3" stroke-opacity="0.6"/>'
132+
)
133+
if z00 != z10: # Left edge
134+
svg_parts.append(
135+
f'<line x1="{cx:.1f}" y1="{cy:.1f}" x2="{cx:.1f}" y2="{cy + cell_h:.1f}" '
136+
f'stroke="#333333" stroke-width="3" stroke-opacity="0.6"/>'
137+
)
138+
139+
# Axis frame
140+
svg_parts.append(
141+
f'<rect x="{plot_x}" y="{plot_y}" width="{plot_width}" height="{plot_height}" '
142+
f'fill="none" stroke="#333333" stroke-width="3"/>'
143+
)
144+
145+
# Draw training points on top
146+
marker_size = 18
147+
for idx in range(len(X)):
148+
px, py = X[idx]
149+
svg_x, svg_y = data_to_svg(px, py)
150+
point_class = y[idx]
151+
color = class_colors[point_class]
152+
153+
# Predict class for this point to check if correctly classified
154+
pred = clf.predict([[px, py]])[0]
155+
is_correct = pred == point_class
156+
157+
# Use different marker for correct vs incorrect
158+
if is_correct:
159+
# Filled circle for correctly classified
160+
svg_parts.append(
161+
f'<circle cx="{svg_x:.1f}" cy="{svg_y:.1f}" r="{marker_size}" '
162+
f'fill="{color}" stroke="#333333" stroke-width="2"/>'
163+
)
164+
else:
165+
# X marker for misclassified
166+
svg_parts.append(
167+
f'<circle cx="{svg_x:.1f}" cy="{svg_y:.1f}" r="{marker_size}" '
168+
f'fill="{color}" stroke="#CC0000" stroke-width="4"/>'
169+
)
170+
size = marker_size * 0.7
171+
svg_parts.append(
172+
f'<line x1="{svg_x - size:.1f}" y1="{svg_y - size:.1f}" '
173+
f'x2="{svg_x + size:.1f}" y2="{svg_y + size:.1f}" stroke="#CC0000" stroke-width="3"/>'
174+
)
175+
svg_parts.append(
176+
f'<line x1="{svg_x + size:.1f}" y1="{svg_y - size:.1f}" '
177+
f'x2="{svg_x - size:.1f}" y2="{svg_y + size:.1f}" stroke="#CC0000" stroke-width="3"/>'
178+
)
179+
180+
# X-axis labels and ticks
181+
n_x_ticks = 7
182+
for i in range(n_x_ticks):
183+
frac = i / (n_x_ticks - 1)
184+
tick_x = plot_x + frac * plot_width
185+
tick_y = plot_y + plot_height
186+
val = x_min + frac * (x_max - x_min)
187+
svg_parts.append(
188+
f'<line x1="{tick_x:.1f}" y1="{tick_y}" x2="{tick_x:.1f}" y2="{tick_y + 20}" '
189+
f'stroke="#333333" stroke-width="3"/>'
190+
)
191+
svg_parts.append(
192+
f'<text x="{tick_x:.1f}" y="{tick_y + 65}" text-anchor="middle" fill="#333333" '
193+
f'style="font-size:42px;font-family:sans-serif">{val:.1f}</text>'
194+
)
195+
196+
# X-axis title
197+
svg_parts.append(
198+
f'<text x="{plot_x + plot_width / 2}" y="{plot_y + plot_height + 140}" text-anchor="middle" '
199+
f'fill="#333333" style="font-size:48px;font-weight:bold;font-family:sans-serif">Feature 1</text>'
200+
)
201+
202+
# Y-axis labels and ticks
203+
n_y_ticks = 7
204+
for i in range(n_y_ticks):
205+
frac = i / (n_y_ticks - 1)
206+
tick_y = plot_y + plot_height - frac * plot_height
207+
tick_x = plot_x
208+
val = y_min + frac * (y_max - y_min)
209+
svg_parts.append(
210+
f'<line x1="{tick_x - 20}" y1="{tick_y:.1f}" x2="{tick_x}" y2="{tick_y:.1f}" '
211+
f'stroke="#333333" stroke-width="3"/>'
212+
)
213+
svg_parts.append(
214+
f'<text x="{tick_x - 30}" y="{tick_y + 14:.1f}" text-anchor="end" fill="#333333" '
215+
f'style="font-size:42px;font-family:sans-serif">{val:.1f}</text>'
216+
)
217+
218+
# Y-axis title (rotated)
219+
y_title_x = plot_x - 200
220+
y_title_y = plot_y + plot_height / 2
221+
svg_parts.append(
222+
f'<text x="{y_title_x}" y="{y_title_y}" text-anchor="middle" fill="#333333" '
223+
f'style="font-size:48px;font-weight:bold;font-family:sans-serif" '
224+
f'transform="rotate(-90, {y_title_x}, {y_title_y})">Feature 2</text>'
225+
)
226+
227+
# Legend
228+
legend_x = plot_x + plot_width + 50
229+
legend_y = plot_y + 50
230+
231+
# Class 0 legend
232+
svg_parts.append(
233+
f'<circle cx="{legend_x + 20}" cy="{legend_y}" r="20" fill="{class_colors[0]}" stroke="#333333" stroke-width="2"/>'
234+
)
235+
svg_parts.append(
236+
f'<text x="{legend_x + 55}" y="{legend_y + 12}" fill="#333333" '
237+
f'style="font-size:42px;font-family:sans-serif">Class 0</text>'
238+
)
239+
240+
# Class 1 legend
241+
svg_parts.append(
242+
f'<circle cx="{legend_x + 20}" cy="{legend_y + 70}" r="20" '
243+
f'fill="{class_colors[1]}" stroke="#333333" stroke-width="2"/>'
244+
)
245+
svg_parts.append(
246+
f'<text x="{legend_x + 55}" y="{legend_y + 82}" fill="#333333" '
247+
f'style="font-size:42px;font-family:sans-serif">Class 1</text>'
248+
)
249+
250+
# Misclassified legend
251+
svg_parts.append(
252+
f'<circle cx="{legend_x + 20}" cy="{legend_y + 150}" r="20" fill="#999999" stroke="#CC0000" stroke-width="4"/>'
253+
)
254+
size = 14
255+
svg_parts.append(
256+
f'<line x1="{legend_x + 20 - size}" y1="{legend_y + 150 - size}" '
257+
f'x2="{legend_x + 20 + size}" y2="{legend_y + 150 + size}" stroke="#CC0000" stroke-width="3"/>'
258+
)
259+
svg_parts.append(
260+
f'<line x1="{legend_x + 20 + size}" y1="{legend_y + 150 - size}" '
261+
f'x2="{legend_x + 20 - size}" y2="{legend_y + 150 + size}" stroke="#CC0000" stroke-width="3"/>'
262+
)
263+
svg_parts.append(
264+
f'<text x="{legend_x + 55}" y="{legend_y + 162}" fill="#333333" '
265+
f'style="font-size:42px;font-family:sans-serif">Misclassified</text>'
266+
)
267+
268+
# Combine all SVG parts
269+
custom_svg = "\n".join(svg_parts)
270+
271+
# Add dummy data point (required by pygal)
272+
chart.add("", [(0, 0)])
273+
274+
# Render base chart and inject custom SVG
275+
base_svg = chart.render(is_unicode=True)
276+
277+
# Insert custom SVG before the closing </svg> tag
278+
output_svg = base_svg.replace("</svg>", f"{custom_svg}\n</svg>")
279+
280+
# Save SVG
281+
with open("plot.svg", "w", encoding="utf-8") as f:
282+
f.write(output_svg)
283+
284+
# Convert to PNG using cairosvg
285+
cairosvg.svg2png(bytestring=output_svg.encode("utf-8"), write_to="plot.png")
286+
287+
# Save interactive HTML
288+
html_content = f"""<!DOCTYPE html>
289+
<html>
290+
<head>
291+
<meta charset="utf-8">
292+
<title>contour-decision-boundary - pygal</title>
293+
<style>
294+
body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f5f5f5; }}
295+
.chart {{ max-width: 100%; height: auto; }}
296+
</style>
297+
</head>
298+
<body>
299+
<figure class="chart">
300+
{output_svg}
301+
</figure>
302+
</body>
303+
</html>
304+
"""
305+
306+
with open("plot.html", "w", encoding="utf-8") as f:
307+
f.write(html_content)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
library: pygal
2+
specification_id: contour-decision-boundary
3+
created: '2026-01-01T21:29:27Z'
4+
updated: '2026-01-01T21:31:51Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20645829976
7+
issue: 2921
8+
python_version: 3.13.11
9+
library_version: 3.1.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/contour-decision-boundary/pygal/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/contour-decision-boundary/pygal/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/contour-decision-boundary/pygal/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent creative solution using pygal XY chart with custom SVG injection for
17+
decision regions
18+
- Clear visualization of the moon-shaped decision boundary with proper color-coded
19+
regions
20+
- Good implementation of misclassified point markers with red X overlay
21+
- Well-designed legend showing all three marker types
22+
- Colorblind-safe blue/yellow color scheme
23+
- Proper use of sklearn make_moons dataset for realistic ML context
24+
weaknesses:
25+
- Helper function data_to_svg() breaks KISS principle slightly, though justified
26+
for coordinate mapping
27+
- Axis labels lack units (acceptable for synthetic ML data, but Feature 1 normalized
28+
would be better)

0 commit comments

Comments
 (0)