Skip to main content
AboutWorkSkillsExperienceWritingContact
PreviousCriteo UpliftNextCustomer Intelligence Platform
HomeProjectsAboutExperienceWritingContactGitHubLinkedIn
© 2026 Bhargav Kr Nath
All projects
ML Systems/2026/Real, deployed project

PricePoint Dynamics.

Nine and a half million scraped UK supermarket prices, resolved into one product catalogue and turned into a forecasting model, an explainability layer, and a market-structure dashboard, rebuilt from a broken prototype into something reproducible.

View live siteView source on GitHub
Solo ML / Data EngineerAPI docs
PolarsDuckDBLightGBMSentence-BERTFAISSSHAPFastAPINext.jsPandera
Median price by retailerreal, computed
Aldi£1.49
Morrisons£2.75
ASDA£3.00
Sainsbury's£3.00
Tesco£3.00

Aldi's median sits roughly half of ASDA's, a structural gap the forecasting model learns directly from data.

9.5M

Price rows analysed

791MB, 5 retailer CSVs, 95 days

£0.15

Forecast error (MAE)

R² 0.965 on 444K held-out rows

68,596

Canonical products

resolved from ~114K raw names

£0

Monthly hosting cost

verified against live pricing pages

190

Real tests

unit · API-contract · pipeline-integration

5

CI jobs / push

lint, unit, API, pipeline, frontend e2e

8

ADRs written

the decisions that shaped the rebuild

151.7×

Feature-eng. speedup

pandas → Polars, 10% sample

The problem

Five retailers, one broken assumption.

Tesco, Sainsbury's, ASDA, Morrisons and Aldi all reprice against each other constantly. Seeing that in data hits a naming problem before it hits a statistics problem: "Tesco Finest British Beef Mince 5% Fat 500g" and "ASDA Extra Special British Beef Steak Mince 5% Fat" are the same category of product, and nothing in a raw scrape says so. Until that's resolved, there's no dataset, just five retailers' worth of unlinked noise.

The second problem only showed up on inspection: a prior Streamlit prototype existed, and going through it line by line, not trusting its README, turned up a long list of things that quietly didn't work. That audit, below, is where most of this project's real engineering value sits.

9,529,247

Raw rows

791MB across 5 retailer CSVs

95 days

Date range

Jan 9 – Apr 13, 2024

~114,000

Unique raw names

before semantic resolution

41.5%

Duplicate rate

at (product, retailer, date), from different pack sizes clustering together

Legacy audit

What the prototype actually got wrong.

The core analysis code was reasonable. Everything wrapped around it had quietly stopped being true, found by reading the code, not by trusting the docs.

legacy_audit --line-by-line7 defects found

2 of 3 Pandera schemas defined, never called

Product matching and feature engineering, the two most complex pipeline stages, had zero validation. A schema drift there would have passed silently.

Dashboard read files no script produced

"canonical_products_lite.parquet" and "feature_data_lite.parquet" were hand-generated once, uploaded to Google Drive, and never documented. A fresh clone could not reproduce what the dashboard read.

~30 of ~40 model inputs were fabricated

The old predictor page invented most of its own features: "price_rol_min_7d = price_rol_mean_7d * 0.9" is a made-up constant, not a real value. The "prediction" had little to do with the actual data.

Headline stats were hardcoded strings

"£0.14", "9.5 Million", "67,000+": not computed from anything. They would silently drift on the next retrain.

Feature engineering: 12+ Python passes over 9.5M rows

groupby().transform(lambda x: x.rolling(...)) ran once per statistic per window size, making it the single slowest stage in the entire pipeline.

Price-leadership: a triple-nested Python loop

One pandas.Series.corr() call per retailer pair per lag: 300,000 individual scalar calls at default config.

No lockfile, two drifted requirements.txt files

No installable package, and the two requirement files had already diverged on their own numpy pin.

Architecture

One pipeline, a static-first frontend.

9 pipeline stages produce everything the dashboard needs. Only one user action, an arbitrary predict request, ever touches a live backend.

$ python run.py ingest match features train marts precompute web-artifacts

1

Ingest

