emosense / model /train.py
Harsh-1611's picture
Initial commit - EmoSense project
e1b0a04
Raw
History Blame Contribute Delete
25.5 kB
"""
Assignment 2 β€” GoEmotions Multi-Label Classifier
UPGRADED Training Script v2
ROOT CAUSE FIXES vs previous run (Macro F1 stuck at 0.44):
1. HEAD OVERRIDES PRETRAINED KNOWLEDGE — The old head (hidden→hidden/2→28)
with 10x LR was destroying the fine-tuned representations. Now using
a clean linear classifier on CLS + mean-pool concatenation.
2. LEARNING RATE β€” 1e-5 with grad_accum=4 was too low. Now: 2e-5
encoder / 1e-4 head, accum=2.
3. FOCAL LOSS GAMMA=2 β€” over-penalized easy classes, starved rare ones.
Replaced with Asymmetric Loss (ASL) which is SOTA for multi-label
imbalanced classification (ICCV 2021).
4. LABEL SMOOTHING ON POSITIVES β€” 0.05 smoothing was hurting rare
emotion recall since those positives are already scarce.
5. POOLING β€” CLS-only pooling misses context. Now: concat CLS + mean pool.
6. NO LAYER-WISE LR DECAY β€” all encoder layers used same LR. Now: LLRD
so top layers learn 10x faster than bottom layers.
7. EPOCHS TOO FEW β€” 6 epochs not enough. Now: 10 with patience=4.
8. NO OVERSAMPLING β€” rare classes (grief: 96 samples) were almost
invisible. Now: WeightedRandomSampler oversamples rare-label examples.
Expected result: Macro F1 0.55-0.60 (vs 0.44 before)
Run on Kaggle T4 GPU:
python train.py
Outputs:
model/best_model/ - HuggingFace checkpoint
model/thresholds.npy
model/training_results.json
"""
import os, json, warnings, re, html, math
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
from torch.optim import AdamW
from torch.amp import autocast, GradScaler
from transformers import AutoTokenizer, AutoModel, get_cosine_schedule_with_warmup
from datasets import load_dataset
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.metrics import f1_score, precision_score, recall_score, hamming_loss
warnings.filterwarnings("ignore")
# ════════════════════════════════════════════════════════════════
# CONFIG
# ════════════════════════════════════════════════════════════════
CFG = {
# Model
"model_name": "SamLowe/roberta-base-go_emotions",
"fallback": "roberta-base",
"max_length": 128,
# Training
"epochs": 10,
"batch_size": 32, # increased from 16 β€” better gradient estimates
"grad_accum_steps": 2, # effective batch = 64
"encoder_lr": 2e-5, # encoder learning rate
"head_lr": 1e-4, # head LR β€” higher, separate param group
"llrd_factor": 0.9, # layer-wise LR decay per layer (top→bottom)
"weight_decay": 0.01,
"warmup_ratio": 0.06,
"max_grad_norm": 1.0,
"fp16": True,
# Loss (Asymmetric Loss)
"asl_gamma_pos": 0.0, # no down-weighting for positives
"asl_gamma_neg": 4.0, # hard down-weighting for easy negatives
"asl_clip": 0.05, # probability margin shift
# Stopping & threshold
"patience": 4,
"eval_threshold": 0.5, # used during training; tuned after on val
# Paths
"output_dir": "model/best_model",
"thresh_path": "model/thresholds.npy",
"results_path": "model/training_results.json",
}
EMOTIONS = [
"admiration", "amusement", "anger", "annoyance", "approval",
"caring", "confusion", "curiosity", "desire", "disappointment",
"disapproval", "disgust", "embarrassment", "excitement", "fear",
"gratitude", "grief", "joy", "love", "nervousness",
"optimism", "pride", "realization", "relief", "remorse",
"sadness", "surprise", "neutral"
]
NUM_LABELS = len(EMOTIONS)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device : {DEVICE}")
print(f"Batch : {CFG['batch_size']} x accum {CFG['grad_accum_steps']} "
f"= effective {CFG['batch_size'] * CFG['grad_accum_steps']}")
# ════════════════════════════════════════════════════════════════
# DATA
# ════════════════════════════════════════════════════════════════
def clean_text(text):
text = html.unescape(text)
text = re.sub(r"^>+\s?", "", text, flags=re.MULTILINE)
text = re.sub(r"http\S+|www\.\S+", "[URL]", text)
text = re.sub(r"\[deleted\]|\[removed\]", "", text)
text = re.sub(r"r/\w+|u/\w+", "", text)
text = re.sub(r"([!?])\1{2,}", r"\1\1", text)
text = re.sub(r"(.)\1{2,}", r"\1\1", text)
text = re.sub(r"[^\x00-\x7F]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def load_data():
print("\nLoading GoEmotions ...")
ds = load_dataset("google-research-datasets/go_emotions", "simplified")
splits = {}
for s in ["train", "validation", "test"]:
df = ds[s].to_pandas()
df["text"] = df["text"].apply(clean_text)
df = df[df["text"].str.len() > 2].reset_index(drop=True)
splits[s] = df
print(f" Train {len(splits['train'])} | Val {len(splits['validation'])} | Test {len(splits['test'])}")
return splits["train"], splits["validation"], splits["test"]
def binarize(train_df, val_df, test_df):
mlb = MultiLabelBinarizer(classes=list(range(NUM_LABELS)))
mlb.fit(train_df["labels"])
y_tr = mlb.transform(train_df["labels"]).astype(np.float32)
y_va = mlb.transform(val_df["labels"]).astype(np.float32)
y_te = mlb.transform(test_df["labels"]).astype(np.float32)
return y_tr, y_va, y_te, mlb
# ════════════════════════════════════════════════════════════════
# DATASET β€” with optional word-dropout augmentation
# ════════════════════════════════════════════════════════════════
class EmotionDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_len, augment=False):
self.texts = list(texts)
self.labels = torch.tensor(labels, dtype=torch.float32)
self.tokenizer = tokenizer
self.max_len = max_len
self.augment = augment
def __len__(self):
return len(self.labels)
def _word_dropout(self, text, p=0.10):
words = text.split()
if len(words) <= 3:
return text
keep = [w for w in words if np.random.rand() > p]
return " ".join(keep) if keep else text
def __getitem__(self, idx):
text = self.texts[idx]
if self.augment and np.random.rand() < 0.3:
text = self._word_dropout(text)
enc = self.tokenizer(
text, max_length=self.max_len,
padding="max_length", truncation=True, return_tensors="pt"
)
return {
"input_ids": enc["input_ids"].squeeze(0),
"attention_mask": enc["attention_mask"].squeeze(0),
"labels": self.labels[idx],
}
# ════════════════════════════════════════════════════════════════
# MODEL β€” CLS + Mean pooling, clean linear head
# ════════════════════════════════════════════════════════════════
class MeanPooling(nn.Module):
def forward(self, last_hidden, attention_mask):
mask = attention_mask.unsqueeze(-1).float()
summed = (last_hidden * mask).sum(1)
count = mask.sum(1).clamp(min=1e-9)
return summed / count
class EmotionClassifier(nn.Module):
def __init__(self, model_name, num_labels=28, dropout=0.1):
super().__init__()
self.encoder = AutoModel.from_pretrained(model_name)
hidden = self.encoder.config.hidden_size
self.pool = MeanPooling()
# CLS + mean concatenated β†’ richer representation
self.norm = nn.LayerNorm(hidden * 2)
self.drop = nn.Dropout(dropout)
self.classifier = nn.Linear(hidden * 2, num_labels)
nn.init.normal_(self.classifier.weight, std=0.02)
nn.init.zeros_(self.classifier.bias)
def forward(self, input_ids, attention_mask):
out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
cls = out.last_hidden_state[:, 0, :]
mean = self.pool(out.last_hidden_state, attention_mask)
feat = torch.cat([cls, mean], dim=-1)
feat = self.norm(feat)
return self.classifier(self.drop(feat))
# ════════════════════════════════════════════════════════════════
# LOSS β€” Asymmetric Loss (ASL)
# Paper: "Asymmetric Loss For Multi-Label Classification" ICCV 2021
# Down-weights easy negatives heavily (gamma_neg=4) while treating
# positives gently (gamma_pos=0) β€” ideal for 185x imbalance.
# ════════════════════════════════════════════════════════════════
class AsymmetricLoss(nn.Module):
def __init__(self, gamma_neg=4, gamma_pos=0, clip=0.05, eps=1e-8):
super().__init__()
self.gamma_neg = gamma_neg
self.gamma_pos = gamma_pos
self.clip = clip
self.eps = eps
def forward(self, logits, targets):
probs = torch.sigmoid(logits)
probs_neg = (probs - self.clip).clamp(min=0) # margin shift
loss_pos = targets * torch.log(probs.clamp(min=self.eps))
loss_neg = (1 - targets) * torch.log((1 - probs_neg).clamp(min=self.eps))
p_t = probs * targets + probs_neg * (1 - targets)
focal_pos = (1 - p_t) ** self.gamma_pos
focal_neg = p_t ** self.gamma_neg
return -(focal_pos * loss_pos + focal_neg * loss_neg).mean()
# ════════════════════════════════════════════════════════════════
# OPTIMIZER β€” Layer-wise LR Decay (LLRD)
# Layer 11 (top) β†’ encoder_lr; Layer 0 (bottom) β†’ encoder_lr * 0.9^11
# ════════════════════════════════════════════════════════════════
def build_optimizer(model, encoder_lr, head_lr, llrd, weight_decay):
no_decay = ["bias", "LayerNorm.weight", "layer_norm.weight"]
named_params = list(model.encoder.named_parameters())
param_groups = []
# Embeddings β€” lowest LR
emb_lr = encoder_lr * (llrd ** 13)
for wd in (False, True):
p = [v for n, v in named_params
if "embeddings" in n and (any(nd in n for nd in no_decay) == wd)]
if p:
param_groups.append({
"params": p,
"lr": emb_lr,
"weight_decay": 0.0 if wd else weight_decay
})
# Transformer layers
num_layers = model.encoder.config.num_hidden_layers
for layer_idx in range(num_layers):
layer_lr = encoder_lr * (llrd ** (num_layers - layer_idx - 1))
for wd in (False, True):
p = [v for n, v in named_params
if (f"layer.{layer_idx}." in n or f"layers.{layer_idx}." in n)
and (any(nd in n for nd in no_decay) == wd)]
if p:
param_groups.append({
"params": p,
"lr": layer_lr,
"weight_decay": 0.0 if wd else weight_decay
})
# Pooler
pooler = [v for n, v in named_params if "pooler" in n]
if pooler:
param_groups.append({"params": pooler, "lr": encoder_lr, "weight_decay": weight_decay})
# Head (norm + dropout + classifier + mean pooler)
head_params = (list(model.norm.parameters()) +
list(model.drop.parameters()) +
list(model.classifier.parameters()) +
list(model.pool.parameters()))
param_groups.append({"params": head_params, "lr": head_lr, "weight_decay": weight_decay})
return AdamW(param_groups, eps=1e-8)
# ════════════════════════════════════════════════════════════════
# THRESHOLD TUNING β€” coarse-to-fine per class
# ════════════════════════════════════════════════════════════════
def tune_thresholds(y_true, y_probs):
best = np.full(NUM_LABELS, 0.5)
for i in range(NUM_LABELS):
col = y_probs[:, i]
best_f1, best_t = 0.0, 0.5
# Coarse pass
for t in np.arange(0.05, 0.95, 0.05):
f1 = f1_score(y_true[:, i], (col >= t).astype(int), zero_division=0)
if f1 > best_f1:
best_f1, best_t = f1, t
# Fine pass
for t in np.arange(max(0.01, best_t - 0.06), min(0.99, best_t + 0.06), 0.01):
f1 = f1_score(y_true[:, i], (col >= t).astype(int), zero_division=0)
if f1 > best_f1:
best_f1, best_t = f1, t
best[i] = best_t
return best
# ════════════════════════════════════════════════════════════════
# EVALUATION
# ════════════════════════════════════════════════════════════════
def evaluate(y_true, y_probs, thresholds=None, name=""):
if thresholds is None:
thresholds = np.full(NUM_LABELS, 0.5)
y_pred = (y_probs >= thresholds).astype(int)
per_class = f1_score(y_true, y_pred, average=None, zero_division=0)
bottom5 = [(EMOTIONS[i], round(per_class[i], 3)) for i in np.argsort(per_class)[:5]]
top5 = [(EMOTIONS[i], round(per_class[i], 3)) for i in np.argsort(per_class)[-5:]]
results = {
"model": name,
"macro_f1": round(f1_score(y_true, y_pred, average="macro", zero_division=0), 4),
"micro_f1": round(f1_score(y_true, y_pred, average="micro", zero_division=0), 4),
"weighted_f1": round(f1_score(y_true, y_pred, average="weighted", zero_division=0), 4),
"precision": round(precision_score(y_true, y_pred, average="macro", zero_division=0), 4),
"recall": round(recall_score(y_true, y_pred, average="macro", zero_division=0), 4),
"hamming_loss": round(hamming_loss(y_true, y_pred), 4),
"per_class_f1": {e: round(float(per_class[i]), 4) for i, e in enumerate(EMOTIONS)},
}
print(f"\n{'='*55}\nResults β€” {name}\n{'='*55}")
for k, v in results.items():
if k not in ("model", "per_class_f1"):
print(f" {k:15s}: {v}")
print(f"\n Bottom-5: {bottom5}")
print(f" Top-5 : {top5}")
return results
# ════════════════════════════════════════════════════════════════
# MAIN
# ════════════════════════════════════════════════════════════════
def train():
os.makedirs(CFG["output_dir"], exist_ok=True)
train_df, val_df, test_df = load_data()
y_train, y_val, y_test, mlb = binarize(train_df, val_df, test_df)
# Load model
try:
tokenizer = AutoTokenizer.from_pretrained(CFG["model_name"])
model = EmotionClassifier(CFG["model_name"], NUM_LABELS).to(DEVICE).float()
used = CFG["model_name"]
except Exception as e:
print(f"Primary model failed ({e}), using fallback.")
tokenizer = AutoTokenizer.from_pretrained(CFG["fallback"])
model = EmotionClassifier(CFG["fallback"], NUM_LABELS).to(DEVICE).float()
used = CFG["fallback"]
print(f"\nModel : {used}")
print(f"Parameters : {sum(p.numel() for p in model.parameters()):,}")
# WeightedRandomSampler β€” oversample rare-label examples
label_counts = y_train.sum(axis=0)
sample_weight = np.ones(len(y_train))
for i in range(len(y_train)):
active = np.where(y_train[i] == 1)[0]
if len(active) > 0:
rarest = label_counts[active].min()
sample_weight[i] = label_counts.max() / max(rarest, 1)
sampler = WeightedRandomSampler(
torch.tensor(sample_weight, dtype=torch.float),
num_samples=len(y_train), replacement=True
)
train_ds = EmotionDataset(train_df["text"], y_train, tokenizer, CFG["max_length"], augment=True)
val_ds = EmotionDataset(val_df["text"], y_val, tokenizer, CFG["max_length"], augment=False)
test_ds = EmotionDataset(test_df["text"], y_test, tokenizer, CFG["max_length"], augment=False)
train_loader = DataLoader(train_ds, batch_size=CFG["batch_size"],
sampler=sampler, num_workers=2, pin_memory=True)
val_loader = DataLoader(val_ds, batch_size=CFG["batch_size"] * 2,
shuffle=False, num_workers=2, pin_memory=True)
test_loader = DataLoader(test_ds, batch_size=CFG["batch_size"] * 2,
shuffle=False, num_workers=2, pin_memory=True)
criterion = AsymmetricLoss(
gamma_neg=CFG["asl_gamma_neg"],
gamma_pos=CFG["asl_gamma_pos"],
clip=CFG["asl_clip"]
)
optimizer = build_optimizer(
model, CFG["encoder_lr"], CFG["head_lr"],
CFG["llrd_factor"], CFG["weight_decay"]
)
steps_per_epoch = math.ceil(len(train_loader) / CFG["grad_accum_steps"])
total_steps = steps_per_epoch * CFG["epochs"]
warmup_steps = int(total_steps * CFG["warmup_ratio"])
scheduler = get_cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps)
scaler = GradScaler(device="cuda", enabled=(CFG["fp16"] and DEVICE.type == "cuda"))
print(f"\nSteps/epoch : {steps_per_epoch}")
print(f"Total steps : {total_steps} | Warmup: {warmup_steps}")
print(f"Encoder LR : {CFG['encoder_lr']} | Head LR: {CFG['head_lr']}")
print(f"Loss : AsymmetricLoss gamma_neg={CFG['asl_gamma_neg']}")
print(f"Sampler : WeightedRandomSampler (rare-class boost)")
best_f1, patience_count = 0.0, 0
best_val_probs = best_val_labels = None
history = []
print(f"\n{'─'*55}")
print(f"Training β€” {CFG['epochs']} epochs max | patience={CFG['patience']}")
print(f"{'─'*55}")
for epoch in range(CFG["epochs"]):
# ── Train ─────────────────────────────────────────────
model.train()
train_loss = 0.0
optimizer.zero_grad()
fp16_on = CFG["fp16"] and DEVICE.type == "cuda"
for step, batch in enumerate(train_loader):
ids = batch["input_ids"].to(DEVICE)
mask = batch["attention_mask"].to(DEVICE)
lbls = batch["labels"].to(DEVICE)
with autocast(device_type=DEVICE.type, enabled=fp16_on):
loss = criterion(model(ids, mask), lbls) / CFG["grad_accum_steps"]
scaler.scale(loss).backward()
if (step + 1) % CFG["grad_accum_steps"] == 0 or (step + 1) == len(train_loader):
scaler.unscale_(optimizer)
nn.utils.clip_grad_norm_(model.parameters(), CFG["max_grad_norm"])
scaler.step(optimizer)
scaler.update()
scheduler.step()
optimizer.zero_grad()
train_loss += loss.item() * CFG["grad_accum_steps"]
# ── Validate ──────────────────────────────────────────
model.eval()
vp_list, vl_list, val_loss = [], [], 0.0
with torch.no_grad():
for batch in val_loader:
ids = batch["input_ids"].to(DEVICE)
mask = batch["attention_mask"].to(DEVICE)
lbls = batch["labels"].to(DEVICE)
with autocast(device_type=DEVICE.type, enabled=fp16_on):
logits = model(ids, mask)
val_loss += criterion(logits, lbls).item()
vp_list.append(torch.sigmoid(logits).cpu().numpy())
vl_list.append(lbls.cpu().numpy())
vp = np.vstack(vp_list)
vl = np.vstack(vl_list)
macro_f1 = f1_score(vl, (vp >= CFG["eval_threshold"]).astype(int),
average="macro", zero_division=0)
head_lr = optimizer.param_groups[-1]["lr"]
print(f" Epoch {epoch+1:02d}/{CFG['epochs']} | "
f"TLoss {train_loss/len(train_loader):.4f} | "
f"VLoss {val_loss/len(val_loader):.4f} | "
f"MacroF1 {macro_f1:.4f} | LR {head_lr:.2e}")
history.append({
"epoch": epoch + 1,
"train_loss": round(train_loss / len(train_loader), 4),
"val_loss": round(val_loss / len(val_loader), 4),
"macro_f1": round(macro_f1, 4),
})
if macro_f1 > best_f1:
best_f1, patience_count = macro_f1, 0
model.encoder.save_pretrained(CFG["output_dir"])
tokenizer.save_pretrained(CFG["output_dir"])
torch.save({
"norm": model.norm.state_dict(),
"drop": model.drop.state_dict(),
"classifier": model.classifier.state_dict(),
}, os.path.join(CFG["output_dir"], "head.pt"))
best_val_probs = vp.copy()
best_val_labels = vl.copy()
print(f" βœ… New best F1={best_f1:.4f} [saved]")
else:
patience_count += 1
print(f" No improvement ({patience_count}/{CFG['patience']})")
if patience_count >= CFG["patience"]:
print(f" Early stopping at epoch {epoch+1}")
break
# ── Threshold tuning ──────────────────────────────────────
print("\nTuning per-class thresholds (coarse→fine) ...")
thresholds = tune_thresholds(best_val_labels, best_val_probs)
np.save(CFG["thresh_path"], thresholds)
print(f" Mean : {thresholds.mean():.3f}")
print(f" Range : [{thresholds.min():.2f}, {thresholds.max():.2f}]")
rare = ["grief", "pride", "nervousness", "relief", "embarrassment"]
for e in rare:
i = EMOTIONS.index(e)
print(f" [{e}] threshold = {thresholds[i]:.2f}")
# ── Test evaluation ────────────────────────────────────────
print("\nLoading best checkpoint ...")
model.encoder = AutoModel.from_pretrained(CFG["output_dir"]).to(DEVICE)
ckpt = torch.load(os.path.join(CFG["output_dir"], "head.pt"), map_location=DEVICE)
model.norm.load_state_dict(ckpt["norm"])
model.drop.load_state_dict(ckpt["drop"])
model.classifier.load_state_dict(ckpt["classifier"])
model.eval()
test_probs = []
with torch.no_grad():
for batch in test_loader:
ids = batch["input_ids"].to(DEVICE)
mask = batch["attention_mask"].to(DEVICE)
test_probs.append(torch.sigmoid(model(ids, mask)).cpu().numpy())
test_results = evaluate(
y_test, np.vstack(test_probs), thresholds,
name="RoBERTa-GoEmotions-ASL-LLRD-v2"
)
a1_best = 0.5245
output = {
"model_used": used,
"config": CFG,
"history": history,
"test_results": test_results,
"assignment1_best": {"model": "roberta-base", "macro_f1": a1_best},
"improvement": round(test_results["macro_f1"] - a1_best, 4),
}
with open(CFG["results_path"], "w") as f:
json.dump(output, f, indent=2)
print(f"\n{'='*55}")
print(f" Macro F1 (test) : {test_results['macro_f1']}")
print(f" Micro F1 (test) : {test_results['micro_f1']}")
print(f" A1 best : {a1_best}")
print(f" Improvement : +{output['improvement']:.4f}")
print(f" Saved to : {CFG['output_dir']}/")
print(f"{'='*55}")
if __name__ == "__main__":
train()