BayesianBahn · evaluation · 2026-08-17, 2026-08-18, 2026-08-19, 2026-08-20, 2026-08-21, 2026-08-22, 2026-08-23, 2026-08-24, 2026-08-25, 2026-08-29, 2026-08-30, 2026-08-31, 2026-09-01, 2026-09-02, 2026-09-03, 2026-09-04

Forecasts against DB’s own

BayesianBahn’s arrival forecasts and DB’s, scored against what the trains actually did. Sample sizes, method and the limits of each figure are given alongside it.

What was measured

Every ten minutes, DB’s own forecast was recorded for twenty stations chosen in advance across the whole network — six major hubs down to three village halts. The next day the archive says when each train really arrived, and both forecasts are scored against that.

The model is the app’s own prediction code at commit eee57d9ba683, which declares version 0.3.0 (versionCode 7). That commit is not a released version, and the version number above is the last released one rather than this code's — the bump happens in the release commit, so a candidate carries its predecessor's. The app code has moved on in 7 commit(s) since 0.3.0 was tagged. The app published in the stores does not contain this model unless a later release says so. It is only ever shown history from before the day it predicts. It appears twice: as shipped, which when DB actually reports a delay anchors on that report and widens it by the error such reports are measured to carry at that much notice, and leans on the train’s own past runs when DB says nothing; and history only, which never looks at the live number at all. The same treatment is given to the connecting train’s departure report, so a change is the difference of two admitted errors rather than one distribution and one promise.

Two kinds of answer are scored, and they correspond to the two kinds of journey the app plans. For a journey without a change the answer is an arrival time, scored as a distribution against the arrival that happened. For a journey with one change the answer is the probability of making that change, scored against whether it was made. The two use different scores and are not comparable with each other; each is compared only with DB’s answer to the same question. A third answer joins them below: the complete two-leg journey — the predicted arrival at the far end of a change, against the arrival that happened. It is in the same units as a direct journey, so those two are comparable with each other.

296,619arrival predictions scored
395,401one-change connections scored
history onlyscores below DB from 20-45m before departure
27%of missed connections DB called correctly

The comparison, with its uncertainty

Each row is BayesianBahn’s score minus DB’s over the same predictions, so a negative number is the lower score for BayesianBahn, and lower is better for both scores used here. The interval is what decides it: delays arrive in clusters — one late train produces a dozen correlated predictions — so the range comes from resampling whole trains, not individual predictions. Where the interval crosses zero, the collected days are not enough to claim anything, however suggestive the middle number looks.

ComparisonPredictionsScoreOurs − DB95% intervalReading
Arrival time, as shipped296,619CRPS, minutes-0.695-0.732 to -0.659BayesianBahn lower
Arrival time, history only296,619CRPS, minutes-0.426-0.456 to -0.396BayesianBahn lower
Every connection, as shipped395,401Brier-0.005-0.006 to -0.005BayesianBahn lower
Every connection, history only395,401Brier-0.001-0.002 to -0.000BayesianBahn lower
Missed connections, as shipped14,835Brier-0.242-0.256 to -0.227BayesianBahn lower
Missed connections, history only14,835Brier-0.056-0.084 to -0.025BayesianBahn lower
Journey with a change, as shipped60,308CRPS, minutes-1.756-1.919 to -1.608BayesianBahn lower
Journey with a change, history only60,308CRPS, minutes-1.771-1.933 to -1.625BayesianBahn lower

How far ahead, and how wrong

Lead time is counted back from the train’s scheduled departure, because that is when a passenger can still act on the answer. Lower is better; the scores are in minutes and are directly comparable — see the definitions at the foot of the page for why a point forecast and a distribution can be put on one axis.

