|
| 1 | +""" pyplots.ai |
| 2 | +chernoff-basic: Chernoff Faces for Multivariate Data |
| 3 | +Library: bokeh 3.8.1 | Python 3.13.11 |
| 4 | +Quality: 91/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +from bokeh.io import export_png |
| 9 | +from bokeh.models import ColumnDataSource, HoverTool, Label |
| 10 | +from bokeh.plotting import figure |
| 11 | + |
| 12 | + |
| 13 | +# Generate synthetic company performance data (4 metrics for 12 companies) |
| 14 | +# Metrics: Revenue Growth, Profit Margin, Customer Satisfaction, Market Share |
| 15 | +np.random.seed(42) |
| 16 | + |
| 17 | +# Three company sectors with different profiles |
| 18 | +sectors = ["Tech", "Retail", "Energy"] |
| 19 | +sector_idx = np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]) |
| 20 | + |
| 21 | +# Tech companies: high growth, high margin |
| 22 | +tech_data = np.column_stack( |
| 23 | + [ |
| 24 | + np.random.uniform(0.6, 1.0, 4), # Revenue Growth |
| 25 | + np.random.uniform(0.5, 0.9, 4), # Profit Margin |
| 26 | + np.random.uniform(0.6, 0.95, 4), # Customer Satisfaction |
| 27 | + np.random.uniform(0.3, 0.7, 4), # Market Share |
| 28 | + ] |
| 29 | +) |
| 30 | + |
| 31 | +# Retail companies: moderate growth, moderate margin |
| 32 | +retail_data = np.column_stack( |
| 33 | + [ |
| 34 | + np.random.uniform(0.2, 0.5, 4), # Revenue Growth |
| 35 | + np.random.uniform(0.15, 0.4, 4), # Profit Margin |
| 36 | + np.random.uniform(0.5, 0.8, 4), # Customer Satisfaction |
| 37 | + np.random.uniform(0.4, 0.8, 4), # Market Share |
| 38 | + ] |
| 39 | +) |
| 40 | + |
| 41 | +# Energy companies: low growth, variable margin |
| 42 | +energy_data = np.column_stack( |
| 43 | + [ |
| 44 | + np.random.uniform(0.05, 0.3, 4), # Revenue Growth |
| 45 | + np.random.uniform(0.3, 0.6, 4), # Profit Margin |
| 46 | + np.random.uniform(0.3, 0.6, 4), # Customer Satisfaction |
| 47 | + np.random.uniform(0.5, 0.9, 4), # Market Share |
| 48 | + ] |
| 49 | +) |
| 50 | + |
| 51 | +data = np.vstack([tech_data, retail_data, energy_data]) |
| 52 | + |
| 53 | +# Normalize each feature to 0-1 |
| 54 | +data_norm = (data - data.min(axis=0)) / (data.max(axis=0) - data.min(axis=0) + 1e-10) |
| 55 | + |
| 56 | +# Colors for sectors |
| 57 | +colors = ["#306998", "#FFD43B", "#8B4513"] |
| 58 | + |
| 59 | +# Store face center data for hover tooltips using ColumnDataSource |
| 60 | +face_centers_x = [] |
| 61 | +face_centers_y = [] |
| 62 | +face_labels = [] |
| 63 | +face_revenue = [] |
| 64 | +face_margin = [] |
| 65 | +face_satisfaction = [] |
| 66 | +face_market_share = [] |
| 67 | + |
| 68 | +# Create figure with 4x3 grid for 12 faces |
| 69 | +p = figure( |
| 70 | + width=4800, |
| 71 | + height=2700, |
| 72 | + title="chernoff-basic · bokeh · pyplots.ai", |
| 73 | + x_range=(-0.1, 4.1), |
| 74 | + y_range=(-0.2, 3.2), |
| 75 | + tools="", |
| 76 | +) |
| 77 | + |
| 78 | +# Style |
| 79 | +p.title.text_font_size = "32pt" |
| 80 | +p.title.align = "center" |
| 81 | +p.xaxis.visible = False |
| 82 | +p.yaxis.visible = False |
| 83 | +p.xgrid.visible = False |
| 84 | +p.ygrid.visible = False |
| 85 | +p.outline_line_color = None |
| 86 | +p.background_fill_color = "#FAFAFA" |
| 87 | + |
| 88 | +# Draw faces in a 4x3 grid (inline, no helper function) |
| 89 | +face_size = 0.4 |
| 90 | +for i, (features, sec_idx) in enumerate(zip(data_norm, sector_idx, strict=True)): |
| 91 | + col = i % 4 |
| 92 | + row = 2 - i // 4 # Start from top row |
| 93 | + cx = col + 0.5 |
| 94 | + cy = row + 0.5 |
| 95 | + color = colors[sec_idx] |
| 96 | + label_text = f"{sectors[sec_idx]} #{i % 4 + 1}" |
| 97 | + |
| 98 | + # Store data for hover tooltip |
| 99 | + face_centers_x.append(cx) |
| 100 | + face_centers_y.append(cy) |
| 101 | + face_labels.append(label_text) |
| 102 | + face_revenue.append(f"{data[i, 0] * 100:.1f}%") |
| 103 | + face_margin.append(f"{data[i, 1] * 100:.1f}%") |
| 104 | + face_satisfaction.append(f"{data[i, 2] * 100:.1f}%") |
| 105 | + face_market_share.append(f"{data[i, 3] * 100:.1f}%") |
| 106 | + |
| 107 | + # Features mapping: |
| 108 | + # - revenue_growth (features[0]) -> face width |
| 109 | + # - profit_margin (features[1]) -> face height |
| 110 | + # - customer_satisfaction (features[2]) -> eye size |
| 111 | + # - market_share (features[3]) -> mouth curvature |
| 112 | + face_width = (0.3 + features[0] * 0.3) * face_size |
| 113 | + face_height = (0.35 + features[1] * 0.25) * face_size |
| 114 | + eye_size = (0.03 + features[2] * 0.05) * face_size |
| 115 | + mouth_curve = features[3] |
| 116 | + |
| 117 | + # Draw face outline (ellipse approximation using patches) |
| 118 | + theta = np.linspace(0, 2 * np.pi, 50) |
| 119 | + face_x = cx + face_width * np.cos(theta) |
| 120 | + face_y = cy + face_height * np.sin(theta) |
| 121 | + p.patch(face_x, face_y, fill_color=color, fill_alpha=0.3, line_color=color, line_width=3) |
| 122 | + |
| 123 | + # Draw eyes |
| 124 | + eye_spacing = face_width * 0.5 |
| 125 | + eye_y = cy + face_height * 0.25 |
| 126 | + eye_theta = np.linspace(0, 2 * np.pi, 30) |
| 127 | + |
| 128 | + # Left eye |
| 129 | + left_eye_x = cx - eye_spacing |
| 130 | + left_ex = left_eye_x + eye_size * np.cos(eye_theta) |
| 131 | + left_ey = eye_y + eye_size * np.sin(eye_theta) |
| 132 | + p.patch(left_ex, left_ey, fill_color="white", line_color="#333333", line_width=2) |
| 133 | + |
| 134 | + # Left pupil |
| 135 | + pupil_size = eye_size * 0.5 |
| 136 | + left_px = left_eye_x + pupil_size * np.cos(eye_theta) * 0.6 |
| 137 | + left_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6 |
| 138 | + p.patch(left_px, left_py, fill_color="#333333", line_color="#333333") |
| 139 | + |
| 140 | + # Right eye |
| 141 | + right_eye_x = cx + eye_spacing |
| 142 | + right_ex = right_eye_x + eye_size * np.cos(eye_theta) |
| 143 | + right_ey = eye_y + eye_size * np.sin(eye_theta) |
| 144 | + p.patch(right_ex, right_ey, fill_color="white", line_color="#333333", line_width=2) |
| 145 | + |
| 146 | + # Right pupil |
| 147 | + right_px = right_eye_x + pupil_size * np.cos(eye_theta) * 0.6 |
| 148 | + right_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6 |
| 149 | + p.patch(right_px, right_py, fill_color="#333333", line_color="#333333") |
| 150 | + |
| 151 | + # Draw eyebrows |
| 152 | + brow_y = eye_y + eye_size * 1.8 |
| 153 | + brow_width = eye_size * 1.2 |
| 154 | + eyebrow_slant = (features[0] - 0.5) * 0.02 * face_size |
| 155 | + |
| 156 | + p.line( |
| 157 | + [left_eye_x - brow_width, left_eye_x + brow_width], |
| 158 | + [brow_y + eyebrow_slant, brow_y - eyebrow_slant], |
| 159 | + line_color="#333333", |
| 160 | + line_width=3, |
| 161 | + ) |
| 162 | + p.line( |
| 163 | + [right_eye_x - brow_width, right_eye_x + brow_width], |
| 164 | + [brow_y - eyebrow_slant, brow_y + eyebrow_slant], |
| 165 | + line_color="#333333", |
| 166 | + line_width=3, |
| 167 | + ) |
| 168 | + |
| 169 | + # Draw nose |
| 170 | + nose_length = (0.02 + features[1] * 0.03) * face_size |
| 171 | + nose_y_top = cy + face_height * 0.1 |
| 172 | + nose_y_bottom = cy - face_height * 0.1 |
| 173 | + p.line([cx, cx], [nose_y_top, nose_y_bottom], line_color="#333333", line_width=2) |
| 174 | + p.line( |
| 175 | + [cx - nose_length * 0.5, cx, cx + nose_length * 0.5], |
| 176 | + [nose_y_bottom, nose_y_bottom - nose_length * 0.3, nose_y_bottom], |
| 177 | + line_color="#333333", |
| 178 | + line_width=2, |
| 179 | + ) |
| 180 | + |
| 181 | + # Draw mouth (curved based on market_share) |
| 182 | + mouth_y = cy - face_height * 0.4 |
| 183 | + mouth_width = face_width * 0.5 |
| 184 | + mouth_x = np.linspace(cx - mouth_width, cx + mouth_width, 20) |
| 185 | + curve_amount = (mouth_curve - 0.5) * 0.08 * face_size |
| 186 | + mouth_y_curve = mouth_y + curve_amount * (1 - ((mouth_x - cx) / mouth_width) ** 2) * 4 |
| 187 | + p.line(mouth_x, mouth_y_curve, line_color="#333333", line_width=3) |
| 188 | + |
| 189 | + # Add label below face |
| 190 | + label_obj = Label( |
| 191 | + x=cx, |
| 192 | + y=cy - face_height - 0.1, |
| 193 | + text=label_text, |
| 194 | + text_align="center", |
| 195 | + text_font_size="20pt", |
| 196 | + text_color="#333333", |
| 197 | + ) |
| 198 | + p.add_layout(label_obj) |
| 199 | + |
| 200 | +# Create ColumnDataSource for hover tooltips (Bokeh-specific feature) |
| 201 | +hover_source = ColumnDataSource( |
| 202 | + data={ |
| 203 | + "x": face_centers_x, |
| 204 | + "y": face_centers_y, |
| 205 | + "label": face_labels, |
| 206 | + "revenue": face_revenue, |
| 207 | + "margin": face_margin, |
| 208 | + "satisfaction": face_satisfaction, |
| 209 | + "market_share": face_market_share, |
| 210 | + } |
| 211 | +) |
| 212 | + |
| 213 | +# Add invisible scatter for hover interaction |
| 214 | +hover_renderer = p.scatter("x", "y", source=hover_source, size=80, fill_alpha=0, line_alpha=0) |
| 215 | + |
| 216 | +# Add HoverTool for interactivity (distinctive Bokeh feature) |
| 217 | +hover_tool = HoverTool( |
| 218 | + renderers=[hover_renderer], |
| 219 | + tooltips=[ |
| 220 | + ("Company", "@label"), |
| 221 | + ("Revenue Growth", "@revenue"), |
| 222 | + ("Profit Margin", "@margin"), |
| 223 | + ("Satisfaction", "@satisfaction"), |
| 224 | + ("Market Share", "@market_share"), |
| 225 | + ], |
| 226 | +) |
| 227 | +p.add_tools(hover_tool) |
| 228 | + |
| 229 | +# Add legend manually using patches and labels (positioned below grid) |
| 230 | +legend_y_base = 2.95 |
| 231 | +legend_x_positions = [0.5, 1.5, 2.5] |
| 232 | +for i, (name, color) in enumerate(zip(sectors, colors, strict=True)): |
| 233 | + lx_center = legend_x_positions[i] |
| 234 | + theta = np.linspace(0, 2 * np.pi, 30) |
| 235 | + lx = lx_center + 0.06 * np.cos(theta) |
| 236 | + ly = legend_y_base + 0.06 * np.sin(theta) |
| 237 | + p.patch(lx, ly, fill_color=color, fill_alpha=0.3, line_color=color, line_width=2) |
| 238 | + legend_label = Label( |
| 239 | + x=lx_center + 0.12, y=legend_y_base - 0.02, text=name, text_font_size="20pt", text_color="#333333" |
| 240 | + ) |
| 241 | + p.add_layout(legend_label) |
| 242 | + |
| 243 | +# Add subtitle with feature mapping explanation (increased font size) |
| 244 | +subtitle = Label( |
| 245 | + x=2.0, |
| 246 | + y=-0.02, |
| 247 | + text="Face width=Revenue Growth, Face height=Profit Margin, Eye size=Satisfaction, Mouth=Market Share", |
| 248 | + text_align="center", |
| 249 | + text_font_size="22pt", |
| 250 | + text_color="#666666", |
| 251 | +) |
| 252 | +p.add_layout(subtitle) |
| 253 | + |
| 254 | +# Save |
| 255 | +export_png(p, filename="plot.png") |
0 commit comments