Skip to content

Commit 062b245

Browse files
feat(letsplot): implement chernoff-basic (#3055)
## Implementation: `chernoff-basic` - letsplot Implements the **letsplot** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/letsplot.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20617567410)* --------- 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 626d3c0 commit 062b245

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: letsplot 4.8.2 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
element_blank,
13+
element_text,
14+
geom_path,
15+
geom_polygon,
16+
geom_text,
17+
ggplot,
18+
ggsave,
19+
ggsize,
20+
labs,
21+
scale_fill_manual,
22+
theme,
23+
)
24+
from sklearn.datasets import load_iris
25+
26+
27+
LetsPlot.setup_html()
28+
29+
# Data - Iris dataset (4 measurements per flower, 3 species)
30+
np.random.seed(42)
31+
iris = load_iris()
32+
df = pd.DataFrame(iris.data, columns=["sepal_length", "sepal_width", "petal_length", "petal_width"])
33+
df["species"] = [iris.target_names[i] for i in iris.target]
34+
35+
# Sample 12 flowers (4 per species) for clear visualization
36+
sample_idx = []
37+
for species in range(3):
38+
species_idx = np.where(iris.target == species)[0]
39+
sample_idx.extend(np.random.choice(species_idx, 4, replace=False))
40+
df_sample = df.iloc[sample_idx].reset_index(drop=True)
41+
42+
# Normalize data to 0-1 range for facial feature mapping
43+
features = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
44+
for col in features:
45+
min_val = df_sample[col].min()
46+
max_val = df_sample[col].max()
47+
df_sample[col + "_norm"] = (df_sample[col] - min_val) / (max_val - min_val)
48+
49+
50+
# Chernoff face generator - maps 4 variables to facial features
51+
def create_face(row_data, center_x, center_y, scale=0.4):
52+
"""Generate face components based on normalized data values."""
53+
sepal_len = row_data["sepal_length_norm"] # Face width
54+
sepal_wid = row_data["sepal_width_norm"] # Eye size
55+
petal_len = row_data["petal_length_norm"] # Mouth curvature
56+
petal_wid = row_data["petal_width_norm"] # Eyebrow slant
57+
58+
face_data = []
59+
60+
# Face outline (ellipse) - face width controlled by sepal_length
61+
face_width = 0.35 + 0.2 * sepal_len # Range: 0.35 to 0.55
62+
face_height = 0.45
63+
theta = np.linspace(0, 2 * np.pi, 50)
64+
face_x = center_x + scale * face_width * np.cos(theta)
65+
face_y = center_y + scale * face_height * np.sin(theta)
66+
for i in range(len(theta)):
67+
face_data.append({"x": face_x[i], "y": face_y[i], "part": "face", "order": i})
68+
69+
# Eyes - eye size controlled by sepal_width
70+
eye_size = 0.03 + 0.04 * sepal_wid # Range: 0.03 to 0.07
71+
eye_y = center_y + scale * 0.12
72+
eye_spacing = 0.12
73+
74+
# Left eye
75+
theta_eye = np.linspace(0, 2 * np.pi, 20)
76+
left_eye_x = center_x - scale * eye_spacing + scale * eye_size * np.cos(theta_eye)
77+
left_eye_y = eye_y + scale * eye_size * np.sin(theta_eye)
78+
for i in range(len(theta_eye)):
79+
face_data.append({"x": left_eye_x[i], "y": left_eye_y[i], "part": "left_eye", "order": i})
80+
81+
# Right eye
82+
right_eye_x = center_x + scale * eye_spacing + scale * eye_size * np.cos(theta_eye)
83+
right_eye_y = eye_y + scale * eye_size * np.sin(theta_eye)
84+
for i in range(len(theta_eye)):
85+
face_data.append({"x": right_eye_x[i], "y": right_eye_y[i], "part": "right_eye", "order": i})
86+
87+
# Pupils
88+
pupil_size = eye_size * 0.4
89+
left_pupil_x = center_x - scale * eye_spacing + scale * pupil_size * np.cos(theta_eye)
90+
left_pupil_y = eye_y + scale * pupil_size * np.sin(theta_eye)
91+
for i in range(len(theta_eye)):
92+
face_data.append({"x": left_pupil_x[i], "y": left_pupil_y[i], "part": "left_pupil", "order": i})
93+
94+
right_pupil_x = center_x + scale * eye_spacing + scale * pupil_size * np.cos(theta_eye)
95+
right_pupil_y = eye_y + scale * pupil_size * np.sin(theta_eye)
96+
for i in range(len(theta_eye)):
97+
face_data.append({"x": right_pupil_x[i], "y": right_pupil_y[i], "part": "right_pupil", "order": i})
98+
99+
# Mouth - curvature controlled by petal_length
100+
mouth_y = center_y - scale * 0.15
101+
mouth_width = 0.12
102+
curvature = -0.08 + 0.16 * petal_len # Range: -0.08 (sad) to 0.08 (happy)
103+
mouth_x = np.linspace(-mouth_width, mouth_width, 20)
104+
mouth_curve_y = mouth_y + scale * curvature * (1 - (mouth_x / mouth_width) ** 2)
105+
mouth_curve_x = center_x + scale * mouth_x
106+
for i in range(len(mouth_x)):
107+
face_data.append({"x": mouth_curve_x[i], "y": mouth_curve_y[i], "part": "mouth", "order": i})
108+
109+
# Eyebrows - slant controlled by petal_width
110+
brow_y = center_y + scale * 0.22
111+
brow_slant = -0.03 + 0.06 * petal_wid # Range: -0.03 (angry) to 0.03 (surprised)
112+
brow_length = 0.06
113+
114+
# Left eyebrow
115+
face_data.append(
116+
{
117+
"x": center_x - scale * (eye_spacing + brow_length),
118+
"y": brow_y - scale * brow_slant,
119+
"part": "left_brow",
120+
"order": 0,
121+
}
122+
)
123+
face_data.append(
124+
{
125+
"x": center_x - scale * (eye_spacing - brow_length),
126+
"y": brow_y + scale * brow_slant,
127+
"part": "left_brow",
128+
"order": 1,
129+
}
130+
)
131+
132+
# Right eyebrow
133+
face_data.append(
134+
{
135+
"x": center_x + scale * (eye_spacing - brow_length),
136+
"y": brow_y + scale * brow_slant,
137+
"part": "right_brow",
138+
"order": 0,
139+
}
140+
)
141+
face_data.append(
142+
{
143+
"x": center_x + scale * (eye_spacing + brow_length),
144+
"y": brow_y - scale * brow_slant,
145+
"part": "right_brow",
146+
"order": 1,
147+
}
148+
)
149+
150+
# Nose - simple vertical line
151+
nose_top = center_y + scale * 0.02
152+
nose_bottom = center_y - scale * 0.08
153+
face_data.append({"x": center_x, "y": nose_top, "part": "nose", "order": 0})
154+
face_data.append({"x": center_x, "y": nose_bottom, "part": "nose", "order": 1})
155+
156+
return pd.DataFrame(face_data)
157+
158+
159+
# Generate faces in a grid (3 rows x 4 columns = 12 faces)
160+
grid_rows = 3
161+
grid_cols = 4
162+
all_face_data = []
163+
label_data = []
164+
species_colors = {"setosa": "#306998", "versicolor": "#FFD43B", "virginica": "#DC2626"}
165+
166+
for idx, row in df_sample.iterrows():
167+
col = idx % grid_cols
168+
row_pos = idx // grid_cols
169+
center_x = col + 0.5
170+
center_y = (grid_rows - 1 - row_pos) + 0.5 # Flip y so row 0 is at top
171+
172+
face_df = create_face(row, center_x, center_y, scale=0.42)
173+
face_df["face_id"] = idx
174+
face_df["species"] = row["species"]
175+
all_face_data.append(face_df)
176+
177+
# Add label
178+
label_data.append({"x": center_x, "y": center_y - 0.45, "label": row["species"].title(), "species": row["species"]})
179+
180+
faces_df = pd.concat(all_face_data, ignore_index=True)
181+
labels_df = pd.DataFrame(label_data)
182+
183+
# Separate dataframes for different face parts
184+
face_outline = faces_df[faces_df["part"] == "face"]
185+
eyes = faces_df[faces_df["part"].isin(["left_eye", "right_eye"])]
186+
pupils = faces_df[faces_df["part"].isin(["left_pupil", "right_pupil"])]
187+
mouth = faces_df[faces_df["part"] == "mouth"]
188+
brows = faces_df[faces_df["part"].isin(["left_brow", "right_brow"])]
189+
nose = faces_df[faces_df["part"] == "nose"]
190+
191+
# Create the plot
192+
plot = (
193+
ggplot()
194+
# Face outlines (colored by species)
195+
+ geom_polygon(
196+
aes(x="x", y="y", group="face_id", fill="species"), data=face_outline, color="#333333", size=1.5, alpha=0.3
197+
)
198+
# Eyes (white fill)
199+
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=eyes, fill="white", color="#333333", size=1.0)
200+
# Pupils (black fill)
201+
+ geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=pupils, fill="#333333", color="#333333", size=0.5)
202+
# Mouth (line)
203+
+ geom_path(aes(x="x", y="y", group="face_id"), data=mouth, color="#333333", size=2.0)
204+
# Eyebrows (lines)
205+
+ geom_path(aes(x="x", y="y", group=["face_id", "part"]), data=brows, color="#333333", size=2.5)
206+
# Nose (line)
207+
+ geom_path(aes(x="x", y="y", group="face_id"), data=nose, color="#333333", size=1.5)
208+
# Species labels (no legend for text color)
209+
+ geom_text(aes(x="x", y="y", label="label"), data=labels_df, color="#333333", size=12, fontface="bold")
210+
# Color scale
211+
+ scale_fill_manual(values=species_colors)
212+
# Labels
213+
+ labs(title="Iris Species Comparison · chernoff-basic · lets-plot · pyplots.ai", fill="Species")
214+
# Theme
215+
+ theme(
216+
plot_title=element_text(size=24, face="bold"),
217+
axis_title=element_blank(),
218+
axis_text=element_blank(),
219+
axis_ticks=element_blank(),
220+
panel_grid=element_blank(),
221+
legend_title=element_text(size=16),
222+
legend_text=element_text(size=14),
223+
legend_position="right",
224+
plot_margin=[40, 20, 20, 20], # top, right, bottom, left
225+
)
226+
+ ggsize(1600, 900)
227+
)
228+
229+
# Save as PNG (use path parameter to avoid subdirectory creation)
230+
ggsave(plot, "plot.png", scale=3, path=".")
231+
232+
# Save as HTML for interactivity
233+
ggsave(plot, "plot.html", path=".")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
library: letsplot
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T11:07:14Z'
4+
updated: '2025-12-31T11:17:46Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617567410
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: 4.8.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/letsplot/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/letsplot/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/letsplot/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent implementation of Chernoff faces using lets-plot grammar of graphics
17+
approach
18+
- Clear visual distinction between species through face colors and facial feature
19+
variations
20+
- Well-organized 3×4 grid layout with proper spacing
21+
- Good use of geom_polygon for face outlines and eyes, geom_path for mouth and eyebrows
22+
- Real Iris dataset provides meaningful multivariate data demonstration
23+
- Proper normalization of data to 0-1 range as specified
24+
- 'Facial features effectively encode data: face width (sepal length), eye size
25+
(sepal width), mouth curvature (petal length), eyebrow slant (petal width)'
26+
weaknesses:
27+
- Code uses a helper function (create_face) which violates the KISS structure requirement
28+
- Title uses lets-plot with hyphen instead of letsplot (single word)
29+
- Legend is somewhat small and isolated on the right side
30+
- Nose does not vary based on any data variable (static for all faces)

0 commit comments

Comments
 (0)