I replaced straight-line distance with real transit routing — and my price model got worse
flatlas.sg’s hedonic model explains a flat’s price with a handful of terms:
unit size, remaining lease, storey, town fixed effects, land use, and two
distance terms — how far the block is from the nearest MRT station, and how
far it is from the CBD. Both of those started life as straight-line distance:
ST_Distance between two points on a sphere, no roads, no rivers, no train
lines involved.
That always bugged me a little. Straight-line distance treats Singapore as featureless. Two blocks the same crow-flies distance from a station can have wildly different real commutes — one’s a five-minute walk to the platform, the other is on the far side of an expressway and needs a bus to get there at all. A straight line doesn’t know the difference. I had a hypothesis: real transit routing has to be a better predictor of what people actually pay for, because what people pay for is the commute, not the geometry.
So I built it. The controlled test said I was wrong.
Building the routing feature
The plan was straightforward: for every block, ask OneMap’s public-transport
router (routeType=pt) for the walk-plus-bus/train journey to the CBD —
Raffles Place — and store the resulting distance and number of transfers.
etl/transit_routing.py reuses the same OneMap auth as the geocoder
(ONEMAP_EMAIL/ONEMAP_PASSWORD, no new secret), and it routes against a
fixed future time — the next weekday at 08:00 — so every block gets scored
against the same commute assumption regardless of what day the script
happens to run. Routing against “now” would have made a Tuesday-afternoon
run and a Monday-morning run produce different numbers for the same block,
which is its own kind of noise.
Two locked-decision patterns carried straight over from the geocoder, because they’d already paid for themselves once:
- Permanent caching. Every block gets routed once and the result lands in
transit_cache.parquet, keyed byblock_no+street_name— the same key the geocode cache uses. A re-run only pays for new blocks. A full data reload nulls the database columns, but the cache survives on disk, so it doesn’t cost a single new OneMap call to repopulate them. - Skip and log, don’t force it. If OneMap can’t route a block, or the response comes back in a shape the parser doesn’t recognise, the block gets skipped and logged rather than given a wrong or default value. The response parsing here is defensive by necessity — this endpoint had never been contacted from this environment before, so I had no ground truth for what every edge case looked like.
The build also had the obligatory undocumented-API gotcha. OneMap’s routing
service wants its date parameter as MM-DD-YYYY. Not ISO. Send
2026-07-02 and you get an HTTP 400 with no useful explanation; send
07-02-2026 and it works. I found this the annoying way — by testing — and
it’s now a comment in the code so future-me doesn’t rediscover it.
With the columns landing in core.blocks.transit_distance_km and
transit_transfers (migration 031), I swapped them into the model:
hedonic_fit.py’s feature SQL and formula used the routed distances instead
of live ST_Distance calls, and explain.go read the stored columns instead
of recomputing them per request. Everything compiled, the model refit
without error, the numbers looked plausible. By every naive check, this was
done. The migration commit is timestamped 1:09 on a Thursday morning; the
revert landed at 8:25 the same morning. The feature lived for seven hours,
and I was asleep for most of them.
The controlled test
The one habit that’s saved me from myself more than once on this project: never trust a model change on vibes. Refit on the same data, the same train/test split, and compare the out-of-sample metrics before believing anything shipped.
The full swap — both dist_cbd and dist_mrt replaced by the routed
figures — made the model worse:
R²: 0.9342 -> 0.9266 (-0.0076)
MAPE: 5.97% -> 6.40% (+0.43pp)
within10%: 82.3% -> 80.0% (-2.3pp)
Every metric moved the wrong way. Not catastrophically — this isn’t a broken model, it’s a worse model — but decisively enough that shipping it would have been a regression dressed up as an upgrade.
I didn’t want to just shrug and revert, though, because the result was
surprising and I wanted to know why real routing lost to a straight line.
So I ran a second, narrower test: swap out only dist_mrt for a real
walking-route distance to the nearest station, and leave dist_cbd at its
original straight-line value. That isolated swap came back as a wash — no
meaningful change in either direction. Nearest-station distance, it turns
out, was already a good term in its straight-line form; routing it properly
didn’t add anything measurable.
Which meant the damage in the full swap wasn’t coming from “real routing is worse than straight lines” in general. It was coming from something specific to the CBD-distance term, or from combining the two changes.
Why the straight line wins here
The best explanation the data supports: the full swap collapsed two
different location signals into one. dist_cbd and dist_mrt, in their
straight-line form, aren’t measuring the same thing even though they’re both
“distance to somewhere important.” dist_mrt mostly captures local station
accessibility — is there a train near this specific block. dist_cbd
captures macro location — which part of Singapore is this, roughly.
Between them, and alongside the town fixed effects the model already carries,
they triangulate a block’s position reasonably well.
Routing both terms through the same transit network, to the same
destination, pulls them toward correlated versions of a single “commute
distance” signal. You lose the part of dist_mrt’s independent variance that
was doing real work — capturing within-town differences in station
proximity that the town fixed effect (C(town)) doesn’t capture, because it
only knows which town a block is in, not where inside it. Straight-line
dist_mrt, precisely because it’s crude and local, was carrying information
that a shared routing pipeline smeared away.
“More realistic” and “more predictive” turn out to be different properties. Real transit routing is a strictly more accurate description of the physical world. It was still a worse model input, because the model doesn’t need physical accuracy — it needs orthogonal signal, and the straight-line terms happened to be less correlated with each other than the routed ones.
What shipped instead
I reverted hedonic_fit.py and explain.go back to live ST_Distance for
both terms — matching the model that was already tested and known-good. The
routing pipeline didn’t get thrown away, though: transit_distance_km and
transit_transfers are still computed and stored, still fetched by
/api/explain, just no longer fed into the regression. They’re returned as
plain pass-through fields and rendered in the block detail panel as an
informational fact — “🚇 10.7 km · 1 transfer to the CBD” — sitting alongside
the model’s actual price terms but clearly not one of them. Same treatment as
the HDB upgrading-status badges: real, useful context that didn’t earn a
place in the price breakdown.
A feature you can explain, that intuitively should help, still has to clear the same bar as anything else before it touches production math: does it improve the out-of-sample number, on a held-out split, measured the same way every other term was measured. Mine didn’t. It cost an afternoon of OneMap calls and a revert commit, and it’s now a permanently-cached column that earns its keep as a footnote instead of a feature. That’s a fine outcome for a hypothesis that didn’t pan out — cheap to test, cheap to keep the good part, cheap to throw away the rest.
Curious what the model actually uses, and why? Poke a block on the map at flatlas.sg and open its “why this price” panel — every term is live. More on how the rest of flatlas is built in Building flatlas.sg.