Chernoff Faces for Multivariate Data — ggplot2

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 ggplot2

Renders

R source (ggplot2)

#' anyplot.ai
#' chernoff-basic: Chernoff Faces for Multivariate Data
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 93/100 | Created: 2026-09-02

library(ggplot2)
library(dplyr)
library(tibble)
library(scales)
library(ragg)

set.seed(42)

# --- Theme tokens ------------------------------------------------------------
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
IMPRINT_PALETTE <- c("#009E73", "#C475FD", "#4467A3", "#BD8233",
                     "#AE3030", "#2ABCCD", "#954477", "#99B314")

# --- Data: mtcars performance metrics per model -------------------------------
cars <- c(
  "Mazda RX4", "Datsun 710", "Hornet Sportabout", "Duster 360",
  "Merc 240D", "Merc 280", "Cadillac Fleetwood", "Fiat 128",
  "Honda Civic", "Toyota Corolla", "Dodge Challenger", "Camaro Z28",
  "Porsche 914-2", "Ferrari Dino", "Volvo 142E"
)

faces <- rownames_to_column(mtcars, "car") |>
  filter(car %in% cars) |>
  mutate(
    car       = factor(car, levels = cars),
    cyl_group = factor(cyl, levels = c(4, 6, 8),
                        labels = c("4 cyl", "6 cyl", "8 cyl")),
    # Each performance metric is rescaled onto its own facial-feature range
    face_width  = rescale(mpg,  to = c(0.65, 1.05)),
    eye_size    = rescale(qsec, to = c(0.05, 0.12)),
    eye_spacing = rescale(hp,   to = c(0.22, 0.42)),
    brow_slant  = rescale(wt,   to = c(20, -20)),
    nose_length = rescale(disp, to = c(0.14, 0.32)),
    mouth_curve = rescale(drat, to = c(-1, 1)),
    mouth_width = rescale(carb, to = c(0.30, 0.50))
  ) |>
  arrange(car)

# --- Facial-feature geometry helpers ------------------------------------------
ellipse_pts <- function(cx, cy, rx, ry, n = 60) {
  t <- seq(0, 2 * pi, length.out = n)
  tibble(x = cx + rx * cos(t), y = cy + ry * sin(t))
}

mouth_pts <- function(cx, cy, half_width, curvature, amp = 0.14, n = 24) {
  t <- seq(-1, 1, length.out = n)
  tibble(x = cx + t * half_width, y = cy + curvature * amp * (t^2 - 1))
}

# --- Build one face's geometry per row, then stack into shared layers ---------
n_faces   <- nrow(faces)
brow_half <- 0.11
eye_y     <- 0.18

outline_list  <- vector("list", n_faces)
eyes_list     <- vector("list", n_faces)
pupils_list   <- vector("list", n_faces)
eyebrows_list <- vector("list", n_faces)
nose_list     <- vector("list", n_faces)
mouth_list    <- vector("list", n_faces)

for (i in seq_len(n_faces)) {
  row <- faces[i, ]

  outline_list[[i]] <- bind_cols(
    car = row$car, cyl_group = row$cyl_group,
    ellipse_pts(0, 0, row$face_width, 0.85)
  )

  eyes_list[[i]] <- bind_rows(
    bind_cols(car = row$car, side = "left",
              ellipse_pts(-row$eye_spacing, eye_y, row$eye_size, row$eye_size, n = 28)),
    bind_cols(car = row$car, side = "right",
              ellipse_pts( row$eye_spacing, eye_y, row$eye_size, row$eye_size, n = 28))
  )

  pupils_list[[i]] <- tibble(
    car = row$car,
    x   = c(-row$eye_spacing, row$eye_spacing),
    y   = c(eye_y, eye_y)
  )

  slant  <- row$brow_slant * pi / 180
  dy     <- brow_half * tan(slant)
  brow_y <- eye_y + row$eye_size + 0.07
  eyebrows_list[[i]] <- tibble(
    car  = row$car,
    x    = c(-row$eye_spacing - brow_half, row$eye_spacing - brow_half),
    xend = c(-row$eye_spacing + brow_half, row$eye_spacing + brow_half),
    y    = c(brow_y - dy, brow_y + dy),
    yend = c(brow_y + dy, brow_y - dy)
  )

  nose_list[[i]] <- tibble(
    car = row$car, x = 0, xend = 0,
    y = -0.02, yend = -0.02 - row$nose_length
  )

  mouth_list[[i]] <- bind_cols(
    car = row$car,
    mouth_pts(0, -0.55, row$mouth_width / 2, row$mouth_curve)
  )
}

