Chernoff Faces for Multivariate Data — Altair

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 Altair

Python source (Altair)

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

import os

import altair as alt
import numpy as np
import pandas as pd


# 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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette for species
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"]

# Data - Iris dataset features for 12 representative flowers
np.random.seed(42)
# Diverse samples from iris-like measurements (normalized 0-1)
data = pd.DataFrame(
    {
        "observation": [f"Sample {i + 1}" for i in range(12)],
        "sepal_length": [0.22, 0.83, 0.45, 0.12, 0.91, 0.67, 0.33, 0.78, 0.55, 0.95, 0.28, 0.61],
        "sepal_width": [0.63, 0.45, 0.78, 0.89, 0.32, 0.56, 0.71, 0.41, 0.65, 0.25, 0.82, 0.48],
        "petal_length": [0.07, 0.69, 0.42, 0.05, 0.83, 0.55, 0.18, 0.76, 0.38, 0.95, 0.11, 0.62],
        "petal_width": [0.04, 0.54, 0.33, 0.02, 0.79, 0.48, 0.12, 0.67, 0.29, 0.88, 0.08, 0.52],
        "species": [
            "setosa",
            "virginica",
            "versicolor",
            "setosa",
            "virginica",
            "versicolor",
            "setosa",
            "virginica",
            "versicolor",
            "virginica",
            "setosa",
            "versicolor",
        ],
    }
)

# Map species to Okabe-Ito colors
species_colors = {"setosa": IMPRINT[0], "versicolor": IMPRINT[1], "virginica": IMPRINT[2]}
data["color"] = data["species"].map(species_colors)

# Create descriptive labels including species name
data["label"] = [f"{obs} ({sp})" for obs, sp in zip(data["observation"], data["species"], strict=True)]

