Skip to content

Commit dd077fe

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

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: seaborn 0.13.2 | Python 3.13.11
4+
Quality: 82/100 | Created: 2025-12-31
5+
"""
6+
7+
import matplotlib.patches as mpatches
8+
import matplotlib.pyplot as plt
9+
import numpy as np
10+
import seaborn as sns
11+
12+
13+
# Load and prepare data (Iris dataset - 4 measurements per flower)
14+
iris_df = sns.load_dataset("iris")
15+
feature_cols = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
16+
data = iris_df[feature_cols].values
17+
species_map = {"setosa": 0, "versicolor": 1, "virginica": 2}
18+
target = iris_df["species"].map(species_map).values
19+
20+
# Select subset of samples with maximum variation within each species (5 per species = 15 total)
21+
np.random.seed(42)
22+
indices = []
23+
for species in range(3):
24+
species_indices = np.where(target == species)[0]
25+
species_data = data[species_indices]
26+
# For each feature, find samples with min and max values to maximize visible variation
27+
# Then add centroid sample
28+
selected = set()
29+
for feat_idx in range(4):
30+
feat_values = species_data[:, feat_idx]
31+
min_idx = species_indices[np.argmin(feat_values)]
32+
max_idx = species_indices[np.argmax(feat_values)]
33+
selected.add(min_idx)
34+
selected.add(max_idx)
35+
# Add sample closest to mean
36+
mean_vals = species_data.mean(axis=0)
37+
distances = np.sum((species_data - mean_vals) ** 2, axis=1)
38+
selected.add(species_indices[np.argmin(distances)])
39+
# Convert to list and take first 5
40+
selected = list(selected)[:5]
41+
while len(selected) < 5:
42+
for idx in species_indices:
43+
if idx not in selected:
44+
selected.append(idx)
45+
break
46+
indices.extend(selected[:5])
47+
indices = np.array(indices)
48+
49+
subset_data = data[indices]
50+
subset_target = target[indices]
51+
species_names = ["Setosa", "Versicolor", "Virginica"]
52+
53+
# Global normalization for heatmap (shows species differences)
54+
data_min = data.min(axis=0)
55+
data_max = data.max(axis=0)
56+
heatmap_normalized = (subset_data - data_min) / (data_max - data_min + 1e-8)
57+
58+
# WITHIN-species normalization for faces (maximizes visible variation within each species)
59+
# This ensures each species shows its full range of variation in facial features
60+
normalized_data = np.zeros_like(subset_data)
61+
for species in range(3):
62+
species_mask = subset_target == species
63+
species_subset = subset_data[species_mask]
64+
for feat_idx in range(4):
65+
feat_min = species_subset[:, feat_idx].min()
66+
feat_max = species_subset[:, feat_idx].max()
67+
feat_range = feat_max - feat_min if feat_max > feat_min else 1.0
68+
normalized_data[species_mask, feat_idx] = (species_subset[:, feat_idx] - feat_min) / feat_range
69+
70+
# Set seaborn style
71+
sns.set_style("white")
72+
sns.set_context("poster", font_scale=1.0)
73+
74+
# Color palette for species (using seaborn palette)
75+
palette = sns.color_palette("Set2", 3)
76+
face_colors = [palette[t] for t in subset_target]
77+
78+
# Create figure - 16:9 aspect ratio for 4800x2700 at 300dpi
79+
fig = plt.figure(figsize=(16, 9))
80+
81+
# Create grid layout: heatmap on left, faces on right
82+
gs = fig.add_gridspec(3, 7, width_ratios=[1.5, 1, 1, 1, 1, 1, 0.1], hspace=0.25, wspace=0.15)
83+
84+
# Add heatmap showing feature values on left side using seaborn (global normalization)
85+
ax_heatmap = fig.add_subplot(gs[:, 0])
86+
sns.heatmap(
87+
heatmap_normalized,
88+
ax=ax_heatmap,
89+
cmap="YlOrRd",
90+
cbar=False,
91+
xticklabels=["Sepal\nLength", "Sepal\nWidth", "Petal\nLength", "Petal\nWidth"],
92+
yticklabels=[f"{species_names[subset_target[i]][0]}{(i % 5) + 1}" for i in range(15)],
93+
linewidths=1,
94+
linecolor="white",
95+
annot=True,
96+
fmt=".2f",
97+
annot_kws={"size": 9},
98+
)
99+
ax_heatmap.set_title("Feature Values\n(Normalized)", fontsize=16, fontweight="bold", pad=10)
100+
ax_heatmap.tick_params(axis="x", labelsize=12, rotation=0)
101+
ax_heatmap.tick_params(axis="y", labelsize=11)
102+
103+
# Draw Chernoff faces in grid (3 rows x 5 cols on the right side)
104+
for idx in range(15):
105+
row = idx // 5 # Rows 0, 1, 2
106+
col = idx % 5 + 1 # Columns 1-5 (column 0 is heatmap)
107+
ax = fig.add_subplot(gs[row, col])
108+
109+
features = normalized_data[idx]
110+
color = face_colors[idx]
111+
112+
# Map features to facial characteristics with WIDER ranges for visible variation
113+
# Feature 0: sepal_length -> face width (0.45 to 1.0)
114+
# Feature 1: sepal_width -> face height (0.55 to 1.1)
115+
# Feature 2: petal_length -> eye size (0.04 to 0.16)
116+
# Feature 3: petal_width -> mouth curvature (-0.35 to 0.35)
117+
118+
face_width = 0.45 + features[0] * 0.55
119+
face_height = 0.55 + features[1] * 0.55
120+
eye_size = 0.04 + features[2] * 0.12
121+
mouth_curve = -0.35 + features[3] * 0.7
122+
123+
# Draw face outline (ellipse)
124+
face = mpatches.Ellipse((0.5, 0.5), face_width, face_height, facecolor=color, edgecolor="black", linewidth=2.5)
125+
ax.add_patch(face)
126+
127+
# Draw eyes
128+
eye_y = 0.58
129+
eye_spacing = 0.11 + features[1] * 0.05
130+
131+
# Left eye
132+
left_eye = mpatches.Ellipse(
133+
(0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2
134+
)
135+
ax.add_patch(left_eye)
136+
left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor="black")
137+
ax.add_patch(left_pupil)
138+
139+
# Right eye
140+
right_eye = mpatches.Ellipse(
141+
(0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2
142+
)
143+
ax.add_patch(right_eye)
144+
right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor="black")
145+
ax.add_patch(right_pupil)
146+
147+
# Draw eyebrows
148+
eyebrow_angle = -0.12 + features[2] * 0.24
149+
eyebrow_y = eye_y + eye_size + 0.05
150+
151+
ax.plot(
152+
[0.5 - eye_spacing - 0.05, 0.5 - eye_spacing + 0.05],
153+
[eyebrow_y + eyebrow_angle, eyebrow_y - eyebrow_angle],
154+
color="black",
155+
linewidth=3.5,
156+
solid_capstyle="round",
157+
)
158+
ax.plot(
159+
[0.5 + eye_spacing - 0.05, 0.5 + eye_spacing + 0.05],
160+
[eyebrow_y - eyebrow_angle, eyebrow_y + eyebrow_angle],
161+
color="black",
162+
linewidth=3.5,
163+
solid_capstyle="round",
164+
)
165+
166+
# Draw nose
167+
nose_size = 0.03 + features[0] * 0.025
168+
nose = mpatches.Polygon(
169+
[[0.5, 0.50], [0.5 - nose_size, 0.40], [0.5 + nose_size, 0.40]],
170+
facecolor=tuple(c * 0.85 for c in color[:3]),
171+
edgecolor="black",
172+
linewidth=1.5,
173+
)
174+
ax.add_patch(nose)
175+
176+
# Draw mouth
177+
mouth_width = 0.08 + features[0] * 0.05
178+
mouth_x = np.linspace(0.5 - mouth_width, 0.5 + mouth_width, 25)
179+
mouth_y = 0.30 + mouth_curve * ((mouth_x - 0.5) ** 2) * 18
180+
ax.plot(mouth_x, mouth_y, color="#8B0000", linewidth=3.5, solid_capstyle="round")
181+
182+
# Set axis properties
183+
ax.set_xlim(0, 1)
184+
ax.set_ylim(0, 1)
185+
ax.set_aspect("equal")
186+
ax.axis("off")
187+
188+
# Add label below face
189+
species_idx = subset_target[idx]
190+
sample_num = (idx % 5) + 1
191+
ax.text(
192+
0.5, -0.02, f"{species_names[species_idx]} #{sample_num}", ha="center", va="top", fontsize=11, fontweight="bold"
193+
)
194+
195+
# Add overall title
196+
fig.suptitle("chernoff-basic · seaborn · pyplots.ai", fontsize=24, fontweight="bold", y=0.98)
197+
198+
# Add legend for species (positioned to the right)
199+
legend_handles = [mpatches.Patch(color=palette[i], label=species_names[i], ec="black", lw=1.5) for i in range(3)]
200+
fig.legend(
201+
handles=legend_handles, loc="center right", fontsize=14, frameon=True, bbox_to_anchor=(0.99, 0.5), title="Species"
202+
)
203+
204+
# Add feature mapping explanation at bottom
205+
feature_text = (
206+
"Face Width ← Sepal Length | Face Height ← Sepal Width | Eye Size ← Petal Length | Mouth Curve ← Petal Width"
207+
)
208+
fig.text(0.55, 0.02, feature_text, ha="center", fontsize=12, style="italic")
209+
210+
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
library: seaborn
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T21:36:50Z'
4+
updated: '2025-12-31T21:57:25Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20627533498
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: 0.13.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/seaborn/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/seaborn/plot_thumb.png
12+
preview_html: null
13+
quality_score: 82
14+
review:
15+
strengths:
16+
- Excellent use of the Iris dataset with intelligent sample selection to maximize
17+
visible variation within each species
18+
- Good colorblind-safe palette (Set2) with clear species differentiation
19+
- Comprehensive facial feature mappings with wide parameter ranges for visible variation
20+
- Clean feature mapping explanation at the bottom helps interpretation
21+
- Species labels on each face aid identification
22+
weaknesses:
23+
- The heatmap (coded at lines 86-101) does not appear in the rendered output image,
24+
reducing the complementary value
25+
- Within-species normalization makes cross-species comparisons less intuitive (Setosa
26+
faces look similar to Virginica despite different actual values)

0 commit comments

Comments
 (0)