{"spec_id":"timeseries-forecast-uncertainty","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ntimeseries-forecast-uncertainty: Time Series Forecast with Uncertainty Band\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-19\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, Range1d, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\"\n\nOKABE_ITO_1 = \"#009E73\"  # Bluish green (brand)\nOKABE_ITO_2 = \"#C475FD\"  # Vermillion\nOKABE_ITO_4 = \"#BD8233\"  # Reddish purple\n\n# Data - Monthly product sales with forecast\nnp.random.seed(42)\n\n# Historical data: 36 months (3 years)\nn_historical = 36\ndates_hist = pd.date_range(\"2022-01-01\", periods=n_historical, freq=\"MS\")\ntrend = np.linspace(80, 120, n_historical)\nseasonal = 15 * np.sin(np.linspace(0, 6 * np.pi, n_historical))\nnoise = np.random.normal(0, 5, n_historical)\nactual = trend + seasonal + noise\n\n# Forecast data: 12 months\nn_forecast = 12\ndates_forecast = pd.date_range(dates_hist[-1] + pd.DateOffset(months=1), periods=n_forecast, freq=\"MS\")\ntrend_forecast = np.linspace(120, 135, n_forecast)\nseasonal_forecast = 15 * np.sin(np.linspace(6 * np.pi, 8 * np.pi, n_forecast))\nforecast = trend_forecast + seasonal_forecast\n\n# Uncertainty grows over time\nuncertainty_80 = np.linspace(5, 15, n_forecast)\nuncertainty_95 = np.linspace(8, 25, n_forecast)\n\nlower_80 = forecast - uncertainty_80\nupper_80 = forecast + uncertainty_80\nlower_95 = forecast - uncertainty_95\nupper_95 = forecast + uncertainty_95\n\n# X-range with right padding (2 months past last forecast date)\nx_start = dates_hist[0]\nx_end = dates_forecast[-1] + pd.DateOffset(months=2)\nx_range = Range1d(start=x_start.timestamp() * 1000, end=x_end.timestamp() * 1000)\n\n# Create figure\np = figure(\n    width=3200,\n    height=1800,\n    title=\"timeseries-forecast-uncertainty · python · bokeh · anyplot.ai\",\n    x_axis_label=\"Date\",\n    y_axis_label=\"Sales (thousands)\",\n    x_axis_type=\"datetime\",\n    x_range=x_range,\n    toolbar_location=None,\n    min_border_bottom=180,\n    min_border_left=200,\n    min_border_top=120,\n    min_border_right=60,\n)\n\n# Style title and axes\np.title.text_font_size = \"56pt\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"36pt\"\np.yaxis.major_label_text_font_size = \"36pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\n\n# Background — outline=None removes the enclosing box (top/right spines equivalent)\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Axis styling — keep left and bottom lines (L-shaped frame)\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.axis_line_width = 2\np.yaxis.axis_line_width = 2\n\n# Grid styling - subtle y-axis only\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK_SOFT\np.ygrid.grid_line_alpha = 0.10\np.ygrid.grid_line_width = 1\n\n# 95% confidence band (lighter, drawn first)\nsource_95 = ColumnDataSource(\n    data={\"x\": np.concatenate([dates_forecast, dates_forecast[::-1]]), \"y\": np.concatenate([upper_95, lower_95[::-1]])}\n)\nband_95 = p.patch(x=\"x\", y=\"y\", source=source_95, fill_color=OKABE_ITO_4, fill_alpha=0.15, line_color=None)\n\n# 80% confidence band (darker, drawn on top)\nsource_80 = ColumnDataSource(\n    data={\"x\": np.concatenate([dates_forecast, dates_forecast[::-1]]), \"y\": np.concatenate([upper_80, lower_80[::-1]])}\n)\nband_80 = p.patch(x=\"x\", y=\"y\", source=source_80, fill_color=OKABE_ITO_4, fill_alpha=0.30, line_color=None)\n\n# Historical data line (solid) with hover data\nsource_hist = ColumnDataSource(data={\"x\": dates_hist, \"y\": actual})\nhist_line = p.line(x=\"x\", y=\"y\", source=source_hist, line_color=OKABE_ITO_1, line_width=3)\n\n# Forecast line (dashed) with CI data for hover tooltip\nsource_forecast = ColumnDataSource(\n    data={\n        \"x\": dates_forecast,\n        \"y\": forecast,\n        \"lower_80\": lower_80,\n        \"upper_80\": upper_80,\n        \"lower_95\": lower_95,\n        \"upper_95\": upper_95,\n    }\n)\nforecast_line = p.line(x=\"x\", y=\"y\", source=source_forecast, line_color=OKABE_ITO_2, line_width=3, line_dash=\"dashed\")\n\n# Connection line from last historical point to first forecast point\nsource_connect = ColumnDataSource(data={\"x\": [dates_hist[-1], dates_forecast[0]], \"y\": [actual[-1], forecast[0]]})\np.line(x=\"x\", y=\"y\", source=source_connect, line_color=OKABE_ITO_2, line_width=3, line_dash=\"dashed\")\n\n# Vertical line at forecast start\nforecast_start = Span(\n    location=dates_hist[-1], dimension=\"height\", line_color=INK_SOFT, line_width=2, line_dash=\"dashed\"\n)\np.add_layout(forecast_start)\n\n# Annotation labelling the forecast region\nforecast_label = Label(\n    x=dates_forecast[0].timestamp() * 1000,\n    y=168,\n    x_units=\"data\",\n    y_units=\"data\",\n    text=\"Forecast ▶\",\n    text_color=INK_SOFT,\n    text_font_size=\"32pt\",\n    x_offset=20,\n)\np.add_layout(forecast_label)\n\n# HoverTool for historical data\nhover_hist = HoverTool(\n    renderers=[hist_line],\n    tooltips=[(\"Date\", \"@x{%b %Y}\"), (\"Sales\", \"@y{0.0}k\")],\n    formatters={\"@x\": \"datetime\"},\n    mode=\"vline\",\n)\np.add_tools(hover_hist)\n\n# HoverTool for forecast with confidence intervals\nhover_forecast = HoverTool(\n    renderers=[forecast_line],\n    tooltips=[\n        (\"Date\", \"@x{%b %Y}\"),\n        (\"Forecast\", \"@y{0.0}k\"),\n        (\"80% CI\", \"[@lower_80{0.0}, @upper_80{0.0}]k\"),\n        (\"95% CI\", \"[@lower_95{0.0}, @upper_95{0.0}]k\"),\n    ],\n    formatters={\"@x\": \"datetime\"},\n    mode=\"vline\",\n)\np.add_tools(hover_forecast)\n\n# Legend\nlegend = Legend(\n    items=[\n        (\"Historical Data\", [hist_line]),\n        (\"Forecast\", [forecast_line]),\n        (\"80% Confidence Interval\", [band_80]),\n        (\"95% Confidence Interval\", [band_95]),\n    ],\n    location=\"top_left\",\n)\n\nlegend.label_text_font_size = \"36pt\"\nlegend.label_text_color = INK_SOFT\nlegend.background_fill_color = ELEVATED_BG\nlegend.background_fill_alpha = 0.95\nlegend.border_line_color = INK_SOFT\nlegend.border_line_width = 2\nlegend.padding = 30\nlegend.spacing = 16\nlegend.glyph_width = 60\nlegend.glyph_height = 40\np.add_layout(legend)\n\n# Set y-axis range with room for confidence bands and annotation\np.y_range.start = 55\np.y_range.end = 175\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}