← All work 2026Personal project

London cycle hire forecasting

Santander Cycles docks run empty or full, and a van can only visit so many. This project forecasts how many bikes will leave each of 80 busy docks in every hour of the next day, with a range around the number, then asks what that range is worth to someone sending vans. On spring 2026 data I kept untouched until every choice was fixed, the forecasts have 18% lower error than the fairest simple baseline.

  • Python
  • LightGBM
  • Pandas
  • SciPy
  • Statsmodels
  • Conformal prediction
  • Backtesting
  • Simulation
Results
18%
lower error than the fair baseline on the locked-away test
1.17
trips per dock-hour typical miss over two years, against 1.33 for the baseline
18.1M
trips in the TfL data behind it

The question

An operator of a bike scheme sends vans to move bikes. A dock that runs empty loses a trip. A dock that runs full blocks a return. Those two costs are not the same, so the right number of bikes to leave at a dock is not simply the number you expect to be taken.

So the forecast has to say how sure it is, not only what it expects. This project forecasts departures and returns for each dock for the next 24 hours, issued every morning at 06:00 UTC, and then tests whether knowing the spread changes the decision.

It sits next to LapByLap. Both predict first and optimise second, but this one has real operating data and no Formula 1.

The data, and what is wrong with it

The base is TfL’s public journey data: 18.1 million trips from June 2024 to May 2026, turned into hourly counts per dock. I picked 80 docks, 40 that are busy at the weekday morning peak and 40 that are busy at weekends, each open under one name for the whole period. Forecast weather comes from Open-Meteo, bank holidays from GOV.UK, school terms from Housemait and Tube strike dates from a list I compiled and checked against a TfL freedom of information response.

Most of the work was finding where the data lies.

  • Trips taken are not trips wanted. An empty dock records no demand. The archive has no dock availability, so I score only docks that are rarely empty and say so beside every headline number. I also flag hours where a normally busy dock logged nothing, which is a floor on how much demand is hidden.
  • A dead day. On 5 August 2025 the whole network logged 188 trips against about 30,700 on a normal Tuesday. That is the data failing, not demand. It had been sitting in a scored window as zeros, so it is now missing, and so are the days that read it.
  • Closed docks. A dock silent for a day or more, when it normally logs plenty, is closed, not unpopular. 2,016 dock-hours match events I can name: the Hyde Park docks around Winter Wonderland, the Notting Hill Carnival, New Year at Waterloo. They are treated as missing.
  • A feature that did nothing. School-term dates for 2024/25 were missing, so I compiled them by hand from five borough calendars. The flag moved the score by 0.0003, with an interval spanning zero. It stays in because it costs nothing, but I do not credit it.
What demand looks like Each line is the average number of bikes leaving a dock in that hour of the day. I split the 80 docks into two groups of 40: morning docks, the busiest at the weekday commute, and weekend docks, the busiest on weekends. Each group is shown on weekdays and on weekends, so the dark solid line is a commuter dock on a weekday, with a sharp peak at 8am. The bars underneath show that 44% of dock-hours have no departures at all, which is why the model treats trips as counts.
How many dock-hours had 0, 1, 2 … departures (share of all dock-hours)
44.1% 0
18.7% 1
12.1% 2
3
4
5
6
7
8
9
10+

How the model works

The count of bikes leaving a dock in an hour is a small whole number: the average is about two, and 44% of hours are zero. That shape matters, because a model built to hit the middle of a smooth curve behaves badly on it.

  • Step one, the average. A LightGBM model, trained on all 80 docks together, predicts the expected count for each dock-hour. It reads the same hour in each of the last four weeks, the last day and the last six hours, the hour and weekday, bank holidays, school terms, Tube strikes and the weather forecast for that morning. It uses Poisson loss, the standard choice for counting events.
  • Step two, the spread. A negative binomial turns that average into a range. It is a distribution for counts that are usually near the average and sometimes burst. How bursty depends on how busy the dock is, so each volume level has its own setting, fitted by maximum likelihood on spare mornings.
  • Step three, the safety margin. A conformal step checks the range against recent mornings the model did not train on and nudges it wider or narrower, separately for each hour of the day and for busy and quiet docks. It targets 80%, and on whole numbers it lands higher, because a range has to round outward when 44% of outcomes are zero.