Read the history-only series as a flat reference, not as a trend. It never looks at the live number and the arrival it predicts does not move, so its answer for a given stop is the same however early it is asked — checked on every render, and for the stops polled more than once the quantiles are identical at every lead time. What little the column does move across the buckets is composition and not behaviour: a train first seen forty minutes out is in the near buckets and in none of the far ones, so each bucket averages a different set of trains. Held to the stops present in all six, the history-only figure is the same in every bucket to three decimal places, while DB still climbs from 1.49 to 3.08 and the shipped model from 1.60 to 2.28. The degradation with lead time is real for the two series that read the live number, and only for those two.

  • DB
  • BayesianBahn, history only
  • BayesianBahn, as shipped
0.00.91.92.83.8<10m10-20m20-45m45-90m1.5-3h>3hDB — <10m: 1.60DB — 10-20m: 1.90DB — 20-45m: 2.44DB — 45-90m: 2.98DB — 1.5-3h: 3.21DB — >3h: 3.30BayesianBahn, history only — <10m: 2.19BayesianBahn, history only — 10-20m: 2.18BayesianBahn, history only — 20-45m: 2.24BayesianBahn, history only — 45-90m: 2.23BayesianBahn, history only — 1.5-3h: 2.22BayesianBahn, history only — >3h: 2.22BayesianBahn, as shipped — <10m: 1.44BayesianBahn, as shipped — 10-20m: 1.59BayesianBahn, as shipped — 20-45m: 1.88BayesianBahn, as shipped — 45-90m: 2.13BayesianBahn, as shipped — 1.5-3h: 2.21BayesianBahn, as shipped — >3h: 2.23CRPS, minutes (lower is better)
Before departurePredictionsDBHistory onlyAs shippedDB biasDB surprises
<10m21,9501.602.191.44-1.105%
10-20m49,2711.902.181.59-1.387%
20-45m77,8852.442.241.88-1.9810%
45-90m49,8752.982.232.13-2.6713%
1.5-3h49,1313.212.222.21-2.9214%
>3h48,5073.302.222.23-3.0715%

The two variants cross over in this bucket. In the >3h bucket the shipped model scores 2.23 against history alone at 2.22 — leaning on DB’s number makes the answer 0.01 minutes worse there. That is the shape this model was changed to remove, so a crossover appearing here means a reported delay is being believed in a range where it should not be.

How the errors are distributed

The means above summarise a badly skewed distribution, and the two forecasts differ in its upper tail rather than in its middle. That is worth reading in full, because a forecast a minute out and one twenty minutes out have very different consequences for a passenger.

One asymmetry has to be held in mind while reading the boxes. DB answers with a single minute, so its error is a whole number of minutes and is exactly zero — the train arrived in the minute DB named — for 41% of all predictions here. A distribution cannot score zero: it always pays something for its own spread, however well placed it is. So the bottom of the two boxes is not a like-for-like comparison and should not be read as one; the medians and everything above them are. The same asymmetry is why DB's median is exactly 1.00 minute in every bucket.

The last two columns of the table are the point. DB's score is the lower one on slightly more than half of the individual predictions, and its mean is higher all the same. Being closer more often and worse on average is what a heavy tail looks like from the inside.

The box spans the middle half of the predictions, the ruled line across it — overhanging on both sides, so it stays visible where it meets a box edge — is the median, and the whiskers reach the 10th and 90th percentiles. The worst tenth of each forecast therefore reaches past the whisker, and is given exactly in the table below.

  • DB
  • BayesianBahn, as shipped
