Tornado Diagram for Sensitivity Analysis — lets-plot

A horizontal bar chart where bars are sorted by influence magnitude, extending left and right from a base case vertical reference line. Each bar represents one input parameter and shows how varying that parameter between its low and high values affects the output, creating a characteristic tornado shape (widest bars at top, narrowest at bottom). Dual colors distinguish low-input from high-input effects, making it immediately clear which parameters drive the most uncertainty.

Tornado Diagram for Sensitivity Analysis rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
bar-tornado-sensitivity: Tornado Diagram for Sensitivity Analysis
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 90/100 | Updated: 2026-06-02
"""

import os

import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    flavor_high_contrast_dark,
    flavor_high_contrast_light,
    geom_bar,
    geom_text,
    geom_vline,
    ggplot,
    ggsave,
    ggsize,
    labs,
    layer_tooltips,
    scale_fill_manual,
    scale_x_continuous,
    theme,
    theme_minimal,
)


LetsPlot.setup_html()

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

# Imprint palette — semantic exception: gain/loss for financial sensitivity
# High Scenario (upper NPV bound) → #009E73 brand green (profit/gain)
# Low Scenario  (lower NPV bound) → #AE3030 matte red  (loss/downside)
COLOR_HIGH = "#009E73"
COLOR_LOW = "#AE3030"

# Data — NPV sensitivity analysis for a capital investment project
base_npv = 12.5  # Base case NPV in $M

parameters = [
    "Discount Rate",
    "Revenue Growth",
    "Material Cost",
    "Labor Cost",
    "Sales Volume",
    "Tax Rate",
    "Salvage Value",
    "Operating Expenses",
    "Inflation Rate",
    "Capacity Utilization",
]

low_values = [16.8, 8.2, 14.9, 13.8, 9.1, 14.2, 11.6, 13.9, 13.4, 10.8]
high_values = [9.1, 17.3, 10.4, 11.0, 16.2, 10.9, 13.5, 11.2, 11.7, 14.1]

df = pd.DataFrame({"parameter": parameters, "low_value": low_values, "high_value": high_values})

# Sort by total range (widest bar at top)
df["total_range"] = abs(df["high_value"] - df["low_value"])
df = df.sort_values("total_range", ascending=True).reset_index(drop=True)

# Build long-form data: each parameter gets two bars (low side and high side)
rows = []
for _, row in df.iterrows():
    low_side = min(row["low_value"], row["high_value"])
    high_side = max(row["low_value"], row["high_value"])
    low_delta = low_side - base_npv
    high_delta = high_side - base_npv

    low_nudge = low_delta - 0.25
    high_nudge = high_delta + 0.25

    rows.append(
        {
            "parameter": row["parameter"],
            "value": low_delta,
            "scenario": "Low Scenario",
            "label": f"{low_delta:+.1f}",
            "npv": f"${low_side:.1f}M",
            "label_x": low_nudge,
        }
    )
    rows.append(
        {
            "parameter": row["parameter"],
            "value": high_delta,
            "scenario": "High Scenario",
            "label": f"{high_delta:+.1f}",
            "npv": f"${high_side:.1f}M",
            "label_x": high_nudge,
        }
    )

plot_df = pd.DataFrame(rows)

# Preserve sorted order (ascending range = narrowest at bottom, widest at top)
param_order = df["parameter"].tolist()
plot_df["parameter"] = pd.Categorical(plot_df["parameter"], categories=param_order, ordered=True)

# Title — scale fontsize for 83-char title (default 16, floor 11)
TITLE = "NPV Sensitivity Analysis · bar-tornado-sensitivity · python · letsplot · anyplot.ai"
title_fs = max(11, round(16 * 67 / len(TITLE)))

flavor = flavor_high_contrast_light() if THEME == "light" else flavor_high_contrast_dark()

# Plot
plot = (
    ggplot(plot_df, aes(x="value", y="parameter", fill="scenario"))
    + geom_bar(
        stat="identity",
        width=0.7,
        alpha=0.92,
        position="identity",
        tooltips=layer_tooltips()
        .line("@parameter")
        .line("Scenario: @scenario")
        .line("NPV Impact: @label $M")
        .line("Resulting NPV: @npv"),
    )
    + geom_vline(xintercept=0, color=INK, size=1.4, linetype="solid")
    + geom_text(
        aes(x="label_x", label="label"),
        position="identity",
        hjust=1.1,
        size=4,
        color=INK,
        fontface="bold",
        data=plot_df[plot_df["scenario"] == "Low Scenario"],
    )
    + geom_text(
        aes(x="label_x", label="label"),
        position="identity",
        hjust=-0.1,
        size=4,
        color=INK,
        fontface="bold",
        data=plot_df[plot_df["scenario"] == "High Scenario"],
    )
    + scale_fill_manual(
        values=[COLOR_LOW, COLOR_HIGH],
        breaks=["Low Scenario", "High Scenario"],
        labels=["◀ Low Scenario", "High Scenario ▶"],
    )
    + scale_x_continuous(expand=[0.18, 0], format="{.1f}", breaks=[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5])
    + labs(
        x="Change in NPV ($M)",
        y="Input Parameter",
        title=TITLE,
        subtitle="One-at-a-time parameter variation from base case (NPV = $12.5M)",
        caption="Revenue Growth and Discount Rate account for over 50% of total NPV sensitivity",
        fill="",
    )
    + theme_minimal()
    + flavor
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        plot_title=element_text(size=title_fs, face="bold", hjust=0.5, color=INK),
        plot_subtitle=element_text(size=10, hjust=0.5, color=INK_SOFT),
        axis_title_x=element_text(size=12, margin=[10, 0, 0, 0], color=INK),
        axis_title_y=element_text(size=12, color=INK),
        axis_text_x=element_text(size=10, color=INK_SOFT),
        axis_text_y=element_text(size=10, color=INK_SOFT),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_title=element_blank(),
        legend_position="top",
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        panel_grid_major_y=element_blank(),
        panel_grid_minor=element_blank(),
        panel_grid_major_x=element_line(color=INK_SOFT, size=0.3),
        plot_caption=element_text(size=8, color=INK_MUTED, face="italic", hjust=0.5),
        plot_margin=[20, 30, 20, 10],
    )
    + ggsize(800, 450)  # 800 × 450 × scale=4 = 3200 × 1800 px (landscape)
)

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

Part of Tornado Diagram for Sensitivity Analysis on anyplot.ai.

Other implementations