Clean + validate 5 raw retailer CSVs (791MB, 9.5M rows) against RAW_DATA_SCHEMA.

PolarsPandera
2

Match

Sentence-BERT embeddings → FAISS → bounded-degree mutual-kNN clustering into 68,596 canonical products.

e5-largeFAISS
3

Features

Temporal (rolling/lag), leave-one-out competitive, and cyclical date features, via Polars .over() expressions.

Polars
4

Train

LightGBM, MAE objective, time-series split, with the final 7 days held out chronologically.

LightGBM
5

Marts

DuckDB analytics marts over Parquet: fact_price_daily, dim_product, dim_retailer.

DuckDB
6

Precompute

SHAP (8K-sample TreeExplainer), HHI concentration, price-leadership cross-correlation.

SHAPNumPy
7

Web artifacts

Export page-shaped JSON (committed) and Parquet (R2) for the frontend to read directly.

DuckDB
only 1 of 6 dashboard pages ever calls a live endpoint
Frontend: 5 static pages

Next.js static export on Vercel. Reads committed JSON / R2 Parquet directly, with zero backend round-trip.

API: 3 endpoints

FastAPI on Cloud Run. Only called when the Predictor page's "Predict" button is actually clicked.

Data

Aldi is the price floor, not subtly.

Real per-retailer quartiles, computed once by the pipeline and shipped as a static artifact, not hardcoded strings.

Price distribution by retailer: real quartiles
25th–75th pct.median
Aldi£0.99 – £2.49 · median £1.49
Morrisons£1.75 – £5.00 · median £2.75
ASDA£1.70 – £6.00 · median £3.00
Sainsbury's£1.90 – £6.20 · median £3.00
Tesco£1.75 – £6.00 · median £3.00
£0.00£2£3£5£7

Aldi's entire interquartile range sits below every other retailer's 25th percentile, not a marginal difference, a structurally separate price tier.

Methodology

One clustering idea, two failed attempts.

Matching ~114,000 raw product names down to a canonical set looks like a plain embedding-similarity problem. Two attempts at that failed catastrophically before the actual fix.

Unbounded similarity threshold96% → 1 cluster

One generic phrase becomes a hub, chaining everything together

Bounded-degree mutual-kNN68,596 products

An edge only exists if the match is genuinely mutual

Attempt 1: Greedy top-1 nearest neighbour

non-transitive result

A matches B does not imply B matches A. Order-dependent, and wrong in a way that is hard to detect downstream.

Attempt 2: Unbounded connected components, threshold 0.85

96% % names in one cluster

FAISS range_search fixed transitivity but gave every node unbounded degree. Generic short phrases ("0 fat greek style") became hub nodes chaining unrelated products together.

Attempt 3: Same approach, threshold raised to 0.90

60%+ % names in one cluster

Chaining gets worse, not better, with corpus scale: a threshold that looks safe on a small sample can still fail catastrophically at full size.

Final: Bounded-degree mutual-kNN (threshold 0.95, k=5)

68,596 canonical products

An edge only exists if each name is genuinely among the other's top-5 nearest neighbours, bounding how many names any single hub can pull in, while still resolving real multi-hop A-B-C chains.

Methodology

Closing the target-leakage door.

A "how does this price compare to today's market average" feature sounds harmless, until the row's own price is part of that average. The model was, in effect, handed a feature partly derived from the exact value it was trying to predict.

The fix: exclude the row's own price before averaging the rest, and persist only the derived delta, never the raw aggregate, which would let the target be reconstructed by simple subtraction regardless of how the aggregate was computed.

feature_engineering.py::add_competitive_features
1# Leave-one-out market average: the row's own
2# price is excluded before averaging the rest.
3other_count = group_count - 1
4market_avg_others = (
5 (group_sum - df["prices"]) / other_count
6)
7 
8# Only the derived delta is persisted, never
9# the raw aggregate, which would let the target
10# be reconstructed by simple subtraction.
11df["price_vs_market_avg"] = (
12 df["prices"] - market_avg_others
13)
Model

LightGBM, trained on 5.15M rows.

Time-series split: the final 7 days held out chronologically, since a random split would leak future rolling-window information into training.

£0.1487

MAE

