Chernoff Faces for Multivariate Data — Seaborn

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 Seaborn

Python source (Seaborn)

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

import os

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
from sklearn.datasets import load_wine


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"
BRAND = "#009E73"

# Load wine dataset (13 chemical measurements per wine sample)
wine = load_wine()
feature_names = wine.feature_names
data = wine.data
target = wine.target

# Select 5 wines from each class (3 classes = 15 total)
np.random.seed(42)
indices = []
for cls in range(3):
    class_indices = np.where(target == cls)[0]
    selected = np.random.choice(class_indices, size=5, replace=False)
    indices.extend(selected)
indices = np.array(indices)

subset_data = data[indices]
subset_target = target[indices]
class_names = ["Class 1", "Class 2", "Class 3"]

# Select 4 key features for face mapping
selected_features = [0, 6, 9, 12]  # Alcohol, phenols, malic acid, proline
selected_feature_names = [feature_names[i].replace(" ", "\n") for i in selected_features]
data_subset = subset_data[:, selected_features]

# Within-species normalization for faces
normalized_data = np.zeros((15, 4))
for cls in range(3):
    class_mask = subset_target == cls
    class_subset = data_subset[class_mask]
    for feat_idx in range(4):
        feat_min = class_subset[:, feat_idx].min()
        feat_max = class_subset[:, feat_idx].max()
        feat_range = feat_max - feat_min if feat_max > feat_min else 1.0
        normalized_data[class_mask, feat_idx] = (class_subset[:, feat_idx] - feat_min) / feat_range

# Set seaborn theme
sns.set_style("white")
sns.set_context("poster", font_scale=1.0)
sns.set_theme(
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "text.color": INK,
        "axes.labelcolor": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
    }
)

# Okabe-Ito palette - first series is always brand green
okabe_ito = [BRAND, "#C475FD", "#4467A3"]
face_colors = [okabe_ito[t] for t in subset_target]

# Create figure
fig = plt.figure(figsize=(16, 9), facecolor=PAGE_BG)

# Grid layout: faces only (3 rows x 5 cols)
gs = fig.add_gridspec(3, 5, hspace=0.35, wspace=0.25)


def hex_to_rgb(h):
    return tuple(int(h.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4))


# Draw Chernoff faces
for idx in range(15):
    row = idx // 5
    col = idx % 5
    ax = fig.add_subplot(gs[row, col])

    features = normalized_data[idx]
    color = face_colors[idx]

    # Map features to facial characteristics
    # Feature 0 (alcohol): face width
    # Feature 1 (phenols): face height
    # Feature 2 (malic acid): eye size
    # Feature 3 (proline): mouth curvature

    face_width = 0.45 + features[0] * 0.55
    face_height = 0.55 + features[1] * 0.55
    eye_size = 0.04 + features[2] * 0.12
    mouth_curve = -0.35 + features[3] * 0.7

    # Draw face outline
    face = mpatches.Ellipse(
        (0.5, 0.5), face_width, face_height, facecolor=color, edgecolor=INK, linewidth=2.5, alpha=0.9
    )
    ax.add_patch(face)

    # Draw eyes
    eye_y = 0.58
    eye_spacing = 0.11 + features[1] * 0.05

    # Left eye
    left_eye = mpatches.Ellipse(
        (0.5 - eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2
    )
    ax.add_patch(left_eye)
    left_pupil = mpatches.Circle((0.5 - eye_spacing, eye_y), eye_size * 0.35, facecolor=INK)
    ax.add_patch(left_pupil)

    # Right eye
    right_eye = mpatches.Ellipse(
        (0.5 + eye_spacing, eye_y), eye_size * 1.6, eye_size, facecolor=ELEVATED_BG, edgecolor=INK, linewidth=2
    )
    ax.add_patch(right_eye)
    right_pupil = mpatches.Circle((0.5 + eye_spacing, eye_y), eye_size * 0.35, facecolor=INK)
    ax.add_patch(right_pupil)

    # Draw eyebrows
    eyebrow_angle = -0.12 + features[2] * 0.24
    eyebrow_y = eye_y + eye_size + 0.05

    ax.plot(
        [0.5 - eye_spacing - 0.05, 0.5 - eye_spacing + 0.05],
        [eyebrow_y + eyebrow_angle, eyebrow_y - eyebrow_angle],
        color=INK,
        linewidth=3.5,
        solid_capstyle="round",
    )
    ax.plot(
        [0.5 + eye_spacing - 0.05, 0.5 + eye_spacing + 0.05],
        [eyebrow_y - eyebrow_angle, eyebrow_y + eyebrow_angle],
        color=INK,
        linewidth=3.5,
        solid_capstyle="round",
    )

    # Draw nose
    nose_size = 0.03 + features[0] * 0.025
    color_rgb = hex_to_rgb(color)
    nose_color = tuple(c * 0.7 for c in color_rgb)
    nose = mpatches.Polygon(
        [[0.5, 0.50], [0.5 - nose_size, 0.40], [0.5 + nose_size, 0.40]],
        facecolor=nose_color,
        edgecolor=INK,
        linewidth=1.5,
    )
    ax.add_patch(nose)

    # Draw mouth
    mouth_width = 0.08 + features[0] * 0.05
    mouth_x = np.linspace(0.5 - mouth_width, 0.5 + mouth_width, 25)
    mouth_y = 0.30 + mouth_curve * ((mouth_x - 0.5) ** 2) * 18
    ax.plot(mouth_x, mouth_y, color=INK_SOFT, linewidth=3.5, solid_capstyle="round")

    # Set axis properties
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_aspect("equal")
    ax.axis("off")

    # Add label below face
    class_idx = subset_target[idx]
    sample_num = (idx % 5) + 1
    ax.text(
        0.5,
        -0.05,
        f"{class_names[class_idx]} #{sample_num}",
        ha="center",
        va="top",
        fontsize=11,
        fontweight="bold",
        color=INK,
    )

# Add overall title
fig.suptitle("chernoff-basic · seaborn · anyplot.ai", fontsize=24, fontweight="medium", y=0.98, color=INK)

# Add legend for classes
legend_handles = [mpatches.Patch(color=okabe_ito[i], label=class_names[i], ec=INK, lw=1.5) for i in range(3)]
fig.legend(
    handles=legend_handles,
    loc="lower right",
    fontsize=14,
    frameon=True,
    bbox_to_anchor=(0.98, 0.02),
    title="Wine Class",
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    title_fontsize=14,
)

# Add feature mapping explanation
feature_text = "Face Width ← Alcohol  |  Face Height ← Phenols  |  Eye Size ← Malic Acid  |  Mouth Curve ← Proline"
fig.text(0.5, 0.01, feature_text, ha="center", fontsize=12, style="italic", color=INK_SOFT)

plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

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

Other implementations