0.02.34.66.99.2DB — <10m: median 1.00, p25-p75 0.00-1.00, p90 3.00, p99 21.00 minBayesianBahn, as shipped — <10m: median 0.81, p25-p75 0.31-1.32, p90 2.35, p99 16.61 min<10mDB — 10-20m: median 1.00, p25-p75 0.00-2.00, p90 4.00, p99 21.00 minBayesianBahn, as shipped — 10-20m: median 0.89, p25-p75 0.33-1.47, p90 2.74, p99 16.76 min10-20mDB — 20-45m: median 1.00, p25-p75 0.00-2.00, p90 6.00, p99 27.00 minBayesianBahn, as shipped — 20-45m: median 0.89, p25-p75 0.34-1.74, p90 3.55, p99 20.71 min20-45mDB — 45-90m: median 1.00, p25-p75 0.00-3.00, p90 7.00, p99 34.00 minBayesianBahn, as shipped — 45-90m: median 0.84, p25-p75 0.33-1.97, p90 4.34, p99 24.70 min45-90mDB — 1.5-3h: median 1.00, p25-p75 0.00-3.00, p90 8.00, p99 38.00 minBayesianBahn, as shipped — 1.5-3h: median 0.82, p25-p75 0.33-1.80, p90 4.65, p99 26.55 min1.5-3hDB — >3h: median 1.00, p25-p75 0.00-3.00, p90 8.00, p99 40.00 minBayesianBahn, as shipped — >3h: median 0.82, p25-p75 0.33-1.80, p90 4.76, p99 26.88 min>3hError, minutes (lower is better)
Before departurePredictionsDB medianOur medianDB p90Our p90DB p99Our p99DB over 15 minOurs over 15 minDB lower on the predictionDB meanOur mean
<10m21,9501.000.813.002.3521.0016.611.5%1.1%58.6%1.601.44
10-20m49,2711.000.894.002.7421.0016.761.7%1.2%55.9%1.901.59
20-45m77,8851.000.896.003.5527.0020.712.6%1.7%51.9%2.441.88
45-90m49,8751.000.847.004.3434.0024.703.8%2.1%48.1%2.982.13
1.5-3h49,1311.000.828.004.6538.0026.554.5%2.4%47.5%3.212.21
>3h48,5071.000.828.004.7640.0026.884.7%2.4%47.2%3.302.23

The last two columns give the share of forecasts out by more than 15 minutes. Over 296,619 predictions, most of which are uneventful, this is the part of the distribution a mean is least able to convey.

Does the 80% range hold?

The app gives not only a time but a range stated to contain the true arrival four times in five. That is checkable: count how often the real arrival fell inside it. A bar at the dashed line matches the stated probability; below it the range is narrower than the forecast’s accuracy supports, and above it wider.

  • BayesianBahn, history only
  • BayesianBahn, as shipped
0%25%50%75%100%80% — what the app claimsBayesianBahn, history only — <10m: 0.884BayesianBahn, as shipped — <10m: 0.924<10mBayesianBahn, history only — 10-20m: 0.881BayesianBahn, as shipped — 10-20m: 0.91010-20mBayesianBahn, history only — 20-45m: 0.881BayesianBahn, as shipped — 20-45m: 0.89620-45mBayesianBahn, history only — 45-90m: 0.882BayesianBahn, as shipped — 45-90m: 0.88845-90mBayesianBahn, history only — 1.5-3h: 0.882BayesianBahn, as shipped — 1.5-3h: 0.8841.5-3hBayesianBahn, history only — >3h: 0.882BayesianBahn, as shipped — >3h: 0.883>3hShare of arrivals inside the stated range
Before departurePredictionsHistory onlyAs shipped
<10m21,95088%92%
10-20m49,27188%91%
20-45m77,88588%90%
45-90m49,87588%89%
1.5-3h49,13188%88%
>3h48,50788%88%

Connections

A connection here is a train arriving at one of the sampled stations and another leaving it a few minutes later, judged from before the first train set off — the last moment at which the answer could still change a decision. DB’s timetable answers yes or no; the app answers with a probability.

  • DB
  • BayesianBahn, history only
  • BayesianBahn, as shipped
0.0000.0260.0520.0780.103DB — 2-5 min: 0.086BayesianBahn, history only — 2-5 min: 0.078BayesianBahn, as shipped — 2-5 min: 0.0712-5 minDB — 6-10 min: 0.047BayesianBahn, history only — 6-10 min: 0.046BayesianBahn, as shipped — 6-10 min: 0.0396-10 minDB — 11-20 min: 0.025BayesianBahn, history only — 11-20 min: 0.025BayesianBahn, as shipped — 11-20 min: 0.02111-20 minDB — 21-30 min: 0.011BayesianBahn, history only — 21-30 min: 0.012BayesianBahn, as shipped — 21-30 min: 0.01021-30 minBrier score (lower is better)
Time to changeConnectionsActually caughtDBHistory onlyAs shipped
2-5 min56,07690%0.0860.0780.071
6-10 min65,50294%0.0470.0460.039
11-20 min135,47397%0.0250.0250.021
21-30 min138,35099%0.0110.0120.010

