Chernoff Faces for Multivariate Data — Plotly

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 Plotly

Python source (Plotly)

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

import os

import numpy as np
import plotly.graph_objects as go
from sklearn.datasets import load_iris


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"

# Okabe-Ito palette (first series is always #009E73)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data - use Iris dataset with 4 measurements per flower
np.random.seed(42)
iris = load_iris()
X = iris.data
y = iris.target
feature_names = iris.feature_names
target_names = iris.target_names

# Select subset for clear visualization (5 samples per species = 15 faces)
indices = []
for species in range(3):
    species_mask = y == species
    species_data = X[species_mask]
    species_indices_all = np.where(species_mask)[0]
    # Calculate variance score for each sample and select diverse ones
    mean_vals = species_data.mean(axis=0)
    distances = np.sum((species_data - mean_vals) ** 2, axis=1)
    # Select min, max distance and 3 evenly spaced others
    sorted_idx = np.argsort(distances)
    selected = [
        sorted_idx[0],
        sorted_idx[len(sorted_idx) // 4],
        sorted_idx[len(sorted_idx) // 2],
        sorted_idx[3 * len(sorted_idx) // 4],
        sorted_idx[-1],
    ]
    indices.extend([species_indices_all[i] for i in selected])

X_subset = X[indices]
y_subset = y[indices]

# Normalize data to 0-1 range
X_norm = (X_subset - X_subset.min(axis=0)) / (X_subset.max(axis=0) - X_subset.min(axis=0))

# Create figure
fig = go.Figure()

# Grid layout: 3 rows (species) x 5 columns (samples)
n_cols = 5
n_rows = 3
spacing = 2.2
radius = 0.95

all_shapes = []

# Create all faces inline (KISS - no functions)
for i, (data, species) in enumerate(zip(X_norm, y_subset)):
    row = i // n_cols
    col = i % n_cols

    cx = col * spacing + spacing / 2
    cy = (n_rows - 1 - row) * spacing + spacing / 2

    color = IMPRINT[species]

    # Feature mapping with increased variation:
    #   - sepal_length (data[0]) -> face width
    #   - sepal_width (data[1]) -> face height
    #   - petal_length (data[2]) -> eye size
    #   - petal_width (data[3]) -> mouth curvature
    face_width_factor = 0.6 + data[0] * 0.8  # 0.6-1.4 (wider range)
    face_height_factor = 0.7 + data[1] * 0.6  # 0.7-1.3 (wider range)
    eye_size = 0.06 + data[2] * 0.18  # 0.06-0.24 (more variation)
    mouth_curve = -0.3 + data[3] * 0.6  # -0.3 to +0.3 (frown to big smile)

    # Face outline (ellipse)
    face_w = radius * face_width_factor
    face_h = radius * face_height_factor
    theta = np.linspace(0, 2 * np.pi, 50)
    face_x = cx + face_w * np.cos(theta)
    face_y = cy + face_h * np.sin(theta)

    all_shapes.append(
        dict(
            type="path",
            path="M " + " L ".join([f"{x},{y}" for x, y in zip(face_x, face_y)]) + " Z",
            fillcolor=color,
            line=dict(color=INK_SOFT, width=2),
            opacity=0.35,
        )
    )

    # Eyes
    eye_offset_x = face_w * 0.35
    eye_offset_y = face_h * 0.22
    eye_r = radius * eye_size
    eye_theta = np.linspace(0, 2 * np.pi, 30)

    # Left eye
    left_eye_x = (cx - eye_offset_x) + eye_r * np.cos(eye_theta)
    left_eye_y = (cy + eye_offset_y) + eye_r * 0.7 * np.sin(eye_theta)
    all_shapes.append(
        dict(
            type="path",
            path="M " + " L ".join([f"{x},{y}" for x, y in zip(left_eye_x, left_eye_y)]) + " Z",
            fillcolor=ELEVATED_BG,
            line=dict(color=INK_SOFT, width=2),
        )
    )

    # Left pupil
    pupil_r = eye_r * 0.5
    pupil_x = (cx - eye_offset_x) + pupil_r * np.cos(eye_theta)
    pupil_y = (cy + eye_offset_y) + pupil_r * np.sin(eye_theta)
    all_shapes.append(
        dict(
            type="path",
            path="M " + " L ".join([f"{x},{y}" for x, y in zip(pupil_x, pupil_y)]) + " Z",
            fillcolor=INK,
            line=dict(color=INK, width=1),
        )
    )

    # Right eye
    right_eye_x = (cx + eye_offset_x) + eye_r * np.cos(eye_theta)
    right_eye_y = (cy + eye_offset_y) + eye_r * 0.7 * np.sin(eye_theta)
    all_shapes.append(
        dict(
            type="path",
            path="M " + " L ".join([f"{x},{y}" for x, y in zip(right_eye_x, right_eye_y)]) + " Z",
            fillcolor=ELEVATED_BG,
            line=dict(color=INK_SOFT, width=2),
        )
    )

    # Right pupil
    pupil_x = (cx + eye_offset_x) + pupil_r * np.cos(eye_theta)
    pupil_y = (cy + eye_offset_y) + pupil_r * np.sin(eye_theta)
    all_shapes.append(
        dict(
            type="path",
            path="M " + " L ".join([f"{x},{y}" for x, y in zip(pupil_x, pupil_y)]) + " Z",
            fillcolor=INK,
            line=dict(color=INK, width=1),
        )
    )

    # Nose (simple triangle)
    nose_h = face_h * 0.15
    nose_w = face_w * 0.1
    nose_y_center = cy
    nose_path = f"M {cx},{nose_y_center + nose_h * 0.5} L {cx - nose_w},{nose_y_center - nose_h * 0.5} L {cx + nose_w},{nose_y_center - nose_h * 0.5} Z"
    all_shapes.append(dict(type="path", path=nose_path, fillcolor=INK, line=dict(color=INK, width=1), opacity=0.5))

    # Mouth (curved line with much more variation)
    mouth_y_base = cy - face_h * 0.35
    mouth_width = face_w * 0.5
    mouth_points = 20
    mouth_x_vals = np.linspace(cx - mouth_width, cx + mouth_width, mouth_points)
    # Parabolic curve: positive = smile, negative = frown
    mouth_y_vals = mouth_y_base + mouth_curve * (1 - ((mouth_x_vals - cx) / mouth_width) ** 2) * radius * 0.5
    mouth_path = "M " + " L ".join([f"{x},{y}" for x, y in zip(mouth_x_vals, mouth_y_vals)])
    all_shapes.append(dict(type="path", path=mouth_path, line=dict(color=INK, width=3)))

    # Eyebrows (angled based on face width feature)
    brow_offset_y = eye_offset_y + eye_r + face_h * 0.1
    brow_width = eye_r * 1.2
    brow_angle = (data[0] - 0.5) * 0.2  # More angle variation

    # Left eyebrow
    all_shapes.append(
        dict(
            type="line",
            x0=cx - eye_offset_x - brow_width,
            y0=cy + brow_offset_y - brow_angle * radius,
            x1=cx - eye_offset_x + brow_width,
            y1=cy + brow_offset_y + brow_angle * radius,
            line=dict(color=INK, width=3),
        )
    )

    # Right eyebrow
    all_shapes.append(
        dict(
            type="line",
            x0=cx + eye_offset_x - brow_width,
            y0=cy + brow_offset_y + brow_angle * radius,
            x1=cx + eye_offset_x + brow_width,
            y1=cy + brow_offset_y - brow_angle * radius,
            line=dict(color=INK, width=3),
        )
    )

# Add invisible scatter for axis setup
fig.add_trace(go.Scatter(x=[0], y=[0], mode="markers", marker=dict(opacity=0), showlegend=False))

# Add legend entries for species
for i, (name, color) in enumerate(zip(target_names, IMPRINT)):
    fig.add_trace(
        go.Scatter(
            x=[None],
            y=[None],
            mode="markers",
            marker=dict(size=20, color=color, opacity=0.5, line=dict(color=INK_SOFT, width=2)),
            name=name.capitalize(),
            showlegend=True,
        )
    )

# Row labels (species names)
for i, name in enumerate(target_names):
    row_y = (n_rows - 1 - i) * spacing + spacing / 2
    fig.add_annotation(
        x=-0.6,
        y=row_y,
        text=f"<b>{name.capitalize()}</b>",
        showarrow=False,
        font=dict(size=18, color=INK),
        xanchor="right",
    )

# Column labels (sample numbers)
for col in range(n_cols):
    col_x = col * spacing + spacing / 2
    fig.add_annotation(
        x=col_x, y=n_rows * spacing + 0.2, text=f"Sample {col + 1}", showarrow=False, font=dict(size=16, color=INK_SOFT)
    )

# Feature mapping legend
mapping_text = (
    "<b>Feature Mapping:</b><br>"
    "Face Width: Sepal Length<br>"
    "Face Height: Sepal Width<br>"
    "Eye Size: Petal Length<br>"
    "Smile: Petal Width"
)
fig.add_annotation(
    x=n_cols * spacing + 0.3,
    y=n_rows * spacing / 2,
    text=mapping_text,
    showarrow=False,
    font=dict(size=14, color=INK_SOFT),
    align="left",
    xanchor="left",
    bgcolor=ELEVATED_BG,
    bordercolor=INK_SOFT,
    borderwidth=1,
    borderpad=10,
)

# Update layout - optimized for better space utilization
fig.update_layout(
    title=dict(text="chernoff-basic · plotly · anyplot.ai", font=dict(size=28, color=INK), x=0.5, xanchor="center"),
    shapes=all_shapes,
    xaxis=dict(range=[-1.2, n_cols * spacing + 3.0], showgrid=False, zeroline=False, showticklabels=False, title=""),
    yaxis=dict(
        range=[-0.3, n_rows * spacing + 0.6],
        showgrid=False,
        zeroline=False,
        showticklabels=False,
        title="",
        scaleanchor="x",
        scaleratio=1,
    ),
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font=dict(color=INK),
    legend=dict(
        title=dict(text="<b>Species</b>", font=dict(size=18, color=INK)),
        font=dict(size=16, color=INK_SOFT),
        x=1.02,
        y=0.98,
        xanchor="left",
        bgcolor=ELEVATED_BG,
        bordercolor=INK_SOFT,
        borderwidth=1,
    ),
    margin=dict(l=100, r=180, t=80, b=40),
)

# Save as PNG
fig.write_image(f"plot-{THEME}.png", width=1600, height=900, scale=3)

# Save interactive HTML
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

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

Other implementations