How I tested it

The test is a rolling backtest. Each forecast uses only data from before 06:00 UTC on its day, and the weather it sees is the forecast that was stored then, not what actually happened. There are nine windows of 28 mornings across two years, and a test fails if any feature can see the future.

The fair baseline is the quantiles of the same hour over the last eight weeks, with no fitting. I added it after noticing that “same hour last week” was an easy bar: it scored half the error of my first model, and the real gap was a seventh of that. ARIMA, which had looked like progress against last week, turned out to be worse than the no-fit baseline.

Spring 2026, which includes the April Tube strike, was kept locked. I opened it once, on 29 October 2026, after every model, setting and cost was fixed. The code refuses to run it a second time.

I also checked my own dock selection. The 80 docks were first picked with the whole two years in view, which can flatter results. Re-picking them from the first six months only, and scoring on the six later windows, moved the count model’s mean pinball from 0.349 to 0.348 and left the ranking untouched. 65 of the 80 docks were the same.

Picking the docks without hindsight The same five models on the original 80 docks and on 80 re-picked from the first six months of data only, scored on the six later windows. The spring 2026 test is not used.
Same hour last week 0.754
Last 8 weeks, quantiles 0.427
Weekly ARIMA 0.520
LightGBM quantile 0.365
Count model 0.349
Gap to the fair baseline, mean pinball (negative is better)
Same hour last week +0.328
Weekly ARIMA +0.093
LightGBM quantile −0.062
Count model −0.078

314,225 dock-hours scored · Christmas 2024 to Christmas 2025 (six windows of 28 mornings)

Same hour last week 0.752
Last 8 weeks, quantiles 0.425
Weekly ARIMA 0.521
LightGBM quantile 0.363
Count model 0.348
Gap to the fair baseline, mean pinball (negative is better)
Same hour last week +0.328
Weekly ARIMA +0.096
LightGBM quantile −0.061
Count model −0.077

313,927 dock-hours scored · Christmas 2024 to Christmas 2025 (six windows of 28 mornings)

65 of the 80 docks are the same (32 of 40 morning, 33 of 40 weekend). The docks were re-picked from 1 Jun 2024 to 14 Dec 2024 only. Scored on the six windows from Christmas 2024 to Christmas 2025.

Same decision result on the re-picked docks: doing nothing 5.27, last week 5.31, model average 5.06, whole forecast 5.05, oracle 4.83.

How it did

Lower is better in the first two rows. Mean pinball loss scores the whole range, not only the middle guess. All five models are scored on the same dock-hours.

Last weekLast 8 weeksARIMALightGBMCount model
Typical miss, trips per dock-hour (spring 2026)1.691.451.691.321.25
Mean pinball, spring 20260.8460.4860.5800.4180.399
Mean pinball, nine earlier windows0.7980.4480.5470.3890.375
Gap to the fair baseline, spring 2026+0.359baseline+0.094−0.068−0.087
Five models, same dock-hours Lower is better. The dashed line is the fair baseline, the same hour over the last eight weeks with no fitting. The dots below show the gap to it with 95% intervals, resampling whole mornings. Negative is better.
Same hour last week 0.846
Last 8 weeks, quantiles 0.486
Weekly ARIMA 0.580
LightGBM quantile 0.418
Count model 0.399
Gap to the fair baseline, mean pinball (negative is better)
Same hour last week +0.359
Weekly ARIMA +0.094
LightGBM quantile −0.068
Count model −0.087