Split by what actually happened

Almost every connection is caught, so answering “yes” every time scores well on the pooled average, and a yes/no answer taken from the timetable is close to doing that. The two outcomes are therefore worth reading apart: the connections that failed are the smaller group and the one the pooled figure says least about.

OutcomeConnectionsDB called it rightDB BrierHistory only…mean P(catch)As shipped
Connection was caught380,56699%0.0050.0060.970.009
Connection was missed14,83527%0.7340.6790.810.493

Does it hold from one day to the next?

Everything above pools the collected days. Pooling gives the more precise estimate; it cannot show whether a result is a property of the model or of one day’s conditions. Here each day stands alone. A column that points the same way in every row is the more durable result; one that changes sign between days is within the range of day-to-day variation.

Read the row lengths too. The first day was collected from the evening onwards, so it is both smaller and drawn only from the busiest hours — a day with fewer missed connections here is not necessarily a calmer day.

DayPredictionsDBHistory onlyAs shipped80% range, history80% range, shipped
2026-08-173,8942.932.592.2086%87%
2026-08-1820,9412.862.522.1588%90%
2026-08-1920,5733.402.882.4685%87%
2026-08-2020,9143.192.672.3389%90%
2026-08-2120,7353.182.512.2588%89%
2026-08-2217,7251.911.781.5491%92%
2026-08-2316,6131.761.731.5891%92%
2026-08-2421,6062.882.332.0889%90%
2026-08-2521,1423.452.872.5385%87%
2026-08-2917,6132.572.161.8988%89%
2026-08-3016,4892.081.861.6790%90%
2026-08-3119,8861.831.611.4590%91%
2026-09-0119,9362.421.871.6788%90%
2026-09-0220,3722.412.011.7290%92%
2026-09-0319,3082.231.721.5589%90%
2026-09-0418,8723.002.402.1083%85%

The missed connections, day by day

This is the smallest sample on the page and the one most easily over-read.

DayMissedDB BrierHistory onlyAs shipped
2026-08-171890.9630.7210.690
2026-08-181,2180.6960.7600.484
2026-08-191,6930.6390.7010.453
2026-08-201,6110.6540.6750.440
2026-08-211,5780.6360.6670.443
2026-08-225010.8420.6740.556
2026-08-234560.7350.7380.510
2026-08-241,4150.7210.7060.517
2026-08-251,6740.6470.7140.471
2026-08-297220.6660.6400.433
2026-08-303910.8260.5810.509
2026-08-314960.8770.5670.488
2026-09-017460.9520.6330.593
2026-09-026620.9180.6310.569
2026-09-036470.9270.6230.557
2026-09-048360.8440.6530.534

Journeys with a change, end to end

Everything above scores a journey with a change by whether the change works. That is not the question a passenger has, which is when they get there — and it cannot be compared with the direct-journey figures, because a probability and a distribution over arrival times are not in the same units. This section asks the passenger's question. The answer is scored the same way a direct journey is, in minutes of CRPS, so the two rows can finally be read against each other.

Both forecasters are asked before the feeder sets off and see the same 5.7 candidate trains on average. DB answers with the arrival of whichever train its own forecasts say the passenger catches; ours answers with a distribution over the arrival, which includes missing the planned connection and taking a later train — it puts 0.8% of its weight on catching none of the candidates at all. The truth is the arrival of whichever train was actually caught.

Truth is not held to that list. A passenger who misses every train the app offered waits for the next one that runs, and the forecast is then wrong by however long that took — so the walk to the train actually boarded goes on past the end of the list, and both forecasters are charged for the difference. That happens on 28.4% of the journeys here. Scoring only the ones that fit inside the list would drop exactly the answers that were most wrong: it raised this model's margin over DB by about 0.8 minutes of CRPS while discarding a third of the evidence.

