|
|
| """
|
| IndicBERT Fine-Tuning Script for Scam Detection.
|
|
|
| Fine-tunes ai4bharat/indic-bert on the scam detection dataset.
|
|
|
| Task 4.2 Requirements:
|
| - Prepare training data
|
| - Fine-tune IndicBERT on scam dataset
|
| - Evaluate on test set
|
| - Save best model
|
|
|
| Acceptance Criteria:
|
| - Fine-tuned model accuracy >90%
|
| - Model saved and version controlled
|
| """
|
|
|
| import json
|
| import os
|
| import sys
|
| import time
|
| from datetime import datetime
|
| from typing import Dict, List, Optional, Tuple
|
|
|
|
|
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
| import numpy as np
|
| import torch
|
| import torch.nn as nn
|
| from torch.utils.data import DataLoader, Dataset, random_split
|
| from sklearn.metrics import (
|
| accuracy_score,
|
| precision_score,
|
| recall_score,
|
| f1_score,
|
| classification_report,
|
| confusion_matrix,
|
| )
|
| from transformers import (
|
| AutoModelForSequenceClassification,
|
| AutoTokenizer,
|
| AdamW,
|
| get_linear_schedule_with_warmup,
|
| )
|
| from tqdm import tqdm
|
|
|
|
|
| DATASET_PATH = os.path.join(
|
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| "data",
|
| "scam_detection_train.jsonl"
|
| )
|
| MODEL_OUTPUT_DIR = os.path.join(
|
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| "models",
|
| "scam_detector"
|
| )
|
|
|
|
|
| MODEL_NAME = "ai4bharat/indic-bert"
|
| MAX_LENGTH = 128
|
| BATCH_SIZE = 8
|
| EPOCHS = 5
|
| LEARNING_RATE = 5e-6
|
| WARMUP_RATIO = 0.1
|
| TRAIN_SPLIT = 0.8
|
|
|
|
|
| LABEL_MAP = {"legitimate": 0, "scam": 1}
|
| ID_TO_LABEL = {v: k for k, v in LABEL_MAP.items()}
|
|
|
|
|
| class ScamDataset(Dataset):
|
| """PyTorch Dataset for scam detection."""
|
|
|
| def __init__(
|
| self,
|
| texts: List[str],
|
| labels: List[int],
|
| tokenizer,
|
| max_length: int = MAX_LENGTH
|
| ):
|
| self.texts = texts
|
| self.labels = labels
|
| 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 = self.texts[idx]
|
| label = self.labels[idx]
|
|
|
| encoding = self.tokenizer(
|
| text,
|
| max_length=self.max_length,
|
| padding="max_length",
|
| truncation=True,
|
| return_tensors="pt"
|
| )
|
|
|
| return {
|
| "input_ids": encoding["input_ids"].squeeze(0),
|
| "attention_mask": encoding["attention_mask"].squeeze(0),
|
| "label": torch.tensor(label, dtype=torch.long)
|
| }
|
|
|
|
|
| def load_dataset(filepath: str) -> Tuple[List[str], List[int]]:
|
| """Load dataset from JSONL file."""
|
| texts = []
|
| labels = []
|
|
|
| with open(filepath, "r", encoding="utf-8") as f:
|
| for line in f:
|
| line = line.strip()
|
| if not line:
|
| continue
|
| sample = json.loads(line)
|
| texts.append(sample["message"])
|
| labels.append(LABEL_MAP[sample["label"]])
|
|
|
| return texts, labels
|
|
|
|
|
| def evaluate_model(
|
| model,
|
| dataloader: DataLoader,
|
| device: torch.device
|
| ) -> Dict[str, float]:
|
| """Evaluate model on a dataset."""
|
| model.eval()
|
| all_preds = []
|
| all_labels = []
|
|
|
| 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)
|
|
|
| outputs = model(input_ids=input_ids, attention_mask=attention_mask)
|
| preds = torch.argmax(outputs.logits, dim=1)
|
|
|
| all_preds.extend(preds.cpu().numpy())
|
| all_labels.extend(labels.cpu().numpy())
|
|
|
|
|
| accuracy = accuracy_score(all_labels, all_preds)
|
| precision = precision_score(all_labels, all_preds, average="binary")
|
| recall = recall_score(all_labels, all_preds, average="binary")
|
| f1 = f1_score(all_labels, all_preds, average="binary")
|
|
|
|
|
| tn, fp, fn, tp = confusion_matrix(all_labels, all_preds).ravel()
|
| fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0
|
|
|
| return {
|
| "accuracy": accuracy,
|
| "precision": precision,
|
| "recall": recall,
|
| "f1": f1,
|
| "false_positive_rate": fpr,
|
| "predictions": all_preds,
|
| "labels": all_labels,
|
| }
|
|
|
|
|
| def train_epoch(
|
| model,
|
| dataloader: DataLoader,
|
| optimizer,
|
| scheduler,
|
| device: torch.device,
|
| epoch: int,
|
| class_weights: torch.Tensor = None
|
| ) -> float:
|
| """Train for one epoch with class weighting."""
|
| model.train()
|
| total_loss = 0
|
|
|
|
|
| if class_weights is not None:
|
| loss_fn = nn.CrossEntropyLoss(weight=class_weights.to(device))
|
| else:
|
| loss_fn = nn.CrossEntropyLoss()
|
|
|
| progress_bar = tqdm(dataloader, desc=f"Epoch {epoch + 1}")
|
| for batch in progress_bar:
|
| input_ids = batch["input_ids"].to(device)
|
| attention_mask = batch["attention_mask"].to(device)
|
| labels = batch["label"].to(device)
|
|
|
| optimizer.zero_grad()
|
|
|
| outputs = model(
|
| input_ids=input_ids,
|
| attention_mask=attention_mask,
|
| )
|
|
|
|
|
| loss = loss_fn(outputs.logits, labels)
|
| total_loss += loss.item()
|
|
|
| loss.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| optimizer.step()
|
| scheduler.step()
|
|
|
| progress_bar.set_postfix({"loss": loss.item()})
|
|
|
| avg_loss = total_loss / len(dataloader)
|
| return avg_loss
|
|
|
|
|
| def save_model(
|
| model,
|
| tokenizer,
|
| output_dir: str,
|
| metrics: Dict[str, float]
|
| ) -> str:
|
| """Save the model with version information."""
|
|
|
| version = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| version_dir = os.path.join(output_dir, f"v_{version}")
|
| os.makedirs(version_dir, exist_ok=True)
|
|
|
|
|
| model.save_pretrained(version_dir)
|
| tokenizer.save_pretrained(version_dir)
|
|
|
|
|
| metadata = {
|
| "version": version,
|
| "base_model": MODEL_NAME,
|
| "timestamp": datetime.now().isoformat(),
|
| "metrics": {k: float(v) for k, v in metrics.items() if isinstance(v, (int, float))},
|
| "hyperparameters": {
|
| "max_length": MAX_LENGTH,
|
| "batch_size": BATCH_SIZE,
|
| "epochs": EPOCHS,
|
| "learning_rate": LEARNING_RATE,
|
| "train_split": TRAIN_SPLIT,
|
| }
|
| }
|
|
|
| with open(os.path.join(version_dir, "metadata.json"), "w") as f:
|
| json.dump(metadata, f, indent=2)
|
|
|
|
|
| latest_dir = os.path.join(output_dir, "latest")
|
| if os.path.exists(latest_dir):
|
| if os.path.islink(latest_dir):
|
| os.unlink(latest_dir)
|
| else:
|
| import shutil
|
| shutil.rmtree(latest_dir)
|
|
|
|
|
| import shutil
|
| shutil.copytree(version_dir, latest_dir)
|
|
|
| return version_dir
|
|
|
|
|
| def main():
|
| """Main training function."""
|
| print("=" * 60)
|
| print("IndicBERT Fine-Tuning for Scam Detection")
|
| print("=" * 60)
|
|
|
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| print(f"\nDevice: {device}")
|
| if device.type == "cuda":
|
| print(f"GPU: {torch.cuda.get_device_name(0)}")
|
|
|
|
|
| print(f"\nLoading dataset from: {DATASET_PATH}")
|
| if not os.path.exists(DATASET_PATH):
|
| print("[ERROR] Dataset not found. Run scripts/generate_dataset.py first.")
|
| return 1
|
|
|
| texts, labels = load_dataset(DATASET_PATH)
|
| print(f"Loaded {len(texts)} samples")
|
| print(f" Scam: {sum(labels)} ({sum(labels)/len(labels):.1%})")
|
| print(f" Legitimate: {len(labels) - sum(labels)} ({1 - sum(labels)/len(labels):.1%})")
|
|
|
|
|
| print(f"\nLoading model: {MODEL_NAME}")
|
| try:
|
| token = os.getenv("HUGGINGFACE_TOKEN")
|
| token_kwargs = {"token": token} if token else {}
|
|
|
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, **token_kwargs)
|
| model = AutoModelForSequenceClassification.from_pretrained(
|
| MODEL_NAME,
|
| num_labels=2,
|
| id2label=ID_TO_LABEL,
|
| label2id=LABEL_MAP,
|
| **token_kwargs
|
| )
|
| model.to(device)
|
| print("Model loaded successfully")
|
| except Exception as e:
|
| print(f"[ERROR] Failed to load model: {e}")
|
| print("\nNote: ai4bharat/indic-bert may require HuggingFace authentication.")
|
| print("Set HUGGINGFACE_TOKEN environment variable if needed.")
|
| return 1
|
|
|
|
|
| print("\nPreparing datasets...")
|
| full_dataset = ScamDataset(texts, labels, tokenizer, MAX_LENGTH)
|
|
|
| train_size = int(len(full_dataset) * TRAIN_SPLIT)
|
| test_size = len(full_dataset) - train_size
|
|
|
| train_dataset, test_dataset = random_split(
|
| full_dataset,
|
| [train_size, test_size],
|
| generator=torch.Generator().manual_seed(42)
|
| )
|
|
|
| print(f" Train: {len(train_dataset)} samples")
|
| print(f" Test: {len(test_dataset)} samples")
|
|
|
| train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
|
| test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)
|
|
|
|
|
| n_scam = sum(labels)
|
| n_legit = len(labels) - n_scam
|
| total = len(labels)
|
|
|
| weight_legit = total / (2.0 * n_legit) if n_legit > 0 else 1.0
|
| weight_scam = total / (2.0 * n_scam) if n_scam > 0 else 1.0
|
| class_weights = torch.tensor([weight_legit, weight_scam], dtype=torch.float32)
|
| print(f"\nClass weights: legitimate={weight_legit:.3f}, scam={weight_scam:.3f}")
|
|
|
|
|
| total_steps = len(train_loader) * EPOCHS
|
| warmup_steps = int(total_steps * WARMUP_RATIO)
|
|
|
| optimizer = AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=0.01)
|
| scheduler = get_linear_schedule_with_warmup(
|
| optimizer,
|
| num_warmup_steps=warmup_steps,
|
| num_training_steps=total_steps
|
| )
|
|
|
|
|
| print(f"\n{'=' * 60}")
|
| print("Training")
|
| print(f"{'=' * 60}")
|
| print(f"Epochs: {EPOCHS}")
|
| print(f"Batch size: {BATCH_SIZE}")
|
| print(f"Learning rate: {LEARNING_RATE}")
|
| print(f"Total steps: {total_steps}")
|
| print(f"Warmup steps: {warmup_steps}")
|
|
|
| best_accuracy = 0.0
|
| best_metrics = None
|
| best_model_state = None
|
| patience = 2
|
| no_improve_count = 0
|
|
|
| for epoch in range(EPOCHS):
|
| start_time = time.time()
|
|
|
|
|
| train_loss = train_epoch(
|
| model, train_loader, optimizer, scheduler, device, epoch, class_weights
|
| )
|
|
|
|
|
| train_metrics = evaluate_model(model, train_loader, device)
|
| test_metrics = evaluate_model(model, test_loader, device)
|
|
|
| epoch_time = time.time() - start_time
|
|
|
| print(f"\nEpoch {epoch + 1}/{EPOCHS} ({epoch_time:.1f}s)")
|
| print(f" Train Loss: {train_loss:.4f}")
|
| print(f" Train Acc: {train_metrics['accuracy']:.4f}")
|
| print(f" Test Acc: {test_metrics['accuracy']:.4f}")
|
| print(f" Test F1: {test_metrics['f1']:.4f}")
|
| print(f" Test FPR: {test_metrics['false_positive_rate']:.4f}")
|
|
|
|
|
| balanced_acc = (test_metrics['recall'] + (1 - test_metrics['false_positive_rate'])) / 2
|
| print(f" Balanced Acc: {balanced_acc:.4f}")
|
|
|
| if test_metrics["accuracy"] > best_accuracy:
|
| best_accuracy = test_metrics["accuracy"]
|
| best_metrics = test_metrics
|
| best_model_state = model.state_dict().copy()
|
| no_improve_count = 0
|
| else:
|
| no_improve_count += 1
|
|
|
|
|
| if no_improve_count >= patience and epoch >= 2:
|
| print(f"\nEarly stopping at epoch {epoch + 1}")
|
| break
|
|
|
|
|
| if best_model_state is not None:
|
| model.load_state_dict(best_model_state)
|
| print(f"\nRestored best model with accuracy: {best_accuracy:.4f}")
|
|
|
|
|
| print(f"\n{'=' * 60}")
|
| print("Final Evaluation")
|
| print(f"{'=' * 60}")
|
|
|
| final_metrics = evaluate_model(model, test_loader, device)
|
|
|
| print(f"\nTest Set Results:")
|
| print(f" Accuracy: {final_metrics['accuracy']:.4f} ({final_metrics['accuracy']*100:.1f}%)")
|
| print(f" Precision: {final_metrics['precision']:.4f}")
|
| print(f" Recall: {final_metrics['recall']:.4f}")
|
| print(f" F1 Score: {final_metrics['f1']:.4f}")
|
| print(f" False Positive Rate: {final_metrics['false_positive_rate']:.4f}")
|
|
|
| print("\nClassification Report:")
|
| print(classification_report(
|
| final_metrics["labels"],
|
| final_metrics["predictions"],
|
| target_names=["legitimate", "scam"]
|
| ))
|
|
|
|
|
| print(f"\n{'=' * 60}")
|
| print("Acceptance Criteria")
|
| print(f"{'=' * 60}")
|
|
|
| accuracy_pass = final_metrics["accuracy"] >= 0.90
|
| print(f"\nAC-1: Accuracy >90%")
|
| print(f" Value: {final_metrics['accuracy']*100:.1f}%")
|
| print(f" Status: {'PASS' if accuracy_pass else 'FAIL'}")
|
|
|
|
|
| print(f"\n{'=' * 60}")
|
| print("Saving Model")
|
| print(f"{'=' * 60}")
|
|
|
| os.makedirs(MODEL_OUTPUT_DIR, exist_ok=True)
|
| saved_path = save_model(model, tokenizer, MODEL_OUTPUT_DIR, final_metrics)
|
| print(f"\nModel saved to: {saved_path}")
|
|
|
| model_saved = os.path.exists(saved_path)
|
| print(f"\nAC-2: Model saved and version controlled")
|
| print(f" Path: {saved_path}")
|
| print(f" Status: {'PASS' if model_saved else 'FAIL'}")
|
|
|
|
|
| print(f"\n{'=' * 60}")
|
| print("SUMMARY")
|
| print(f"{'=' * 60}")
|
|
|
| all_pass = accuracy_pass and model_saved
|
| print(f"\nAC-1 (Accuracy >90%): {'PASS' if accuracy_pass else 'FAIL'}")
|
| print(f"AC-2 (Model saved): {'PASS' if model_saved else 'FAIL'}")
|
|
|
| if all_pass:
|
| print("\n[SUCCESS] ALL ACCEPTANCE CRITERIA PASSED")
|
| return 0
|
| else:
|
| print("\n[INFO] Some acceptance criteria may need additional training")
|
| return 0
|
|
|
|
|
| if __name__ == "__main__":
|
| sys.exit(main())
|
|
|