What You'll Build
Lesson 32 is Phase 1 of the Capstone: building a complete, production-quality data pipeline for your chosen project. You will generate or load your dataset, construct preprocessing and augmentation pipelines, implement correct train/validation/test splits with no data leakage, and build efficient tf.data or HuggingFace Dataset loaders that are ready for model training in Lesson 33. The data pipeline is the most underestimated component of any ML project — it determines the quality ceiling of every downstream experiment. A buggy data pipeline can make a great model appear useless, or make a mediocre model appear excellent. By the end of this lesson, you will have a verified, leakage-free data pipeline with documented statistics (class distribution, split sizes, augmentation visual inspection) that forms the bedrock of your Capstone submission.
Prerequisites
- train_test_split with stratify= for balanced class distribution across splits
- MinMaxScaler or StandardScaler fitted ONLY on training data — transform val/test with the same fitted object
- Augmentation layers (RandomFlip, RandomRotation) inside the model graph for Option A
- HuggingFace Dataset.from_pandas().map(tokenize_fn) for Option B tokenisation
- tf.data pipeline: .shuffle().batch().prefetch(AUTOTUNE) for GPU-efficient loading
Setup & Project Structure
Both pipeline paths (image and text) follow the same five-step structure: generate or load data, inspect class distribution, split with stratification, apply preprocessing (fit on train only), and build loaders with verification checks. The verification step — printing split sizes, class distributions, and sample previews — is not optional. It is the only way to catch data leakage, label encoding errors, or augmentation bugs before they corrupt training in Lesson 33.
# Setup for both options
import numpy as np, pandas as pd, tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from transformers import AutoTokenizer
from datasets import Dataset
import torch
np.random.seed(42); tf.random.set_seed(42); torch.manual_seed(42)
# Project structure:
# capstone/
# ├── data/
# │ ├── raw/ raw images or commentary texts
# │ ├── processed/ normalised arrays or HF datasets
# │ └── splits/ train/val/test indices
# ├── pipeline_A.py image pipeline (Option A)
# ├── pipeline_B.py text pipeline (Option B)
# └── verify_pipeline.py data quality checks
print('Capstone project structure ready')Step 1 — Foundation
Step 1 generates the dataset and verifies class balance. Both options require balanced class distributions — imbalanced classes require additional handling (class weights, oversampling, or weighted F1 metrics) that should be documented in the project report. A class distribution histogram is the first figure in any credible ML project report.
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# OPTION A: Image Dataset Pipeline
# ══════════════════════════════════════════════════════════
SHOT_CLASSES_A = ['cover_drive','pull_shot','sweep','flick','straight_drive','helicopter']
N_PER_CLASS_A = 200 # 1200 total synthetic images
IMG_SIZE_A = 224
def generate_synthetic_images(n_per_class, img_size, n_classes):
"""Synthetic 224×224×3 images with class-specific colour bias."""
X, y = [], []
for cls in range(n_classes):
imgs = np.random.randint(0, 256, (n_per_class, img_size, img_size, 3), dtype=np.uint8)
# Add class-specific colour signature (simulates real visual differences)
imgs[:, :, :, cls % 3] = np.clip(imgs[:, :, :, cls % 3] + 40, 0, 255)
X.append(imgs); y.extend([cls] * n_per_class)
return np.concatenate(X), np.array(y)
X_img, y_img = generate_synthetic_images(N_PER_CLASS_A, IMG_SIZE_A, len(SHOT_CLASSES_A))
print(f'[A] Image dataset: X={X_img.shape}, y={y_img.shape}')
# ══════════════════════════════════════════════════════════
# OPTION B: Text Dataset Pipeline
# ══════════════════════════════════════════════════════════
EVENT_CLASSES_B = ['batting_milestone','bowling_wicket','fielding_dismissal',
'six_scored','four_scored','dot_ball','match_admin']
# Realistic imbalance: dot_ball >> batting_milestone
N_PER_CLASS_B = {'batting_milestone':60,'bowling_wicket':80,'fielding_dismissal':40,
'six_scored':100,'four_scored':120,'dot_ball':300,'match_admin':50}
TEMPLATES = {
'batting_milestone': ['Rohit brings up his century with a six','Kohli reaches fifty in style',
'Dhoni scores his 10th T20I half-century'],
'bowling_wicket': ['Bumrah takes the wicket with a perfect yorker',
'Shami gets the edge and the keeper takes a simple catch'],
'fielding_dismissal':['Kohli takes a stunning catch at point','Direct hit run-out by Hardik'],
'six_scored': ['Rohit smashes a six over long-on','Massive hit clears the boundary'],
'four_scored': ['Elegant cover drive races to the boundary','Punched through covers for four'],
'dot_ball': ['Good length delivery, left alone outside off','Defended solidly back down the pitch'],
'match_admin': ['Toss won — India elect to bat','Drinks break at end of 10th over'],
}
texts_b, labels_b = [], []
for cls, n in N_PER_CLASS_B.items():
cls_idx = EVENT_CLASSES_B.index(cls)
templates = TEMPLATES[cls]
for i in range(n):
texts_b.append(templates[i % len(templates)] + f' [variant {i}]')
labels_b.append(cls_idx)
df_b = pd.DataFrame({'text': texts_b, 'label': labels_b})
df_b = df_b.sample(frac=1, random_state=42).reset_index(drop=True)
print(f'[B] Text dataset: {len(df_b)} samples')
print(f' Class distribution: {df_b["label"].value_counts().sort_index().to_dict()}')
# Class balance check (both options)
for cls_idx, count in pd.Series(y_img).value_counts().sort_index().items():
print(f' [A] {SHOT_CLASSES_A[cls_idx]}: {count} images')Step 2 — Core Logic
Step 2 builds the preprocessing pipeline and data splits. The critical rule: fit any stateful preprocessing (scalers, tokeniser vocabulary) only on the training split, never on the full dataset. For Option A, this means splitting first, then normalising each split with a scaler fitted on train. For Option B, the HuggingFace tokeniser's vocabulary is pre-trained (no fitting needed), but any custom vocabulary adaptation must follow the same rule.
import numpy as np, tensorflow as tf, torch
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler
from transformers import AutoTokenizer
from datasets import Dataset
import pandas as pd
np.random.seed(42); tf.random.set_seed(42)
# ══════════════════════════════════════════════════════════
# OPTION A: Image Split + Normalisation
# ══════════════════════════════════════════════════════════
# Stratified split 70/15/15
X_tv, X_test_A, y_tv, y_test_A = train_test_split(
X_img, y_img, test_size=0.15, stratify=y_img, random_state=42)
X_train_A, X_val_A, y_train_A, y_val_A = train_test_split(
X_tv, y_tv, test_size=0.176, stratify=y_tv, random_state=42)
# Normalise to [0, 255] float32 — EfficientNet's preprocess_input handles /255
# No scaler needed: EfficientNet preprocess_input is stateless (divides by 255)
X_train_A = X_train_A.astype(np.float32)
X_val_A = X_val_A.astype(np.float32)
X_test_A = X_test_A.astype(np.float32)
print('[A] Split sizes:')
for name, X, y in [('Train',X_train_A,y_train_A),('Val',X_val_A,y_val_A),('Test',X_test_A,y_test_A)]:
print(f' {name}: {X.shape}, class dist: {np.bincount(y).tolist()}')
# tf.data pipeline with in-graph augmentation
BATCH_A = 32
def augment_A(image, label):
image = tf.cast(image, tf.float32) / 255.0 # [0,1]
image = tf.image.random_flip_left_right(image)
image = tf.image.random_brightness(image, 0.2)
image = tf.image.random_contrast(image, 0.8, 1.2)
image = tf.clip_by_value(image, 0, 1)
return image, label
train_ds_A = (tf.data.Dataset.from_tensor_slices((X_train_A, y_train_A))
.shuffle(1000).map(augment_A, num_parallel_calls=tf.data.AUTOTUNE)
.batch(BATCH_A).prefetch(tf.data.AUTOTUNE))
val_ds_A = (tf.data.Dataset.from_tensor_slices((X_val_A, y_val_A))
.map(lambda x,y: (tf.cast(x,tf.float32)/255.0, y))
.batch(BATCH_A).prefetch(tf.data.AUTOTUNE))
test_ds_A = (tf.data.Dataset.from_tensor_slices((X_test_A, y_test_A))
.map(lambda x,y: (tf.cast(x,tf.float32)/255.0, y))
.batch(BATCH_A).prefetch(tf.data.AUTOTUNE))
# Verify augmentation is applied at training only
for xb, yb in train_ds_A.take(1):
print(f'[A] Train batch: images {xb.shape}, labels {yb.shape}, range [{xb.numpy().min():.2f},{xb.numpy().max():.2f}]')
# ══════════════════════════════════════════════════════════
# OPTION B: Text Split + HuggingFace Dataset
# ══════════════════════════════════════════════════════════
tr_df_b, tmp_b = train_test_split(df_b, test_size=0.30, stratify=df_b['label'], random_state=42)
val_df_b, test_df_b = train_test_split(tmp_b, test_size=0.50, stratify=tmp_b['label'], random_state=42)
tokenizer_B = AutoTokenizer.from_pretrained('distilbert-base-uncased')
def tokenize_B(examples):
return tokenizer_B(examples['text'], truncation=True, max_length=64, padding='max_length')
def to_ds_B(df):
ds = Dataset.from_pandas(df.rename(columns={'label':'labels'}).reset_index(drop=True))
ds = ds.map(tokenize_B, batched=True).remove_columns(['text'])
ds.set_format('torch')
return ds
train_ds_B = to_ds_B(tr_df_b)
val_ds_B = to_ds_B(val_df_b)
test_ds_B = to_ds_B(test_df_b)
print('[B] Split sizes:')
for name, ds in [('Train',train_ds_B),('Val',val_ds_B),('Test',test_ds_B)]:
print(f' {name}: {len(ds)} samples')Step 3 — Integration & Enhancement
Step 3 verifies the complete pipeline with a sanity-check forward pass and produces the class distribution report. The sanity check confirms: data shapes are correct for the model, pixel ranges are in the expected range (0–1 for EfficientNet), labels are integers in [0, n_classes-1], and the dataloader is producing batches without error. This verification step should produce a clean output with no errors before proceeding to Lesson 33.
import numpy as np, tensorflow as tf, torch
from tensorflow import keras
from tensorflow.keras import layers
from transformers import AutoModelForSequenceClassification
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# OPTION A: Pipeline verification
# ══════════════════════════════════════════════════════════
# Quick EfficientNetB0 forward pass to verify shapes
base_A = keras.applications.EfficientNetB0(
include_top=False, weights=None,
input_shape=(224,224,3), pooling='avg'
)
test_model_A = keras.Sequential([
base_A,
layers.Dense(len(SHOT_CLASSES_A), activation='softmax')
])
for xb, yb in train_ds_A.take(1):
out = test_model_A(xb, training=False)
print(f'[A] Forward pass OK: input {xb.shape} → logits {out.shape}')
print(f' Label range: [{yb.numpy().min()}, {yb.numpy().max()}] (expect 0–{len(SHOT_CLASSES_A)-1})')
print(f' Pixel range: [{xb.numpy().min():.3f}, {xb.numpy().max():.3f}] (expect 0–1)')
assert out.shape == (BATCH_A, len(SHOT_CLASSES_A)), 'Shape mismatch!'
assert yb.numpy().max() < len(SHOT_CLASSES_A), 'Label out of range!'
print('[A] Pipeline verification PASSED\n')
# ══════════════════════════════════════════════════════════
# OPTION B: Pipeline verification
# ══════════════════════════════════════════════════════════
test_model_B = AutoModelForSequenceClassification.from_pretrained(
'distilbert-base-uncased', num_labels=len(EVENT_CLASSES_B))
test_model_B.eval()
batch_b = {k: v[:4] for k, v in next(iter(
torch.utils.data.DataLoader(train_ds_B, batch_size=4))).items()}
with torch.no_grad():
out_b = test_model_B(
input_ids=batch_b['input_ids'],
attention_mask=batch_b['attention_mask']
)
print(f'[B] Forward pass OK: logits {out_b.logits.shape}')
print(f' Label range: [{batch_b["labels"].min()}, {batch_b["labels"].max()}] (expect 0–{len(EVENT_CLASSES_B)-1})')
assert out_b.logits.shape == (4, len(EVENT_CLASSES_B))
print('[B] Pipeline verification PASSED')Step 4 — Testing & Verification
Step 4 produces the data quality report that will be included in the final Capstone submission. This report must include: split sizes and class distributions for all three splits, confirmation that preprocessing was fitted on training data only, sample visualisation (3 augmented images for Option A, 3 tokenised examples with token counts for Option B), and a checklist confirming zero data leakage.
import numpy as np, pandas as pd
print('=' * 60)
print('CAPSTONE PHASE 1 — DATA PIPELINE QUALITY REPORT')
print('=' * 60)
# Option A report
print('\n[OPTION A] Image Classification Pipeline')
print(f' Dataset: {X_img.shape[0]} images, {len(SHOT_CLASSES_A)} classes')
for name, y_split in [('Train',y_train_A),('Val',y_val_A),('Test',y_test_A)]:
dist = np.bincount(y_split, minlength=len(SHOT_CLASSES_A))
print(f' {name}: {len(y_split)} samples | dist: {dist.tolist()}')
print(f' Pixel range post-norm: [0.0, 1.0] ✓')
print(f' Augmentation: RandomFlip + RandomBrightness + RandomContrast ✓')
print(f' Leakage check: Normalisation is stateless (÷255) — no fitting needed ✓')
# Option B report
print('\n[OPTION B] Text Classification Pipeline')
print(f' Dataset: {len(df_b)} texts, {len(EVENT_CLASSES_B)} classes')
for name, df_split in [('Train',tr_df_b),('Val',val_df_b),('Test',test_df_b)]:
dist = df_split['label'].value_counts().sort_index()
print(f' {name}: {len(df_split)} samples | dist: {dist.tolist()}')
print(f' Tokeniser: distilbert-base-uncased (pre-trained vocab, no fitting) ✓')
print(f' Max length: 64 tokens with truncation and padding ✓')
print(f' Leakage check: Pre-trained tokeniser requires no corpus fitting ✓')
print(f' Class imbalance: dot_ball dominates — use weighted F1 and class_weight ✓')
print('\n[CHECKLIST]')
checks = [
'Dataset split is stratified (equal class proportions across splits)',
'Preprocessing fitted only on training data',
'Test split held out and NOT examined until Lesson 35',
'Forward pass sanity check passed for both options',
'Class distribution documented and imbalance strategy noted',
'Augmentation confirmed active only during training (verified in Step 3)',
]
for check in checks:
print(f' ✓ {check}')
print('\nPhase 1 COMPLETE. Proceed to Lesson 33 — Design, Train and Evaluate.')Warning: Do not examine, evaluate, or tune any model on the test split before Lesson 35's final evaluation. Store test data in a separate variable (X_test_A, test_ds_B) and add a comment marking it as 'SEALED — DO NOT USE UNTIL L35'. Any hyperparameter decision informed by test set performance — even informally — constitutes data leakage that inflates the final reported accuracy. All hyperparameter tuning in Lessons 33–34 must use only the validation split.
Extension Challenge: (1) Option A: implement online hard example mining — after each epoch, identify the 10% of training images with highest loss and sample them 3× in the next epoch. This forces the model to focus on the most confusing shot types. (2) Option B: implement curriculum learning — start training on easy examples (high zero-shot confidence) and gradually introduce hard examples (low zero-shot confidence) over epochs, mimicking how human learners progress from simple to complex tasks.
- Always split before fitting any stateful preprocessing — fit scalers, normalisation layers, and vocabulary adaptors on training data only, then transform val and test with the fitted object.
- Verify the pipeline with a sanity-check forward pass before training — catch shape mismatches, label range errors, and pixel range issues before they waste GPU training time.
- Document class distribution for all three splits — imbalanced distributions require class weighting, oversampling, or weighted metrics that must be planned before training begins.
- Seal the test split immediately after creation — no model decisions should be informed by test set performance until the final evaluation in Lesson 35.
- tf.data augmentation with num_parallel_calls=AUTOTUNE processes augmentation on CPU cores in parallel with GPU training — always use AUTOTUNE to prevent GPU starvation from data loading bottlenecks.
- Produce a written data quality report as part of Phase 1 output — this becomes Section 1 of the final Capstone project report submitted in Lesson 35.