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.
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:
| Problem | Why it hurts | Fix |
|---|---|---|
| Civic generations | A "2016 Honda Civic" can be two very different cars at two very different prices | Generation separated from family and variant via year–engine–variant rules; ambiguous transition rows excluded, not guessed |
| Missing zeros | A car at 850,000 that should read 8,500,000 wrecks a regression | High-confidence cases corrected in price_model_pkr only; raw price never edited |
| Mileage placeholders | "123456 km" and friends distort the depreciation signal | Annualised mileage checked against make/model/year cohorts; extremes imputed into a dedicated model field |
| Messy variant text | Bad categories fragment training data into useless slivers | Canonical make/model/variant fields built from title rules (incl. fixing Peugeot 2008 and MG4 records) |
| Rare vehicles | One-row classes can't generalise — they memorise | Variants with ≥5 rows train exactly; rarer trims fall back to model level; ≤20-row make/model groups excluded |
| EV engines | Battery kWh parsed as engine cc poisons a numeric feature | Combustion cc and battery kWh parsed separately; EVs held out of the MVP entirely |
| Leakage | Listing IDs and price cohorts "predict" price by cheating | IDs, 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.
05 · VALIDATEA validation protocol worth trusting
Before any model ran, the rules of the contest were fixed:
- Target: natural log of cleaned asking price. Price errors are multiplicative — a 10% miss on a 1.5M hatchback and a 10% miss on a 30M import should cost the model the same.
- Holdout: 80/20 split, stratified by price band, so cheap cars and luxury imports appear in both sides at true proportions.
- Cross-validation: 6-fold stratified CV on the training split — every headline number had to agree with its CV twin before I believed it.
- Selection metric: holdout MAPE, sanity-checked against CV MAPE, WMAPE, R² and within-10%/within-20% rates.
- Known gap: this dataset is one market snapshot, so validation is cross-sectional. Time-based validation joins the protocol as soon as weekly snapshots accumulate — flagged in the roadmap, not hidden.
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.
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.
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
| Metric | Holdout | 6-fold CV |
|---|---|---|
| MAPE | 7.31% | 7.37% |
| WMAPE | 6.49% | 6.47% |
| R² | 0.9335 | 0.9737 |
| Mean absolute error | PKR 2.88 lacs | PKR 2.88 lacs |
| Median absolute error | PKR 1.50 lacs | PKR 1.50 lacs |
| Within 10% of asking | 76.21% | 76.13% |
| Within 20% of asking | 93.43% | 93.12% |
| 90% interval coverage | 90.01% | calibrated from holdout residuals |
| Median 90% interval width | PKR 11.17 lacs | 33.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.
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:
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.
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