malicious-email-scorer / scripts /train_custom_models.py
itay
Deploy modular architecture with custom LFS models
cf5ed6c
Raw
History Blame Contribute Delete
15.8 kB
"""
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_<task>/`` 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})
""")