
(Source)
Deep Learning Models: A collection of various deep learning architectures, models, & their implementations (PyTorch or TensorFlow) in Jupyter Notebooks.
- Author: Chizoba Obasi
- Github Repo: https://github.com/chizkidd/deep-learning-models
%%capture
!pip install watermark
!pip install torchinfo datasets
%load_ext watermark
# %reload_ext watermark
%watermark -a 'Chizoba Obasi' -v -p torch
Author: Chizoba Obasi Python implementation: CPython Python version : 3.12.12 IPython version : 7.34.0 torch: 2.10.0+cu128
Encoder-Decoder (Seq2Seq) with Bahdanau Attention¶
The network in this notebook implements an advanced Sequence-to-Sequence (Seq2Seq) architecture enhanced with Bahdanau Attention [3]. By moving beyond the fixed-length bottleneck of the basic Seq2Seq model, this architecture allows the decoder to "look back" at specific parts of the source sentence during every step of the translation process. This implementation utilizes the Multi30k [5] dataset to demonstrate high-fidelity English-to-German transduction.
(Source, [4])
Network Architecture:
The Attention-based Seq2Seq model evolves the "Message Passing" principle into a "Dynamic Retrieval" system:
- The Encoder: Processes the input sequence but, instead of producing one final vector, it outputs a sequence of Hidden States (one for every word in the source).
- The Attention Mechanism: Acts as a "search engine" that calculates alignment scores between the current decoder state and all encoder hidden states.
- The Decoder: Uses the attention scores to create a Weighted Context Vector, effectively focusing on the most relevant source words to generate the next token.
Attention¶

(Source)
Attention Variants¶

(Source)
Bahdanau Attention¶

