File size: 12,603 Bytes
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
# BITS Phishing Detector — all dials in one place.
# Verified pins and the reasoning behind every number: docs/research/*.md (2026-10-07).

seed: 42

paths:
  data_raw: data/raw
  data_processed: data/processed
  tokenizer: data/processed/tokenizer.json
  checkpoints: checkpoints
  reports: reports
  logs: logs
  app_dir: app

# ---------------------------------------------------------------- model (Step 3)
model:
  # "Large" BERT-style pre-LN encoder. 24L/1024/16/4096/32768/512 = 337,476,608 params with the
  # tied MLM head, 337,443,842 as the deployed classifier (+2.3% over the ~330M target).
  # Switch n_layers to 23 (324,880,384) only if ~330M must be a hard ceiling.
  n_layers: 24
  d_model: 1024
  n_heads: 16            # head_dim 64 -> Flash-SDPA eligible on sm_86
  d_ff: 4096
  vocab_size: 32768      # 2^15, tensor-core aligned
  max_seq_len: 512
  dropout: 0.1
  attention_dropout: 0.1
  norm: pre_ln
  activation: gelu
  tie_mlm_head: true
  grad_checkpointing: false   # B=16 fits without it; checkpointing costs ~1.33x compute

# ---------------------------------------------------------------- tokenizer
tokenizer:
  type: byte_level_bpe
  vocab_size: 32768
  min_frequency: 2
  special_tokens: ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]
  # Trained ONLY on the pretraining corpus + fine-tune train split. Never on val/test.
  train_on: [pretrain, finetune_train]

# ---------------------------------------------------------------- data (Step 2 / data_prep)
data:
  labelled_primary: puyang2025/seven-phishing-email-datasets   # 203,017 rows; text/subject/label/dataset_name
  labelled_excluded:
    - it4lia/PhishingEmailCuratedDatasets_Cleaned   # no body text (subjects + 53 numeric features only)
    - zefang-liu/phishing-email-dataset             # 91.9% exact subset of the primary set, lossy text
  pretrain_corpus: corbt/enron-emails               # 517,401 rows -> ~244k after exact dedup
  holdout_test_source: TREC-06       # never seen in tokenizer, pretraining, or fine-tuning
  second_choice_holdout: CEAS-08     # only after stripping mailing-list footers
  # Positive class = label 1 of the primary set ("phishing or spam / unwanted"). All label-1 rows
  # come from spam-track corpora with no spam-vs-phishing distinction; mapping spam->0 would leave
  # ~0 positives. PENDING Nathan's confirmation (see RUN_LOG.md entry 0).
  spam_policy: positive_class
  min_alnum_chars: 20              # labelled rows: min alphanumerics in subject+body
  pretrain_min_alnum_chars: 50     # corbt bodies shorter than this (after quote stripping) are dropped
  quote_strip_min_alnum: 200       # cut a quoted/forwarded tail only if this much text survives above it
  dedup:
    normalize: "NFKC; lowercase; keep [a-z0-9] only (common.normalize_text)"
    keys: [full_sha1, prefix300_sha1, subject_body_prefix300_sha1, subject_body_full_sha1]
    key_prefix_chars: 300
    key_prefix_min_len: 80
    near_dup_shingle_chars: 50
    near_dup_stride: 25
    near_dup_min_shared: 3
    near_dup_window_chars: 1500
    near_dup_shingle_cap: 2000     # shingles shared by more rows than this are boilerplate; ignored and counted
  finetune_val_fraction: 0.10        # grouped by near-dup cluster, stratified by source x label
  model_input: "subject + \"\\n\\n\" + body"   # never sender/receiver (TREC headers are synthetic)
  synthetic:
    enabled: false                   # <= 2,000 rows, source=synthetic, training-only; results reported with/without
    max_rows: 2000
  # Three-way labels (RUN_LOG entry 6; data_prep.py build3 / check3). label3: 0 legitimate / 1 phishing / 2 spam /
  # -1 unwanted-but-unsplit; label_set: legit / phish / spam / unwanted. The binary `label` column is unchanged.
  threeway:
    # Provenance of a binary-positive row -> label3. TREC-05/06/07 and CEAS-08 never separated spam from phishing
    # (-1: trains on P(phishing)+P(spam)); SpamAssassin / Enron-Spam / Ling label-1 rows are spam folders (2);
    # Nazario and Nigerian_Fraud are phishing corpora by construction (1). Every label-0 row is 0.
    positive_label3_by_source:
      Assassin: 2
      Enron: 2
      Ling: 2
      TREC-05: -1
      TREC-06: -1
      TREC-07: -1
      CEAS-08: -1
      Nazario: 1
      Nigerian_Fraud: 1
    champa:                          # Champa et al. raw corpus mirror (CC-BY-4.0, byte-identical to Zenodo 8339691)
      repo: kudzaiprichard/aura-phishing-email-corpus
      files: [Nazario.csv, Nigerian_Fraud.csv]
      local_dir: data/raw/aura
      label3: 1
    bigshp:                          # locuoco 300k set: research verdict DO_NOT_USE (docs/research/bigshp_research.md)
      enabled: false                 # set true only on a USE / USE_PARTIALLY verdict; the filter chain below then applies
      verdict: DO_NOT_USE
      repo: locuoco/the-biggest-spam-ham-phish-email-dataset-300000
      file: df.csv
      local_dir: data/raw/bigshp
      drop_phish_class: true         # its phish label is source-derived (87% spam-folder / spam-track mail) and stopword-stripped
      min_chars: 160                 # SMS-like fragments
      min_english_fraction: 0.7      # label-2 rows: share of word tokens in our legitimate-mail vocabulary
      near_dup_token_key_len: 30     # within-file near-dup key: first 30 word tokens
    new_source_test_fraction: 0.15   # Nazario + Nigerian_Fraud: cluster-grouped slice -> test_holdout3
    new_source_val_fraction: 0.10    # of the remainder -> finetune_val
    # A new near-dup cluster that links to BOTH train (or the MLM corpus) AND val is a 419 template the primary set
    # already holds on both sides; dropping it keeps train_vs_val_shingle_links at the binary build's 0 (costs ~424
    # Nigerian_Fraud rows). false = pin such clusters to train (adds ~226 info-only train/val near-dup links).
    drop_new_clusters_linked_to_both_train_and_val: true
    holdout3_from_val:               # clean-provenance legit / spam rows moved from finetune_val into test_holdout3
      legit_sources: [CEAS-08, TREC-07, Assassin]
      spam_sources: [Assassin, Enron]
      legit_rows: 2000
      spam_rows: 700
      # exclusions: keys or >=3-shingle links into the pretraining corpus, clusters also in finetune_train,
      # clusters touched by a new row, clusters with an ineligible member (whole clusters move or none)
    display_names: {0: Legitimate, 1: Phishing, 2: Spam}
    binary_rollup_name: "Unwanted (phishing or spam)"

