File size: 55,489 Bytes
439e7b1 07cdb81 439e7b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 | # 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_<run>/`.
### 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/<run>/ 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_<run>/, 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/<run>/ 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_<run>/ later
├── model_export/<run>/ 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/<script>-.log`. Flags below were checked against each script's `--help` on 2026-10-08.
**While pretraining holds the GPU, prefix any other Python command with `CUDA_VISIBLE_DEVICES=""` and do not
start smoke_test, finetune, evaluate on GPU, or a second training job.**
### 5.1 Data (`src/data_prep.py`)
```bash
$PY src/data_prep.py inspect # optional: schema + 2 rows per dataset, streaming
$PY src/data_prep.py build # binary splits + pretrain corpus + manifest + data_report.md (~2.5 min, 6.9 GB RSS)
$PY src/data_prep.py check # recompute every binary leakage assertion from the written files
$PY src/data_prep.py build3 # three-way labels, Champa phishing corpora, test_holdout3 (~1 min)
$PY src/data_prep.py check3 # recompute binary + three-way assertions
```
Flags: `build --limit N` / `build3 --limit N` are dev-only subsets (`build3 --limit` writes to
`data/processed/dev_threeway_limitN/`); `build --spam-policy {positive_class,map_to_zero,exclude}` (default
`positive_class`); `build --no-strip-quotes`; `--shingle-cap N`; `build3 --bigshp {on,off}` overrides the
rejected 300k set (leave off); `check3 --out-dir`.
**Do not re-run `build` or `tokenizer.py train` while `large-24L` is running**: a new `pretrain.parquet` or
`tokenizer.json` rebuilds the token cache and the run then refuses to resume. `build` also rewrites the
binary splits, so `build3` must follow it.
### 5.2 Tokenizer (`src/tokenizer.py`)
```bash
$PY src/tokenizer.py train # byte-level BPE, vocab 32,768, NFKC, case kept; ~30 s
$PY src/tokenizer.py stats # length / truncation / [UNK] table per split -> tokenizer_report.json
```
`train --vocab-size N`, `train --limit-docs N` (dev). Trains only on `pretrain.parquet` + `finetune_train.parquet`;
val and test files are refused by name and checked with an exact-hash tripwire.
### 5.3 Smoke test (`src/smoke_test.py`) — GPU must be free
```bash
$PY src/smoke_test.py # -> reports/smoke_test.json + printed summary
```
Useful flags: `--candidates 16,24,12,8`, `--ckpt-candidates`, `--steps 30`, `--skip-cpu`, `--n-docs 3000`,
`--latency-samples 100`, `--cpu-threads 16`. It probes micro-batches with the real gradient-accumulation
pattern, measures tokens/s, checks which SDPA kernel runs, runs a 30-step convergence check and times
single-email inference on GPU and CPU.
### 5.4 MLM pretraining (`scripts/launch_pretrain.sh` -> `src/pretrain.py`)
```bash
EPOCHS=12 MICRO_BATCH=24 scripts/launch_pretrain.sh # how large-24L was launched (2026-10-08 12:28)
tmux attach -t phish-pretrain # watch; Ctrl-b d detaches; Ctrl-C inside = save + clean exit
RESUME=1 MICRO_BATCH=24 scripts/launch_pretrain.sh # resume from the latest checkpoint
```
Environment variables: `RUN_NAME` (default `large-24L`), `EPOCHS` (script default 6; fixes the schedule
length at first launch, ignored on resume), `MICRO_BATCH` (**script default 16; always pass 24 for this run**:
16 gives effective batch 256 instead of the run's 264 and the resume is refused), `SESSION` (default
`phish-pretrain`), `RESUME=1`. The script refuses to start if the tmux session already exists; after the
trainer exits the pane waits for Enter (or `tmux kill-session -t phish-pretrain`). Stdout is teed to
`logs/pretrain_<run>_<timestamp>.out`.
Direct use: `$PY src/pretrain.py train --run-name R [--resume [PATH|latest]] [--epochs N] [--max-steps N]
[--max-hours H] [--micro-batch B] [--grad-checkpointing] [--limit-docs N] [--checkpoint-every N]
[--log-every N] [--eval-every N] [--keep-checkpoints K] [--fresh]`. After the run:
```bash
$PY src/pretrain.py plot --run-name large-24L # -> reports/pretrain_curves.png
$PY src/pretrain.py export --run-name large-24L # only if stopped early: builds final/ from the latest checkpoint
```
How it stops (every path writes a checkpoint first; checkpoints are atomic tmp-dir + rename):
| Event | Behaviour | Exit code |
|---|---|---|
| Normal end | `final/` written | 0 |
| Ctrl-C / SIGTERM | save at the next optimizer-step boundary | 0 |
| `--max-hours` reached | save | 0 |
| **VRAM guard**: this process's reserved peak > `vram.hard_cap_gb` (20 GiB) | save, stop before a real OOM | 3 |
| **CUDA OOM** (e.g. the desktop grabs VRAM) in forward/backward, optimizer step or eval | partial step discarded, data cursor rewound, save | 3 |
| 5 consecutive non-finite losses (each such step is skipped) | status `diverged`, save | 4 |
| Config/plan mismatch on resume (model shape, `pretrain:` LR/betas/eps/decay/clip/masking/schedule, seed, effective batch, epochs, data) | refuses before training | 2 |
Checkpoints every 500 steps and at every epoch end (keep 3, ~3.8 GB each) in
`checkpoints/pretrain/large-24L/step-NNNNNNN/`; val MLM loss and accuracy on 64 fixed held-out windows every
500 steps in `metrics.csv`. If val MLM loss rises, stop the run (Ctrl-C) and use `pretrain.py export`.
### 5.5 Synthetic supplement (`src/make_synthetic.py`) — already built
```bash
$PY src/make_synthetic.py build [--n 2000] [--out PATH] [--seed S] # -> data/processed/synthetic.parquet + _report.json
$PY src/make_synthetic.py show --k 6
```
### 5.6 Fine-tuning (`src/finetune.py`) — after pretraining, GPU free
Baseline (config defaults: 3 classes, cross-entropy over the partial-label NLL, 3 epochs, batch 32 = micro-batch
16 x 2, LR 3e-5, early stopping on val F1 of the unwanted roll-up, patience 2):
```bash
$PY src/finetune.py train --run-name ft-ce --init pretrained:checkpoints/pretrain/large-24L/final
```
`ft-ce` is the cross-entropy baseline. The queue ran all six runs of section 9 on 2026-10-09 (03:36-10:02);
**`ft-focal` won on the validation selection metric** (val F1 of the unwanted roll-up 0.9951 vs 0.9929 for
`ft-ce`, val three-way macro-F1 0.9869 vs 0.9782) and is therefore the run evaluated at top level, exported
and deployed. Every example in sections 5.7-5.9 uses it.
**The queue (`scripts/run_experiments.sh`)** is how the experiments are meant to run: it waits for
`checkpoints/pretrain/large-24L/final/model.safetensors`, renders `pretrain.py plot`, then trains the six runs
of section 9 one after another with `--eval-every 1250`, plots each to `reports/finetune_curves_<run>.png`,
evaluates each `best/` on both holdouts into `reports/eval_<run>/` (marker `reports/eval_done_<run>`), and
finally evaluates + exports the main run (`MAIN_RUN`, default `ft-ce`) into the **top-level**
`reports/metrics.json` / `metrics_test3.json` that section 2.1 quotes, `model_export/<MAIN_RUN>/` and `app/`.
The top-level evaluate + export were re-run by hand for `ft-focal` after the queue finished (section 9).
A run whose `best/` exists is skipped, so re-running the script continues after a crash.
```bash
tmux new-session -d -s phish-finetune 'scripts/run_experiments.sh 2>&1 | tee logs/experiments_$(date +%Y%m%d-%H%M%S).out'
```
Environment: `PRETRAIN_RUN` (default `large-24L`), `EVAL_EVERY` (default 1250), `MAIN_RUN` (default `ft-ce`).
The instructor's dials (all flags of `train`):
| Dial | Flag | Values |
|---|---|---|
| Loss function | `--loss` | `cross_entropy` / `weighted_cross_entropy` (inverse-frequency) / `focal` (`--focal-gamma 2.0`) |
| Epochs | `--epochs` | e.g. 1-5; early stopping may end sooner |
| Number of layers | `--n-layers N` | keep the first N pretrained layers (e.g. 12 of 24) |
| Frozen layers | `--freeze-layers K` | freeze embeddings + first K layers |
| Synthetic data | `--synthetic on\|off` | appends the 2,000-row supplement to train only |
| Init | `--init scratch` / `--init pretrained:<dir>` | scratch = ablation showing what pretraining buys |
| Others | `--lr`, `--batch-size`, `--micro-batch`, `--num-labels 2\|3`, `--seed`, `--eval-every`, `--log-every`, `--eval-batch-size` | |
| Dev only | `--limit-rows N`, `--max-steps N`, `--cpu` | not evidence |
Always pass `--init` explicitly: without it the script takes the newest `checkpoints/pretrain/*/final` by
modification time, and `checkpoints/pretrain/dev/final` (a tiny self-test model) exists.
```bash
$PY src/finetune.py train --run-name ft-focal --resume # continue /last after a crash or Ctrl-C
$PY src/finetune.py train --run-name ft-focal --overwrite # discard an existing run dir of that name
$PY src/finetune.py list # experiments.jsonl as a markdown table
$PY src/finetune.py plot --run-name ft-focal # -> reports/finetune_curves.png (`--out PATH` to rename)
```
On resume, `--init/--n-layers/--freeze-layers/--batch-size/--seed/--num-labels` are refused; `--lr` is
re-applied to the restored optimizer. Output: `checkpoints/finetune//{best,last}/`, `metrics.csv`,
`epochs.csv`. The test files are refused by name and by `source` column.
### 5.7 Evaluation (`src/evaluate.py`) — the only script that opens the holdouts
```bash
$PY src/evaluate.py run --checkpoint checkpoints/finetune/ft-focal/best --split test,test3
$PY src/evaluate.py run --checkpoint checkpoints/finetune/ft-focal/best --split val # model selection view, `_val` suffix
```
The queue runs this per experiment with `--run-name <run> --out-dir reports/eval_<run>`; only the main run is
written to the top-level `reports/` (section 2.1).
Writes `reports/metrics.json`, `metrics_test3.json`, `latency.json` (batch-1 `timeit.default_timer` around
tokenize + forward + softmax, GPU bf16 and CPU fp32, next to the 74 ms benchmark), `confusion_matrix*.png`,
`confusion_matrix3_test3.png`, `roc_curve*.png`, `pr_curve*.png`, `error_analysis*.md` (top false negatives /
positives, phishing<->spam confusions, length-confound check, credential-lure slice), one line in
`reports/experiments.jsonl` and one RUN_LOG.md entry. Flags: `--threshold`, `--batch-size`,
`--latency-samples`, `--cpu-threads`, `--max-len`, `--cpu`, `--out-dir`, `--data-dir`, `--run-name`,
`--limit-rows` (dev; no RUN_LOG entry), `--no-run-log`, `--finetune-run-dir` / `--pretrain-run-dir` (extra
`eval_*_curves.png`). The binary metrics use P(unwanted) at 0.5; the three-way decision is roll-up first,
then argmax(phishing, spam), so the 3x3 matrix collapses exactly onto the binary one (plain argmax-of-3 is
reported alongside).
### 5.8 Export (`src/export.py`)
```bash
$PY src/export.py run --checkpoint checkpoints/finetune/ft-focal/best
```
Writes `model_export/ft-focal/` (`model.safetensors` bf16 ~675 MB, `config.json` with label names, metrics and
dials, `tokenizer.json`, `README.md` model card, `export_manifest.json`) and copies `model.py` (sha1-checked)
plus the three weight files into `app/`. It refuses if `reports/metrics*.json` name a different checkpoint, so
run 5.7 on the same `best/` first (`--allow-metrics-mismatch` overrides and records it). Other flags:
`--out-dir`, `--app-dir`, `--dtype {bf16,fp32}`, `--metrics-json`, `--metrics3-json`, `--pretrain-config`.
Local app check before uploading: `CUDA_VISIBLE_DEVICES="" $PY app/app.py` (CPU; drop the prefix once the GPU
is free: `app.py` moves the model to CUDA at import time whenever a GPU is visible).
### 5.9 Upload (`scripts/upload_to_hf.py`) — Nathan only
```bash
$PY scripts/upload_to_hf.py --model-dir model_export/ft-focal --bundle-weights --check-auth # dry run (default): prints the plan, no writes
$PY scripts/upload_to_hf.py --model-dir model_export/ft-focal --bundle-weights --yes # live (what was run on 2026-10-12)
```
The dry run lists every file, size, repo, visibility, the hardware flavour and any blocking problem (a missing
export, a missing `app/model.py`, or `HF_TOKEN_FOR_SPACE` unset when the weights are not bundled). Other flags:
`--bundle-weights` (weights inside the Space, no secret needed; the mode used), `--skip-secret`, `--hardware`,
`--model-repo`, `--space-repo`, `--private/--no-private`, `--commit-message`. `--yes` is only ever run with
Nathan's explicit approval; the one live run happened on 2026-10-12 11:35 (RUN_LOG 24) after his approval of
2026-10-08. Lesson from that run: the Hub validates the Space card's YAML front matter at upload time and the
dry run does not, so a colon inside an unquoted `short_description` failed the first attempt; the value is now
quoted and the model-repo half of the first attempt was simply reused (LFS blobs dedupe by content hash).
### 5.10 The notebook
`FinalProject_PhishingDetector.ipynb` (project root; generated by `scripts/build_notebook.py`, last executed top
to bottom on the CPU on 2026-10-12: 36 code cells, no errors, every artifact present) is the presentation
walk-through. It mirrors the instructor's
`reference/FashionMNIST_Gradio_Full.ipynb` step for step, with the nine headers quoted verbatim: "Step 1: Install
Dependencies & Setup Device", "Step 2: Download Data & Create DataLoaders", "Step 3: Define the Neural Network",
"Step 4: Train the Model", "Step 5: Test Inference Speed and Save the Model", "Step 6: Create the App Files
(Optional)", "Step 7: Launch the Gradio Interface", "Step 8: Authenticate with Hugging Face", "Step 9: Upload
the App to Hugging Face Spaces". It **loads the artifacts above instead of training**: the contract is that it
runs top to bottom on CPU and prints "not available yet — produced by <command>" for anything missing; with the
2026-10-09 artifacts in place it shows the real numbers, curves, confusion matrices and the `ft-focal` export.
```bash
CUDA_VISIBLE_DEVICES="" .venv/bin/jupyter lab
CUDA_VISIBLE_DEVICES="" .venv/bin/jupyter nbconvert --to notebook --execute --inplace FinalProject_PhishingDetector.ipynb
```
### 5.11 The Colab presentation notebook (`FinalProject_PhishingDetector_Colab.ipynb`)
Built by the same script with `--colab` (`--out PATH` picks the output file; the default mode and its output are
unchanged). Design: **the notebook is narrative, the Space is the compute.** Nothing trains or infers in Colab;
every prediction runs on the ZeroGPU Space, and all evidence is pulled from the Hub:
| Need | Where it comes from in Colab |
|---|---|
| Metrics, curves, confusion matrices, experiments table, data report, run log, code | `presentation/` in the model repo (85 files, 3.6 MB; relative paths mirror this project), fetched with `snapshot_download` into `/content/phishing_bundle` |
| Model definition and parameter count (Step 3) | `presentation/src/model.py` imported on the Colab CPU (torch is preinstalled there); falls back to the counts in `config.json` without torch |
| Inference speed (Step 5) | `gradio_client.Client(SPACE, token=...).predict(subject, body, api_name="/predict")` on six synthetic emails: in-app "Prediction Time (s)" measured on the Space's A10G (about 0.23 s cold, 0.01 s warm) plus the round-trip; `latency.json` adds the RTX 3090 Ti numbers |
| The app (Step 7) | `gr.load(SPACE, src="spaces", token=...)` embeds the Space UI; the private model repo's weights never leave the Hub. Gradio 6.29.1 has a bug rebuilding a Space whose Interface has `examples=` (`Textbox.__init__() got an unexpected keyword argument 'class_id'`), so the cell installs a two-line compatibility shim and retries, and falls back to a hand-built proxy `gr.Interface` over `gradio_client` if that also fails. When the Space is public the cell also shows the Space's own page in an `IFrame` of https://builditsystems-phishing-detector-demo.hf.space |
| Auth (Step 8) | Token resolution order: Colab secret `HF_TOKEN` -> `os.environ["HF_TOKEN"]` -> the `hf auth login` cache (outside Colab only when `NOTEBOOK_ALLOW_LOGIN_CACHE=1`, so a headless run never silently forwards the workstation's write token) -> none (enough once the repos are public). Only the token *source* is ever printed |
How Nathan runs it: upload the `.ipynb` to Colab (File -> Upload notebook, or from Drive); while the repos are
private, add a Colab Secret named `HF_TOKEN` with notebook access on, and make it a **fine-grained read token
scoped to the two repos**, because `gr.load` forwards the token to the Space with every proxied call; run all
cells. The first cell installs `gradio==6.29.1 huggingface_hub==1.33.0` (only when it detects Colab). Once both
repos are public no token is needed and the iframe appears automatically.
Verified on 2026-10-12: executed headlessly from an empty directory in a clean Python 3.12 venv (torch CPU,
gradio 6.29.1, huggingface_hub 1.33.0) with no project file reachable: 36/36 code cells ran, zero errors, six
live predictions from the Space, `gr.load` succeeded after the shim retry, the account name printed and no
token-shaped string in any output; the numbers it renders were spot-checked against `reports/metrics*.json`
(RUN_LOG 25). The Colab-only branches (`%pip`, `userdata.get`, inline `launch()`) are exercised by the
`IN_COLAB` switch but were not run on a real Colab runtime; do one rehearsal there.
---
## 6. Config dials (`config.yaml`)
| Section | What it controls |
|---|---|
| `seed`, `paths` | global seed 42; every relative path is resolved against the project root |
| `model` | encoder shape (24 / 1024 / 16 / 4096 / 32768 / 512), dropout, pre-LN, tied MLM head, gradient checkpointing |
| `tokenizer` | byte-level BPE, vocab 32,768, min frequency 2, specials `[PAD] [UNK] [CLS] [SEP] [MASK]`, training corpora |
| `data` | sources, exclusions, holdout source (TREC-06), alnum minimums, dedup keys and shingle parameters, val fraction, `synthetic`, `threeway` (label3 by provenance, Champa mirror, the rejected `bigshp` set with its verdict, holdout3 carving) |
| `vram` | 14-18 GiB target band, 20 GiB hard cap (GiB, compared with `torch.cuda.max_memory_reserved()`), allocator config |
| `pretrain` | bf16, packing, effective batch 256, micro-batch start 24 + candidates, 12 epochs, AdamW LR 1.5e-4 / betas (0.9, 0.98) / eps 1e-6 / decay 0.01 / clip 1.0, warmup, cosine to 10%, 15% masking 80/10/10, checkpoint/log/eval cadence |
| `finetune` | `num_labels: 3`, epochs, batch / micro-batch, LR 3e-5, warmup + linear decay, loss and class weights, focal gamma, early stopping metric + patience, frozen layers, padding multiple |
| `eval` | metric list, credential-lure slice, latency samples, threshold 0.5, test3 file, label contract (asserted against the code), top-k confusions, model-card metrics source |
| `hf` | owner, model and Space repo ids, private flag, Gradio 6.29.1, Python 3.12.12, `zero-a10g`, Space torch 2.13.0, safetensors bf16 |
**Rule during a live pretraining run:** do not edit `model:` or the `pretrain:` keys that shape the plan
(`lr`, `betas`, `eps`, `weight_decay`, `grad_clip`, `mlm_probability`, `mask_replace_probs`, `schedule`,
`effective_batch_sequences`, `warmup_steps`, `warmup_fraction`), `seed`, or anything that changes
`pretrain.parquet` / `tokenizer.json`. Each is stored in the checkpoint and a resume refuses a mismatch. This is
why `num_labels` lives under `finetune:`. `finetune:`, `eval:` and `hf:` can be edited at any time.
---
## 7. Data provenance and licences
| Source | Used for | Licence / terms | Notes |
|---|---|---|---|
| `puyang2025/seven-phishing-email-datasets` @ b7f3f8d9 (203,017 rows) | labelled legit + unwanted: TREC-05/06/07, CEAS-08, Enron-Spam, SpamAssassin | Hub card "other"; upstream Champa, Rabbi & Zibran, "Seven Phishing Email Datasets", figshare 25432108, CC BY 4.0 | Its own train/test split leaks (5.1% exact duplicates) and is ignored. Ling (2,859 academic list rows) excluded. Label 1 = "unwanted": these are spam-track corpora with no phishing/spam distinction. |
| `corbt/enron-emails` @ cfc06c75 (517,401 rows) | MLM pretraining only | CMU Enron corpus (FERC public release); no formal licence; CMU asks users to respect privacy | 52.7% exact duplicates removed, quoted/forwarded history stripped -> 226,273 docs. |
| `kudzaiprichard/aura-phishing-email-corpus` @ 539567ce: `Nazario.csv`, `Nigerian_Fraud.csv` | phishing class (1,507 + 2,504 rows kept) | CC BY 4.0; byte-identical mirror of Champa, Rabbi & Zibran, "Phishing Email Curated Datasets", Zenodo, doi:10.5281/zenodo.8339691 | Nazario phishing archive and advance-fee (419) fraud, 1995-2022. 235 Nigerian rows near-duplicating TREC-06 spam and 13 exact matches removed to keep the test set clean. |
| `data/processed/synthetic.parquet` (2,000 rows) | optional training supplement | generated by `make_synthetic.py`; fictional names, `.example`/`.invalid` domains, RFC 5737 IPs, 555-01xx phones | 8 BEC / Microsoft 365 / invoice lure categories vs 8 ordinary business categories; shared filler pools and a token-purity audit against template shortcuts. Never in val or test, never presented as real mail. |
| Rejected: `locuoco/the-biggest-spam-ham-phish-email-dataset-300000` | nothing | MIT (Kaggle re-upload) | 365,448 rows, 51.5% internal duplicates, a union of corpora we already hold; its "phish" label agrees with provenance only 60.5%; **36,020 rows are copies of our TREC-06 test set**; phish rows went through a stopword-stripping pipeline the others did not (a label-proxy artifact). Not one row ingested. `docs/research/bigshp_research.md`. |
| Rejected: `it4lia/PhishingEmailCuratedDatasets_Cleaned` | nothing | CC BY 4.0 | no body text (subjects/headers + 53 numeric features). |
| Rejected: `zefang-liu/phishing-email-dataset` | nothing | LGPL-3.0 | lowercased re-packaging, 91.9% contained in the primary set, "enron" token shortcut. |
Details and evidence: `docs/research/research_dataset-schemas.md`, `docs/research/verify_data.md`,
`data/processed/data_report.md`. The exported model card states that phishing/spam labels follow corpus
provenance.
---
## 8. Leakage policy
- **TREC-06 is the binary test set and nothing upstream has seen it.** It is excluded from tokenizer training,
the MLM corpus and fine-tuning. It is Enron-clean (5 rows mention "enron", 0 enron.com addresses); TREC-05 ham
and Enron-Spam ham are 86-89% inside the pretraining corpus, so neither could be the test set. Model input is
subject + body only because TREC rewrote the spam headers.
- **Decontamination.** Exact keys on normalised text (full, 300-char prefix, subject+body prefix, subject+body
full) plus near-duplicates (>= 3 shared 50-char shingles over the first 1,500 chars). 1,652 training rows that
near-duplicate TREC-06 templates were removed; 85 pretraining docs near-duplicating holdout emails and 4,004
matching val keys were removed from the MLM corpus.
- **Train/val split** is grouped by near-duplicate cluster and stratified by source x label, so no template
straddles train and val.
- **test_holdout3** rules: legitimate rows only from CEAS-08, TREC-07, SpamAssassin (never Enron, which overlaps
pretraining); spam from SpamAssassin and Enron-Spam spam folders; phishing from Nazario + Nigerian_Fraud.
Rows come from finetune_val (never seen by the tokenizer) or the new sources (never seen by anything); whole
clusters move or none; any cluster with a key or shingle link to the pretraining corpus or train is excluded.
- **Asserted zeros** (recomputed independently from the written files by `check` / `check3`; a non-zero count
fails the build): holdout vs train / val / pretrain keys and shingle links; train vs val full-key rows; val vs
pretrain keys; holdout source rows in train/val; holdout3 vs train / val / pretrain keys and shingle links;
holdout3 vs TREC-06 keys; doc_id shared between splits; label3 contract violations. All are 0
(`data/processed/data_report.md`). Counted but allowed: 5,336 val-to-pretrain near-dup links (val is only
used for model selection), 102,100 train-to-pretrain key overlaps (Enron ham).
- **Code guards.** The tokenizer refuses val/test files by name with an exact-hash tripwire (0 collisions);
`finetune.py` refuses the holdout by file name and `source` column; `pretrain.py` never opens it;
`evaluate.py` is the only reader.
---
## 9. Experimentation record
- **`reports/experiments.jsonl`**: `finetune.py train` appends one JSON line per run (dials: init, layers,
frozen layers, epochs planned/run, loss, focal gamma, LR, batch, class weights, synthetic rows, seed,
selection metric; parameter counts; steps, seconds, peak VRAM; best step; val metrics) and `evaluate.py`
appends one line per invocation with the test metrics. `finetune.py list` renders it as the markdown table for
the presentation. Generated from evidence, never typed up afterwards.
- **Contents**: the six real runs below (2026-10-09) plus one evaluate line per holdout evaluation. The dev
self-test rows from 2026-10-08 were deleted before the queue ran (RUN_LOG 9). `reports/finetune_curves.png` is
the `ft-focal` curve, `reports/finetune_curves_.png` the others, `reports/pretrain_curves.png` the
`large-24L` curve.
- **Pretraining** is one run (`large-24L`); its curve is `metrics.csv` -> `pretrain.py plot`.
- **Fine-tune queue** (`scripts/run_experiments.sh`, section 5.6; one architecture, one dial changed per run
against the baseline). It ran unattended on 2026-10-09 from 03:36 to 10:02 (six runs, each evaluated on both
holdouts). The baseline `ft-ce` was allowed 3 epochs; the five ablations 2 epochs each for run-time reasons;
early stopping (patience 2 on the validation unwanted-F1, evaluated every 1,250 steps) ended five of the six
before their epoch budget. All start from `checkpoints/pretrain/large-24L/final` except `ft-scratch`.
| Run | Flags | Epochs | Question it answers |
|---|---|---|---|
| ft-ce | `--loss cross_entropy` | 3 | reference point: pretrained 24L, cross-entropy, synthetic off |
| ft-weighted | `--loss weighted_cross_entropy` | 2 | class imbalance (3,150 phishing vs 81,438 legit fully-labelled rows) |
| ft-focal | `--loss focal` (gamma 2.0 from config) | 2 | hard-example weighting |
| ft-synthetic | `--loss cross_entropy --synthetic on` | 2 | does the BEC/M365 supplement move anything (reported even if it does not) |
| ft-12L | `--loss cross_entropy --n-layers 12` | 2 | number of layers (half the stack, ~2x faster inference) |
| ft-scratch | `--loss cross_entropy --init scratch` | 2 | what MLM pretraining buys |
### 9.1 Results (validation = selection; holdouts = reporting)
Selection view (validation split, 15,284 rows; the queue's early-stopping metric is the unwanted-F1 column):
| Run | Loss / dial | Epochs run / planned | Stopped by | Best step | Train min | Peak GiB | Val F1 unwanted (selection) | Val macro-F1 3-way | Val P / R phishing |
|---|---|---|---|---|---|---|---|---|---|
| ft-ce | cross_entropy | 1.25 / 3 | early_stopping | 5000 | 51 | 13.13 | 0.9929 | 0.9782 | 0.939 / 0.997 |
| ft-weighted | weighted_cross_entropy | 2.00 / 2 | epochs_done | 10000 | 84 | 13.13 | 0.9937 | 0.9775 | 0.963 / 0.997 |
| **ft-focal** | focal (gamma 2.0) | 2.00 / 2 | early_stopping | 8750 | 84 | 13.13 | **0.9951** | 0.9869 | 0.985 / 0.988 |
| ft-synthetic | cross_entropy, synthetic on | 1.23 / 2 | early_stopping | 5000 | 52 | 13.13 | 0.9927 | 0.9665 | 0.881 / 0.997 |
| ft-12L | cross_entropy, 12 layers | 1.75 / 2 | early_stopping | 6250 | 35 | 6.97 | 0.9925 | 0.9774 | 0.957 / 0.982 |
| ft-scratch | cross_entropy, scratch init | 1.50 / 2 | early_stopping | 5009 | 61 | 13.13 | 0.9875 | 0.9538 | 0.938 / 0.941 |
Held-out view (TREC-06: 16,302 rows, whole corpus unseen; test_holdout3: 3,220 rows, 1,999 / 521 / 700). Evaluated once per run after training; not used for selection:
| Run | TREC-06 recall | precision | F1 | FPR | ROC-AUC | PR-AUC | recall @ 1% FPR | test3 F1 unwanted | test3 macro-F1 | F1 phishing | F1 spam |
|---|---|---|---|---|---|---|---|---|---|---|---|
| ft-ce | 0.9404 | 0.8610 | 0.8990 | 4.8% | 0.9875 | 0.9626 | 0.7745 | 0.9865 | 0.9822 | 0.9769 | 0.9779 |
| ft-weighted | 0.9818 | 0.8307 | 0.9000 | 6.3% | 0.9943 | 0.9833 | 0.8767 | 0.9862 | 0.9852 | 0.9875 | 0.9767 |
| **ft-focal** | 0.9741 | 0.8794 | **0.9243** | 4.2% | 0.9946 | 0.9848 | 0.8955 | 0.9864 | **0.9867** | 0.9913 | 0.9771 |
| ft-synthetic | 0.9679 | 0.8402 | 0.8995 | 5.8% | 0.9908 | 0.9716 | 0.7871 | 0.9873 | 0.9814 | 0.9772 | 0.9749 |
| ft-12L | 0.9461 | 0.8120 | 0.8739 | 6.9% | 0.9836 | 0.9513 | 0.6608 | 0.9823 | 0.9807 | 0.9807 | 0.9721 |
| ft-scratch | 0.9165 | 0.7752 | 0.8400 | 8.3% | 0.9755 | 0.9456 | 0.7773 | 0.9767 | 0.9634 | 0.9574 | 0.9470 |
What the dials say: focal loss is the clear winner (fewer misses on TREC-06 at a lower false-alarm rate, and
the best phishing-vs-spam separation); inverse-frequency class weights buy recall at the cost of precision;
the 2,000-row synthetic supplement does not help (reported, as promised, even though it does not move anything);
halving the stack to 12 layers costs ~5 F1 points on TREC-06 for ~2x faster inference; and scratch
initialisation is 8 F1 points behind the same model with MLM pretraining, which is what pretraining buys.
`ft-focal` was selected on validation alone, before the holdouts were read, and is the run evaluated at top
level (RUN_LOG 22), exported to `model_export/ft-focal/` + `app/` and uploaded (section 10).
Not run, optional if time allows: `--freeze-layers 12` (forgetting vs cost), an epochs sweep (`--epochs 1` /
`5`), `--num-labels 2` (binary baseline comparable with the binary-era plan).
---
## 10. Deployment
| Item | Value |
|---|---|
| Model repo | `BuildITSystems/phishing-detector-encoder` (private): `model.safetensors` (bf16), `config.json`, `tokenizer.json`, `README.md` model card |
| Space | `BuildITSystems/phishing-detector-demo` (private), Gradio SDK 6.29.1, hardware `zero-a10g` (ZeroGPU) |
| Space stack | **torch 2.13.0 on Python 3.12.12**: ZeroGPU supports torch 2.8.0-2.13.0 and Python 3.12.12 / 3.10.13 only, and no torch.compile. Training uses 2.14.1 on 3.14; safetensors makes the weights portable. Gradio is pinned by `sdk_version` in `app/README.md`, not in `requirements.txt`. |
| Weights at startup | The three weight files are **bundled into the Space** (`upload_to_hf.py --bundle-weights`, the mode used), so no secret is needed and the Space works even while the model repo is private. `app.py` can alternatively download them from the repo named by the `PHISH_MODEL_REPO` variable with an `HF_TOKEN` read-token secret. |
| UI | Subject + body in; 3-class `gr.Label`, "Prediction Time (s)" (`timeit.default_timer`), gradient x input token highlights, rule-based cues shown for context only (they never feed the probability). |
**Before class: make both repos public** (the instructor needs the model link and a Space that opens without
an invite), then open the Space once so ZeroGPU is warm. `create_repo(exist_ok=True)` does not change the
visibility of an existing repo, so do it in each repo's Settings page on huggingface.co, or:
```bash
.venv/bin/python -c "from huggingface_hub import HfApi; a=HfApi(); a.update_repo_settings('BuildITSystems/phishing-detector-encoder', private=False); a.update_repo_settings('BuildITSystems/phishing-detector-demo', repo_type='space', private=False)"
```
`app/README.md` (the Space card) already links the model repo.
---
## 11. Instructor requirement mapping
| Requirement (`reference/INSTRUCTOR_REQUIREMENTS.md`) | Where it is satisfied |
|---|---|
| Classifier, not regressor | 3-class softmax classifier + binary roll-up (section 1) |
| Dataset from `torchvision.datasets` | **Deviation, agreed text variant**: there is no torchvision email dataset. Public Hugging Face datasets loaded with `datasets` (section 7); the PyTorch data pipeline is kept (DataLoader below). |
| Problem statement: input, classes, challenge | Section 1 |
| Algorithm and architecture, >= 2 layers | 24-layer transformer encoder, `src/model.py`; justification in `docs/research/research_model-math.md` and RUN_LOG 0 |
| Loss function | Partial-label NLL (= cross-entropy for fully-labelled rows) with `--loss cross_entropy / weighted_cross_entropy / focal`; MLM cross-entropy on masked tokens for pretraining |
| Optimizer | AdamW (fused, no decay on biases/LayerNorm), grad clip 1.0, bf16 autocast |
| Epochs, early stopping / LR scheduling | Fine-tune: early stopping on val F1 (patience 2), warmup + linear decay. Pretrain: warmup + cosine to 10%, val MLM loss every 500 steps |
| Justify choices | This README, RUN_LOG.md, `docs/research/` |
| `DataLoader` with batching, shuffling, preprocessing | `finetune.make_loader`: `torch.utils.data.DataLoader` with a seeded length-bucketed shuffled `batch_sampler` and a padding collate; tokenisation as the preprocessing transform (RUN_LOG 8: moved from a numpy sampler on 2026-10-08 13:55, losses verified identical, explicit `generator` to keep the dropout stream unchanged) |
| Evaluate on an unseen test set with standard metrics | `evaluate.py` on TREC-06 and test_holdout3: accuracy, precision, recall, F1, ROC-AUC, PR-AUC, confusion matrices (section 2.1) |
| Inference timer, seconds per single prediction | `evaluate.py` -> `reports/latency.json` (GPU and CPU, batch 1); `smoke_test.py`; the app's "Prediction Time (s)" |
| Gradio UI: input -> label + inference time | `app/app.py` (text input instead of an image upload) |
| HF Spaces with `app.py`, weights, `requirements.txt` | Section 10; `upload_to_hf.py` |
| Class: dataset, trained model, performance results | Sections 2, 7; `reports/` |
| Class: model on Hugging Face, link to instructor | `https://huggingface.co/BuildITSystems/phishing-detector-encoder` (make public first) |
| Class: live Gradio app on a Space, not gradio.live | `https://huggingface.co/spaces/BuildITSystems/phishing-detector-demo` |
| Class: Space has app.py, model.py, requirements.txt (pinned torch/gradio) + weights | `app/`; torch pinned in requirements.txt, gradio pinned by `sdk_version` |
| Class: show experimentation (layers, epochs, loss), record each run | Section 9, `reports/experiments.jsonl`, `finetune.py list`, RUN_LOG.md |
| Class: prediction time per sample vs 0.074 s, GPU and CPU | Section 2.1: 10.1 ms GPU bf16, 459 ms CPU fp32 per email (`reports/latency.json`) |
| Class: presentation covers data, algorithm, performance, challenges | RUN_LOG.md (OOM tuning, leakage, rejected datasets, reviewer-found bugs), sections 2, 7, 8, 12 |
| Class: synthetic data allowed | `make_synthetic.py`, `--synthetic on`, reported with and without |
| Class: one strong model, not an architecture comparison | One architecture (24L encoder); the six runs of section 9 vary training dials only, and `ft-focal` is the one deployed |
| Notebook in the instructor's 9-step pattern | Section 5.10 |
---
## 12. Known limitations and ethics
- **Defensive use only.** It flags mail for a human; it is not a verdict, not a replacement for SPF/DKIM/DMARC,
sender reputation, link or attachment scanning, and must not be used to tune lures against.
- **Old corpora.** Training mail is English, 1995-2022 with the bulk 1998-2008 (Enron, TREC, CEAS,
SpamAssassin). Modern lures (MFA fatigue, QR codes, chat-app phishing) and non-English mail are out of
distribution; the synthetic supplement only nudges this.
- **Spam vs phishing follows provenance**, not per-message review: phishing = Nazario + Nigerian fraud +
synthetic lures; spam = SpamAssassin and Enron-Spam spam folders. Only 3,150 real phishing rows carry the
distinction; 62,805 TREC/CEAS positives train as "unwanted" only. Trust the roll-up first.
- **Length confound.** Legitimate emails average about twice the tokens of unwanted ones in every split
(TREC-06: 842 vs 283). Truncation at 512 tokens blunts it; `evaluate.py` checks it in the error analysis.
- **Truncation.** About 22% of emails exceed 512 tokens (~1,800 characters) and are judged on their opening.
- **Test-set caveat.** About 47% of TREC-06 ham is academic mailing-list traffic, which is not typical business mail.
- **CPU latency.** 699 ms per email on CPU vs the 74 ms benchmark; the benchmark is met on GPU only.
- **Privacy.** Enron mail is real correspondence released by FERC; nothing pasted into the Space is stored,
but do not paste secrets or client content.
---
## 13. Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Pretraining exits 3 ("CUDA OOM" or "VRAM guard") | Another process took VRAM (browser, game, a second job) or the 20 GiB guard tripped. A checkpoint was saved. Check `nvidia-smi`, close the culprit, then `RESUME=1 MICRO_BATCH=24 scripts/launch_pretrain.sh`. The run's own peak is ~17.9 GiB reserved and the desktop adds ~2 GiB, so total device use sits near 20 GB. If it recurs, resume directly with `$PY src/pretrain.py train --run-name large-24L --resume --micro-batch 24 --grad-checkpointing` (8.06 GiB measured, ~25% slower). Accumulation = round(256 / micro-batch), so of the micro-batches that fit in 24 GB only 24 (x11) and 22 (x12) keep the effective batch at 264; 16 gives 256 and is refused on resume. |
| Resume refused, exit 2, "'effective_batch' differs" | `launch_pretrain.sh` defaults to `MICRO_BATCH=16`; pass `MICRO_BATCH=24`. |
| Resume refused, "'pretrain'/'schedule'/'data' differs" | `config.yaml` `pretrain:`/`model:`/`seed` was edited, or `pretrain.parquet` / `tokenizer.json` changed. Revert the edit; see section 6. |
| `launch_pretrain.sh`: "tmux session already exists" | The finished pane waits for Enter: `tmux attach -t phish-pretrain` and press Enter, or `tmux kill-session -t phish-pretrain` once the trainer has exited. |
| Fine-tune crashed or was stopped | `$PY src/finetune.py train --run-name <run> --resume` (restores optimizer, RNG and batch position). |
| Fine-tune picked the wrong pretrained model | Pass `--init pretrained:checkpoints/pretrain/large-24L/final`; the default is the newest `*/final` by mtime and `dev/final` exists. |
| Install resolves a Python 3.15 interpreter | torch.compile, triton 3.8.0 and `spaces` all exclude 3.15. Recreate with `uv venv --python 3.14.7 .venv`. |
| Install fails on fsspec | datasets 5.1.0 caps `fsspec<=2026.7.0`; keep `fsspec==2026.7.0` from requirements-train.txt and never upgrade it alone. |
| Install pulls cu132 wheels | `--torch-backend=auto` was used; reinstall with `--torch-backend=cu130`. |
| huggingface_hub 2.x conflict | tokenizers 0.23.2 needs `<2.0`; keep 1.33.0. |
| `export.py` refuses: metrics name another checkpoint | Run `evaluate.py run --split test,test3` on the same `best/` first. |
| Space build fails on torch | ZeroGPU allows torch <= 2.13.0 and Python 3.12.12; keep `app/requirements.txt` and the `python_version` front matter as they are. |
| Space starts but cannot load weights | `HF_TOKEN` secret missing or not a read token for the model repo, or `PHISH_MODEL_REPO` variable wrong; re-run the upload with `HF_TOKEN_FOR_SPACE` set, or use `--bundle-weights`. |
| Any Python command while pretraining runs | Prefix with `CUDA_VISIBLE_DEVICES=""`; never run `smoke_test.py`, a GPU fine-tune or GPU evaluation alongside it. |
|