search_ads_recsys / 29 · data engineering lesson 29 / 39

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 one-line summary you should be able to give
Data engineering for recsys is the discipline of moving large volumes of behavioral data correctly and on time — batch for cheap throughput, stream for freshness — and then proving the data is still good before the model trusts it. Two pillars: the big-data processing stack, and data-quality monitoring. Skip the second and the first just delivers bad data faster.

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.

COUNT IMPRESSIONS PER ITEM — and where the cost actually is ────────────────────────────────────────────────────────────────── INPUT SPLITS MAP (parallel, cheap, local) ┌───────────┐ (itemA,1) (itemB,1) (itemA,1) │ log shard │ ───► emit (item_id, 1) for each impression └───────────┘ ┌───────────┐ (itemC,1) (itemA,1) │ log shard │ ───► emit (item_id, 1) └───────────┘ │ ══════════ SHUFFLE ═══╪═══ ◄── the expensive part: sort by key + move │ disk write + network + sort, every pair over net │ all-to-all between map & reduce ▼ REDUCE (one call per key) itemA → [1,1,1,1] → 4 ◄── a HOT key (viral item) sends a huge itemB → [1] → 1 value list to ONE reducer = straggler itemC → [1] → 1 ──────────────────────────────────────────────────────────────────

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 MapReduceSparkFlink (lesson 17)
Modelmap → shuffle → reduce, materialized to disk each stageRDD / DataFrame DAG, lazy, in-memory working setstateful streaming operators, event-time
Latencyminutes–hours (T+1)seconds–hours (batch / micro-batch)milliseconds–seconds (true streaming)
Throughputvery high, cheapest per TBvery high, in-memory boundhigh, latency-optimized
Iterative MLre-reads disk every pass — slowcaches in RAM — 10–100× fasterincremental updates, online learning
Fault recoveryre-run failed task from HDFSlineage — recompute lost partition from its DAGcheckpoint + replay from log offset
Reach for it whenone-shot huge ETL, cost-sensitive, freshness irrelevanttraining-set builds, feature backfills, iterative jobsper-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.

Batch or stream? It is a freshness-vs-cost allocation, not a religion
Same placement rule as lesson 17: push work left to batch as far as the freshness SLA allows — batch is cheaper per TB, easier to backfill, and trivially reproducible. Pull it right to streaming only when the half-life demands it. A 90-day item CTR is a Spark batch job; "likes in the last 60 seconds" is a Flink job. Most pipelines are both, and that duality is exactly where the next trap lives.

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 straggler is invisible in the averages
Dashboards that show mean CPU or mean task time will look healthy while one reducer runs 50× longer than the rest and holds the whole job hostage. The skew only shows up in the max (the p99 of per-task duration). If a batch job's runtime suddenly doubles with no data-volume change, suspect a newly-hot key before you suspect the cluster — and look at the slowest task, never the average.

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.

LeverWhat it doesFailure mode it fixes
spark.sql.shuffle.partitionscount of post-shuffle partitions (default 200) — size it to the shuffled bytes, not the cluster core counttoo 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_DISKcache the reused DataFrame; _SER stores a compact serialized blob in RAM, _AND_DISK spills the overflow instead of recomputingre-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 sideshuffling 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 IDsa probabilistic membership test that drops rows whose key can't match before they ever enter the shuffleshuffling 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 uneventhe 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.
Worked numbers — sizing spark.sql.shuffle.partitions

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

LAMBDA — two layers, two codepaths, ONE feature definition duplicated ────────────────────────────────────────────────────────────────────── ┌──────────────────────────┐ raw events ─► │ BATCH layer (Spark) │ accurate, hours stale │ recompute feature in SQL │──┐ └──────────────────────────┘ │ ┌──────────────┐ ┌──────────────────────────┐ ├──►│ serving view │─► model raw events ─► │ SPEED layer (Flink) │ │ │ (merge both) │ │ recompute feature in Java│──┘ └──────────────┘ └──────────────────────────┘ fresh, approximate ▲ SAME feature, TWO implementations ──► they drift ──► SKEW ────────────────────────────────────────────────────────────────────── KAPPA — one streaming codepath; "batch" is just replaying the log ────────────────────────────────────────────────────────────────────── raw events ─► [ append-only log (Kafka) ] ─► ONE stream job ─► serving replay from offset 0 = backfill; replay tail = live one definition, no skew by construction ──────────────────────────────────────────────────────────────────────

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:

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:

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

