Chernoff Faces for Multivariate Data — Bokeh

Chernoff faces visualize multivariate data by mapping each variable to a facial feature (eye size, mouth curvature, face width, nose length, etc.), transforming each observation into a unique cartoon face. This technique leverages humans' innate ability to recognize and distinguish faces, making it easier to identify patterns, clusters, and outliers across multiple dimensions simultaneously.

Chernoff Faces for Multivariate Data rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
chernoff-basic: Chernoff Faces for Multivariate Data
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 79/100 | Updated: 2026-05-15
"""

import numpy as np
from bokeh.io import export_png
from bokeh.models import ColumnDataSource, HoverTool, Label
from bokeh.plotting import figure


# Generate synthetic company performance data (4 metrics for 12 companies)
# Metrics: Revenue Growth, Profit Margin, Customer Satisfaction, Market Share
np.random.seed(42)

# Three company sectors with different profiles
sectors = ["Tech", "Retail", "Energy"]
sector_idx = np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2])

# Tech companies: high growth, high margin
tech_data = np.column_stack(
    [
        np.random.uniform(0.6, 1.0, 4),  # Revenue Growth
        np.random.uniform(0.5, 0.9, 4),  # Profit Margin
        np.random.uniform(0.6, 0.95, 4),  # Customer Satisfaction
        np.random.uniform(0.3, 0.7, 4),  # Market Share
    ]
)

# Retail companies: moderate growth, moderate margin
retail_data = np.column_stack(
    [
        np.random.uniform(0.2, 0.5, 4),  # Revenue Growth
        np.random.uniform(0.15, 0.4, 4),  # Profit Margin
        np.random.uniform(0.5, 0.8, 4),  # Customer Satisfaction
        np.random.uniform(0.4, 0.8, 4),  # Market Share
    ]
)

# Energy companies: low growth, variable margin
energy_data = np.column_stack(
    [
        np.random.uniform(0.05, 0.3, 4),  # Revenue Growth
        np.random.uniform(0.3, 0.6, 4),  # Profit Margin
        np.random.uniform(0.3, 0.6, 4),  # Customer Satisfaction
        np.random.uniform(0.5, 0.9, 4),  # Market Share
    ]
)

data = np.vstack([tech_data, retail_data, energy_data])

# Normalize each feature to 0-1
data_norm = (data - data.min(axis=0)) / (data.max(axis=0) - data.min(axis=0) + 1e-10)

# Colors for sectors
colors = ["#306998", "#FFD43B", "#8B4513"]

# Store face center data for hover tooltips using ColumnDataSource
face_centers_x = []
face_centers_y = []
face_labels = []
face_revenue = []
face_margin = []
face_satisfaction = []
face_market_share = []

# Create figure with 4x3 grid for 12 faces
p = figure(
    width=4800,
    height=2700,
    title="chernoff-basic · bokeh · pyplots.ai",
    x_range=(-0.1, 4.1),
    y_range=(-0.2, 3.2),
    tools="",
)

# Style
p.title.text_font_size = "32pt"
p.title.align = "center"
p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.visible = False
p.ygrid.visible = False
p.outline_line_color = None
p.background_fill_color = "#FAFAFA"

# Draw faces in a 4x3 grid (inline, no helper function)
face_size = 0.4
for i, (features, sec_idx) in enumerate(zip(data_norm, sector_idx, strict=True)):
    col = i % 4
    row = 2 - i // 4  # Start from top row
    cx = col + 0.5
    cy = row + 0.5
    color = colors[sec_idx]
    label_text = f"{sectors[sec_idx]} #{i % 4 + 1}"

    # Store data for hover tooltip
    face_centers_x.append(cx)
    face_centers_y.append(cy)
    face_labels.append(label_text)
    face_revenue.append(f"{data[i, 0] * 100:.1f}%")
    face_margin.append(f"{data[i, 1] * 100:.1f}%")
    face_satisfaction.append(f"{data[i, 2] * 100:.1f}%")
    face_market_share.append(f"{data[i, 3] * 100:.1f}%")

    # Features mapping:
    # - revenue_growth (features[0]) -> face width
    # - profit_margin (features[1]) -> face height
    # - customer_satisfaction (features[2]) -> eye size
    # - market_share (features[3]) -> mouth curvature
    face_width = (0.3 + features[0] * 0.3) * face_size
    face_height = (0.35 + features[1] * 0.25) * face_size
    eye_size = (0.03 + features[2] * 0.05) * face_size
    mouth_curve = features[3]

    # Draw face outline (ellipse approximation using patches)
    theta = np.linspace(0, 2 * np.pi, 50)
    face_x = cx + face_width * np.cos(theta)
    face_y = cy + face_height * np.sin(theta)
    p.patch(face_x, face_y, fill_color=color, fill_alpha=0.3, line_color=color, line_width=3)

    # Draw eyes
    eye_spacing = face_width * 0.5
    eye_y = cy + face_height * 0.25
    eye_theta = np.linspace(0, 2 * np.pi, 30)

    # Left eye
    left_eye_x = cx - eye_spacing
    left_ex = left_eye_x + eye_size * np.cos(eye_theta)
    left_ey = eye_y + eye_size * np.sin(eye_theta)
    p.patch(left_ex, left_ey, fill_color="white", line_color="#333333", line_width=2)

    # Left pupil
    pupil_size = eye_size * 0.5
    left_px = left_eye_x + pupil_size * np.cos(eye_theta) * 0.6
    left_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6
    p.patch(left_px, left_py, fill_color="#333333", line_color="#333333")

    # Right eye
    right_eye_x = cx + eye_spacing
    right_ex = right_eye_x + eye_size * np.cos(eye_theta)
    right_ey = eye_y + eye_size * np.sin(eye_theta)
    p.patch(right_ex, right_ey, fill_color="white", line_color="#333333", line_width=2)

    # Right pupil
    right_px = right_eye_x + pupil_size * np.cos(eye_theta) * 0.6
    right_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6
    p.patch(right_px, right_py, fill_color="#333333", line_color="#333333")

    # Draw eyebrows
    brow_y = eye_y + eye_size * 1.8
    brow_width = eye_size * 1.2
    eyebrow_slant = (features[0] - 0.5) * 0.02 * face_size

    p.line(
        [left_eye_x - brow_width, left_eye_x + brow_width],
        [brow_y + eyebrow_slant, brow_y - eyebrow_slant],
        line_color="#333333",
        line_width=3,
    )
    p.line(
        [right_eye_x - brow_width, right_eye_x + brow_width],
        [brow_y - eyebrow_slant, brow_y + eyebrow_slant],
        line_color="#333333",
        line_width=3,
    )

    # Draw nose
    nose_length = (0.02 + features[1] * 0.03) * face_size
    nose_y_top = cy + face_height * 0.1
    nose_y_bottom = cy - face_height * 0.1
    p.line([cx, cx], [nose_y_top, nose_y_bottom], line_color="#333333", line_width=2)
    p.line(
        [cx - nose_length * 0.5, cx, cx + nose_length * 0.5],
        [nose_y_bottom, nose_y_bottom - nose_length * 0.3, nose_y_bottom],
        line_color="#333333",
        line_width=2,
    )

    # Draw mouth (curved based on market_share)
    mouth_y = cy - face_height * 0.4
    mouth_width = face_width * 0.5
    mouth_x = np.linspace(cx - mouth_width, cx + mouth_width, 20)
    curve_amount = (mouth_curve - 0.5) * 0.08 * face_size
    mouth_y_curve = mouth_y + curve_amount * (1 - ((mouth_x - cx) / mouth_width) ** 2) * 4
    p.line(mouth_x, mouth_y_curve, line_color="#333333", line_width=3)

    # Add label below face
    label_obj = Label(
        x=cx,
        y=cy - face_height - 0.1,
        text=label_text,
        text_align="center",
        text_font_size="20pt",
        text_color="#333333",
    )
    p.add_layout(label_obj)

# Create ColumnDataSource for hover tooltips (Bokeh-specific feature)
hover_source = ColumnDataSource(
    data={
        "x": face_centers_x,
        "y": face_centers_y,
        "label": face_labels,
        "revenue": face_revenue,
        "margin": face_margin,
        "satisfaction": face_satisfaction,
        "market_share": face_market_share,
    }
)

# Add invisible scatter for hover interaction
hover_renderer = p.scatter("x", "y", source=hover_source, size=80, fill_alpha=0, line_alpha=0)

# Add HoverTool for interactivity (distinctive Bokeh feature)
hover_tool = HoverTool(
    renderers=[hover_renderer],
    tooltips=[
        ("Company", "@label"),
        ("Revenue Growth", "@revenue"),
        ("Profit Margin", "@margin"),
        ("Satisfaction", "@satisfaction"),
        ("Market Share", "@market_share"),
    ],
)
p.add_tools(hover_tool)

# Add legend manually using patches and labels (positioned below grid)
legend_y_base = 2.95
legend_x_positions = [0.5, 1.5, 2.5]
for i, (name, color) in enumerate(zip(sectors, colors, strict=True)):
    lx_center = legend_x_positions[i]
    theta = np.linspace(0, 2 * np.pi, 30)
    lx = lx_center + 0.06 * np.cos(theta)
    ly = legend_y_base + 0.06 * np.sin(theta)
    p.patch(lx, ly, fill_color=color, fill_alpha=0.3, line_color=color, line_width=2)
    legend_label = Label(
        x=lx_center + 0.12, y=legend_y_base - 0.02, text=name, text_font_size="20pt", text_color="#333333"
    )
    p.add_layout(legend_label)

# Add subtitle with feature mapping explanation (increased font size)
subtitle = Label(
    x=2.0,
    y=-0.02,
    text="Face width=Revenue Growth, Face height=Profit Margin, Eye size=Satisfaction, Mouth=Market Share",
    text_align="center",
    text_font_size="22pt",
    text_color="#666666",
)
p.add_layout(subtitle)

# Save
export_png(p, filename="plot.png")

Part of Chernoff Faces for Multivariate Data on anyplot.ai.

Other implementations