Ride Demand Forecasting
A spatiotemporal demand model, an evaluation bug it caught red-handed, and what changed shipping it to production
The problem
This started as a take-home exercise for a ride-hailing company's data science interview, built against ride data they provided for a specific city. Reusing a company's exact prompt and dataset publicly isn't appropriate, so this is a full rebuild: the same methodology, applied honestly end to end to the public Kaggle NYC Taxi Trip Duration dataset (1.46 million trips). The original dataset included a fare field; this public one doesn't, so ride duration stands in as the per-ride signal throughout.
Architecture
Exploration
Production service
The eval bug
The modeling unit is (pickup zone, hour), and the original methodology used a straightforward 80/20 train/test split by row count. That looked fine until the numbers didn't add up: raw ride counts per zone-hour averaged 1,214 on the training set but only 304 on the test set, a 4x gap, for what should be the same underlying demand pattern.
The cause: ride volume is roughly uniform per day, so an 80/20 split by row count isn't an 80/20 split by time. Training ended up covering 145 days, testing only 38, so a zone-hour's raw ride count was being compared across two completely different-length windows. Trained and evaluated directly on that raw sum, the model's error would have looked far worse than it actually was, for a reason that had nothing to do with the model.
The fix: aggregate into an average daily ride count for that split, rather than a raw sum, which is also the number that actually matters for driver guidance (how many rides typically happen in this zone during this hour). After the fix, train and test means both land around 8 rides a day, and the baseline evaluates to 1.72 MAE and 2.67 RMSE, about a fifth of the mean.
What the exploration found
Ride volume peaks at 6pm and troughs at 5am, a 6x gap, with Friday busiest and Monday quietest. One sharp exception stands out: January 23, 2016 crashed to about 1,600 rides from a normal 7,000 to 9,500, which lines up exactly with Winter Storm Jonas, a real external demand shock the feature set has no way to see coming. Simple IQR-based outlier detection on coordinates flagged 4 to 6% of rows, too aggressive for geospatial data. Switching to an OpenStreetMap administrative-boundary check found only 0.09% genuine geo-outliers instead: GPS glitches landing near Sacramento and out over the Atlantic.
From notebook to production
Porting the notebook's logic into a real service surfaced two more issues that only matter once you're actually serving predictions. First, the notebook fit its pickup-zone KMeans on train and test coordinates combined. The production training pipeline fits it on training data only, then assigns zones to the test set with the already-fitted model.
Second, the notebook trained on average ride duration and distance as if they were known ahead of time, but those are outcomes of rides that haven't happened yet: a real caller can't supply them for a future prediction window. The production pipeline instead builds a (zone, hour) to historical-average lookup from training data, so the API only ever needs a zone (or raw coordinates) and an hour.
With both fixes in place, the production pipeline evaluates to 2.10 MAE and 3.24 RMSE, worse than the notebook's 1.72 and 2.67. That's expected: the leakage fixes removed an unrealistic advantage the notebook's baseline had. The worse number is the honest one.
Results
| Pipeline | MAE | RMSE |
|---|---|---|
| Notebook baseline (has leakage) | 1.72 | 2.67 |
| Production service (leakage-free) | 2.10 | 3.24 |
Both are rides per day. The production number is the one that reflects what the service can actually know at prediction time, so it's the one that matters.
Example request
Real responses from the committed model artifact. No Kaggle download needed to run this.
curl -X POST localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"pickup_zone": 5, "hour": 18}'{"pickup_zone": 5, "hour": 18, "predicted_avg_daily_ride_count": 7.41}curl 'localhost:8000/rankings?hour=18&top_n=3'{"hour": 18, "rankings": [
{"pickup_zone": 30, "predicted_avg_daily_ride_count": 30.08},
{"pickup_zone": 23, "predicted_avg_daily_ride_count": 28.87},
{"pickup_zone": 19, "predicted_avg_daily_ride_count": 28.64}
]}Testing
20 tests cover the data, feature, and clustering pipeline plus the FastAPI service, run with pytest. CI runs ruff, mypy, and the full test suite on every push, plus a separate job that builds the Docker image.
Limitations
- ▸Public data stands in as a structural analog, not the original dataset: same shape (timestamped pickup/dropoff coordinates), a different city, and no fare field, so ride duration substitutes for ride value throughout.
- ▸k=40 zone clusters is a heuristic carried over from the original methodology, not a validated choice. A proper elbow or silhouette pass on a representative sample would be the next step.
- ▸Baseline model only. No hyperparameter tuning and no alternative architectures (LightGBM, a time-series model) tried yet.
- ▸Batch retraining only. There's no scheduled retraining or CD pipeline; running the training command is a manual step for now.