Part 9 · Derived views
Feature Stores and ML Data Pipelines
Lesson 22 built the last retrieval view — a search and vector index that answers "what is relevant to this query." This lesson builds the last family of derived views in Part 9, and the one closest to the machine-learning product itself: the features a model reads as input, and the predictions it writes as output. Both are derived data. A feature is not a fact a user typed; it is a function computed over the raw event log — "this user's 7-day click rate," "this product's average rating." A prediction is a function of features and a model version. Treat them as derived views and the whole discipline of Part 9 applies: there is one source of truth (the raw event log and base tables), the views can go stale, and they can always be recomputed. Treat them carelessly and you get the two failures that ruin ML systems in production — label leakage (training on information that did not exist yet) and train-serve skew (the same feature computed two different ways offline and online). This lesson builds the machinery that prevents both: the DataFrame as the ML execution abstraction, point-in-time joins, and the offline/online feature store split.
New capability: Design an ML data pipeline and feature store as correct derived data — pick the DataFrame abstraction that fits your scale, materialize a training set with a point-in-time join that cannot leak labels, split offline (batch) from online (low-latency lookup) feature serving, and guarantee the two stay consistent so the model sees at inference exactly what it saw in training.
1 · The DataFrame: named columns over partitioned rows
Almost all analytics and ML data work happens through one abstraction: the DataFrame. A DataFrame is a table of named, typed columns over a set of rows — like a SQL table, but as a first-class value in a program you can pass around, transform, and chain operations on. The operations are the relational ones you already know plus a few ML-flavored extras: filter (drop rows), select (project columns), join (combine on a key), groupBy ... agg (aggregate), withColumn (derive a new column). The reason this shape dominates ML is that feature engineering is column algebra: you take raw columns and compute new ones — "encode this category as an integer," "compute the 7-day rolling click rate," "bucket this price into deciles." Each is a column-to-column transform.
Two design axes separate the DataFrame libraries you will meet, and choosing wrong is a real production cost.
Eager vs lazy evaluation. An eager DataFrame (pandas) executes each operation the instant you write it — df.filter(...) immediately materializes a new filtered frame in memory. Simple to reason about and debug, but it cannot optimize across operations: if you filter after a join, pandas still built the full join first. A lazy DataFrame (Spark, Polars in lazy mode) does not execute as you go; it records your operations as a query plan (a directed graph of transforms) and runs nothing until you call an action that needs a result — collect(), count(), write-to-disk. Before executing, an optimizer rewrites the plan: it pushes the filter down below the join so fewer rows are ever joined (predicate pushdown), drops unread columns (projection pushdown), and picks join strategies — the very batch optimizations of lesson 18. Lazy buys whole-pipeline optimization at the cost of "nothing happened until the action," which surprises people debugging.
Single-node vs distributed. A single-node DataFrame (pandas, Polars) holds all the data in one machine's memory and is bounded by that machine's RAM — wonderful up to tens of GB, a wall beyond it. A distributed DataFrame (Spark) partitions the rows across many machines (partitioning, lesson 18) and runs each operation in parallel on every partition; a join or groupBy triggers a shuffle — the same all-to-all repartition-by-key from lesson 18 — which is again where the cost lives. The rule of thumb: if your data fits comfortably on one box, a single-node lazy frame (Polars) is faster and far simpler than a cluster; reach for Spark only when the data genuinely exceeds one machine, because the shuffle and coordination overhead is not free.
| Library | Evaluation | Scale | Use when |
|---|---|---|---|
| pandas | Eager | Single node, in-RAM | Exploration, small/medium data, subsecond iteration |
| Polars | Lazy (and eager) | Single node, larger-than-pandas | One big machine, want the optimizer without a cluster |
| Spark | Lazy | Distributed, partitioned | Data exceeds one machine; cluster-scale joins/aggregates |
From DataFrame to tensor: what the model actually eats
A DataFrame is the preparation surface, but a model does not consume named columns — it consumes dense numeric arrays. The last step of any ML pipeline converts the typed, named, often-categorical DataFrame into a matrix (a 2-D array of numbers: rows = examples, columns = numeric features) for classical models, or a tensor (an N-dimensional array — e.g. a batch of images is 4-D: batch × height × width × channels) for deep models. This conversion is exactly where categorical encodings (one-hot, integer, embedding lookups) and normalization happen, and it is why columnar storage (lesson 04) underpins ML: a feature is a column, models read whole columns at once, and a columnar layout lets you scan one feature across millions of rows without touching the others. The DataFrame is the bridge from named, heterogeneous, human-meaningful data to the dense, homogeneous, numeric form the linear algebra needs.
2 · The ML data pipeline as derived data
An ML system's data substrate is a pipeline of derived views, each computed from the one before, all ultimately derived from the raw event log and base tables. The stages:
The framing you will hear is ETL vs ELT. ETL (extract, transform, load) transforms data before loading it into the warehouse — you decide the shape up front. ELT (extract, load, transform) loads raw data first and transforms it inside the warehouse with SQL/DataFrame jobs, so the raw layer is preserved and transforms can be re-run and revised. ELT is the modern default for ML precisely because of lesson 18's lesson: keep the immutable raw input, and recompute the derived layers when a feature definition changes. The feature tables, the training set, and the predictions are all derived; the raw event log is the only thing you cannot regenerate.
3 · The point-in-time join: the one you must get right
Here is the join at the center of every feature store, and the one that quietly destroys models. You have labels — each is an event with a timestamp: "user 42 was shown product 91 at 10:00 and clicked." You have features that change over time — "user 42's 7-day click rate," which was 0.10 last week and 0.30 today. To build a training row for that 10:00 label, you must attach the feature value as it was at 10:00 — not its value now. Reading "whatever the feature table says today" is the same mistake as the stream-table join in lesson 19: you must read the feature as of the label's event time.
Why it matters has a name: label leakage. If you join the 10:00 label to today's feature value (0.30), you have leaked information from after the prediction moment into the training row. The model learns a relationship that was not knowable at prediction time; offline metrics look fantastic, and the model fails in production because at serving time the future is, of course, unavailable. The fix is a point-in-time join (also called an as-of join): for each label, take the most recent feature value whose timestamp is ≤ the label's event time.
Mechanically, the point-in-time join is a join on entity_id plus an inequality on time (feature.ts ≤ label.ts), keeping the row with the maximum feature timestamp per label. It is more expensive than an equality join — engines implement it as a sort-merge on (entity, time) so it stays a sequential scan rather than a per-row lookup (lesson 18) — but it is the price of not leaking. Every serious feature store (Feast, Tecton, Databricks) makes the point-in-time join the primitive of training-set generation precisely because hand-rolled joins get it wrong.
4 · Offline vs online: two stores, two access patterns
The same logical features are read in two completely different ways, and a feature store is, at heart, the system that serves both from one definition.
The same feature ("7-day click rate") is therefore materialized into two physical stores: a wide columnar history for training, and a single hot row per entity for serving. A batch or streaming job computes the feature and writes it to both — and that dual materialization is exactly where the next failure mode lives.
Keeping the two consistent is the core feature-store problem. The clean pattern is the CDC/log approach of lesson 19: one feature-computation job writes to the offline columnar table, and the online store is kept in sync from that same computation (a materialization job pushes the latest value per entity into the KV store, or both subscribe to one feature stream). The freshness gap between them — how stale the online value is relative to the offline history — is the online store's lag budget, the streaming-vs-batch trade of lessons 18–19 applied to features.
5 · Train-serve skew: the same feature, computed twice
Train-serve skew is the silent killer of deployed models: the feature value the model saw in training differs from the value it sees at serving, even for the identical entity and time, because the two were computed by two different code paths. The classic origin: the training feature is computed in a Spark/SQL batch job ("7-day click rate" as a windowed aggregate over the event log), while the serving feature is computed in application code in the request path ("count this user's clicks in the last 7 days" via a different query, a different time-zone handling, a different null policy). Each looks right in isolation; they disagree at the third decimal place; the model — which is exquisitely sensitive to its input distribution — degrades, and nothing in your tests catches it because each path passes its own unit tests.
7d_click_rate = clicks_7d / impressions_7d where the 7-day window is computed in UTC, and a user with zero impressions yields NULL (dropped). Serving (app code): the same ratio, but the window is computed in the server's local time zone, and zero impressions yields 0.0 (kept). For most users the two agree; for users near a day boundary or with sparse activity they diverge — say training says 0.25 and serving says 0.20 for the same user at the same instant. The model was fit assuming 0.25; it receives 0.20; predictions drift. The bug is invisible per-path: both "work." The only reliable detection is an offline/online parity audit — log the served feature vector alongside the prediction, recompute the same feature from the offline definition for those entities/times, and assert they match within tolerance.The fix is structural, not "be more careful": one feature definition, shared by both paths. You define the transform once — as a function, a SQL/DataFrame expression, or a feature-store-managed definition — and both the offline training-set job and the online serving path execute that same definition over the same inputs, with the same encoding. That shared encoding is exactly lesson 05's point: a feature is a schema (name, type, null policy, units, time zone) that two systems must agree on, and the way you keep two systems agreeing on a schema is to make the schema a single shared artifact, not two re-implementations. Feature stores enforce this by being the registry where a feature is defined once and both stores are populated from that one definition. When the definition changes, you bump its version and recompute — the derived-data discipline of lesson 20.
6 · Predictions are derived data too
The final move closes the loop. A model's output — a score, a ranking, a class, an embedding — is itself a derived view: a deterministic function of (input features, model version). That reframing buys the same superpowers as every other derived view in Part 9. Predictions can be recomputed: ship model v2 and re-score the backlog. They can be backfilled: generate v2's predictions over all of history to compare against v1 before you roll out. They can be cached as a materialized view (precompute recommendations nightly) or computed on demand, the freshness-vs-cost trade of the whole part. And they are auditable: record the feature vector, the model version, and the timestamp alongside each prediction, and any output is reproducible — you can re-derive exactly what the model saw and said. The source of truth was never the prediction; it was the event log and the model artifact, and the prediction is a regenerable function of them.
This is why a prediction log is not just a debugging convenience — it is the evidence layer. It is what powers the parity audit of §5 (compare served vs recomputed features), what lets you attribute a metric regression to a model version versus a data shift, and what makes "predictions as derived data" an operational reality rather than a slogan. The model registry (lesson 31's sibling case at lesson 33) is the catalog of model versions that makes this reproducibility complete.
Failure modes
- Label leakage. Joining a label to a feature value computed after the label's event time; offline metrics are inflated, production fails. Symptom: a model that is "too good" offline and disappoints live. Fix: point-in-time / as-of join (§3).
- Train-serve skew. The same feature computed by two code paths (batch vs request-path) diverges. Symptom: live performance below offline, no failing test. Fix: one shared definition + shared encoding (§5).
- Stale online store. The online KV value lags the offline definition past its budget; the model serves on old features. Symptom: features "frozen" after a pipeline stall. Fix: monitor materialization lag (§4).
- Eager frame OOM. A pandas join on data that no longer fits RAM. Symptom: the box swaps and dies. Fix: lazy + larger node (Polars) or distribute (Spark) (§1).
- Silent data corruption. A malformed value passes ingest unvalidated and poisons every downstream model. Symptom: gradual metric rot with no code change. Fix: validation gate at stage 2 (§2).
- Untracked feature version. Changing a feature definition in place so old training sets cannot be reproduced. Fix: version definitions, recompute (§5, lesson 20).
Decision checklist
- Does the data fit one machine? If yes, lazy single-node (Polars); if no, distributed (Spark) and watch the shuffle (§1).
- Is every training-set join a point-in-time join on (entity, time ≤ label time)? Can you state, for each feature, that it was knowable at the label's event time? (§3)
- Are offline and online features written from one definition, or two code paths? (§5)
- Is there an offline/online parity audit logging served features and recomputing them? (§5, §6)
- What is the online store's freshness budget, and do you monitor materialization lag against it? (§4)
- Are feature definitions versioned so a training set is reproducible and recomputable after a change? (§2, §6)
- Do you log (features, model version, timestamp) with every prediction so outputs are auditable and backfillable? (§6)
Checkpoint exercise
user_7d_click_rate and item_avg_rating, both changing over time; labels are impression events with a timestamp and a clicked flag. (a) You have 4 billion impression events and a feature history far larger than one machine — pick the DataFrame engine and say what the expensive step in the training-set join will be. (b) Write, in words, the join condition that builds a leak-free training row for an impression at time t, and give a two-row worked example where the naive "current value" join would leak. (c) The model will serve at 5 ms p99 per request — describe the online store and how it is kept consistent with the offline store from one feature definition. (d) Three weeks in, live CTR is below the offline estimate but no test fails. Name the most likely cause and the one audit that would have caught it.Where this points next
Part 9 has now built every family of derived view — materialized views and their correctness (lesson 20), analytics query execution (21), search and vector retrieval (22), and now features and predictions for ML. Every one of them — the columnar warehouse, the vector index, the offline feature store, the prediction log — lives on the same physical substrate: cloud object storage. The next lesson (lesson 24, Cloud-Native Storage as a Subsystem) opens that substrate up. It asks how a system like S3 actually delivers durability, elasticity, and the separation of storage from compute that lets a Spark cluster, a warehouse, and a feature store all read the same Parquet files — and what the data-intensive designer gives up (latency, strong consistency guarantees, per-request cost) in exchange. The feature store you just designed assumes that substrate exists; lesson 24 builds it.
Interview prompts
- What is a DataFrame, and what do the eager/lazy and single-node/distributed axes buy you? (§1 — named typed columns over rows; lazy (Spark/Polars) records a plan and optimizes the whole pipeline (pushdown, join choice) before any action, eager (pandas) runs each op immediately; single-node is RAM-bound and simple, distributed partitions rows and parallelizes but pays a shuffle on joins/aggregates. Fit one machine → Polars; exceed it → Spark.)
- Why does ML ultimately need columnar storage and tensors, not just a DataFrame? (§1 — a model consumes dense numeric matrices/tensors, not named columns; a feature is a column and models read whole columns across millions of rows, so columnar layout (lesson 04) lets you scan one feature without the others; the DataFrame is the bridge that encodes categoricals/normalizes into the dense array.)
- What is a point-in-time / as-of join and why is it mandatory for training sets? (§3 — join each label to the most recent feature value with timestamp ≤ the label's event time (a join on entity + a time inequality, lesson 19's "as of event time"); joining to the current value leaks future information (label leakage), inflating offline metrics while the model fails live.)
- Contrast the offline and online feature stores. (§4 — offline = columnar history (Parquet/warehouse), scans millions of rows with point-in-time joins to build training sets, throughput-optimized; online = low-latency KV (Redis/DynamoDB), point-lookup the latest vector for one entity in ~ms at inference; same feature materialized into both from one definition.)
- What is train-serve skew, how does it arise, and what is the structural fix? (§5 — the feature value in training differs from serving because two code paths (batch vs request-path) compute it differently (time zone, null policy, windowing); the fix is one shared definition + shared encoding (lesson 05), not "be careful," detected by an offline/online parity audit.)
- In what sense is a model's prediction "derived data," and why care? (§6 — a prediction is a deterministic function of (features, model version); so it can be recomputed, backfilled for a new model, cached as a materialized view, and audited — provided you log features + model version + timestamp; the SoR is the event log and model artifact, never the prediction.)
- Your offline AUC is excellent but live performance is mediocre and no test fails — what are the two prime suspects and how do you tell them apart? (§3, §5 — label leakage (a feature used a value not knowable at the label's event time → check every feature ts ≤ label ts with a no-leakage audit) and train-serve skew (offline and online features diverge → run an offline/online parity audit logging served vectors and recomputing them).)