107,413 dock-hours scored · 6 Apr to 31 May 2026, 56 mornings, opened once on 29 Oct 2026

Same hour last week 0.798
Last 8 weeks, quantiles 0.448
Weekly ARIMA 0.547
LightGBM quantile 0.389
Count model 0.375
Gap to the fair baseline, mean pinball (negative is better)
Same hour last week +0.350
Weekly ARIMA +0.099
LightGBM quantile −0.059
Count model −0.072

475,240 dock-hours scored · 24 Jun 2024 to 11 Jan 2026, nine windows of 28 mornings

One dock, one week The forecast against what happened. The shaded band is the model’s 10 to 90% range, issued each morning at 06:00 UTC for the next 24 hours. The model had not seen any of these days. It opens on Argyle Street, the busy dock with the lowest error relative to its volume that week, so it shows the model at its best. Every other dock is one click away.
10 to 90% range Median What happened Strike hours

How often the real count landed inside the range An 80% range on whole numbers has to round outward, so it holds more than 80%. The shape matters more than the level.

Overall 91.2%. 8am: 91.3%. Overall 92.1%. 8am, the weak spot: 84.6%.

Where the errors are, spring 2026 Strike hours are 21 to 24 April 2026. The count model’s gain over the baseline is larger on strike and bank-holiday hours than on ordinary ones.
Last 8 weeksCount model
Ordinary hours
0.463 0.390
16% better
Strike hours
0.592 0.435
27% better
Bank-holiday hours
0.742 0.501
32% better
Morning docks
0.426 0.340
20% better
Weekend docks
0.546 0.457
16% better

The ranking was the same in the nine earlier windows and on the locked-away spring: the count model is 16% better than the fair baseline in the first and 18% in the second. Strike and bank-holiday hours are harder for everyone, but its gain over the baseline is larger there, about 27% and 32% against 16% on ordinary hours.

The range is not perfectly calibrated and I would rather say where. Across the nine windows the 8am peak was the weak spot, with the range holding 85% against 92% overall. In spring 2026 it did not show up. One window of 56 mornings is a result, not a fix.

Where the gain came from

My first count model beat plain LightGBM by 4%, but it differed in four ways at once. Before crediting the clever part, I changed one thing at a time.

ChangeEffect on mean pinballVerdict
Four weeks of history instead of one, quantile model−0.007 (−0.009 to −0.004)Real
Count distribution instead of quantile loss, same inputs−0.007 (−0.009 to −0.005)Real
Dropping forecast weather+0.004 (+0.001 to +0.008)Helps a little
Dropping the school-term flag+0.0003 (−0.0004 to +0.001)No effect
One change at a time Change in mean pinball loss, negative is better. A whisker that crosses zero means no detectable effect.
Count distribution instead of quantile loss, same inputs −0.0071
Four weeks of history instead of one, quantile loss −0.0066
Four weeks of history instead of one, count model −0.0111
Dropping the school-term flag 0.0003 · no effect
Dropping forecast weather 0.0043

So about half the lead was just more history, and the other half was the count distribution. The model I thought was clever was less than half of the story, and I would rather know that.

What it is worth to someone sending vans

The test: for each dock and a four-hour window, the operator picks how many bikes to have there at the start. Changing from half full costs a van stop. Policies are scored on what actually happened, with costs for a lost trip, a blocked return and a stop.

Docks per station come from TfL’s live listing (13 to 55, average 29). A lost trip costs 1.65, the pay-as-you-go fare. Assume a van holds 18 bikes. The cost of a van stop, 10, is my arithmetic, since no UK operator publishes one, so I swept it from 3 to 50.

PolicyCost per windowAvoidable cost removed
No rebalancing5.800%
Plan from last week’s counts as if exact5.784%
Plan from the model’s average as if exact5.5551%
Plan against the whole forecast5.5354%
Oracle that sees the future5.31100%
Try the costs Each window is one dock and four hours. Scenarios change one input at a time from the base case, so not every pair of settings exists.