outline_df  <- bind_rows(outline_list)
eyes_df     <- bind_rows(eyes_list)
pupils_df   <- bind_rows(pupils_list)
eyebrows_df <- bind_rows(eyebrows_list)
nose_df     <- bind_rows(nose_list)
mouth_df    <- bind_rows(mouth_list)

# --- Plot ----------------------------------------------------------------------
title_text     <- "Car Performance Profiles · chernoff-basic · r · ggplot2 · anyplot.ai"
title_fontsize <- round(12 * min(1, 67 / nchar(title_text)))

p <- ggplot() +
  geom_polygon(data = outline_df, aes(x, y, group = car, fill = cyl_group),
               color = INK_SOFT, linewidth = 0.35, alpha = 0.9) +
  geom_polygon(data = eyes_df, aes(x, y, group = interaction(car, side)),
               fill = PAGE_BG, color = INK, linewidth = 0.35) +
  geom_point(data = pupils_df, aes(x, y), color = INK, size = 1.6) +
  geom_segment(data = eyebrows_df, aes(x = x, xend = xend, y = y, yend = yend),
               color = INK, linewidth = 0.9, lineend = "round") +
  geom_segment(data = nose_df, aes(x = x, xend = xend, y = y, yend = yend),
               color = INK, linewidth = 0.7, lineend = "round") +
  geom_path(data = mouth_df, aes(x, y, group = car),
            color = INK, linewidth = 0.9, lineend = "round") +
  scale_fill_manual(values = IMPRINT_PALETTE[1:3], name = "Cylinders") +
  coord_equal(xlim = c(-1.15, 1.15), ylim = c(-1.05, 1.05), expand = FALSE) +
  facet_wrap(~ car, ncol = 5, labeller = label_wrap_gen(width = 11)) +
  labs(title = title_text) +
  theme_void(base_size = 8) +
  theme(
    plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),
    panel.background  = element_rect(fill = PAGE_BG, color = NA),
    panel.spacing     = unit(1.2, "lines"),
    plot.title        = element_text(color = INK, size = title_fontsize, hjust = 0.5,
                                      margin = margin(b = 14)),
    strip.text        = element_text(color = INK_SOFT, size = 9, lineheight = 0.9,
                                      margin = margin(b = 4)),
    legend.position    = "bottom",
    legend.title       = element_text(color = INK, size = 10),
    legend.text        = element_text(color = INK_SOFT, size = 9),
    legend.background  = element_rect(fill = PAGE_BG, color = NA),
    legend.key         = element_rect(fill = PAGE_BG, color = NA),
    plot.margin        = margin(20, 24, 20, 24)
  )

# --- Save ------------------------------------------------------------------
ggsave(
  filename = sprintf("plot-%s.png", THEME),
  plot     = p,
  device   = ragg::agg_png,
  width    = 6,
  height   = 6,
  units    = "in",
  dpi      = 400
)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/chernoff-basic/ggplot2/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "chernoff-basic",
  "language": "r",
  "library": "ggplot2",
  "page": "https://anyplot.ai/chernoff-basic/r/ggplot2",
  "hub": "https://anyplot.ai/chernoff-basic",
  "code_json": "https://api.anyplot.ai/specs/chernoff-basic/ggplot2/code",
  "spec_json": "https://api.anyplot.ai/specs/chernoff-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/chernoff-basic/r/ggplot2/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/chernoff-basic/r/ggplot2/plot-dark.png",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations