available · data science and machine learning roles · UKManchester, UK
bright@uzosike:~$

VoyageRail fare desk

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.

LIVEDynamic pricing20262 min readLive console

R² · gradient boosting · holdout
0.90
5-fold CV 0.903 ± 0.002
Average error · holdout
£6.53
vs £11.21 for the linear baseline
Model size
0.6 MB
about 26 times smaller than a random forest

Problem

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
14.159.910615219724314.159.910615219724348.8, 48.817.1, 17.156.4, 56.425.1, 25.1110, 11037.2, 37.244.3, 44.335.1, 35.187.9, 87.9100, 10045.8, 45.842.9, 42.9111, 11147.5, 47.546.3, 46.356.5, 56.525.4, 25.440, 40116, 11680.6, 80.634.7, 34.772.5, 72.559, 5934.7, 34.796.1, 96.147.7, 47.742.7, 42.792.2, 92.244.8, 44.856.5, 56.541.1, 41.156, 5635.8, 35.835.1, 35.170, 7048.2, 48.273.8, 73.862.5, 62.5105, 10540.9, 40.944.8, 44.825, 2574.7, 74.757.6, 57.653.4, 53.442.4, 42.463.6, 63.627.6, 27.625.2, 25.271, 7160.5, 60.514.1, 14.130, 3065.5, 65.591.3, 91.321.1, 21.154.9, 54.966.7, 66.732.1, 32.1122, 12238.2, 38.239.9, 39.9243, 243127, 12739.5, 39.575.9, 75.946.7, 46.762.4, 62.488.9, 88.945.8, 45.847.7, 47.734.7, 34.739.7, 39.721.4, 21.464.4, 64.474.1, 74.173.1, 73.159.4, 59.443, 4341.6, 41.640.1, 40.197.3, 97.355.1, 55.1120, 12055.6, 55.629.1, 29.150.6, 50.6104, 10455.8, 55.8104, 10436.7, 36.752, 5283.1, 83.146.3, 46.321.7, 21.727.9, 27.9119, 11938.8, 38.853.5, 53.570.6, 70.624.7, 24.7144, 14443.5, 43.558.2, 58.261.3, 61.357.6, 57.634.6, 34.654, 5480.5, 80.547.2, 47.282.8, 82.845.1, 45.1107, 10767.1, 67.122.1, 22.183.8, 83.845.1, 45.196.7, 96.743.3, 43.335.6, 35.646.1, 46.1184, 18460.4, 60.4102, 102115, 11576, 7672, 7242.8, 42.835.8, 35.832.5, 32.551.8, 51.865.4, 65.450.7, 50.738.9, 38.923.1, 23.114.8, 14.821.8, 21.889.6, 89.665.4, 65.480.1, 80.119.1, 19.186.1, 86.135.1, 35.158.8, 58.824.1, 24.125, 2547.3, 47.328.9, 28.961.7, 61.769.5, 69.556.1, 56.168.9, 68.924.7, 24.7156, 15650.5, 50.541.4, 41.442.7, 42.768.5, 68.565.6, 65.651.2, 51.247.2, 47.233.5, 33.533, 3395.4, 95.440.8, 40.821.6, 21.643.8, 43.851.8, 51.823.3, 23.364.2, 64.291.2, 91.277.6, 77.673.4, 73.4139, 13988.4, 88.480.2, 80.248, 4837.3, 37.371, 7167.2, 67.2119, 11936.9, 36.972.5, 72.560, 6036.9, 36.927.9, 27.9101, 10187.6, 87.657.5, 57.547.5, 47.5121, 12150.7, 50.7104, 10416.2, 16.219.6, 19.683.8, 83.828.3, 28.344.9, 44.970.7, 70.738.9, 38.9129, 12935.3, 35.324.4, 24.431.2, 31.273.1, 73.192.5, 92.540.6, 40.662, 6218.1, 18.139.4, 39.449.2, 49.218.5, 18.525, 2550.8, 50.874.9, 74.966, 6649, 4921, 2127.2, 27.277.3, 77.339.8, 39.817.9, 17.920.2, 20.272.4, 72.453.1, 53.188.6, 88.675.3, 75.371.2, 71.282.7, 82.719.7, 19.750.1, 50.168.2, 68.241.6, 41.6167, 16784, 8448.1, 48.175.8, 75.8162, 16259.2, 59.270.5, 70.555, 5576.3, 76.356.5, 56.549.4, 49.439.3, 39.348.2, 48.280.9, 80.988.6, 88.680.7, 80.722.3, 22.367.3, 67.340.2, 40.258.5, 58.557.7, 57.740.7, 40.722.1, 22.151.7, 51.737.1, 37.140.7, 40.727.1, 27.179.1, 79.116.5, 16.557.2, 57.258, 5836.6, 36.640.4, 40.438.9, 38.956.8, 56.845.3, 45.316.9, 16.930.7, 30.791.8, 91.824.5, 24.561.7, 61.721.9, 21.937.9, 37.983.1, 83.133.8, 33.836.4, 36.461.1, 61.1112, 11245.7, 45.763.7, 63.765, 6567.4, 67.432.6, 32.615.1, 15.149.3, 49.345.3, 45.348.7, 48.736.7, 36.744.6, 44.659.1, 59.160.6, 60.6102, 10241.9, 41.931.3, 31.363.7, 63.719.6, 19.687.9, 87.948.3, 48.369.7, 69.728.2, 28.292.9, 92.939.4, 39.461.2, 61.235.4, 35.422.5, 22.5184, 18425.7, 25.744.8, 44.835.5, 35.582.5, 82.545.2, 45.269.2, 69.266.4, 66.436.5, 36.566.1, 66.141.7, 41.773, 73108, 10846.7, 46.726.7, 26.757.1, 57.162.2, 62.241.7, 41.737.3, 37.347.7, 47.778.9, 78.953.7, 53.766.5, 66.5142, 14262.6, 62.6112, 11270.5, 70.570.4, 70.454, 5446, 4646, 4643.2, 43.229.7, 29.725.9, 25.9103, 103131, 13181, 8128.4, 28.4115, 11522.1, 22.192.2, 92.260.2, 60.272, 7227.4, 27.480, 8083.3, 83.329.2, 29.253.1, 53.137.4, 37.456.1, 56.152.8, 52.868.6, 68.692.1, 92.1100, 10044.9, 44.945.1, 45.1109, 10915.9, 15.932.8, 32.890.8, 90.817.2, 17.246.5, 46.5142, 14287.4, 87.487.4, 87.426.4, 26.478.3, 78.321.7, 21.763.8, 63.853.7, 53.768.6, 68.693.4, 93.421.4, 21.462.3, 62.355.4, 55.440.7, 40.787.1, 87.155.1, 55.155.3, 55.387.7, 87.7144, 14425.6, 25.623.1, 23.134.4, 34.480.6, 80.656.4, 56.492.1, 92.149.3, 49.338.5, 38.516.9, 16.979.6, 79.644.7, 44.7base fare + premium (£)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.848.8
17.117.1
56.456.4
25.125.1
110110
37.237.2
44.344.3
35.135.1
87.987.9
100100
45.845.8
42.942.9
111111
47.547.5
46.346.3
56.556.5
25.425.4
4040
116116
80.680.6
34.734.7
72.572.5
5959
34.734.7
96.196.1
Seats sold against the number of bookings, per departure
8515021528034541085150215280345410196, 196179, 179249, 249112, 112219, 21985, 85148, 148244, 244256, 256377, 377238, 238389, 389115, 115165, 165159, 159389, 389279, 279215, 215115, 11585, 85250, 250229, 229349, 349292, 292334, 334235, 235305, 305119, 119213, 213148, 148303, 303158, 158163, 163178, 178168, 168193, 193173, 173119, 119203, 203283, 283182, 182156, 156160, 160198, 198232, 232166, 166273, 273305, 305311, 311354, 354321, 321300, 300143, 143235, 235251, 251143, 143278, 278188, 188132, 132217, 217302, 302350, 350198, 198132, 13299, 99227, 227286, 286306, 306138, 138365, 365156, 156134, 134277, 277145, 145171, 171156, 156200, 200195, 195320, 320161, 161292, 292166, 166362, 362209, 209101, 101158, 158189, 189132, 132383, 383246, 246297, 297210, 210154, 154222, 222243, 243265, 265328, 328281, 281181, 181393, 393153, 153179, 179194, 194191, 191212, 212245, 245173, 173209, 209322, 322266, 266226, 226293, 293222, 222198, 198192, 192217, 217175, 175131, 131220, 220108, 108223, 223157, 157225, 225183, 183217, 217211, 211262, 262163, 163189, 189117, 117183, 183329, 329213, 213194, 194221, 221178, 178174, 174151, 151120, 120175, 175174, 174173, 173206, 206256, 256273, 273148, 148276, 276195, 195151, 151235, 235182, 182266, 266330, 330147, 147204, 204209, 209358, 358184, 184161, 161390, 390152, 152270, 270396, 396327, 327175, 175345, 345305, 305367, 367234, 234354, 354298, 298172, 172165, 165361, 361339, 339218, 218189, 189308, 308240, 240232, 232187, 187258, 258138, 138156, 156176, 176309, 309148, 148303, 303219, 219336, 336150, 150127, 127230, 230269, 269334, 334312, 312216, 216330, 330228, 228137, 137260, 260245, 245338, 338189, 189213, 213247, 247327, 327217, 217379, 379222, 222298, 298180, 180250, 250255, 255266, 266156, 156177, 177237, 237175, 175113, 113256, 256152, 152189, 189162, 162192, 192148, 148115, 115410, 410294, 294385, 385225, 225194, 194230, 230310, 310325, 325245, 245238, 238176, 176323, 323178, 178235, 235352, 352178, 178348, 348199, 199313, 313271, 271330, 330161, 161339, 339272, 272299, 299163, 163346, 346210, 210125, 125245, 245231, 231260, 260134, 134314, 314388, 388325, 325187, 187360, 360178, 178199, 199278, 278404, 404303, 303221, 221354, 354279, 279130, 130256, 256251, 251258, 258158, 158247, 247403, 403210, 210288, 288211, 211168, 168190, 190166, 166118, 118323, 323299, 29988, 88166, 166176, 176391, 391360, 360215, 215170, 170259, 259341, 341194, 194362, 362229, 229255, 255145, 145212, 212191, 191188, 188195, 195199, 199125, 125287, 287332, 332384, 384325, 325212, 212237, 237312, 312223, 223183, 183227, 227257, 257296, 296394, 394286, 286317, 317252, 252322, 322263, 263240, 240209, 209242, 242161, 161165, 165340, 340342, 342298, 298169, 16992, 92366, 366341, 341208, 208249, 249352, 352155, 155307, 307233, 233132, 132175, 175152, 152290, 290240, 240160, 160276, 276202, 202250, 250143, 143168, 168189, 189271, 271296, 296346, 346158, 158225, 225204, 204140, 140269, 269222, 222234, 234358, 358178, 178383, 383355, 355349, 349220, 220188, 188219, 219210, 210326, 326154, 154217, 217256, 256161, 161196, 196204, 204132, 132257, 257247, 247271, 271135, 135246, 246174, 174169, 169378, 378175, 175128, 128137, 137276, 276211, 211218, 218198, 198234, 234bookings for the departureseats sold (realised)
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 departureseats sold (realised)
196196
179179
249249
112112
219219
8585
148148
244244
256256
377377
238238
389389
115115
165165
159159
389389
279279
215215
115115
8585
250250
229229
349349
292292
334334

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
days before travel
Show as table
days before travelValue
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
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 travelValue
same day169,037
1 to 383,842
4 to 780,395
8 to 1488,665
15 to 2141,583
22 to 3028,995
31 to 4521,592
46 to 604,949
61 to 89655

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
  1. Fare deskroute, class, timing
  2. FastAPI servicechecks route and ranges
  3. Route lookupdistance, category, seats
  4. Modelsgradient boostinglinear baseline
  5. SHAP driversin pounds, summing exactly
  6. Quote and curve0 to 89 days out

Evaluation

R² on the same holdout splithigher is better
Gradient boosting beat the random forest on accuracy, size and training time, and the linear baseline by a wide margin.
Show as table
ItemValueNote
Gradient boosting, quote-time features0.903shipped, 0.6 MB
Random forest, quote-time features0.86417 MB
Linear baseline, quote-time features0.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.

Stack

  • Python
  • scikit-learn
  • gradient boosting
  • SHAP
  • MLflow
  • FastAPI
  • Docker Compose

All work

shell · brightuzosike.devEsc to close · every command is also a link