What the test assumes Which inputs are sourced and which are my assumptions. The assumed ones are why the sweep is the result and the base case is not.
InputValueStatusSource
Docks per station13 to 55, mean 29sourcedTfL BikePoint listing, fetched 29 Oct 2026. Today's working docks, not the layout on every day.
Value of a lost trip1.65sourced as a pricePay-as-you-go fare for 30 minutes. TfL's average income per hire was 1.09 to 1.12 in 2017/18; an e-bike ride is about 3.30; the sweep covers 1.10 to 3.30.
Bikes a van holds18sourced, oldEuropean Cycling Federation presentation on the scheme under its 2017 contract. Swept at 9 and 36.
Cost of one van stop10assumedA van and two staff at about 50 an hour calling at five stops an hour. No UK operator figure found. Swept from 3 to 50.
Cost of a blocked return0.825assumedHalf a lost trip. Swept at 0.25x, 1x and 2x a lost trip.
Cost of moving one bike0.4assumedAbout half a minute of handling at the same hourly cost.
Default start levelhalf the docksassumedDock occupancy history is not in the archive.

The clearest result is that the model beats last week’s counts by a lot. A noisy single number sends vans that were not needed, and was worse than doing nothing at the quiet docks. Having the forecast at all is where most of the gain is.

The spread adds little in this setup. Planning against the whole forecast beats planning from the average by about 0.02 per window, 0.3% of total cost, and on spring 2026’s 56 mornings that gap is within noise. Even an oracle saves only about 8% of total cost, because most of the cost is the busiest third of docks, where four hours of demand exceeds the docks whatever anyone does.

So I tried a harder version. A real operator has a van that can make only so many stops a morning, so the forecast has to choose which docks to visit.

A limited van

Each morning one van can make at most so many stops across the 80 docks. “Van already paid for” means a stop costs only the handling, 0.4 a bike. Otherwise a stop costs 10.

SetupLast weekModel averageWhole forecastOracle
Stop costs 10, 3 stops a morning10.615.917.225.1
Van paid for, 4 stops a morning41.749.150.463.4
Van paid for, 16 stops a morning62.571.780.5133.6
Van paid for, 32 stops a morning71.478.193.7161.4
A limited van Pounds saved per morning across the 80 docks, against doing nothing, which costs about 460 a morning. Each policy ranks the docks by what it expects a visit to save, visits the top ones and is scored on what happened. Nine earlier windows. The spring 2026 test was not used for this.

Here the spread earns its place. Ranking docks by the whole forecast saves about 7 to 16% more than ranking by the average when a stop costs 10, and up to 24% more when a stop costs only the handling. The reason is the textbook one. A lost trip costs about twice a blocked return, so the right stocking level is well above the average, and a plan built on the average under-stocks.

The honest summary is that the spread matters when moving a bike is cheap compared with a lost trip, and adds little when every stop is expensive. I would rather show where it helps and where it does not than pick the setup that flatters it. I also modelled departures and returns moving together, with a Gaussian copula. It matched the spread of real outcomes better and changed no decision, so the simple version stays.

Caveats

  • Trips taken stand in for trips wanted. Empty docks hide demand, and the archive cannot show how much. I flag a floor, about 0.8% of departures, and score docks that are rarely empty.
  • The 80 docks were first chosen with the whole two years in view. A re-pick using only the first six months gave the same scores, but it is still one network over two years, not a different city or period.
  • The van-stop cost, the cost of a blocked return, the cost of moving a bike and the default start level are assumptions, not operator data, which is why the result is the sweep and not the base case.
  • The decision test is one stop per dock per window, with no routing and a van limited to the bikes it holds.
  • The spring 2026 test is one season of 56 mornings with one strike. Dock counts are today’s layout, not the layout on every day of the two years.

Data and credits

Derived results only. The raw TfL journey files are not republished.