Skip to content

Commit 3fc1350

Browse files
feat(altair): implement chernoff-basic (#3043)
## Implementation: `chernoff-basic` - altair Implements the **altair** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/altair.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617521051)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 636e746 commit 3fc1350

2 files changed

Lines changed: 375 additions & 0 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 87/100 | Created: 2025-12-31
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data - Iris dataset features for 12 representative flowers
13+
np.random.seed(42)
14+
# Diverse samples from iris-like measurements (normalized 0-1)
15+
data = pd.DataFrame(
16+
{
17+
"observation": [f"Sample {i + 1}" for i in range(12)],
18+
"sepal_length": [0.22, 0.83, 0.45, 0.12, 0.91, 0.67, 0.33, 0.78, 0.55, 0.95, 0.28, 0.61],
19+
"sepal_width": [0.63, 0.45, 0.78, 0.89, 0.32, 0.56, 0.71, 0.41, 0.65, 0.25, 0.82, 0.48],
20+
"petal_length": [0.07, 0.69, 0.42, 0.05, 0.83, 0.55, 0.18, 0.76, 0.38, 0.95, 0.11, 0.62],
21+
"petal_width": [0.04, 0.54, 0.33, 0.02, 0.79, 0.48, 0.12, 0.67, 0.29, 0.88, 0.08, 0.52],
22+
"species": [
23+
"setosa",
24+
"virginica",
25+
"versicolor",
26+
"setosa",
27+
"virginica",
28+
"versicolor",
29+
"setosa",
30+
"virginica",
31+
"versicolor",
32+
"virginica",
33+
"setosa",
34+
"versicolor",
35+
],
36+
}
37+
)
38+
39+
# Map species to colors
40+
species_colors = {"setosa": "#306998", "versicolor": "#FFD43B", "virginica": "#4B8BBE"}
41+
data["color"] = data["species"].map(species_colors)
42+
43+
# Create descriptive labels including species name
44+
data["label"] = [f"{obs} ({sp})" for obs, sp in zip(data["observation"], data["species"], strict=True)]
45+
46+
# Grid positions for 12 faces (4 columns x 3 rows for better canvas utilization)
47+
data["col"] = [i % 4 for i in range(12)]
48+
data["row"] = [i // 4 for i in range(12)]
49+
data["x_center"] = data["col"] * 200 + 130
50+
data["y_center"] = (2 - data["row"]) * 240 + 160
51+
52+
# Calculate face feature dimensions based on variables with more pronounced variation
53+
# face_width: sepal_length, face_height: sepal_width
54+
# eye_size: petal_length, mouth_width: petal_width
55+
# eyebrow_slant: derived from petal_length (maps to eyebrow angle)
56+
data["face_width"] = 40 + data["sepal_length"] * 70 # 40-110
57+
data["face_height"] = 50 + data["sepal_width"] * 80 # 50-130
58+
data["eye_size"] = 6 + data["petal_length"] * 22 # 6-28
59+
data["mouth_width"] = 15 + data["petal_width"] * 35 # 15-50
60+
data["eyebrow_slant"] = -15 + data["petal_length"] * 30 # -15 to 15
61+
62+
# Build face components using layered shapes
63+
face_records = []
64+
65+
for _, r in data.iterrows():
66+
xc, yc = r["x_center"], r["y_center"]
67+
fw, fh = r["face_width"], r["face_height"]
68+
es = r["eye_size"]
69+
mw = r["mouth_width"]
70+
eb_slant = r["eyebrow_slant"]
71+
72+
# Face outline - single smooth ellipse using many small points on perimeter
73+
# This creates a clean ellipse shape instead of blobby overlapping circles
74+
for angle in np.linspace(0, 2 * np.pi, 48, endpoint=False):
75+
px = xc + (fw * 0.9) * np.cos(angle)
76+
py = yc + (fh * 0.75) * np.sin(angle)
77+
face_records.append(
78+
{
79+
"x": px,
80+
"y": py,
81+
"size": 350,
82+
"color": r["color"],
83+
"part": "outline",
84+
"observation": r["observation"],
85+
"species": r["species"],
86+
"opacity": 0.7,
87+
}
88+
)
89+
90+
# Face fill - concentric rings of points to fill the ellipse smoothly
91+
for scale in [0.8, 0.6, 0.4, 0.2]:
92+
for angle in np.linspace(0, 2 * np.pi, int(36 * scale) + 8, endpoint=False):
93+
px = xc + (fw * 0.9 * scale) * np.cos(angle)
94+
py = yc + (fh * 0.75 * scale) * np.sin(angle)
95+
face_records.append(
96+
{
97+
"x": px,
98+
"y": py,
99+
"size": 400,
100+
"color": r["color"],
101+
"part": "face_fill",
102+
"observation": r["observation"],
103+
"species": r["species"],
104+
"opacity": 0.5,
105+
}
106+
)
107+
108+
# Center fill point
109+
face_records.append(
110+
{
111+
"x": xc,
112+
"y": yc,
113+
"size": 600,
114+
"color": r["color"],
115+
"part": "face_fill",
116+
"observation": r["observation"],
117+
"species": r["species"],
118+
"opacity": 0.5,
119+
}
120+
)
121+
122+
# Left eyebrow (line represented by two points)
123+
face_records.append(
124+
{
125+
"x": xc - fw * 0.38,
126+
"y": yc + fh * 0.32 + eb_slant * 0.3,
127+
"size": 120,
128+
"color": "#2C3E50",
129+
"part": "eyebrow",
130+
"observation": r["observation"],
131+
"species": r["species"],
132+
"opacity": 0.9,
133+
}
134+
)
135+
face_records.append(
136+
{
137+
"x": xc - fw * 0.22,
138+
"y": yc + fh * 0.32 - eb_slant * 0.3,
139+
"size": 120,
140+
"color": "#2C3E50",
141+
"part": "eyebrow",
142+
"observation": r["observation"],
143+
"species": r["species"],
144+
"opacity": 0.9,
145+
}
146+
)
147+
# Right eyebrow
148+
face_records.append(
149+
{
150+
"x": xc + fw * 0.22,
151+
"y": yc + fh * 0.32 - eb_slant * 0.3,
152+
"size": 120,
153+
"color": "#2C3E50",
154+
"part": "eyebrow",
155+
"observation": r["observation"],
156+
"species": r["species"],
157+
"opacity": 0.9,
158+
}
159+
)
160+
face_records.append(
161+
{
162+
"x": xc + fw * 0.38,
163+
"y": yc + fh * 0.32 + eb_slant * 0.3,
164+
"size": 120,
165+
"color": "#2C3E50",
166+
"part": "eyebrow",
167+
"observation": r["observation"],
168+
"species": r["species"],
169+
"opacity": 0.9,
170+
}
171+
)
172+
# Left eye
173+
face_records.append(
174+
{
175+
"x": xc - fw * 0.30,
176+
"y": yc + fh * 0.15,
177+
"size": es * 45,
178+
"color": "#1A252F",
179+
"part": "eye",
180+
"observation": r["observation"],
181+
"species": r["species"],
182+
"opacity": 1.0,
183+
}
184+
)
185+
# Right eye
186+
face_records.append(
187+
{
188+
"x": xc + fw * 0.30,
189+
"y": yc + fh * 0.15,
190+
"size": es * 45,
191+
"color": "#1A252F",
192+
"part": "eye",
193+
"observation": r["observation"],
194+
"species": r["species"],
195+
"opacity": 1.0,
196+
}
197+
)
198+
# Left pupil (white highlight)
199+
face_records.append(
200+
{
201+
"x": xc - fw * 0.30 + 3,
202+
"y": yc + fh * 0.15 + 3,
203+
"size": es * 12,
204+
"color": "#FFFFFF",
205+
"part": "pupil",
206+
"observation": r["observation"],
207+
"species": r["species"],
208+
"opacity": 0.95,
209+
}
210+
)
211+
# Right pupil (white highlight)
212+
face_records.append(
213+
{
214+
"x": xc + fw * 0.30 + 3,
215+
"y": yc + fh * 0.15 + 3,
216+
"size": es * 12,
217+
"color": "#FFFFFF",
218+
"part": "pupil",
219+
"observation": r["observation"],
220+
"species": r["species"],
221+
"opacity": 0.95,
222+
}
223+
)
224+
# Nose
225+
face_records.append(
226+
{
227+
"x": xc,
228+
"y": yc - fh * 0.05,
229+
"size": 90,
230+
"color": "#5D6D7E",
231+
"part": "nose",
232+
"observation": r["observation"],
233+
"species": r["species"],
234+
"opacity": 0.7,
235+
}
236+
)
237+
# Mouth - using horizontal ellipse shape for better representation
238+
mouth_y = yc - fh * 0.30
239+
for dx in np.linspace(-mw * 0.4, mw * 0.4, 7):
240+
# Parabolic curve for mouth (smiling effect based on width)
241+
dy = -(dx**2) / (mw * 1.2) + mw * 0.08
242+
face_records.append(
243+
{
244+
"x": xc + dx,
245+
"y": mouth_y + dy,
246+
"size": 80 if abs(dx) < mw * 0.3 else 50,
247+
"color": "#C0392B",
248+
"part": "mouth",
249+
"observation": r["observation"],
250+
"species": r["species"],
251+
"opacity": 0.9,
252+
}
253+
)
254+
255+
face_df = pd.DataFrame(face_records)
256+
257+
# Reorder facial features drawing order
258+
part_order = {"outline": 0, "face_fill": 1, "eyebrow": 2, "nose": 3, "mouth": 4, "eye": 5, "pupil": 6}
259+
face_df["order"] = face_df["part"].map(part_order)
260+
face_df = face_df.sort_values("order")
261+
262+
# Create labels for each face - positioned below faces with descriptive text
263+
label_df = data[["x_center", "y_center", "label", "face_height"]].copy()
264+
label_df["y_label"] = label_df["y_center"] - label_df["face_height"] * 0.7 - 35
265+
266+
# Face features chart (includes outline, fill, and features)
267+
features = (
268+
alt.Chart(face_df)
269+
.mark_point(filled=True)
270+
.encode(
271+
x=alt.X("x:Q", axis=None, scale=alt.Scale(domain=[0, 900])),
272+
y=alt.Y("y:Q", axis=None, scale=alt.Scale(domain=[0, 800])),
273+
size=alt.Size("size:Q", legend=None, scale=alt.Scale(range=[40, 1600])),
274+
color=alt.Color("color:N", legend=None, scale=None),
275+
opacity=alt.Opacity("opacity:Q", legend=None),
276+
order="order:O",
277+
)
278+
)
279+
280+
# Labels with species info
281+
labels = (
282+
alt.Chart(label_df)
283+
.mark_text(fontSize=13, fontWeight="bold", color="#2C3E50")
284+
.encode(x=alt.X("x_center:Q", axis=None), y=alt.Y("y_label:Q", axis=None), text="label:N")
285+
)
286+
287+
# Legend for species (positioned on right side, higher up to avoid overlap)
288+
legend_data = pd.DataFrame(
289+
{
290+
"species": ["setosa", "versicolor", "virginica"],
291+
"x": [850, 850, 850],
292+
"y": [780, 730, 680],
293+
"color": ["#306998", "#FFD43B", "#4B8BBE"],
294+
}
295+
)
296+
297+
legend_points = (
298+
alt.Chart(legend_data)
299+
.mark_point(filled=True, size=600, opacity=0.5)
300+
.encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), color=alt.Color("color:N", scale=None, legend=None))
301+
)
302+
303+
legend_text = (
304+
alt.Chart(legend_data)
305+
.mark_text(align="right", fontSize=14, dx=-25, fontWeight="bold")
306+
.encode(x="x:Q", y="y:Q", text="species:N")
307+
)
308+
309+
# Feature mapping explanation - positioned at top left to avoid overlap with faces
310+
mapping_data = pd.DataFrame(
311+
{
312+
"text": [
313+
"Feature Mapping:",
314+
"Face width ← sepal length",
315+
"Face height ← sepal width",
316+
"Eye size ← petal length",
317+
"Mouth width ← petal width",
318+
"Eyebrow slant ← petal length",
319+
],
320+
"x": [30, 30, 30, 30, 30, 30],
321+
"y": [785, 760, 735, 710, 685, 660],
322+
}
323+
)
324+
325+
mapping_text = (
326+
alt.Chart(mapping_data)
327+
.mark_text(align="left", fontSize=12, color="#34495E")
328+
.encode(x="x:Q", y="y:Q", text="text:N")
329+
)
330+
331+
# Combine all layers
332+
chart = (
333+
(features + labels + legend_points + legend_text + mapping_text)
334+
.properties(
335+
width=1600,
336+
height=900,
337+
title=alt.Title(
338+
"chernoff-basic · altair · pyplots.ai",
339+
fontSize=28,
340+
anchor="middle",
341+
subtitle="Iris Dataset: Each face represents a flower sample with features encoding measurements",
342+
subtitleFontSize=16,
343+
),
344+
)
345+
.configure_view(strokeWidth=0)
346+
)
347+
348+
# Save as PNG and HTML
349+
chart.save("plot.png", scale_factor=3.0)
350+
chart.save("plot.html")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
library: altair
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T11:00:16Z'
4+
updated: '2025-12-31T11:38:44Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617521051
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/altair/plot.html
13+
quality_score: 87
14+
review:
15+
strengths:
16+
- Creative implementation of Chernoff faces using Altair mark_point with layered
17+
scatter points
18+
- Excellent color coding by species with colorblind-friendly palette
19+
- Well-organized grid layout with good canvas utilization
20+
- Feature mapping legend clearly explains variable-to-feature correspondence
21+
- Good use of Iris dataset as a classic multivariate example
22+
weaknesses:
23+
- Feature mapping text overlaps with Sample 9 label causing text collision
24+
- Does not leverage Altair distinctive features like interactivity or tooltips
25+
- Face feature variations could be more visually pronounced between samples

0 commit comments

Comments
 (0)