正在加载完整 README。 以下为已保存的摘录。
Empirical arrival-time predictions for Deutsche Bahn trains — as a
distribution, not a point estimate.
Early release. Predictions are experimental — always cross-check
times and connections with DB's official apps before relying on them.查看完整 README ↗Empirical arrival-time predictions for Deutsche Bahn trains — as a distribution, not a point estimate.
Early release. Predictions are experimental — always cross-check times and connections with DB's official apps before relying on them.
Enter from, to and a departure time (future trips included): the app finds direct trains and one-transfer connections and predicts when you will actually arrive — median arrival, an 80% credible interval, the full distribution, and per-train catch probabilities — all computed from real historical runs, not from DB's own forecast. A Deutschland-Ticket filter restricts the search to regional trains; live station boards and per-train forecasts remain available behind the list icon.
From F-Droid — the recommended route, because updates arrive automatically and because F-Droid rebuilds the app from this repository and checks that the result matches the APK published here, byte for byte, before it distributes anything.
The GitHub releases carry the same APKs for anyone who prefers to sideload. They are signed with the key F-Droid pins for this package, so a sideloaded build and an F-Droid build upgrade over each other without an uninstall.
Android 8.0 (API 26) or newer.
The interface is English, and German on a phone set to German. Android 13 and newer can pick either one for this app alone, under the app's system settings.
pipeline/build_shards.py condenses it into one small
shard per train: every station it calls at, with date, planned time,
arrival/departure delay, delay at the previous stop, and cancellations.
It writes a second, smaller set keyed by line and station — the fallback
for a run number too new to have a history of its own.iris.noncd.db.de) for the live board — the same source DB's station
displays use.pipeline/backtest.py and the model review
in the git history for the measurements. When a
live delay is reported, the delta model shifts each run's observed
last-hop progression (final − previous stop) onto the live report,
sharpened towards runs that were similarly late. A report only counts
when it reports a delay: DB states a stop in four shapes and three of
them mean "on time", which is the plan restated rather than an
observation — see LiveReport for what
believing it cost. A train's own runs and its line's runs at that station
go into one distribution; only when the two together are still too thin does a
Bayesian Normal-inverse-gamma prior per train class and time of day answer
(closed-form Student-t predictive).n / (n + 8) of
the weight and its line's the rest, so a fresh run number answers almost
entirely from its line and a settled one barely notices the line is there.
pipeline/backtest_fallback.py walk-forwards this over 781,000 archive events
at 62 stations, resampling whole trains for the intervals. Against the class
prior on the events that used to reach it, the line is worth 0.43 min of CRPS
(95% 0.40..0.46) with a shard available for 88% of them; pooling instead of
switching is worth a further 0.105 (95% 0.094..0.116), and 0.002 where the
number already has a history (95% 0.002..0.003). Switching cannot have both
ends: half-and-half wins by 0.119 where the number has nothing and loses
0.032 across the 87% of predictions where it has plenty. End to end the pooled
model is 0.025 min ahead of the version with no line at all (95% 0.023..0.026)
and leaves 0.5% of arrivals on the prior instead of 4.8%; across the December
2025 timetable change, where the thin population doubles, 0.055 and 1.3%
instead of 11.1%. The gain is regional and S-Bahn; long distance is too rare
to say either way, because almost no long-distance train carries a line number.
The line usually comes from the train's own shard rather than from the board —
IRIS names a line on 17% of stops, the shard on 99.7% of the ones that need
it, and where both spoke they agreed every time. Above 32 effective runs of
its own a train does not read the line shard at all: the shrinkage has
converged by there, so it costs 0.002 and saves a fetch on seven predictions
in eight.Σ_k P(board k) · P(arrival | board k) over all candidate trains towards
the destination — including the case where a delayed earlier train is
still catchable. Departure and arrival delays of a candidate come from the
same historical run, preserving their correlation; feeder and candidates
are assumed independent (documented in-app). A Deutschland-Ticket
switch (on by default when the feeder is regional) restricts candidates
to covered trains (RE, RB, IRE, S-Bahn, private regional operators — no
ICE/IC/EC, night trains or FlixTrain).update-data workflow maintains three assets on the
data release: meta.json (tiny descriptor), history.zip (monthly
base, rebuilt when the archive publishes a new month) and recent.zip
(rebuilt daily by pipeline/build_recent.py from the archive's raw
IRIS logs, covering the days newer than the newest monthly file). The
in-app update checks meta.json first and fetches only the tier that
changed; the app overlays recent runs onto the base.pipeline/build_boards.py: per station, every train that
recently called there with typical times, weekday pattern and
last-seen date) and predicts blind — clearly labelled in the UI, since
timetable changes and construction can shift planned times.shards branch, where the
workflow publishes the merged base+recent set daily. Fetched shards are
cached on disk with an 18-hour refresh, so a commuter's usual
connections cost one download and then work offline. A line shard is fetched
only once the train's own shard has come up short, which is about a quarter
of predictions.pipeline/backtest.py walk-forward evaluates model
variants on months of archive data with proper scoring rules (CRPS,
pinball loss, interval coverage), reported with their upper tail rather
than as means alone. The two evaluations answer different halves and
neither replaces the other: the archive says how history should be
weighted and whether a genuine live signal is worth conditioning on,
and only the collected forecasts can say whether DB's number is a genuine
signal — the archive records what the trains did, not what DB said they
would do. On a 12-week eval (Easter–June 2026,
91k predictions) the delta model cut live-scenario CRPS 3.2× versus
ignoring live data (1.53 vs 4.83); a 30-day half-life beat 7/14/60 days;
explicit holiday handling showed no benefit even across the April–June
holidays. Parameters above are the backtest winners. That 3.2× is measured
where the live signal is a measured previous-stop delay, which is not the
same as where DB has said something. The gated scenario applies the
shipped rule to that measured signal and is deliberately a control: over
160,299 events it scores 0.580 CRPS against 0.462 for believing the signal
and 1.906 for ignoring it entirely. Gating a real measurement should cost
something, and it does — which is the evidence that the rule compensates
for how DB reports rather than for a property of delays.Current restrictions, all of them temporary and all of them things that make the app report fewer connections than DB does — never wrong ones:
At most one change. Journeys needing two or more changes are not searched at all. "No connection found" therefore means "none with at most one change", not "none exists".
A three-hour window from the requested departure at the origin, and four hours at the transfer station.
The transfer search is a heuristic, not an exhaustive one: the origin's
board is read once, every train on it comes with its own route, and the
(feeder, transfer station) pairs those routes describe are ranked by distance
to the destination. At most eight are evaluated, because each one costs a live
board request. Measured with tools/route_bench.py against exhaustive ground
truth — 4,000 journeys over five days of the archive, each with a one-change
connection that provably exists inside the windows the app itself searches —
recall still depends on how busy the origin is:
| origin | typical departures in the 3h window | found at the shipped budget | before 0.2.1 |
|---|---|---|---|
| village halt (weight < 40) | 6 | 91% | 89% |
| small station (40–100) | 10 | 86% | 83% |
| town (100–250) | 19 | 79% | 74% |
| hub (250+) | 51 | 73% | 57% |
Until 0.2.1 the eight attempts were spent feeder by feeder in departure order, two stations each, so at a hub the first four trains out consumed the whole budget while forty others were never looked at — and departure time is not evidence about whether a change works. Ordering the pairs globally costs nothing (the routes are already in the board that was fetched anyway) and spends the same 5.8 attempts a search; it just spends them on the changes nearest the destination, wherever they sit in the board. The itinerary found is also 5 minutes earlier on average, because a change near the destination is usually a change late in the journey.
Of the remaining misses, 4 points are transfer stations excluded by the detour filter, 2 are admissible but never reached inside the budget, and 1 is a station missing from the bundled list. Quoting one aggregate number would hide the shape of this, and since most journeys start at the busy end, any average over stations flatters the cases people actually use.
An earlier live measurement over 44 journeys reported 98%. That number was too kind: its ground truth was built by walking the same station boards the search walks, so it could only ever pose journeys the mechanism already sees.
Predictions need history. A connection can be found and still not be shown when too few historical runs exist for the trains involved.
Cross-check with DB's own apps before relying on any of it.
Journeys with more than one change. The current board-scanning heuristic cannot be extended to two changes without the number of live board requests exploding; doing this properly means an exact timetable router (RAPTOR or the Connection Scan Algorithm) over a real timetable feed such as GTFS.
Widen the data beyond the draft subset (trains calling at Augsburg Hbf / München Hbf) — the pipeline and the update mechanism already handle any station list.
Condition on the true previous-stop live delay instead of the current station's report.
A feeder-specific arrival residual — measured, and not currently worth it.
Feeder trains at interchanges are a different population from stops at large,
so a residual fitted only on them looked like a free improvement to the change
decision. On the population the app actually applies it to it is a
regression: Brier 0.082 against 0.073 after the shift and 0.090 against
0.082 within one regime, with roughly half again the calibration error. The
earlier apparent gain came from fitting and scoring on a subset six times
narrower than the real one, which is a caution worth keeping as much as the
result. Left here because the underlying observation is still true and a
better-posed version might pay; tools/sensitivity_live.py changes scores it
as a row, so re-checking costs one command.
Condition the anchored width on the train class. This is the largest known gap and the cheapest to close. The app carries one residual for every train, but an S-Bahn reported five minutes late is not an ICE reported five minutes late: the 10th-to-90th width at 20-45 minutes' lead is 8 minutes for an S-Bahn, 11-12 for RB and RE, 15 for an ICE and 22 for a rail-replacement bus, against the single 15.3 the app issues. Fitting the same six numbers per class is worth -7.9% of CRPS on held-out days and fixes a systematic over-coverage: S-Bahn intervals cover 95% where they claim 80%, and 77% once conditioned. Needs a fallback to the pooled fit for thin classes — only four had enough fit-day data to stand alone.
Combine the anchor with the train's own history. When DB reports a delay the app answers the delay from the report and its residual alone; only cancellation still comes from the train's record. That beats what came before it on every train class measured, but a train with fifty runs of its own knows something a population residual does not. Worth noting that most of what the old history-based path was contributing appears to be train class, above, which is recoverable with two dozen numbers rather than a two-source model — so do that first and re-measure what is left.
Done, 2026-09-06: the live-conditioned forecast was far too narrow. When DB reported a delay the model shifted each historical run's last-hop progression onto that report and treated the report as exact. It is not: has a 10th-to-90th spread of 11-12 minutes, while the interval issued was a median of two, and 42% of those arrivals landed above their own 90th percentile.
Toolchain is pinned with pixi; the Android SDK must be
available via local.properties or ANDROID_HOME.
pixi run app # build, install and launch on a device
pixi run build-app # just the debug APK
pixi run test-app # unit tests
pixi run lint-app # Android lint
pixi run -e pipeline test-pipeline # data-pipeline tests
The pipeline tests matter more than their size suggests: the nightly job's monthly path only executes when a new archive file appears, so without them a break in it stays invisible for weeks.
The unit tests cover the logic; the emulator is for what they cannot check — that a search actually returns something, and how long it takes.
One-time setup. emulator and a system image are separate SDK packages and are
not pulled in by a Gradle build:
# local.properties points Gradle at the SDK, but not the shell.
export ANDROID_HOME=$HOME/android-sdk # or whatever sdk.dir says there
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
sdkmanager platform-tools emulator "platforms;android-35" \
"system-images;android-35;default;x86_64"
avdmanager create avd -n bb -k "system-images;android-35;default;x86_64" -d pixel_6
Start it, and wait until Android is actually up — adb devices reports the
device long before the system has booted:
$ANDROID_HOME/emulator/emulator -avd bb -no-boot-anim &
adb wait-for-device
until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done
Install and launch:
pixi run app
That builds, installs and opens it, finding adb in the SDK that
local.properties names (ANDROID_HOME wins if it is set), so the shell needs
no exports of its own. Only the emulator setup above does.
To test what F-Droid will publish, install the release APK instead
(assembleRelease, needs keystore.properties). Debug and release builds are
signed with different keys, so switching between them needs
adb uninstall io.github.derweh.bayesianbahn first.
While testing:
adb logcat --pid=$(adb shell pidof io.github.derweh.bayesianbahn) # app log only
adb exec-out screencap -p > screen.png # screenshot
adb shell dumpsys package io.github.derweh.bayesianbahn | grep version
adb emu kill # stop it
Some notes that cost time to rediscover:
/dev/kvm the emulator falls back to
software and a cold boot takes many minutes. Check with ls -l /dev/kvm; the
user must be in the kvm group (sudo usermod -aG kvm $USER, then log in
again).echo $DISPLAY
should print something) and nested virtualisation is enabled on the Windows
host. If the window stays black, -gpu swiftshader_indirect renders on the
CPU. Alternatively run the emulator on Windows and reach it from WSL with
adb connect.pixi run -e pipeline python pipeline/backtest.py \
--data-dir pipeline/data --stations 8000013,8000261 --eval-weeks 12
tools/route_bench.py measures the routing heuristic offline. Each archived
stop carries a ride id and a stop number, so one day of the archive rebuilds
into the whole national timetable — ~42k train runs over ~5200 stations, which
covers 97% of the transfer-eligible stations in the app's station list. Ground
truth is then exhaustive rather than sampled, and costs no API requests:
pixi run -e pipeline python tools/route_bench.py snapshot --day 2026-06-10
pixi run -e pipeline python tools/route_bench.py bench --day 2026-06-10 --queries 1200
pixi run -e pipeline python tools/route_bench.py sweep --day 2026-06-10 --queries 1200
Does the app actually beat the number already on the platform display? That needs DB's forecast as it changed over time, which exists nowhere retrospectively — the archive records what a train did, never what was predicted beforehand. So it is collected live:
# runs continuously; append-only journal, resumes after a restart or power cut
pixi run -e evaluate collect
pixi run -e evaluate collect-status # rounds, missed slots, stations answering
Twenty stations (tools/forecast_stations.csv, stratified and fixed before any
data existed) are polled every ten minutes with jitter. The archive publishes
ground truth the next morning, after which one command scores the day end to end
and rebuilds the report:
pixi run -e evaluate evaluate 2026-08-17 [2026-08-18 ...]
The evaluate environment is the only one carrying both toolchains — polars to
build the events, the JDK to run the model over them. Stages skip work already
done, so adding a day costs only that day.
That runs the shipping Kotlin model — not the Python mirror in backtest.py —
over the recorded forecasts via the opt-in ForecastHarness unit test, with the
history trimmed to runs strictly before the day being predicted. tools/report.py
renders the result as a self-contained HTML page carrying its own definitions,
caveats and reproduction commands — including the commit it was generated from
and whether that commit is a released version, since the page is rendered from a
working tree and the model it scores is not always one that has shipped.
To publish it:
pixi run -e evaluate publish-evaluation 2026-08-17 2026-08-18 # -> docs/evaluation/
git add docs/evaluation && git commit && git push
GitHub Pages serves /docs from the default branch. This is deliberately not a
CI job: the collector runs on a laptop and the archive is fetched locally, so no
runner has the data to rebuild the page.
Every comparison is reported with a 95% interval from a bootstrap over trains, not over predictions — one late train produces a dozen correlated predictions, and treating them as independent manufactures confidence that is not there. Each day is also shown unpooled, because the first pass at this read a single evening's 189 missed connections as a result that the next day did not reproduce.
As of three days (2026-08-17/18/19, 45,408 arrival predictions, 67,796 connections) the app's point forecast beats DB's by 0.65 min of CRPS [0.60, 0.69] and is markedly better on the connections that actually failed (Brier −0.207 [−0.183, −0.232]). It wins in every lead-time bucket and on every collected day.
Both figures come from taking DB's live number seriously only when it reports a
delay. Before that change the app beat DB by 0.24 min and its stated 80% range
held 53–61% of arrivals; the range now holds 78–86% across every lead bucket
(77.7 / 84.1 / 80.5 per day) against a nominal 80%. The change is a single
condition in Predictor, and its effect was predicted to three decimal places
by scoring the rule against already-collected forecasts before it was written.
What remains open is the other half: where DB does report a delay it
understates it, saying +7.2 min for trains that arrive +10.6 on average. Beyond
90 minutes out, believing such a report is still marginally worse than ignoring
it (2.70–2.74 against 2.69–2.71 for history alone), which is the residue of the
previous-stop approximation documented in Predictor.
tools/journey_bench.py does the same thing against live IRIS. It is limited to
a few dozen journeys by politeness to a keyless public API, so it is no longer
what the constants are tuned on — but it is the only one of the two that
exercises name resolution, since offline every station is an EVA number and
identity is exact. Use it to confirm that a change measured offline survives
contact with the real API, on a handful of journeys.
# download monthly parquet files into pipeline/data/, then:
pixi run -e pipeline python pipeline/build_shards.py \
--data-dir pipeline/data --out-dir pipeline/output --stations 8000013,8000261
cp pipeline/output/shards/*.jgz pipeline/output/index.json app/src/main/assets/history/
The bundled station list (app/src/main/assets/stations.csv) derives from
db-stations (DB open data, CC BY 4.0):
pixi run -e pipeline python pipeline/build_stations.py
Its eva;name;weight;lat;lon rows carry coordinates because the journey search
ranks possible transfer stations by how far they are from the destination —
ranked by station size instead, a search from Ulm to Türkheim spent its first
attempts on Stuttgart Hbf, 130 km the wrong way.
MIT — see LICENSE. Bundled data: CC BY 4.0 by Deutsche Bahn (station list and delay history).
truth - DB's reportAnchoredDelay replaces it with the measured residual — six numbers, an
asymmetric Laplace whose width and centre grow with the lead. Held out on
four days after the rail-replacement blockade ended, so the fit had to survive
a change in what was running: CRPS 3.25 against 3.93, and the stated 80%
interval covers 89% where the old one covered 44%.
The same point mass was in ConnectionModel.propagate, where a live
departure report boarded or lost a train with certainty. Fixing only the
arrival made changes worse — the two errors had been partly cancelling — so
both went together. tools/sensitivity_live.py is the study;
tools/anchor-model.json is what was fitted and tools/tests/ test_anchor_model.py fails if the app drifts from it.
Re-fit the shrinkage as more data arrives. n / (n + 8) was chosen on
two windows of one archive, and the sweep that chose it only tried k of 4, 8
and 16 — 4 is better where the number has almost nothing and 16 is better in
the middle band, so the curve is flat enough that the number is not sharp.
The pooled intervals also cover 88.6% against a nominal 80%, a shade wider
than the 88.4% they replace, which is the wrong direction for an interval
that is already too wide.
Measure the line fallback forward. The archive backtest decided it; the
forward comparison against DB cannot report it until the daily data job has
published a set of line shards, because the collected days were scored
against a shards branch that had none. The prediction to hold it to: the
prior's share of arrivals drops from 24% to about 3%, and the arrivals figure
improves by roughly 0.1 minutes of CRPS.
Stratify the report by the kind of line, now that the second cohort samples
it: does the advantage hold on a single-track branch as well as on a
multi-track corridor, and where long-distance services share the tracks? Each
station carries its busiest-segment traffic and long-distance share in
forecast_stations_cohort2.csv, and the report reads them.
Track count and freight are still not observed. Both drive delay and neither
appears in a passenger timetable, so network_graph.py uses segment traffic
and traffic mix as proxies and says so. OpenStreetMap has the track data but
rate-limits per-station queries and leaves tracks unset on most German ways
— double track is mapped as two ways — so using it needs a bulk extract
rather than an API.
On-device TabICL v2 (BSD-3) via ONNX Runtime as the conditional model: context = this connection's historical runs, query = today's features, output = full predictive distribution.