Data and feature pipelines
A model in production is only as good as the rows it is fed every night. This lesson shows you how to pull new data from the warehouse while offline tills upload late, stop bad data at a gate before it reaches the model, store features so that training sees exactly what the nightly run saw, and keep the training and serving paths from quietly computing the same feature two different ways.
By the end you can
- Extract incrementally with a watermark on load time, and rebuild a short window every night for late data
- Validate data before the model sees it: schema, freshness, row counts, ranges, nulls and completeness by store, each with an action on failure
- Store features in a table keyed by entity and as-of date, and build training sets with point-in-time joins
- Prevent training/serving skew with one feature definition, logged serving features and a parity test
- Change a feature safely: a new version next to the old one, a backfill, and a switch together with the model
The everyday picture
A restaurant's head chef spends a month perfecting a new curry in the test kitchen. She weighs every ingredient and writes the recipe on a card. Then the line cooks make it every night for 200 guests. Three things decide whether tonight's curry tastes like the one from the test kitchen. Deliveries: vegetables arrive at 5 pm, but some days a supplier's van is late, and the kitchen needs a rule for food that arrives after prep has started. Receiving: a clerk opens every crate before it goes into the fridge, because one box of spoiled tomatoes ruins the whole pot. And the recipe card: if the test kitchen wrote "20 g salt" and the night cook's card says "salt to taste", the dish changes even though both cooks are good at their job.
A feature pipeline is that kitchen. The warehouse is the delivery door, and late vans are tills that come back online after midnight. The validation gate is the receiving clerk. The feature table is the prep shelf, where every container is labelled with the day it was made. The shared feature function is the one recipe card that the test kitchen (training) and the night shift (serving) both cook from. When the restaurant's curry tastes different from the test kitchen's, check the cards before you blame the chef.
Extract only what changed, and expect late rows
In From notebook to pipeline you split Tomás's notebook into steps. The first one, extract, decides what data the rest of the night works with. The simplest version is a full extract: read all two years of daily_sales every night. Mangrove has about 2.5 million active store-product pairs, so two years is up to about 1.8 billion rows if every pair sold every day (fewer in practice, since many products don't sell daily). Reading that much takes a large share of the 90-minute budget between 23:15 and 01:30, and it gets slower every month as history grows.
An incremental extract reads only the rows that are new or changed since the last successful run. To know where the last run stopped, it keeps a watermark: the highest value of an ordered column that has been processed, stored in a small table. Tonight's run reads the rows above the watermark and, when it has finished, moves the watermark up.
Which column? The tempting choice is sale_date: "give me sales after the last day I saw". That breaks at Mangrove. The tills keep selling when the internet drops and upload when it returns (Case study: syncing POS sales from hundreds of stores). About 2% of a day's transactions arrive after midnight, roughly 8,000 of 400,000 (approximate), and a till that is offline all weekend uploads Saturday's sales on Monday. A sale_date watermark has already moved past Saturday, so those rows are never read. Put the watermark on loaded_at instead: the time the row reached the warehouse, set by the warehouse's clock. It only moves forward, and every late row gets a fresh loaded_at whatever its sale date. When late transactions change a day's total, the warehouse rewrites that daily_sales row and its loaded_at changes with it.
Finding late rows is half the job; the days they belong to also need rebuilding. Every night the pipeline rebuilds a reprocessing window of the last 3 sale dates from scratch: the cleaned daily sales for today, yesterday and the day before are recomputed and their partitions overwritten. Overwriting a partition makes the step safe to re-run, the idempotency rule from ml-01. Three days covers nearly all of Mangrove's late data, because most offline tills reconnect within a day. Rows older than the window are counted and logged by the extract, and the weekly Sunday run rebuilds any older day that changed before training starts. A store that was offline for a week gets a manual backfill, triggered by the sync monitor's alert.
Two rules keep the watermark honest. Move it last, after the output is written, to the cut-off you actually read up to; a watermark set to now() at the start of a run that then fails skips rows without any error. And read with a 10-minute overlap, because a row committed during the extract can carry a loaded_at slightly before the cut-off. Reading a few rows twice does no harm when the next step rebuilds whole days.
Quick check
A store's tills are offline from Friday evening and upload Friday's and Saturday's sales on Sunday at 10:00. Which extract picks them up on Sunday night?
The loaded_at of the uploaded rows is Sunday 10:00, above Saturday night's watermark, so Sunday's run reads them. A sale_date watermark already stands at Saturday after Saturday's run, so the Friday and Saturday rows are not above it and are skipped forever, whatever time the run starts.
Validate the data before the model sees it
A model never complains about its inputs. If three stores' tills didn't sync, their rows are missing, the features read as zero sales, and the model forecasts almost nothing for those stores. Tomorrow's order is short, and nobody notices until the shelves are empty. The fix is a validation gate: a step between extract and features that runs checks and decides what happens next. Each check has four parts: a rule, a threshold, a severity, and an action on failure.
| Check | Rule at Mangrove (thresholds are a starting point) | On failure |
|---|---|---|
| Schema | Expected columns exist with the expected types | Fail |
| Freshness | Last daily_sales load within 60 minutes of the 23:15 start; promotions loaded since yesterday | Fail; promotions warn for one day |
| Volume | Tonight's rows between 0.75 and 1.3 times the median of the last 7 nights | Fail |
| Completeness | All 320 stores have rows for today | Quarantine the missing stores |
| Ranges | No more than 50 rows with negative units; prices above 0 | Warn |
| Nulls | No nulls in key columns or in promo_flag | Fail |
| Uniqueness | One row per store, product and day | Fail |
There are three actions, and choosing between them is the real design work. Fail stops the run, pages the owner, and lets the fallback take over: the stores order from last night's forecast shifted by one day (Serving: batch and online builds that chain). Quarantine removes only the affected stores or products from tonight's run, gives them the fallback, and alerts their owner, so one offline store doesn't stop the other 319. Warn lets the run continue and records the problem for someone to look at in the morning. Pick by two questions: how much of the output would be wrong, and would the model be fooled? Missing stores fool the model but only for those stores, so quarantine. A null in a feature column fools the model everywhere, so fail. Fifty returns that net to negative units are odd but harmless, so warn.
Set thresholds from history: compute each check's value for the last 90 nights, put the threshold outside the normal range, and review false alarms monthly. Festival nights need a wider band or a comparison with last year's festival (Monitoring, drift and retraining). Write every result to a check_result table with the run_id, so you see a value creeping towards its limit before it crosses it. Tools such as Great Expectations and pandera let you declare checks and get readable reports, but plain SQL queries that each return a value and a pass flag are enough to start, and they run in minutes inside the warehouse.
Quick check
At 23:20 the gate finds that 9 stores have no sales rows for today. Every other check passes. What should the run do?
The damage is limited to 9 stores, so stopping the run for all 320 throws away 311 good forecasts. Treating missing rows as zero is the worst choice: the model would forecast almost nothing and those stores would order too little. Retraining doesn't fix a sync problem; it only teaches the model wrong data.
Feature tables and point-in-time joins
Once the data passes the gate, the features step computes every feature for every store-product pair and writes them to a feature table: store_article_features(store_id, article_id, as_of_date, …). The key is the entity (store and product) plus the as-of date: each row holds the feature values as they were known when that night's run started at 23:15. One night adds about 2.5 million rows, so two years is about 1.8 billion (approximate). A warehouse handles that easily when the table is partitioned by as_of_date.
Why store features instead of recomputing them for training? Because the table records what you knew. In Features and leakage you saw the "same timing" problem: at 23:15 a store with an offline till has only part of today's sales, while a month later the warehouse shows the complete day. If training recomputes features from today's warehouse, it sees complete days that the nightly run never had. The feature table freezes the partial value the model actually used. So the reprocessing window rebuilds the cleaned sales, never an old feature partition. Labels are the opposite: the units actually sold should be the final, complete numbers. Rebuild facts as late data arrives; never rebuild what you knew.
Building a training set means joining labels to features. For data that changes on a schedule, like the nightly feature partitions, a plain join on date works. Much data changes at irregular times instead: a price changes when a buyer decides, a store is refitted, a promotion plan is edited. These live in history tables with a valid_from date, and for each label you need the value that was valid on that day. A point-in-time join (also called an as-of join) does exactly that: for each label row, it picks the latest value whose time is on or before the label's time.
Join every label to today's price instead, and a sale from 6 October gets a price decided on 20 October: time leakage, and a price effect that never existed. Join with equality on date, and most labels find no price, because the history only has rows on change days. In PostgreSQL you write the as-of join with LATERAL (a subquery that can see the current label row) and ORDER BY valid_from DESC LIMIT 1, or with DISTINCT ON; pandas has merge_asof.
Where do the features live when something needs them fast? The warehouse tables are the offline store: full history, for training and the nightly batch. The ordering screen's what-if endpoint needs one store's features in a few milliseconds, so the nightly run also copies tonight's values into Redis, the online store (Caching covers the pattern). A feature store such as Feast packages these ideas: a registry of feature definitions, point-in-time joins to build training sets, and a job that copies the latest values to the online store (Feast calls it materialization). For one model with a nightly batch and a small endpoint, warehouse tables and a Redis load are enough; a feature store pays off when several models or teams share features.
Quick check
Tomás adds store size band to the training set by joining two years of labels to the current site table. Last month, 40 stores were refitted and moved up a band. What is wrong?
The current table describes today. Two years of those 40 stores' sales now look like big-store sales at small-store levels, and the model learns a false link. This is the same leak as joining today's price to old sales. He needs a band history with valid_from and a point-in-time join; without that history, the feature can't be built honestly for the past.
Training/serving skew: same model, different inputs
Training/serving skew is when the model receives a feature at prediction time that was computed differently from the one it learned on. ds-02 named it. Three mechanisms cause most of it, and each looks innocent in code review:
- Windows that end at different moments. Training computes
avg_last_7in SQL over 7 complete days. The ordering screen's endpoint recomputes it at 10:00 from the last 7 days including this morning, when about 15% of the day's sales have happened (approximate). One of the seven days is nearly empty, so the average comes out about 12% low and so does the what-if answer. - Time zones. Training uses the POS business day, set when the store opens. A serving job derives the date from a UTC timestamp. In a city three hours behind UTC, every sale after 21:00 local time lands on tomorrow's date, so "today" loses its evening rush.
- Defaults for missing values. Training fills a missing promotion with 0; serving leaves it null. The model has never seen a null there, so it sends it down whichever branch its library uses for missing values. The example box below shows what that did in the pilot.
Skew is dangerous because nothing breaks. The backtest is fine, the job succeeds, the numbers look plausible. Only the live error in some stores or products creeps up. There are four fixes, and you want all of them:
- One feature definition. One function in one package, imported by the training build, the nightly run, every backfill and the online endpoint. Better still, compute features once in the pipeline and let serving read the feature table or Redis, so there is nothing to recompute.
- Log what the model saw. For the nightly batch, tonight's feature partition is the log, tagged with
run_id. The online endpoint writes its inputs to aserved_featurestable; at about 20 requests a second at peak, that is cheap. - Compare every night. For each feature, compare tonight's null share, mean and range with the training data. A null share that goes from 0% to 4% is a skew alarm, whatever the error says. ml-05 adds a drift score (PSI) on the same logs.
- A parity test in CI. For a sample of 50 store-products and 3 dates, build features through every path and require identical values. The test fails the pull request that changes one path but not the other.
Quick check
The nightly job's logged features show promo_flag null on 4.6% of rows. In the training data it was never null. The WAPE hasn't moved much yet. What should you do?
A feature distribution that training never saw is a skew signal on its own, and it shows up before the error does. Retraining would teach the model to live with a bug instead of fixing it, and waiting means stores order from wrong inputs for days.
Backfills and versioned features
Features change. Tomás wants avg_last_28 to skip days when the product was out of stock, because those days hold sales down (the censored demand from Framing a problem as a model). If he edits the function in place, two things break. The champion model, trained on the old meaning, receives the new meaning tonight: skew he created himself. And the feature table now mixes two meanings, old partitions with one and new partitions with the other.
The safe way is a feature version: add avg_last_28_v2 next to v1 and leave v1 alone. Then run a backfill: the feature step for past dates, with the same code and the same --run-date parameter from ml-01, over the two-year training window. That is about 730 partitions. At roughly 3 minutes each, one worker needs about 36 hours; 8 parallel workers take about 5 hours (approximate). Run it outside the nightly window, in chunks of a month, with the gate checks on every partition, so a failed chunk can be re-run on its own.
Next, train a challenger on v2 and compare it with the champion through the gate in Experiments, versions and the model registry. If it wins, the model and its feature version switch together, and the registry records that model version 15 reads v2. Keep v1 as long as any model you might roll back to needs it, then drop it.
A backfill has its own point-in-time trap: it must read each past date's sources as they were known that night. Out-of-stock days come from stock_snapshot, which keeps one row per day, so that history is honest. Sales are slightly optimistic, since the backfill sees complete days where the night run saw about 98% of the latest one; measure the gap against the frozen v1 partitions. A source that keeps only its current value can't be backfilled honestly at all. Start recording its history today.
Build it
Build the front half of the nightly pipeline: an incremental extract with a watermark, the gate checks, the point-in-time training join and one shared feature function.
Extract incrementally with a watermark
The watermark table has one row per source. The extract copies rows that arrived since the last run into a staging partition for tonight, which it overwrites on a re-run. Then it lists the changed sale dates: the last 3 days are rebuilt tonight, and older ones are counted for the Sunday backfill. The watermark moves only at the end.
SQLCREATE TABLE IF NOT EXISTS etl_watermark ( source text PRIMARY KEY, loaded_upto timestamptz NOT NULL -- everything loaded up to here is processed ); -- :'run_date' and :'cutoff' are psql variables, e.g. 2026-10-20 and 2026-10-20 23:15 DELETE FROM stg_daily_sales WHERE run_date = DATE :'run_date'; -- safe to re-run INSERT INTO stg_daily_sales (run_date, store_id, article_id, sale_date, units_sold, loaded_at) SELECT DATE :'run_date', store_id, article_id, sale_date, units_sold, loaded_at FROM daily_sales WHERE loaded_at > (SELECT loaded_upto - interval '10 minutes' -- small overlap FROM etl_watermark WHERE source = 'daily_sales') AND loaded_at <= TIMESTAMPTZ :'cutoff'; -- Which sale dates changed? in_window = rebuild tonight; the rest go to the backfill log SELECT sale_date, count(*) AS changed_rows, sale_date >= DATE :'run_date' - 2 AS in_window FROM stg_daily_sales WHERE run_date = DATE :'run_date' GROUP BY sale_date ORDER BY sale_date; -- Last step of the night, after the feature partition is written UPDATE etl_watermark SET loaded_upto = TIMESTAMPTZ :'cutoff' WHERE source = 'daily_sales';Gate the run with SQL checks
Each output row is one check with its value, its severity and whether it passed. The gate step writes the rows to
check_resultwith therun_id, stops the run if anyfailcheck failed, and passes the list of missing stores to the predict step for quarantine.SQLWITH today AS ( SELECT count(*) AS n_rows, count(DISTINCT store_id) AS n_stores, count(*) FILTER (WHERE units_sold < 0) AS n_negative FROM daily_sales WHERE sale_date = DATE :'run_date' ), last7 AS ( SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n) AS median_rows FROM (SELECT sale_date, count(*) AS n FROM daily_sales WHERE sale_date BETWEEN DATE :'run_date' - 7 AND DATE :'run_date' - 1 GROUP BY sale_date) d ), fresh AS ( SELECT max(loaded_at) AS last_load FROM daily_sales WHERE sale_date >= DATE :'run_date' - 3 ) SELECT 'row_count_ratio' AS check_name, 'fail' AS severity, round((n_rows / median_rows)::numeric, 2) AS value, n_rows BETWEEN 0.75 * median_rows AND 1.30 * median_rows AS passed FROM today, last7 UNION ALL SELECT 'minutes_since_load', 'fail', round(extract(epoch FROM now() - last_load) / 60, 0), last_load > now() - interval '60 minutes' FROM fresh UNION ALL SELECT 'negative_unit_rows', 'warn', n_negative, n_negative <= 50 FROM today UNION ALL SELECT 'stores_reporting', 'quarantine', n_stores, n_stores = 320 FROM today;Build the training set with a point-in-time join
Each label row (a forecast date, a target date and the units sold) takes the feature row from the latest night on or before its forecast date, at most a week old. The nightly run uses the same rule when a partition is missing, so the model sees the same staleness in training and in serving. Use
LEFT JOIN LATERAL … ON trueinstead if you want to keep labels with no feature row and count them.SQLSELECT l.store_id, l.article_id, l.forecast_date, l.target_date, l.units, f.as_of_date, f.avg_last_7, f.avg_last_28, f.promo_flag FROM training_label l CROSS JOIN LATERAL ( SELECT sf.* FROM store_article_features sf WHERE sf.store_id = l.store_id AND sf.article_id = l.article_id AND sf.as_of_date <= l.forecast_date -- never after the forecast was made AND sf.as_of_date > l.forecast_date - 7 -- and not more than a week stale ORDER BY sf.as_of_date DESC LIMIT 1 ) f;Write one feature function and import it everywhere
The nightly features step, every backfill and the online endpoint import this module. The fill rule for promotions lives here and nowhere else. Upstream SQL expands national promotions to one row per store, product and day, keeping the deepest discount.
Python# features.py: the one definition, imported by every path import pandas as pd FEATURE_VERSION = "v1" FEATURES = ["avg_last_7", "avg_last_28", "dow", "promo_flag", "promo_depth"] KEYS = ["store_id", "article_id"] def build_features(sales: pd.DataFrame, promo_days: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame: """Features for horizons 1-4 as known at the end of as_of. sales: store_id, article_id, sale_date, units_sold (days with no row sold 0) promo_days: store_id, article_id, promo_date, discount_pct (one row per day)""" hist = sales[sales["sale_date"] <= as_of] # never read past as_of last28 = hist[hist["sale_date"] > as_of - pd.Timedelta(days=28)] last7 = last28[last28["sale_date"] > as_of - pd.Timedelta(days=7)] out = (last28.groupby(KEYS)["units_sold"].sum().div(28).rename("avg_last_28").to_frame() .join(last7.groupby(KEYS)["units_sold"].sum().div(7).rename("avg_last_7")) .fillna({"avg_last_7": 0.0}) .reset_index()) out = out.merge(pd.DataFrame({"horizon": [1, 2, 3, 4]}), how="cross") out["target_date"] = as_of + pd.to_timedelta(out["horizon"], unit="D") out["dow"] = out["target_date"].dt.dayofweek promos = promo_days.rename(columns={"promo_date": "target_date"}) out = out.merge(promos, on=KEYS + ["target_date"], how="left") out["promo_depth"] = out["discount_pct"].fillna(0.0) # the ONE fill rule out["promo_flag"] = (out["promo_depth"] > 0).astype(int) assert out[FEATURES].notna().all().all(), "null feature: check upstream data" out["feature_version"] = FEATURE_VERSION return out.drop(columns=["discount_pct"])Compare served features with training every night
Run this after the predict step and alert when a null share moves away from 0 or a mean moves by more than about 20%. Add one row per feature, or generate the query from the
FEATURESlist.SQLSELECT 'promo_flag' AS feature, avg((t.promo_flag IS NULL)::int) AS null_share_train, (SELECT avg((s.promo_flag IS NULL)::int) FROM served_features s WHERE s.run_id = :'run_id') AS null_share_tonight, avg(t.promo_flag) AS mean_train, (SELECT avg(s.promo_flag) FROM served_features s WHERE s.run_id = :'run_id') AS mean_tonight FROM store_article_features t WHERE t.as_of_date >= DATE :'run_date' - 56; -- the last 8 weeks of training data
Bring it to your team
Ask these when you review a feature pipeline.
- Which column is the watermark on, and what happens to a row that arrives two days late?
- When the watermark moves, is the output already written?
- For each gate check, what is the threshold, where did it come from, and does a failure stop the run, quarantine some stores, or warn?
- Can we rebuild tonight's training row exactly as the model saw it, from the feature table alone?
- How many places compute this feature? If more than one, where is the parity test?
- Which models read feature version v1, and when can we drop it?
Red flags
- An extract that filters on
sale_date > last_date. - A gate with only one action: every problem stops the whole run, or none does.
- Training sets joined to the current version of a table that changes over time.
- Feature logic copied into the API "for speed".
- A feature definition edited in place while the champion model still reads it.
Practice
Join labels to the price that was valid
You are building a training set. label(store_id, article_id, label_date, units) holds one row per store, product and day. price_history(article_id, valid_from, price_cents) holds one row each time a product's price changed; a price applies from its valid_from date until the next change. Return every label row with the price valid on its label_date: the price with the latest valid_from on or before label_date. If no price was valid yet, keep the row with an empty (null) price_cents. Columns: store_id, article_id, label_date, units, price_cents, ordered by store_id, article_id, label_date.
CREATE TABLE label (store_id int, article_id int, label_date date, units int); INSERT INTO label VALUES (14, 101, '2026-10-06', 31), (14, 101, '2026-10-10', 44), (14, 101, '2026-10-15', 40), (14, 101, '2026-10-24', 27), (14, 205, '2026-10-02', 12), (14, 205, '2026-10-12', 15), (22, 101, '2026-10-09', 18), (22, 101, '2026-10-20', 16), (22, 307, '2026-10-05', 9); CREATE TABLE price_history (article_id int, valid_from date, price_cents int); INSERT INTO price_history VALUES (101, '2026-10-01', 250), (101, '2026-10-10', 220), (101, '2026-10-20', 260), (205, '2026-10-03', 480), (205, '2026-10-11', 450), (205, '2026-10-30', 499), (307, '2026-09-15', 120);
-- Each label row with the price valid on its label_date
SELECT l.store_id, l.article_id, l.label_date, l.units, p.price_cents
FROM label l
JOIN price_history p ON p.article_id = l.article_id
ORDER BY l.store_id, l.article_id, l.label_date;
[
{"store_id": 14, "article_id": 101, "label_date": "2026-10-06", "units": 31, "price_cents": 250},
{"store_id": 14, "article_id": 101, "label_date": "2026-10-10", "units": 44, "price_cents": 220},
{"store_id": 14, "article_id": 101, "label_date": "2026-10-15", "units": 40, "price_cents": 220},
{"store_id": 14, "article_id": 101, "label_date": "2026-10-24", "units": 27, "price_cents": 260},
{"store_id": 14, "article_id": 205, "label_date": "2026-10-02", "units": 12, "price_cents": null},
{"store_id": 14, "article_id": 205, "label_date": "2026-10-12", "units": 15, "price_cents": 450},
{"store_id": 22, "article_id": 101, "label_date": "2026-10-09", "units": 18, "price_cents": 250},
{"store_id": 22, "article_id": 101, "label_date": "2026-10-20", "units": 16, "price_cents": 260},
{"store_id": 22, "article_id": 307, "label_date": "2026-10-05", "units": 9, "price_cents": 120}
]
Show a solution
SELECT l.store_id, l.article_id, l.label_date, l.units, p.price_cents
FROM label l
LEFT JOIN LATERAL (
SELECT ph.price_cents
FROM price_history ph
WHERE ph.article_id = l.article_id
AND ph.valid_from <= l.label_date -- on or before: 10 Oct picks the 10 Oct price
ORDER BY ph.valid_from DESC -- the latest one that had started
LIMIT 1
) p ON true -- LEFT ... ON true keeps labels with no price yet
ORDER BY l.store_id, l.article_id, l.label_date;The starter joins every price to every label, so each label row appears three times, with prices from the future. DISTINCT ON (l.store_id, l.article_id, l.label_date) over a LEFT JOIN on valid_from <= label_date, ordered by valid_from DESC, gives the same result.
Design tonight's gate
Design the validation gate for Mangrove's nightly run. For each check, give the rule, the threshold and the action on failure (fail the run, quarantine some stores or products, or warn), with one line on why. Cover schema, freshness, volume, completeness by store, ranges, nulls and uniqueness. Then decide what tonight's run does with these results: 7 stores have no rows for today; the promotions table was last loaded 3 days ago; 120 rows have negative units; tonight's row count is 0.97 of the 7-night median.
- Every check has a numeric or exact threshold, and you say where the thresholds come from (history, reviewed for false alarms)
- You use all three actions and choose by how much output would be wrong and whether the model would be fooled
- You quarantine the 7 stores with the fallback forecast and an alert, instead of failing the whole run or treating them as zero sales
- You treat the 3-day-old promotions as a failure (or a fallback) because recent promotions would be missing everywhere, and you explain the difference from a one-day delay
- You say that every result is logged with the run id and who gets alerted
Show a model answer
Check Rule and threshold On failure Why
Schema expected columns and types in daily_sales, promo fail features would be wrong everywhere
Freshness last sales load within 60 min of 23:15 fail the whole night is stale
promo loaded since yesterday; 2+ days late warn / fail plans are known weeks ahead,
but new promos would be missing
Volume rows 0.75 to 1.3 x median of last 7 nights fail a half-loaded table fools the model
(festival window: compare with last year's festival)
Completeness 320 stores with rows for today quarantine damage limited to those stores
Ranges negative-unit rows <= 50; price > 0 warn returns can net below zero
Nulls 0 nulls in keys and in promo_flag fail a new null is skew for every row
Uniqueness 1 row per store, article, day fail duplicates double features
Thresholds: from the last 90 nights, reviewed monthly for false alarms.
Every result goes to check_result with run_id; fail pages the data on-call.
Tonight: 7 stores -> quarantine, fallback forecast, alert the store sync owner.
Promotions 3 days old -> fail the model step: promotions planned in the last
3 days are missing in all 320 stores and the model would under-forecast them.
Publish the fallback (last night's forecast shifted a day) and page the promo
feed owner; a one-day delay would only warn. 120 negative rows -> warn, check
for a returns bug tomorrow. Row count 0.97 -> pass.Check yourself
The champion model reads avg_last_28. An engineer opens a pull request that changes the function so it skips out-of-stock days, and says the backtest improves by 1 point. What is the right way to ship it?
Editing in place gives the champion a meaning it was never trained on, starting tonight, and leaves the feature table with two meanings mixed in one column. Retraining later still leaves days of skew. Changing features is fine; it needs a new version, a backfill and a switch tied to the model.
A store's tills are offline from Saturday morning and upload Saturday's sales on Monday at 02:00. The extract uses a loaded_at watermark and rebuilds the last 3 sale dates every night. When do Saturday's sales reach the cleaned sales table?
Sunday night's run finished before 02:00 on Monday, when the rows hadn't arrived. Monday night's run reads them because their load time is new, and its window covers Monday, Sunday and Saturday. "Older than the watermark" is the sale-date thinking a load-time watermark avoids.
After launch, the live WAPE in the 30 stores of one city is 6 points worse than elsewhere, although the backtest showed no difference. The logged features for those stores show today's units about 15% below the training values for the same weekdays. What is the most likely cause and fix?
The model is the same everywhere; only one city's inputs differ from training, and they differ in a regular way. That points to a feature computed differently at serving time, and a time zone shift that moves evening sales to the next day fits exactly. Retraining or a new model would hide the bug instead of fixing it.
Flashcards
Key takeaways
- Extract incrementally with a watermark on load time, rebuild a short window for late data, and move the watermark only after the output is written.
- Gate the data before the model: each check has a rule, a threshold and an action, and quarantine keeps one bad store from stopping the rest.
- Store features by entity and as-of date, keep old partitions frozen, and build training sets with point-in-time joins.
- Skew breaks nothing visible: prevent it with one feature definition, logged serving features, nightly comparisons and a parity test.
- Change features by versioning and backfilling, and switch the feature version together with the model.
Sources and further reading
- Rules of Machine Learning, Martin Zinkevich, Google for Developers (the training-serving skew rules)
- Feast documentation, the Feast project (feature views, point-in-time joins, materialization)
- Great Expectations documentation, Great Expectations
- Table expressions, including LATERAL subqueries, PostgreSQL documentation