1. Courses
  2. Data
  3. Machine Learning in Production
  4. Stop 2

35 minAdvanced

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 reads only the rows that are new or changed since the last successful run. To know where the last run stopped, it keeps a : 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 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?

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 : 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.

CheckRule at Mangrove (thresholds are a starting point)On failure
SchemaExpected columns exist with the expected typesFail
FreshnessLast daily_sales load within 60 minutes of the 23:15 start; promotions loaded since yesterdayFail; promotions warn for one day
VolumeTonight's rows between 0.75 and 1.3 times the median of the last 7 nightsFail
CompletenessAll 320 stores have rows for todayQuarantine the missing stores
RangesNo more than 50 rows with negative units; prices above 0Warn
NullsNo nulls in key columns or in promo_flagFail
UniquenessOne row per store, product and dayFail

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.

Warehouse daily_sales, promo rows since watermark Validation gate counts, freshness fail fall back + alert pass Feature table store, article, as_of point-in-time join tonight only same code on both paths Training set weekly, 2 years Tonight's rows predict 4 days training logged Compare nightly nulls, means, ranges
Only rows that pass the gate become features. The feature table feeds both the weekly training set and tonight's prediction rows through the same feature code, and every night the features the model actually used are compared with the training statistics, which catches skew in days instead of weeks.

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?

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 : 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 (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.

250 cents from 1 Oct 220 cents from 10 Oct 260 cents from 20 Oct Price of article 101 6 Oct 10 Oct 15 Oct 24 Oct labels as-of join picks 250 220 220 260 latest price only 260 260 260 260
Each label date looks back to the latest price that had started on or before it. On 10 October the new price is already valid, because the rule is on or before. Joining the latest price instead gives every old sale a price that was decided later, which leaks the future into training.

Where do the features live when something needs them fast? The warehouse tables are the : 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 (Caching covers the pattern). A 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?

Training/serving skew: same model, different inputs

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_7 in 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 a served_features table; 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?

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 : add avg_last_28_v2 next to v1 and leave v1 alone. Then run a : 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.

  1. 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.

    SQL
    CREATE 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';
  2. 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_result with the run_id, stops the run if any fail check failed, and passes the list of missing stores to the predict step for quarantine.

    SQL
    WITH 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;
  3. 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 true instead if you want to keep labels with no feature row and count them.

    SQL
    SELECT 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;
  4. 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"])
  5. 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 FEATURES list.

    SQL
    SELECT '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.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- 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;
Ctrl + Enter runs
Show a solution
SQL
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.

Pass the exercise to finish this task.

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
Output
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?

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?

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?

Flashcards

Open my review

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

Episode transcriptTwo recipes for one feature · about 11 minutes · tap any line to play from there
  1. NadiaImran, two weekends in a row Ama's store ran out of promoted cooking oil. The model was supposed to see those promotions coming. What would you check first?
  2. ImranThe model, I suppose. Maybe it never learned how much promotions lift sales.
  3. NadiaThat's where most people look. The model was fine. The inputs it got at night were different from the ones it learned on. Today is about the pipes that feed a model, and how they quietly go wrong.
  4. NadiaPicture a restaurant. The head chef perfects a curry in the test kitchen, weighs everything, and writes a recipe card. Then the line cooks make it every night for two hundred guests.
  5. ImranAnd the curry tastes different in the restaurant.
  6. NadiaSometimes. Three things decide it. Deliveries, because some days the vegetable van is late. Receiving, because a clerk has to open every crate before it goes in the fridge. And the card. If the test kitchen wrote twenty grams of salt and the night cook's card says salt to taste, the dish changes, even with good cooks.
  7. ImranSo the warehouse is the delivery door, there's a check at receiving, and the recipe card is the feature code.
  8. NadiaExactly. Let's start with deliveries. The first step every night pulls sales from the warehouse. You could read all two years every night.
  9. ImranThat sounds slow. Two point five million store and product pairs, times seven hundred days.
  10. NadiaUp to about one point eight billion rows, roughly, and it grows every month. So you extract incrementally. Only the rows that are new or changed since the last good run. You remember where you stopped with a watermark, the highest value you've already processed.
  11. ImranSo I keep the last sale date I saw, and next night I ask for anything after it.
  12. NadiaThat's the trap. Our tills keep selling when the internet drops and upload later. About two percent of a day's transactions arrive after midnight, roughly. A till that's offline all weekend uploads Saturday's sales on Monday.
  13. ImranAnd by Monday my sale date watermark is already past Saturday. So those rows are never read.
  14. NadiaNever, and no error anywhere. Put the watermark on the load time instead, the moment the row reached the warehouse. That clock only moves forward, so every late row looks new, whatever day it belongs to.
  15. ImranBut then I've found Saturday's rows on Monday. Saturday's numbers still need fixing.
  16. NadiaRight. So every night the pipeline rebuilds the last three sale dates from scratch, overwriting them. That's the reprocessing window. Three days catches nearly all late data. Anything older gets counted, and the Sunday run repairs it before training.
  17. NadiaTwo small rules. Move the watermark last, after the output is written, so a failed run reads the same rows again. And read with a ten minute overlap, because a row being saved during the extract can carry a slightly earlier time.
  18. ImranOkay. Receiving. What does the clerk check?
  19. NadiaA model never complains about its inputs. If three stores didn't sync, their rows are missing, the features read as zero sales, and the model forecasts almost nothing for them. So there's a validation gate between the extract and the features.
  20. NadiaIf you can see the screen, the first diagram shows it. Warehouse at the top, then the gate, with a red branch to the right that says fail, fall back and alert. If not, picture a funnel with a guard halfway down.
  21. ImranWhat does the guard look at?
  22. NadiaSchema, freshness, row counts against the median of the last seven nights, all three hundred and twenty stores present, ranges like negative units, nulls in key columns, and one row per store, product and day.
  23. ImranAnd if one fails, the whole night stops?
  24. NadiaThat's the design question. There are three actions. Fail stops the run, and stores order from last night's forecast shifted by a day. Quarantine drops only the affected stores and gives them the fallback. Warn keeps going and logs it for the morning.
  25. ImranSo nine stores missing is quarantine. The other three hundred and eleven still get a real forecast.
  26. NadiaYes. And a null in a feature column is a fail, because it fools the model in every store. Fifty odd rows with negative units, probably returns, is a warn. You choose by how much output would be wrong and whether the model would be fooled.
  27. ImranDo we need a tool for this? I hear Great Expectations mentioned a lot.
  28. NadiaTools like that and pandera are nice for declaring checks and reports. But plain SQL queries, each returning a value and a pass flag, are enough to start. Log every result with the run ID, so you see a number creeping towards its limit.
  29. ImranAfter the gate, the features. Where do they go?
  30. NadiaInto a feature table. One row per store, product and as-of date, holding the values as they were known when that night's run started. About two point five million rows a night.
  31. ImranWhy store them? Couldn't training recompute them from the warehouse?
  32. NadiaIt could, and it would cheat a little. At a quarter past eleven, a store with an offline till has only part of today's sales. A month later the warehouse shows the complete day. Recompute, and training sees complete days the nightly run never had.
  33. ImranSo the table freezes what we knew. And the late data only fixes the sales themselves.
  34. NadiaNicely put. Rebuild facts as late data arrives. Never rebuild what you knew. The labels, the units actually sold, should be the final numbers.
  35. ImranWhat about things that change at random times? Prices.
  36. NadiaThat's the point-in-time join, and the second diagram shows it. Product one oh one costs two hundred and fifty cents from the first of October, two hundred and twenty from the tenth, and two hundred and sixty from the twentieth.
  37. NadiaEach sale looks back to the latest price that had started on or before its day. The sixth gets two fifty. The tenth gets two twenty, because the new price is already valid that day. The twenty-fourth gets two sixty.
  38. ImranAnd the lazy version joins today's price to everything.
  39. NadiaWhich gives a sale from the sixth a price decided on the twentieth. That's leakage, the same idea from the data science course. The model learns a price effect that never happened.
  40. ImranWhere does Redis come in? The ordering screen needs answers fast.
  41. NadiaThe warehouse tables are the offline store, full history for training and the batch. Each night we copy tonight's values into Redis, the online store, for the what-if endpoint. Feature stores like Feast package all of this. For one model, tables plus Redis are enough.
  42. ImranNow the cooking oil. What actually happened?
  43. NadiaIn the pilot, the training set came from Tomás's SQL, and the nightly rows came from a Python job someone rewrote from his notebook. Both built a promo flag. One when a promotion covers the day.
  44. ImranTwo recipes for one dish.
  45. NadiaExactly. National promotions are stored with an empty store ID, meaning every store. Tomás's SQL matched this store or empty, and filled anything unmatched with zero. The Python job matched exact store IDs, and left the rest empty.
  46. ImranAnd the model had never seen an empty promo flag.
  47. NadiaNever. Gradient boosting sends a missing value down a default branch, and that branch pointed to the common case, no promotion. So every national promotion looked like an ordinary day. Promoted items came out about thirty percent low, roughly, in the four pilot stores.
  48. ImranHow did he find it?
  49. NadiaBy comparing logged serving features with training. In training the flag was never empty. In the pilot logs it was empty on about four point six percent of rows.
  50. ImranSo that's training and serving skew. What stops it next time?
  51. NadiaFour things. One feature function, imported by training, the nightly run, backfills and the endpoint. Log what the model actually saw. Compare it with training every night, null share, mean, range. And a parity test in CI that builds features both ways for fifty store products and demands identical values.
  52. ImranAny other sneaky versions?
  53. NadiaTime zones. Training uses the store's business day, and a serving job takes the date from a UTC timestamp. In a city three hours behind UTC, sales after nine in the evening land on tomorrow. Nothing crashes. One city's error just creeps up.
  54. ImranLast thing. Tomás wants the twenty-eight day average to skip out of stock days. Can he just change it?
  55. NadiaNot in place. The champion was trained on the old meaning. Add a version two next to version one, backfill two years, train a challenger on it, and switch the model and the feature version together.
  56. ImranHow long is a two year backfill?
  57. NadiaAbout seven hundred and thirty nights. At roughly three minutes each, about a day and a half on one worker, or around five hours on eight. Run it outside the nightly window, a month per chunk.
  58. ImranAnd the Build it section puts the front of the pipeline together.
  59. NadiaIt does. A small watermark table and an extract that copies tonight's new rows with an overlap. Gate checks as one SQL query, each row a check with its severity. A point-in-time join for the training set. One shared feature function in Python. And a nightly query comparing served features with training.
  60. NadiaA quick one. A till goes offline Saturday morning and uploads at two on Monday morning. When do Saturday's sales reach the clean table? Take a second.
  61. ImranSunday night's run finished before two, so not then. Monday night's run sees the new load times, and Saturday is inside Monday's three days. Monday night.
  62. NadiaExactly right.
  63. ImranMy summary. One, watermark on load time, rebuild three days, move the watermark last. Two, a gate that can fail, quarantine or warn. Three, freeze what the model knew, and join prices as they were on the day.
  64. NadiaGood. I'd add the one people skip. If a feature is computed in two places, assume they differ until a test proves they don't.
  65. ImranAnd next time?
  66. NadiaExperiments and the model registry. How to answer which model made the festival forecast, in ten minutes.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Data-quality checks

Duplicates, missing values, orphans and impossible values, each as one row with a count.

Open in the SQL Lab
Next stopExperiments, versions and the model registry
Was this lesson helpful?
Questions and discussion

Loading the discussion…