← TAIMOOR ALI Case study
PRICING DECISIONS · LIVE PRODUCT

MotorGauge: pricing 58,000 used cars in your browser

An end-to-end machine learning system that estimates used-car asking prices in Pakistan — from a scraper that respects the marketplace, through a cleaning layer that turned listings into vehicles, to a LightGBM model that ships as pure JavaScript. No backend. No API. Just a model, deployed.

7.31%holdout MAPE
0.9335holdout R²
93.4%within 20%
58,583cleaned listings

01 · THE QUESTIONWhat is this car actually worth?

Pakistan's largest auto marketplace, PakWheels, hosts tens of thousands of live listings — and no public pricing intelligence on top of them. Buyers anchor on whatever the seller typed. Sellers guess, then wonder why nobody calls. I wanted to know whether listing data alone could answer the pricing question with calibrated confidence — and whether the answer could run as a real product anyone can open on a phone.

One framing decision shaped everything downstream: the model predicts advertised asking price, not transaction price, because asking price is what the data actually contains. Pretending the model knows sale prices would bake an invisible error into every estimate. Instead, the product handles the asking-vs-deal gap explicitly, with a separate rule that never touches model training — more on that in section 08.

The project started deliberately small: a Kia Sportage–only prototype, one model family, a scikit-learn pipeline and a local API, just to prove search-page data carried enough signal. Only once that worked did the scope expand to the whole market — which is the moment the real engineering began.

02 · MEASUREGetting the data without getting blocked

The obvious scraping approach — visit every listing's detail page — is slow, brittle, and the fastest route to a rate limit. So the Playwright pipeline was built around search-result cards instead. A single search page yields dozens of listings' worth of structure: title, exact numeric price from the card's data attribute (not the display text), listing ID, city, year, mileage, fuel, engine, transmission, badges, managed/certified/inspected flags, photo count and freshness text.

Two price-relevant fields never appear on cards: colour and registration. The workaround is the part of the scraper I'm proudest of — crawl filtered search pages (say, "white cars only"), record which listing IDs appear, and join the implied label back to the main dataset. Hidden attributes recovered, zero detail pages visited. Throughout, raw values were preserved untouched and every cleaned or derived field written to a separate column, so any row can be audited back to what the seller actually wrote.

System architecture: from marketplace to browser
Each stage writes auditable outputs; the model ships as a static bundle
Playwright search cards Enrichment filter-page joins Cleaning canonical fields LightGBM log-price model JS export model + metadata Browser motorguage.com
No detail-page scraping, no server at inference time. The two most fragile pieces of a typical ML product — heavy crawling and a live API — were designed out.
From scrape to production model
Rows surviving each stage of the pipeline
The MVP scope is post-2000 vehicles, EVs excluded, make/model groups with ≤20 rows excluded. Accuracy you can't support is accuracy you shouldn't ship.

03 · CLEANThe real work: making listings mean vehicles

The model algorithm was never the hard part. The hard part was that listings describe text, not vehicles — and text written by motivated sellers at that. The cleaning layer is a set of deterministic, auditable rules, each one earned the hard way:

ProblemWhy it hurtsFix
Civic generationsA "2016 Honda Civic" can be two very different cars at two very different pricesGeneration separated from family and variant via year–engine–variant rules; ambiguous transition rows excluded, not guessed
Missing zerosA car at 850,000 that should read 8,500,000 wrecks a regressionHigh-confidence cases corrected in price_model_pkr only; raw price never edited
Mileage placeholders"123456 km" and friends distort the depreciation signalAnnualised mileage checked against make/model/year cohorts; extremes imputed into a dedicated model field
Messy variant textBad categories fragment training data into useless sliversCanonical make/model/variant fields built from title rules (incl. fixing Peugeot 2008 and MG4 records)
Rare vehiclesOne-row classes can't generalise — they memoriseVariants with ≥5 rows train exactly; rarer trims fall back to model level; ≤20-row make/model groups excluded
EV enginesBattery kWh parsed as engine cc poisons a numeric featureCombustion cc and battery kWh parsed separately; EVs held out of the MVP entirely
LeakageListing IDs and price cohorts "predict" price by cheatingIDs, URLs, price-derived fields and cohort labels explicitly banned from the feature schema

The design principle running through all of it: source values stay intact; canonical, quality and model-ready fields are written beside them. Six months from now, any surprising prediction can be traced to the exact raw text that produced it.

04 · EXPLOREWhat the market data says

Exploration before modelling confirmed the structure the model would need. Depreciation is steep and nonlinear, with the price spread widening as cars age into mixed-condition territory — which is why the target is log price and the contenders are tree ensembles. Engine size and model year carry the strongest relationships with price; mileage matters but is noisy, because vehicle class and year dominate it.

What correlates with (log) price
Pearson correlation of numeric model-ready fields with log asking price
Model year and vehicle age are near-perfect mirrors (r = −1.00 with each other), so the final schema keeps a compact set and avoids redundant age/year engineering.
Model-ready market coverage by make
Listings in the MVP training scope (53,034 rows)
Toyota 28.4% · Suzuki 27.7% · Honda 21.5% — three Japanese makes are over three-quarters of the supported market. The long tail below Daihatsu is where accuracy starts to strain.

05 · VALIDATEA validation protocol worth trusting