# ---------------------------------------------------------------- VRAM policy
vram:
  target_gb_low: 14
  target_gb_high: 18
  hard_cap_gb: 20
  alloc_conf: "expandable_segments:True"
  log_peak_every_steps: 20

# ---------------------------------------------------------------- Phase 1: MLM pretraining
pretrain:
  precision: bf16_autocast
  sequence_packing: true             # no attn_mask -> Flash SDPA backend
  effective_batch_sequences: 256     # 256 x 512 = 131,072 tokens/step (BERT setting)
  micro_batch: auto                  # batch finder picks the largest candidate whose reserved peak is in the VRAM band
  micro_batch_start: 24              # Nathan's choice 2026-10-08 (17.65 GiB reserved, 25.2k tok/s); 256/24 -> accumulation 11 = effective 264
  micro_batch_candidates: [16, 24, 12, 8]
  epochs: 12                         # Nathan's choice 2026-10-08: ~14 h at 25k tok/s, ~9,550 steps; val MLM loss watched for a rise
  optimizer: adamw
  lr: 1.5e-4
  betas: [0.9, 0.98]
  eps: 1.0e-6
  weight_decay: 0.01                 # excluded for biases and LayerNorm
  grad_clip: 1.0
  warmup_steps: 1000                 # cap; actual warmup = min(warmup_steps, warmup_fraction x total steps)
  warmup_fraction: 0.06              # 3 epochs (~2,460 steps) -> ~148 warmup steps; 6 epochs -> ~295
  schedule: cosine_to_10pct
  mlm_probability: 0.15
  mask_replace_probs: [0.8, 0.1, 0.1]  # [MASK] / random token / unchanged
  loss_on_masked_only: true          # gather masked positions before the MLM head
  checkpoint_every_steps: 500
  log_every_steps: 20
  eval_every_steps: 500