# Grid positions for 12 faces (4 columns x 3 rows for better canvas utilization)
data["col"] = [i % 4 for i in range(12)]
data["row"] = [i // 4 for i in range(12)]
data["x_center"] = data["col"] * 200 + 130
data["y_center"] = (2 - data["row"]) * 240 + 160

# Calculate face feature dimensions based on variables with more pronounced variation
# face_width: sepal_length, face_height: sepal_width
# eye_size: petal_length, mouth_width: petal_width
# eyebrow_slant: derived from petal_length (maps to eyebrow angle)
data["face_width"] = 40 + data["sepal_length"] * 70  # 40-110
data["face_height"] = 50 + data["sepal_width"] * 80  # 50-130
data["eye_size"] = 6 + data["petal_length"] * 22  # 6-28
data["mouth_width"] = 15 + data["petal_width"] * 35  # 15-50
data["eyebrow_slant"] = -15 + data["petal_length"] * 30  # -15 to 15

# Build face components using layered shapes
face_records = []

for _, r in data.iterrows():
    xc, yc = r["x_center"], r["y_center"]
    fw, fh = r["face_width"], r["face_height"]
    es = r["eye_size"]
    mw = r["mouth_width"]
    eb_slant = r["eyebrow_slant"]

    # Face outline - single smooth ellipse using many small points on perimeter
    # This creates a clean ellipse shape instead of blobby overlapping circles
    for angle in np.linspace(0, 2 * np.pi, 48, endpoint=False):
        px = xc + (fw * 0.9) * np.cos(angle)
        py = yc + (fh * 0.75) * np.sin(angle)
        face_records.append(
            {
                "x": px,
                "y": py,
                "size": 350,
                "color": r["color"],
                "part": "outline",
                "observation": r["observation"],
                "species": r["species"],
                "opacity": 0.7,
            }
        )

    # Face fill - concentric rings of points to fill the ellipse smoothly
    for scale in [0.8, 0.6, 0.4, 0.2]:
        for angle in np.linspace(0, 2 * np.pi, int(36 * scale) + 8, endpoint=False):
            px = xc + (fw * 0.9 * scale) * np.cos(angle)
            py = yc + (fh * 0.75 * scale) * np.sin(angle)
            face_records.append(
                {
                    "x": px,
                    "y": py,
                    "size": 400,
                    "color": r["color"],
                    "part": "face_fill",
                    "observation": r["observation"],
                    "species": r["species"],
                    "opacity": 0.5,
                }
            )

    # Center fill point
    face_records.append(
        {
            "x": xc,
            "y": yc,
            "size": 600,
            "color": r["color"],
            "part": "face_fill",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.5,
        }
    )

    # Left eyebrow (line represented by two points)
    eyebrow_color = INK_SOFT
    face_records.append(
        {
            "x": xc - fw * 0.38,
            "y": yc + fh * 0.32 + eb_slant * 0.3,
            "size": 120,
            "color": eyebrow_color,
            "part": "eyebrow",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.9,
        }
    )
    face_records.append(
        {
            "x": xc - fw * 0.22,
            "y": yc + fh * 0.32 - eb_slant * 0.3,
            "size": 120,
            "color": eyebrow_color,
            "part": "eyebrow",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.9,
        }
    )
    # Right eyebrow
    face_records.append(
        {
            "x": xc + fw * 0.22,
            "y": yc + fh * 0.32 - eb_slant * 0.3,
            "size": 120,
            "color": eyebrow_color,
            "part": "eyebrow",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.9,
        }
    )
    face_records.append(
        {
            "x": xc + fw * 0.38,
            "y": yc + fh * 0.32 + eb_slant * 0.3,
            "size": 120,
            "color": eyebrow_color,
            "part": "eyebrow",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.9,
        }
    )
    # Left eye
    face_records.append(
        {
            "x": xc - fw * 0.30,
            "y": yc + fh * 0.15,
            "size": es * 45,
            "color": INK,
            "part": "eye",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 1.0,
        }
    )
    # Right eye
    face_records.append(
        {
            "x": xc + fw * 0.30,
            "y": yc + fh * 0.15,
            "size": es * 45,
            "color": INK,
            "part": "eye",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 1.0,
        }
    )
    # Left pupil (white/light highlight)
    pupil_color = PAGE_BG if THEME == "light" else INK_SOFT
    face_records.append(
        {
            "x": xc - fw * 0.30 + 3,
            "y": yc + fh * 0.15 + 3,
            "size": es * 12,
            "color": pupil_color,
            "part": "pupil",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.95,
        }
    )
    # Right pupil (white/light highlight)
    face_records.append(
        {
            "x": xc + fw * 0.30 + 3,
            "y": yc + fh * 0.15 + 3,
            "size": es * 12,
            "color": pupil_color,
            "part": "pupil",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.95,
        }
    )
    # Nose
    nose_color = INK_MUTED
    face_records.append(
        {
            "x": xc,
            "y": yc - fh * 0.05,
            "size": 90,
            "color": nose_color,
            "part": "nose",
            "observation": r["observation"],
            "species": r["species"],
            "opacity": 0.7,
        }
    )
    # Mouth - using horizontal ellipse shape for better representation
    mouth_color = IMPRINT[1] if THEME == "light" else IMPRINT[4]
    mouth_y = yc - fh * 0.30
    for dx in np.linspace(-mw * 0.4, mw * 0.4, 7):
        # Parabolic curve for mouth (smiling effect based on width)
        dy = -(dx**2) / (mw * 1.2) + mw * 0.08
        face_records.append(
            {
                "x": xc + dx,
                "y": mouth_y + dy,
                "size": 80 if abs(dx) < mw * 0.3 else 50,
                "color": mouth_color,
                "part": "mouth",
                "observation": r["observation"],
                "species": r["species"],
                "opacity": 0.9,
            }
        )

face_df = pd.DataFrame(face_records)

# Reorder facial features drawing order
part_order = {"outline": 0, "face_fill": 1, "eyebrow": 2, "nose": 3, "mouth": 4, "eye": 5, "pupil": 6}
face_df["order"] = face_df["part"].map(part_order)
face_df = face_df.sort_values("order")

# Create labels for each face - positioned below faces with descriptive text
label_df = data[["x_center", "y_center", "label", "face_height"]].copy()
label_df["y_label"] = label_df["y_center"] - label_df["face_height"] * 0.7 - 35

# Face features chart (includes outline, fill, and features)
features = (
    alt.Chart(face_df)
    .mark_point(filled=True)
    .encode(
        x=alt.X("x:Q", axis=None, scale=alt.Scale(domain=[0, 900])),
        y=alt.Y("y:Q", axis=None, scale=alt.Scale(domain=[0, 800])),
        size=alt.Size("size:Q", legend=None, scale=alt.Scale(range=[40, 1600])),
        color=alt.Color("color:N", legend=None, scale=None),
        opacity=alt.Opacity("opacity:Q", legend=None),
        order="order:O",
    )
)

# Labels with species info
labels = (
    alt.Chart(label_df)
    .mark_text(fontSize=13, fontWeight="bold", color=INK_SOFT)
    .encode(x=alt.X("x_center:Q", axis=None), y=alt.Y("y_label:Q", axis=None), text="label:N")
)

# Legend for species (positioned on right side, higher up to avoid overlap)
legend_data = pd.DataFrame(
    {
        "species": ["setosa", "versicolor", "virginica"],
        "x": [850, 850, 850],
        "y": [780, 730, 680],
        "color": [IMPRINT[0], IMPRINT[1], IMPRINT[2]],
    }
)

legend_points = (
    alt.Chart(legend_data)
    .mark_point(filled=True, size=600, opacity=0.5)
    .encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), color=alt.Color("color:N", scale=None, legend=None))
)

legend_text = (
    alt.Chart(legend_data)
    .mark_text(align="right", fontSize=14, dx=-25, fontWeight="bold", color=INK_SOFT)
    .encode(x="x:Q", y="y:Q", text="species:N")
)

# Feature mapping explanation - positioned at top left to avoid overlap with faces
mapping_data = pd.DataFrame(
    {
        "text": [
            "Feature Mapping:",
            "Face width ← sepal length",
            "Face height ← sepal width",
            "Eye size ← petal length",
            "Mouth width ← petal width",
            "Eyebrow slant ← petal length",
        ],
        "x": [30, 30, 30, 30, 30, 30],
        "y": [785, 760, 735, 710, 685, 660],
    }
)

mapping_text = (
    alt.Chart(mapping_data)
    .mark_text(align="left", fontSize=12, color=INK_MUTED)
    .encode(x="x:Q", y="y:Q", text="text:N")
)

# Combine all layers
chart = (
    (features + labels + legend_points + legend_text + mapping_text)
    .properties(
        width=1600,
        height=900,
        background=PAGE_BG,
        title=alt.Title(
            "chernoff-basic · altair · anyplot.ai",
            fontSize=28,
            anchor="middle",
            color=INK,
            subtitle="Iris Dataset: Each face represents a flower sample with features encoding measurements",
            subtitleFontSize=16,
            subtitleColor=INK_SOFT,
        ),
    )
    .configure_view(strokeWidth=0, fill=PAGE_BG)
)

# Save as PNG and HTML
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
chart.save(f"plot-{THEME}.html")

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

Other implementations