language:
- en
license: other
tags:
- text-classification
- phishing-detection
- distilbert
base_model: distilbert-base-uncased
pipeline_tag: text-classification
Model Card — PriorMail Phishing Detector v2.1
Model ID: phishing/v2.1
Owner: Faiz (PJK-GM095)
Date: 2026-06-19
Git SHA: 7cdeae7 (branch feat/phishing-distilbert-v2)
Status: In-distribution §8 gates PASS; real-world acceptance gate FAILS on false-negative rate (documented known-limitation below). Ship decision is deliberate, scoped, and pending sign-off from Insan + Syafiq — this is not an automatic promotion.
What This Model Does
Binary email classifier: legit (0) vs phishing (1).
Used by prior-mail-backend to flag potentially malicious emails. Intended as one layer of defense — see Known Limitations for why it should not be the sole line.
Base Model
distilbert-base-uncased (~67M params).
Replaces v1.0's bert-base-multilingual-cased: the training data is English-only, and DistilBERT is ~3× smaller and faster on CPU (the backend runs CPU on Render), easing the 500 ms latency gate and the checkpoint-size constraint.
Training Data
| Source | Role | Notes |
|---|---|---|
ealvaradob/phishing-dataset (texts) |
Phishing + benign legit | ~20,137 rows; ~62% benign |
insanar/prior-mail-priority (v4) |
Legit negatives | Product-distribution legit mail (transactional/banking) |
Synthetic augmentation (src/data/augment.py) |
Both classes | 600 rows/class, header-complete, seed 42 |
Enron (emails.csv) is off in v2 (it made v1 learn "non-Enron-style ⇒ phishing" → over-flagging). Legit pool downsampled to ~1.2× phishing; deduplicated on the first 200 chars of body before splitting to prevent leakage.
After dedup: ~16,669 rows (7,572 phishing / 9,097 legit) Split (stratified, seed 42): train 11,667 / val 2,501 / test 2,501
The synthetic augmentation covers tactics the public corpora miss (BEC/CEO-fraud, fake invoice, brand- and IT-helpdesk credential harvest, delivery). It is header-complete on both classes so header presence cannot become a class proxy (the v1 leak).
Input / Output Format
Input (src/data/preprocess.build_phishing_input()):
{sender display-name + domain} [SEP] {subject} [SEP] {body}
Text is cleaned with keep_domains=True: URL host and email domain are kept as signal (lookalike domains, odd TLDs) while the PII-bearing URL path/query and email local-part are masked. The [SEP] scaffolding is always present (header-less rows carry empty fields) — this is what makes the sender/subject reinstatement leak-safe.
Output:
- Softmax probability for class 1 (phishing), range [0, 1].
- Label
phishingif probability ≥ threshold, elselegit. - Threshold: 0.06, stored in
threshold.json. Selected on the validation set to maximize F-beta (β=2, recall-weighted — false negatives are the worst case) subject to precision ≥ 0.80; median of the optimal plateau.
Training Config
See configs/phishing_v2.yaml. Key hyperparameters:
| Parameter | Value |
|---|---|
| Learning rate | 2e-5 (AdamW) |
| Batch size | 16 per device |
| Max sequence length | 512 |
| Epochs | 4 (early stopping, patience 2) |
| Early stopping metric | f1_phishing |
| Loss | Weighted cross-entropy, phishing_class_multiplier=1.0 (threshold drives recall, not the loss) |
| Mixed precision | bf16 |
| Seed | 42 |
Evaluation Results
In-distribution test set (n=2,501) — §8 corpus gates
| Metric | Result | Gate | Status |
|---|---|---|---|
| Recall | 0.990 | ≥ 0.95 | PASS ✅ |
| Precision | 0.977 | ≥ 0.80 | PASS ✅ |
| Latency p95 (CPU) | 242 ms | < 500 ms | PASS ✅ |
| F1 | 0.984 | — | — |
Confusion matrix (test):
| Predicted Legit | Predicted Phishing | |
|---|---|---|
| Actual Legit | 1,339 (TN) | 26 (FP) |
| Actual Phishing | 11 (FN) | 1,125 (TP) |
The test split is drawn from the same corpora as training, so it measures in-distribution fit, not real-world behaviour (v1.0 passed these yet over-flagged real mail). The acceptance set below is the real-world judge.
Real-world acceptance set (36 hand-curated .eml, 18 legit / 18 phishing)
| Metric | Result | Gate | Status |
|---|---|---|---|
| False-positive rate | 0.00 (0/18) | ≤ 0.20 | PASS ✅ |
| False-negative rate | 0.33 (6/18) | ≤ 0.05 | FAIL ❌ |
| Accuracy | 0.833 | — | — |
- Zero false positives — including deliberately "scary but legit" mail (a real new-sign-in security alert, bank statement, password-reset notice, CI/Slack/GitHub notifications). The v1/v2 over-flagging problem is resolved.
- Caught (12/18): credential harvest (PayPal, IT-helpdesk), BEC/CEO-fraud, fake invoice, bank/card suspension, lottery, package delivery, tax refund, crypto wallet, job-offer scam, voicemail lure.
- Missed (6/18), all scored ≈0.00: phishing that mimics a routine SaaS/workflow notification — DocuSign signature request, MFA-reset notice, payroll direct-deposit redirect, shared-document lure, subscription-renewal scare, and an Office 365 lookalike-domain login.
Known Limitations
- Workflow-mimicking phishing (primary gap). The model learned "phishing = urgent threat + suspicious URL." Phishing that impersonates a calm, routine notification (DocuSign/MFA/payroll/shared-document/subscription) whose only tell is a lookalike sender domain is scored confidently legit. This is the ceiling of synthetic-template augmentation — the model reliably catches tactics it was trained on and misses novel ones. Closing it requires diverse real phishing data (e.g. Nazario / PhishTank email corpora), not more synthetic templates, and/or a header/auth-signal layer (SPF/DKIM/DMARC, display-name↔domain mismatch) at the backend — which is the right home for sender-spoofing detection.
- Acceptance FN gate fails (0.33 > 0.05). Per CLAUDE.md §8/§12 this is not a clean promotion. It is shipped as a scoped candidate with this limitation documented; do not treat the model as a sole line of defense.
- English only. Indonesian-language phishing is not represented and not validated.
- Adversarial robustness (obfuscated URLs, Unicode/homoglyph tricks beyond simple lookalikes) is not systematically evaluated.
Artifacts
| File | Description |
|---|---|
model.safetensors |
Fine-tuned weights (~256 MB) |
config.json |
HuggingFace model config (id2label: 0=legit, 1=phishing) |
tokenizer.json, tokenizer_config.json, vocab.txt |
Tokenizer (distilbert-base-uncased) |
threshold.json |
{"threshold": 0.06} |
training_config.yaml |
Full training config + git SHA + timestamp |
eval_report.json |
In-distribution test metrics + confusion matrix + latency |
acceptance_report.json |
36-file real-world acceptance results (per-file table) |
val_metrics.json |
Validation metrics at best epoch |
Changelog
| Version | Date | Notes |
|---|---|---|
| v1.0 | 2026-06-13 | Initial release. mBERT. Passed corpus gates; over-flagged real mail at inference. |
| v2.1 | 2026-06-19 | DistilBERT rework. Domain-aware cleaning (A1), header-complete synthetic augmentation + sender/subject input (B1/B2), FN-averse threshold selection, real-world .eml acceptance gate. Corpus gates pass, FP eliminated (0%); documented FN gap on workflow-mimicking phishing. |
Reproduce:
make data-phishing && make train-phishing config=configs/phishing_v2.yaml && make eval-phishing config=configs/phishing_v2.yamlthenpython -m src.eval.acceptance_phishing --config configs/phishing_v2.yaml(git7cdeae7, seed 42).