What You'll Build
Lesson 33 is Phase 2 of the Capstone: designing and training the model on the verified data pipeline from Lesson 32. You will write a documented architecture justification (why this architecture for this task), implement the training loop with the full callback suite, track training and validation metrics across epochs, and conduct the first systematic evaluation with per-class metrics and training curves. The model does not need to be perfect after Lesson 33 — the purpose of this phase is to establish a working baseline with clean training behaviour. Lesson 34 is dedicated to optimisation. The key deliverable is a training run that converges without NaN loss, shows a decreasing validation loss trend, and produces a confusion matrix that reveals specific weaknesses to address in the next phase.
Prerequisites
- Completed Lesson 32 pipeline: train_ds_A/train_ds_B, val_ds_A/val_ds_B with verified shapes and class distributions
- EfficientNetB0 two-phase training: frozen base (Phase 1) → unfreeze top 20 layers (Phase 2) for Option A
- DistilBERT fine-tuning recipe: lr=2e-5, warmup_ratio=0.1, weight_decay=0.01 for Option B
- EarlyStopping + ModelCheckpoint callbacks for both options
- per-class metrics: classification_report, confusion_matrix from sklearn
Setup & Project Structure
This lesson uses the data pipelines from Lesson 32. Start by loading the verified datasets and confirming the sanity checks still pass. Then build the model, write the architecture justification, and run the training loop with full monitoring. The architecture justification section is mandatory for the Capstone report — it demonstrates that architecture decisions were made deliberately rather than by copying a template.
# Assume all imports and data from Lesson 32 are available
# Reload if running in a new session:
import numpy as np, tensorflow as tf, torch
from tensorflow import keras
from tensorflow.keras import layers, callbacks
from transformers import (
AutoModelForSequenceClassification, TrainingArguments,
Trainer, EarlyStoppingCallback
)
from sklearn.metrics import classification_report, confusion_matrix
import matplotlib.pyplot as plt, matplotlib; matplotlib.use('Agg')
np.random.seed(42); tf.random.set_seed(42); torch.manual_seed(42)
print('Architecture Justification Template (complete this in your report):')
print()
print('OPTION A JUSTIFICATION:')
print(' Architecture: EfficientNetB0 pretrained on ImageNet')
print(' Reason: [YOUR TEXT] e.g., "EfficientNetB0 provides the best accuracy/')
print(' parameter ratio at 5.3M params. ImageNet pretraining gives')
print(' edge/texture features that transfer to cricket shot images."')
print(' Alternative considered: ResNet50 — rejected because 25M params risks')
print(' overfitting on our 840-image training set.')
print()
print('OPTION B JUSTIFICATION:')
print(' Architecture: DistilBERT-base-uncased fine-tuned')
print(' Reason: [YOUR TEXT] e.g., "DistilBERT has 66M params vs BERT-base 110M,')
print(' achieving 97% of BERT performance at 60% size. Cricket')
print(' commentary is short (<50 words), so the 512-token context')
print(' limit is never a constraint. Bidirectional attention captures')
print(' shot context better than GPT-2 causal attention."')
print(' Alternative considered: Training from scratch — rejected because we')
print(' have only 530 training texts, insufficient for learning')
print(' language representations from random initialisation.')Step 1 — Foundation
Step 1 builds and compiles the model with the justified architecture. For Option A, this is the two-phase EfficientNetB0 setup with frozen base and trainable head. For Option B, this is DistilBERT loaded with the classification head. Both models are verified for parameter count, trainable vs frozen parameter split, and output shape.
import tensorflow as tf, torch, numpy as np
from tensorflow import keras
from tensorflow.keras import layers
from transformers import AutoModelForSequenceClassification
# ══════════════════════════════════════════════════════════
# OPTION A: Build EfficientNetB0 model
# ══════════════════════════════════════════════════════════
N_CLASSES_A = 6
SHOT_CLASSES = ['cover_drive','pull_shot','sweep','flick','straight_drive','helicopter']
def build_phase1_A():
"""Phase 1: frozen base + trainable head."""
base = keras.applications.EfficientNetB0(
include_top=False, weights=None, # use 'imagenet' with real data
input_shape=(224,224,3), pooling='avg')
base.trainable = False
inp = keras.Input((224,224,3))
x = layers.RandomFlip('horizontal')(inp) # in-graph augmentation
x = layers.RandomRotation(0.1)(x)
x = layers.RandomZoom(0.1)(x)
x = layers.RandomBrightness(0.2)(x)
x = keras.applications.efficientnet.preprocess_input(x)
x = base(x, training=False)
x = layers.Dense(256, activation='relu')(x)
x = layers.Dropout(0.4)(x)
x = layers.Dense(N_CLASSES_A, activation='softmax', dtype='float32')(x)
m = keras.Model(inp, x)
m.compile(
optimizer=keras.optimizers.Adam(1e-3),
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
return m, base
model_A, base_A = build_phase1_A()
train_params_A = sum(v.numpy().size for v in model_A.trainable_variables)
total_params_A = model_A.count_params()
print(f'[A] Phase 1 — Trainable: {train_params_A:,} / {total_params_A:,} ({train_params_A/total_params_A:.1%})')
# ══════════════════════════════════════════════════════════
# OPTION B: Load DistilBERT
# ══════════════════════════════════════════════════════════
N_CLASSES_B = 7
EVENT_CLASSES = ['batting_milestone','bowling_wicket','fielding_dismissal',
'six_scored','four_scored','dot_ball','match_admin']
model_B = AutoModelForSequenceClassification.from_pretrained(
'distilbert-base-uncased',
num_labels=N_CLASSES_B,
id2label={i:c for i,c in enumerate(EVENT_CLASSES)},
label2id={c:i for i,c in enumerate(EVENT_CLASSES)}
)
total_B = sum(p.numel() for p in model_B.parameters())
train_B = sum(p.numel() for p in model_B.parameters() if p.requires_grad)
print(f'[B] DistilBERT — Trainable: {train_B:,} / {total_B:,} ({train_B/total_B:.1%})')Step 2 — Core Logic
Step 2 runs the full training loop for both phases (Option A) or the complete fine-tuning run (Option B) with the production-grade callback suite. After training, the validation metrics are inspected and the preliminary confusion matrix is generated. This confusion matrix guides the optimisation strategy in Lesson 34.
import numpy as np, tensorflow as tf, torch
from tensorflow import keras
from tensorflow.keras import callbacks
from transformers import TrainingArguments, Trainer, EarlyStoppingCallback
from sklearn.metrics import classification_report, confusion_matrix
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# OPTION A: Two-phase training
# ══════════════════════════════════════════════════════════
cb_A = [
callbacks.EarlyStopping('val_loss', patience=7, restore_best_weights=True, verbose=1),
callbacks.ModelCheckpoint('/tmp/capstone_A_best.keras', save_best_only=True, verbose=0),
callbacks.ReduceLROnPlateau('val_loss', factor=0.5, patience=4, min_lr=1e-6, verbose=1)
]
# Phase 1: frozen base
print('[A] Phase 1 Training (frozen base)...')
hist1_A = model_A.fit(train_ds_A, epochs=20, validation_data=val_ds_A, callbacks=cb_A, verbose=1)
p1_val_acc = max(hist1_A.history['val_accuracy'])
print(f'Phase 1 best val accuracy: {p1_val_acc:.2%}')
# Phase 2: unfreeze top 20 layers
base_A.trainable = True
for layer in base_A.layers[:-20]:
layer.trainable = False
model_A.compile(
optimizer=keras.optimizers.Adam(1e-5),
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
print('\n[A] Phase 2 Training (top-20 layers fine-tuned)...')
hist2_A = model_A.fit(train_ds_A, epochs=15, validation_data=val_ds_A, callbacks=cb_A, verbose=1)
p2_val_acc = max(hist2_A.history['val_accuracy'])
print(f'Phase 2 best val accuracy: {p2_val_acc:.2%}')
# ══════════════════════════════════════════════════════════
# OPTION B: DistilBERT fine-tuning
# ══════════════════════════════════════════════════════════
def compute_metrics_B(eval_pred):
from sklearn.metrics import f1_score
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
acc = (preds == labels).mean()
f1_w = f1_score(labels, preds, average='weighted')
return {'accuracy': acc, 'weighted_f1': f1_w}
args_B = TrainingArguments(
output_dir='/tmp/capstone_B',
num_train_epochs=5,
per_device_train_batch_size=16,
per_device_eval_batch_size=32,
learning_rate=2e-5,
warmup_ratio=0.1,
weight_decay=0.01,
evaluation_strategy='epoch',
save_strategy='epoch',
load_best_model_at_end=True,
metric_for_best_model='weighted_f1',
fp16=torch.cuda.is_available(),
logging_steps=20,
report_to='none'
)
trainer_B = Trainer(
model=model_B, args=args_B,
train_dataset=train_ds_B, eval_dataset=val_ds_B,
compute_metrics=compute_metrics_B,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)]
)
print('[B] DistilBERT fine-tuning...')
trainer_B.train()
val_results_B = trainer_B.evaluate()
print(f'Val accuracy: {val_results_B["eval_accuracy"]:.2%}')
print(f'Val weighted F1: {val_results_B["eval_weighted_f1"]:.3f}')Step 3 — Integration & Enhancement
Step 3 generates the training curves, preliminary confusion matrix, and per-class classification report. These three outputs are the core of the Phase 2 evaluation and directly feed the optimisation strategy in Lesson 34. The confusion matrix should be inspected carefully: off-diagonal cells with high counts identify the model's systematic errors, which are almost always more fixable than random errors.
import numpy as np, matplotlib.pyplot as plt
from sklearn.metrics import classification_report, confusion_matrix
import seaborn as sns
# ══════════════════════════════════════════════════════════
# OPTION A: Evaluate and visualise
# ══════════════════════════════════════════════════════════
# Training curves (combined phases)
def plot_combined_A(h1, h2, path='/tmp/capstone_A_curves.png'):
fig,(ax1,ax2)=plt.subplots(1,2,figsize=(14,5))
e1=range(1,len(h1.history['loss'])+1)
e2=range(len(e1)+1,len(e1)+len(h2.history['loss'])+1)
for ax, key, title in [(ax1,'loss','Loss'),(ax2,'accuracy','Accuracy')]:
ax.plot(e1,h1.history[key],'b-',label='Ph1 Train')
ax.plot(e1,h1.history[f'val_{key}'],'b--',label='Ph1 Val')
ax.plot(e2,h2.history[key],'r-',label='Ph2 Train')
ax.plot(e2,h2.history[f'val_{key}'],'r--',label='Ph2 Val')
ax.axvline(len(e1),color='gray',linestyle=':',label='Fine-tune start')
ax.set_title(f'Capstone A — {title}'); ax.legend(); ax.grid(True,alpha=0.3)
plt.tight_layout(); plt.savefig(path,dpi=150,bbox_inches='tight')
print(f'Saved: {path}')
plot_combined_A(hist1_A, hist2_A)
# Predictions on validation set
y_pred_A = model_A.predict(val_ds_A, verbose=0).argmax(axis=1)
y_true_A = np.concatenate([y.numpy() for _, y in val_ds_A])
print('[A] Preliminary Per-Class Report:')
print(classification_report(y_true_A, y_pred_A, target_names=SHOT_CLASSES))
# Confusion matrix
cm_A = confusion_matrix(y_true_A, y_pred_A)
fig,ax=plt.subplots(figsize=(8,7))
sns.heatmap(cm_A,annot=True,fmt='d',cmap='Blues',
xticklabels=SHOT_CLASSES,yticklabels=SHOT_CLASSES,ax=ax)
ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')
ax.set_title('Capstone A — Confusion Matrix (Phase 2 Val)')
plt.xticks(rotation=30,ha='right'); plt.tight_layout()
plt.savefig('/tmp/capstone_A_confusion.png',dpi=150,bbox_inches='tight')
print('Confusion matrix saved.')
# ══════════════════════════════════════════════════════════
# OPTION B: Evaluate and visualise
# ══════════════════════════════════════════════════════════
# Detailed per-class metrics
preds_B, labels_B = [], []
for batch in torch.utils.data.DataLoader(val_ds_B, batch_size=32):
with torch.no_grad():
out = trainer_B.model(
input_ids=batch['input_ids'],
attention_mask=batch['attention_mask']
)
preds_B.extend(out.logits.argmax(dim=-1).cpu().numpy())
labels_B.extend(batch['labels'].cpu().numpy())
print('[B] Preliminary Per-Class Report:')
print(classification_report(labels_B, preds_B, target_names=EVENT_CLASSES))
cm_B = confusion_matrix(labels_B, preds_B)
fig,ax=plt.subplots(figsize=(9,8))
sns.heatmap(cm_B,annot=True,fmt='d',cmap='Blues',
xticklabels=EVENT_CLASSES,yticklabels=EVENT_CLASSES,ax=ax)
ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')
ax.set_title('Capstone B — Confusion Matrix (Val)')
plt.xticks(rotation=30,ha='right'); plt.tight_layout()
plt.savefig('/tmp/capstone_B_confusion.png',dpi=150,bbox_inches='tight')
print('Confusion matrix saved.')Step 4 — Testing & Verification
Step 4 confirms the Phase 2 deliverables: a training run that converged cleanly, a preliminary confusion matrix that identifies the model's weaknesses, and a written optimisation plan for Lesson 34. The optimisation plan is a 3-bullet list identifying the top-3 errors from the confusion matrix and the specific changes planned to address each.
# Phase 2 deliverables checklist
print('=' * 60)
print('CAPSTONE PHASE 2 — DELIVERABLES CHECKLIST')
print('=' * 60)
print('\n[OPTION A]')
print(f' ✓ Phase 1 val accuracy: {p1_val_acc:.2%}')
print(f' ✓ Phase 2 val accuracy: {p2_val_acc:.2%}')
print(f' ✓ Training curves saved: /tmp/capstone_A_curves.png')
print(f' ✓ Confusion matrix saved: /tmp/capstone_A_confusion.png')
print()
print(' Optimisation Plan (complete based on your confusion matrix):')
print(' 1. Most confused pair: [class X] → [class Y]')
print(' Plan: Add more augmentation for class X to improve distinction')
print(' 2. Lowest recall class: [class Z]')
print(' Plan: Check class Z has sufficient training examples (balance)')
print(' 3. Overall val acc below 75%:')
print(' Plan: Unfreeze additional 10 base layers, reduce LR to 5e-6')
print('\n[OPTION B]')
val_f1 = val_results_B.get('eval_weighted_f1', 0)
print(f' ✓ Val accuracy: {val_results_B["eval_accuracy"]:.2%}')
print(f' ✓ Val weighted F1: {val_f1:.3f}')
print(f' ✓ Confusion matrix saved: /tmp/capstone_B_confusion.png')
print()
print(' Optimisation Plan (complete based on your confusion matrix):')
print(' 1. dot_ball recall likely high, rare class recall likely low')
print(' Plan: Add class_weight to Trainer to up-weight rare classes')
print(' 2. batting_milestone vs six_scored confusion (both positive events)')
print(' Plan: Add more diverse training examples for each class')
print(' 3. Weighted F1 below 0.80:')
print(' Plan: Try RoBERTa instead of DistilBERT for higher base accuracy')
print('\nProceed to Lesson 34 — Optimise and Generate Confusion Matrix.')Warning: If training loss goes to NaN in the first epoch for Option A (image), the learning rate is too high for the current model state. The most common cause is starting Phase 2 fine-tuning without compiling the model with the new lower learning rate — if model.compile() is not called after base_A.trainable = True, the optimiser retains the Phase 1 learning rate (1e-3) which is 100× too large for fine-tuning pretrained layers. Always compile after changing trainable status.
Extension Challenge: (1) Implement gradient checkpointing for Option A to reduce GPU memory usage by 40% — enable with model.gradient_checkpointing_enable() to allow training larger batch sizes. (2) For Option B, implement layer-wise learning rate decay (LLRD): assign lr × 0.9^(n_layers - layer_idx) to each DistilBERT layer using parameter groups in the optimiser, applying lower rates to earlier layers that encode more general features.
- Write the architecture justification before building the model — explain why this architecture was chosen over alternatives, citing parameter count, pre-training quality, and dataset size compatibility.
- Phase 1 (frozen base) must converge before Phase 2 (fine-tuning) begins — if Phase 1 never improves, the data pipeline has a bug, not the architecture.
- Always recompile after changing trainable status in Keras — the optimiser must be rebuilt to correctly handle the new set of trainable parameters and their learning rates.
- Use compute_metrics to return both accuracy AND weighted_f1 for Option B — tracking both metrics reveals when a model improves accuracy by over-predicting majority classes at the expense of rare class F1.
- The confusion matrix is the most valuable output of Phase 2 — inspect off-diagonal cells to identify systematic errors and plan targeted remediation in Lesson 34.
- The Phase 2 optimisation plan is a contract with yourself: write 3 specific changes you will make in Lesson 34 based on the confusion matrix evidence — not general improvements, specific targeted fixes.