Chernoff Faces for Multivariate Data — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_rect,
    element_text,
    geom_path,
    geom_polygon,
    geom_text,
    ggplot,
    ggsave,
    ggsize,
    labs,
    scale_fill_manual,
    theme,
)
from sklearn.datasets import load_iris


LetsPlot.setup_html()

# 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"

# Data - Iris dataset
np.random.seed(42)
iris = load_iris()
df = pd.DataFrame(iris.data, columns=["sepal_length", "sepal_width", "petal_length", "petal_width"])
df["species"] = [iris.target_names[i] for i in iris.target]

# Sample 12 flowers (4 per species)
sample_idx = []
for species in range(3):
    species_idx = np.where(iris.target == species)[0]
    sample_idx.extend(np.random.choice(species_idx, 4, replace=False))
df_sample = df.iloc[sample_idx].reset_index(drop=True)

# Normalize data to 0-1 range
features = ["sepal_length", "sepal_width", "petal_length", "petal_width"]
for col in features:
    min_val = df_sample[col].min()
    max_val = df_sample[col].max()
    df_sample[col + "_norm"] = (df_sample[col] - min_val) / (max_val - min_val)

# Generate faces in a grid
grid_rows = 3
grid_cols = 4
all_face_data = []
label_data = []
species_colors = {"setosa": "#009E73", "versicolor": "#C475FD", "virginica": "#4467A3"}

for idx, row in df_sample.iterrows():
    col = idx % grid_cols
    row_pos = idx // grid_cols
    center_x = col + 0.5
    center_y = (grid_rows - 1 - row_pos) + 0.5

    sepal_len = row["sepal_length_norm"]
    sepal_wid = row["sepal_width_norm"]
    petal_len = row["petal_length_norm"]
    petal_wid = row["petal_width_norm"]

    scale = 0.42
    face_data = []

    # Face outline (ellipse)
    face_width = 0.35 + 0.2 * sepal_len
    face_height = 0.45
    theta = np.linspace(0, 2 * np.pi, 50)
    face_x = center_x + scale * face_width * np.cos(theta)
    face_y = center_y + scale * face_height * np.sin(theta)
    for i in range(len(theta)):
        face_data.append({"x": face_x[i], "y": face_y[i], "part": "face", "order": i})

    # Eyes
    eye_size = 0.03 + 0.04 * sepal_wid
    eye_y = center_y + scale * 0.12
    eye_spacing = 0.12

    # Left eye
    theta_eye = np.linspace(0, 2 * np.pi, 20)
    left_eye_x = center_x - scale * eye_spacing + scale * eye_size * np.cos(theta_eye)
    left_eye_y = eye_y + scale * eye_size * np.sin(theta_eye)
    for i in range(len(theta_eye)):
        face_data.append({"x": left_eye_x[i], "y": left_eye_y[i], "part": "left_eye", "order": i})

    # Right eye
    right_eye_x = center_x + scale * eye_spacing + scale * eye_size * np.cos(theta_eye)
    right_eye_y = eye_y + scale * eye_size * np.sin(theta_eye)
    for i in range(len(theta_eye)):
        face_data.append({"x": right_eye_x[i], "y": right_eye_y[i], "part": "right_eye", "order": i})

    # Pupils
    pupil_size = eye_size * 0.4
    left_pupil_x = center_x - scale * eye_spacing + scale * pupil_size * np.cos(theta_eye)
    left_pupil_y = eye_y + scale * pupil_size * np.sin(theta_eye)
    for i in range(len(theta_eye)):
        face_data.append({"x": left_pupil_x[i], "y": left_pupil_y[i], "part": "left_pupil", "order": i})

    right_pupil_x = center_x + scale * eye_spacing + scale * pupil_size * np.cos(theta_eye)
    right_pupil_y = eye_y + scale * pupil_size * np.sin(theta_eye)
    for i in range(len(theta_eye)):
        face_data.append({"x": right_pupil_x[i], "y": right_pupil_y[i], "part": "right_pupil", "order": i})

    # Mouth
    mouth_y = center_y - scale * 0.15
    mouth_width = 0.12
    curvature = -0.08 + 0.16 * petal_len
    mouth_x = np.linspace(-mouth_width, mouth_width, 20)
    mouth_curve_y = mouth_y + scale * curvature * (1 - (mouth_x / mouth_width) ** 2)
    mouth_curve_x = center_x + scale * mouth_x
    for i in range(len(mouth_x)):
        face_data.append({"x": mouth_curve_x[i], "y": mouth_curve_y[i], "part": "mouth", "order": i})

    # Eyebrows
    brow_y = center_y + scale * 0.22
    brow_slant = -0.03 + 0.06 * petal_wid
    brow_length = 0.06

    # Left eyebrow
    face_data.append(
        {
            "x": center_x - scale * (eye_spacing + brow_length),
            "y": brow_y - scale * brow_slant,
            "part": "left_brow",
            "order": 0,
        }
    )
    face_data.append(
        {
            "x": center_x - scale * (eye_spacing - brow_length),
            "y": brow_y + scale * brow_slant,
            "part": "left_brow",
            "order": 1,
        }
    )

    # Right eyebrow
    face_data.append(
        {
            "x": center_x + scale * (eye_spacing - brow_length),
            "y": brow_y + scale * brow_slant,
            "part": "right_brow",
            "order": 0,
        }
    )
    face_data.append(
        {
            "x": center_x + scale * (eye_spacing + brow_length),
            "y": brow_y - scale * brow_slant,
            "part": "right_brow",
            "order": 1,
        }
    )

    # Nose
    nose_top = center_y + scale * 0.02
    nose_bottom = center_y - scale * 0.08
    face_data.append({"x": center_x, "y": nose_top, "part": "nose", "order": 0})
    face_data.append({"x": center_x, "y": nose_bottom, "part": "nose", "order": 1})

    # Convert to DataFrame and add metadata
    face_df = pd.DataFrame(face_data)
    face_df["face_id"] = idx
    face_df["species"] = row["species"]
    all_face_data.append(face_df)

    # Add species label
    label_data.append({"x": center_x, "y": center_y - 0.45, "label": row["species"].title(), "species": row["species"]})

