Learn AI Series (#118) - Data Engineering for AI
Learn AI Series (#118) - Data Engineering for AI

What will I learn
- You will learn the difference between batch and streaming data pipelines, and (more importantly) when each one actually earns its complexity;
- data quality: validation, schema enforcement, and why "garbage in, garbage out" gets worse at scale, not better;
- data versioning with DVC, and why reproducibility is not optional the moment more than one person touches your data;
- feature stores -- what they are, why they exist, and how Feast wires training and serving together;
- data labeling strategies, including active learning to stretch a tiny labeling budget much further;
- and synthetic data as a practical (but dangerous) tool for bootstrapping a project when real data is scarce.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.11+) distribution -- today is mostly architecture and plumbing, so no GPU needed;
- You've been through episode #14 (data preparation) and episode #117 (ML system design), because today we take the "data pipeline" and "feature engineering" layers we sketched last time and actually crawl inside them.
Difficulty
- Beginner
Curriculum (of the Learn AI Series):
- Learn AI Series (#1) - What Machine Learning Actually Is
- Learn AI Series (#2) - Setting Up Your AI Workbench - Python and NumPy
- Learn AI Series (#3) - Your Data Is Just Numbers - How Machines See the World
- Learn AI Series (#4) - Your First Prediction - No Math, Just Intuition
- Learn AI Series (#5) - Patterns in Data - What "Learning" Actually Looks Like
- Learn AI Series (#6) - From Intuition to Math - Why We Need Formulas
- Learn AI Series (#7) - The Training Loop - See It Work Step by Step
- Learn AI Series (#8) - The Math You Actually Need (Part 1) - Linear Algebra
- Learn AI Series (#9) - The Math You Actually Need (Part 2) - Calculus and Probability
- Learn AI Series (#10) - Your First ML Model - Linear Regression From Scratch
- Learn AI Series (#11) - Making Linear Regression Real
- Learn AI Series (#12) - Classification - Logistic Regression From Scratch
- Learn AI Series (#13) - Evaluation - How to Know If Your Model Actually Works
- Learn AI Series (#14) - Data Preparation - The 80% Nobody Talks About
- Learn AI Series (#15) - Feature Engineering and Selection
- Learn AI Series (#16) - Scikit-Learn - The Standard Library of ML
- Learn AI Series (#17) - Decision Trees - How Machines Make Decisions
- Learn AI Series (#18) - Random Forests - Wisdom of Crowds
- Learn AI Series (#19) - Gradient Boosting - The Kaggle Champion
- Learn AI Series (#20) - Support Vector Machines - Drawing the Perfect Boundary
- Learn AI Series (#21) - Mini Project - Predicting Crypto Market Regimes
- Learn AI Series (#22) - K-Means Clustering - Finding Groups
- Learn AI Series (#23) - Advanced Clustering - Beyond K-Means
- Learn AI Series (#24) - Dimensionality Reduction - PCA
- Learn AI Series (#25) - Advanced Dimensionality Reduction - t-SNE and UMAP
- Learn AI Series (#26) - Anomaly Detection - Finding What Doesn't Belong
- Learn AI Series (#27) - Recommendation Systems - "Users Like You Also Liked..."
- Learn AI Series (#28) - Time Series Fundamentals - When Order Matters
- Learn AI Series (#29) - Time Series Forecasting - Predicting What Comes Next
- Learn AI Series (#30) - Natural Language Processing - Text as Data
- Learn AI Series (#31) - Word Embeddings - Meaning in Numbers
- Learn AI Series (#32) - Bayesian Methods - Thinking in Probabilities
- Learn AI Series (#33) - Ensemble Methods Deep Dive - Stacking and Blending
- Learn AI Series (#34) - ML Engineering - From Notebook to Production
- Learn AI Series (#35) - Data Ethics and Bias in ML
- Learn AI Series (#36) - Mini Project - Complete ML Pipeline
- Learn AI Series (#37) - The Perceptron - Where It All Started
- Learn AI Series (#38) - Neural Networks From Scratch - Forward Pass
- Learn AI Series (#39) - Neural Networks From Scratch - Backpropagation
- Learn AI Series (#40) - Training Neural Networks - Practical Challenges
- Learn AI Series (#41) - Optimization Algorithms - SGD, Momentum, Adam
- Learn AI Series (#42) - PyTorch Fundamentals - Tensors and Autograd
- Learn AI Series (#43) - PyTorch Data and Training
- Learn AI Series (#44) - PyTorch nn.Module - Building Real Networks
- Learn AI Series (#45) - Convolutional Neural Networks - Theory
- Learn AI Series (#46) - CNNs in Practice - Classic to Modern Architectures
- Learn AI Series (#47) - CNN Applications - Detection, Segmentation, Style Transfer
- Learn AI Series (#48) - Recurrent Neural Networks - Sequences
- Learn AI Series (#49) - LSTM and GRU - Solving the Memory Problem
- Learn AI Series (#50) - Sequence-to-Sequence Models
- Learn AI Series (#51) - Attention Mechanisms
- Learn AI Series (#52) - The Transformer Architecture (Part 1)
- Learn AI Series (#53) - The Transformer Architecture (Part 2)
- Learn AI Series (#54) - Vision Transformers
- Learn AI Series (#55) - Generative Adversarial Networks
- Learn AI Series (#56) - Mini Project - Building a Transformer From Scratch
- Learn AI Series (#57) - Language Modeling - Predicting the Next Word
- Learn AI Series (#58) - GPT Architecture - Decoder-Only Transformers
- Learn AI Series (#59) - BERT and Encoder Models
- Learn AI Series (#60) - Training Large Language Models
- Learn AI Series (#61) - Instruction Tuning and Alignment
- Learn AI Series (#62) - Prompt Engineering - Getting the Most from LLMs
- Learn AI Series (#63) - Embeddings and Vector Search
- Learn AI Series (#64) - Retrieval-Augmented Generation (RAG) - Basics
- Learn AI Series (#65) - RAG - Advanced Techniques
- Learn AI Series (#66) - Working with LLM APIs
- Learn AI Series (#67) - Building AI Agents (Part 1) - Foundations
- Learn AI Series (#68) - Building AI Agents (Part 2) - Advanced Patterns
- Learn AI Series (#69) - Fine-Tuning Language Models
- Learn AI Series (#70) - Running Local Models
- Learn AI Series (#71) - Text Generation Techniques
- Learn AI Series (#72) - Tokenization Deep Dive
- Learn AI Series (#73) - LLM Evaluation
- Learn AI Series (#74) - The Hugging Face Ecosystem
- Learn AI Series (#75) - Multimodal Models - Text Meets Vision
- Learn AI Series (#76) - Mini Project - Your Own AI Assistant
- Learn AI Series (#77) - Image Processing Fundamentals
- Learn AI Series (#78) - Object Detection (Part 1) - Foundations
- Learn AI Series (#79) - Object Detection (Part 2) - Modern Approaches
- Learn AI Series (#80) - Image Segmentation
- Learn AI Series (#81) - Pose Estimation and Tracking
- Learn AI Series (#82) - Optical Character Recognition
- Learn AI Series (#83) - Video Understanding
- Learn AI Series (#84) - Generative Images - Diffusion Models (Part 1)
- Learn AI Series (#85) - Generative Images - Diffusion Models (Part 2)
- Learn AI Series (#86) - Image-to-Image and Editing
- Learn AI Series (#87) - 3D Vision
- Learn AI Series (#88) - Face Analysis
- Learn AI Series (#89) - Medical and Scientific Imaging
- Learn AI Series (#90) - Self-Supervised Learning for Vision
- Learn AI Series (#91) - Mini Project - Building a Visual AI System
- Learn AI Series (#92) - Audio Fundamentals for AI
- Learn AI Series (#93) - Speech Recognition
- Learn AI Series (#94) - Text-to-Speech (TTS)
- Learn AI Series (#95) - Audio Classification
- Learn AI Series (#96) - Music Generation
- Learn AI Series (#97) - Speaker Recognition and Diarization
- Learn AI Series (#98) - Natural Language Understanding for Voice
- Learn AI Series (#99) - Audio Enhancement
- Learn AI Series (#100) - Multimodal Audio-Visual Models
- Learn AI Series (#101) - Mini Project: Voice-Controlled AI Assistant
- Learn AI Series (#102) - What Is Reinforcement Learning?
- Learn AI Series (#103) - Multi-Armed Bandits
- Learn AI Series (#104) - Dynamic Programming
- Learn AI Series (#105) - Monte Carlo Methods
- Learn AI Series (#106) - Temporal Difference Learning
- Learn AI Series (#107) - Deep Q-Networks (DQN)
- Learn AI Series (#108) - Policy Gradient Methods
- Learn AI Series (#109) - Advanced Policy Optimization
- Learn AI Series (#110) - Model-Based Reinforcement Learning
- Learn AI Series (#111) - Multi-Agent Reinforcement Learning
- Learn AI Series (#112) - RL for Games
- Learn AI Series (#113) - RL for Real-World Applications
- Learn AI Series (#114) - Inverse Reinforcement Learning
- Learn AI Series (#115) - Offline Reinforcement Learning
- Learn AI Series (#116) - Mini Project: Training a Game-Playing AI
- Learn AI Series (#117) - ML System Design
- Learn AI Series (#118) - Data Engineering for AI (this post)
Learn AI Series (#118) - Data Engineering for AI
Last episode we zoomed all the way out and learned to think in systems instead of models. We drew five layers -- problem, data, features, model, serving-and-monitoring -- and I made a big fuss about how the model is just one box of six. Today we climb down into two of the outer boxes (data pipeline and feature engineering) and get our hands properly dirty. Because those boxes are where real ML projects live or die, quietly, long after the exciting model-training part is over.
Way back in episode #14 we talked about data preparation: cleaning a CSV, filling missing values, encoding categories. That was the single-machine, single-dataset version -- data as a noun, a thing sitting still on your disk. Now we treat data as a verb: the pipes that move it, validate it, version it, and feed it to a model day after boring day, at scale, without silently rotting. If ML is a Ferrari engine, data engineering is the road system. A beautiful engine on a road full of potholes (bad data), missing signs (no schemas) and dead ends (broken pipelines) gets you exactly nowhere ;-)
Solutions to Episode #117 Exercises
As promised, let's clear last episode's three tasks before we start anything new. They all lean on MLProblemFrame, should_use_ml, latency_budget and population_stability_index from episode #117, so I'm assuming those are imported and in scope.
Exercise 1 asked you to frame three scruffy real-world problems, and -- the important bit -- to force a genuine non-ML baseline and a success_metric that is NOT accuracy for each:
# MLProblemFrame and should_use_ml come straight from episode #117.
fraud = MLProblemFrame(
business_goal="Cut fraudulent-transaction losses by 40%",
ml_objective="Binary classification: is this transaction fraud?",
input_data="Amount, merchant, device, geo, velocity features",
output="Fraud probability per transaction",
success_metric="Recall at a fixed 1% review budget (catch the most fraud a human team can actually review)",
baseline="Rule: flag any charge > $500 from a new device in a new country",
latency_requirement="Real-time -- under 100ms, it sits in the checkout path",
update_frequency="Daily -- fraud patterns mutate fast",
)
tickets = MLProblemFrame(
business_goal="Route support tickets to the right team automatically",
ml_objective="Multi-class classification: which of 12 queues?",
input_data="Ticket subject, body, customer plan tier",
output="Queue label plus a confidence score",
success_metric="Macro-F1 (the rare queues matter as much as the busy ones)",
baseline="Keyword routing: 'refund' -> billing, 'password' -> auth",
latency_requirement="Batch -- a few seconds after submission is perfectly fine",
update_frequency="Monthly -- queues change slowly",
)
server_load = MLProblemFrame(
business_goal="Provision capacity before tomorrow's traffic spikes",
ml_objective="Regression: predict peak requests/sec for the next 24h",
input_data="Historical load, day-of-week, holidays, marketing calendar",
output="Predicted peak load with a confidence interval",
success_metric="MAPE, penalised harder for UNDER-prediction (a smoking server costs more than an idle one)",
baseline="Last week's same-weekday peak, plus 10%",
latency_requirement="Batch -- one prediction per day",
update_frequency="Weekly retrain",
)
for name, frame in [("fraud", fraud), ("tickets", tickets), ("load", server_load)]:
verdict = should_use_ml({
"pattern_complexity": 3 if name == "fraud" else 2,
"data_availability": 2 if name == "load" else 3,
"value_of_accuracy": 3 if name == "fraud" else 2,
"maintenance_budget": 2,
})
print(f"{name:8s}: {verdict}")
The key insight I wanted you to feel: not one of those success_metric lines says "accuracy". Fraud cares about recall inside a fixed human-review budget. Ticket routing cares about macro-F1 so the rare queues don't get trampled. Load forecasting deliberately punishes under-prediction harder than over-prediction, because the two mistakes cost wildly different amounts of money. The metric IS the problem statement wearing a disguise -- we hammered that in episode #13, and it never stops being true.
Exercise 2 was about turning a latency budget into something that yells at you when one stage gets greedy:
def latency_budget(total_ms, stages, max_fraction=0.4):
"""Split a latency budget and flag any stage that hogs more than max_fraction."""
assert abs(sum(stages.values()) - 1.0) < 1e-6, "fractions must sum to 1.0"
budget = {}
for name, frac in stages.items():
budget[name] = round(total_ms * frac, 1)
if frac > max_fraction:
print(f"WARNING: '{name}' eats {frac:.0%} of the budget (cap {max_fraction:.0%})")
budget["_headroom_ms"] = round(total_ms - sum(budget.values()), 1)
return budget
# A two-stage recommender under a 100ms budget:
plan = latency_budget(100, {
"candidate_generation": 0.50, # 50ms -- the expensive nearest-neighbour search
"ranking": 0.35, # 35ms -- the cross-attention model
"post_processing": 0.15, # 15ms -- diversity + business rules
}, max_fraction=0.4)
print(plan)
Candidate generation trips the cap immediately at 50%. And the fix is the whole point of the exercise: you do NOT try to make the nearest-neighbour search twice as fast. You push it to batch -- precompute each user's candidate set offline, refresh it hourly, and the online request only pays for ranking. Suddenly your 50ms monster is a trivial cache lookup, and your latency budget breathes again. Optimise the plumbing, not the pump.
Exercise 3 asked you to actually summon training-serving skew and watch a monitor catch it:
import numpy as np
# population_stability_index from episode #117.
rng = np.random.default_rng(0)
raw = rng.normal(100, 15, 5000) # some underlying daily signal
# "training" feature: a smooth 30-day rolling mean
train_feature = np.convolve(raw, np.ones(30) / 30, mode="valid")
# "serving" feature: accidentically a 7-day rolling mean on the SAME data
serve_feature = np.convolve(raw, np.ones(7) / 7, mode="valid")
n = min(len(train_feature), len(serve_feature))
psi = population_stability_index(train_feature[:n], serve_feature[:n])
print("PSI:", round(psi, 3))
The 7-day mean is noisier -- a wider spread -- than the smooth 30-day mean, even though it's computed on identical raw numbers. So the PSI climbs up off the floor and past the 0.1 "something is shifting" line. Nobody changed the model. Nobody changed the data. Somebody just used a different window in the serving code, and the model quietly started seeing a distribution it never trained on. In production you catch this by logging PSI between training-time and live features on a schedule -- the alarm trips before a human ever notices the business metric sagging. Right, solutions done. On to the new stuff.
Batch vs streaming pipelines
There are two fundamentally different ways to move data toward a model, and picking wrong is expensive. The first is batch: process everything in scheduled chunks. Think "every night at 2 AM, chew through yesterday's events."
import json
from pathlib import Path
class BatchPipeline:
"""Process data in scheduled chunks -- the workhorse of ML data engineering."""
def __init__(self, source_dir, output_dir):
self.source_dir = Path(source_dir)
self.output_dir = Path(output_dir)
def run(self, date):
"""The classic extract-transform-load, one day at a time."""
raw_data = self.extract(date)
clean_data = self.transform(raw_data)
self.load(clean_data, date)
def extract(self, date):
"""Read raw events for a specific date."""
file_path = self.source_dir / f"events_{date}.jsonl"
events = []
with open(file_path) as f:
for line in f:
events.append(json.loads(line))
return events
def transform(self, events):
"""Clean, validate, compute features."""
processed = []
for event in events:
if not self.validate(event):
continue
processed.append({
"user_id": event["user_id"],
"item_id": event["item_id"],
"action": event["action"],
"timestamp": event["timestamp"],
"session_duration": event.get("duration", 0),
})
return processed
def validate(self, event):
"""Reject malformed events before they poison the output."""
required = ["user_id", "item_id", "action", "timestamp"]
return all(k in event for k in required)
def load(self, data, date):
"""Write processed data to output (Parquet, BigQuery, S3, ...)."""
output_path = self.output_dir / f"features_{date}.parquet"
print(f"Wrote {len(data)} records to {output_path}")
Batch is simple, debuggable, cheap, and re-runnable (a job died at 3 AM? re-run it, nobody notices). It happily covers the overwhelming majority of real ML features. But sometimes once-a-day is just too slow -- a user's behaviour in the current session, a price from thirty seconds ago -- and for that you need streaming: process each event the instant it lands.
from collections import defaultdict
class StreamProcessor:
"""Process events as they arrive, keeping a little live state per user."""
def __init__(self):
self.user_states = defaultdict(lambda: {
"click_count": 0,
"last_action_time": 0,
"session_items": [],
})
def process_event(self, event):
"""Update state and emit fresh features for one incoming event."""
user_id = event["user_id"]
state = self.user_states[user_id]
gap = event["timestamp"] - state["last_action_time"]
state["click_count"] += 1
state["last_action_time"] = event["timestamp"]
state["session_items"].append(event["item_id"])
return {
"user_id": user_id,
"clicks_this_session": state["click_count"],
"seconds_since_last_action": gap,
"unique_items_viewed": len(set(state["session_items"])),
}
def run(self, event_stream):
"""Consume events from a stream (Kafka, Kinesis, Pulsar, ...)."""
for event in event_stream:
features = self.process_event(event)
self.publish_features(features)
def publish_features(self, features):
"""Push updated features to the online store (Redis, DynamoDB, ...)."""
pass
Here's the practical reality nobody puts on a conference slide: most teams run batch for 90% of their features and reserve streaming for the 10% that genuinely, provably need real-time freshness. Streaming is not free -- you now own message queues, watermarks, out-of-order events, exactly-once headaches, and a pager. Having said that, the correct move is almost always to start batch-only, ship it, and add streaming later for the handful of features that measurably move a metric. Reaching for Kafka on day one because it feels grown-up is how you spend three months building infrastructure for a model that could have run off a nightly cron job.
Data quality: validation and schema enforcement
Here is the single scariest thing about bad data in ML: it does not throw an exception. A broken web request 500s and someone gets paged. A column that silently turns to nulls just... degrades your model, gently, for weeks, while every dashboard stays green. So we put a guard at the door and check every record against an explicit schema before it gets anywhere near the model.
from dataclasses import dataclass
from typing import Any
@dataclass
class SchemaField:
name: str
dtype: type
nullable: bool = False
min_value: Any = None
max_value: Any = None
allowed_values: list = None
class DataValidator:
"""Validate records against a schema at the pipeline's front door."""
def __init__(self, schema):
self.schema = {f.name: f for f in schema}
def validate_record(self, record):
errors = []
for name, field in self.schema.items():
value = record.get(name)
if value is None:
if not field.nullable:
errors.append(f"{name}: required field is null")
continue
if not isinstance(value, field.dtype):
errors.append(f"{name}: expected {field.dtype}, got {type(value)}")
continue
if field.min_value is not None and value < field.min_value:
errors.append(f"{name}: {value} below min {field.min_value}")
if field.max_value is not None and value > field.max_value:
errors.append(f"{name}: {value} above max {field.max_value}")
if field.allowed_values and value not in field.allowed_values:
errors.append(f"{name}: {value} not in {field.allowed_values}")
return len(errors) == 0, errors
def validate_batch(self, records):
valid, invalid = [], []
for record in records:
ok, errors = self.validate_record(record)
(valid if ok else invalid).append(record if ok else (record, errors))
rejection_rate = len(invalid) / max(len(records), 1)
if rejection_rate > 0.05:
print(f"WARNING: {rejection_rate:.1%} rejection rate -- investigate upstream!")
return valid, invalid
schema = [
SchemaField("user_id", str, nullable=False),
SchemaField("item_id", str, nullable=False),
SchemaField("action", str, allowed_values=["click", "view", "purchase"]),
SchemaField("price", float, nullable=True, min_value=0, max_value=100000),
SchemaField("timestamp", float, min_value=1600000000),
]
validator = DataValidator(schema)
Notice that 5% rejection threshold, because it is not a random number -- it's a real-world pattern. Some rejection is always normal (buggy clients, weird edge cases, a phone that lost signal mid-request). What you actually care about is the derivative: a steady 2% that suddenly jumps to 15% overnight. That spike almost always means something upstream changed under you -- a new app version sending a renamed field, a third-party API that quietly altered its date format, a database migration that went sideways. The validator doesn't just clean data, it's an early-warning tripwire for the rest of the pipeline. In the real world you'd reach for a library like Great Expectations or Pandera rather than hand-rolling this, but the principle is identical: make the schema explicit, and fail loud.
Data versioning with DVC
Retrain a model, and it comes out worse. Now answer me one question: did the data change, or did the code change? If you can't answer that instantly, you are debugging blindfolded. Code we already version with git. Data we version with DVC (Data Version Control), which is basically "git for big files that don't belong in git."
# DVC keeps a tiny pointer in git and the heavy data in remote storage.
# Initialise DVC inside your existing git repo
# $ dvc init
# $ dvc remote add -d storage s3://my-bucket/dvc-storage
# Track a dataset -- this creates a small .dvc pointer file
# $ dvc add data/training_set.parquet
# -> data/training_set.parquet.dvc (goes in git)
# -> the actual parquet (goes to remote storage)
# Typical workflow, every time the data changes:
# 1. Regenerate data/training_set.parquet
# 2. $ dvc add data/training_set.parquet
# 3. $ git add data/training_set.parquet.dvc
# 4. $ git commit -m "training data: added Jan 2026 events"
# 5. $ dvc push
# And the payoff -- reproduce ANY historical dataset exactly:
# $ git checkout v1.2.0
# $ dvc checkout
# -> data/ now holds the precise bytes that shipped with v1.2.0
That last pair of commands is the whole reason DVC exists. Six months from now, when a stakeholder asks "why did the model behave differently in March?", you check out March's git commit, run dvc checkout, and you are staring at the exact data that trained that exact model. No guessing, no "I think we had the older customer file loaded." Reproducibility stops being a virtue you aspire to and becomes a command you type. Nota bene: DVC also does pipelines -- you declare your data processing as a DAG and it only reruns the stages whose inputs actually changed. Think make, but for data science.
Feature stores
We name-dropped feature stores last episode as the cure for training-serving skew. Time to make it concrete. The most popular open-source one is Feast, and its whole job is to guarantee that the features you train on and the features you serve on are the same features.
# Feast feature definition (feature_repo/features.py)
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64, String
from datetime import timedelta
# An entity is the "thing" we attach features to
user = Entity(name="user_id", join_keys=["user_id"])
# A feature view groups related features from one source
user_features = FeatureView(
name="user_activity_features",
entities=[user],
ttl=timedelta(days=1), # these features go stale after a day
schema=[
Field(name="total_clicks_7d", dtype=Int64),
Field(name="avg_session_duration", dtype=Float32),
Field(name="favorite_category", dtype=String),
Field(name="days_since_last_visit", dtype=Int64),
],
source=FileSource(
path="data/user_features.parquet",
timestamp_field="feature_timestamp",
),
)
# --- Retrieve features for TRAINING (point-in-time correct, from history) ---
# training_df = store.get_historical_features(
# entity_df=entity_df, # user_ids + the timestamps we care about
# features=["user_activity_features:total_clicks_7d",
# "user_activity_features:avg_session_duration"],
# ).to_df()
# --- Retrieve the SAME features for SERVING (live, low-latency) ---
# online = store.get_online_features(
# features=["user_activity_features:total_clicks_7d"],
# entity_rows=[{"user_id": "user_123"}],
# ).to_dict()
The magic property -- and it really is the whole ballgame -- is that get_historical_features and get_online_features return identical values for the same entity at the same point in time. One definition, two access paths, zero drift. That "point-in-time correct" bit on the training path is subtler than it looks: when you build a training row for a user as they were last Tuesday, Feast makes sure you get the feature values as they existed last Tuesday, not today's values leaking backward in time (which would be a lovely way to accidentically train on the future and then wonder why production is so much worse than your eval). This is the machinery that quietly murders training-serving skew, the silent killer from episode #117.
Data labeling strategies
Your model is only ever as good as its labels, and labels are expensive -- slow, tedious, and surprisingly error-prone once a bored human is on hour six. So the smart move is not "label more"; it's "label the right examples." That's active learning: let the model itself tell you which unlabeled examples would teach it the most.
import numpy as np
class ActiveLearner:
"""Pick the most informative examples to send for labeling next."""
def __init__(self, model, unlabeled_pool):
self.model = model
self.unlabeled_pool = unlabeled_pool
def uncertainty_sampling(self, n_samples):
"""Grab the examples the model is LEAST sure about."""
probs = self.model.predict_proba(self.unlabeled_pool)
# High entropy == the model is genuinely confused == high value to label
entropy = -np.sum(probs * np.log(probs + 1e-10), axis=1)
return np.argsort(entropy)[-n_samples:]
def diversity_sampling(self, n_samples):
"""Grab examples that spread across the input space (avoid redundancy)."""
embeddings = self.model.encode(self.unlabeled_pool)
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=n_samples).fit(embeddings)
indices = []
for center in kmeans.cluster_centers_:
distances = np.linalg.norm(embeddings - center, axis=1)
indices.append(int(np.argmin(distances)))
return indices
The loop is beautifully simple: the model flags the examples it's most confused about, a human labels just those, you retrain, repeat. In practice this cuts labeling costs by somewhere between 30% and 70% compared to labeling random examples, and the effect is strongest exactly where you feel the pain most -- early in a project, when you have a mountain of unlabeled data and a molehill of labeled data. The two strategies above pull in different directions on purpose: uncertainty_sampling chases the model's blind spots, while diversity_sampling makes sure you don't waste your whole budget re-labeling ten near-identical confusing examples. Good active-learning setups blend both.
Synthetic data
When real data is scarce, private, or painfully imbalanced (fraud is the classic -- 0.1% positives), synthetic data can fill the gap. The idea: learn the statistical shape of your real data, then generate new samples that share that shape.
import numpy as np
class SimpleTabularSynthesizer:
"""A toy synthesizer -- learn per-column distributions, then sample from them."""
def __init__(self):
self.column_stats = {}
def fit(self, data):
"""Learn a distribution from each real column."""
for col, values in data.items():
values = np.asarray(values, dtype=float)
self.column_stats[col] = {
"mean": np.mean(values),
"std": np.std(values),
"min": np.min(values),
"max": np.max(values),
}
def generate(self, n_samples):
"""Sample new synthetic rows that mimic the originals."""
synthetic = {}
for col, stats in self.column_stats.items():
samples = np.random.normal(stats["mean"], stats["std"], n_samples)
samples = np.clip(samples, stats["min"], stats["max"])
synthetic[col] = samples.tolist()
return synthetic
Now, I want to be honest with you, because this is where people get burned. The toy above is deliberately naive: it models each column independently, which means it happily destroys the correlations between columns (in real data, "age" and "income" are related; this thing treats them as strangers). The grown-up tools -- CTGAN, TVAE, and friends -- model those joint relationships, handle mixed numeric/categorical types, and can even offer privacy guarantees. But the golden rule is the same at every level of sophistication: use synthetic data to augment and to test, never as a wholesale replacement for reality. A model trained entirely on synthetic data doesn't learn the world -- it learns your synthesizer's blind spots and biases, and then confidently applies them in production. Synthetic data is seasoning, not the meal ;-)
Let's recap
- Batch pipelines run on a schedule and quietly handle ~90% of features; streaming buys you real-time freshness at the price of real operational pain -- start batch, add streaming only where a metric proves you need it;
- data validation against explicit schemas catches bad data at the door -- a steady low rejection rate is healthy, a sudden spike is a scream that something upstream broke;
- DVC versions data alongside code so you can reproduce any historical training run by checking out its commit -- reproducibility becomes a command, not a hope;
- feature stores (Feast) serve one definition of a feature to both training and serving, which is what actually kills training-serving skew;
- active learning stretches a labeling budget 30-70% further by letting the model pick which examples are worth a human's time;
- synthetic data is a fine seasoning for scarce or imbalanced datasets, but a model raised purely on synthetic data learns the synthesizer, not the world.
Zoom back out and the theme of the last two episodes rhymes: the model is one box, and everything around it -- how data arrives, how it's checked, how it's versioned, how features stay consistent -- is the unglamorous machinery that decides whether your system survives contact with reality. In the next stretch of episodes we'll keep working through that outer machinery: how you prove an experiment is reproducible, how you make a trained model fast enough to actually serve, and how you keep watch on a living system so it can't rot in silence. The road system, in stead of just the engine.
Exercises
Three tasks to chew on before next time. As always -- wrestle with them yourself first, and I'll walk through full solutions in the next episode.
Explain the rejection. Extend
DataValidator.validate_batchso that, alongside the valid/invalid split, it tallies which field caused each rejection and prints the top offending field for the batch (e.g. "62% of rejections werepriceout of range"). This is the difference between a monitor that says "something's wrong" and one that says "go look at the price field."A real windowed feature. Give
StreamProcessora genuine time-windowed feature: "clicks in the last 60 seconds," using the event timestamps rather than a running total. Then write one paragraph on what breaks when events arrive out of order (they will), and how you'd defend against it.Measure the synthetic tax. Fit
SimpleTabularSynthesizeron a small real dataset (any toy tabular set from scikit-learn works), generate an equal-sized synthetic set, then train the same simple classifier once on real data and once on synthetic data, evaluating BOTH on a held-out slice of real data. Report the accuracy gap -- that gap is the price you pay for pretending. Bonus: swap in CTGAN and watch the gap shrink.