This is the newest part of the page and rests on the fewest days: the far end of a change only began being polled once the second tier existed, so this table starts later than every other one here.

The table is measured over the journeys both variants answered. They do not answer the same set: a connecting train with too little shared history can be carried by a live report and cannot be carried without one, so the shipped model answers 63,720 journeys against the blinded model's 62,031, and 60,308 are answered by both. Measuring each over its own set would put answers to different questions in one row — it made the live number look worth four times what it is worth on the journeys both answered. Answering more journeys is a real advantage of the shipped model, but it is a difference in coverage and is counted here rather than folded into a score.

Before departureJourneysBoarded past the listDBHistory onlyAs shipped80% range, history80% range, shipped
<10m3,02023%3.673.453.7872%75%
10-20m8,58323%3.823.403.4872%76%
20-45m26,78946%6.104.794.7576%77%
45-90m15,31013%7.916.146.1670%72%
1.5-3h6,2610%18.0812.0712.0470%71%
>3h3450%20.8113.3613.3868%68%

Which kinds of train, and which kinds of line

Everything above pools every train at every sampled station. The model does not treat them alike: it carries a separate prior per class of train — a mean of 4.0 minutes for long distance against 1.0 for an S-Bahn — so the classes are the axis those constants were chosen on, and splitting by them is the only way to see whether each one earns its value. Lower is better; the gap is BayesianBahn's score minus DB's, with the interval that decides it.

Kind of trainPredictionsDBHistory onlyAs shippedOurs - DB95% interval80% range
Long distance2,6116.606.704.88-1.717-2.341 to -1.04786%
Regional110,0573.582.972.50-1.083-1.161 to -1.00789%
Bus (regional prior)34,1524.624.203.86-0.763-0.871 to -0.65483%
S-Bahn149,7991.441.141.07-0.376-0.399 to -0.35591%

A category that is neither long distance nor one of the named regional ones still falls through to the regional prior, because that is what TrainClass.fromCategory does with anything beginning with a letter. Replacement buses reach it that way, and they are a third of every regional prediction here. They are not trains — they do not share the tracks and are not held by the same signals — and they score about half a minute worse than the regional trains they were pooled with, so they are given their own row. The model is unchanged: the row name says which prior they were scored under.

Working days and the weekend

The two are different networks to forecast. DB’s own mean error is 2.82 minutes from Monday to Friday against 2.08 minutes at the weekend, so there is substantially less to improve on in the quieter half. Each figure below is BayesianBahn’s score minus DB’s, computed within that part of the week alone.

DaysPredictionsDB’s own errorArrival, shippedArrival, history onlyMissed connectionsMissed, shipped
Monday to Friday228,1792.82-0.780 (-0.818 to -0.742)-0.495 (-0.530 to -0.462)12,765-0.239 (-0.255 to -0.223)
Saturday and Sunday68,4402.08-0.410 (-0.456 to -0.365)-0.196 (-0.242 to -0.151)2,070-0.260 (-0.286 to -0.235)

An interval that spans zero says the collected days cannot separate the two forecasters in that part of the week, not that they are equal.

Delay through the day

Delay is not a property of a train alone: a late train occupies a platform and the next one can inherit some of it, so delay accumulated in the morning may still be on the network in the afternoon. If so, an hour-of-day term describes the data rather than merely fitting it.

The collected days show that pattern. The mean arrival delay climbs from 1.16 min at 01:00 to 4.18 min at 16:00, a factor of 3.6, and then drains overnight. Each train counts once, at its scheduled arrival hour, however many times it was polled. Only days that cover the whole clock are included, and only hours carrying at least 100 trains.