Worked numbers — the size of the leak
Concretely: you compute 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.
Look-ahead leakage is the prettiest bug you will ever ship
Leakage makes offline metrics better, so every incentive pushes you to trust it. A model that "predicts" clicks using features computed from those same clicks looks superhuman offline and is worthless online. If an offline AUC jumps suspiciously after a feature change, your first hypothesis should be a point-in-time violation, not a breakthrough.

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:

CheckAssertsCatchesExample
Schema / contractcolumns, types, nullability unchangedupstream SDK change, renamed field, type coercionuser_id stays STRING, not silently INT
Null / rangenull-rate & value bounds within tolerancea join that started returning NULL; negative durationswatch_time ∈ [0, 86400], null-rate < 1%
Distributionshape close to a reference windowsilent drift, a logging change, bot floodsPSI(today, last-week) < 0.2
Freshness / volumepartition arrived on time, row-count in bandlate/missing partition, dropped upstreamtoday'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:

Missing-value strategyWhenCost / caveat
Drop rowsmissing-at-random & rare (<1%)throws data away; biases if missingness is informative
Median / mode fillnumeric / categorical, MAR, need a quick robust defaultshrinks variance; hides the fact it was missing
Model-based (kNN / regression)missingness correlates with other featuresexpensive; risk of leakage if it peeks at the label
Sentinel + indicatormissingness 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:

RemedyHowVerdict for recsys
Undersample negativeskeep all positives, sample a fraction of negativescheap, standard for billion-row CTR data — but you must recalibrate the predicted probabilities (lesson 05), since you changed the base rate
Oversample positivesduplicate / bootstrap positiveswastes compute, risks overfitting exact rows; rarely worth it at scale
SMOTEsynthesize positives by interpolating between neighbors in feature spacedubious for recsys — see warning; interpolating sparse high-cardinality ID embeddings produces meaningless "users"
Class weightsweight 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 itselfusually preferred in recsys — see lesson 05
SMOTE on recsys features is usually a trap
SMOTE synthesizes minority examples by interpolating between a point and its neighbors — it assumes a dense, continuous, low-dimensional feature space where the midpoint of two valid points is itself valid. Recsys features are the opposite: sparse, extremely high-cardinality (millions of item/user IDs), one-hot or embedding-based. The interpolation of two one-hot users is a fractional nonsense user; interpolating IDs is meaningless. Prefer the loss-level answer (focal loss / weighted BCE, lesson 05) or negative undersampling with recalibration — they respect the structure of the data instead of inventing fake rows in a space where "between" has no meaning.

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.

Stragglers vs salting, live
Job wall-clock = the slowest reducer (the max bar), never the average. Crank the Zipf exponent to make one key dominate, then flip salting on to spread it.
max reducer load
mean load
job time (∝ max)
speedup vs no-salt
Reading
Raise the skew to create a straggler, then toggle salting to flatten it.

Interview prompts you should be ready for

  1. "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.)
  2. "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.)
  3. "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.)
  4. "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×.)
  5. "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.)
  6. "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.)
  7. "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.)
  8. "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.)
  9. "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.)
Takeaway
A recommender is only as good as its data plumbing, and the plumbing fails silently — no exception, just stale, skewed, or dirty features and a quietly rotting model. Two pillars. Processing: MapReduce's shuffle is the expensive all-to-all step; Spark wins iterative ML by keeping the working set in memory (RDD/DAG/lineage); batch for cheap throughput, stream for freshness, placed by the same left-as-far-as-SLA-allows rule; salt the hot keys so one straggler doesn't own the wall-clock. Quality: prefer Kappa's one feature definition to kill train–serve skew; partition by date and store columnar to prune scans; build training sets with point-in-time joins to avoid look-ahead leakage; and assert the contract every run — schema, nulls, ranges, PSI drift, freshness, consumer lag. For the CTR≈1% imbalance, reach for focal/weighted BCE or undersample-and-recalibrate before you ever reach for SMOTE. Deliver bad data faster and you have only automated the failure.