How to display prediction intervals around a line in Plotly (with Code)
The Short Answer (TL;DR)
Draw lower and upper Scatter traces in increasing x order, then set fill="tonexty" on the upper trace to shade the interval between them. Plot the fitted mean separately and calculate prediction bounds using a model with stated sampling assumptions.
import plotly.graph_objects as go
x, lower, upper, fitted = [0, 1, 2], [0.5, 1.4, 2.2], [1.5, 2.6, 3.8], [1, 2, 3]
fig = go.Figure([go.Scatter(x=x, y=lower, mode="lines", line={"width": 0}, showlegend=False), go.Scatter(x=x, y=upper, fill="tonexty", mode="lines", name="Supplied prediction bounds"), go.Scatter(x=x, y=fitted, mode="lines", name="Fitted mean")]); fig.write_html("prediction-band.html", include_plotlyjs=True, auto_open=False)
Detailed Implementation
This example uses synthetic data and does not need a network connection. Install the recorded package versions in a separate Python environment before running it.
python -m pip install pandas==3.0.6 plotly==7.1.0 numpy==2.5.4 scipy==1.18.1
import numpy as np
from scipy.stats import t
import plotly.graph_objects as go
# Synthetic independent observations from a line with constant Gaussian noise.
rng = np.random.default_rng(29)
x = np.arange(1, 21, dtype=float)
y = 1.5 + 0.7 * x + rng.normal(scale=1.2, size=x.size)
n = x.size
slope, intercept = np.polyfit(x, y, 1)
residuals = y - (intercept + slope * x)
s = np.sqrt(np.sum(residuals**2) / (n - 2))
grid = np.linspace(x.min(), x.max(), 61)
fitted = intercept + slope * grid
sxx = np.sum((x - x.mean())**2)
prediction_se = s * np.sqrt(1 + 1/n + (grid - x.mean())**2 / sxx)
critical = t.ppf(0.975, df=n - 2)
lower, upper = fitted - critical * prediction_se, fitted + critical * prediction_se
fig = go.Figure()
fig.add_trace(go.Scatter(x=grid, y=lower, mode="lines", line={"width": 0},
showlegend=False, hoverinfo="skip"))
fig.add_trace(go.Scatter(x=grid, y=upper, mode="lines", line={"width": 0},
fill="tonexty", fillcolor="rgba(23,107,135,0.22)",
name="Pointwise 95% prediction interval"))
fig.add_trace(go.Scatter(x=grid, y=fitted, mode="lines", name="Fitted mean"))
fig.add_trace(go.Scatter(x=x, y=y, mode="markers", name="Observed data"))
fig.update_layout(template="plotly_white", xaxis_title="Synthetic input",
yaxis_title="Synthetic response", height=480,
title="Prediction interval for one new observation at a chosen x")
fig.write_html("prediction-band.html", include_plotlyjs=True, full_html=True,
auto_open=False, div_id="prediction-interval")
The chart contains 20 observed points, a fitted line and a shaded pointwise 95% prediction interval evaluated on 61 sorted inputs. The prediction interval is wider than the corresponding confidence interval for the mean.
Where do the prediction bounds come from?
The three-line answer demonstrates rendering with supplied example bounds; it does not estimate their coverage. The full example fits a simple line to 20 synthetic observations and computes pointwise 95% prediction intervals with a Student t critical value and 18 residual degrees of freedom.
How is this different from a confidence band for the mean?
A new observation includes its own residual noise as well as uncertainty in the fitted mean. The additional 1 under the square root accounts for that residual variation. Removing it gives an interval for the conditional mean under the same model, which is narrower.
Which assumptions support the stated 95% level?
The classical calculation assumes a correct linear mean, independent normally distributed errors with constant variance, and a new independent observation at a chosen x. The displayed band is pointwise, not simultaneous coverage of every future observation or the entire curve. Check residuals and the sampling design before applying this formula to real data; extrapolation adds model risk.
Mathematical Context
For simple linear regression with an intercept, s squared is the residual sum of squares divided by n-2. At a chosen input x0, the prediction standard error is s times the square root of 1+1/n+(x0-xbar)^2/Sxx, where Sxx is the sum of squared centered training inputs. Multiply by the 0.975 quantile of a t distribution with n-2 degrees of freedom for a two-sided pointwise 95% interval. Plotly draws the bounds supplied to it and does not infer their statistical validity.
\[ \hat y(x_0)\pm t_{0.975,n-2}\,s\sqrt{1+\frac{1}{n}+\frac{(x_0-\bar x)^2}{S_{xx}}} \]
Reproducibility
Executed with Python 3.12.14; pandas 3.0.6, plotly 7.1.0, numpy 2.5.4, scipy 1.18.1. Automated checks confirm the stated output properties.
Primary documentation
- Plotly fill between two traces
- NIST prediction intervals for a new response
- SciPy Student t quantiles