Emergency department demand forecasting
Daily attendance forecasts 28 days ahead from three models and an average, scored with a rolling-origin backtest across 2025 and given 80% intervals built from past errors.
Synthetic data Every record here is generated. No real patient or organisational data is used.
- MASE of the best model over 28 days
- 0.79
- Lower MAE than the seasonal naive baseline
- 28%
- Actuals inside the 80% interval (target 80%)
- 76.4%
- Noise-floor MAE; the best model reached 24.1
- 20.0
Rotas, escalation plans and bed plans for next month need a number for each day, and a range around it. This project forecasts daily emergency department attendances 28 days ahead for a fictional acute hospital, compares three models and a simple average, and scores them the way they would be used: twelve monthly forecasts across 2025, each made with only the data that existed on the day.
All the data is synthetic, generated by the project code with a fixed seed. No real hospital, patient or staff data is involved.
In short: an average of exponential smoothing and gradient boosting had the lowest error, a MASE of 0.79 and a mean absolute error 28% below a seasonal naive baseline. Its 80% intervals held on 76.4% of days, a little too narrow. And every model missed Christmas Day by 85 attendances or more.
The planning problem
A rota for the four weeks from 1 December has to be drafted in November. So the useful forecast is the one made at the end of a month for the month that follows, with nothing from that month in hand.
A planner needs three things from it:
- A daily number for each of the next 28 days, because staffing follows the weekly cycle and the bank holidays, not a monthly total.
- A range, so the plan can be set against a bad day rather than an average one. An 80% interval is a reasonable default: wide enough to be honest, narrow enough to act on.
- A warning when the forecast is weak. Christmas, bank holidays and heatwaves are exactly the days when a plan matters most, and they are where models do worst.
What’s in the data
Five years of daily attendances, 1 January 2021 to 31 December 2025, split into ambulance and walk-in arrivals. Every pattern below is planted on purpose and set as a named constant at the top of simulate.py, so the forecasts can be judged against known truth.
| Pattern | What I planted | What shows up |
|---|---|---|
| Day of week | Monday 13% above an average day, Saturday and Sunday 7% below | Monday mean 342.0, Saturday 285.6, Sunday 285.3 |
| Trend | About 1.8% growth a year | Yearly mean 291.3 in 2021, 321.1 in 2025 |
| Winter pressure | A 6% annual cycle peaking in mid-January, plus one respiratory wave each winter with a random peak (early December to late January) and a random size (2% to 8%) | Winter peaks of different heights and timing |
| Bank holidays | All 43 England and Wales bank holidays for 2021 to 2025, hard-coded from gov.uk. A bank holiday looks like a Sunday; the next working day gets an 8% catch-up; Christmas Day is a further 20% lower | Includes the one-off 2022 Platinum Jubilee and State Funeral holidays and the 2023 Coronation |
| Heatwaves | Eight spells of three to five days, three of them in summer 2025, peaking at +12% | Short spikes each summer, weighted towards walk-ins |
| Data-feed outages | Eight days recorded as missing, including one on a forecast origin (30 September 2025) and one inside a scored window (11 March 2025) | Blank rows in the CSV |
| Noise | A slow AR(1) disturbance on the log scale (φ = 0.85), then negative binomial counts with variance = mean + mean²/300 | On the scored days of 2025, recorded counts sit 20.0 a day from the planted expected value on average |
Ambulance arrivals are 28.1% of the total, a little higher in winter and lower during heatwaves. The CSV keeps the planted expected value for every day in a planted_expected column, which turns out to be the most useful column in the file: it shows how much of any miss was the model and how much was noise.
Data table
| Week starting | All arrivals | By ambulance |
|---|---|---|
| 2021-01-04 | 322 | 101 |
| 2021-01-11 | 329 | 103 |
| 2021-01-18 | 332 | 102 |
| 2021-01-25 | 304 | 95 |
| 2021-02-01 | 312 | 94 |
| 2021-02-08 | 308 | 95 |
| 2021-02-15 | 298 | 90 |
| 2021-02-22 | 301 | 92 |
| 2021-03-01 | 278 | 81 |
| 2021-03-08 | 276 | 85 |
| 2021-03-15 | 285 | 85 |
| 2021-03-22 | 276 | 79 |
| 2021-03-29 | 292 | 85 |
| 2021-04-05 | 279 | 77 |
| 2021-04-12 | 267 | 68 |
| 2021-04-19 | 283 | 79 |
| 2021-04-26 | 281 | 77 |
| 2021-05-03 | 270 | 76 |
| 2021-05-10 | 305 | 81 |
| 2021-05-17 | 289 | 78 |
| 2021-05-24 | 288 | 72 |
| 2021-05-31 | 267 | 68 |
| 2021-06-07 | 287 | 71 |
| 2021-06-14 | 284 | 74 |
| 2021-06-21 | 264 | 64 |
| 2021-06-28 | 299 | 76 |
| 2021-07-05 | 294 | 72 |
| 2021-07-12 | 276 | 65 |
| 2021-07-19 | 318 | 75 |
| 2021-07-26 | 294 | 76 |
| 2021-08-02 | 278 | 74 |
| 2021-08-09 | 275 | 67 |
| 2021-08-16 | 277 | 69 |
| 2021-08-23 | 267 | 70 |
| 2021-08-30 | 268 | 66 |
| 2021-09-06 | 281 | 78 |
| 2021-09-13 | 280 | 76 |
| 2021-09-20 | 295 | 82 |
| 2021-09-27 | 303 | 80 |
| 2021-10-04 | 301 | 85 |
| 2021-10-11 | 294 | 84 |
| 2021-10-18 | 287 | 86 |
| 2021-10-25 | 288 | 82 |
| 2021-11-01 | 284 | 81 |
| 2021-11-08 | 299 | 87 |
| 2021-11-15 | 299 | 91 |
| 2021-11-22 | 318 | 97 |
| 2021-11-29 | 305 | 93 |
| 2021-12-06 | 300 | 86 |
| 2021-12-13 | 294 | 87 |
| 2021-12-20 | 289 | 89 |
| 2021-12-27 | 297 | 89 |
| 2022-01-03 | 325 | 105 |
| 2022-01-10 | 356 | 115 |
| 2022-01-17 | 326 | 102 |
| 2022-01-24 | 302 | 94 |
| 2022-01-31 | 344 | 101 |
| 2022-02-07 | 338 | 105 |
| 2022-02-14 | 290 | 95 |
| 2022-02-21 | 319 | 101 |
| 2022-02-28 | 307 | 94 |
| 2022-03-07 | 305 | 91 |
| 2022-03-14 | 297 | 91 |
| 2022-03-21 | 294 | 80 |
| 2022-03-28 | 304 | 88 |
| 2022-04-04 | 297 | 89 |
| 2022-04-11 | 307 | 92 |
| 2022-04-18 | 292 | 80 |
| 2022-04-25 | 304 | 81 |
| 2022-05-02 | 268 | 72 |
| 2022-05-09 | 272 | 73 |
| 2022-05-16 | 289 | 77 |
| 2022-05-23 | 277 | 68 |
| 2022-05-30 | 290 | 76 |
| 2022-06-06 | 294 | 74 |
| 2022-06-13 | 289 | 73 |
| 2022-06-20 | 283 | 77 |
| 2022-06-27 | 285 | 71 |
| 2022-07-04 | 295 | 71 |
| 2022-07-11 | 295 | 76 |
| 2022-07-18 | 295 | 74 |
| 2022-07-25 | 276 | 68 |
| 2022-08-01 | 289 | 76 |
| 2022-08-08 | 296 | 68 |
| 2022-08-15 | 295 | 74 |
| 2022-08-22 | 285 | 73 |
| 2022-08-29 | 298 | 78 |
| 2022-09-05 | 309 | 79 |
| 2022-09-12 | 313 | 85 |
| 2022-09-19 | 305 | 86 |
| 2022-09-26 | 297 | 83 |
| 2022-10-03 | 313 | 84 |
| 2022-10-10 | 297 | 83 |
| 2022-10-17 | 345 | 97 |
| 2022-10-24 | 312 | 88 |
| 2022-10-31 | 321 | 88 |
| 2022-11-07 | 291 | 85 |
| 2022-11-14 | 305 | 88 |
| 2022-11-21 | 297 | 88 |
| 2022-11-28 | 281 | 91 |
| 2022-12-05 | 327 | 103 |
| 2022-12-12 | 321 | 97 |
| 2022-12-19 | 330 | 104 |
| 2022-12-26 | 338 | 106 |
| 2023-01-02 | 362 | 119 |
| 2023-01-09 | 358 | 113 |
| 2023-01-16 | 313 | 99 |
| 2023-01-23 | 336 | 99 |
| 2023-01-30 | 337 | 105 |
| 2023-02-06 | 323 | 102 |
| 2023-02-13 | 324 | 96 |
| 2023-02-20 | 322 | 97 |
| 2023-02-27 | 302 | 95 |
| 2023-03-06 | 345 | 104 |
| 2023-03-13 | 321 | 92 |
| 2023-03-20 | 314 | 95 |
| 2023-03-27 | 330 | 90 |
| 2023-04-03 | 290 | 84 |
| 2023-04-10 | 300 | 86 |
| 2023-04-17 | 312 | 91 |
| 2023-04-24 | 295 | 82 |
| 2023-05-01 | 293 | 74 |
| 2023-05-08 | 277 | 74 |
| 2023-05-15 | 277 | 74 |
| 2023-05-22 | 279 | 76 |
| 2023-05-29 | 269 | 73 |
| 2023-06-05 | 286 | 64 |
| 2023-06-12 | 313 | 80 |
| 2023-06-19 | 294 | 74 |
| 2023-06-26 | 289 | 68 |
| 2023-07-03 | 279 | 66 |
| 2023-07-10 | 308 | 75 |
| 2023-07-17 | 307 | 78 |
| 2023-07-24 | 291 | 70 |
| 2023-07-31 | 295 | 75 |
| 2023-08-07 | 272 | 69 |
| 2023-08-14 | 270 | 74 |
| 2023-08-21 | 284 | 74 |
| 2023-08-28 | 293 | 70 |
| 2023-09-04 | 314 | 85 |
| 2023-09-11 | 286 | 77 |
| 2023-09-18 | 298 | 79 |
| 2023-09-25 | 317 | 81 |
| 2023-10-02 | 313 | 87 |
| 2023-10-09 | 293 | 82 |
| 2023-10-16 | 324 | 95 |
| 2023-10-23 | 306 | 88 |
| 2023-10-30 | 313 | 89 |
| 2023-11-06 | 323 | 88 |
| 2023-11-13 | 324 | 99 |
| 2023-11-20 | 330 | 97 |
| 2023-11-27 | 332 | 96 |
| 2023-12-04 | 315 | 95 |
| 2023-12-11 | 332 | 101 |
| 2023-12-18 | 359 | 112 |
| 2023-12-25 | 312 | 90 |
| 2024-01-01 | 312 | 93 |
| 2024-01-08 | 337 | 108 |
| 2024-01-15 | 319 | 102 |
| 2024-01-22 | 333 | 104 |
| 2024-01-29 | 299 | 88 |
| 2024-02-05 | 327 | 97 |
| 2024-02-12 | 319 | 98 |
| 2024-02-19 | 320 | 98 |
| 2024-02-26 | 335 | 105 |
| 2024-03-04 | 321 | 96 |
| 2024-03-11 | 306 | 85 |
| 2024-03-18 | 314 | 92 |
| 2024-03-25 | 314 | 90 |
| 2024-04-01 | 313 | 90 |
| 2024-04-08 | 310 | 87 |
| 2024-04-15 | 316 | 90 |
| 2024-04-22 | 302 | 82 |
| 2024-04-29 | 315 | 91 |
| 2024-05-06 | 287 | 75 |
| 2024-05-13 | 298 | 80 |
| 2024-05-20 | 277 | 70 |
| 2024-05-27 | 291 | 80 |
| 2024-06-03 | 293 | 69 |
| 2024-06-10 | 288 | 75 |
| 2024-06-17 | 286 | 72 |
| 2024-06-24 | 273 | 68 |
| 2024-07-01 | 290 | 70 |
| 2024-07-08 | 281 | 72 |
| 2024-07-15 | 287 | 67 |
| 2024-07-22 | 272 | 70 |
| 2024-07-29 | 304 | 75 |
| 2024-08-05 | 273 | 71 |
| 2024-08-12 | 296 | 71 |
| 2024-08-19 | 304 | 76 |
| 2024-08-26 | 276 | 70 |
| 2024-09-02 | 304 | 80 |
| 2024-09-09 | 300 | 86 |
| 2024-09-16 | 303 | 77 |
| 2024-09-23 | 309 | 79 |
| 2024-09-30 | 306 | 83 |
| 2024-10-07 | 301 | 86 |
| 2024-10-14 | 298 | 85 |
| 2024-10-21 | 319 | 94 |
| 2024-10-28 | 305 | 90 |
| 2024-11-04 | 327 | 97 |
| 2024-11-11 | 325 | 95 |
| 2024-11-18 | 336 | 103 |
| 2024-11-25 | 322 | 96 |
| 2024-12-02 | 322 | 95 |
| 2024-12-09 | 336 | 105 |
| 2024-12-16 | 349 | 114 |
| 2024-12-23 | 340 | 107 |
| 2024-12-30 | 345 | 108 |
| 2025-01-06 | 328 | 105 |
| 2025-01-13 | 346 | 111 |
| 2025-01-20 | 326 | 105 |
| 2025-01-27 | 321 | 100 |
| 2025-02-03 | 307 | 95 |
| 2025-02-10 | 295 | 89 |
| 2025-02-17 | 321 | 95 |
| 2025-02-24 | 321 | 95 |
| 2025-03-03 | 331 | 93 |
| 2025-03-10 | 320 | 94 |
| 2025-03-17 | 326 | 95 |
| 2025-03-24 | 334 | 94 |
| 2025-03-31 | 331 | 92 |
| 2025-04-07 | 334 | 94 |
| 2025-04-14 | 330 | 93 |
| 2025-04-21 | 320 | 93 |
| 2025-04-28 | 308 | 83 |
| 2025-05-05 | 312 | 85 |
| 2025-05-12 | 324 | 81 |
| 2025-05-19 | 330 | 88 |
| 2025-05-26 | 333 | 85 |
| 2025-06-02 | 315 | 86 |
| 2025-06-09 | 308 | 80 |
| 2025-06-16 | 312 | 73 |
| 2025-06-23 | 291 | 70 |
| 2025-06-30 | 321 | 80 |
| 2025-07-07 | 301 | 72 |
| 2025-07-14 | 315 | 76 |
| 2025-07-21 | 287 | 73 |
| 2025-07-28 | 281 | 76 |
| 2025-08-04 | 294 | 75 |
| 2025-08-11 | 334 | 81 |
| 2025-08-18 | 283 | 75 |
| 2025-08-25 | 310 | 82 |
| 2025-09-01 | 298 | 75 |
| 2025-09-08 | 293 | 79 |
| 2025-09-15 | 292 | 75 |
| 2025-09-22 | 316 | 88 |
| 2025-09-29 | 322 | 87 |
| 2025-10-06 | 334 | 93 |
| 2025-10-13 | 346 | 97 |
| 2025-10-20 | 339 | 98 |
| 2025-10-27 | 344 | 106 |
| 2025-11-03 | 352 | 104 |
| 2025-11-10 | 345 | 99 |
| 2025-11-17 | 327 | 94 |
| 2025-11-24 | 345 | 98 |
| 2025-12-01 | 372 | 109 |
| 2025-12-08 | 346 | 104 |
| 2025-12-15 | 325 | 102 |
| 2025-12-22 | 309 | 100 |
Synthetic data. Source: projects/ed-demand-forecasting. Weeks with an outage day average the days that were recorded.
Approach
Backtest design
Twenty-four forecast origins, one at each month end from 31 December 2023 to 30 November 2025. Each forecast covers the first 28 days of the following month. The twelve origins that forecast 2024 are for calibrating the prediction intervals and for design choices. The twelve that forecast 2025 are the ones scored: 335 days per model, because the outage on 11 March has no actual to score against.
Every model receives the history up to its origin and nothing else. Outage days in that history are filled with the same weekday one week earlier, which only looks backwards. The rolling-origin backtesting article explains why this beats a single train/test split.
Models
- Seasonal naive. Repeat the last observed week. It is the baseline any other model has to beat.
- Exponential smoothing (ETS). Holt-Winters from statsmodels, with a damped additive trend and multiplicative weekly seasonality, refitted at every origin. It knows nothing about bank holidays or the time of year.
- Gradient boosting (GBM). scikit-learn’s
HistGradientBoostingRegressoras a direct multi-horizon model: one regressor, with the horizon as a feature. Every (origin, horizon) pair inside the history becomes a training row, 29,526 of them at the first 2025 origin and 38,878 at the last. - Average of ETS and GBM. The mean of the two point forecasts.
The GBM predicts the log of attendances relative to the 28-day mean at the origin, so the trees never have to extrapolate the trend. Its features are the horizon, the day of week, flags for bank holidays, the working day after one, and Christmas Day, and four ratios to that 28-day level. Every lagged value is chosen so that it existed at the origin:
def direct_features(y, calendar, origin, horizon):
cumsum = np.concatenate(([0.0], np.cumsum(y)))
target = origin + horizon
level = _window_mean(cumsum, origin, LEVEL_DAYS)
# The four most recent observed days with the same weekday as the target.
back = target - WEEK * np.ceil(horizon / WEEK).astype(int)
same_weekday = (y[back] + y[back - 7] + y[back - 14] + y[back - 21]) / 4
last_week = _window_mean(cumsum, origin, WEEK)
last_year_week = _window_mean(cumsum, target - YEAR + 3, WEEK) # centred on target - 364
last_year_month = _window_mean(cumsum, target - YEAR + 14, LEVEL_DAYS)
...
For a target ten days ahead, “same weekday” means 14 days before the target, not 7, because 7 days before the target is still in the future. At forecast time y stops at the origin, so a feature that reached past it would raise an IndexError instead of leaking quietly.
An earlier version also had day of year as a feature. It did worse on the 2024 origins: with three or four winters of history, the trees learnt the timing of past respiratory waves, and that timing moves every year. The feature set above is the one that scored best on the 2024 origins. To be straight about it: I had also seen the earlier version’s 2025 scores before making that change, so the 2025 results aren’t a perfectly clean hold-out for the gradient boosting model. The choice holds up on 2024 alone, which is why I kept it.
Prediction intervals
The intervals are empirical, not model-based. For each origin, I take the log residuals, log(actual ÷ forecast), from the previous 12 origins, split them by model and horizon week, and use the 10th and 90th percentiles as multiplicative bounds. The first 2025 interval therefore uses only 2024 errors, and no interval ever sees an actual from its own forecast window.
past = out[out["origin"].isin(origins[i - INTERVAL_WINDOW : i]) & (out["date"] <= origin)]
quantiles = past.groupby(["model", "week"])["log_residual"].quantile(list(INTERVAL))
Prediction intervals for planners covers this method and quantile regression in more depth.
Results
| Model | MAE | RMSE | MAPE | MASE | Bias | 80% coverage | Mean width |
|---|---|---|---|---|---|---|---|
| Seasonal naive | 33.5 | 44.1 | 10.7% | 1.09 | +2.0 | 74.3% | 100.7 |
| Exponential smoothing | 24.9 | 32.6 | 7.9% | 0.81 | +1.1 | 75.5% | 76.4 |
| Gradient boosting | 24.2 | 31.2 | 7.6% | 0.79 | −4.0 | 75.8% | 70.5 |
| Average of ETS and GBM | 24.1 | 31.4 | 7.6% | 0.79 | −1.4 | 76.4% | 72.3 |
Errors are in attendances per day. Bias is the mean of forecast minus actual, so a negative number means under-forecasting. MASE is scaled by the in-sample error of a one-week seasonal naive at each origin; the forecast accuracy metrics article explains that choice.
Data table
| Model | MASE |
|---|---|
| Seasonal naive | 1.09 |
| Exponential smoothing | 0.81 |
| Gradient boosting | 0.79 |
| Average of ETS and GBM | 0.79 |
Synthetic data. Source: projects/ed-demand-forecasting.
The seasonal naive scores above 1 because its denominator is a one-week-ahead naive forecast, and forecasting up to four weeks ahead is harder. The other three beat it by a wide margin. The average edges the GBM, 0.787 against 0.791, but that gap is far smaller than the month-to-month swings, and the GBM won six of the twelve months on its own. I’d call them tied.
Accuracy by horizon
Data table
| Days ahead | Seasonal naive | Exponential smoothing | Gradient boosting | Average of ETS and GBM |
|---|---|---|---|---|
| 1 | 29.2 | 25.4 | 19.8 | 22.6 |
| 2 | 47.5 | 31.6 | 30.1 | 30.8 |
| 3 | 27.3 | 19.2 | 27.8 | 23.5 |
| 4 | 37.1 | 24.0 | 24.3 | 24.1 |
| 5 | 30.7 | 28.6 | 32.1 | 28.2 |
| 6 | 18.9 | 15.3 | 16.7 | 15.9 |
| 7 | 44.3 | 31.9 | 29.9 | 30.8 |
| 8 | 23.8 | 27.6 | 22.5 | 25.0 |
| 9 | 37.5 | 16.9 | 15.4 | 16.2 |
| 10 | 20.8 | 15.8 | 13.9 | 13.0 |
| 11 | 37.9 | 27.1 | 26.3 | 26.1 |
| 12 | 27.7 | 23.3 | 25.9 | 24.4 |
| 13 | 36.4 | 32.3 | 30.8 | 30.5 |
| 14 | 39.0 | 24.6 | 23.3 | 23.5 |
| 15 | 29.3 | 25.6 | 25.6 | 25.5 |
| 16 | 41.7 | 21.2 | 24.8 | 23.0 |
| 17 | 35.6 | 28.6 | 26.4 | 27.5 |
| 18 | 33.7 | 22.6 | 16.1 | 19.3 |
| 19 | 35.3 | 25.8 | 29.3 | 27.1 |
| 20 | 30.6 | 26.0 | 24.3 | 25.1 |
| 21 | 38.3 | 20.8 | 19.0 | 18.7 |
| 22 | 32.9 | 25.6 | 25.6 | 24.9 |
| 23 | 32.0 | 18.9 | 17.2 | 17.9 |
| 24 | 38.1 | 26.9 | 24.4 | 25.7 |
| 25 | 42.4 | 32.3 | 32.7 | 31.0 |
| 26 | 28.9 | 30.0 | 26.1 | 26.6 |
| 27 | 30.2 | 27.9 | 25.5 | 26.3 |
| 28 | 31.8 | 22.4 | 22.5 | 22.3 |
Synthetic data. Source: projects/ed-demand-forecasting. Each point averages at most 12 days, so expect wobble.
Error barely grows across the 28 days. The average’s MAE by horizon week runs 25.1, 22.6, 23.7 and 24.9. The level of demand moves slowly, and most of the remaining error is day-to-day noise that no model can predict at any horizon.
That noise has a size. A forecast that knew the planted expected value for every day, with no error at all, would still have an MAE of 20.0 against the recorded counts. The best model reaches 24.1. Measured against the planted expected value instead of the noisy actual, the GBM’s own error is 13.0 and the average’s 13.1, against 14.7 for ETS and 25.7 for the seasonal naive.
Intervals
Data table
| Model | Coverage |
|---|---|
| Seasonal naive | 74.3% |
| Exponential smoothing | 75.5% |
| Gradient boosting | 75.8% |
| Average of ETS and GBM | 76.4% |
Synthetic data. Source: projects/ed-demand-forecasting. Intervals come from past backtest residuals by horizon week.
Every model’s 80% interval held on 74% to 76% of days, so the intervals are a few points too narrow. The 2024 residuals that calibrated them came from a calmer year: on the 2024 origins the average’s MAE was 21.9, against 24.1 in 2025, and 2025 had two months where every model was wrong in the same direction (below). For a planner the practical reading is that the interval missed on 23.6% of days rather than the 20% it promised. Widening it, or refreshing it after a bad month, would be the fix.
Where the best models still fail
Because the data is synthetic, each miss can be split into the model’s own error (forecast minus the planted expected value) and noise (actual minus expected). The chart shows only the model’s part.
Data table
| Day type (days) | Seasonal naive | Exponential smoothing | Gradient boosting | Average of ETS and GBM |
|---|---|---|---|---|
| Ordinary days (313) | 24.7 | 13.7 | 12.4 | 12.3 |
| Bank holidays (8) | 59.3 | 46.4 | 22.2 | 34.0 |
| Working day after a bank holiday (5) | 26.4 | 21.5 | 12.2 | 16.5 |
| Planted heatwave days (9) | 28.4 | 14.9 | 27.1 | 20.9 |
Synthetic data. Source: projects/ed-demand-forecasting. Heatwaves are planted in the data but unknown to every model.
Christmas Day. Actual 238, planted expected 241.9. The forecasts were 323 (GBM), 329 (average), 335 (ETS) and 341 (seasonal naive). The GBM has a Christmas flag, but it can’t use it: min_samples_leaf is 200, and the training window holds three Christmas Days, 84 rows across the 28 horizons. The trees can’t give Christmas a leaf of its own.
Data table
| Date | Actual | Forecast (Average of ETS and GBM) | Forecast (Average of ETS and GBM) (low) | Forecast (Average of ETS and GBM) (high) |
|---|---|---|---|---|
| 2025-11-10 | 407 | |||
| 2025-11-11 | 400 | |||
| 2025-11-12 | 328 | |||
| 2025-11-13 | 308 | |||
| 2025-11-14 | 362 | |||
| 2025-11-15 | 313 | |||
| 2025-11-16 | 296 | |||
| 2025-11-17 | 362 | |||
| 2025-11-18 | 372 | |||
| 2025-11-19 | 338 | |||
| 2025-11-20 | 311 | |||
| 2025-11-21 | 293 | |||
| 2025-11-22 | 304 | |||
| 2025-11-23 | 310 | |||
| 2025-11-24 | 417 | |||
| 2025-11-25 | 319 | |||
| 2025-11-26 | 349 | |||
| 2025-11-27 | 341 | |||
| 2025-11-28 | 372 | |||
| 2025-11-29 | 300 | |||
| 2025-11-30 | 317 | |||
| 2025-12-01 | 449 | 386 | 333 | 432 |
| 2025-12-02 | 379 | 353 | 304 | 394 |
| 2025-12-03 | 317 | 338 | 292 | 378 |
| 2025-12-04 | 375 | 341 | 294 | 381 |
| 2025-12-05 | 403 | 338 | 291 | 377 |
| 2025-12-06 | 338 | 319 | 275 | 357 |
| 2025-12-07 | 343 | 317 | 274 | 355 |
| 2025-12-08 | 411 | 380 | 344 | 431 |
| 2025-12-09 | 360 | 354 | 320 | 401 |
| 2025-12-10 | 325 | 338 | 305 | 383 |
| 2025-12-11 | 345 | 335 | 303 | 380 |
| 2025-12-12 | 352 | 336 | 304 | 381 |
| 2025-12-13 | 301 | 318 | 287 | 360 |
| 2025-12-14 | 326 | 318 | 288 | 361 |
| 2025-12-15 | 438 | 376 | 334 | 424 |
| 2025-12-16 | 330 | 356 | 316 | 401 |
| 2025-12-17 | 317 | 338 | 300 | 381 |
| 2025-12-18 | 326 | 334 | 297 | 377 |
| 2025-12-19 | 269 | 335 | 298 | 378 |
| 2025-12-20 | 271 | 318 | 282 | 358 |
| 2025-12-21 | 325 | 320 | 284 | 361 |
| 2025-12-22 | 355 | 388 | 347 | 440 |
| 2025-12-23 | 343 | 354 | 317 | 401 |
| 2025-12-24 | 317 | 338 | 303 | 383 |
| 2025-12-25 | 238 | 329 | 295 | 373 |
| 2025-12-26 | 298 | 329 | 295 | 373 |
| 2025-12-27 | 284 | 319 | 286 | 362 |
| 2025-12-28 | 325 | 317 | 284 | 359 |
Synthetic data. Source: projects/ed-demand-forecasting. Gaps in the actual line are data-feed outage days.
Bank holidays. Here the flags earn their place. The GBM’s own error on the eight 2025 bank holidays was 22.2, against 46.4 for ETS, which forecasts a bank holiday as if it were an ordinary weekday. Averaging the two dilutes the gain to 34.0, so the “best” model is worse than the GBM alone on exactly the days a planner worries about.
Heatwaves. No model knows about heat. On 12 to 14 August the actuals were 382, 368 and 362, against the average’s 305, 290 and 288. ETS’s smaller heatwave error in the chart is luck, not insight: its forecasts sat higher than the GBM’s on all nine heatwave days, for reasons that have nothing to do with heat.
Data table
| Date | Actual | Forecast (Average of ETS and GBM) | Forecast (Average of ETS and GBM) (low) | Forecast (Average of ETS and GBM) (high) |
|---|---|---|---|---|
| 2025-07-11 | 323 | |||
| 2025-07-12 | 284 | |||
| 2025-07-13 | 334 | |||
| 2025-07-14 | 358 | |||
| 2025-07-15 | 302 | |||
| 2025-07-16 | 321 | |||
| 2025-07-17 | 315 | |||
| 2025-07-18 | 289 | |||
| 2025-07-19 | 324 | |||
| 2025-07-20 | 296 | |||
| 2025-07-21 | 323 | |||
| 2025-07-22 | 305 | |||
| 2025-07-23 | 282 | |||
| 2025-07-24 | 285 | |||
| 2025-07-25 | 314 | |||
| 2025-07-26 | 255 | |||
| 2025-07-27 | 248 | |||
| 2025-07-28 | 267 | |||
| 2025-07-29 | 307 | |||
| 2025-07-30 | 290 | |||
| 2025-07-31 | 286 | |||
| 2025-08-01 | 284 | 287 | 247 | 321 |
| 2025-08-02 | 264 | 270 | 232 | 302 |
| 2025-08-03 | 272 | 268 | 231 | 300 |
| 2025-08-04 | 354 | 325 | 280 | 364 |
| 2025-08-05 | 320 | 303 | 261 | 339 |
| 2025-08-06 | 300 | 290 | 250 | 325 |
| 2025-08-07 | 289 | 289 | 249 | 324 |
| 2025-08-08 | 275 | 288 | 258 | 314 |
| 2025-08-09 | 292 | 272 | 244 | 297 |
| 2025-08-10 | 227 | 270 | 242 | 295 |
| 2025-08-11 | 380 | 326 | 292 | 356 |
| 2025-08-12 | 382 | 305 | 273 | 333 |
| 2025-08-13 | 368 | 290 | 260 | 316 |
| 2025-08-14 | 362 | 288 | 258 | 314 |
| 2025-08-15 | 263 | 288 | 256 | 325 |
| 2025-08-16 | 269 | 272 | 242 | 308 |
| 2025-08-17 | 312 | 270 | 240 | 305 |
| 2025-08-18 | 292 | 317 | 282 | 358 |
| 2025-08-19 | 274 | 306 | 272 | 345 |
| 2025-08-20 | 281 | 291 | 258 | 329 |
| 2025-08-21 | 299 | 291 | 258 | 328 |
| 2025-08-22 | 301 | 288 | 260 | 317 |
| 2025-08-23 | 266 | 276 | 250 | 304 |
| 2025-08-24 | 268 | 275 | 248 | 303 |
| 2025-08-25 | 335 | 316 | 286 | 348 |
| 2025-08-26 | 432 | 329 | 298 | 362 |
| 2025-08-27 | 271 | 293 | 265 | 323 |
| 2025-08-28 | 265 | 292 | 264 | 322 |
Synthetic data. Source: projects/ed-demand-forecasting. Gaps in the actual line are data-feed outage days.
Noise that looks like a miss. On 26 August, the day after the summer bank holiday, 432 people attended. The planted expected value was 349, and the GBM forecast 351. With real data this would look like an 81-attendance model failure, and someone would be tempted to fix the model. It was noise.
Level shifts. February was over-forecast by 23.5 to 27.0 a day by every model. The planted winter wave faded after January, the planted expected level fell from 334.7 to 314.7, and the models carried January’s level forward. October was under-forecast by 27.4 to 30.3 a day by ETS, the GBM and the average, because the planted expected level jumped from 304.9 in September to 334.1. The seasonal naive, which just copies the last week of September, was least wrong in October, the only month it won.
Limits
- The seasonal shape is planted, not observed. I put the winter peak into attendances. NHS England’s monthly A&E report for June 2016 notes that A&E attendances usually peak in the summer months, while emergency admissions generally peak in winter. For a real hospital, the seasonal shape has to come from its own data.
- No COVID-era disruption. Real 2021 data would need special handling, or excluding from training.
- Twelve origins is a small test. 335 scored days per model is enough to separate the seasonal naive from the rest, not enough to separate the GBM from the average.
- Thin interval calibration. Each horizon week’s interval rests on about 84 past residuals, 12 origins by 7 days.
- One daily series. Rotas need arrivals by hour, and bed plans need admissions, not attendances.
What I’d do next
- Use each model where it’s strong. Take the GBM on flagged days and the average elsewhere, and test that rule on the 2024 origins before believing it.
- Handle Christmas explicitly. A multiplicative adjustment estimated from past Christmases, or a smaller minimum leaf size, checked on the 2024 origins so the rest of the year doesn’t pay for it.
- Add a weather forecast. Temperature for heatwaves, using forecast temperatures available at the origin, not observed ones, to avoid leaking the future.
- Adaptive intervals. Widen the band after a month where coverage slips, or fit quantile GBMs with
loss="quantile"and compare their pinball loss with the empirical intervals. - More origins. Weekly origins would give about four times as many forecasts at each horizon, and a firmer ranking.
- Turn it into a bed plan. Forecast admissions instead of attendances and feed them to the bed occupancy simulation as its arrival rate.
- Keep the parts coherent. Separate forecasts for ambulance and walk-in arrivals won’t add up to the total; hierarchical forecast reconciliation makes them agree.
Run it yourself
uv run python projects/ed-demand-forecasting/run.py
It regenerates the data, runs all 24 origins, rewrites every chart and download on this page, and prints the tables quoted here, in about 20 to 30 seconds. A rerun gives byte-identical files. The full code is in the download below, with a README that explains each file.
Built with
- Python
- pandas
- statsmodels
- scikit-learn
- NumPy
Downloads
-
Daily ED attendances 2021-2025
One row per day: total, ambulance and walk-in arrivals, the planted flags, and the planted expected value.
-
Backtest forecasts
Every forecast from 24 monthly origins, with 80% intervals for the 2025 origins and the actuals.
- Project code