|
| 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") |
0 commit comments