--- datasets: - puyang2025/seven-phishing-email-datasets - corbt/enron-emails - kudzaiprichard/aura-phishing-email-corpus language: en library_name: pytorch license: mit pipeline_tag: text-classification tags: - phishing - email-security - text-classification - from-scratch - spam-detection model-index: - name: phishing-detector-encoder results: - task: type: text-classification dataset: name: TREC-06 holdout (puyang2025), binary roll-up type: puyang2025/seven-phishing-email-datasets metrics: - type: recall value: 0.9741 name: Recall (unwanted) - type: precision value: 0.8794 name: Precision (unwanted) - type: f1 value: 0.9243 name: F1 (unwanted) - type: accuracy value: 0.9619 name: Accuracy - type: roc_auc value: 0.9946 name: ROC-AUC - task: type: text-classification dataset: name: three-way holdout (Nazario + Nigerian Fraud phishing, SpamAssassin / Enron-Spam spam, CEAS-08 / TREC-07 / SpamAssassin ham) type: kudzaiprichard/aura-phishing-email-corpus metrics: - type: f1 value: 0.9867 name: Macro-F1 (legitimate / phishing / spam) - type: accuracy value: 0.9885 name: Accuracy (3-way) --- # phishing-detector-encoder: legitimate / phishing / spam email classifier trained from scratch > **No external pretrained weights were used.** The encoder, the tokenizer and every parameter in `model.safetensors` > were trained from random initialisation inside this project, in plain PyTorch, without the `transformers` > library. The only external inputs are the public email corpora listed below. > Initialisation of this checkpoint: `pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final`. ## What it is A 337,444,867-parameter BERT-style **pre-LayerNorm Transformer encoder** (24 layers, d_model 1024, 16 heads, FFN 4096, byte-level BPE vocabulary of 32,768, 512 positions) with a CLS-pooled classification head that maps an email (`subject + "\n\n" + body`) to three classes: `0 = Legitimate`, `1 = Phishing`, `2 = Spam`. The binary verdict shown by the demo is the roll-up **Unwanted (phishing or spam)** = P(phishing) + P(spam) (`config.json["binary_rollup"]`). Phishing = Nazario phishing corpus + Nigerian-fraud (advance-fee) corpus + synthetic BITS-style credential/BEC lures; Spam = SpamAssassin and Enron-Spam spam folders; the TREC-05 / TREC-07 / CEAS-08 positives never separated spam from phishing and were trained as 'unwanted' only (probability mass of phishing + spam), never as either class. The architecture is defined in `model.py`, which ships with the weights. Weights are stored in **bf16** safetensors (~675 MB); the header carries the architecture config and the label names as JSON. ## How it was trained 1. **Masked-language-model pretraining from scratch** on the deduplicated Enron email corpus (`corbt/enron-emails`: 517,401 rows -> 226,273 documents after exact/near-duplicate removal and quote stripping), 15% dynamic masking (80/10/10), sequence packing to 512 tokens, AdamW (betas (0.9, 0.98), weight decay 0.01), peak LR 0.00015, bf16 autocast on one RTX 3090 Ti. Run summary (from the pretraining run's `final/config.json`): - run_name: large-24L - status: complete - source_checkpoint: step-0009552 - steps: 9552 - total_steps_planned: 9552 - steps_per_epoch: 796 - epochs_planned: 12 - epochs_completed: 12.0 - tokens_seen: 1291124736 - total_training_seconds: 54185.9 - total_training_time: 15h 03m 05s - final_loss: 1.523698394948786 - val_loss: 1.9862932266429436 - val_masked_acc: 0.6291136684837473 - peak_vram_reserved_gb: 17.939 - peak_vram_allocated_gb: 17.494 - micro_batch: 24 - grad_accumulation: 11 - effective_batch: 264 - precision: bf16_autocast - optimizer: AdamW(fused) {"lr": 0.00015, "betas": [0.9, 0.98], "eps": 1e-06, "weight_decay": 0.01, "grad_clip": 1.0, "mlm_probability": 0.15, "mask_replace_probs": [0.8, 0.1, 0.1], "schedule": "cosine_to_10pct", "effective_batch_sequences": 256} - schedule: {'kind': 'cosine', 'peak_lr': 0.00015, 'warmup_steps': 573, 'total_steps': 9552, 'floor_ratio': 0.1} - mean_tokens_per_s: 23889 - data: {'n_windows': 210368, 'n_val_windows': 1999, 'tokenizer_sha1': '9f50c5e775089d2ca3efc82e100cd6b3396ed7e7', 'n_docs': 226273, 'limit_docs': None, 'window': '[CLS] + 511 stream tokens'} - weights_dtype: float32 - exported_at: 2026-10-09T03:31:14 2. **Fine-tuning** on `puyang2025/seven-phishing-email-datasets` with our own split: TREC-05, TREC-07 and CEAS-08 (positives trained as *unwanted* only), Enron-Spam and SpamAssassin (spam folders = `Spam`), the Nazario and Nigerian-fraud corpora of `kudzaiprichard/aura-phishing-email-corpus` (= `Phishing`) and synthetic BITS-style lures (= `Phishing`) form train (157,113) / val (17,643), grouped by near-duplicate cluster so no template straddles the two; **TREC-06 (16,302 rows, 23.9% positive) is held out entirely** as the unseen binary test set and never touched by the tokenizer, pretraining or fine-tuning (1,652 pool rows that near-duplicate TREC-06 templates were removed). A second holdout, **test_holdout3**, holds clean-provenance three-way rows never seen by the tokenizer or the pretraining corpus for the 3x3 confusion matrix. Counts come from `data/processed/splits_manifest.json`. Rows with a clean three-way label train with 3-class cross-entropy; TREC / CEAS positives train on the probability mass of {phishing, spam} (partial-label loss: -log(P(phishing) + P(spam))), so they shape the legitimate-vs-unwanted decision without asserting a kind they were never labelled with. Fine-tune dials (from the checkpoint's config.json): - run_name: ft-focal - init: pretrained:checkpoints/pretrain/large-24L/final - n_layers: 24 - freeze_layers: 0 - loss: focal - focal_gamma: 2.0 - lr: 3e-05 - batch_size: 32 - micro_batch: 16 - epochs: 2 - max_steps: None - eval_every: 1250 - log_every: 20 - eval_batch_size: 64 - warmup_fraction: 0.06 - weight_decay: 0.01 - betas: [0.9, 0.98] - eps: 1e-06 - grad_clip: 1.0 - patience: 2 - schedule: linear_decay - selection_metric: val_f1_unwanted - pooling: cls - num_labels: 3 - synthetic: off - limit_rows: None - seed: 42 - cpu: False ## Metrics ### TREC-06 binary holdout (Unwanted (phishing or spam)) Evaluated by `src/evaluate.py` on the **test** split (`test_holdout.parquet`, 16,302 rows, threshold 0.5). Positive class = **Unwanted (phishing or spam)**, P(unwanted) = P(phishing) + P(spam); these definitions are identical to the binary-era runs, so the numbers are comparable. | metric | value | |---|---:| | **Recall (unwanted)** | **0.9741** (101 missed of 3,894) | | Precision (unwanted) | 0.8794 | | F1 (unwanted) | 0.9243 | | Accuracy | 0.9619 | | Specificity | 0.9581 | | ROC-AUC | 0.9946 | | PR-AUC | 0.9848 | | Recall at 1% FPR | 0.8955 | | Recall at 0.1% FPR | 0.6497 | | Credential-lure slice recall / precision (n=398) | 0.9959 / 0.9758 | | Latency, batch 1, GPU bf16 (avg / p50) | 10.08 ms / 8.9 ms | | Latency, batch 1, CPU fp32, 16 threads (avg / p50) | 458.57 ms / 445.01 ms | | Instructor benchmark | 74.0 ms per sample | Recall on the unwanted class is the headline number: in a defensive triage aid a missed phish is the costly error, while a false positive costs a human one glance. TREC-06 carries no phishing-vs-spam ground truth, so the three-way head can only be *described* here: of the 3,793 true positives it flagged, the model called 974 phishing and 2,819 spam (informational, not a metric). ### Three-way holdout (legitimate / phishing / spam) Evaluated by `src/evaluate.py --split test3` on `test_holdout3.parquet` (3,220 rows with a full three-way label, threshold 0.5). Decision rule `rollup_then_argmax`: a row is unwanted iff P(phishing) + P(spam) >= threshold, then the kind is argmax(P(phishing), P(spam)), so this table collapses exactly onto the binary roll-up. | metric | value | |---|---:| | **Macro-F1 (3 classes)** | **0.9867** | | Accuracy | 0.9885 | | Legitimate: precision / recall / F1 (n=1,999) | 0.9900 / 0.9935 / 0.9918 | | Phishing: precision / recall / F1 (n=521) | 0.9961 / 0.9866 / 0.9913 | | Spam: precision / recall / F1 (n=700) | 0.9785 / 0.9757 / 0.9771 | | Phishing-vs-spam accuracy on the 1,221 true unwanted rows (kind decision alone) | 0.9959 (4 phishing read as spam, 1 spam read as phishing) | | Binary roll-up on this split: recall / precision / F1 | 0.9836 / 0.9893 / 0.9864 | Confusion matrix (rows = true label by corpus provenance, columns = predicted): | true \ predicted | Legitimate | Phishing | Spam | |---|---:|---:|---:| | **Legitimate** | 1,986 | 1 | 12 | | **Phishing** | 4 | 514 | 3 | | **Spam** | 16 | 1 | 683 | | source | rows | Legitimate | Phishing | Spam | accuracy | |---|---:|---:|---:|---:|---:| | Assassin | 236 | 164 | 0 | 72 | 0.9534 | | CEAS-08 | 736 | 736 | 0 | 0 | 0.9905 | | Enron | 628 | 0 | 0 | 628 | 0.9841 | | Nazario | 222 | 0 | 222 | 0 | 0.9865 | | Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9866 | | TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9982 | **Label provenance.** Phishing = Nazario phishing corpus + Nigerian-fraud (advance-fee) corpus + synthetic BITS-style credential/BEC lures; Spam = SpamAssassin and Enron-Spam spam folders; the TREC-05 / TREC-07 / CEAS-08 positives never separated spam from phishing and were trained as 'unwanted' only (probability mass of phishing + spam), never as either class. The three-way holdout is drawn from text the tokenizer never saw and the pretraining corpus does not contain (Nazario / Nigerian-fraud phishing, SpamAssassin / Enron-Spam spam, CEAS-08 / TREC-07 / SpamAssassin legitimate mail from the former validation split). Because the kind labels come from which corpus a message sits in, part of any phishing-vs-spam confusion measures corpus style rather than lure content. ## Intended use A **defensive triage aid for small-business inboxes**: rank or flag incoming mail for a human to review, surface a probability and the attention-highlighted tokens that drove it, and shorten the time between a phishing wave landing and someone noticing. It is a classroom/final-project model and is not a substitute for a mail gateway. ## Limitations - **Era**: the labelled data is 1998-2008 spam-track mail (TREC, CEAS, SpamAssassin, Enron), plus Nazario phishing (1995-2022) and Nigerian-fraud mail. Modern lures (OAuth consent phishing, QR codes, MFA fatigue, SaaS-brand impersonation) are under-represented; expect lower recall on them. - **Labels follow corpus provenance**: Phishing = Nazario phishing corpus + Nigerian-fraud (advance-fee) corpus + synthetic BITS-style credential/BEC lures; Spam = SpamAssassin and Enron-Spam spam folders; the TREC-05 / TREC-07 / CEAS-08 positives never separated spam from phishing and were trained as 'unwanted' only (probability mass of phishing + spam), never as either class. A phishing-vs-spam confusion therefore partly measures corpus style, and the credential-lure slice remains the closest proxy for phishing-proper recall. - **Length confound**: legitimate emails in these corpora average ~2x the tokens of positives. Inputs are truncated to 512 tokens, and the error analysis checks whether errors follow length, but a short legitimate note can still look suspicious. - **English only**; byte-level BPE will tokenize other scripts but the model has not seen them. - **Headers are not used** (TREC rewrote them); the model sees subject and body text only. Sender reputation, SPF/DKIM and URL reputation are out of scope and should be combined with this score in any real deployment. - Attention highlights are a signal of what the model attended to, not an explanation of why it decided. ## How to load `model.py` from this repository is the only code dependency (plain PyTorch). ```python import json, torch from safetensors.torch import load_file from tokenizers import Tokenizer from model import EncoderConfig, EncoderForClassification # model.py, shipped with the weights cfg = json.load(open("config.json")) model = EncoderForClassification(EncoderConfig.from_dict(cfg["model"])) # num_labels = 3 model.load_state_dict(load_file("model.safetensors"), strict=True) # bf16 weights cast into the fp32 module model.eval() tok = Tokenizer.from_file("tokenizer.json") tok.enable_truncation(max_length=cfg["max_seq_len"]) enc = tok.encode("Your account has been suspended\n\nClick here to verify your identity within 24 hours.") ids = torch.tensor([enc.ids]) mask = torch.ones_like(ids, dtype=torch.bool) with torch.inference_mode(): probs = model(ids, mask).float().softmax(-1)[0] p_unwanted = sum(float(probs[i]) for i in cfg["binary_rollup"]["unwanted"]) # P(phishing) + P(spam) print("unwanted" if p_unwanted >= 0.5 else "legitimate", {cfg["labels"][str(i)]: round(float(p), 4) for i, p in enumerate(probs)}) ``` On a GPU wrap the forward in `torch.autocast("cuda", dtype=torch.bfloat16)`. Measured single-email latency (batch 1, tokenize + forward + softmax, see the metrics table): GPU bf16 10.08 ms avg on NVIDIA GeForce RTX 3090 Ti, CPU fp32 (16 threads) 458.57 ms avg. ## Files - `model.safetensors` — bf16 weights, metadata `format=pt, dtype=bf16, architecture=EncoderForClassification, config=, labels=` - `config.json` — model config, num_labels, labels, binary_rollup, label provenance, max_seq_len, export dtype, source checkpoint, fine-tune init, binary metrics (TREC-06), three-way metrics (test_holdout3), pretraining summary (null for scratch init) - `tokenizer.json` — byte-level BPE, NFKC, case preserved, specials `[PAD]=0 [UNK]=1 [CLS]=2 [SEP]=3 [MASK]=4` - `README.md` — this card ## Provenance Built for the Skillspire AI & Machine Learning course final project (BITS, 2026). Data decisions, measured VRAM / throughput / latency and every challenge hit along the way are recorded in the project's RUN_LOG.md.