Data engineering for recommendation
The model gets the credit. The pipeline does the work — and when it silently breaks, it kills the model with no error: stale features, train–serve skew, a null column that used to be populated, a date partition that never landed. A recommender is only ever as good as the data plumbing beneath it. This lesson is the offline/batch + data-quality substrate that lesson 17's streaming plane quietly assumes.
Why this is the unglamorous half that decides everything
A ranking model is a pure function: same features in, same score out. So when last week's AUC was 0.78 and today's is 0.71 with no code change, the model didn't rot — the data feeding it did. The failure mode that should keep you up at night is not the model throwing an exception; it is the pipeline that keeps running and keeps lying: a feature whose upstream join started returning NULL, an aggregate computed over a partition that arrived three hours late, a column whose distribution drifted because a logging SDK shipped a new version. The model dutifully consumes the garbage and produces confident, wrong scores. No alarm fires, because nothing crashed.
The batch big-data stack — MapReduce, Hadoop, Spark
Recsys data is too big for one machine: years of impressions, billions of events a day. The whole field is built on one primitive — MapReduce — and one realization about why it was slow.
MapReduce: map → shuffle → reduce
Express a computation as two functions over key-value pairs. Map runs in parallel on each input split, emitting (key, value) pairs. Reduce runs once per key, aggregating all values for that key. Between them sits the part nobody advertises and everybody pays for: the shuffle, which sorts and moves every mapper's output across the network so that all values for a given key land on the same reducer.
Map and reduce are embarrassingly parallel and run on local data. The shuffle is the bottleneck because it is the only all-to-all step: it serializes intermediate output to disk, sorts it, and ships it across the network. Roughly, if mappers emit N records that must be regrouped onto R reducers, the shuffle moves O(N) bytes over the network and does O(N log N) sort work — and it cannot start reducing until enough of it has landed. Minimizing shuffle volume is the single highest-leverage batch optimization (combiners that pre-aggregate map-side, broadcast joins that avoid shuffling the big side, columnar pruning so you shuffle fewer columns).
Hadoop/HDFS/YARN vs Spark — why Spark won iterative ML
Classic Hadoop MapReduce writes every intermediate result to HDFS (the distributed disk) between stages, with YARN scheduling containers. That is fine for a single pass but catastrophic for ML: training, feature aggregation, and graph algorithms are iterative, and Hadoop re-reads the dataset from disk on every pass. Spark's core insight is to keep the working set in memory across iterations.
| Hadoop MapReduce | Spark | Flink (lesson 17) | |
|---|---|---|---|
| Model | map → shuffle → reduce, materialized to disk each stage | RDD / DataFrame DAG, lazy, in-memory working set | stateful streaming operators, event-time |
| Latency | minutes–hours (T+1) | seconds–hours (batch / micro-batch) | milliseconds–seconds (true streaming) |
| Throughput | very high, cheapest per TB | very high, in-memory bound | high, latency-optimized |
| Iterative ML | re-reads disk every pass — slow | caches in RAM — 10–100× faster | incremental updates, online learning |
| Fault recovery | re-run failed task from HDFS | lineage — recompute lost partition from its DAG | checkpoint + replay from log offset |
| Reach for it when | one-shot huge ETL, cost-sensitive, freshness irrelevant | training-set builds, feature backfills, iterative jobs | per-second freshness, session features, trends |
Three Spark concepts earn the speedup. The RDD/DataFrame is the in-memory partitioned dataset. The lazy DAG means transformations (map, filter, join) build a plan but execute nothing until an action (count, write) forces it — so the optimizer can fuse stages and prune columns before any work runs. Lineage is the recovery story: instead of replicating data, Spark remembers the DAG that produced each partition, so a lost partition is recomputed from its parents rather than re-read from disk. Cache the training matrix once with persist() and every gradient pass reads RAM — that is the 10–100× over Hadoop on iterative work.
Data skew and the straggler problem
Recsys data follows a power law: a handful of items and users dominate. Partition by item_id and the viral clip's key sends a colossal value list to a single reducer while the others idle. Batch jobs run as fast as their slowest task, so one hot key makes wall-clock time blow up even though average load is tiny.
Tjob = maxr load(r) ≫ meanr load(r) when one key is hot
The standard fix is salting: append a random suffix to the hot key (itemA#0 … itemA#k) so its traffic spreads across k reducers, then do a second small reduce to merge the k partials. You trade an extra cheap aggregation for a flat load profile. Other levers: tune partition count (more partitions → finer granularity), isolate known hot keys into their own path, prefer map-side/broadcast joins so the skewed side is never shuffled. The widget below lets you feel the straggler problem and watch salting flatten the max.
The named tuning levers — and the failure mode each one fixes
Everything above is conceptual: shuffle is expensive, skew makes stragglers, salt the hot keys. In an interview the follow-up is always "okay, what do you actually turn?" Each Spark/Hadoop knob below exists to attack one of the failure modes already on the table — shuffle volume, skew, or the small-files problem. Anchor it to a real win: a feature build over 2 TB of daily user-behavior logs (impression × user-stats join) went from 4 hours to 35 minutes after four changes — none of them touched the algorithm.
| Lever | What it does | Failure mode it fixes |
|---|---|---|
spark.sql.shuffle.partitions | count of post-shuffle partitions (default 200) — size it to the shuffled bytes, not the cluster core count | too few → giant partitions spill to disk / OOM; too many → scheduler overhead + tiny files. The default 200 is almost always wrong at TB scale. |
persist(MEMORY_ONLY_SER) vs MEMORY_AND_DISK | cache the reused DataFrame; _SER stores a compact serialized blob in RAM, _AND_DISK spills the overflow instead of recomputing | re-reading / re-deriving the same dataset every pass (the iterative-ML problem above). _SER when it fits RAM tightly; _AND_DISK when it doesn't and recompute is costlier than a disk read. |
broadcast join (broadcast(df)) | ship the small side to every executor so the join is map-side — zero shuffle of the big side | shuffling the skewed big table. This is the cleanest skew fix when the small side fits in executor memory (autoBroadcastJoinThreshold defaults to 10 MB; teams often raise it to ~100 MB+ when the executors have headroom). |
| Bloom pre-filter on hot IDs | a probabilistic membership test that drops rows whose key can't match before they ever enter the shuffle | shuffling rows that will never join. Pre-filtering the big side against the join keys cuts shuffle volume directly (and ORC/Parquet bloom indexes do the same at scan time). |
repartition(n) vs coalesce(n) | repartition does a full shuffle to n even partitions; coalesce merges existing partitions with no shuffle but can leave them uneven | the small-files problem — thousands of tiny output files choke the next reader and the namenode. coalesce to shrink file count cheaply; repartition when you also need to rebalance skew. |
spark.sql.shuffle.partitionsThe rule: target ~128 MB of shuffled data per partition (HDFS-block-aligned — big enough to amortize task launch, small enough to stay in memory and not spill). The arithmetic is just a division:
Npartitions = shuffled_bytes / 128 MB
The trap is using the raw table size. Our join input is 2 TB on disk, so naively:
2 TB / 128 MB = 2,097,152 MB / 128 MB = 16,384 partitions
But the shuffle moves only what survives columnar projection + filtering, not the whole table. The join needs a handful of columns (user_id, item_id, watch_time, label) out of ~200, so Parquet's columnar projection reads only a fraction of the bytes (see the columnar section below), and an invalid-impression filter drops more rows still. Say that leaves ~256 GB actually shuffled:
256 GB / 128 MB = 262,144 MB / 128 MB = 2,048 ≈ 2000 partitions
So spark.sql.shuffle.partitions ≈ 2000 isn't a magic number — it's 256 GB / 128 MB rounded. Leaving the default 200 would have forced 256 GB / 200 ≈ 1.3 GB per partition: every reducer spills to disk and the 2 TB job crawls. Always size from the post-shuffle bytes; recompute the divisor whenever the shuffled volume changes by more than ~2×.
Stacking these on the 2 TB job: a broadcast join of the small user-stats dimension table eliminated the big side's shuffle entirely; spark.sql.shuffle.partitions=2000 right-sized the remaining shuffle so nothing spilled; persist(MEMORY_AND_DISK) kept the cleaned input resident across the multi-stage feature derivation instead of recomputing it; and repartition(200) on write collapsed thousands of tiny part-files into a clean output. Net: 4 h → 35 min (≈ 7×), no model change. On the Hadoop side the same instincts have older names — mapreduce.task.io.sort.mb and mapreduce.reduce.shuffle.parallelcopies tune the shuffle buffers, speculative execution relaunches a straggler's task on a second node, and CombineFileInputFormat is the small-files fix at read time. (Skew itself is fixed with salting — derived in the section above, not repeated here; broadcast and bloom pre-filter are the levers that let you avoid shuffling the skewed side in the first place. For the streaming-plane equivalents of these knobs — partition sizing, backpressure, state backends — see lesson 36; for where these computed features physically land, see the columnar/partitioning section below and the feature deep-dive in lesson 35.)
Lambda vs Kappa — and the train–serve skew it causes
Because most pipelines need both throughput and freshness, the classic architecture runs two layers in parallel. This is Lambda, and its convenience hides the single most common silent killer in production recsys.
In Lambda the same feature — say "user's 7-day average watch time" — is implemented twice: once in the batch layer's SQL, once in the speed layer's stream code. The two implementations inevitably diverge: a different rounding rule, a different window boundary, a different null handling. The model trains on the batch version and serves on the speed version, and the gap between them is train–serve skew — the model sees feature distributions at serving time that it never saw in training, and quality degrades with no error anywhere. (Same disease diagnosed from the serving side in lesson 18 and the feature-definition side in lesson 28.)
Kappa is the structural answer: there is no batch layer. Everything is a stream over the append-only log; a "batch recompute" is just replaying the log from offset zero, run by the same code that serves live traffic. One definition, one codepath, no skew by construction — the Kappa argument from lesson 17. The cost is that all logic must fit the streaming model and replays of years of history can be slow, so many shops keep a thin batch layer for cold backfills and accept the discipline of generating both from one shared transformation library. The principle stands: one feature, one definition.
ETL and the feature pipeline — Extract, Transform, Load
The plumbing that turns raw logs into model-ready features is an ETL (or ELT) pipeline. Three stages:
- Extract — pull from sources: the Kafka event log, application DBs (CDC), third-party feeds, the existing warehouse. The job here is faithful capture with provenance, not cleverness.
- Transform — the bulk of the work: parse, dedup, join behavioral events to entity attributes, compute aggregates and windows, encode features (see lesson 28), attach labels. This is where most bugs and most skew are born.
- Load — write to the destination: the offline feature store / warehouse tables for training, and the online KV store for serving. ELT flips the order — load raw, transform in-warehouse — which is the modern lakehouse pattern when storage is cheap and compute is elastic.
Warehouse, lakehouse, and the storage layout that decides your scan cost
Where the data lands matters as much as how it is computed. A data warehouse (BigQuery, Snowflake) stores cleaned, schema-on-write tables; a data lake stores raw files cheaply (schema-on-read); a lakehouse (Delta/Iceberg/Hudi) puts warehouse-grade transactions, schema evolution, and time-travel on top of lake-cheap object storage — the dominant recsys pattern because it serves both the training backfill and the analyst's query from one copy.
Two layout choices dominate query cost:
- Columnar formats (Parquet / ORC). Behavioral tables are wide but queries touch few columns. Columnar storage keeps each column contiguous, so reading
watch_timeandlabelfrom a 200-column table touches ~1% of the bytes. It also compresses far better (a column is homogeneous — run-length / dictionary encoding on low-cardinality fields likecountry), so less I/O and less network on the shuffle. - Partitioning (Hive-style, usually by date). Physically lay the table out as
/dt=2026-05-30/…directories. A query for "last 7 days" reads 7 directories and the engine prunes the rest without opening a single file — the scan cost drops from "all history" to "the days you asked for." Date is the canonical partition key in recsys because almost every training query and every aggregate is time-bounded. Over-partitioning (e.g. by user_id) creates millions of tiny files and destroys throughput — the small-files problem — so partition coarse, sort fine.
The offline feature store and point-in-time-correct joins
The offline feature store is the warehouse side of the contract whose online side is lesson 17's KV store. Its hardest job is building a training set without leaking the future. To train on an impression at time t, every feature value you attach must be the value as it was known at t — not the latest value. A naive join of the impression table to a "current user stats" table is a look-ahead leak: you train on a 7-day CTR that includes clicks that happened after the impression, get a gorgeous offline AUC, and watch it collapse in production because that future never exists at serving time.
The fix is a point-in-time (as-of) join: for each label row at t, join to the most recent feature snapshot with timestamp ≤ t. This is exactly the leakage discipline of lesson 28, enforced at the pipeline level: features carry event-time, and the training-set builder asks "what did we know then?", never "what do we know now?"
user_ctr_7d with a window [t−7d, t] but accidentally let it run to [t−7d, t+7d], so for an impression that the user did click, the feature already "knows" about that click and ~7 days of correlated future clicks. The model leans on this near-oracle feature and posts offline AUC 0.82. In production the window can only be [t−7d, t] — the future doesn't exist yet — so the feature is far weaker and online AUC falls to ~0.76, a 6-point collapse with no error anywhere in the stack. The tell is the direction: leakage always flatters the offline number, which is why a suspiciously large AUC jump after a feature change should trigger a point-in-time audit before a celebration.
Data quality — proving the data is still good
The processing stack delivers data fast; data-quality monitoring is what stops it from delivering bad data fast. The mental model: every feature pipeline is a contract, and you assert the contract on every run. Garbage feature in → silent model rot. The four classes of assertion:
| Check | Asserts | Catches | Example |
|---|---|---|---|
| Schema / contract | columns, types, nullability unchanged | upstream SDK change, renamed field, type coercion | user_id stays STRING, not silently INT |
| Null / range | null-rate & value bounds within tolerance | a join that started returning NULL; negative durations | watch_time ∈ [0, 86400], null-rate < 1% |
| Distribution | shape close to a reference window | silent drift, a logging change, bot floods | PSI(today, last-week) < 0.2 |
| Freshness / volume | partition arrived on time, row-count in band | late/missing partition, dropped upstream | today's row count within ±30% of 7-day median |
PSI — quantifying distribution drift
The standard recsys drift metric is the Population Stability Index. Bin a feature into B buckets; compare today's proportion a_i in each bucket to a reference (training, or last week) proportion e_i:
PSI = Σi=1..B (ai − ei) · ln(ai / ei)
It is a symmetrized KL-style divergence. Rule of thumb: PSI < 0.1 stable, 0.1–0.25 moderate drift (investigate), > 0.25 significant drift (the model's training distribution no longer matches reality — retrain or alert). Compute PSI per important feature on every batch and you catch the "logging SDK changed and a feature's distribution shifted" failure before AUC ever moves. This is the offline twin of the freshness SLA: freshness watches when data arrives, PSI watches what it looks like when it does.
Kafka consumer-lag / backlog — the streaming-side smell
On the streaming plane (defer to lesson 17 for internals), the headline data-quality signal is consumer lag: the gap between the latest offset written to a Kafka topic and the offset your stream processor has consumed. Rising lag means the processor cannot keep up — features are getting staler by the second even though nothing has crashed. Root-cause it in order: a traffic spike (transient), a slow downstream sink (the store can't absorb writes), a skewed partition (one hot key — same straggler problem, now in streaming), or an under-provisioned job. The protective mechanism is backpressure: the processor signals upstream to slow down rather than OOM, and the durable log buffers the backlog so you degrade instead of dropping events. Alert on lag, not just on crashes — a job that is alive but 20 minutes behind is serving stale features silently.
EDA and cleaning as a skill
Before any of this is automated, someone has to look at the data. Exploratory data analysis is not a junior chore — it is where you find the bot traffic, the broken column, and the long tail that will wreck a naive average. The recurring recsys diagnoses:
- Long-tail / heavy-tail. Watch-time, follower counts, item popularity are all power-law. A linear-axis histogram looks like a single spike at zero with nothing else visible. Always plot counts on a log scale (or log-log) — the tail becomes a readable line, and you can decide whether to log-transform the feature (lesson 28) so the model isn't dominated by a few whales.
- Missing values — strategy depends on why it is missing (table below). The senior point: missingness is often itself a signal (a user who never set a profile field behaves differently), so add an explicit
is_missingindicator rather than silently imputing it away. - Outliers — a 14-hour "watch time" is a bug or a bot, not a superfan. Detect with IQR (outside Q1−1.5·IQR … Q3+1.5·IQR), z-score (|z| > 3, but only valid if roughly normal — useless on heavy tails), or isolation forest for multivariate anomalies. Then decide: cap (winsorize), drop, or keep-and-flag — capping is usually safest for skewed features.
- Bot / fraud signals — superhuman click rates, impossibly regular inter-event timing, one device hammering thousands of accounts, click-without-dwell. These poison both labels and aggregates; filter them in the cleaning stage or they teach the model to recommend whatever the bots inflate.
| Missing-value strategy | When | Cost / caveat |
|---|---|---|
| Drop rows | missing-at-random & rare (<1%) | throws data away; biases if missingness is informative |
| Median / mode fill | numeric / categorical, MAR, need a quick robust default | shrinks variance; hides the fact it was missing |
| Model-based (kNN / regression) | missingness correlates with other features | expensive; risk of leakage if it peeks at the label |
| Sentinel + indicator | missingness itself is signal (the recsys default) | none real — best practice: impute AND add is_missing flag |
Class imbalance — the CTR ≈ 1% problem
CTR labels are extreme: roughly one positive per hundred impressions. A model can score 99% "accuracy" by predicting "no click" always — and be useless. The imbalance ratio is the headline:
imbalance ρ = Nneg / Npos ≈ 99 : 1 (CTR ≈ 1%)
Three families of remedy, from blunt to preferred:
| Remedy | How | Verdict for recsys |
|---|---|---|
| Undersample negatives | keep all positives, sample a fraction of negatives | cheap, standard for billion-row CTR data — but you must recalibrate the predicted probabilities (lesson 05), since you changed the base rate |
| Oversample positives | duplicate / bootstrap positives | wastes compute, risks overfitting exact rows; rarely worth it at scale |
| SMOTE | synthesize positives by interpolating between neighbors in feature space | dubious for recsys — see warning; interpolating sparse high-cardinality ID embeddings produces meaningless "users" |
| Class weights | weight the positive's loss by ≈ρ | clean, no data churn; equivalent in expectation to resampling |
| Loss-level (focal / weighted BCE) | down-weight easy negatives in the loss itself | usually preferred in recsys — see lesson 05 |
Interactive · data-skew & salting simulator
Distribute a fixed number of keys across N reducers under a tunable Zipf skew. Without salting, hot keys pile onto one reducer and job time = the slowest reducer's load. Toggle salting to split hot keys across reducers and watch the max flatten. The speedup is the ratio of the two job times — all computed live.
Interview prompts you should be ready for
- "Walk me through MapReduce and tell me which part is expensive and why." (map → shuffle → reduce. Map and reduce are parallel and local; the shuffle is the only all-to-all step — it serializes intermediates to disk, sorts, and ships every record over the network so all values for a key co-locate. Minimizing shuffle volume — combiners, broadcast joins, columnar pruning — is the top batch optimization.)
- "Why did Spark replace Hadoop MapReduce for ML?" (Hadoop materializes every stage to HDFS and re-reads disk each pass; ML is iterative, so that is 10–100× too slow. Spark keeps the working set in memory via RDD/DataFrame, a lazy DAG the optimizer can fuse, and lineage for recovery without replication. Cache the training matrix once, every gradient pass reads RAM.)
- "A batch job's runtime suddenly doubled with no extra data. What's your first hypothesis?" (Data skew / a newly-hot key creating a straggler — job time = slowest task, which the averages hide. Look at the max/p99 per-task duration, not the mean. Fix with salting, partition tuning, or isolating hot keys; broadcast-join the small side so the skewed side isn't shuffled.)
- "How would you size
spark.sql.shuffle.partitions, and why is the default 200 usually wrong?" (Target ~128 MB of shuffled data per partition: N = shuffled_bytes / 128 MB. Size from the POST-shuffle bytes — after columnar projection and filtering — not the raw table. A 2 TB table that prunes to ~256 GB shuffled wants ~2000 partitions; the default 200 forces ~1.3 GB each, which spills to disk and crawls. Recompute the divisor when shuffled volume changes > ~2×.) - "Name the concrete Spark levers that took a 2 TB join from 4 h to 35 min, and which failure mode each fixes." (Broadcast-join the small dimension table → zero shuffle of the skewed big side; spark.sql.shuffle.partitions=2000 → right-sized shuffle so nothing spills; persist(MEMORY_AND_DISK) → no recompute across the iterative feature derivation; repartition/coalesce on write → fixes the small-files problem. Bloom pre-filter drops non-matching rows before the shuffle. The point: every lever attacks shuffle volume, skew, or small files — no algorithm change.)
- "Lambda vs Kappa — and where does train–serve skew come from?" (Lambda runs batch + speed layers in parallel, so the same feature is implemented twice and the two definitions drift — that gap IS train–serve skew. Kappa is one streaming codepath where 'batch' is just replaying the log from offset 0: one definition, no skew by construction. Principle: one feature, one definition.)
- "How do you build a training set without future leakage?" (Point-in-time / as-of join: for each label at time t, attach the most recent feature snapshot with timestamp ≤ t — what we knew then, never what we know now. A naive join to current stats leaks future clicks, inflates offline AUC, and collapses in production. Suspect leakage when offline metrics jump suspiciously.)
- "How do you catch a silently broken feature before it rots the model?" (Assert the contract every run: schema/type, null-rate & range, distribution drift via PSI (>0.25 = significant), and freshness/volume bands. On the stream side, alert on Kafka consumer lag — a job that's alive but 20 min behind serves stale features with no crash. Nothing throwing an exception is the dangerous case.)
- "CTR is ~1% positive. How do you handle the imbalance, and why not SMOTE?" (Negative undersampling with recalibration, class weights, or the preferred loss-level fix — focal / weighted BCE (lesson 05). SMOTE assumes a dense continuous space where interpolation is valid; recsys features are sparse, high-cardinality, one-hot/embedding, so synthesized 'between' points are nonsense. Respect the data structure.)