# BITS Phishing Detector Final project for the Skillspire AI & Machine Learning course (Nathan King, Build IT Systems LLC), presented late Oct / early Nov 2026. This README is the hand-off document for the instructor and for future BITS work. The running record of every decision, number and problem is `RUN_LOG.md` (entries 0-8 as of this writing); this file summarises it and explains how to reproduce it. Status as of 2026-10-08 14:00: data, tokenizer, smoke test and all `src/`, `scripts/` and `app/` code are done; MLM pretraining is running; the presentation notebook is in progress; fine-tuning, evaluation, export and deployment have not run yet. --- ## 1. What this is | | | |---|---| | Task | **Classifier** (not a regressor). Input: one email as `subject + "\n\n" + body` (plain text; sender, headers and attachments are never read). Output: probabilities for three classes, **Legitimate / Phishing / Spam**, plus the roll-up **Unwanted (phishing or spam)** = P(Phishing) + P(Spam). | | Use | Defensive triage: flag unwanted mail for a human to review. Not a filter that deletes mail, not a tool for writing lures. | | Model | One BERT-style pre-LayerNorm encoder written from scratch in plain PyTorch (`src/model.py`, no `transformers`, no pretrained weights): 24 layers, d_model 1024, 16 heads, FFN 4096, vocab 32,768 (own byte-level BPE), 512 tokens. **337,476,608 parameters** with the tied MLM head, **337,443,842** as the classifier. | | Training | Phase 1: masked-language-model pretraining on deduplicated Enron email. Phase 2: fine-tuning with a 3-way head. Rows whose corpus never separated spam from phishing (TREC-05/07, CEAS-08 positives) train on P(phishing)+P(spam) with a partial-label loss. | | Core challenge | Public corpora label "unwanted" mail, not "phishing vs spam"; they are 1998-2008 heavy, overlap each other and the pretraining corpus, and legitimate mail is about twice as long as spam. The work is mostly in honest data: deduplication, leakage checks and a test set nothing upstream has seen. | | Scope | **One strong model.** The experiments vary the instructor's dials (layers, epochs, loss, frozen layers, synthetic data, scratch vs pretrained init) on this one architecture; there is no architecture comparison. | ## 2. Results ### 2.1 Headline metrics (deployed run `ft-focal`, evaluated 2026-10-09 10:46) Model selection used the **validation** set only: `ft-focal` had the best validation F1 of the unwanted roll-up (0.9951) and the best validation three-way macro-F1 (0.9869) of the six runs in section 9. The two holdouts below were then evaluated once per run for reporting; `ft-focal` also leads on both of them. | TREC-06 holdout (16,302 emails, 23.9% unwanted; whole corpus unseen), threshold 0.5 | value | |---|---| | Recall, unwanted (reported first: a missed phish is the costly error) | **0.9741** (101 of 3,894 missed) | | Precision, unwanted | 0.8794 (520 false alarms on 12,408 legitimate; FPR 4.2%) | | F1, unwanted | **0.9243** (best achievable at any threshold: 0.9397 at p >= 0.68) | | Accuracy | 0.9619 | | ROC-AUC / PR-AUC | **0.9946** / 0.9848 | | Recall at 1% FPR / at 0.1% FPR | 0.8955 / 0.6497 | | Credential-lure slice (398 rows matching 37 lure phrases) recall / precision | 0.9959 / 0.9758 | TREC-06 is the hard, honest number: a different corpus (2006 TREC spam track) with its own labelling conventions. `reports/error_analysis.md` shows that many "false positives" are spam-looking mail that TREC-06 labels ham (quoted spam inside mailing-list threads, "remove me from your list" replies, sweepstakes mail) and that the largest block of misses are "ScanMail virus found" notices TREC-06 labels spam. Legitimate TREC-06 mail is also ~3x longer than its spam and 24% of it is truncated at 512 tokens. | test_holdout3 (3,220 emails: 1,999 legit / 521 phishing / 700 spam; tokenizer never saw them) | Legitimate | Phishing | Spam | |---|---|---|---| | Precision / Recall / F1 | 0.9900 / 0.9935 / 0.9918 | 0.9961 / 0.9866 / 0.9913 | 0.9785 / 0.9757 / 0.9771 | | Macro-F1 (3 classes) / accuracy | **0.9867** / 0.9885 | | | | Binary roll-up (unwanted vs legitimate): F1 / recall / precision / ROC-AUC / PR-AUC | 0.9864 / 0.9836 / 0.9893 / 0.9993 / 0.9989 | | | | Phishing vs spam, given the mail is unwanted (1,221 rows) | 0.9959 (4 phishing called spam, 1 spam called phishing) | | | 3x3 confusion matrix (rows true Legitimate / Phishing / Spam, columns predicted): `[1986, 1, 12]`, `[4, 514, 3]`, `[16, 1, 683]`. Three-way accuracy per source: Assassin 95.3% (n=236), CEAS-08 99.0% (n=736), Enron 98.4% (n=628), Nazario 98.6% (n=222), Nigerian_Fraud 98.7% (n=299), TREC-07 99.8% (n=1099). | Prediction time, one email, batch 1 (`reports/latency.json`, 100 TREC-06 samples, mean 280 tokens) | mean | p50 | p95 | vs 74 ms benchmark | |---|---|---|---|---| | GPU, bf16 (RTX 3090 Ti) | **10.1 ms** | 8.9 ms | 13.7 ms | 0.14x (faster) | | CPU, fp32, 16 threads | **459 ms** | 445 ms | 701 ms | 6.2x (slower) | Plots next to these files: `reports/confusion_matrix.png`, `confusion_matrix_test3.png`, `confusion_matrix3_test3.png` (3x3), `roc_curve*.png`, `pr_curve*.png`, `finetune_curves.png`, `pretrain_curves.png`, `eval_finetune_curves*.png`, `eval_pretrain_curves*.png`; written error analyses `error_analysis.md` / `error_analysis_test3.md`. The fine-tune experiment table is `finetune.py list` (section 9). Every run's own evaluation lives in `reports/eval_/`. ### 2.2 Measured facts that exist today | Fact | Value | Source | |---|---|---| | Parameters | 337,476,608 (MLM, tied head) / 337,443,842 (classifier) | `reports/smoke_test.json` | | Pretraining VRAM, micro-batch 16, no checkpointing | 13.65 GiB reserved, 23,642 tok/s | `reports/smoke_test.json` | | Pretraining VRAM, micro-batch 24, no checkpointing (**used**) | 17.65 GiB reserved, 19.80 GiB total device incl. desktop, 25,227 tok/s | `reports/smoke_test.json` | | Pretraining VRAM, micro-batch 24, gradient checkpointing | 8.06 GiB reserved, 19,156 tok/s | `reports/smoke_test.json` | | Measured bf16 matmul | 63.9 TFLOPS (4096^3); the smoke test's 25k tok/s is ~63% MFU of the 80 TFLOPS peak | RUN_LOG 0, 3 | | Convergence check (fresh init, 30 steps) | MLM loss 10.58 -> 7.67 (ln V = 10.40) | RUN_LOG 3 | | Latency, one 512-token email, GPU bf16 (RTX 3090 Ti), untrained-weight smoke test | 10.7 ms (trained checkpoint, real emails: section 2.1) | `reports/smoke_test.json` | | Latency, one 512-token email, CPU fp32, 16 threads, untrained-weight smoke test | 699 ms (319 ms at 128 tokens) | `reports/smoke_test.json` | | Pretraining plan `large-24L` | 12 epochs x 796 steps = 9,552 optimizer steps, micro-batch 24 x 11 = effective batch 264, peak LR 1.5e-4, warmup 573, cosine to 10% | `checkpoints/pretrain/large-24L/run_summary.json` | | **Pretraining result `large-24L`** (complete 2026-10-09 03:31) | 9,552 steps, 1.29B tokens, 15.05 h of training, 23,889 tok/s mean, peak 17.94 GiB reserved; train MLM loss 1.524, **val MLM loss 1.986, masked-token accuracy 62.9%** (val loss fell monotonically, no restart needed) | `checkpoints/pretrain/large-24L/run_summary.json`, `metrics.csv` | The smoke-test latencies were measured on untrained weights (latency does not depend on the weights); the numbers to quote are the trained-checkpoint measurements in section 2.1 (`reports/latency.json`). The instructor's 0.074 s/sample benchmark is met on GPU by a factor of ~7 and missed on CPU by a factor of ~6, which is why the Space runs on ZeroGPU. ### 2.3 Data splits Binary build (RUN_LOG 1, `data_prep.py build`): | split | rows | positives | base rate | sources | |---|---|---|---|---| | finetune_train | 157,113 | 75,675 | 48.2% | TREC-05 46,386 / TREC-07 47,233 / CEAS-08 34,178 / Enron 24,223 / Assassin 5,093 | | finetune_val | 17,643 | 8,642 | 49.0% | same five, grouped by near-duplicate cluster | | test_holdout (TREC-06) | 16,302 | 3,894 | 23.9% | never seen by tokenizer, pretraining or fine-tuning | | pretrain (MLM) | 226,273 docs | - | - | corbt/enron-emails after dedup + quote stripping; 407.9M chars, 107.7M tokens | Three-way build (RUN_LOG 7, `data_prep.py build3`; label3 0 legit / 1 phishing / 2 spam / -1 unwanted-unsplit): | split | rows | legit | phishing | spam | unwanted (TREC/CEAS positives) | |---|---|---|---|---|---| | finetune_train | 160,263 | 81,438 | 3,150 | 12,870 | 62,805 | | finetune_val | 15,284 | 7,002 | 340 | 763 | 7,179 | | test_holdout (TREC-06, rows unchanged) | 16,302 | 12,408 | - | - | 3,894 | | **test_holdout3** | 3,220 | 1,999 | 521 | 700 | - | Plus `data/processed/synthetic.parquet`: 2,000 template-generated rows (1,000 legitimate, 1,000 phishing lures, 0 spam), training-only, off by default. finetune_val shrank from 17,643 to 15,284 rows when test_holdout3 was carved out, so val numbers from binary-era runs are not strictly comparable. --- ## 3. Setup on Fedora 44 Machine used: Fedora 44 KDE, RTX 3090 Ti 24 GB (sm_86), NVIDIA driver 615.71.09 (CUDA 13.4), Ryzen 9 5950X, 40 GB RAM, system Python 3.14.7, uv 0.12.5 (`~/.local/bin/uv`), tmux. ```bash cd "/home/nathank/BITS/dev/Phishing Model" uv venv --python 3.14.7 .venv uv pip install --python .venv/bin/python --torch-backend=cu130 -r requirements-train.txt hf auth login # .venv/bin/hf; needed for the upload step only ``` Why these pins: - **`--torch-backend=cu130`, never `auto`.** The plain PyPI torch 2.14.1 Linux wheel is the CUDA 13.0 build; driver 615 runs it by backward compatibility. uv 0.12.5's `auto` would pick cu132 on this driver. One resolution installs everything (180 packages, all binary wheels, venv ~6.1 GB). - **Python 3.14.7, not 3.15.** torch.compile raises on 3.15, triton 3.8.0 requires <3.15 and `spaces` requires <3.15. Always pass `--python 3.14.7`. - **`huggingface_hub==1.33.0`** because tokenizers 0.23.2 requires `huggingface-hub<2.0`. - **`fsspec==2026.7.0`** because datasets 5.1.0 caps `fsspec[http]<=2026.7.0` (2026.9.0 is newer and breaks the resolve). Do not `uv pip install -U fsspec`. Verify the GPU (do not run while a training job holds the GPU unless you accept a small extra CUDA context): ```bash nvidia-smi .venv/bin/python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.get_device_name(0), torch.cuda.is_bf16_supported())" # expected (2026-10-07): 2.14.1+cu130 13.0 NVIDIA GeForce RTX 3090 Ti True ``` --- ## 4. Project layout ``` Phishing Model/ ├── README.md this file ├── RUN_LOG.md dated log of every run, decision, number, bug and fix (the "challenges" source) ├── config.yaml every dial in one file (section 6) ├── requirements-train.txt pinned local training stack (Python 3.14.7, torch 2.14.1 cu130) ├── .gitignore excludes .venv, data, checkpoints, logs, weights, tokens ├── src/ │ ├── common.py config loading, seeding, logging, text normalisation, dedup keys (shared by all) │ ├── data_prep.py download -> dedupe -> decontaminate -> split -> leakage asserts (build/check/build3/check3) │ ├── tokenizer.py byte-level BPE (NFKC, case kept): train on pretrain + finetune_train only; stats │ ├── model.py from-scratch pre-LN encoder, MLM head, classification head (copied to app/) │ ├── smoke_test.py VRAM micro-batch finder, throughput, SDPA backend check, convergence, latency │ ├── pretrain.py resumable bf16 MLM pretraining (train/export/plot) │ ├── make_synthetic.py 2,000 template-generated training rows (build/show) │ ├── finetune.py 3-way classifier fine-tuning with the instructor's dials (train/plot/list) │ ├── evaluate.py the only reader of the holdouts: metrics, latency, plots, error analysis │ └── export.py bf16 safetensors + config + model card -> model_export// and app/ ├── scripts/ │ ├── launch_pretrain.sh start or resume pretraining in tmux session `phish-pretrain` │ ├── run_experiments.sh fine-tune queue: waits for large-24L/final, runs the six dial experiments, │ │ evaluates each into reports/eval_/, exports ft-ce to model_export/ft-ce and app/ │ └── upload_to_hf.py publish model repo + Space; dry run by default, `--yes` to write ├── app/ the Hugging Face Space │ ├── app.py Gradio UI: 3-class label, prediction time, token saliency, rule-based cues │ ├── README.md Space front matter (sdk gradio 6.29.1, python 3.12.12) + description │ ├── requirements.txt Space pins (torch 2.13.0, spaces 0.51.3, ...) │ ├── .gitattributes LFS rules for weights and tokenizer │ └── model.py, model.safetensors, config.json, tokenizer.json written by export.py (not yet present) ├── FinalProject_PhishingDetector.ipynb presentation notebook in the instructor's 9-step pattern (section 5.10); │ written by the notebook step, in progress as of 2026-10-08 14:00 ├── reference/ │ ├── FashionMNIST_Gradio_Full.ipynb instructor's example notebook (the 9-step pattern) │ └── INSTRUCTOR_REQUIREMENTS.md grading rubric, verbatim ├── docs/research/ │ ├── research_dataset-schemas.md schemas, provenance and licences of every candidate dataset │ ├── verify_data.md independent verification of the data plan and holdout choice │ ├── research_model-math.md parameter / memory / FLOP / latency math for the encoder │ ├── model_math.py pure-Python sizing calculator behind that document │ ├── research_torch-python.md torch 2.14.1 / CUDA 13.0 / Python 3.14 compatibility, SDPA backends │ ├── research_other-libs.md every other pin and its conflicts (fsspec, huggingface_hub, spaces) │ ├── verify_pins.md independent verification of the pins │ ├── research_gradio-spaces-hub.md Gradio 6, ZeroGPU, Space front matter, Hub upload API │ └── bigshp_research.md why the 300k "spam/ham/phish" set was rejected ├── data/ (git-ignored) │ ├── raw/ HF cache for corbt, Champa mirror CSVs (aura/), inspected-only 300k set (bigshp/) │ └── processed/ │ ├── finetune_train.parquet, finetune_val.parquet training / model-selection splits (with label3) │ ├── test_holdout.parquet TREC-06 binary test set │ ├── test_holdout3.parquet clean-provenance three-way test set │ ├── pretrain.parquet MLM corpus (226,273 docs) │ ├── pretrain_tokens.npy/.json, pretrain_val_tokens.npy cached token stream used by pretrain.py │ ├── synthetic.parquet, synthetic_report.json template supplement + shortcut audit │ ├── tokenizer.json, tokenizer_report.json BPE vocabulary + per-split length stats │ ├── splits_manifest.json, data_report.md counts, removals, every leakage assertion │ └── binary_snapshot/ binary-era splits that build3 starts from ├── checkpoints/ (git-ignored) │ ├── pretrain/large-24L/ live run: step-NNNNNNN/, metrics.csv, run_summary.json, final/ when done │ ├── pretrain/dev/ tiny self-test run (delete after Nathan's OK) │ └── finetune// best/, last/, metrics.csv, epochs.csv per fine-tune run (dev, dev-focal = self-tests) ├── reports/ smoke_test.json; experiments.jsonl + finetune_curves.png + pretrain_curves.png are │ dev-only until the real runs regenerate them (section 9); metrics*, latency*, │ confusion/ROC/PR plots, error analyses, eval_/ later ├── model_export// export output (created by export.py) └── logs/ one timestamped log per script invocation + tmux stdout of the pretraining run ``` --- ## 5. How to run each phase, in order All commands run from the project root with `.venv/bin/python` (written `PY` below: `PY=.venv/bin/python`). Every script takes `--config config.yaml` before the subcommand and writes a log to `logs/