# Combine all face data
faces_df = pd.concat(all_face_data, ignore_index=True)
labels_df = pd.DataFrame(label_data)

# Separate face parts for layering
face_outline = faces_df[faces_df["part"] == "face"]
eyes = faces_df[faces_df["part"].isin(["left_eye", "right_eye"])]
pupils = faces_df[faces_df["part"].isin(["left_pupil", "right_pupil"])]
mouth = faces_df[faces_df["part"] == "mouth"]
brows = faces_df[faces_df["part"].isin(["left_brow", "right_brow"])]
nose = faces_df[faces_df["part"] == "nose"]

# Create plot with theme-adaptive styling
anyplot_theme = theme(
    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    panel_grid=element_blank(),
    axis_title=element_blank(),
    axis_text=element_blank(),
    axis_ticks=element_blank(),
    plot_title=element_text(size=24, face="bold", color=INK),
    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
    legend_title=element_text(size=18, color=INK),
    legend_text=element_text(size=16, color=INK_SOFT),
    legend_position="right",
    plot_margin=[40, 20, 20, 20],
)

plot = (
    ggplot()
    + geom_polygon(
        aes(x="x", y="y", group="face_id", fill="species"), data=face_outline, color=INK_SOFT, size=1.5, alpha=0.3
    )
    + geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=eyes, fill="white", color=INK_SOFT, size=1.0)
    + geom_polygon(aes(x="x", y="y", group=["face_id", "part"]), data=pupils, fill=INK, color=INK, size=0.5)
    + geom_path(aes(x="x", y="y", group="face_id"), data=mouth, color=INK, size=2.0)
    + geom_path(aes(x="x", y="y", group=["face_id", "part"]), data=brows, color=INK, size=2.5)
    + geom_path(aes(x="x", y="y", group="face_id"), data=nose, color=INK, size=1.5)
    + geom_text(aes(x="x", y="y", label="label"), data=labels_df, color=INK_SOFT, size=12, fontface="bold")
    + scale_fill_manual(values=species_colors)
    + labs(title="chernoff-basic · letsplot · anyplot.ai", fill="Species")
    + anyplot_theme
    + ggsize(1600, 900)
)

# Save outputs
ggsave(plot, f"plot-{THEME}.png", scale=3, path=".")
ggsave(plot, f"plot-{THEME}.html", path=".")

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

Other implementations