(Source)
Key Concepts of the Seq2Seq + Attention Architecture:¶
- Breaking the Bottleneck: Unlike basic Seq2Seq, information is no longer compressed into a single vector. The Decoder has access to the entire history of the Encoder.
- Alignment Scores: The model learns an internal "alignment function" that decides which source words are most important for the current target word (e.g., mapping "pink" to "pinken").
- Dynamic Context: At every time step $t$, a new context vector $c_t$ is calculated. The Decoder's input is a concatenation of the current word embedding and this dynamic context.
- Soft Attention: This implementation uses "Soft" (global) attention, meaning it considers every single word in the source sentence to produce the weighted average.
- Global vs. Local State: The Decoder maintains its own recurrent state while simultaneously "querying" the Encoder's memory, creating a rich, multi-layered representation for generation.
Quick Comparison: Basic Seq2Seq vs. Seq2Seq + Attention¶
| Feature | Basic Seq2Seq (Cho/Sutskever) | Seq2Seq + Attention (Bahdanau) |
|---|---|---|
| Information Bridge | Single Fixed Context Vector | Dynamic Weighted Context Vector |
| Encoder Output | Only the final hidden state | All hidden states (Memory Bank) |
| Long Sequences | Performance degrades rapidly | Robust performance on long sentences |
| Decoder Input | Embedding + Prev Hidden State | Embedding + Context + Prev Hidden State |
| Interpretability | "Black Box" hidden state | Attention Maps (Alignment Visualization) |
| Complexity | Linear $O(N)$ | Quadratic $O(N \times M)$ alignment |
| Primary Metric | Perplexity (PPL) | BLEU Score / BPC |
REFERENCES:
- Sutskever, Ilya, Oriol Vinyals, and Quoc V. Le. "Sequence to sequence learning with neural networks." NeurIPS, 2014. (The "Seq2Seq" Origin)
- Cho, Kyunghyun, et al. "Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation." arXiv preprint arXiv:1406.1078, 2014. (Original GRU + Seq2Seq)
- Bahdanau, Dzmitry, et al. "Neural machine translation by jointly learning to align and translate." arXiv, 2014. (Foundational Additive Attention)
- Zhang, Aston, et al. "Sequence to Sequence Learning." Dive into Deep Learning, Ch. 10.7. (Excellent Textbook Breakdown)
- Elliott, Desmond, et al. "Multi30k: Multilingual Extended Descriptional Images." Proc. 5th Workshop on Vision and Language, 2016. (Core Dataset Source)
- Google. (2026). "Gemini 3 Flash" [Large language model]. Google AI
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from sklearn.model_selection import train_test_split
from torch.utils.data import Sampler, DataLoader
from torch.nn.utils.rnn import pad_sequence
from datasets import load_dataset, DatasetDict
from collections import Counter
import nltk
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix, classification_report
# from torch.cuda.amp import GradScaler, autocast
from torch.amp import GradScaler, autocast
from torchinfo import summary
import seaborn as sns
import matplotlib.image as mpimg
import matplotlib.ticker as ticker
import matplotlib.pyplot as plt
%matplotlib inline
import pandas as pd
import numpy as np
import math
import time
import os
import random
import re
if torch.cuda.is_available():
torch.backends.cudnn.deterministic = True
def set_all_seeds(seed):
os.environ["PL_GLOBAL_SEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
##########################
### SETTINGS (Seq2Seq)
##########################
# Hyperparameters
RANDOM_SEED = 123
BATCH_SIZE = 128
LEARNING_RATE = 0.001
NUM_EPOCHS = 50
# Vocabulary & Architecture
VOCABULARY_SIZE = 10000
ENC_EMB_DIM = 256
DEC_EMB_DIM = 256
HID_DIM = 512
ENC_DROPOUT = 0.5
DEC_DROPOUT = 0.5
DROPOUT_P = 0.5
# Seq2Seq Specifics
TEACHER_FORCING_RATIO = 0.5
# Set device
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")
Device: cuda
# 1. Load Dataset (Translation: English to German)
DATASET_CONFIG = "bentrevett/multi30k"
raw_datasets = load_dataset(DATASET_CONFIG)
ds_splits = DatasetDict({
'train': raw_datasets['train'],
'validation': raw_datasets['validation'],
'test': raw_datasets['test']
})
print(f"\n[ROBUST CONFIG COMPLETE]")
print(f"Dataset: {DATASET_CONFIG} | Task: EN to DE Translation")
# 2. Build Dual Vocabularies (Source and Target)
tokenizer = lambda text: re.findall(r'\w+', text.lower())
en_counts, de_counts = Counter(), Counter()
for item in ds_splits['train']:
en_counts.update(tokenizer(item['en']))
de_counts.update(tokenizer(item['de']))
# Special Tokens are critical for Seq2Seq
SPECIAL_TOKENS = ['<PAD>', '<UNK>', '<SOS>', '<EOS>']
def build_vocab(counts, max_size):
vocab = {word: i for i, word in enumerate(SPECIAL_TOKENS)}
for word, _ in counts.most_common(max_size - len(SPECIAL_TOKENS)):
vocab[word] = len(vocab)
return vocab
src_vocab = build_vocab(en_counts, VOCABULARY_SIZE)
trg_vocab = build_vocab(de_counts, VOCABULARY_SIZE)
SRC_PAD_IDX = src_vocab['<PAD>']
TRG_PAD_IDX = trg_vocab['<PAD>']
print(f"Source (EN) Vocab size: {len(src_vocab)}")
print(f"Target (DE) Vocab size: {len(trg_vocab)}")
# 3. Robust Collate Batch for Seq2Seq
def collate_batch(batch):
src_list, trg_list = [], []
for item in batch:
# Source pipeline: <SOS> + tokens + <EOS>; reversing the source sentence (The Sutskever Trick)
src_toks = [src_vocab['<SOS>']] + [src_vocab.get(t, src_vocab['<UNK>']) for t in tokenizer(item['en'])] + [src_vocab['<EOS>']]
# Target pipeline: <SOS> + tokens + <EOS>
trg_toks = [trg_vocab['<SOS>']] + [trg_vocab.get(t, trg_vocab['<UNK>']) for t in tokenizer(item['de'])] + [trg_vocab['<EOS>']]
src_list.append(torch.tensor(src_toks, dtype=torch.int64))
trg_list.append(torch.tensor(trg_toks, dtype=torch.int64))
# Pad on CPU, Batch First
src_padded = pad_sequence(src_list, batch_first=True, padding_value=SRC_PAD_IDX)
trg_padded = pad_sequence(trg_list, batch_first=True, padding_value=TRG_PAD_IDX)
return src_padded, trg_padded
# 4. Initialize DataLoaders
loader_kwargs = {
'batch_size': BATCH_SIZE,
'collate_fn': collate_batch,
'pin_memory': True,
'num_workers': 2
}
train_loader = DataLoader(ds_splits['train'], shuffle=True, **loader_kwargs)
val_loader = DataLoader(ds_splits['validation'], shuffle=False, **loader_kwargs)
test_loader = DataLoader(ds_splits['test'], shuffle=False, **loader_kwargs)
[ROBUST CONFIG COMPLETE] Dataset: bentrevett/multi30k | Task: EN to DE Translation Source (EN) Vocab size: 9766 Target (DE) Vocab size: 10000
import torch
#----------------------------------------------------------------------------------------------
# --- JIT FUSED LSTM CELL (From Scratch) ---
@torch.jit.script
def fused_lstm_logic(gate_x, gate_h, c_prev):
# LSTM gate calculation: Input, Forget, Cell, Output
gates = gate_x + gate_h
i, f, g, o = gates.chunk(4, 1)
i = torch.sigmoid(i)
f = torch.sigmoid(f)
g = torch.tanh(g)
o = torch.sigmoid(o)
# Cell state update
c_curr = (f * c_prev) + (i * g)
# Hidden state update
h_curr = o * torch.tanh(c_curr)
return h_curr, c_curr
class LSTMCellFromScratch(nn.Module):
def __init__(self, input_size: int, hidden_size: int):
super().__init__()
self.hidden_size = hidden_size
self.x2h = nn.Linear(input_size, 4 * hidden_size)
self.h2h = nn.Linear(hidden_size, 4 * hidden_size)
def forward(self, x: torch.Tensor, states: tuple[torch.Tensor, torch.Tensor]):
h_prev, c_prev = states
gate_x = self.x2h(x)
gate_h = self.h2h(h_prev)
return fused_lstm_logic(gate_x, gate_h, c_prev)
#----------------------------------------------------------------------------------------------
# --- JIT FUSED GRU CELL (From Scratch) ---
@torch.jit.script
def fused_gru_logic(i_r, i_z, i_n, h_r, h_z, h_n, h):
reset_gate = torch.sigmoid(i_r + h_r)
update_gate = torch.sigmoid(i_z + h_z)
new_gate = torch.tanh(i_n + (reset_gate * h_n))
return (1.0 - update_gate) * h + update_gate * new_gate
class GRUCellFromScratch(nn.Module):
def __init__(self, input_size: int, hidden_size: int):
super().__init__()
self.hidden_size = hidden_size
self.x2h = nn.Linear(input_size, 3 * hidden_size)
self.h2h = nn.Linear(hidden_size, 3 * hidden_size)
def forward(self, x: torch.Tensor, h: torch.Tensor) -> torch.Tensor:
gate_x = self.x2h(x)
gate_h = self.h2h(h)
i_r, i_z, i_n = gate_x.chunk(3, 1)
h_r, h_z, h_n = gate_h.chunk(3, 1)
return fused_gru_logic(i_r, i_z, i_n, h_r, h_z, h_n, h)
#----------------------------------------------------------------------------------------------
class Attention(nn.Module):
def __init__(self, enc_hid_dim, dec_hid_dim):
super().__init__()
self.attn = nn.Linear(enc_hid_dim + dec_hid_dim, dec_hid_dim)
self.v = nn.Linear(dec_hid_dim, 1, bias=False)
def forward(self, hidden, encoder_outputs, mask):
# hidden: [batch, dec_hid]
# encoder_outputs: [batch, src_len, enc_hid]
# mask: [batch, src_len]
src_len = encoder_outputs.shape[1]
# Repeat decoder hidden state src_len times
hidden = hidden.unsqueeze(1).repeat(1, src_len, 1)
# Energy = v * tanh(W * [dec_hid; enc_hid])
combined = torch.cat((hidden, encoder_outputs), dim=2)
energy = torch.tanh(self.attn(combined))
attention = self.v(energy).squeeze(2) # [batch, src_len]
# --- THE ROBUST FIX: MASKING ---
# If mask is 0 (PAD), set energy to -infinity so softmax makes it 0.0
attention = attention.masked_fill(mask == 0, float('-inf'))
return F.softmax(attention, dim=1)
# --- ROBUST ENCODER ---
class AttentionEncoder(nn.Module):
def __init__(self, input_dim, embed_dim, hidden_dim, dropout, src_pad_idx, cell_type='GRU'):
super().__init__()
self.src_pad_idx = src_pad_idx # Essential for masking
self.hidden_dim = hidden_dim
self.cell_type = cell_type
self.embedding = nn.Embedding(input_dim, embed_dim)
self.dropout = nn.Dropout(dropout)
if cell_type == 'LSTM':
self.rnn_cell = LSTMCellFromScratch(embed_dim, hidden_dim)
else:
self.rnn_cell = GRUCellFromScratch(embed_dim, hidden_dim)
def forward(self, src):
embedded = self.dropout(self.embedding(src))
batch_size, src_len, _ = embedded.shape
outputs = torch.zeros(batch_size, src_len, self.hidden_dim).to(src.device)
h = torch.zeros(batch_size, self.hidden_dim).to(src.device)
state = (h, torch.zeros(batch_size, self.hidden_dim).to(src.device)) if self.cell_type == 'LSTM' else h
for t in range(src_len):
input_t = embedded[:, t, :]
state = self.rnn_cell(input_t, state)
h = state[0] if self.cell_type == 'LSTM' else state
outputs[:, t, :] = h
return outputs, state
#----------------------------------------------------------------------------------------------
# --- ROBUST DECODER ---
class AttentionDecoder(nn.Module):
def __init__(self, output_dim, embed_dim, hidden_dim, dropout, attention, cell_type='GRU'):
super().__init__()
self.cell_type = cell_type
self.output_dim = output_dim
self.attention = attention
self.embedding = nn.Embedding(output_dim, embed_dim)
self.dropout = nn.Dropout(dropout)
rnn_input_dim = embed_dim + hidden_dim
if cell_type == 'LSTM':
self.rnn_cell = LSTMCellFromScratch(rnn_input_dim, hidden_dim)
else:
self.rnn_cell = GRUCellFromScratch(rnn_input_dim, hidden_dim)
self.fc_out = nn.Linear(hidden_dim + hidden_dim + embed_dim, output_dim)
def forward(self, input_token, state, encoder_outputs, mask):
embedded = self.dropout(self.embedding(input_token))
h_current = state[0] if self.cell_type == 'LSTM' else state
# 3. Calculate Attention Weights (PASSING THE MASK)
a = self.attention(h_current, encoder_outputs, mask)
a = a.unsqueeze(1) # [Batch, 1, Src_Len]
# Weighted context vector
weighted = torch.bmm(a, encoder_outputs).squeeze(1)
rnn_input = torch.cat((embedded, weighted), dim=1)
if self.cell_type == 'LSTM':
h, c = self.rnn_cell(rnn_input, state)
new_state, out_h = (h, c), h
else:
h = self.rnn_cell(rnn_input, state)
new_state, out_h = h, h
prediction = self.fc_out(torch.cat((out_h, weighted, embedded), dim=1))
return prediction, new_state, a.squeeze(1)
#----------------------------------------------------------------------------------------------
# --- ROBUST MASTER SEQ2SEQ ---
class Seq2SeqAttention(nn.Module):
def __init__(self, encoder, decoder, device):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src, trg, teacher_forcing_ratio=0.5):
# Create mask: [batch, src_len]
mask = (src != self.encoder.src_pad_idx).to(self.device)
batch_size, trg_len = trg.shape
trg_vocab_size = self.decoder.output_dim
outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device)
# 1. Get FULL memory from Encoder
# encoder_outputs: [batch_size, src_len, hidden_dim]
# state: final (h, c) or h
encoder_outputs, state = self.encoder(src)
# 2. First input is <SOS>
input_token = trg[:, 0]
for t in range(1, trg_len):
# 3. Pass encoder_outputs so the decoder can "attend" and mask
# decoder now returns (output, state, attention_weights)
output, state, _ = self.decoder(input_token, state, encoder_outputs, mask)
outputs[:, t, :] = output
teacher_force = random.random() < teacher_forcing_ratio
input_token = trg[:, t] if teacher_force else output.argmax(1)
return outputs
def init_weights(m):
for name, param in m.named_parameters():
nn.init.uniform_(param.data, -0.08, 0.08)
def init_weights_robust(m):
for name, param in m.named_parameters():
if 'weight' in name:
if 'rnn' in name or 'rnn_cell' in name:
# Keep the Sutskever/LSTM-specific uniform init for the recurrent units
nn.init.uniform_(param.data, -0.08, 0.08)
elif 'embedding' in name:
# Embeddings benefit from a slightly wider uniform distribution
nn.init.uniform_(param.data, -0.1, 0.1)
else:
# Use Xavier for Attention and FC layers to ensure stable gradients
nn.init.xavier_uniform_(param.data)
elif 'bias' in name:
# Zero out biases to prevent early layer saturation
nn.init.constant_(param.data, 0)
def get_robust_maxlen(dataset, tokenizer, p=95, absolute_max=512, text_key='en'):
# 1. Get the statistical length using the dictionary key
# ex is a dict like {'en': '...', 'de': '...'}
lengths = [len(tokenizer(ex[text_key])) for ex in dataset]
p_length = np.percentile(lengths, p)
# 2. Find the next power of 2
robust_len = 2 ** math.ceil(math.log2(max(16, p_length)))
# 3. Cap at absolute_max
final_len = min(robust_len, absolute_max)
print(f"Source Key: {text_key}")
print(f"{p}th Percentile: {p_length:.2f}")
print(f"Nearest Power of 2: {robust_len}")
print(f"Final Robust MAX_LEN: {final_len}")
return final_len
# Usage - Ensure 'en' matches your dataset column name
MAX_LEN_SRC = get_robust_maxlen(ds_splits['train'], tokenizer, p=95, text_key='en')
print('\n')
MAX_LEN_TRG = get_robust_maxlen(ds_splits['train'], tokenizer, p=95, text_key='de')
# Final Robust Cap for Tensors
MAX_LEN = max(MAX_LEN_SRC, MAX_LEN_TRG)
print(f"\nGlobal MAX_LEN set to: {MAX_LEN}")
Source Key: en 95th Percentile: 19.00 Nearest Power of 2: 32 Final Robust MAX_LEN: 32 Source Key: de 95th Percentile: 18.00 Nearest Power of 2: 32 Final Robust MAX_LEN: 32 Global MAX_LEN set to: 32
DATASET_CONFIG, MAX_LEN_SRC, MAX_LEN_TRG, MAX_LEN
('bentrevett/multi30k', 32, 32, 32)
def count_parameters_seq2seq(model):
def count(m): return sum(p.numel() for p in m.parameters() if p.requires_grad)
# Breakdown by Sub-Module
enc_params = count(model.encoder)
dec_params = count(model.decoder)
total_params = enc_params + dec_params
# Further breakdown (optional but professional)
enc_emb = count(model.encoder.embedding)
dec_emb = count(model.decoder.embedding)
# The 'brain' is the RNN/LSTM/GRU layers
# We subtract embeddings and the output layer (fc_out) from the decoder
dec_rnn = dec_params - dec_emb - count(model.decoder.fc_out)
enc_rnn = enc_params - enc_emb
print(f"{'Component':<20} | {'Parameters':<12}")
print("-" * 35)
print(f"{'Encoder Embedding':<20} | {enc_emb:,}")
print(f"{'Encoder RNN':<20} | {enc_rnn:,}")
print(f"{'Decoder Embedding':<20} | {dec_emb:,}")
print(f"{'Decoder RNN':<20} | {dec_rnn:,}")
print(f"{'Output Projection':<20} | {count(model.decoder.fc_out):,}")
print("-" * 35)
print(f"{'TOTAL TRAINABLE':<20} | {total_params:,}")
def evaluate_seq2seq(model, data_loader, criterion, device):
model.eval()
total_loss = 0.0
total_tokens = 0
with torch.no_grad():
for src, trg in data_loader:
src, trg = src.to(device), trg.to(device)
# 1. Forward pass (No Teacher Forcing)
output = model(src, trg, teacher_forcing_ratio=0)
# output shape: [batch_size, trg_len, trg_vocab_size]
# trg shape: [batch_size, trg_len]
output_dim = output.shape[-1]
# 1. Slice off <SOS> token (index 0) from the sequence length dimension
# 2. Reshape to flatten Batch and Sequence dimensions into one [N] dimension
# Resulting output: [(trg_len - 1) * batch_size, trg_vocab_size]
# Resulting trg: [(trg_len - 1) * batch_size]
output = output[:, 1:, :].reshape(-1, output_dim)
trg = trg[:, 1:].reshape(-1)
# Now the dimensions match what nn.CrossEntropyLoss expects
loss = criterion(output, trg)
# 4. Global Accumulation
# Count only non-padding tokens for weighted average
n_tokens = (trg != TRG_PAD_IDX).sum().item()
# Recover total loss for this batch and add to global sum
total_loss += loss.item() * n_tokens
total_tokens += n_tokens
# Final Academic Metrics
avg_loss = total_loss / total_tokens
ppl = math.exp(avg_loss)
bpc = avg_loss / math.log(2)
return avg_loss, ppl, bpc
def train_seq2seq(model, train_loader, val_loader, optimizer, criterion, scheduler, num_epochs, device, patience=5):
scaler = torch.amp.GradScaler('cuda')
start_time = time.time()
best_val_loss = float('inf')
epochs_no_improve = 0
history = {'train_loss': [], 'val_loss': [], 'val_ppl': [], 'val_bpc': [], 'lrs': []}
print(f"Starting Seq2Seq training on {device}...")
for epoch in range(num_epochs):
model.train()
train_running_loss = 0.0
for src, trg in train_loader:
src, trg = src.to(device, non_blocking=True), trg.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast('cuda'):
output = model(src, trg, TEACHER_FORCING_RATIO)
output_dim = output.shape[-1]
# Flatten and ignore <SOS>
output = output[:, 1:, :].reshape(-1, output_dim)
trg = trg[:, 1:].reshape(-1)
loss = criterion(output, trg)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
train_running_loss += loss.item()
# Epoch Metrics
epoch_train_loss = train_running_loss / len(train_loader)
epoch_val_loss, epoch_val_ppl, epoch_val_bpc = evaluate_seq2seq(model, val_loader, criterion, device)
# Early Stopping & Checkpoint
if epoch_val_loss < best_val_loss:
best_val_loss = epoch_val_loss
torch.save(model.state_dict(), 'best_seq2seq.pth')
epochs_no_improve = 0
status_msg = " * (Saved Best)"
else:
epochs_no_improve += 1
status_msg = f" (No improve: {epochs_no_improve}/{patience})"
scheduler.step()
current_lr = optimizer.param_groups[0]['lr']
# Update History
history['train_loss'].append(epoch_train_loss)
history['val_loss'].append(epoch_val_loss)
history['val_ppl'].append(epoch_val_ppl)
history['val_bpc'].append(epoch_val_bpc) # Add BPC to history
history['lrs'].append(current_lr)
print(f'Epoch {epoch + 1:03d}/{num_epochs:03d} | '
f'Loss (T/V): {epoch_train_loss:.4f} / {epoch_val_loss:.4f} | '
f'PPL (V): {epoch_val_ppl:.2f} | '
f'BPC (V): {epoch_val_bpc:.4f} | '
f'LR: {current_lr:.6f}{status_msg}')
if epochs_no_improve >= patience:
print(f"\nEarly stopping triggered.")
break
print(f'\nDone! Total Time: {(time.time() - start_time) / 60:.2f} min')
model.load_state_dict(torch.load('best_seq2seq.pth'))
return history
def plot_history(history):
epochs = range(1, len(history['train_loss']) + 1)
# Identify which metrics were tracked
has_acc = 'val_acc' in history
has_f1 = 'val_f1' in history
has_gen = 'val_ppl' in history
# Dynamic grid layout
num_plots = 2 + int(has_acc) + int(has_f1) + int(has_gen)
plt.figure(figsize=(num_plots * 5, 5))
# --- Plot 1: Loss ---
plt.subplot(1, num_plots, 1)
plt.plot(epochs, history['train_loss'], 'o-', label='Train ', color='#1f77b4')
plt.plot(epochs, history['val_loss'], 's--', label='Val', color='#ff7f0e')
plt.title('Cross Entropy Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.grid(True, alpha=0.3)
plot_idx = 2
# --- Plot 2: Accuracy ---
if has_acc:
plt.subplot(1, num_plots, plot_idx)
plt.plot(epochs, np.array(history['train_acc']) * 100, 'o-', label='Train', color='#1f77b4')
plt.plot(epochs, np.array(history['val_acc']) * 100, 's--', label='Val', color='#ff7f0e')
plt.title('Accuracy (%)')
plt.xlabel('Epochs')
plt.ylabel('Percentage')
plt.legend()
plt.grid(True, alpha=0.3)
plot_idx += 1
# --- Plot 3: F1 Score ---
if has_f1:
plt.subplot(1, num_plots, plot_idx)
plt.plot(epochs, np.array(history['val_f1']) * 100, 'd-', label='Val F1', color='#2ca02c')
plt.title('F1 Score (%)')
plt.ylabel('Percentage')
plt.legend()
plt.grid(True, alpha=0.3)
plot_idx += 1
# --- Plot 4: Generative Metrics (PPL & BPC) ---
if has_gen:
plt.subplot(1, num_plots, plot_idx)
ax1 = plt.gca()
# PPL on Primary Y-Axis
ax1.plot(epochs, history['val_ppl'], 'o-', label='PPL', color='purple')
ax1.set_ylabel('Perplexity', color='purple', fontweight='bold')
ax1.tick_params(axis='y', labelcolor='purple')
if 'val_bpc' in history:
# BPC on Secondary Y-Axis
ax2 = ax1.twinx()
ax2.plot(epochs, history['val_bpc'], 'x--', label='BPC', color='red')
ax2.set_ylabel('Bits Per Character (BPC)', color='red', fontweight='bold')
ax2.tick_params(axis='y', labelcolor='red')
ax2.grid(False)
plt.title('Generative Metrics')
ax1.set_xlabel('Epochs')
ax1.grid(True, alpha=0.3)
plot_idx += 1
# --- Plot 5: Learning Rate ---
plt.subplot(1, num_plots, num_plots)
plt.plot(epochs, history['lrs'], label='LR', color='black', linewidth=2)
plt.title('LR Schedule')
plt.xlabel('Epochs')
plt.ylabel('Rate')
plt.ticklabel_format(style='sci', axis='y', scilimits=(0,0))
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
def translate_sentence_attention(model, sentence, src_vocab, trg_vocab, tokenizer, device, max_len=50):
model.eval()
tokens = tokenizer(sentence)
indexed = [src_vocab['<SOS>']] + [src_vocab.get(t, src_vocab['<UNK>']) for t in tokens] + [src_vocab['<EOS>']]
src_tensor = torch.LongTensor(indexed).unsqueeze(0).to(device)
# Create mask for inference
mask = (src_tensor != model.encoder.src_pad_idx).to(device)
with torch.no_grad():
encoder_outputs, state = model.encoder(src_tensor)
trg_indexes = [trg_vocab['<SOS>']]
attentions = torch.zeros(max_len, 1, len(indexed)).to(device)
for i in range(max_len):
trg_tensor = torch.LongTensor([trg_indexes[-1]]).to(device)
with torch.no_grad():
# Pass mask here
output, state, attention = model.decoder(trg_tensor, state, encoder_outputs, mask)
attentions[i] = attention
pred_token = output.argmax(1).item()
trg_indexes.append(pred_token)
if pred_token == trg_vocab['<EOS>']: break
reverse_trg_vocab = {v: k for k, v in trg_vocab.items()}
translated_words = [reverse_trg_vocab[i] for i in trg_indexes[1:]]
return translated_words, attentions[:len(translated_words)]
#----------------------------------------------------------------------------------------------
# --- The Wrapper for BLEU and Suite ---
def translate_robust(model, sentence, src_vocab, trg_vocab, tokenizer, device, max_len=50):
words, _ = translate_sentence_attention(model, sentence, src_vocab, trg_vocab, tokenizer, device, max_len)
# Clean up any trailing <EOS> or <PAD> if they snuck in
if words and words[-1] == '<EOS>':
words = words[:-1]
return " ".join(words)
def run_translation_suite(model, test_sentences, src_vocab, trg_vocab, tokenizer, device):
"""
Runs a batch of translation tests and prints a professional, formatted report.
"""
print(f"{'Source Sentence (English)':<55} | {'Model Translation (German)'}")
print("-" * 110)
for text in test_sentences:
# Use our generative inference function
translation = translate_robust(
model, text, src_vocab, trg_vocab, tokenizer, device
)
# Truncate source for clean table view, but show full translation
source_display = text[:52] + "..." if len(text) > 52 else text
print(f"{source_display:<55} | {translation}")
def calculate_bleu(data, model, src_vocab, trg_vocab, tokenizer, device):
"""
Calculates the average BLEU-4 score across the dataset using NLTK.
"""
bleu_scores = []
# Smoothing function handles cases where there are no 4-gram overlaps
# (very common in early training) so the score doesn't just drop to 0.
smoothie = SmoothingFunction().method1
# We use a subset for a quick audit (e.g., 500 samples) to save time
audit_subset = data if len(data) < 500 else data.select(range(500))
for datum in audit_subset:
src = datum['en']
trg = datum['de']
# 1. Get model prediction (string)
prediction = translate_robust(model, src, src_vocab, trg_vocab, tokenizer, device)
# 2. Tokenize for NLTK
# Reference must be a list of lists: [[ref1_tok1, ref1_tok2]]
# Candidate must be a list: [cand_tok1, cand_tok2]
reference = [tokenizer(trg.lower())]
candidate = tokenizer(prediction.lower())
# 3. Calculate score (Weights for BLEU-4 are 0.25 for each n-gram)
score = sentence_bleu(reference, candidate, weights=(0.25, 0.25, 0.25, 0.25), smoothing_function=smoothie)
bleu_scores.append(score)
return sum(bleu_scores) / len(bleu_scores)
def plot_attention(sentence, translation, attention, tokenizer, use_colorbar=False):
"""
Plots a 'cleaned' attention heatmap matching the Bahdanau paper style.
Args:
sentence (str/list): The source English sentence.
translation (list): The model's predicted German tokens (including <EOS>).
attention (Tensor): Attention weights of shape [Target_Len, Source_Len].
tokenizer (callable): Tokenizer for the source language.
"""
# 1. Prepare Full Tokens
src_tokens = ['<SOS>'] + tokenizer(sentence) + ['<EOS>']
trg_tokens = translation # Predicted German words
# 2. Fix the Shape (Crucial Step)
# Your tensor is likely [Trg_Len, Batch, Src_Len]
# We need to remove the Batch dimension (index 1)
if attention.ndim == 3:
# This takes the first (and only) item in the batch
attention = attention.permute(1, 0, 2).squeeze(0)
# Now attention should be [Trg_Len, Src_Len] -> (8, 12)
display_attention = attention.cpu().detach().numpy()
# 3. Slice for the "Bahdanau" look
# Check bounds to prevent the "0" dimension error if sentence is too short
row_end = display_attention.shape[0] - 1
col_start = 1
col_end = display_attention.shape[1] - 1
display_attention = display_attention[:row_end, col_start:col_end]
# Match labels to the sliced data
display_src = src_tokens[1:-1]
display_trg = trg_tokens[:-1]
# 3. Setup Figure
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(111)
# matshow places the [0,0] index at the top-left
cax = ax.matshow(display_attention, cmap='bone')
if use_colorbar:
fig.colorbar(cax, fraction=0.046, pad=0.04)
# 4. Set Axis Labels
ax.set_xticks(range(len(display_src)))
ax.set_yticks(range(len(display_trg)))
ax.set_xticklabels(display_src, rotation=45, ha='left')
ax.set_yticklabels(display_trg)
# 5. Paper Aesthetics
ax.xaxis.set_ticks_position('top')
ax.tick_params(axis='both', which='major', labelsize=12)
ax.set_xlabel("Source (English)", labelpad=20)
ax.xaxis.set_label_position('top')
ax.set_ylabel("Predicted (German)", labelpad=20)
plt.tight_layout()
plt.show()
def plot_multi_word_attention(sentence, translation, attention, tokenizer):
"""
Plots a grid of bar charts showing attention focus for EVERY predicted word.
"""
# 1. Prepare Data (Same slicing logic)
src_tokens = ['<SOS>'] + tokenizer(sentence) + ['<EOS>']
trg_tokens = translation
if attention.ndim == 3:
attention = attention.permute(1, 0, 2).squeeze(0)
attn_data = attention.cpu().detach().numpy()
# Slice to remove <SOS>/<EOS>
clean_attn = attn_data[:-1, 1:-1]
clean_src = src_tokens[1:-1]
clean_trg = trg_tokens[:-1]
# 2. Setup the Grid (e.g., 3 columns wide)
num_words = len(clean_trg)
cols = 3
rows = math.ceil(num_words / cols)
fig, axes = plt.subplots(rows, cols, figsize=(15, rows * 4), sharex=True)
axes = axes.flatten()
# 3. Plot each word's focus
for i in range(num_words):
ax = axes[i]
ax.barh(clean_src, clean_attn[i, :], color='teal', alpha=0.7)
ax.set_title(f"Target: '{clean_trg[i]}'", fontsize=12, fontweight='bold')
ax.set_xlim(0, 1.1) # Probability is 0 to 1
ax.invert_yaxis()
# 4. Cleanup empty subplots
for j in range(i + 1, len(axes)):
fig.delaxes(axes[j])
plt.suptitle(f"Source: {sentence}", fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
INPUT_DIM = len(src_vocab)
OUTPUT_DIM = len(trg_vocab)
set_all_seeds(RANDOM_SEED)
# Initialization
# For LSTM (Original Sutskever 2014)
CELL_TYPE = 'LSTM'
attn = Attention(HID_DIM, HID_DIM)
enc = AttentionEncoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, ENC_DROPOUT, SRC_PAD_IDX, cell_type=CELL_TYPE)
dec = AttentionDecoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, DEC_DROPOUT, attn, cell_type=CELL_TYPE)
model = Seq2SeqAttention(enc, dec, DEVICE).to(DEVICE)
model.apply(init_weights_robust)
print(f"Model initialized on {DEVICE} with {CELL_TYPE} cells.")
print(f"Masking enabled using SRC_PAD_IDX: {SRC_PAD_IDX}")
Model initialized on cuda with LSTM cells. Masking enabled using SRC_PAD_IDX: 0
# Usage
count_parameters_seq2seq(model)
Component | Parameters ----------------------------------- Encoder Embedding | 2,500,096 Encoder RNN | 1,576,960 Decoder Embedding | 2,560,000 Decoder RNN | 3,150,848 Output Projection | 12,810,000 ----------------------------------- TOTAL TRAINABLE | 22,597,904
# 1. Setup
criterion = nn.CrossEntropyLoss(ignore_index=TRG_PAD_IDX)
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-5)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=NUM_EPOCHS)
# 2. Train
history = train_seq2seq(
model, train_loader, val_loader,
optimizer, criterion, scheduler,
NUM_EPOCHS, DEVICE, patience=12
)
Starting Seq2Seq training on cuda... Epoch 001/050 | Loss (T/V): 5.3099 / 5.0460 | PPL (V): 155.39 | BPC (V): 7.2798 | LR: 0.000999 * (Saved Best) Epoch 002/050 | Loss (T/V): 4.5274 / 4.7515 | PPL (V): 115.76 | BPC (V): 6.8550 | LR: 0.000996 * (Saved Best) Epoch 003/050 | Loss (T/V): 4.1606 / 4.3729 | PPL (V): 79.28 | BPC (V): 6.3088 | LR: 0.000991 * (Saved Best) Epoch 004/050 | Loss (T/V): 3.7017 / 3.8933 | PPL (V): 49.07 | BPC (V): 5.6169 | LR: 0.000984 * (Saved Best) Epoch 005/050 | Loss (T/V): 3.1870 / 3.6004 | PPL (V): 36.61 | BPC (V): 5.1943 | LR: 0.000976 * (Saved Best) Epoch 006/050 | Loss (T/V): 2.8072 / 3.4116 | PPL (V): 30.31 | BPC (V): 4.9218 | LR: 0.000965 * (Saved Best) Epoch 007/050 | Loss (T/V): 2.5292 / 3.3204 | PPL (V): 27.67 | BPC (V): 4.7904 | LR: 0.000952 * (Saved Best) Epoch 008/050 | Loss (T/V): 2.2891 / 3.2414 | PPL (V): 25.57 | BPC (V): 4.6764 | LR: 0.000938 * (Saved Best) Epoch 009/050 | Loss (T/V): 2.0776 / 3.2274 | PPL (V): 25.21 | BPC (V): 4.6561 | LR: 0.000922 * (Saved Best) Epoch 010/050 | Loss (T/V): 1.8689 / 3.2465 | PPL (V): 25.70 | BPC (V): 4.6837 | LR: 0.000905 (No improve: 1/12) Epoch 011/050 | Loss (T/V): 1.7439 / 3.2097 | PPL (V): 24.77 | BPC (V): 4.6305 | LR: 0.000885 * (Saved Best) Epoch 012/050 | Loss (T/V): 1.6194 / 3.2252 | PPL (V): 25.16 | BPC (V): 4.6530 | LR: 0.000864 (No improve: 1/12) Epoch 013/050 | Loss (T/V): 1.4870 / 3.2554 | PPL (V): 25.93 | BPC (V): 4.6966 | LR: 0.000842 (No improve: 2/12) Epoch 014/050 | Loss (T/V): 1.4074 / 3.3131 | PPL (V): 27.47 | BPC (V): 4.7798 | LR: 0.000819 (No improve: 3/12) Epoch 015/050 | Loss (T/V): 1.3276 / 3.3168 | PPL (V): 27.57 | BPC (V): 4.7851 | LR: 0.000794 (No improve: 4/12) Epoch 016/050 | Loss (T/V): 1.2362 / 3.3047 | PPL (V): 27.24 | BPC (V): 4.7676 | LR: 0.000768 (No improve: 5/12) Epoch 017/050 | Loss (T/V): 1.1744 / 3.3524 | PPL (V): 28.57 | BPC (V): 4.8365 | LR: 0.000741 (No improve: 6/12) Epoch 018/050 | Loss (T/V): 1.1013 / 3.4013 | PPL (V): 30.00 | BPC (V): 4.9070 | LR: 0.000713 (No improve: 7/12) Epoch 019/050 | Loss (T/V): 1.0280 / 3.4258 | PPL (V): 30.75 | BPC (V): 4.9424 | LR: 0.000684 (No improve: 8/12) Epoch 020/050 | Loss (T/V): 0.9757 / 3.4690 | PPL (V): 32.10 | BPC (V): 5.0047 | LR: 0.000655 (No improve: 9/12) Epoch 021/050 | Loss (T/V): 0.9368 / 3.4921 | PPL (V): 32.86 | BPC (V): 5.0381 | LR: 0.000624 (No improve: 10/12) Epoch 022/050 | Loss (T/V): 0.8854 / 3.4979 | PPL (V): 33.05 | BPC (V): 5.0465 | LR: 0.000594 (No improve: 11/12) Epoch 023/050 | Loss (T/V): 0.8350 / 3.5695 | PPL (V): 35.50 | BPC (V): 5.1497 | LR: 0.000563 (No improve: 12/12) Early stopping triggered. Done! Total Time: 23.48 min
# Visualize & Audit
plot_history(history)
# --- Test the translation ---
test_sentences = [
"A man in an orange hat starring at something.",
"Two young, white males are outside near many bushes."
]
print("="*80)
for sent in test_sentences:
translation = translate_robust(model, sent, src_vocab, trg_vocab, tokenizer, DEVICE)
print(f"Source (EN): {sent}")
print(f"Target (DE): {translation}")
print("-" * 80)
================================================================================ Source (EN): A man in an orange hat starring at something. Target (DE): ein mann mit einem orangefarbenen hut starrt auf etwas an -------------------------------------------------------------------------------- Source (EN): Two young, white males are outside near many bushes. Target (DE): büsche junge stehen in der nähe von büschen in der nähe --------------------------------------------------------------------------------
# 1. Define Multi30k-style test cases (Small descriptive sentences)
translation_tests = [
"A man in an orange hat is looking at a map.",
"A group of people are standing in front of an old building.",
"Two dogs are running through the green grass.",
"A woman is cooking a meal in a small kitchen.",
"A young boy is jumping into a swimming pool.",
"The sun is setting over a calm lake.",
"A person rides a bicycle down a crowded street.",
"A white cat is sleeping on a red sofa."
]
# 2. Run the Suite
run_translation_suite(
model,
translation_tests,
src_vocab,
trg_vocab,
tokenizer,
DEVICE
)
Source Sentence (English) | Model Translation (German) -------------------------------------------------------------------------------------------------------------- A man in an orange hat is looking at a map. | ein mann mit einem orangefarbenen hut schaut eine landkarte A group of people are standing in front of an old bu... | ein gruppe von steht vor einem alten gebäude Two dogs are running through the green grass. | zwei hunde rennen über das grüne gras A woman is cooking a meal in a small kitchen. | eine eine kleine küche essen in einer kleinen küche kocht A young boy is jumping into a swimming pool. | ein junge springt in ein schwimmbecken The sun is setting over a calm lake. | die sonne über einen baumstamm see A person rides a bicycle down a crowded street. | eine fährt fahrrad eine eine straße straße entlang A white cat is sleeping on a red sofa. | eine katze schläft schläft auf einem roten sofa
# --- Final Evaluation (The "Audit") ---
print("\n" + "="*50)
print(" FINAL TEST SET AUDIT ")
print("="*50)
# 1. Run evaluation on the test set
# For Seq2Seq, we focus on Loss, Perplexity (PPL), and Bits Per Character (BPC)
test_loss, test_ppl, test_bpc = evaluate_seq2seq(
model,
test_loader,
criterion,
DEVICE
)
# 2. Linguistic Audit (BLEU)
# Note: This can be slow on large datasets, so we often use the first 500-1000 samples
print("Calculating BLEU Score (this may take a minute)...")
test_bleu = calculate_bleu(ds_splits['test'], model, src_vocab, trg_vocab, tokenizer, DEVICE)
print(f"Test Loss: {test_loss:.4f}")
print(f"Test PPL: {test_ppl:.2f}")
print(f"Test BPC: {test_bpc:.4f}")
print(f"Test BLEU: {test_bleu*100:.2f}") # Shown as a percentage 0-100
# 3. Qualitative Audit: Show actual translations
print("\n" + "="*50)
print(" QUALITATIVE SAMPLE CHECK ")
print("="*50)
# We use the inference function to see how the model actually performs
sample_indices = random.sample(range(len(ds_splits['test'])), 3)
for idx in sample_indices:
item = ds_splits['test'][idx]
src_sent = item['en']
trg_sent = item['de']
prediction = translate_robust(model, src_sent, src_vocab, trg_vocab, tokenizer, DEVICE)
print(f"Source: {src_sent}")
print(f"Target: {trg_sent}")
print(f"Model: {prediction}")
print("-" * 50)
print("="*100)
print(f'\nFinal Summary | Loss: {test_loss:.4f} | Perplexity: {test_ppl:.2f} | BPC: {test_bpc:.4f} | BLEU: {test_bleu*100:.2f}')
print("="*100)
==================================================
FINAL TEST SET AUDIT
==================================================
Calculating BLEU Score (this may take a minute)...
Test Loss: 3.1547
Test PPL: 23.45
Test BPC: 4.5513
Test BLEU: 21.85
==================================================
QUALITATIVE SAMPLE CHECK
==================================================
Source: Three men are cooking in a kitchen.
Target: Drei Männer kochen in einer Küche.
Model: in einer küche küche in einer küche
--------------------------------------------------
Source: A man trying to ride a very mad bull on a beautiful Saturday afternoon.
Target: Ein Mann versucht an einem schönen Samstagnachmittag, einen sehr wütenden Bullen zu reiten.
Model: ein mann versucht auf einem sonnigen tag auf einem sehr <UNK> <UNK> zu <UNK>
--------------------------------------------------
Source: A dog is jumping through a fiery obstacle.
Target: Ein Hund springt durch ein brennendes Hindernis.
Model: hund springt über eine <UNK>
--------------------------------------------------
====================================================================================================
Final Summary | Loss: 3.1547 | Perplexity: 23.45 | BPC: 4.5513 | BLEU: 21.85
====================================================================================================
# 1. Get the raw "Brain Scan" from the model
sentence = "A family is playing on the beach with their dog."
pred_tokens, attn_matrix = translate_sentence_attention(model, sentence, src_vocab, trg_vocab, tokenizer, DEVICE)
# 2. Visualize it
plot_attention(sentence, pred_tokens, attn_matrix, tokenizer)
plot_multi_word_attention(sentence, pred_tokens, attn_matrix, tokenizer)
Summary¶
In this notebook, I implemented a robust Encoder-Decoder Seq2Seq model. By reversing the source sequences (Sutskever et al.), I observed a BLEU score of 21.85. The model achieved a final Test Perplexity of 23.45 and a Bits Per Character (Word) of 4.55.
Updated Project Summary: Seq2Seq with Attention¶
The tandard Seq2Seq architectures hit a structural "bottleneck plateau" at approximately 15 BLEU, where the single, compressed hidden vector lacks the capacity to preserve specific action verbs (e.g., "tunes," "films," or "cooking").
By implementing Bahdanau Attention, we replaced this rigid compression with a dynamic "search and align" mechanism. This allowed the decoder to perform precise word-to-word mapping, directly addressing the "semantic drift" and repetition seen in earlier samples. The transition resulted in a significant performance leap to 21.85 BLEU, successfully resolving complex alignments that previously caused the model to default to generic context (like "arbeiten") or repetitive loops. This shift transforms the architecture from a probabilistic guesser into a specialized translator capable of maintaining high-fidelity token relationships across the entire sequence.
Key Takeaways¶
- Quantifiable Improvement: A +6.9 BLEU increase solely by removing the fixed-length bottleneck.
- Mechanism of Action: The model no longer "forgets" the beginning of the sentence by the time it reaches the end, as evidenced by our improved qualitative samples.
- The "Robust" Factor: Even with minor "stuttering" in Greedy search, the underlying Attention maps (the heatmaps we generated) prove the model is finally looking at the correct source words.
Detailed Tabular Audit Summary¶
| Tier | PPL | BPC | BLEU | Core Behavioral Signature |
|---|---|---|---|---|
| Random | 500+ | 8.0+ | 0-2 | Gibberish; lacks basic SOS/EOS structure. |
| Structuralist | 100-300 | 6.5-8 | 3-8 | Basic syntax emerges; high noun hallucinations. |
| Generalist | 40-80 | 5-6.5 | 9-15 | Frequency bias; fails on colors/rare nouns. |
| Robust | 20-40 | 3.5-5 | 16-25 | High factual accuracy; minor grammar slips. |
| SOTA | < 15 | < 3.5 | 30+ | Human-level fluency and nuance. |
%watermark -iv
datasets : 4.0.0 matplotlib : 3.10.0 nltk : 3.9.1 numpy : 2.0.2 pandas : 2.2.2 re : 2.2.1 seaborn : 0.13.2 sklearn : 1.6.1 torch : 2.10.0+cu128 torchinfo : 1.8.0 torchvision: 0.25.0+cu128