# ---------------------------------------------------------------- Phase 2: fine-tuning (the instructor's dials)
finetune:
  # Three-way head (RUN_LOG entry 6): 0 Legitimate / 1 Phishing / 2 Spam. Lives here, NOT under model:, because the
  # live pretraining run refuses to resume if its model snapshot changes. 2 reproduces the binary baseline runs.
  # Rows with label_set "unwanted" (TREC-05/07, CEAS-08 positives) train on P(phishing)+P(spam) (partial-label NLL).
  num_labels: 3
  epochs: 3
  batch_size: 32                     # examples per optimizer step (= micro_batch x accumulation)
  micro_batch: 16                    # rows per forward/backward; measured ~11.8 GiB reserved at 16 x 512
  eval_batch_size: 64
  lr: 3.0e-5
  weight_decay: 0.01
  betas: [0.9, 0.98]
  eps: 1.0e-6
  grad_clip: 1.0
  warmup_fraction: 0.06
  schedule: linear_decay
  eval_every_steps: 500
  log_every_steps: 20
  loss: cross_entropy                # experiment set: cross_entropy | weighted_cross_entropy | focal (all over the partial-label NLL)
  class_weights: auto                # inverse-frequency over fully-labelled rows when loss == weighted_cross_entropy;
                                     # "unwanted" rows get mean(w_phishing, w_spam)
  focal_gamma: 2.0
  early_stopping:
    # val_f1_unwanted = F1 of the binary roll-up P(phishing)+P(spam) >= 0.5 (identical to the binary runs' val F1,
    # so results stay comparable); alternative: val_macro_f1_3way (macro-F1 over the three classes on val rows
    # with a full label).
    metric: val_f1_unwanted
    patience: 2
  pooling: cls
  freeze_encoder_layers: 0           # experiment dial (instructor: "number of layers")
  dynamic_padding_multiple: 8
  checkpoint_every_steps: 500

# ---------------------------------------------------------------- evaluation
eval:
  metrics: [accuracy, precision, recall, f1, roc_auc, pr_auc, confusion_matrix, recall_at_fpr_1pct, recall_at_fpr_0_1pct]
  credential_lure_slice: true        # secondary slice of the holdout matching credential-lure phrases
  latency_samples: 100               # avg ms/email, GPU and CPU, like the instructor's step 5
  latency_max_len: 512
  threshold: 0.5                     # decision threshold; evaluate.py also reports the best-F1 threshold
  # Three-way evaluation (RUN_LOG entry 6). The binary metrics above are unchanged: they are computed on the
  # roll-up P(unwanted) = P(phishing) + P(spam) (= P(class 1) for a 2-class head), so results stay comparable
  # with the binary-era runs. Three-way metrics use only rows with label3 in {0,1,2}; -1/"unwanted" rows
  # (TREC-05 / TREC-07 / CEAS-08 positives) enter the roll-up only.
  test3_file: test_holdout3.parquet  # clean-provenance three-way holdout written by data_prep (--split test3); read by evaluate.py
  # The three keys below RESTATE the label contract that is fixed in code (evaluate.LABEL_NAMES / UNWANTED_NAME /
  # DECISION_RULE, mirrored in export.py and the app). evaluate.py asserts at startup that they still match and
  # refuses to run otherwise, so a drift is loud; editing them here does not rename anything.
  label_names: {0: Legitimate, 1: Phishing, 2: Spam}
  unwanted_name: "Unwanted (phishing or spam)"
  threeway_decision_rule: rollup_then_argmax   # unwanted iff P(unwanted) >= threshold, then argmax(P(phishing), P(spam)); argmax-of-3 reported alongside
  # documentation only: the sections every evaluated three-way block contains (not read by any script)
  metrics_threeway: [confusion_matrix_3x3, per_class_precision_recall_f1, macro_f1, phishing_vs_spam_on_true_unwanted, per_source_counts]
  top_k_confusions: 25               # read by evaluate.py: size of the phishing->spam / spam->phishing preview tables in error_analysis_test3.md
  model_card_metrics3: reports/metrics_test3.json   # read by export.py: three-way table source (binary table: reports/metrics.json); both files must name the exported checkpoint

# ---------------------------------------------------------------- Hugging Face (ask before any push)
hf:
  owner: BuildITSystems
  model_repo: BuildITSystems/phishing-detector-encoder
  space_repo: BuildITSystems/phishing-detector-demo
  private: true
  space_sdk: gradio
  space_sdk_version: "6.29.1"
  space_python_version: "3.12.12"    # ZeroGPU supports exactly 3.12.12 / 3.10.13
  space_hardware: zero-a10g          # ZeroGPU flavor string (backing GPU: RTX Pro 6000 Blackwell slice)
  space_torch: "2.13.0"              # highest version on the ZeroGPU allow-list (2.14.x not listed)
  weights_format: safetensors_bf16