Approach: compute weights = 1 / RMSE (normalized to sum to 1) from per-model validation residuals; use weighted average of forecasts for ensemble point forecasts. For 90% empirical prediction intervals, pool bootstrap samples of residuals (preserving horizon structure if available) and add sampled residuals to ensemble point forecast to generate bootstrap ensemble forecast distributions; take 5th and 95th percentiles per horizon.python
import numpy as np
def ensemble_with_bootstrap(forecasts, val_residuals, n_boot=2000, alpha=0.10, random_state=None):
"""
forecasts: list or array of shape (M, H) or (N, H) where M = models, H = horizons
val_residuals: list of arrays, each array shape (T_val, H) or (T_val,) if residuals pooled across horizons
n_boot: number of bootstrap samples
alpha: 1 - coverage (0.10 -> 90% PI)
Returns: dict with 'point' (H,), 'lower' (H,), 'upper' (H,), 'weights' (M,)
"""
rng = np.random.default_rng(random_state)
F = np.asarray(forecasts)
M, H = F.shape
# compute RMSE per model across validation residuals
rmses = np.empty(M)
for i in range(M):
r = np.asarray(val_residuals[i])
if r.ndim == 1:
rmses[i] = np.sqrt(np.mean(r**2))
else:
rmses[i] = np.sqrt(np.mean(r**2))
# guard against zero RMSE
eps = 1e-8
inv = 1.0 / (rmses + eps)
weights = inv / np.sum(inv)
# point forecast: weighted average across models
point = weights.reshape(-1, 1) * F
point = point.sum(axis=0) # shape (H,)
# prepare residual pool per model: keep same horizon shape if available
# For bootstrap, sample a model's residual row then add to ensemble forecast using model weights
# Precompute residual matrices as (T_i, H)
residual_mats = []
for i in range(M):
r = np.asarray(val_residuals[i])
if r.ndim == 1:
# treat as same residual applied to all horizons
residual_mats.append(r.reshape(-1, 1) * np.ones((1, H)))
else:
if r.shape[1] != H:
raise ValueError("Validation residual horizon mismatch")
residual_mats.append(r)
# bootstrap ensemble residual by sampling one residual vector per model (with replacement) each bootstrap
sims = np.empty((n_boot, H))
for b in range(n_boot):
sampled = np.empty((M, H))
for i in range(M):
ri = residual_mats[i]
idx = rng.integers(0, ri.shape[0])
sampled[i] = ri[idx]
# ensemble residual = weighted sum of sampled model residuals
ens_resid = np.dot(weights, sampled)
sims[b] = point + ens_resid
lower = np.percentile(sims, 100 * (alpha / 2), axis=0)
upper = np.percentile(sims, 100 * (1 - alpha / 2), axis=0)
return {"point": point, "lower": lower, "upper": upper, "weights": weights}
Key points:- Assumptions: validation residuals representative of future errors; residuals independent across time or bootstrap preserves horizon correlation if residuals provided per-horizon; models' forecasts are aligned by horizon.- Edge cases: zero RMSE (handled by eps), mismatched shapes, very few validation residuals (bootstrap unstable), identical forecasts.- Complexity: O(M*H + n_boot*M*H). Memory O(n_boot*H) for sims (can stream percentiles to reduce memory).