Tornado Diagram for Sensitivity Analysis — Matplotlib

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 Matplotlib

Python source (Matplotlib)

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

import os

import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import numpy as np


# Theme tokens — Imprint palette 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 — positions 1 and 2 for dual-series tornado
COLOR_LOW = "#009E73"  # Imprint position 1 — brand green (Low Scenario)
COLOR_HIGH = "#C475FD"  # Imprint position 2 — lavender   (High Scenario)

# Data — NPV sensitivity analysis for a capital investment project ($M)
base_npv = 12.5

parameters = [
    "Discount Rate",
    "Revenue Growth",
    "Initial CapEx",
    "Operating Costs",
    "Tax Rate",
    "Terminal Value",
    "Working Capital",
    "Inflation Rate",
    "Project Duration",
    "Salvage Value",
]
low_npv = np.array([17.0, 7.6, 14.7, 15.2, 14.0, 10.3, 13.2, 13.4, 11.4, 11.9])
high_npv = np.array([8.9, 18.4, 10.8, 10.1, 11.1, 15.6, 11.6, 11.7, 13.9, 13.1])

# Sort by total range — widest bar at top
total_range = np.abs(high_npv - low_npv)
sort_idx = np.argsort(total_range)
parameters = [parameters[i] for i in sort_idx]
low_npv = low_npv[sort_idx]
high_npv = high_npv[sort_idx]

low_delta = low_npv - base_npv
high_delta = high_npv - base_npv

y_pos = np.arange(len(parameters))
n = len(parameters)
top_k = 3

# Intensity gradient — wider bars are more saturated; floor at 0.55 keeps narrow bars visible
sorted_range = np.abs(high_npv - low_npv)
range_norm = sorted_range / sorted_range.max()
alphas = 0.55 + 0.45 * range_norm

# Plot
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

for i in range(n):
    ax.barh(
        y_pos[i],
        low_delta[i],
        left=base_npv,
        height=0.62,
        color=COLOR_LOW,
        alpha=alphas[i],
        label="Low Scenario" if i == n - 1 else None,
        edgecolor=PAGE_BG,
        linewidth=0.5,
    )
    ax.barh(
        y_pos[i],
        high_delta[i],
        left=base_npv,
        height=0.62,
        color=COLOR_HIGH,
        alpha=alphas[i],
        label="High Scenario" if i == n - 1 else None,
        edgecolor=PAGE_BG,
        linewidth=0.5,
    )

# Bar-end value labels with PathEffects halos for legibility
label_halo = [pe.withStroke(linewidth=2.5, foreground=PAGE_BG)]

for i in range(n):
    is_top = i >= n - top_k
    lsize = 9 if is_top else 8
    lweight = "bold" if is_top else "medium"

    # Wider nudge for narrow bars so label pairs don't crowd together
    nudge = 0.30 if sorted_range[i] < 1.5 else 0.12

    lx = low_npv[i]
    lo = -nudge if low_delta[i] < 0 else nudge
    lh = "right" if low_delta[i] < 0 else "left"
    ax.text(
        lx + lo,
        y_pos[i],
        f"${lx:.1f}M",
        va="center",
        ha=lh,
        fontsize=lsize,
        fontweight=lweight,
        color=COLOR_LOW,
        path_effects=label_halo,
    )

    hx = high_npv[i]
    ho = nudge if high_delta[i] > 0 else -nudge
    hh = "left" if high_delta[i] > 0 else "right"
    ax.text(
        hx + ho,
        y_pos[i],
        f"${hx:.1f}M",
        va="center",
        ha=hh,
        fontsize=lsize,
        fontweight=lweight,
        color=COLOR_HIGH,
        path_effects=label_halo,
    )

# Base case reference line with styled annotation box (FancyBboxPatch via bbox kwarg)
ax.axvline(x=base_npv, color=INK_SOFT, linewidth=1.5, linestyle="-", zorder=3)
ax.text(
    base_npv + 0.10,
    n - 0.65,
    f"Base: ${base_npv:.1f}M",
    fontsize=8,
    fontweight="bold",
    color=INK,
    ha="left",
    va="center",
    bbox={"facecolor": ELEVATED_BG, "edgecolor": INK_SOFT, "alpha": 0.9, "boxstyle": "round,pad=0.3"},
)

# X-axis dollar tick formatting
ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:.0f}M"))

# Style — bold y-tick labels for top drivers
ax.set_yticks(y_pos)
ytick_labels = ax.set_yticklabels(parameters, fontsize=8, color=INK_SOFT)
for i in range(n):
    if i >= n - top_k:
        ytick_labels[i].set_fontweight("bold")
        ytick_labels[i].set_fontsize(9)
        ytick_labels[i].set_color(INK)

title = "bar-tornado-sensitivity · python · matplotlib · anyplot.ai"
title_fs = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12

ax.set_xlabel("Net Present Value ($M)", fontsize=10, color=INK)
ax.set_title(title, fontsize=title_fs, fontweight="medium", color=INK)
ax.tick_params(axis="x", labelsize=8, colors=INK_SOFT, labelcolor=INK_SOFT)
ax.tick_params(axis="y", length=0, colors=INK_SOFT, labelcolor=INK_SOFT)

leg = ax.legend(fontsize=8, loc="lower right", frameon=True)
if leg:
    leg.get_frame().set_facecolor(ELEVATED_BG)
    leg.get_frame().set_edgecolor(INK_SOFT)
    plt.setp(leg.get_texts(), color=INK_SOFT)

ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_color(INK_SOFT)
ax.xaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK)

# Tight x-limits — minimize excess padding while leaving room for bar-end labels
x_min = min(low_npv.min(), high_npv.min())
x_max = max(low_npv.max(), high_npv.max())
x_span = x_max - x_min
ax.set_xlim(x_min - 0.14 * x_span, x_max + 0.14 * x_span)

fig.subplots_adjust(left=0.20, right=0.96, top=0.93, bottom=0.12)

# Save
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

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

Other implementations