average error, ~5–10% on a typical £1.50–£3 item

£1.2589

RMSE

penalises rare large misses

0.9652

R²

high in part because grocery prices are sticky

5.15M / 444K

Train / test rows

final 7 days held out chronologically

Honest framing
£0.15 average error is roughly 5–10% on a typical £1.50–£3 item, reasonable, but R² of 0.965 isn't an unqualified win. Grocery prices are sticky: a model leaning on yesterday's price and last week's rolling average looks accurate largely because a naive "predict no change" baseline would too. See the explainability section below for what the model captures beyond that inertia.
42 features, 4 groups
Temporalrolling mean/std/max/min (7/14/30d) · lag 1d/7d · day-over-day diff
Competitiveleave-one-out market average · price rank · is-cheapest flag
Cyclicalsin/cos of day-of-week · day-of-month · week-of-year
Categoricalsupermarket · category · own-brand (one-hot)
objective=maen_estimators=1000learning_rate=0.05num_leaves=63min_child_samples=100subsample=0.8colsample_bytree=0.8
Explainability

What is the model actually picking up on?

SHAP values computed against the trained model, grouped into human-scale categories.

Share of mean(|SHAP|), computed directly from the precomputed sample, 4,768 rows
Recent price history90.3%
Competitive position9.6%
Everything else0.1%

The model's explanatory weight sits overwhelmingly on price persistence, not exotic signal, independent confirmation of the honest R² framing above, computed directly against the artifact rather than taken from the project's own README.

Market dynamics

Who leads, and who follows?

Cross-correlation over price series, vectorized across every sampled product pair at once, not a single pandas call per pair.

Market price dispersion, 95 days: real daily valueshover to inspect
2024-01-092024-04-13

A sharp one-day dip in mid-January (0.112 vs. a typical ~0.19) aside, the market settles into a stable equilibrium: dispersion neither escalates into a price war nor collapses.

Price-leadership lag, in days: row leads columndarker = stronger lead
swipe to see all 5 retailers →
Aldi
ASDA
Morrisons
Sainsbury's
Tesco
Aldi
·
+3d
+3.5d
+1.5d
+4d
ASDA
-3d
·
+2d
-2d
+2d
Morrisons
-3.5d
-2d
·
+1d
+1d
Sainsbury's
-1.5d
+2d
-1d
·
·
Tesco
-4d
-2d
-1d
·
·

