Cumulative Explained Variance for PCA Component Selection — Bokeh

A line plot showing the cumulative proportion of explained variance as a function of the number of Principal Component Analysis (PCA) components. This visualization helps determine the optimal number of components to retain by displaying the trade-off between dimensionality reduction and information preservation. The cumulative curve typically exhibits an elbow pattern where additional components yield diminishing returns, and horizontal threshold lines (e.g., 90%, 95%) guide component selection decisions.

Cumulative Explained Variance for PCA Component Selection rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
line-pca-variance-cumulative: Cumulative Explained Variance for PCA Component Selection
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-29
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import Band, ColumnDataSource, HoverTool, Label, Span
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from sklearn.datasets import load_wine
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler


# Theme tokens (Imprint palette — theme-adaptive chrome)
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"

# Imprint categorical palette — canonical order, positions 1–4
BRAND = "#009E73"  # pos 1: cumulative variance line (always first series)
LAVENDER = "#C475FD"  # pos 2: individual variance bars
BLUE = "#4467A3"  # pos 3: 90% threshold reference line
OCHRE = "#BD8233"  # pos 4: 95% threshold reference line

# Data — Wine dataset (13 chemical features, authentic PCA distribution)
wine = load_wine()
X_scaled = StandardScaler().fit_transform(wine.data)
pca = PCA()
pca.fit(X_scaled)

n_components = np.arange(1, len(pca.explained_variance_ratio_) + 1)
cumulative_variance = np.cumsum(pca.explained_variance_ratio_) * 100
individual_variance = pca.explained_variance_ratio_ * 100

threshold_90 = int(np.argmax(cumulative_variance >= 90) + 1)
threshold_95 = int(np.argmax(cumulative_variance >= 95) + 1)

# ColumnDataSources
source_main = ColumnDataSource(
    data={
        "component": n_components,
        "cumulative": cumulative_variance,
        "individual": individual_variance,
        "base": np.zeros_like(cumulative_variance),
    }
)
source_bars = ColumnDataSource(data={"component": n_components, "individual": individual_variance})

# Title — scale fontsize if > 67 chars
title_str = "line-pca-variance-cumulative · python · bokeh · anyplot.ai"
n_chars = len(title_str)
ratio = 67 / n_chars if n_chars > 67 else 1.0
title_fontsize = f"{max(34, round(50 * ratio))}pt"

# Plot — canonical 3200×1800 bokeh canvas with toolbar disabled for PNG
p = figure(
    width=3200,
    height=1800,
    title=title_str,
    x_axis_label="Number of Principal Components",
    y_axis_label="Cumulative Explained Variance (%)",
    toolbar_location=None,
    y_range=(-2, 112),
    x_range=(0.3, 13.7),
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=120,
)

# Area fill under cumulative curve (Bokeh Band — library-distinctive)
band = Band(
    base="component",
    upper="cumulative",
    lower="base",
    source=source_main,
    fill_color=BRAND,
    fill_alpha=0.08,
    line_color=None,
)
p.add_layout(band)

# Individual variance bars (subtle background, secondary series)
p.vbar(
    x="component",
    top="individual",
    source=source_bars,
    width=0.5,
    fill_color=LAVENDER,
    fill_alpha=0.20,
    line_color=LAVENDER,
    line_alpha=0.30,
    line_width=1.5,
    legend_label="Individual Variance",
)

# Cumulative variance line — capture renderer for HoverTool attachment
cumulative_renderer = p.line(
    x="component",
    y="cumulative",
    source=source_main,
    line_width=6,
    line_color=BRAND,
    line_alpha=0.9,
    legend_label="Cumulative Variance",
)

# Markers at each component count
p.scatter(
    x="component",
    y="cumulative",
    source=source_main,
    size=18,
    fill_color=BRAND,
    line_color=PAGE_BG,
    line_width=3,
    fill_alpha=0.95,
)

