0. Quick Reference -- Common Syntax
NumPy
import numpy as np
# Creating arrays
a = np.array([1, 2, 3]) # from list
np.ones((2, 3)) # 2x3 of ones
np.random.randn(5, 3) # 5x3 standard normal
np.random.randint(0, 10, size=(3,)) # 3 random ints in [0, 10)
np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0]
np.eye(3) # 3x3 identity
# Shape
a.reshape(1, 3) # reshape to (1, 3)
a[:, None] # add dim: (3,) -> (3, 1)
np.squeeze(a) # remove dims of size 1
# Indexing
mask = a > 1 # [False, True, True]
a[mask] # [2, 3] -- boolean indexing
np.where(a > 1, a, 0) # [0, 2, 3] -- conditional
np.argsort(a) # indices that sort the array
np.argmax(a) # index of max
PyTorch
import torch
import torch.nn as nn
import torch.nn.functional as F
# Creating tensors
t = torch.tensor([1.0, 2.0, 3.0]) # from list
torch.randn(5, 3) # 5x3 standard normal
torch.from_numpy(np_array) # numpy -> tensor
t.numpy() # tensor -> numpy (shares memory)
# Shape
t.view(1, -1) # reshape (-1 infers size)
t.unsqueeze(0) # add dim: (3,) -> (1, 3)
t.squeeze() # remove size-1 dims
# Common operations
t.argmax(dim=-1) # index of max
F.softmax(t, dim=-1) # softmax
F.normalize(t, p=2, dim=-1) # L2 normalize
F.cosine_similarity(a, b, dim=-1) # cosine sim
torch.topk(t, k=3, dim=-1) # top-k values + indices
# Gradient control
with torch.no_grad(): # disable grad tracking
out = model(x)
param.requires_grad = False # freeze a parameter
# Common layers
nn.Linear(in_features, out_features) # fully connected
nn.Embedding(vocab_size, embed_dim) # embedding lookup
nn.Dropout(p=0.1) # dropout
nn.CrossEntropyLoss() # classification loss
nn.MSELoss() # regression loss
1. Data Preprocessing
Load, Split, Handle Imbalance
import pandas as pd
import torch
from sklearn.model_selection import train_test_split
from torch.utils.data import WeightedRandomSampler, DataLoader
# Load and inspect
df = pd.read_csv("dataset.csv")
print(df.shape, df.dtypes, df.isnull().sum())
print(df["label"].value_counts())
# 70/15/15 stratified split
train_df, temp_df = train_test_split(df, test_size=0.3, random_state=42, stratify=df["label"])
val_df, test_df = train_test_split(temp_df, test_size=0.5, random_state=42, stratify=temp_df["label"])
print(f"Train: {len(train_df)}, Val: {len(val_df)}, Test: {len(test_df)}")
# Handle class imbalance -- Option A: weighted loss
class_counts = [8000, 2000] # [negative, positive]
weights = torch.tensor([1.0 / c for c in class_counts])
weights = weights / weights.sum()
loss_fn = torch.nn.CrossEntropyLoss(weight=weights)
# Handle class imbalance -- Option B: oversample minority
labels = [0] * 8000 + [1] * 2000
sample_weights = [1.0 / class_counts[l] for l in labels]
sampler = WeightedRandomSampler(sample_weights, num_samples=len(labels), replacement=True)
loader = DataLoader(dataset, batch_size=32, sampler=sampler)
Always inspect first:
df.describe(), df.isnull().sum(), df["label"].value_counts(). Two ways to handle imbalance: weighted loss (penalize mistakes on minority class more) or oversampling (show minority class more often).
2. Building a Classification Pipeline
End-to-End Spam Classifier with Pretrained Transformer
import torch
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from sklearn.metrics import precision_recall_fscore_support
from sklearn.model_selection import train_test_split
texts = ["Buy cheap meds now!", "Meeting at 3pm tomorrow", "You won $1M!", "Project update attached"]
labels = [1, 0, 1, 0] # 1=spam, 0=ham
train_texts, val_texts, train_labels, val_labels = train_test_split(texts, labels, test_size=0.25, random_state=42)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
class SpamDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len=128):
self.encodings = tokenizer(texts, truncation=True, padding="max_length", max_length=max_len, return_tensors="pt")
self.labels = torch.tensor(labels)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
# pull input_ids and attention_mask for this sample
item = {}
for key, val in self.encodings.items():
item[key] = val[idx] # each is (seq_len,)
return item, self.labels[idx] # dict, scalar
train_loader = DataLoader(SpamDataset(train_texts, train_labels, tokenizer), batch_size=2, shuffle=True)
val_loader = DataLoader(SpamDataset(val_texts, val_labels, tokenizer), batch_size=2)
model = AutoModelForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=2)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
loss_fn = torch.nn.CrossEntropyLoss()
for epoch in range(3):
model.train()
for batch, lbl in train_loader: # batch: dict of (B, seq_len), lbl: (B,)
optimizer.zero_grad()
out = model(**batch) # out.logits: (B, num_labels)
loss = loss_fn(out.logits, lbl)
loss.backward()
optimizer.step()
model.eval()
all_preds, all_labels = [], []
with torch.no_grad():
for batch, lbl in val_loader:
preds = model(**batch).logits.argmax(dim=-1) # (B,)
all_preds.extend(preds.tolist())
all_labels.extend(lbl.tolist())
p, r, f1, _ = precision_recall_fscore_support(all_labels, all_preds, average="binary")
print(f"Epoch {epoch+1}: P={p:.3f} R={r:.3f} F1={f1:.3f}")
Sklearn Baseline for Comparison
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report
pipe = Pipeline([
("tfidf", TfidfVectorizer(max_features=10000, ngram_range=(1, 2))),
("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(train_texts, train_labels)
preds = pipe.predict(val_texts)
print(classification_report(val_labels, preds, target_names=["ham", "spam"]))
Always build a simple baseline first (TF-IDF + LogReg). If the transformer doesn't beat it significantly, your data or labels may be the problem, not the model.
3. K-Means Clustering
K-Means from Scratch (NumPy)
import numpy as np
def kmeans(X, k, max_iters=100, seed=42):
rng = np.random.RandomState(seed)
n, d = X.shape # n=num points, d=num features
# Random initialization: pick k random points as centroids
indices = rng.choice(n, size=k, replace=False) # (k,)
centroids = X[indices] # (k, d)
for _ in range(max_iters):
# Assign each point to nearest centroid
dists = np.linalg.norm(X[:, None] - centroids[None], axis=2) # (n, k)
assignments = np.argmin(dists, axis=1) # (n,)
# Update centroids to mean of assigned points
new_centroids = np.empty_like(centroids) # (k, d)
for j in range(k):
members = X[assignments == j] # (num_in_cluster_j, d)
if len(members) > 0:
new_centroids[j] = members.mean(axis=0) # (d,)
else:
new_centroids[j] = centroids[j]
if np.allclose(centroids, new_centroids):
break
centroids = new_centroids
return centroids, assignments # (k, d), (n,)
X = np.random.randn(200, 5) # 200 points, 5 features
centroids, labels = kmeans(X, k=3)
for i in range(3):
print(f"Cluster {i}: {np.sum(labels == i)} points")
Embedding Clustering
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2")
texts = [
"How to train a neural network", "Backpropagation explained", "Gradient descent variants",
"Best Italian restaurants nearby", "Pizza dough recipe", "Pasta carbonara tips",
"Stock market predictions", "Portfolio diversification", "Bond yields explained",
]
embeddings = model.encode(texts, convert_to_numpy=True) # (9, 384)
# Use our kmeans from above
centroids, labels = kmeans(embeddings, k=3) # (3, 384), (9,)
for i in range(3):
members = [texts[j] for j in range(len(texts)) if labels[j] == i]
print(f"Cluster {i}: {members}")
K-Means algorithm: 1) pick k random centroids, 2) assign points to nearest centroid, 3) recompute centroids as cluster means, 4) repeat until convergence. For high-dimensional embeddings, apply PCA or UMAP first for better clustering and visualization.
4. Logistic & Linear Regression from Scratch
Logistic Regression (NumPy)
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z)) # maps any real number to (0, 1)
def logistic_regression(X, y, lr=0.1, epochs=1000):
n, d = X.shape # n=samples, d=features
W = np.zeros(d) # (d,) -- one weight per feature
b = 0.0 # scalar bias
for _ in range(epochs):
z = X @ W + b # (n,) -- linear combination
preds = sigmoid(z) # (n,) -- probabilities between 0 and 1
error = preds - y # (n,) -- how far off each prediction is
dW = X.T @ error / n # (d,) -- gradient for weights
db = np.mean(error) # scalar -- gradient for bias
W = W - lr * dW
b = b - lr * db
return W, b # (d,), scalar
def predict(X, W, b):
probs = sigmoid(X @ W + b) # (n,)
return (probs >= 0.5).astype(int) # (n,) -- 0 or 1
np.random.seed(42)
X = np.random.randn(200, 3) # 200 samples, 3 features
y = (X @ [1, -2, 0.5] + np.random.randn(200) * 0.5 > 0).astype(float) # binary labels
W, b = logistic_regression(X, y, lr=0.5, epochs=2000)
acc = (predict(X, W, b) == y).mean()
print(f"Accuracy: {acc:.3f}, bias: {b:.3f}")
Linear Regression (NumPy)
import numpy as np
def linear_regression_gd(X, y, lr=0.01, epochs=1000):
n, d = X.shape
W = np.zeros(d) # (d,)
b = 0.0 # scalar
for _ in range(epochs):
preds = X @ W + b # (n,)
error = preds - y # (n,)
dW = X.T @ error / n # (d,)
db = np.mean(error) # scalar
W = W - lr * dW
b = b - lr * db
return W, b
# Closed-form (normal equation) -- for reference
def linear_regression_closed(X, y):
"""W = (X^T X)^{-1} X^T y -- solves in one step, no iterations."""
# Here we use the hstack trick to fold bias into W
ones = np.ones((X.shape[0], 1)) # (n, 1)
X_b = np.hstack([ones, X]) # (n, d+1) -- prepend column of 1s
W_b = np.linalg.pinv(X_b.T @ X_b) @ X_b.T @ y # (d+1,)
return W_b[1:], W_b[0] # weights (d,), bias scalar
np.random.seed(42)
X = np.random.randn(100, 2) # 100 samples, 2 features
y = X @ [3, -1.5] + 2 + np.random.randn(100) * 0.3 # true: W=[3, -1.5], b=2
W_gd, b_gd = linear_regression_gd(X, y, lr=0.1, epochs=2000)
W_cf, b_cf = linear_regression_closed(X, y)
print(f"GD: bias={b_gd:.2f}, weights={W_gd}")
print(f"Closed-form: bias={b_cf:.2f}, weights={W_cf}")
Key gradient formulas (same structure for both):
Where
dW = X.T @ error / n db = mean(error)Where
error = preds - y. Only difference: logistic wraps the prediction in sigmoid, linear does not.
5. Models & Tokenization
Load Model + Tokenize
from transformers import AutoModel, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
model.eval()
# Single text
tokens = tokenizer("Hello world", return_tensors="pt", padding=True, truncation=True, max_length=128)
output = model(**tokens)
print(output.last_hidden_state.shape) # (1, seq_len, 768)
# Batch
texts = ["What is the weather?", "Remind me to call mom.", "Play my morning playlist."]
batch = tokenizer(texts, return_tensors="pt", padding="longest", truncation=True, max_length=64)
print(batch["input_ids"].shape) # (3, longest_seq_len)
Sentence Embedding Model
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
sentences = ["Samsung Bixby is a voice assistant", "Memory systems store user preferences"]
embeddings = model.encode(sentences, convert_to_tensor=True)
print(embeddings.shape) # (2, 384)
AutoModel -- base transformer, returns hidden states. For embeddings or custom heads.
AutoModelForSequenceClassification -- adds classification head on [CLS]. For sentiment, intent.
AutoModelForCausalLM -- LM head for next-token prediction. For text generation.
attention_mask -- 1 = real token, 0 = padding. Always pass to model.
AutoModelForSequenceClassification -- adds classification head on [CLS]. For sentiment, intent.
AutoModelForCausalLM -- LM head for next-token prediction. For text generation.
attention_mask -- 1 = real token, 0 = padding. Always pass to model.
6. Generating Embeddings
Get [CLS] Token Embedding from BERT
import torch
from transformers import AutoModel, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
model.eval()
text = "User prefers jazz music in the evening."
tokens = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=128)
with torch.no_grad():
outputs = model(**tokens)
cls_embedding =outputs.last_hidden_state[:, 0, :] # [CLS]
print(cls_embedding.shape) # (1, 768)
Mean-Pooled Sentence Embedding (Proper Way)
import torch
from transformers import AutoModel, AutoTokenizer
def mean_pooling(model_output, attention_mask):
"""Mean pooling ignoring padding tokens."""
hidden = model_output.last_hidden_state # (batch, seq_len, dim)
mask = attention_mask.unsqueeze(-1).float() # (batch, seq_len, 1)
mask = mask.expand(hidden.size()) # (batch, seq_len, dim)
summed = torch.sum(hidden * mask, dim=1) # (batch, dim)
counts = torch.clamp(mask.sum(dim=1), min=1e-9) # (batch, dim)
return summed / counts
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
model.eval()
text = "Schedule a meeting with the design team."
tokens = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=128)
with torch.no_grad():
outputs = model(**tokens)
embedding = mean_pooling(outputs, tokens["attention_mask"])
print(embedding.shape) # (1, 768)
Batch Embedding with torch.no_grad()
import torch
from transformers import AutoModel, AutoTokenizer
def embed_batch(texts, model, tokenizer, max_length=128, device="cpu"):
"""Embed texts using mean pooling. Returns (N, hidden_dim)."""
tokens = tokenizer(texts, return_tensors="pt", padding=True, truncation=True, max_length=max_length)
tokens = tokens.to(device)
model.to(device)
model.eval()
with torch.no_grad():
outputs = model(**tokens)
# Mean pooling: average token embeddings, ignoring padding
hidden = outputs.last_hidden_state # (N, seq_len, dim)
mask = tokens["attention_mask"].unsqueeze(-1).float() # (N, seq_len, 1)
mask = mask.expand(hidden.size()) # (N, seq_len, dim)
summed = torch.sum(hidden * mask, dim=1) # (N, dim)
counts = torch.clamp(mask.sum(dim=1), min=1e-9) # (N, dim)
embeddings = summed / counts
# L2 normalize
return torch.nn.functional.normalize(embeddings, p=2, dim=1)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModel.from_pretrained("bert-base-uncased")
texts = ["Turn off the lights", "Set alarm for 7am", "What is the weather?"]
embs = embed_batch(texts, model, tokenizer)
print(embs.shape) # (3, 768)
Using sentence-transformers .encode()
from sentence_transformers import SentenceTransformer
import torch
model = SentenceTransformer("all-MiniLM-L6-v2")
texts =[
"Play relaxing music",
"Navigate to the nearest coffee shop",
"Read my unread messages",
]
embeddings =model.encode(
texts,
convert_to_tensor=True,
normalize_embeddings=True,
batch_size=32,
show_progress_bar=False,
)
print(embeddings.shape) # (3, 384)
[CLS] vs Mean Pooling vs Max Pooling
[CLS] token -- fast, single vector. Works well when the model was pre-trained with [CLS] as a sentence-level representation (BERT's NSP). Can be noisy for retrieval.
Mean pooling -- average all token embeddings (excluding padding). Generally the best default for sentence embeddings. Used by sentence-transformers.
Max pooling -- take the max across the sequence dimension for each hidden dim. Can capture salient features but loses ordering information. Rarely the best choice.
[CLS] token -- fast, single vector. Works well when the model was pre-trained with [CLS] as a sentence-level representation (BERT's NSP). Can be noisy for retrieval.
Mean pooling -- average all token embeddings (excluding padding). Generally the best default for sentence embeddings. Used by sentence-transformers.
Max pooling -- take the max across the sequence dimension for each hidden dim. Can capture salient features but loses ordering information. Rarely the best choice.
7. Similarity Search with Embeddings
Cosine Similarity with torch.nn.functional
import torch
import torch.nn.functional as F
def pairwise_cosine_similarity(a, b):
"""a: (M, D), b: (N, D) -> (M, N) cosine similarities."""
a_norm = F.normalize(a, p=2, dim=1) # (M, D)
b_norm = F.normalize(b, p=2, dim=1) # (N, D)
return torch.mm(a_norm, b_norm.t()) # (M, N)
query = torch.randn(1, 768) # 1 query, 768-dim
database = torch.randn(1000, 768) # 1000 docs, 768-dim
similarities = pairwise_cosine_similarity(query, database) # (1, 1000)
top_k_values, top_k_indices = torch.topk(similarities, k=5, dim=1) # (1, 5)
print(top_k_indices)
Build a Simple FAISS Index (Flat L2 and IVF)
import numpy as np
import faiss
dim = 384
num_vectors = 100_000
embeddings = np.random.randn(num_vectors, dim).astype("float32") # (100K, 384)
faiss.normalize_L2(embeddings)
# Flat index (exact brute-force search)
flat_index = faiss.IndexFlatIP(dim)
flat_index.add(embeddings)
# IVF index (approximate, partitions into Voronoi cells)
nlist = 256 # number of Voronoi cells
quantizer = faiss.IndexFlatIP(dim)
ivf_index = faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT)
ivf_index.train(embeddings)
ivf_index.add(embeddings)
ivf_index.nprobe = 16 # search 16 of 256 cells
query = np.random.randn(1, dim).astype("float32") # (1, 384)
faiss.normalize_L2(query)
distances, indices = flat_index.search(query, k=5) # distances: (1, 5), indices: (1, 5)
print(f"Flat results: {indices[0]}")
distances, indices = ivf_index.search(query, k=5)
print(f"IVF results: {indices[0]}")
Approximate Nearest Neighbor with FAISS (HNSW)
import numpy as np
import faiss
dim = 384
num_vectors = 100_000
embeddings = np.random.randn(num_vectors, dim).astype("float32") # (100K, 384)
faiss.normalize_L2(embeddings)
hnsw_index = faiss.IndexHNSWFlat(dim, 32) # M=32 neighbors per node
hnsw_index.hnsw.efConstruction = 200
hnsw_index.hnsw.efSearch = 64
hnsw_index.metric_type = faiss.METRIC_INNER_PRODUCT
hnsw_index.add(embeddings)
query = np.random.randn(1, dim).astype("float32") # (1, 384)
faiss.normalize_L2(query)
distances, indices = hnsw_index.search(query, k=10) # (1, 10), (1, 10)
print(f"HNSW top-10: {indices[0]}")
FAISS Index Types -- Trade-offs for On-Device
IndexFlatIP / IndexFlatL2 -- exact search, O(N) per query. Perfect for <100K vectors. No training needed.
IndexIVFFlat -- partitions space into Voronoi cells. Much faster search, slight accuracy loss. Needs training. Good for 100K-10M vectors.
IndexHNSWFlat -- graph-based. Excellent recall, fast search, but high memory (stores full vectors + graph). Good for on-device if memory allows.
IndexIVFPQ -- IVF + product quantization. Compresses vectors heavily (e.g., 384 floats -> 48 bytes). Best for on-device with tight RAM constraints on Galaxy phones.
IndexFlatIP / IndexFlatL2 -- exact search, O(N) per query. Perfect for <100K vectors. No training needed.
IndexIVFFlat -- partitions space into Voronoi cells. Much faster search, slight accuracy loss. Needs training. Good for 100K-10M vectors.
IndexHNSWFlat -- graph-based. Excellent recall, fast search, but high memory (stores full vectors + graph). Good for on-device if memory allows.
IndexIVFPQ -- IVF + product quantization. Compresses vectors heavily (e.g., 384 floats -> 48 bytes). Best for on-device with tight RAM constraints on Galaxy phones.
8. Training Loop Essentials
Custom Dataset + DataLoader
import torch
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer
class TextDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_length=128):
self.encodings = tokenizer(texts, padding="max_length", truncation=True,
max_length=max_length, return_tensors="pt")
self.labels = torch.tensor(labels, dtype=torch.long)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
# returns input_ids (max_length,), attention_mask (max_length,), label (scalar)
return self.encodings["input_ids"][idx], self.encodings["attention_mask"][idx], self.labels[idx]
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
dataset = TextDataset(["Set alarm", "Play jazz", "Weather?"], [0, 1, 2], tokenizer)
loader = DataLoader(dataset, batch_size=2, shuffle=True)
Training Loop with Validation and Early Stopping
import torch
import torch.nn as nn
def train_model(model, train_loader, val_loader, num_epochs=10, lr=2e-5, patience=3, device="cuda"):
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
best_val_loss = float("inf")
patience_counter = 0
for epoch in range(num_epochs):
# Train
model.train()
train_loss = 0.0
for input_ids, attention_mask, labels in train_loader: # (B, seq_len), (B, seq_len), (B,)
input_ids, attention_mask, labels = input_ids.to(device), attention_mask.to(device), labels.to(device)
optimizer.zero_grad()
logits = model(input_ids, attention_mask) # (B, num_classes)
loss = criterion(logits, labels) # scalar
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
train_loss += loss.item()
# Validate
model.eval()
val_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for input_ids, attention_mask, labels in val_loader:
input_ids, attention_mask, labels = input_ids.to(device), attention_mask.to(device), labels.to(device)
logits = model(input_ids, attention_mask)
val_loss += criterion(logits, labels).item()
correct += (logits.argmax(-1) == labels).sum().item()
total += labels.size(0)
avg_train = train_loss / len(train_loader)
avg_val = val_loss / len(val_loader)
print(f"Epoch {epoch+1}: train={avg_train:.4f} val={avg_val:.4f} acc={correct/total:.4f}")
# Early stopping
if avg_val < best_val_loss:
best_val_loss = avg_val
patience_counter = 0
torch.save(model.state_dict(), "best_model.pt")
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Early stopping at epoch {epoch+1}")
break
model.load_state_dict(torch.load("best_model.pt"))
return model
Training loop pattern: zero_grad -> forward -> loss -> backward -> clip_grad -> step. Always validate after each epoch. Early stopping prevents overfitting: track best val loss and stop if it hasn't improved for
patience epochs.
9. Two-Stage Retrieval (Bi-Encoder + Cross-Encoder)
Reranking with Cross-Encoder
import torch
from sentence_transformers import CrossEncoder
def retrieve_and_rerank(query, candidates, bi_encoder, cross_encoder, initial_k=20, final_k=5):
"""Two-stage retrieval: bi-encoder recall + cross-encoder reranking."""
# Stage 1: bi-encoder retrieves top candidates
query_emb = bi_encoder.encode([query], normalize_embeddings=True) # (1, d)
doc_embs = bi_encoder.encode(candidates, normalize_embeddings=True) # (N, d)
scores = (query_emb @ doc_embs.T)[0] # (N,)
top_indices = scores.argsort()[::-1][:initial_k] # (initial_k,)
shortlist = [candidates[i] for i in top_indices]
# Stage 2: cross-encoder reranks the shortlist
pairs = [[query, doc] for doc in shortlist] # list of [query, doc] pairs
rerank_scores = cross_encoder.predict(pairs) # (initial_k,) float scores
# Sort by rerank score descending
ranked = sorted(zip(shortlist, rerank_scores), key=lambda x: x[1], reverse=True)
return ranked[:final_k]
from sentence_transformers import SentenceTransformer
bi_encoder = SentenceTransformer("all-MiniLM-L6-v2")
cross_encoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
docs = ["User likes jazz", "User likes rock", "User has a cat", "User works at Samsung"]
results = retrieve_and_rerank("What music does the user enjoy?", docs, bi_encoder, cross_encoder, initial_k=3, final_k=2)
for doc, score in results:
print(f"{score:.4f} | {doc}")
Why Two-Stage Retrieval (Bi-Encoder + Cross-Encoder)
Bi-encoder -- encodes query and documents independently. Fast (precompute doc embeddings, just encode query at search time). But limited: no cross-attention between query and document tokens.
Cross-encoder -- concatenates query + document and processes jointly through the transformer. Much more accurate (full cross-attention), but O(N) per query since you cannot precompute.
Solution: Bi-encoder retrieves top-K candidates (e.g., 100 from 1M). Cross-encoder reranks those K. Best of both worlds: speed + accuracy.
Bi-encoder -- encodes query and documents independently. Fast (precompute doc embeddings, just encode query at search time). But limited: no cross-attention between query and document tokens.
Cross-encoder -- concatenates query + document and processes jointly through the transformer. Much more accurate (full cross-attention), but O(N) per query since you cannot precompute.
Solution: Bi-encoder retrieves top-K candidates (e.g., 100 from 1M). Cross-encoder reranks those K. Best of both worlds: speed + accuracy.
10. Evaluation Metrics
Computing Retrieval Metrics (Recall@K, NDCG)
import torch
import numpy as np
def recall_at_k(retrieved_indices, relevant_indices, k):
"""Recall@K: fraction of relevant items found in top-K.
retrieved_indices: (num_queries, num_retrieved) -- doc IDs per query
relevant_indices: list of lists -- ground truth relevant doc IDs per query
"""
recalls = []
for i in range(len(relevant_indices)):
top_k = set(retrieved_indices[i, :k].tolist()) # set of k doc IDs
relevant = set(relevant_indices[i])
if len(relevant) == 0:
continue
hit_count = len(top_k & relevant) # how many relevant found
recalls.append(hit_count / len(relevant))
return np.mean(recalls)
def ndcg_at_k(retrieved_indices, relevance_scores, k):
"""NDCG@K: normalized discounted cumulative gain.
retrieved_indices: (num_queries, num_retrieved)
relevance_scores: list of dicts {doc_id: relevance_score} per query
"""
ndcgs = []
for i in range(len(relevance_scores)):
# Compute DCG from retrieved order
dcg = 0.0
for rank in range(min(k, retrieved_indices.shape[1])):
doc_id = retrieved_indices[i, rank].item()
rel = relevance_scores[i].get(doc_id, 0.0)
dcg += (2**rel - 1) / np.log2(rank + 2)
# Compute ideal DCG (best possible ordering)
ideal_rels = sorted(relevance_scores[i].values(), reverse=True)[:k]
idcg = 0.0
for j, r in enumerate(ideal_rels):
idcg += (2**r - 1) / np.log2(j + 2)
ndcgs.append(dcg / idcg if idcg > 0 else 0.0)
return np.mean(ndcgs)
retrieved = torch.tensor([[3, 1, 4, 0, 2], [1, 0, 3, 2, 4]])
relevant = [[1, 3], [0, 1]]
print(f"Recall@3: {recall_at_k(retrieved, relevant, k=3):.4f}")
rel_scores = [{1: 2.0, 3: 1.0, 4: 0.5}, {0: 2.0, 1: 1.0}]
print(f"NDCG@3: {ndcg_at_k(retrieved, rel_scores, k=3):.4f}")
Perplexity Computation
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
def compute_perplexity(model, tokenizer, text, device="cpu"):
"""Perplexity of text under a causal LM. Lower = more confident."""
model.eval()
model.to(device)
encodings = tokenizer(text, return_tensors="pt").to(device) # input_ids: (1, seq_len)
with torch.no_grad():
outputs = model(**encodings, labels=encodings["input_ids"])
neg_log_likelihood = outputs.loss # scalar -- avg cross-entropy over tokens
return torch.exp(neg_log_likelihood).item() # scalar
tokenizer = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2")
text = "Samsung Bixby is a voice assistant that remembers your preferences."
ppl = compute_perplexity(model, tokenizer, text)
print(f"Perplexity: {ppl:.2f}")
Recall@K -- did we find the relevant items in the top K? NDCG -- are relevant items ranked higher? Both are standard for evaluating retrieval/memory systems. Perplexity measures how confident the LM is in its predictions (lower = better).