Aldi → everyone: +3 to +4 days. Among the Big Four (Tesco, Sainsbury's, Morrisons, ASDA), lags are far shorter (~1–2 days) and reciprocal, a faster, tighter competitive cluster.

Aldi is the price floor, not subtly

Median £1.49 vs. £3.00 at ASDA, and similar-or-higher at the other three. Directly visible in the raw per-retailer quartiles below.

Aldi sets the pace; the Big Four follow, days later

Aldi leads every other retailer by 3–4 days. Among the Big Four themselves, relationships are far faster (~1–2 days) and reciprocal: a tight, reactive cluster distinct from Aldi's slower-followed leadership.

Tesco leads, Sainsbury's follows, consistently

A measurable, one-directional lag, not simultaneous movement. Not visible by eyeballing a two-line chart. It only emerges from running the lag correlation across enough product pairs to separate signal from noise.

Results

Measured, not estimated.

Every plot below is a real matplotlib figure this project's own benchmark scripts generated, including the one where the rewrite doesn't win on every axis.

Feature-engineering & ingestion: speedup

Feature-engineering & ingestion: speedup

Log-scale speedup, pandas → Polars/vectorized rewrite. Feature engineering: 151.7× on a 10% sample.

Feature-engineering & ingestion: memory

Feature-engineering & ingestion: memory

The honest trade-off: at full 9.5M-row scale, Polars' peak RSS (4,355MB) exceeds the pandas baseline.

DuckDB marts vs. pandas-in-Streamlit: speedup

DuckDB marts vs. pandas-in-Streamlit: speedup

Basket cost lookup: 15.7×. Market overview: 4.6×, bounded by the query itself being a full-scan.

DuckDB marts vs. pandas-in-Streamlit: memory

DuckDB marts vs. pandas-in-Streamlit: memory

Unambiguously better here: basket lookup peak RSS drops from 1,476MB to 243MB.

Engineering decisions

Every system is a series of trade-offs.

10 real decisions from this rebuild: what was chosen, why, what it cost, and what happened.

Context

Plain FAISS range_search connected-components clustering collapsed 96% of 114K product names into one cluster at threshold 0.85. Raising it to 0.90 still left 60%+ in one supercluster.

Reasoning

Chaining happens because a single hub node can have unbounded degree. Bounding each node's degree via mutual-k-NN caps how many names any one hub can pull in, while still resolving genuine multi-hop A-B-C chains.

Trade-off
A single global similarity threshold
A clustering result that doesn't degrade catastrophically as the corpus grows
Outcome

68,596 canonical products, close to the project's historical reference figure (67,341), guarded by a regression test built around the exact hub-chaining failure mode.

Failures & iterations

Nothing shipped right the first time.

Five things that broke, or would have shipped wrong, caught during this rebuild.

A float rounding error broke the vectorized correlation rewrite

Found

The original "skip products with no price variation" check used variance == 0. Vectorizing it, 90+ identical float rows accumulated rounding error into a spuriously nonzero variance (~7.98e-31), producing arbitrary near-ties on which lag "wins."

Fix

An empirically-derived 1e-6 threshold, chosen by checking real variance values on this dataset, not a token change.

A CORS misconfiguration broke deployment, for real

Found

"Works locally, fails in prod": a real incident during deployment, not a hypothetical.

Fix

An explicit CORS allow-list, never "*", now covered by a dedicated regression test rather than just fixed and forgotten.

The rolling/groupby stage repeatedly froze a 15GB dev machine

Found

Feature engineering and training's intermediate DataFrames were large enough, held long enough, to exhaust memory on a real development machine, not a theoretical concern.

Fix

downcast_dtypes() + explicit collect_garbage() + log_memory() checkpoints at every major stage boundary; large intermediates (e.g. ~467MB of embeddings) explicitly deleted the moment they're no longer needed.

The API was over-scoped before it was cut down

Found

~9 endpoints were originally planned.

Fix

Going page-by-page through the actual dashboard found 6 were replaceable by a static file the frontend could just read. The API shipped with 3.

The Polars rewrite is not a strictly-better trade at full scale

Found

At the full 9.5M-row feature-engineering stage, peak memory went up (4,355MB), not down, versus the pandas baseline.

Fix

No fix applied. The benchmark plots show this plainly. Speed was the actual constraint being solved for; the memory trade-off is disclosed, not hidden.

Cost & honest limits

£0 a month, stated plainly.

Verified, not assumed

Vercel static export + Cloud Run (request-processing billing, scale-to-zero) + Cloudflare R2 (free egress), independently re-checked against each provider's live pricing page rather than trusted from an earlier note. The write-up says explicitly what couldn't be confirmed, too.

Stated limits
  • ·Training window is 95 days (Jan–Apr 2024): no data exists to learn full-year seasonal effects like Christmas pricing.
  • ·Promotional price drops are not modeled explicitly; the model treats them as noise.
  • ·Retraining is manual (python run.py train): no scheduled job watches for drift yet.
  • ·price_vs_market_avg is contemporaneous with the target: useful for describing today's competitive position, not for forecasting moves that precede a market shift.
What I learned

The audit was the real project.

  • The legacy audit, not the model, was where most of the real engineering value was: 7 concrete defects (unvalidated schemas, fabricated model inputs, hardcoded stats, undocumented artifacts) found by reading the actual code, not the README.
  • Two clustering failures (96% and 60%+ chaining) came before the working bounded-degree mutual-kNN approach: the interesting engineering story is the iteration, not a single clean insight.
  • A high R² (0.965) is not automatically a good story. Grocery prices are sticky, and the honest framing (verified independently via real SHAP values: ~90% of the model's explanatory weight sits on recent price history) matters more than the headline number.
  • A structural fix (one shared feature-vector function for train and serve) closes a bug class permanently; a discipline-based fix ("be more careful") only delays the next drift.
See it for yourself

The dashboard is live, and the code is real.

View live siteView source on GitHub