0.0001.2552.5113.7665.021Mean arrival delay — 00: 1.41600Mean arrival delay — 01: 1.15901Mean arrival delay — 03: 1.39603Mean arrival delay — 04: 1.59904Mean arrival delay — 05: 1.63105Mean arrival delay — 06: 2.20406Mean arrival delay — 07: 2.55107Mean arrival delay — 08: 2.96408Mean arrival delay — 09: 3.11509Mean arrival delay — 10: 3.01910Mean arrival delay — 11: 2.91411Mean arrival delay — 12: 2.93412Mean arrival delay — 13: 2.96313Mean arrival delay — 14: 3.24314Mean arrival delay — 15: 3.91515Mean arrival delay — 16: 4.18416Mean arrival delay — 17: 4.17817Mean arrival delay — 18: 3.74018Mean arrival delay — 19: 3.10219Mean arrival delay — 20: 3.31220Mean arrival delay — 21: 3.22921Mean arrival delay — 22: 2.76322Mean arrival delay — 23: 2.78023Mean arrival delay, minutes

The maximum falls in the mid-afternoon rather than at the evening rush. One reading is that accumulated delay is worked off after the last peak departures, so the worst hour to arrive is set by how much delay the network is still carrying rather than by passenger numbers.

What that means for the time bands

The model keeps separate delay statistics per time band, which only makes sense if the hours inside a band resemble each other. Spread is the distance between the latest and earliest hourly mean inside the band: a large spread means the band is averaging together hours that behave differently.

BandHoursQuietest hourLatest hourSpread
MORNING_PEAK06-082.202.960.76
MIDDAY09-152.913.921.00
EVENING_PEAK16-183.744.180.44
NIGHT19-051.163.312.15

The bands that ship were chosen from the commuter timetable rather than from this curve, and do not line up with it: the peak band ends before the peak, and the night band spans the range from the quietest hour of the night to the tail of the evening. This is an observation about the model’s bucketing rather than about the trains, and it is only as good as the days collected so far.

Definitions

CRPS — continuous ranked probability score
The integral of (F(x) − 1{x ≥ y})² over all x, where F is the forecast’s cumulative distribution and y is what happened. In minutes; lower is better. A point forecast is a distribution with all its weight in one place, and for that case CRPS is exactly the absolute error — which is why DB’s column and ours sit on the same axis. It is the only score here that credits the whole distribution rather than just its middle.
Brier score
Mean squared distance between a stated probability and the 0/1 outcome; lower is better. A yes/no answer is a probability of 1 or 0, so its Brier score is simply the share it got wrong. This makes “yes” and “72%” comparable.
DB bias
Mean signed error. Negative means DB predicted the train earlier than it arrived — optimism, which is the direction that costs a passenger a connection.
DB surprises
Share of arrivals more than 5 minutes later than DB said. This is the error that turns into a missed change.
As shipped vs history only
Both are the same model. As shipped feeds it DB’s live number for the station, but only when that number is a reported delay: DB states a stop in four shapes and three of them mean “on time”, which is the plan restated rather than an observation, so those are passed through as no report at all. History only withholds the live number in every case. The model was built to take the measured delay at the train’s previous stop, and the live path substitutes this station’s forecast instead — an approximation documented in the source, and where DB does report a delay the gap between these two columns is what it costs.
Ground truth
The archive’s recorded arrival minus the scheduled arrival, the same definition used to build the history the model learns from. Cancelled stops are excluded from the delay scores.
Time to change
Scheduled gap between the feeder arriving and the connection leaving, less the 5 minutes assumed for walking between platforms.

What this does not establish

Reproducing this

Every figure above comes from the commands below. The collector must have been running on the day in question; the archive publishes the ground truth the following morning.

# collect (runs continuously, survives restarts and power cuts)
pixi run -e evaluate collect
pixi run -e evaluate collect-status      # is it still healthy?

# once the archive has published the day, score it and rebuild
# this page — stages already done are skipped, so adding a day
# costs only that day
pixi run -e evaluate evaluate 2026-08-17 2026-08-18 2026-08-19 2026-08-20 2026-08-21 2026-08-22 2026-08-23 2026-08-24 2026-08-25 2026-08-29 2026-08-30 2026-08-31 2026-09-01 2026-09-02 2026-09-03 2026-09-04