"""Improved training script with class balancing and GoEmotions dataset.""" import numpy as np from pathlib import Path from collections import Counter import pandas as pd from datasets import load_dataset, DatasetDict, Dataset from sklearn.model_selection import train_test_split from transformers import ( Trainer, TrainingArguments, AutoTokenizer, AutoModelForSequenceClassification, EarlyStoppingCallback ) from sklearn.metrics import accuracy_score, f1_score from sklearn.utils.class_weight import compute_class_weight import torch from .config import config # Mapping from GoEmotions 28 labels to our 8 core emotions GOEMOTIONS_MAPPING = { # Joy/Happiness cluster 'joy': 'joy', 'amusement': 'joy', 'excitement': 'joy', 'optimism': 'joy', 'pride': 'joy', 'relief': 'joy', 'admiration': 'joy', # Sadness cluster 'sadness': 'sadness', 'grief': 'sadness', 'disappointment': 'sadness', 'remorse': 'sadness', # Anger cluster 'anger': 'anger', 'annoyance': 'anger', 'disapproval': 'anger', 'disgust': 'anger', # Fear cluster 'fear': 'fear', 'nervousness': 'fear', # Love cluster 'love': 'love', 'caring': 'love', 'desire': 'love', 'gratitude': 'love', # Surprise cluster 'surprise': 'surprise', 'realization': 'surprise', 'confusion': 'surprise', 'curiosity': 'surprise', # Neutral (skip or map to neutral) 'neutral': 'neutral', 'approval': 'neutral', 'embarrassment': 'neutral', } # Our target labels TARGET_LABELS = ['sadness', 'joy', 'love', 'anger', 'fear', 'surprise', 'neutral', 'sarcasm'] def compute_metrics(eval_pred): """Compute metrics for evaluation.""" logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return { "accuracy": round(accuracy_score(labels, predictions), 4), "f1_macro": round(f1_score(labels, predictions, average="macro", zero_division=0), 4), "f1_weighted": round(f1_score(labels, predictions, average="weighted", zero_division=0), 4), } class WeightedTrainer(Trainer): """Trainer with class weights for imbalanced datasets.""" def __init__(self, class_weights=None, *args, **kwargs): super().__init__(*args, **kwargs) self.class_weights = class_weights def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None): # By popping labels we prevent Deberta from internally calculating its own non-weighted loss. # This keeps the computational graph clean. labels = inputs.pop("labels", None) outputs = model(**inputs) logits = outputs.logits if labels is not None: if self.class_weights is not None: # Force weights and logits to float32 to prevent FP16/BF16 gradient convergence issues weight = torch.tensor(self.class_weights, device=logits.device, dtype=torch.float32) loss_fn = torch.nn.CrossEntropyLoss(weight=weight) else: loss_fn = torch.nn.CrossEntropyLoss() loss = loss_fn(logits.to(torch.float32), labels) else: loss = outputs.loss if hasattr(outputs, "loss") else None return (loss, outputs) if return_outputs else loss def prepare_goemotions_dataset(): """Load and prepare GoEmotions dataset with mapped labels.""" print("šŸ“¦ Loading GoEmotions dataset...") raw_dataset = load_dataset('google-research-datasets/go_emotions', 'simplified') # Get original label names original_labels = raw_dataset['train'].features['labels'].feature.names # Create label mapping label2id = {label: i for i, label in enumerate(TARGET_LABELS)} def map_emotions(example): """Map GoEmotions labels to our target labels.""" mapped_labels = [] for label_id in example['labels']: original_label = original_labels[label_id] if original_label in GOEMOTIONS_MAPPING: target_label = GOEMOTIONS_MAPPING[original_label] if target_label in label2id: mapped_labels.append(label2id[target_label]) # If no valid mapping, skip (return None which we filter later) if not mapped_labels: return {'label': -1, 'text': example['text']} # Use the first mapped label (most confident) return {'label': mapped_labels[0], 'text': example['text']} print("šŸ”„ Mapping emotions to target labels...") mapped_dataset = {} for split in ['train', 'validation', 'test']: mapped = raw_dataset[split].map(map_emotions, remove_columns=['labels', 'id']) # Filter out unmapped samples mapped = mapped.filter(lambda x: x['label'] != -1) mapped_dataset[split] = mapped return DatasetDict(mapped_dataset) def prepare_sarcasm_dataset(): """Load and prepare Kaggle SARC dataset for sarcasm.""" # Assuming user downloads the dataset to data/train-balanced-sarcasm.csv csv_path = config.data_dir / "train-balanced-sarcasm.csv" if not csv_path.exists(): print(f"āš ļø Sarcasm dataset not found at {csv_path}. Please download it from:") print(" https://www.kaggle.com/datasets/danofer/sarcasm") print(" Skipping sarcasm data...") return None print(f"šŸ“¦ Loading Kaggle Sarcasm dataset from {csv_path}...") # Load dataset, taking only sarcastic rows, and drop NA df = pd.read_csv(csv_path) df = df.dropna(subset=['comment', 'label']) # We only take actual sarcastic entries (label == 1), # to avoid muddying neutral/normal text df_sarcastic = df[df['label'] == 1].copy() # Cap it so we don't flood the model with 500k sarcasm samples compared to 59k normal # We will sample 25,000 sarcastic texts (roughly 30% of total dataset) if len(df_sarcastic) > 25000: df_sarcastic = df_sarcastic.sample(n=25000, random_state=42) # Convert to our schema (sarcasm is label index 7) # Strategy 4: Leverage [SEP] for context windows if 'parent_comment' in df_sarcastic.columns: df_sarcastic['text'] = df_sarcastic['parent_comment'].astype(str) + " [SEP] " + df_sarcastic['comment'].astype(str) else: df_sarcastic['text'] = df_sarcastic['comment'].astype(str) df_sarcastic['label'] = 7 # 'sarcasm' index in TARGET_LABELS # Split into train/val/test train_texts, temp_texts, train_labels, temp_labels = train_test_split( df_sarcastic['text'], df_sarcastic['label'], test_size=0.2, random_state=42 ) val_texts, test_texts, val_labels, test_labels = train_test_split( temp_texts, temp_labels, test_size=0.5, random_state=42 ) # Strategy 1: Contrast Data Augmentation contrast_data = [ "I love it when my tire pops on the highway", "I love spending 5 hours in traffic.", "Oh, fantastic! The server is down again.", "Great, another mandatory team-building exercise.", "I'm absolutely thrilled that my flight was canceled.", "Wow, you really outdid yourself this time. Breaking the production server on a Friday takes true talent.", "What a wonderful surprise, taking a pay cut.", "I just adore getting completely ignored.", "Best day ever, everything went wrong.", "I love getting stuck in the rain without an umbrella.", "I love it when people talk over me.", "It's just fantastic when my coffee spills all over my keyboard.", "Absolutely amazing how you managed to mess that up.", "Great job breaking the build.", "I'm so happy my alarm didn't go off today." ] train_texts_list = train_texts.tolist() + contrast_data train_labels_list = train_labels.tolist() + [7] * len(contrast_data) sarc_datasets = { 'train': Dataset.from_dict({'text': train_texts_list, 'label': train_labels_list}), 'validation': Dataset.from_dict({'text': val_texts.tolist(), 'label': val_labels.tolist()}), 'test': Dataset.from_dict({'text': test_texts.tolist(), 'label': test_labels.tolist()}), } return DatasetDict(sarc_datasets) def prepare_combined_dataset(): """Combine dair-ai/emotion with GoEmotions for better coverage.""" print("šŸ“¦ Loading and combining datasets...") # Load dair-ai/emotion dair_dataset = load_dataset('dair-ai/emotion') dair_labels = ['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'] # Load GoEmotions go_dataset = prepare_goemotions_dataset() # dair-ai labels are already 0-5 for sadness, joy, love, anger, fear, surprise # which matches our TARGET_LABELS[0:6], so no remapping needed print("šŸ”— Combining datasets...") # Check if user has downloaded SARC try: sarc_dataset = prepare_sarcasm_dataset() except Exception as e: print(f"Error loading sarcasm dataset: {e}") sarc_dataset = None combined = {} for split in ['train', 'validation', 'test']: # Directly combine text and labels from both datasets # Convert to lists explicitly (newer datasets versions return Column objects) combined_text = list(dair_dataset[split]['text']) + list(go_dataset[split]['text']) combined_label = list(dair_dataset[split]['label']) + list(go_dataset[split]['label']) if sarc_dataset and split in sarc_dataset: combined_text += list(sarc_dataset[split]['text']) combined_label += list(sarc_dataset[split]['label']) combined_data = { 'text': combined_text, 'label': combined_label } combined[split] = Dataset.from_dict(combined_data).shuffle(seed=42) return DatasetDict(combined) def train( output_dir: Path = None, use_sample: bool = False, num_train_samples: int = None, use_goemotions: bool = True, combine_datasets: bool = False, use_class_weights: bool = True, ) -> str: """ Train the emotion classifier with improvements. Args: output_dir: Where to save the model use_sample: Use subset for quick testing num_train_samples: Limit training samples use_goemotions: Use GoEmotions dataset (larger, more diverse) combine_datasets: Combine GoEmotions with dair-ai/emotion use_class_weights: Apply class weights for imbalanced data Returns: Path to saved model """ from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Save to unique dir if user doesn't pass one manually if not output_dir: if use_sample: output_dir = config.model_dir / "sample_models" / f"emotion_classifier_sample_{timestamp}" else: output_dir = config.model_dir / f"emotion_classifier_{timestamp}" output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) # Load dataset if combine_datasets: dataset = prepare_combined_dataset() num_labels = 8 # Including neutral and sarcasm label_names = TARGET_LABELS elif use_goemotions: dataset = prepare_goemotions_dataset() num_labels = 8 # Including neutral and sarcasm label_names = TARGET_LABELS else: print(f"šŸ“¦ Loading dataset: {config.hf_dataset_name}") dataset = load_dataset(config.hf_dataset_name) num_labels = 6 label_names = list(config.label_names)[:6] # Subset for quick testing if use_sample: print("⚔ Using sample subset for quick testing...") dataset["train"] = dataset["train"].select(range(min(2000, len(dataset["train"])))) dataset["validation"] = dataset["validation"].select(range(min(500, len(dataset["validation"])))) dataset["test"] = dataset["test"].select(range(min(500, len(dataset["test"])))) if num_train_samples and num_train_samples < len(dataset["train"]): dataset["train"] = dataset["train"].select(range(num_train_samples)) print(f"\nšŸ“Š Dataset sizes:") print(f" Train: {len(dataset['train'])}") print(f" Validation: {len(dataset['validation'])}") print(f" Test: {len(dataset['test'])}") # Show class distribution train_labels = dataset['train']['label'] label_dist = Counter(train_labels) print(f"\nšŸ“ˆ Class distribution (train):") for label_id, count in sorted(label_dist.items()): if label_id < len(label_names): print(f" {label_names[label_id]:>10}: {count:>5} ({count/len(train_labels)*100:.1f}%)") sarcasm_train_prior = None if "sarcasm" in label_names: sarcasm_idx = label_names.index("sarcasm") sarcasm_train_prior = label_dist.get(sarcasm_idx, 0) / len(train_labels) print(f"\nšŸŽÆ Sarcasm training prior: {sarcasm_train_prior:.2%}") # Compute class weights class_weights = None if use_class_weights: print("\nāš–ļø Computing class weights for balancing...") unique_labels = sorted(set(train_labels)) class_weights = compute_class_weight( class_weight='balanced', classes=np.array(unique_labels), y=np.array(train_labels) ) print(f" Weights: {dict(zip([label_names[i] for i in unique_labels], class_weights.round(2)))}") # Load tokenizer print(f"\nšŸ”§ Loading tokenizer: {config.model_name}") tokenizer = AutoTokenizer.from_pretrained(config.model_name) # Tokenize with longer max_length for better context max_length = 256 # Increased from 128 for longer texts def tokenize_fn(examples): return tokenizer( examples["text"], padding="max_length", truncation=True, max_length=max_length ) print(f"šŸ”„ Tokenizing dataset (max_length={max_length})...") # Strategy 3: Trigger Word Masking import random import re TRIGGER_WORDS = ["love", "great", "amazing", "fantastic", "wonderful", "thrilled", "joy", "happy", "best"] def apply_masking(examples): # We only apply this to training text to force context over keywords masked_texts = [] for text in examples["text"]: for word in TRIGGER_WORDS: if random.random() < 0.15: text = re.sub(rf'\b{word}\b', "[MASK]", text, flags=re.IGNORECASE) masked_texts.append(text) examples["text"] = masked_texts return examples print("šŸŽ­ Applying trigger word masking to training set...") dataset["train"] = dataset["train"].map(apply_masking, batched=True) tokenized = dataset.map(tokenize_fn, batched=True, remove_columns=["text"]) tokenized.set_format("torch") # Load model print(f"🧠 Loading model: {config.model_name}") model = AutoModelForSequenceClassification.from_pretrained( config.model_name, num_labels=num_labels, id2label={i: label for i, label in enumerate(label_names)}, label2id={label: i for i, label in enumerate(label_names)}, ) if sarcasm_train_prior is not None and 0.0 < sarcasm_train_prior < 1.0: model.config.sarcasm_train_prior = float(sarcasm_train_prior) import torch # Strategy 2: Gradual Unfreezing / Differential Learning Rates print("🧠 Setting up differential learning rates...") head_params, body_params = [], [] for name, param in model.named_parameters(): if "classifier" in name or "pooler" in name: head_params.append(param) else: body_params.append(param) # DeBERTa transformer base gets 1e-6 to protect its understanding of language. # The new linear classifier gets 5e-5 to map those embeddings to our 8 specific labels quickly. optimizer_grouped_parameters = [ {"params": body_params, "lr": 1e-6}, {"params": head_params, "lr": 5e-5}, ] custom_optimizer = torch.optim.AdamW(optimizer_grouped_parameters, weight_decay=0.01, eps=1e-6) # Training arguments training_args = TrainingArguments( output_dir=str(output_dir), eval_strategy="epoch", save_strategy="epoch", learning_rate=2e-5, # Back down slightly per_device_train_batch_size=4, # Dropped from 16 to 4 to prevent VRAM spill over to system RAM under pure FP32 gradient_accumulation_steps=4, # 4x4 = 16 effective batch size mathematically identical to original per_device_eval_batch_size=8, num_train_epochs=5, warmup_ratio=0.1, weight_decay=0.01, adam_epsilon=1e-6, # CRITICAL: DeBERTa V3 AdamW requires this to avoid division by zero during early normalization load_best_model_at_end=True, metric_for_best_model="f1_macro", greater_is_better=True, logging_steps=10, report_to="none", fp16=False, bf16=False, # Disable BF16 entirely to guarantee purely stable FP32 gradients max_grad_norm=1.0, ) # Initialize trainer (with or without class weights) if use_class_weights and class_weights is not None: trainer = WeightedTrainer( class_weights=list(class_weights), model=model, args=training_args, train_dataset=tokenized["train"], eval_dataset=tokenized["validation"], compute_metrics=compute_metrics, callbacks=[EarlyStoppingCallback(early_stopping_patience=2)], optimizers=(custom_optimizer, None) ) else: trainer = Trainer( model=model, args=training_args, train_dataset=tokenized["train"], eval_dataset=tokenized["validation"], compute_metrics=compute_metrics, callbacks=[EarlyStoppingCallback(early_stopping_patience=2)], optimizers=(custom_optimizer, None) ) # Train print("\nšŸš€ Starting training with class balancing...") print("=" * 50) # Starting a fully fresh training run trainer.train() # Evaluate print("\nšŸ“Š Evaluating on test set...") test_results = trainer.evaluate(tokenized["test"]) print(f"\nāœ… Test Results:") print(f" Accuracy: {test_results['eval_accuracy']:.2%}") print(f" F1 (macro): {test_results['eval_f1_macro']:.2%}") print(f" F1 (weighted): {test_results['eval_f1_weighted']:.2%}") # Save final_path = output_dir / "final" model.save_pretrained(final_path) tokenizer.save_pretrained(final_path) print(f"\nšŸ’¾ Model saved to: {final_path}") return str(final_path) if __name__ == "__main__": train(use_goemotions=True, use_class_weights=True)