{"spec_id":"timeseries-forecast-uncertainty","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-19\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nfrom matplotlib.patches import Patch\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito — pos 1 (historical), pos 2 (forecast), pos 6 (CI bands)\nHISTORICAL_COLOR = \"#009E73\"\nFORECAST_COLOR = \"#C475FD\"\nCI_COLOR = \"#2ABCCD\"\n\n# Data — monthly retail sales, 3-year history + 12-month ARIMA-style forecast\nnp.random.seed(42)\nn_historical = 36\nn_forecast = 12\noverlap = 1\nn_total = n_historical + n_forecast - overlap\n\ndates = pd.date_range(start=\"2022-01-01\", periods=n_total, freq=\"MS\")\n\nt = np.arange(n_historical)\ntrend = 100 + t * 1.2\nseasonality = 15 * np.sin(2 * np.pi * t / 12)\nnoise = np.random.normal(0, 5, n_historical)\nactual = trend + seasonality + noise\n\nforecast_start_idx = n_historical - overlap\nt_forecast = np.arange(n_forecast)\nforecast_trend = actual[forecast_start_idx] + t_forecast * 1.2\nforecast_seasonality = 15 * np.sin(2 * np.pi * (t[-1] - overlap + 1 + t_forecast) / 12)\nforecast_values = forecast_trend + forecast_seasonality\n\nuncertainty_growth = np.sqrt(1 + t_forecast * 0.5)\nbase_std = 8\nlower_80 = forecast_values - 1.28 * base_std * uncertainty_growth\nupper_80 = forecast_values + 1.28 * base_std * uncertainty_growth\nlower_95 = forecast_values - 1.96 * base_std * uncertainty_growth\nupper_95 = forecast_values + 1.96 * base_std * uncertainty_growth\n\nhistorical_dates = dates[:n_historical]\nforecast_dates = dates[forecast_start_idx:]\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\nax.set_axisbelow(True)\n\n# Subtle forecast region tint\nax.axvspan(forecast_dates[0], forecast_dates[-1], color=INK, alpha=0.03, zorder=0)\n\n# CI bands — outer 95% first, then 80% sits on top (correct nesting)\nax.fill_between(forecast_dates, lower_95, upper_95, color=CI_COLOR, alpha=0.20, zorder=1)\nax.fill_between(forecast_dates, lower_80, upper_80, color=CI_COLOR, alpha=0.30, zorder=2)\n\n# Historical solid line\n(hist_line,) = ax.plot(\n    historical_dates,\n    actual,\n    color=HISTORICAL_COLOR,\n    linewidth=2.5,\n    solid_capstyle=\"round\",\n    zorder=4,\n    label=\"Historical Data\",\n)\n\n# Forecast dashed line\n(fc_line,) = ax.plot(\n    forecast_dates,\n    forecast_values,\n    color=FORECAST_COLOR,\n    linewidth=2.5,\n    linestyle=\"--\",\n    solid_capstyle=\"round\",\n    dash_capstyle=\"round\",\n    zorder=4,\n    label=\"Forecast\",\n)\n\n# Forecast start vertical marker\nax.axvline(x=forecast_dates[0], color=INK_SOFT, linewidth=1.2, linestyle=\":\", alpha=0.8, zorder=3)\n\n# Focal-point text label at the forecast boundary\ny_max = max(actual.max(), upper_95.max()) + 12\nax.text(forecast_dates[0], y_max, \"Forecast →\", color=INK_MUTED, fontsize=8, va=\"top\", ha=\"left\", fontstyle=\"italic\")\n\n# Peak marker — highlights the expected peak of the forecast\npeak_idx = np.argmax(forecast_values)\nax.plot(\n    forecast_dates[peak_idx],\n    forecast_values[peak_idx],\n    \"o\",\n    color=FORECAST_COLOR,\n    markersize=6,\n    zorder=6,\n    markeredgecolor=PAGE_BG,\n    markeredgewidth=1.5,\n)\n\n# Callout annotation at the forecast peak — surfaces the projected peak value\npeak_val = forecast_values[peak_idx]\nax.annotate(\n    f\"Peak ≈ {peak_val:.0f}k\",\n    xy=(forecast_dates[peak_idx], peak_val),\n    xytext=(0, 16),\n    textcoords=\"offset points\",\n    color=INK_MUTED,\n    fontsize=8,\n    ha=\"center\",\n    va=\"bottom\",\n    fontstyle=\"italic\",\n)\n\n# Style\nax.set_xlabel(\"Date\", fontsize=12, color=INK)\nax.set_ylabel(\"Monthly Sales (thousands)\", fontsize=12, color=INK)\nax.set_title(\n    \"timeseries-forecast-uncertainty · python · matplotlib · anyplot.ai\", fontsize=14, fontweight=\"medium\", color=INK\n)\nax.tick_params(axis=\"both\", labelsize=10, colors=INK_SOFT)\n\n# Date axis — semi-annual major ticks\ntick_dates = pd.date_range(start=dates[0], end=dates[-1], freq=\"6MS\")\nax.set_xticks(tick_dates)\nax.set_xticklabels([d.strftime(\"%b %Y\") for d in tick_dates], rotation=30, ha=\"right\")\n\n# Grid — y-axis only, very subtle\nax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK)\n\n# Spines\nfor spine in (\"top\", \"right\"):\n    ax.spines[spine].set_visible(False)\nfor spine in (\"left\", \"bottom\"):\n    ax.spines[spine].set_color(INK_SOFT)\n\n# Y-axis limits with room for the \"Forecast →\" label\ny_min = min(actual.min(), lower_95.min()) - 10\nax.set_ylim(y_min, y_max + 5)\n\n# X-axis padding: small right margin so the CI band doesn't clip at the edge\nax.set_xlim(dates[0] - pd.DateOffset(months=1), dates[-1] + pd.DateOffset(months=2))\n\n# Legend — correct order: Historical, Forecast, Forecast Start, 80% CI, 95% CI\n# Explicit Patch handles with visually distinct alphas so 80% vs 95% CI are clearly differentiable\nfc_start_proxy = plt.Line2D([0], [0], color=INK_SOFT, linestyle=\":\", linewidth=1.2, label=\"Forecast Start\")\nci_80_handle = Patch(facecolor=CI_COLOR, alpha=0.55, label=\"80% CI\")\nci_95_handle = Patch(facecolor=CI_COLOR, alpha=0.25, label=\"95% CI\")\nleg = ax.legend(\n    handles=[hist_line, fc_line, fc_start_proxy, ci_80_handle, ci_95_handle],\n    fontsize=10,\n    loc=\"upper left\",\n    framealpha=0.95,\n)\nif leg:\n    leg.get_frame().set_facecolor(ELEVATED_BG)\n    leg.get_frame().set_edgecolor(INK_SOFT)\n    plt.setp(leg.get_texts(), color=INK_SOFT)\n\nplt.tight_layout()\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, bbox_inches=\"tight\", facecolor=PAGE_BG)\n"}