flatlas.sg · HDB price map

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:

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.

Explore the price map →