Download src/train.py from hari1712/indicbert-deployment: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/spaces/hari1712/indicbert-deployment/resolve/main/src/train.py
- Command line
-
hf download hf://spaces/hari1712/indicbert-deployment/src/train.py
-
curl -L -o train.py https://huggingface.co/spaces/hari1712/indicbert-deployment/resolve/main/src/train.py
18.5 kB
| """ | |
| IndicBERT transfer learning and fine-tuning module. | |
| Fine-tunes IndicBERT on multilingual Indic sentiment data with validation tracking, | |
| early stopping, and best checkpoint selection based on validation Macro F1. | |
| Supports mean-pooling contextual transfer learning for MLM-only IndicBERT checkpoints. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import logging | |
| from pathlib import Path | |
| import time | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| import torch.nn as nn | |
| from torch.optim import AdamW | |
| from torch.utils.data import DataLoader, Dataset | |
| from transformers import PreTrainedTokenizer, get_linear_schedule_with_warmup | |
| from src.config import ProjectConfig, load_config | |
| from src.metrics import compute_classification_metrics | |
| from src.model import get_model_and_tokenizer, save_fine_tuned_model | |
| from src.utils import ensure_dir, get_device, save_json, set_seed, setup_logger | |
| logger = setup_logger("Trainer") | |
| class IndicSentimentDataset(Dataset): | |
| """ | |
| PyTorch Dataset for text classification. | |
| """ | |
| def __init__( | |
| self, | |
| texts: List[str], | |
| labels: List[int], | |
| languages: List[str], | |
| tokenizer: PreTrainedTokenizer, | |
| max_length: int = 128, | |
| ): | |
| self.texts = texts | |
| self.labels = labels | |
| self.languages = languages | |
| self.tokenizer = tokenizer | |
| self.max_length = max_length | |
| def __len__(self) -> int: | |
| return len(self.texts) | |
| def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: | |
| text = str(self.texts[idx]) | |
| label = self.labels[idx] | |
| encoding = self.tokenizer( | |
| text, | |
| padding="max_length", | |
| truncation=True, | |
| max_length=self.max_length, | |
| return_tensors="pt", | |
| ) | |
| item = { | |
| "input_ids": encoding["input_ids"].squeeze(0), | |
| "attention_mask": encoding["attention_mask"].squeeze(0), | |
| "label": torch.tensor(label, dtype=torch.long), | |
| } | |
| if "token_type_ids" in encoding: | |
| item["token_type_ids"] = encoding["token_type_ids"].squeeze(0) | |
| return item | |
| def prepare_dataloader( | |
| df: pd.DataFrame, | |
| tokenizer: PreTrainedTokenizer, | |
| label2id: Dict[str, int], | |
| max_length: int = 128, | |
| batch_size: int = 16, | |
| shuffle: bool = True, | |
| ) -> DataLoader: | |
| """ | |
| Create a PyTorch DataLoader from a DataFrame. | |
| """ | |
| texts = df["text"].tolist() | |
| labels = [label2id[str(lbl).lower()] for lbl in df["label"].tolist()] | |
| languages = df["language"].tolist() | |
| ds = IndicSentimentDataset( | |
| texts=texts, | |
| labels=labels, | |
| languages=languages, | |
| tokenizer=tokenizer, | |
| max_length=max_length, | |
| ) | |
| return DataLoader(ds, batch_size=batch_size, shuffle=shuffle) | |
| def extract_pooled_features( | |
| model: torch.nn.Module, | |
| tokenizer: PreTrainedTokenizer, | |
| texts: List[str], | |
| batch_size: int = 32, | |
| max_length: int = 128, | |
| device: torch.device = torch.device("cpu"), | |
| ) -> torch.Tensor: | |
| """ | |
| Extract mean-pooled sentence representations from frozen IndicBERT backbone. | |
| Enables orders-of-magnitude faster CPU transfer learning without redundant forward passes. | |
| """ | |
| model.eval() | |
| base_bert = getattr(model, "bert", model) | |
| all_features = [] | |
| with torch.no_grad(): | |
| for i in range(0, len(texts), batch_size): | |
| batch_slice = texts[i : i + batch_size] | |
| inputs = tokenizer( | |
| batch_slice, | |
| padding=True, | |
| truncation=True, | |
| max_length=max_length, | |
| return_tensors="pt", | |
| ) | |
| inputs = {k: v.to(device) for k, v in inputs.items()} | |
| outputs = base_bert(**inputs) | |
| sequence_output = outputs[0] | |
| mask = inputs["attention_mask"].unsqueeze(-1).expand(sequence_output.size()).float() | |
| sum_embeddings = torch.sum(sequence_output * mask, 1) | |
| sum_mask = torch.clamp(mask.sum(1), min=1e-9) | |
| pooled = sum_embeddings / sum_mask | |
| all_features.append(pooled.cpu()) | |
| return torch.cat(all_features, dim=0) | |
| def evaluate_epoch( | |
| model: torch.nn.Module, | |
| dataloader: DataLoader, | |
| device: torch.device, | |
| id2label: Dict[int, str], | |
| ) -> Tuple[float, Dict[str, Any]]: | |
| """ | |
| Evaluate model on a dataloader. Returns average loss and classification metrics. | |
| """ | |
| model.eval() | |
| total_loss = 0.0 | |
| all_preds = [] | |
| all_labels = [] | |
| loss_fn = torch.nn.CrossEntropyLoss() | |
| with torch.no_grad(): | |
| for batch in dataloader: | |
| input_ids = batch["input_ids"].to(device) | |
| attention_mask = batch["attention_mask"].to(device) | |
| labels = batch["label"].to(device) | |
| kwargs = {"input_ids": input_ids, "attention_mask": attention_mask} | |
| if "token_type_ids" in batch: | |
| kwargs["token_type_ids"] = batch["token_type_ids"].to(device) | |
| outputs = model(**kwargs) | |
| logits = outputs.logits | |
| loss = loss_fn(logits, labels) | |
| total_loss += loss.item() | |
| preds = torch.argmax(logits, dim=1).cpu().numpy().tolist() | |
| all_preds.extend(preds) | |
| all_labels.extend(labels.cpu().numpy().tolist()) | |
| avg_loss = total_loss / max(1, len(dataloader)) | |
| class_names = [id2label[i] for i in sorted(id2label.keys())] | |
| metrics = compute_classification_metrics(all_labels, all_preds, class_names=class_names) | |
| metrics["loss"] = round(avg_loss, 4) | |
| return avg_loss, metrics | |
| def train_model( | |
| config: Optional[ProjectConfig] = None, | |
| smoke_test: bool = False, | |
| max_samples: Optional[int] = None, | |
| custom_train_df: Optional[pd.DataFrame] = None, | |
| custom_val_df: Optional[pd.DataFrame] = None, | |
| output_dir: Optional[Path | str] = None, | |
| save_checkpoint: bool = True, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Execute transfer learning / fine-tuning training loop for IndicBERT. | |
| Supports: | |
| - Fast, memory-safe transfer learning via frozen backbone & mean-pooled classifier head | |
| - Full end-to-end backpropagation when freeze_base_model is False | |
| - Low-resource fractional evaluation | |
| - CI smoke-testing | |
| """ | |
| if config is None: | |
| config = load_config() | |
| set_seed(config.seed) | |
| device = get_device() | |
| logger.info(f"Using device for training: {device}") | |
| # Resolve paths | |
| target_output_dir = Path(output_dir) if output_dir else config.local_model_dir | |
| # Hyperparameters | |
| if smoke_test: | |
| logger.info("--- Running in SMOKE-TEST mode (fast integration check) ---") | |
| num_epochs = config.smoke_test_epochs | |
| batch_size = config.smoke_test_batch_size | |
| sample_limit = config.smoke_test_max_samples | |
| max_length = 64 | |
| else: | |
| num_epochs = config.num_epochs | |
| batch_size = config.batch_size | |
| sample_limit = max_samples | |
| max_length = config.max_length | |
| lr = config.learning_rate | |
| weight_decay = config.weight_decay | |
| label2id = config.label2id | |
| id2label = config.id2label | |
| # 1. Load data | |
| if custom_train_df is not None: | |
| train_df = custom_train_df.copy() | |
| else: | |
| train_df = pd.read_csv(config.train_file, encoding="utf-8") | |
| if custom_val_df is not None: | |
| val_df = custom_val_df.copy() | |
| else: | |
| val_df = pd.read_csv(config.val_file, encoding="utf-8") | |
| if sample_limit: | |
| train_df = train_df.head(sample_limit) | |
| val_df = val_df.head(min(sample_limit, len(val_df))) | |
| logger.info(f"Training samples: {len(train_df)}, Validation samples: {len(val_df)}") | |
| # 2. Load model and matching tokenizer | |
| model, tokenizer = get_model_and_tokenizer( | |
| model_name_or_path=config.model_name_or_path, | |
| num_labels=config.num_labels, | |
| id2label=id2label, | |
| label2id=label2id, | |
| ) | |
| freeze_base = config.freeze_base_model | |
| trainable_mode = config.trainable_layers | |
| if freeze_base: | |
| base_encoder = getattr(model, "bert", getattr(model, "albert", None)) | |
| if base_encoder is not None: | |
| for param in base_encoder.parameters(): | |
| param.requires_grad = False | |
| trainable_count = sum(p.numel() for p in model.parameters() if p.requires_grad) | |
| total_count = sum(p.numel() for p in model.parameters()) | |
| logger.info( | |
| f"Trainable parameters: {trainable_count:,} / {total_count:,} ({trainable_count / total_count * 100:.3f}%)" | |
| ) | |
| model.to(device) | |
| # ------------------------------------------------------------------------- | |
| # PATH A: FAST FEATURE-CACHED TRANSFER LEARNING (when base is frozen) | |
| # ------------------------------------------------------------------------- | |
| if freeze_base and hasattr(model, "classifier"): | |
| logger.info("Executing optimized representation transfer learning...") | |
| t_start = time.time() | |
| train_texts = train_df["text"].tolist() | |
| val_texts = val_df["text"].tolist() | |
| train_labels_tensor = torch.tensor( | |
| [label2id[str(lbl).lower()] for lbl in train_df["label"].tolist()], | |
| dtype=torch.long, | |
| ) | |
| val_labels_tensor = torch.tensor( | |
| [label2id[str(lbl).lower()] for lbl in val_df["label"].tolist()], | |
| dtype=torch.long, | |
| ) | |
| logger.info(f"Extracting sentence embeddings for {len(train_texts)} train & {len(val_texts)} val texts...") | |
| train_features = extract_pooled_features( | |
| model=model, | |
| tokenizer=tokenizer, | |
| texts=train_texts, | |
| batch_size=batch_size * 2, | |
| max_length=max_length, | |
| device=device, | |
| ) | |
| val_features = extract_pooled_features( | |
| model=model, | |
| tokenizer=tokenizer, | |
| texts=val_texts, | |
| batch_size=batch_size * 2, | |
| max_length=max_length, | |
| device=device, | |
| ) | |
| optimizer = AdamW(model.classifier.parameters(), lr=lr, weight_decay=weight_decay) | |
| loss_fn = nn.CrossEntropyLoss() | |
| history: List[Dict[str, Any]] = [] | |
| best_val_macro_f1 = -1.0 | |
| best_classifier_weights = None | |
| best_epoch = -1 | |
| early_stopping_counter = 0 | |
| train_epochs = 20 if (not smoke_test and num_epochs < 5) else num_epochs | |
| for epoch in range(1, train_epochs + 1): | |
| epoch_start = time.time() | |
| model.classifier.train() | |
| perm = torch.randperm(len(train_features)) | |
| total_train_loss = 0.0 | |
| for i in range(0, len(train_features), batch_size): | |
| idx = perm[i : i + batch_size] | |
| batch_feat = train_features[idx].to(device) | |
| batch_y = train_labels_tensor[idx].to(device) | |
| optimizer.zero_grad() | |
| logits = model.classifier(model.dropout(batch_feat)) | |
| loss = loss_fn(logits, batch_y) | |
| loss.backward() | |
| optimizer.step() | |
| total_train_loss += loss.item() | |
| avg_train_loss = total_train_loss / max(1, (len(train_features) // batch_size)) | |
| # Validation evaluation | |
| model.classifier.eval() | |
| with torch.no_grad(): | |
| val_feat_dev = val_features.to(device) | |
| val_y_dev = val_labels_tensor.to(device) | |
| val_logits = model.classifier(val_feat_dev) | |
| val_loss_val = loss_fn(val_logits, val_y_dev).item() | |
| val_preds = val_logits.argmax(dim=-1).cpu().numpy().tolist() | |
| val_true = val_labels_tensor.numpy().tolist() | |
| class_names = [id2label[i] for i in sorted(id2label.keys())] | |
| val_metrics = compute_classification_metrics(val_true, val_preds, class_names=class_names) | |
| val_macro_f1 = val_metrics["macro_f1"] | |
| epoch_time = time.time() - epoch_start | |
| logger.info( | |
| f"Epoch {epoch:2d}/{train_epochs} [{epoch_time:.2f}s] - " | |
| f"Train Loss: {avg_train_loss:.4f} | Val Loss: {val_loss_val:.4f} | " | |
| f"Val Macro F1: {val_macro_f1:.4f} | Val Acc: {val_metrics['accuracy']:.4f}" | |
| ) | |
| epoch_record = { | |
| "epoch": epoch, | |
| "train_loss": round(avg_train_loss, 4), | |
| "val_loss": round(val_loss_val, 4), | |
| "val_macro_f1": val_macro_f1, | |
| "val_accuracy": val_metrics["accuracy"], | |
| "epoch_duration_sec": round(epoch_time, 2), | |
| } | |
| history.append(epoch_record) | |
| if val_macro_f1 > best_val_macro_f1: | |
| best_val_macro_f1 = val_macro_f1 | |
| best_epoch = epoch | |
| best_classifier_weights = copy.deepcopy(model.classifier.state_dict()) | |
| early_stopping_counter = 0 | |
| else: | |
| early_stopping_counter += 1 | |
| if early_stopping_counter >= config.early_stopping_patience and not smoke_test: | |
| logger.info(f"Early stopping at epoch {epoch}.") | |
| break | |
| if smoke_test: | |
| break | |
| if best_classifier_weights is not None: | |
| model.classifier.load_state_dict(best_classifier_weights) | |
| total_duration = time.time() - t_start | |
| # ------------------------------------------------------------------------- | |
| # PATH B: STANDARD END-TO-END FINE-TUNING LOOP | |
| # ------------------------------------------------------------------------- | |
| else: | |
| train_loader = prepare_dataloader( | |
| train_df, tokenizer, label2id, max_length=max_length, batch_size=batch_size, shuffle=True | |
| ) | |
| val_loader = prepare_dataloader( | |
| val_df, tokenizer, label2id, max_length=max_length, batch_size=batch_size, shuffle=False | |
| ) | |
| no_decay = ["bias", "LayerNorm.weight", "layer_norm.weight"] | |
| optimizer_grouped_parameters = [ | |
| { | |
| "params": [ | |
| p for n, p in model.named_parameters() | |
| if not any(nd in n for nd in no_decay) and p.requires_grad | |
| ], | |
| "weight_decay": weight_decay, | |
| }, | |
| { | |
| "params": [ | |
| p for n, p in model.named_parameters() | |
| if any(nd in n for nd in no_decay) and p.requires_grad | |
| ], | |
| "weight_decay": 0.0, | |
| }, | |
| ] | |
| optimizer_grouped_parameters = [g for g in optimizer_grouped_parameters if g["params"]] | |
| optimizer = AdamW(optimizer_grouped_parameters, lr=lr) | |
| total_training_steps = len(train_loader) * num_epochs | |
| warmup_steps = int(total_training_steps * config.warmup_ratio) | |
| scheduler = get_linear_schedule_with_warmup( | |
| optimizer, | |
| num_warmup_steps=warmup_steps, | |
| num_training_steps=total_training_steps, | |
| ) | |
| loss_fn = torch.nn.CrossEntropyLoss() | |
| history = [] | |
| best_val_macro_f1 = -1.0 | |
| best_model_weights = None | |
| best_epoch = -1 | |
| early_stopping_counter = 0 | |
| start_time = time.time() | |
| for epoch in range(1, num_epochs + 1): | |
| epoch_start = time.time() | |
| model.train() | |
| train_loss = 0.0 | |
| for step, batch in enumerate(train_loader, 1): | |
| optimizer.zero_grad() | |
| input_ids = batch["input_ids"].to(device) | |
| attention_mask = batch["attention_mask"].to(device) | |
| labels = batch["label"].to(device) | |
| kwargs = {"input_ids": input_ids, "attention_mask": attention_mask} | |
| if "token_type_ids" in batch: | |
| kwargs["token_type_ids"] = batch["token_type_ids"].to(device) | |
| outputs = model(**kwargs) | |
| logits = outputs.logits | |
| loss = loss_fn(logits, labels) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) | |
| optimizer.step() | |
| scheduler.step() | |
| train_loss += loss.item() | |
| avg_train_loss = train_loss / max(1, len(train_loader)) | |
| val_loss, val_metrics = evaluate_epoch(model, val_loader, device, id2label) | |
| val_macro_f1 = val_metrics["macro_f1"] | |
| epoch_time = time.time() - epoch_start | |
| epoch_record = { | |
| "epoch": epoch, | |
| "train_loss": round(avg_train_loss, 4), | |
| "val_loss": round(val_loss, 4), | |
| "val_macro_f1": val_macro_f1, | |
| "val_accuracy": val_metrics["accuracy"], | |
| "epoch_duration_sec": round(epoch_time, 2), | |
| } | |
| history.append(epoch_record) | |
| if val_macro_f1 > best_val_macro_f1: | |
| best_val_macro_f1 = val_macro_f1 | |
| best_epoch = epoch | |
| best_model_weights = copy.deepcopy(model.state_dict()) | |
| early_stopping_counter = 0 | |
| else: | |
| early_stopping_counter += 1 | |
| if early_stopping_counter >= config.early_stopping_patience and not smoke_test: | |
| break | |
| total_duration = time.time() - start_time | |
| if best_model_weights is not None: | |
| model.load_state_dict(best_model_weights) | |
| # 3. Save fine-tuned model and metadata | |
| training_summary = { | |
| "base_model": config.model_name_or_path, | |
| "num_epochs": len(history), | |
| "best_epoch": best_epoch, | |
| "best_val_macro_f1": round(best_val_macro_f1, 4), | |
| "total_duration_seconds": round(total_duration, 2), | |
| "history": history, | |
| "smoke_test": smoke_test, | |
| "num_train_samples": len(train_df), | |
| "num_val_samples": len(val_df), | |
| "device": str(device), | |
| } | |
| if save_checkpoint: | |
| save_fine_tuned_model( | |
| model=model, | |
| tokenizer=tokenizer, | |
| output_dir=target_output_dir, | |
| training_info=training_summary, | |
| ) | |
| save_json(training_summary, config.training_history_file) | |
| else: | |
| logger.info("Skipping model checkpoint serialization to disk (in-memory mode).") | |
| return { | |
| "model": model, | |
| "tokenizer": tokenizer, | |
| "summary": training_summary, | |
| "history": history, | |
| "best_val_macro_f1": best_val_macro_f1, | |
| } | |