""" Fine-Tune Custom Email Classifiers — Google Colab Training Script ================================================================= Copy this entire file into a Google Colab notebook (one cell per section) or run it as a standalone Python script. Trains two binary classifiers on the project's JSONL datasets: 1. **Malicious Intent** — phishing / fraud detection 2. **Prompt Injection** — LLM injection attack detection Recommended base models (all < 100M params, sub-1s CPU inference): • microsoft/MiniLM-L12-H384-uncased (33M params) • microsoft/deberta-v3-small (44M params) • distilbert-base-uncased (66M params) After training, the script exports the model + tokenizer to a directory that can be copied into the project at ``models/custom_/`` for use with ``CustomL2SemanticAnalyzer``. Usage (Colab): 1. Upload train.jsonl, val.jsonl, test.jsonl for each task. 2. Run all cells. 3. Download the exported model directories. 4. Place them in the project under ``models/``. """ # ============================================================ # SECTION 1: Setup & Installs # ============================================================ # !pip install -q transformers datasets accelerate safetensors scikit-learn import json import os from pathlib import Path import numpy as np import torch from sklearn.metrics import ( accuracy_score, classification_report, f1_score, precision_score, recall_score, ) from datasets import Dataset from transformers import ( AutoModelForSequenceClassification, AutoTokenizer, Trainer, TrainingArguments, EarlyStoppingCallback, ) # ============================================================ # SECTION 2: Configuration # ============================================================ class TrainingConfig: """Central configuration for the training run. Edit these values before running. All paths are relative to the Colab working directory (or script directory). """ # --- Model --- # Choose one of: # "microsoft/MiniLM-L12-H384-uncased" (33M, fastest) # "microsoft/deberta-v3-small" (44M, best quality) # "distilbert-base-uncased" (66M, good balance) BASE_MODEL: str = "microsoft/deberta-v3-small" # --- Task --- # Set to "malicious_intent" or "prompt_injection" TASK: str = "malicious_intent" # --- Data paths --- TRAIN_PATH: str = f"data/{TASK}/train.jsonl" VAL_PATH: str = f"data/{TASK}/val.jsonl" TEST_PATH: str = f"data/{TASK}/test.jsonl" # --- Tokenizer --- MAX_LENGTH: int = 256 # --- Training hyperparameters --- EPOCHS: int = 5 BATCH_SIZE: int = 16 LEARNING_RATE: float = 2e-5 WEIGHT_DECAY: float = 0.01 WARMUP_RATIO: float = 0.1 FP16: bool = torch.cuda.is_available() # --- Early stopping --- EARLY_STOPPING_PATIENCE: int = 2 # --- Output --- OUTPUT_DIR: str = f"output/{TASK}" EXPORT_DIR: str = f"export/custom_{TASK}" # --- Reproducibility --- SEED: int = 42 cfg = TrainingConfig() # Set seeds for reproducibility torch.manual_seed(cfg.SEED) np.random.seed(cfg.SEED) if torch.cuda.is_available(): torch.cuda.manual_seed_all(cfg.SEED) # ============================================================ # SECTION 3: Data Loading # ============================================================ def load_jsonl_dataset(path: str) -> list[dict]: """Load a JSONL file matching the project's dataset schema. Expected fields per line: - text_body (str): Plain-text email body. - html_body (str): HTML email body (used as fallback). - label (int): Binary label (0 = benign, 1 = malicious/injection). - id (str, optional): Sample identifier. """ samples = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue row = json.loads(line) # Use text_body if available, fall back to html_body text = row.get("text_body", "") or "" if not text: text = row.get("html_body", "") or "" samples.append({ "text": text, "label": int(row.get("label", 0)), "id": row.get("id", ""), }) return samples def prepare_datasets() -> tuple[Dataset, Dataset, Dataset | None]: """Load train/val/test splits and convert to HuggingFace Datasets.""" train_data = load_jsonl_dataset(cfg.TRAIN_PATH) val_data = load_jsonl_dataset(cfg.VAL_PATH) test_data = None if os.path.exists(cfg.TEST_PATH): test_data = load_jsonl_dataset(cfg.TEST_PATH) print(f"Task: {cfg.TASK}") print(f"Train: {len(train_data)} samples") print(f"Validation: {len(val_data)} samples") if test_data: print(f"Test: {len(test_data)} samples") # Label distribution train_pos = sum(1 for s in train_data if s["label"] == 1) print(f"\nTrain label distribution: " f"{train_pos} positive ({train_pos/len(train_data)*100:.1f}%), " f"{len(train_data)-train_pos} negative " f"({(len(train_data)-train_pos)/len(train_data)*100:.1f}%)") train_ds = Dataset.from_list(train_data) val_ds = Dataset.from_list(val_data) test_ds = Dataset.from_list(test_data) if test_data else None return train_ds, val_ds, test_ds train_ds, val_ds, test_ds = prepare_datasets() # ============================================================ # SECTION 4: Tokenization # ============================================================ tokenizer = AutoTokenizer.from_pretrained(cfg.BASE_MODEL) def tokenize_function(examples: dict) -> dict: """Tokenize the 'text' field with truncation and padding.""" return tokenizer( examples["text"], truncation=True, max_length=cfg.MAX_LENGTH, padding="max_length", ) print(f"\nTokenizing with {cfg.BASE_MODEL} (max_length={cfg.MAX_LENGTH})...") train_ds_tok = train_ds.map(tokenize_function, batched=True, batch_size=1000) val_ds_tok = val_ds.map(tokenize_function, batched=True, batch_size=1000) test_ds_tok = test_ds.map(tokenize_function, batched=True, batch_size=1000) if test_ds else None # Set format for PyTorch columns = ["input_ids", "attention_mask", "label"] if "token_type_ids" in train_ds_tok.column_names: columns.append("token_type_ids") train_ds_tok.set_format("torch", columns=columns) val_ds_tok.set_format("torch", columns=columns) if test_ds_tok: test_ds_tok.set_format("torch", columns=columns) print("Tokenization complete.") # ============================================================ # SECTION 5: Model Setup # ============================================================ model = AutoModelForSequenceClassification.from_pretrained( cfg.BASE_MODEL, num_labels=2, id2label={0: "BENIGN", 1: "MALICIOUS"}, label2id={"BENIGN": 0, "MALICIOUS": 1}, ) param_count = sum(p.numel() for p in model.parameters()) trainable_count = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"\nModel: {cfg.BASE_MODEL}") print(f"Total parameters: {param_count:>12,}") print(f"Trainable parameters: {trainable_count:>12,}") print(f"Model size: {param_count * 4 / 1e6:>10.1f} MB (FP32 est.)") # ============================================================ # SECTION 6: Metrics # ============================================================ def compute_metrics(eval_pred) -> dict: """Compute precision, recall, F1, and accuracy for the Trainer.""" logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return { "accuracy": accuracy_score(labels, predictions), "precision": precision_score(labels, predictions, zero_division=0), "recall": recall_score(labels, predictions, zero_division=0), "f1": f1_score(labels, predictions, zero_division=0), } # ============================================================ # SECTION 7: Training # ============================================================ training_args = TrainingArguments( output_dir=cfg.OUTPUT_DIR, num_train_epochs=cfg.EPOCHS, per_device_train_batch_size=cfg.BATCH_SIZE, per_device_eval_batch_size=cfg.BATCH_SIZE * 2, learning_rate=cfg.LEARNING_RATE, weight_decay=cfg.WEIGHT_DECAY, warmup_ratio=cfg.WARMUP_RATIO, fp16=cfg.FP16, # Evaluation eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", greater_is_better=True, # Logging logging_steps=50, logging_first_step=True, report_to="none", # Reproducibility seed=cfg.SEED, data_seed=cfg.SEED, # Save disk space save_total_limit=2, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_ds_tok, eval_dataset=val_ds_tok, compute_metrics=compute_metrics, callbacks=[EarlyStoppingCallback(early_stopping_patience=cfg.EARLY_STOPPING_PATIENCE)], ) print(f"\n{'='*60}") print(f"Starting training: {cfg.TASK}") print(f"Base model: {cfg.BASE_MODEL}") print(f"Epochs: {cfg.EPOCHS}, Batch size: {cfg.BATCH_SIZE}") print(f"Learning rate: {cfg.LEARNING_RATE}, FP16: {cfg.FP16}") print(f"{'='*60}\n") trainer.train() # ============================================================ # SECTION 8: Evaluation # ============================================================ print(f"\n{'='*60}") print("Validation Set Evaluation") print(f"{'='*60}") val_results = trainer.evaluate(val_ds_tok) for key, value in sorted(val_results.items()): if isinstance(value, float): print(f" {key}: {value:.4f}") if test_ds_tok: print(f"\n{'='*60}") print("Test Set Evaluation") print(f"{'='*60}") test_results = trainer.evaluate(test_ds_tok) for key, value in sorted(test_results.items()): if isinstance(value, float): print(f" {key}: {value:.4f}") # Detailed classification report test_pred = trainer.predict(test_ds_tok) test_preds = np.argmax(test_pred.predictions, axis=-1) print("\nDetailed Classification Report:") print(classification_report( test_pred.label_ids, test_preds, target_names=["BENIGN", "MALICIOUS"], )) # ============================================================ # SECTION 9: Threshold Tuning # ============================================================ def find_optimal_threshold( logits: np.ndarray, labels: np.ndarray, recall_floor: float = 0.85, ) -> tuple[float, dict]: """Sweep thresholds on the positive-class probability. Finds the threshold that maximizes F1 while maintaining recall above the specified floor. This mirrors the grid search logic in ``scripts/evaluate_pipeline.py``. Args: logits: Raw model logits (N, 2). labels: Ground-truth binary labels (N,). recall_floor: Minimum recall requirement. Returns: Tuple of (best_threshold, metrics_at_threshold). """ probs = torch.softmax(torch.tensor(logits), dim=-1)[:, 1].numpy() best_threshold = 0.5 best_f1 = -1.0 best_metrics = {} for t in np.arange(0.05, 0.96, 0.01): preds = (probs >= t).astype(int) prec = precision_score(labels, preds, zero_division=0) rec = recall_score(labels, preds, zero_division=0) f1 = f1_score(labels, preds, zero_division=0) if rec >= recall_floor and f1 > best_f1: best_f1 = f1 best_threshold = float(t) best_metrics = { "threshold": float(t), "precision": float(prec), "recall": float(rec), "f1": float(f1), } # Fallback: if no threshold meets recall floor, take max F1 if best_f1 < 0: for t in np.arange(0.05, 0.96, 0.01): preds = (probs >= t).astype(int) f1 = f1_score(labels, preds, zero_division=0) if f1 > best_f1: best_f1 = f1 best_threshold = float(t) prec = precision_score(labels, preds, zero_division=0) rec = recall_score(labels, preds, zero_division=0) best_metrics = { "threshold": float(t), "precision": float(prec), "recall": float(rec), "f1": float(f1), } return best_threshold, best_metrics print(f"\n{'='*60}") print("Threshold Tuning (on validation set)") print(f"{'='*60}") val_pred = trainer.predict(val_ds_tok) optimal_t, optimal_m = find_optimal_threshold( val_pred.predictions, val_pred.label_ids, recall_floor=0.85, ) print(f"\n Optimal threshold: {optimal_t:.2f}") print(f" Precision: {optimal_m.get('precision', 0):.4f}") print(f" Recall: {optimal_m.get('recall', 0):.4f}") print(f" F1: {optimal_m.get('f1', 0):.4f}") # ============================================================ # SECTION 10: Export Model # ============================================================ export_path = Path(cfg.EXPORT_DIR) export_path.mkdir(parents=True, exist_ok=True) # Save model + tokenizer trainer.save_model(str(export_path)) tokenizer.save_pretrained(str(export_path)) # Save training metadata metadata = { "task": cfg.TASK, "base_model": cfg.BASE_MODEL, "max_length": cfg.MAX_LENGTH, "optimal_threshold": optimal_t, "optimal_metrics": optimal_m, "val_metrics": { k: v for k, v in val_results.items() if isinstance(v, (int, float)) }, "training_config": { "epochs": cfg.EPOCHS, "batch_size": cfg.BATCH_SIZE, "learning_rate": cfg.LEARNING_RATE, "weight_decay": cfg.WEIGHT_DECAY, "warmup_ratio": cfg.WARMUP_RATIO, "seed": cfg.SEED, }, "param_count": param_count, } if test_ds_tok: metadata["test_metrics"] = { k: v for k, v in test_results.items() if isinstance(v, (int, float)) } with open(export_path / "training_metadata.json", "w") as f: json.dump(metadata, f, indent=2) print(f"\n{'='*60}") print(f"Model exported to: {export_path}") print(f"{'='*60}") print(f"\nFiles in export directory:") for p in sorted(export_path.iterdir()): size_kb = p.stat().st_size / 1024 print(f" {p.name:40s} {size_kb:>8.1f} KB") # ============================================================ # SECTION 11: Integration Guide # ============================================================ print(f""" {'='*60} INTEGRATION GUIDE {'='*60} 1. Copy the exported directory to your project: cp -r {export_path} /path/to/Malicious-Email-Scorer/models/custom_{cfg.TASK}/ 2. In your app/main.py lifespan(), replace the L2 loader: # BEFORE (off-the-shelf): # from app.engines.semantic.orchestrator import load_models, shutdown_models # load_models() # AFTER (custom): from app.engines.semantic.custom_adapter import ( CustomL2SemanticAnalyzer, load_custom_models, shutdown_custom_models, ) load_custom_models( malicious_threshold={optimal_t:.2f}, # from threshold tuning ) 3. Register the custom analyzer instead of the OTS one: manager = AnalysisManager() manager.register(L1HeuristicsAnalyzer()) manager.register(CustomL2SemanticAnalyzer()) # <-- custom 4. Run the evaluation script to tune pipeline thresholds: python scripts/evaluate_pipeline.py --run-test-eval 5. Recommended threshold for {cfg.TASK}: {optimal_t:.2f} (F1={optimal_m.get('f1', 0):.4f}, Recall={optimal_m.get('recall', 0):.4f}) """)