Before any model ran, the rules of the contest were fixed:

06 · MODELChoosing a model — and rejecting a better one

The feature set evolved in deliberate steps, with the benchmark re-run at each stage. Adding colour and registration (the filter-page enrichment) bought ~0.7 points of MAPE; adding variant, fuel and engine bought another full point. Then two instructive dead ends: a richer internal schema scored slightly better, and neural networks scored notably worse.

Feature-set progression across experiments
Best holdout MAPE achieved at each stage
The 7.17% point is the rejected internal schema — better score, wrong features. The 9.15% point is the neural-net challenger on final features: on tabular market data, trees won comfortably.

On the final user-enterable feature set, ten model families fought it out under the fixed protocol: LightGBM, XGBoost, CatBoost, three HistGradientBoosting variants, Random Forest, Extra Trees, an MLP challenger, and a ridge baseline to measure how nonlinear the problem really is. Answer: very — the linear baseline trails the winner by 7.3 points of MAPE.

Production feature-set model benchmark
Holdout MAPE, lower is better · identical features and validation protocol
LightGBM selected: best holdout MAPE (7.31%), confirmed by 6-fold CV (7.37%), best within-20% rate (93.43%). The top six models sit within 0.17 points — the feature work, not the algorithm, did the heavy lifting.
The call

The richer internal schema — scraping-quality flags, support counts — scored 7.17% MAPE, beating the production model. I rejected it anyway. Those fields describe my dataset, not the user's car, and a public estimator can only use what a real person can type into a form. The support signals moved into confidence scoring instead, where they genuinely belong. Offline metrics are not the product.

07 · PERFORMANCEThe final numbers, in full

MetricHoldout6-fold CV
MAPE7.31%7.37%
WMAPE6.49%6.47%
0.93350.9737
Mean absolute errorPKR 2.88 lacsPKR 2.88 lacs
Median absolute errorPKR 1.50 lacsPKR 1.50 lacs
Within 10% of asking76.21%76.13%
Within 20% of asking93.43%93.12%
90% interval coverage90.01%calibrated from holdout residuals
Median 90% interval widthPKR 11.17 lacs33.9% of prediction

Two of these matter more than the rest. The median absolute error — PKR 1.5 lacs on a typical multi-million-rupee car — is the number a user feels. And interval coverage of 90.01% against a 90% target means the ranges the product shows are honest, not decorative.

08 · DIAGNOSEWhere the model struggles

A headline MAPE hides more than it reveals, so the holdout was cut by segment. The errors were not random — they concentrated exactly where economics says they should: thin support, cheap cars where small rupee misses become large percentages, and luxury imports where condition and rare trims dominate price.

Holdout error by make
MAPE (%) on holdout rows · sample size in tooltip
Mercedes at 15.5% (n=99) vs Honda at 6.1% (n=2,304). Suzuki and Daihatsu sit high partly for an arithmetic reason: in low price bands, a small absolute miss is a big percentage.
Variant support matters
Holdout MAPE: exact-variant rows vs model-level fallback rows
Exact variants: 7.2% (n=10,440). Fallback rows: 13.6% (n=167) — nearly double the error. This single chart justifies the entire confidence system.

09 · DECIDEA price is not enough

The product never shows a lone number, because a lone number lies by omission. Every estimate ships with a calibrated 90% range built from holdout residuals, and a confidence label scored from interval width, training support, variant fallback, mileage risk, colour missingness, fuel-segment risk and price band:

Confidence distribution on the holdout set
Rows per confidence tier · log scale, because honesty is rare by design
High 9,700 · Medium 742 · Low 107 · Very Low 58. The bottom tiers exist so the product can say "we don't really know" — the most underrated feature a model can ship.

And because the target is asking price, a separate deterministic rule estimates the expected deal price by applying a negotiation discount per price band. Keeping that rule outside the model means sale-price assumptions never contaminate training — and the discount schedule can be updated in minutes without retraining anything.

10 · SHIPShipping without a backend

The selected LightGBM model and its metadata export to a static JavaScript bundle. The website loads the bundle, populates form options from model metadata — users can only select supported make/model/variant combinations, so unsupported requests are impossible by construction — and produces the estimate, deal price, range and confidence label entirely in the browser. Hosting is GitHub Pages on a personal domain. Zero servers, zero cold starts, zero monthly bill.

The call

Static deployment is an architecture decision, not a shortcut. For an MVP, a backend adds cost, failure modes and maintenance for no user benefit. The triggers for adding one are written down: model bundles outgrowing the browser, user accounts, or server-side retraining on live snapshots. Until one fires, simplest wins.

11 · NEXTWhat I'd build next

The next quality jump isn't a fancier algorithm — the top six models already sit within 0.17 MAPE points of each other. It's data operations: archive every weekly scrape as an immutable dated snapshot; run schema, row-count, missingness, duplicate and category-drift checks before cleaning; add a time-based holdout once multiple snapshots exist; gate every model export behind segment diagnostics and confidence calibration review; and monitor what users actually ask the live estimator for, because unsupported demand is a roadmap written by the market. EVs join the scope once battery and trim coverage can support them honestly.

Stack: Python · Playwright · pandas · LightGBM / XGBoost / CatBoost / HGB benchmarks · scikit-learn · residual-calibrated intervals · static JS export · GitHub Pages