A time series plot that displays historical observed data followed by a forecast projection with confidence intervals or uncertainty bands. The plot clearly distinguishes between the historical period and the forecast period using a vertical line marker, with shaded bands representing different confidence levels (typically 80% and 95%). This visualization is essential for communicating prediction uncertainty in forecasting applications, helping stakeholders understand both the expected values and the range of possible outcomes.

""" anyplot.ai
timeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band
Library: plotnine 0.15.4 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-19
"""
import os
import numpy as np
import pandas as pd
from plotnine import (
aes,
annotate,
element_blank,
element_line,
element_rect,
element_text,
geom_line,
geom_ribbon,
geom_vline,
ggplot,
guide_legend,
guides,
labs,
scale_color_manual,
scale_fill_manual,
scale_x_datetime,
theme,
theme_minimal,
)
# 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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Okabe-Ito palette
IMPRINT = {"Historical": "#009E73", "Forecast": "#C475FD"}
# Data - Monthly electricity demand with forecast
np.random.seed(42)
# Historical period: 36 months
n_historical = 36
dates_historical = pd.date_range("2022-01-01", periods=n_historical, freq="MS")
# Base trend with seasonality
trend = np.linspace(100, 130, n_historical)
seasonality = 15 * np.sin(2 * np.pi * np.arange(n_historical) / 12)
noise = np.random.normal(0, 5, n_historical)
actual_values = trend + seasonality + noise
# Forecast period: 12 months
n_forecast = 12
dates_forecast = pd.date_range(dates_historical[-1] + pd.DateOffset(months=1), periods=n_forecast, freq="MS")
# Forecast with expanding uncertainty
forecast_trend = np.linspace(actual_values[-1], 145, n_forecast)
forecast_seasonality = 15 * np.sin(2 * np.pi * (np.arange(n_forecast) + n_historical) / 12)
forecast_values = forecast_trend + forecast_seasonality
# Confidence intervals widen over time
time_factor = np.sqrt(np.arange(1, n_forecast + 1))
ci_80 = 5 * time_factor
ci_95 = 10 * time_factor
# Build dataframe for historical data
df_historical = pd.DataFrame({"date": dates_historical, "value": actual_values, "series": "Historical"})
# Build dataframe for forecast
df_forecast = pd.DataFrame({"date": dates_forecast, "value": forecast_values, "series": "Forecast"})
# CI band dataframes with label column for legend mapping
df_ci_95 = pd.DataFrame(
{"date": dates_forecast, "ymin": forecast_values - ci_95, "ymax": forecast_values + ci_95, "ci": "95% CI"}
)
df_ci_80 = pd.DataFrame(
{"date": dates_forecast, "ymin": forecast_values - ci_80, "ymax": forecast_values + ci_80, "ci": "80% CI"}
)
# Forecast start date and annotation anchor
forecast_start = dates_forecast[0]
annotation_x = forecast_start + pd.DateOffset(months=1)
annotation_y = float(df_ci_95["ymax"].max()) + 5
# CI fill colors (same hue; alpha per-geom creates visual depth difference)
CI_COLORS = {"95% CI": IMPRINT["Forecast"], "80% CI": IMPRINT["Forecast"]}
# Plot
plot = (
ggplot()
# 95% confidence band (lighter, outer) — fill mapped for legend entry
+ geom_ribbon(data=df_ci_95, mapping=aes(x="date", ymin="ymin", ymax="ymax", fill="ci"), alpha=0.20)
# 80% confidence band (darker, inner) — fill mapped for legend entry
+ geom_ribbon(data=df_ci_80, mapping=aes(x="date", ymin="ymin", ymax="ymax", fill="ci"), alpha=0.30)
# Vertical line at forecast start
+ geom_vline(xintercept=forecast_start, linetype="dashed", color=INK_SOFT, size=0.8)
# Forecast period label offset right so text clears the vline
+ annotate("text", x=annotation_x, y=annotation_y, label="Forecast period", color=INK_MUTED, size=9, ha="left")
# Historical line
+ geom_line(data=df_historical, mapping=aes(x="date", y="value", color="series"), size=1.0)
# Forecast line (dashed)
+ geom_line(data=df_forecast, mapping=aes(x="date", y="value", color="series"), size=1.0, linetype="dashed")
# Color mapping for lines
+ scale_color_manual(values=IMPRINT)
# Fill mapping for CI bands with legend entries
+ scale_fill_manual(values=CI_COLORS, name="CI bands")
# Suppress "series" column header from color legend
+ guides(color=guide_legend(title=""))
# Labels
+ labs(
x="Date",
y="Electricity Demand (GWh)",
title="timeseries-forecast-uncertainty · python · plotnine · anyplot.ai",
subtitle="Shaded bands show 80% and 95% confidence intervals; uncertainty widens with forecast horizon",
)
# Date axis formatting
+ scale_x_datetime(date_breaks="6 months", date_labels="%b %Y")
# Theme
+ theme_minimal()
+ theme(
figure_size=(8, 4.5),
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_grid_major=element_line(color=INK, size=0.3, alpha=0.1),
panel_grid_minor=element_blank(),
axis_title=element_text(size=10, color=INK),
axis_text=element_text(size=8, color=INK_SOFT),
axis_text_x=element_text(angle=45, ha="right"),
axis_line=element_line(color=INK_SOFT, size=0.5),
plot_title=element_text(size=12, color=INK, weight="medium"),
plot_subtitle=element_text(size=9, color=INK_MUTED),
legend_position="top",
legend_background=element_rect(fill=ELEVATED_BG, color="none"),
legend_text=element_text(size=8, color=INK_SOFT),
legend_title=element_text(size=8, color=INK),
)
)
# Save
plot.save(f"plot-{THEME}.png", dpi=400, width=8, height=4.5, units="in", verbose=False)
Part of Time Series Forecast with Uncertainty Band on anyplot.ai.