A fare model that quotes rail prices from route, class, timing and demand, explains every price in pounds, and shows how a fare should move as departure approaches, so a commercial team can price with confidence.
A rail operator wants to quote fares that respond to demand, timing and capacity, and wants every quote explained so the commercial team can stand behind it. Static fare rules leave money on the table at busy times and leave seats empty at quiet ones. VoyageRail is a stand-in name for a real client that I can’t name under contract.
Data
Because the client’s own bookings are confidential, the project used a stand-in dataset of 519,713 synthetic bookings for travel between January and March 2025. It covers 25 routes, three seat classes, four channels, four customer segments and four loyalty tiers. Each row carries the fare paid.
Before modelling, I checked which columns a fare desk would actually know at the moment it quotes. Five of them give the fare away or are only known after the fact, so the model never uses them.
Base fare plus premium against the ticket price
All 461,445 of the 461,445 rows that have a premium sit exactly on the dashed line, where the two inputs add up to the answer. A random sample of 400 is shown.
Show as table (first 25 points)
base fare + premium (£)
ticket price (£)
48.8
48.8
17.1
17.1
56.4
56.4
25.1
25.1
110
110
37.2
37.2
44.3
44.3
35.1
35.1
87.9
87.9
100
100
45.8
45.8
42.9
42.9
111
111
47.5
47.5
46.3
46.3
56.5
56.5
25.4
25.4
40
40
116
116
80.6
80.6
34.7
34.7
72.5
72.5
59
59
34.7
34.7
96.1
96.1
Seats sold against the number of bookings, per departure
For 2,250 of 2,250 departures, seats sold equals the final booking count. A sample of 400 is shown.
Show as table (first 25 points)
bookings for the departure
seats sold (realised)
196
196
179
179
249
249
112
112
219
219
85
85
148
148
244
244
256
256
377
377
238
238
389
389
115
115
165
165
159
159
389
389
279
279
215
215
115
115
85
85
250
250
229
229
349
349
292
292
334
334
The data also behaves unlike real advance fares. Fares climb for about three weeks of lead time and then flatten, and most bookings happen close to departure.
Average fare by days before travel
£48.74same day
£52.021 to 3
£60.144 to 7
£69.898 to 14
£77.1215 to 21
£77.5522 to 30
£76.0031 to 45
£76.4046 to 60
£75.7161 to 89
days before travel
Show as table
days before travel
Value
same day
£48.74
1 to 3
£52.02
4 to 7
£60.14
8 to 14
£69.89
15 to 21
£77.12
22 to 30
£77.55
31 to 45
£76.00
46 to 60
£76.40
61 to 89
£75.71
Bookings by days before travel
169,037same day
83,8421 to 3
80,3954 to 7
88,6658 to 14
41,58315 to 21
28,99522 to 30
21,59231 to 45
4,94946 to 60
65561 to 89
days before travel
A third of all bookings are made on the day of travel. Only 655 are made more than 60 days out.
Show as table
days before travel
Value
same day
169,037
1 to 3
83,842
4 to 7
80,395
8 to 14
88,665
15 to 21
41,583
22 to 30
28,995
31 to 45
21,592
46 to 60
4,949
61 to 89
655
Approach
The model uses only what a booking clerk knows at quote time. That means the route, seat class, channel, customer segment, loyalty tier, days before travel and the operator’s demand forecast.
Distance, route category and capacity come from the chosen route, so the form never asks for them. Customer history defaults to the median for that segment when it isn’t given.
I compared three model families on the same 80/20 split with a fixed seed, then ran 5-fold cross-validation on the training split for the shipped model and its baseline.
How a quote is produced
Fare deskroute, class, timing
FastAPI servicechecks route and ranges
Route lookupdistance, category, seats
Modelsgradient boostinglinear baseline
SHAP driversin pounds, summing exactly
Quote and curve0 to 89 days out
Evaluation
R² on the same holdout splithigher is better
Gradient boosting, quote-time features0.903
Random forest, quote-time features0.864
Linear baseline, quote-time features0.713
Gradient boosting beat the random forest on accuracy, size and training time, and the linear baseline by a wide margin.
Show as table
Item
Value
Note
Gradient boosting, quote-time features
0.903
shipped, 0.6 MB
Random forest, quote-time features
0.864
17 MB
Linear baseline, quote-time features
0.713
The shipped model’s average error is £6.53 on the holdout set, against £11.21 for the linear baseline. Cross-validation agrees, with an R² of 0.903 ± 0.002 across five folds.
What didn’t work
A random forest was the obvious first choice, and it was accurate enough. But at 17 MB it was too heavy for a small, always-on service, and it trained far more slowly than gradient boosting while scoring lower.
The linear baseline draws a straight line through a curve. Fares climb for about three weeks of lead time and then flatten, and a linear model keeps climbing. It stays in the service as a reference, so every quote shows how much the main model adds.
Limitations
The data is synthetic. In it, fares rise with booking lead time for about three weeks and then level off. That is the opposite of real UK advance fares, so the model reproduces the data and is not pricing advice.
Bookings are front-loaded. A third are same-day and only 655 are more than 60 days out, so the curve beyond 60 days rests on thin data.
The demand index is one score per departure in the source data. The demo treats it as the operator’s forecast, which assumes it would be known when quoting.