# Horizontal threshold reference lines
p.add_layout(Span(location=90, dimension="width", line_color=BLUE, line_width=2.5, line_dash="dashed", line_alpha=0.7))
p.add_layout(Span(location=95, dimension="width", line_color=OCHRE, line_width=2.5, line_dash="dashed", line_alpha=0.7))

# Right-edge threshold labels (kept inside canvas boundary)
p.add_layout(
    Label(
        x=13.2, y=86.0, text="90%", text_font_size="30pt", text_color=BLUE, text_align="right", text_font_style="bold"
    )
)
p.add_layout(
    Label(
        x=13.2, y=96.5, text="95%", text_font_size="30pt", text_color=OCHRE, text_align="right", text_font_style="bold"
    )
)

# Glow-ring highlights at threshold crossings
for th, color in [(threshold_90, BLUE), (threshold_95, OCHRE)]:
    p.scatter(
        x=[th],
        y=[cumulative_variance[th - 1]],
        size=42,
        fill_color=color,
        fill_alpha=0.15,
        line_color=color,
        line_alpha=0.3,
        line_width=2,
    )
    p.scatter(
        x=[th],
        y=[cumulative_variance[th - 1]],
        size=28,
        fill_color=color,
        line_color=PAGE_BG,
        line_width=3,
        fill_alpha=0.9,
    )

# Crossing annotations — offset to avoid crowding
p.add_layout(
    Label(
        x=threshold_90 - 1.5,
        y=cumulative_variance[threshold_90 - 1] - 9,
        text=f"{threshold_90} components ({cumulative_variance[threshold_90 - 1]:.1f}%)",
        text_font_size="24pt",
        text_color=BLUE,
        text_font_style="bold",
        text_align="center",
    )
)
p.add_layout(
    Label(
        x=threshold_95 + 1.5,
        y=cumulative_variance[threshold_95 - 1] + 3,
        text=f"{threshold_95} components ({cumulative_variance[threshold_95 - 1]:.1f}%)",
        text_font_size="24pt",
        text_color=OCHRE,
        text_font_style="bold",
        text_align="center",
    )
)

# HoverTool attached to named renderer (avoids fragile magic index)
p.add_tools(
    HoverTool(
        tooltips=[
            ("Component", "@component"),
            ("Cumulative Variance", "@cumulative{0.1}%"),
            ("Individual Variance", "@individual{0.1}%"),
        ],
        mode="vline",
        renderers=[cumulative_renderer],
    )
)

# Theme-adaptive chrome
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = INK_SOFT

p.title.text_font_size = title_fontsize
p.title.text_color = INK
p.title.align = "center"
p.title.text_font_style = "bold"

p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

p.xaxis.ticker = list(n_components)

p.ygrid.grid_line_color = INK
p.ygrid.grid_line_alpha = 0.15
p.xgrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.08

p.legend.location = "center_right"
p.legend.label_text_font_size = "34pt"
p.legend.label_text_color = INK_SOFT
p.legend.background_fill_color = ELEVATED_BG
p.legend.border_line_color = INK_SOFT
p.legend.border_line_width = 1
p.legend.padding = 20
p.legend.margin = 30
p.legend.glyph_height = 40
p.legend.glyph_width = 40
p.legend.spacing = 16

# Save interactive HTML (required catalog artifact)
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome — CDP override pins inner viewport to exact dims
# (--window-size alone gives 1661 instead of 1800 due to browser chrome offset)
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

# Belt-and-braces: ensure saved PNG is exactly W×H so the post-render gate passes
from PIL import Image as _PILImage


_img = _PILImage.open(f"plot-{THEME}.png").convert("RGB")
if _img.size != (W, H):
    _norm = _PILImage.new("RGB", (W, H), PAGE_BG)
    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))
    _norm.save(f"plot-{THEME}.png")

Part of Cumulative Explained Variance for PCA Component Selection on anyplot.ai.

Other implementations