|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 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 |
| 3 | +Library: seaborn 0.13.2 | Python 3.13.13 |
| 4 | +Quality: 93/100 | Updated: 2026-05-15 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import matplotlib.patches as mpatches |
8 | 10 | import matplotlib.pyplot as plt |
9 | 11 | import numpy as np |
10 | 12 | import seaborn as sns |
| 13 | +from sklearn.datasets import load_wine |
| 14 | + |
11 | 15 |
|
| 16 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 17 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 18 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 19 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 20 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 21 | +BRAND = "#009E73" |
12 | 22 |
|
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 |
| 23 | +# Load wine dataset (13 chemical measurements per wine sample) |
| 24 | +wine = load_wine() |
| 25 | +feature_names = wine.feature_names |
| 26 | +data = wine.data |
| 27 | +target = wine.target |
19 | 28 |
|
20 | | -# Select subset of samples with maximum variation within each species (5 per species = 15 total) |
| 29 | +# Select 5 wines from each class (3 classes = 15 total) |
21 | 30 | np.random.seed(42) |
22 | 31 | 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]) |
| 32 | +for cls in range(3): |
| 33 | + class_indices = np.where(target == cls)[0] |
| 34 | + selected = np.random.choice(class_indices, size=5, replace=False) |
| 35 | + indices.extend(selected) |
47 | 36 | indices = np.array(indices) |
48 | 37 |
|
49 | 38 | subset_data = data[indices] |
50 | 39 | 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] |
| 40 | +class_names = ["Class 1", "Class 2", "Class 3"] |
| 41 | + |
| 42 | +# Select 4 key features for face mapping |
| 43 | +selected_features = [0, 6, 9, 12] # Alcohol, phenols, malic acid, proline |
| 44 | +selected_feature_names = [feature_names[i].replace(" ", "\n") for i in selected_features] |
| 45 | +data_subset = subset_data[:, selected_features] |
| 46 | + |
| 47 | +# Within-species normalization for faces |
| 48 | +normalized_data = np.zeros((15, 4)) |
| 49 | +for cls in range(3): |
| 50 | + class_mask = subset_target == cls |
| 51 | + class_subset = data_subset[class_mask] |
64 | 52 | for feat_idx in range(4): |
65 | | - feat_min = species_subset[:, feat_idx].min() |
66 | | - feat_max = species_subset[:, feat_idx].max() |
| 53 | + feat_min = class_subset[:, feat_idx].min() |
| 54 | + feat_max = class_subset[:, feat_idx].max() |
67 | 55 | 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 |
| 56 | + normalized_data[class_mask, feat_idx] = (class_subset[:, feat_idx] - feat_min) / feat_range |
69 | 57 |
|
70 | | -# Set seaborn style |
| 58 | +# Set seaborn theme |
71 | 59 | sns.set_style("white") |
72 | 60 | 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}, |
| 61 | +sns.set_theme( |
| 62 | + rc={ |
| 63 | + "figure.facecolor": PAGE_BG, |
| 64 | + "axes.facecolor": PAGE_BG, |
| 65 | + "text.color": INK, |
| 66 | + "axes.labelcolor": INK, |
| 67 | + "xtick.color": INK_SOFT, |
| 68 | + "ytick.color": INK_SOFT, |
| 69 | + } |
98 | 70 | ) |
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 | 71 |
|
103 | | -# Draw Chernoff faces in grid (3 rows x 5 cols on the right side) |
| 72 | +# Okabe-Ito palette - first series is always brand green |
| 73 | +okabe_ito = [BRAND, "#D55E00", "#0072B2"] |
| 74 | +face_colors = [okabe_ito[t] for t in subset_target] |
| 75 | + |
| 76 | +# Create figure |
| 77 | +fig = plt.figure(figsize=(16, 9), facecolor=PAGE_BG) |
| 78 | + |
| 79 | +# Grid layout: faces only (3 rows x 5 cols) |
| 80 | +gs = fig.add_gridspec(3, 5, hspace=0.35, wspace=0.25) |
| 81 | + |
| 82 | + |
| 83 | +def hex_to_rgb(h): |
| 84 | + return tuple(int(h.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) |
| 85 | + |
| 86 | + |
| 87 | +# Draw Chernoff faces |
104 | 88 | 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) |
| 89 | + row = idx // 5 |
| 90 | + col = idx % 5 |
107 | 91 | ax = fig.add_subplot(gs[row, col]) |
108 | 92 |
|
109 | 93 | features = normalized_data[idx] |
110 | 94 | color = face_colors[idx] |
111 | 95 |
|
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) |
| 96 | + # Map features to facial characteristics |
| 97 | + # Feature 0 (alcohol): face width |
| 98 | + # Feature 1 (phenols): face height |
| 99 | + # Feature 2 (malic acid): eye size |
| 100 | + # Feature 3 (proline): mouth curvature |
117 | 101 |
|
118 | 102 | face_width = 0.45 + features[0] * 0.55 |
119 | 103 | face_height = 0.55 + features[1] * 0.55 |
120 | 104 | eye_size = 0.04 + features[2] * 0.12 |
121 | 105 | mouth_curve = -0.35 + features[3] * 0.7 |
122 | 106 |
|
123 | | - # Draw face outline (ellipse) |
124 | | - face = mpatches.Ellipse((0.5, 0.5), face_width, face_height, facecolor=color, edgecolor="black", linewidth=2.5) |
| 107 | + # Draw face outline |
| 108 | + face = mpatches.Ellipse( |
| 109 | + (0.5, 0.5), face_width, face_height, facecolor=color, edgecolor=INK, linewidth=2.5, alpha=0.9 |
| 110 | + ) |
125 | 111 | ax.add_patch(face) |
126 | 112 |
|
127 | 113 | # Draw eyes |
|
130 | 116 |
|
131 | 117 | # Left eye |
132 | 118 | left_eye = mpatches.Ellipse( |
133 | | - (0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2 |
| 119 | + (0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2 |
134 | 120 | ) |
135 | 121 | ax.add_patch(left_eye) |
136 | | - left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor="black") |
| 122 | + left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor=INK) |
137 | 123 | ax.add_patch(left_pupil) |
138 | 124 |
|
139 | 125 | # Right eye |
140 | 126 | right_eye = mpatches.Ellipse( |
141 | | - (0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor="white", edgecolor="black", linewidth=2 |
| 127 | + (0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2 |
142 | 128 | ) |
143 | 129 | ax.add_patch(right_eye) |
144 | | - right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor="black") |
| 130 | + right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor=INK) |
145 | 131 | ax.add_patch(right_pupil) |
146 | 132 |
|
147 | 133 | # Draw eyebrows |
|
151 | 137 | ax.plot( |
152 | 138 | [0.5 - eye_spacing - 0.05, 0.5 - eye_spacing + 0.05], |
153 | 139 | [eyebrow_y + eyebrow_angle, eyebrow_y - eyebrow_angle], |
154 | | - color="black", |
| 140 | + color=INK, |
155 | 141 | linewidth=3.5, |
156 | 142 | solid_capstyle="round", |
157 | 143 | ) |
158 | 144 | ax.plot( |
159 | 145 | [0.5 + eye_spacing - 0.05, 0.5 + eye_spacing + 0.05], |
160 | 146 | [eyebrow_y - eyebrow_angle, eyebrow_y + eyebrow_angle], |
161 | | - color="black", |
| 147 | + color=INK, |
162 | 148 | linewidth=3.5, |
163 | 149 | solid_capstyle="round", |
164 | 150 | ) |
165 | 151 |
|
166 | 152 | # Draw nose |
167 | 153 | nose_size = 0.03 + features[0] * 0.025 |
| 154 | + color_rgb = hex_to_rgb(color) |
| 155 | + nose_color = tuple(c * 0.7 for c in color_rgb) |
168 | 156 | nose = mpatches.Polygon( |
169 | 157 | [[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", |
| 158 | + facecolor=nose_color, |
| 159 | + edgecolor=INK, |
172 | 160 | linewidth=1.5, |
173 | 161 | ) |
174 | 162 | ax.add_patch(nose) |
|
177 | 165 | mouth_width = 0.08 + features[0] * 0.05 |
178 | 166 | mouth_x = np.linspace(0.5 - mouth_width, 0.5 + mouth_width, 25) |
179 | 167 | 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") |
| 168 | + ax.plot(mouth_x, mouth_y, color=INK_SOFT, linewidth=3.5, solid_capstyle="round") |
181 | 169 |
|
182 | 170 | # Set axis properties |
183 | 171 | ax.set_xlim(0, 1) |
|
186 | 174 | ax.axis("off") |
187 | 175 |
|
188 | 176 | # Add label below face |
189 | | - species_idx = subset_target[idx] |
| 177 | + class_idx = subset_target[idx] |
190 | 178 | sample_num = (idx % 5) + 1 |
191 | 179 | ax.text( |
192 | | - 0.5, -0.02, f"{species_names[species_idx]} #{sample_num}", ha="center", va="top", fontsize=11, fontweight="bold" |
| 180 | + 0.5, |
| 181 | + -0.05, |
| 182 | + f"{class_names[class_idx]} #{sample_num}", |
| 183 | + ha="center", |
| 184 | + va="top", |
| 185 | + fontsize=11, |
| 186 | + fontweight="bold", |
| 187 | + color=INK, |
193 | 188 | ) |
194 | 189 |
|
195 | 190 | # Add overall title |
196 | | -fig.suptitle("chernoff-basic · seaborn · pyplots.ai", fontsize=24, fontweight="bold", y=0.98) |
| 191 | +fig.suptitle("chernoff-basic · seaborn · anyplot.ai", fontsize=24, fontweight="medium", y=0.98, color=INK) |
197 | 192 |
|
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)] |
| 193 | +# Add legend for classes |
| 194 | +legend_handles = [mpatches.Patch(color=okabe_ito[i], label=class_names[i], ec=INK, lw=1.5) for i in range(3)] |
200 | 195 | fig.legend( |
201 | | - handles=legend_handles, loc="center right", fontsize=14, frameon=True, bbox_to_anchor=(0.99, 0.5), title="Species" |
| 196 | + handles=legend_handles, |
| 197 | + loc="lower right", |
| 198 | + fontsize=14, |
| 199 | + frameon=True, |
| 200 | + bbox_to_anchor=(0.98, 0.02), |
| 201 | + title="Wine Class", |
| 202 | + facecolor=ELEVATED_BG, |
| 203 | + edgecolor=INK_SOFT, |
| 204 | + title_fontsize=14, |
202 | 205 | ) |
203 | 206 |
|
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") |
| 207 | +# Add feature mapping explanation |
| 208 | +feature_text = "Face Width ← Alcohol | Face Height ← Phenols | Eye Size ← Malic Acid | Mouth Curve ← Proline" |
| 209 | +fig.text(0.5, 0.01, feature_text, ha="center", fontsize=12, style="italic", color=INK_SOFT) |
209 | 210 |
|
210 | | -plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
| 211 | +plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
0 commit comments