Bakery Anomaly Detection
Checking a bakery's planned quantities for typing mistakes before production starts.
In production
Overview
I built this with a bakery in northern Italy, with about 50 employees and 200 products. The owner uploads a CSV of the day's planned quantities. The app flags entries that look mistyped so they can be checked before purchasing and production begin.
Why I did not train a model
I considered Isolation Forest, LOF, and other anomaly detectors, but there was very little history to learn from. Each product has a different pattern for each weekday, and the catalogue and quantities keep changing. I needed a check the owner could use without someone maintaining a model.
These were the constraints I worked with:
- Day-of-week independence — Monday orders differ systematically from Saturday for every product. A DOW-aware model would need ~600 instances, each trained on 4 data points.
- No inter-product correlation — a mistyped quantity carries zero signal in any other product.
- Extreme data scarcity — a 4-week rolling window means 4 training observations per model.
- Continuous distribution shift — seasonal volumes, new products, discontinued items.
- Zero-maintenance requirement — no IT budget for retraining pipelines.
The check I use
A rolling statistical estimator with a conjunction rule: for each (product, day-of-week) pair, compute mean and standard deviation over the last W same-weekday observations (default W = 4). Flag an anomaly only when all three conditions hold simultaneously:
- Z-score > 7.0
- Percentage deviation > 30%
- Absolute deviation > volume-tier threshold
A fourth component — a year-over-year seasonality shield — suppresses false positives on seasonal products (Easter specialties, holiday items) by comparing against the same date one year prior.
There is no training job, model artefact, or drift monitor to maintain. The baseline moves with the recent data, and a new product can be handled from its second observation.
Architecture
CSV upload (daily)
│
Streamlit frontend
│
Detection engine (Python)
├── Rolling Z-score per (product, DOW)
├── Conjunction rule
├── Volume-tier thresholds
└── YoY seasonality shield
│
Supabase PostgreSQL
├── Historical orders
├── Precomputed baselines
├── Audit log (append-only)
└── Auth (role-based)
Two user roles: an administrator view (full table with Z-score, deviation, time-series charts) and a simplified operator view (one review card per flag, business-language summaries: "You ordered 500 kg; the usual Monday order is 48 ± 6 kg").
Deployed on free tiers only: Streamlit Cloud, Supabase, Cloudflare DNS.
Total recurring infrastructure cost: €0/month.
Benchmark Results
496-anomaly labelled test set, constructed with the bakery's cooperation. Competing methods receive oracle threshold selection (best possible F1 on test set) — the deployed Z-score gets no oracle.
| Method | Window W | Precision | Recall | F1 |
|---|---|---|---|---|
| Z-score (deployed) ← no oracle | 4 | 89.0% | 90.9% | 85.7% |
| Z-score (oracle) | 4 | 83.0% | 91.1% | 86.9% |
| MAD (oracle) | 4 | 82.3% | 83.2% | 82.8% |
| Holt-Winters (oracle) | 4 | — | — | 0% (inapplicable) |
| Z-score (oracle) | 24 | 86.5% | 91.6% | 89.0% |
| MAD (oracle) | 24 | 83.7% | 92.8% | 88.0% |
| Holt-Winters (oracle) | 24 | 66.5% | 75.7% | 70.8% |
The deployed settings land within 1.2 percentage points of the oracle-tuned Z-score at the same four-week window, without choosing a threshold on the test set. Longer windows score a little higher offline, but they adapt too slowly for the bakery's changing catalogue. Holt-Winters cannot fit the four-observation case at all and is far slower at W = 24.
What changed after people used it
- Too many warnings make every warning invisible. Once false positives get near 20%, the owner stops trusting the list.
- Easter broke the first version. Legitimate seasonal spikes looked alarming, so I added the year-over-year check.
- Maintenance has to be close to zero. A scheduled retraining pipeline would not survive in this setting.
- The explanation is part of the result. “500 kg versus a usual Monday of 48 ± 6 kg” is something the operator can act on.
Built with
Stack: Python · Streamlit · Supabase (PostgreSQL) · Streamlit Cloud · Cloudflare