| import torch |
| import torch.nn as nn |
| import torch.optim as optim |
| from torch.optim.lr_scheduler import ReduceLROnPlateau |
| import os |
| from tqdm import tqdm |
| from sklearn.metrics import accuracy_score, classification_report |
| import argparse |
|
|
| from model import SimpleRNN |
| from data_loader import create_dataloaders |
|
|
|
|
| class EarlyStopping: |
| """Early stopping to stop training when validation loss doesn't improve""" |
| |
| def __init__(self, patience=5, min_delta=0, restore_best_weights=True): |
| self.patience = patience |
| self.min_delta = min_delta |
| self.restore_best_weights = restore_best_weights |
| self.best_loss = None |
| self.counter = 0 |
| self.best_weights = None |
| |
| def __call__(self, val_loss, model): |
| if self.best_loss is None: |
| self.best_loss = val_loss |
| self.save_checkpoint(model) |
| elif val_loss < self.best_loss - self.min_delta: |
| self.best_loss = val_loss |
| self.counter = 0 |
| self.save_checkpoint(model) |
| else: |
| self.counter += 1 |
| |
| if self.counter >= self.patience: |
| if self.restore_best_weights: |
| model.load_state_dict(self.best_weights) |
| return True |
| return False |
| |
| def save_checkpoint(self, model): |
| """Save model checkpoint""" |
| self.best_weights = model.state_dict().copy() |
|
|
|
|
| def train_epoch(model, train_loader, criterion, optimizer, device): |
| """Train for one epoch""" |
| model.train() |
| total_loss = 0 |
| all_preds = [] |
| all_labels = [] |
| |
| pbar = tqdm(train_loader, desc="Training") |
| for texts, labels in pbar: |
| texts = texts.to(device) |
| labels = labels.to(device) |
| |
| |
| optimizer.zero_grad() |
| outputs = model(texts) |
| loss = criterion(outputs, labels) |
| |
| |
| loss.backward() |
| |
| |
| grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) |
| optimizer.step() |
| |
| |
| if pbar.n % 100 == 0: |
| pbar.set_postfix({'loss': loss.item(), 'grad_norm': f'{grad_norm:.2f}'}) |
| else: |
| pbar.set_postfix({'loss': loss.item()}) |
| |
| |
| total_loss += loss.item() |
| preds = torch.argmax(outputs, dim=1) |
| all_preds.extend(preds.cpu().detach().numpy()) |
| all_labels.extend(labels.cpu().detach().numpy()) |
| |
| avg_loss = total_loss / len(train_loader) |
| accuracy = accuracy_score(all_labels, all_preds) |
| |
| return avg_loss, accuracy |
|
|
|
|
| def evaluate(model, val_loader, criterion, device): |
| """Evaluate on validation set""" |
| model.eval() |
| total_loss = 0 |
| all_preds = [] |
| all_labels = [] |
| |
| with torch.no_grad(): |
| for texts, labels in tqdm(val_loader, desc="Evaluating"): |
| texts = texts.to(device) |
| labels = labels.to(device) |
| |
| outputs = model(texts) |
| loss = criterion(outputs, labels) |
| |
| total_loss += loss.item() |
| preds = torch.argmax(outputs, dim=1) |
| all_preds.extend(preds.cpu().detach().numpy()) |
| all_labels.extend(labels.cpu().detach().numpy()) |
| |
| avg_loss = total_loss / len(val_loader) |
| accuracy = accuracy_score(all_labels, all_preds) |
| |
| return avg_loss, accuracy, all_preds, all_labels |
|
|
|
|
| def train_model( |
| dataset_name, |
| embedding_dim=128, |
| hidden_dim=256, |
| num_layers=2, |
| num_hidden_nodes=128, |
| dropout=0.3, |
| batch_size=32, |
| max_length=128, |
| learning_rate=0.001, |
| num_epochs=20, |
| patience=5, |
| vocab_min_freq=2, |
| device=None |
| ): |
| """Main training function""" |
| |
| |
| if device is None: |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| print(f"Using device: {device}") |
| |
| |
| print(f"\nLoading {dataset_name} dataset...") |
| train_loader, val_loader, vocab, num_classes, train_labels = create_dataloaders( |
| dataset_name, batch_size=batch_size, max_length=max_length, min_freq=vocab_min_freq |
| ) |
| |
| |
| if dataset_name == 'emotion': |
| from collections import Counter |
| import numpy as np |
| label_counts = Counter(train_labels) |
| total = len(train_labels) |
| |
| |
| class_weights = torch.tensor([ |
| np.sqrt(total / label_counts.get(i, 1)) if label_counts.get(i, 0) > 0 else 1.0 |
| for i in range(num_classes) |
| ], dtype=torch.float32) |
| |
| class_weights = class_weights / class_weights.mean() |
| print(f"Class distribution: {dict(label_counts)}") |
| print(f"Class weights (sqrt-scaled): {class_weights.numpy()}") |
| else: |
| class_weights = None |
| |
| |
| model = SimpleRNN( |
| vocab_size=len(vocab), |
| embedding_dim=embedding_dim, |
| hidden_dim=hidden_dim, |
| num_layers=num_layers, |
| num_classes=num_classes, |
| dropout=dropout, |
| num_hidden_nodes=num_hidden_nodes |
| ).to(device) |
| |
| |
| def init_weights(m): |
| if isinstance(m, nn.Linear): |
| torch.nn.init.xavier_uniform_(m.weight, gain=1.0) |
| if m.bias is not None: |
| |
| |
| if hasattr(m, 'out_features') and m.out_features == num_classes and num_classes > 4: |
| m.bias.data.fill_(-0.05) |
| else: |
| m.bias.data.fill_(0.0) |
| elif isinstance(m, nn.RNN): |
| |
| for name, param in m.named_parameters(): |
| if 'weight_ih' in name: |
| |
| torch.nn.init.xavier_uniform_(param.data, gain=0.5) |
| elif 'weight_hh' in name: |
| |
| torch.nn.init.orthogonal_(param.data, gain=0.5) |
| elif 'bias' in name: |
| |
| param.data.fill_(0.0) |
| elif isinstance(m, nn.Embedding): |
| |
| torch.nn.init.normal_(m.weight, mean=0.0, std=0.01) |
| |
| if m.padding_idx is not None: |
| m.weight.data[m.padding_idx].fill_(0) |
| |
| model.apply(init_weights) |
| |
| print(f"\nModel architecture:") |
| print(model) |
| print(f"\nTotal parameters: {sum(p.numel() for p in model.parameters()):,}") |
| |
| |
| |
| if class_weights is not None: |
| class_weights = class_weights.to(device) |
| criterion = nn.CrossEntropyLoss(weight=class_weights) |
| print("Using weighted CrossEntropyLoss for class imbalance") |
| else: |
| criterion = nn.CrossEntropyLoss() |
| |
| |
| optimizer = optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=0.001, betas=(0.9, 0.999)) |
| scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.7, patience=3, verbose=True, min_lr=1e-5) |
| |
| |
| early_stopping = EarlyStopping(patience=patience) |
| |
| |
| print(f"\nStarting training for {num_epochs} epochs...") |
| best_val_loss = float('inf') |
| |
| for epoch in range(num_epochs): |
| print(f"\n{'='*60}") |
| print(f"Epoch {epoch+1}/{num_epochs}") |
| print(f"{'='*60}") |
| |
| |
| train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device) |
| |
| |
| val_loss, val_acc, val_preds, val_labels = evaluate(model, val_loader, criterion, device) |
| |
| |
| scheduler.step(val_loss) |
| |
| |
| print(f"\nTrain Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}") |
| print(f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}") |
| print(f"Learning Rate: {optimizer.param_groups[0]['lr']:.6f}") |
| |
| |
| if early_stopping(val_loss, model): |
| print(f"\nEarly stopping triggered after {epoch+1} epochs") |
| break |
| |
| |
| if val_loss < best_val_loss: |
| best_val_loss = val_loss |
| os.makedirs('checkpoints', exist_ok=True) |
| torch.save({ |
| 'epoch': epoch, |
| 'model_state_dict': model.state_dict(), |
| 'optimizer_state_dict': optimizer.state_dict(), |
| 'val_loss': val_loss, |
| 'val_acc': val_acc, |
| 'vocab': vocab, |
| 'num_classes': num_classes, |
| 'model_config': { |
| 'embedding_dim': embedding_dim, |
| 'hidden_dim': hidden_dim, |
| 'num_layers': num_layers, |
| 'num_hidden_nodes': num_hidden_nodes, |
| 'dropout': dropout, |
| } |
| }, f'checkpoints/best_model_{dataset_name}.pt') |
| print(f"Saved best model (val_loss: {val_loss:.4f})") |
| |
| |
| print(f"\n{'='*60}") |
| print("Final Evaluation") |
| print(f"{'='*60}") |
| final_loss, final_acc, final_preds, final_labels = evaluate(model, val_loader, criterion, device) |
| print(f"\nFinal Validation Accuracy: {final_acc:.4f}") |
| print(f"\nClassification Report:") |
| print(classification_report(final_labels, final_preds)) |
| |
| return model, vocab |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Train RNN on text classification datasets') |
| parser.add_argument('--dataset', type=str, choices=['emotion', 'ag_news'], required=True, |
| help='Dataset to use: emotion or ag_news') |
| parser.add_argument('--embedding_dim', type=int, default=128, help='Embedding dimension') |
| parser.add_argument('--hidden_dim', type=int, default=256, help='RNN hidden dimension') |
| parser.add_argument('--num_layers', type=int, default=2, help='Number of RNN layers') |
| parser.add_argument('--num_hidden_nodes', type=int, default=128, help='Number of nodes in hidden layer') |
| parser.add_argument('--dropout', type=float, default=0.3, help='Dropout rate') |
| parser.add_argument('--batch_size', type=int, default=32, help='Batch size') |
| parser.add_argument('--max_length', type=int, default=128, help='Maximum sequence length') |
| parser.add_argument('--learning_rate', type=float, default=0.001, help='Learning rate') |
| parser.add_argument('--num_epochs', type=int, default=20, help='Number of epochs') |
| parser.add_argument('--patience', type=int, default=5, help='Early stopping patience') |
| parser.add_argument('--vocab_min_freq', type=int, default=2, help='Minimum word frequency for vocabulary') |
| |
| args = parser.parse_args() |
| |
| train_model( |
| dataset_name=args.dataset, |
| embedding_dim=args.embedding_dim, |
| hidden_dim=args.hidden_dim, |
| num_layers=args.num_layers, |
| num_hidden_nodes=args.num_hidden_nodes, |
| dropout=args.dropout, |
| batch_size=args.batch_size, |
| max_length=args.max_length, |
| learning_rate=args.learning_rate, |
| num_epochs=args.num_epochs, |
| patience=args.patience, |
| vocab_min_freq=args.vocab_min_freq |
| ) |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|
|
|