BuildITSystems commited on
Commit
07cdb81
·
verified ·
1 Parent(s): 439e7b1

Refresh presentation bundle (RUN_LOG 25, README 5.11, builder --colab) and add the Colab notebook

Browse files
presentation/FinalProject_PhishingDetector_Colab.ipynb ADDED
@@ -0,0 +1,1306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "cell-00",
6
+ "metadata": {},
7
+ "source": [
8
+ "# BITS Phishing Detector: a from-scratch 337M-parameter email classifier\n",
9
+ "\n",
10
+ "**Nathan King, Build IT Systems LLC (BITS)**\n",
11
+ "Skillspire AI & Machine Learning course, final project. Presented in class, late October / early November 2026.\n",
12
+ "\n",
13
+ "**Abstract.** Small-business inboxes are triaged by people, not security teams, so this project builds a\n",
14
+ "*defensive* phishing / spam triage model that a person can paste an email into. It is a text **classifier**\n",
15
+ "(three classes: **legitimate**, **phishing**, **spam**) and nothing in it is pretrained or borrowed: the model is a\n",
16
+ "24-layer BERT-style encoder written in plain PyTorch (`src/model.py`, 337M parameters), the tokenizer is a\n",
17
+ "byte-level BPE trained on our own corpus, and the weights come from two training phases on one RTX 3090 Ti:\n",
18
+ "masked-language-model pretraining on deduplicated Enron email, then fine-tuning on public labelled corpora\n",
19
+ "with a partial-label loss (the TREC / CEAS positives never separated spam from phishing, so they train as\n",
20
+ "\"unwanted\"). The clean TREC-06 corpus is held out as the binary test set and a separate clean-provenance\n",
21
+ "three-way holdout gives the 3x3 confusion matrix. The classifier is exported to safetensors and deployed as a\n",
22
+ "Gradio app on a Hugging Face Space (ZeroGPU). This notebook follows the nine steps of the course reference\n",
23
+ "notebook and loads the real artifacts of the project; anything that is still being produced while the GPU\n",
24
+ "trains is reported as \"not available yet\" together with the command that produces it."
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "markdown",
29
+ "id": "cell-01",
30
+ "metadata": {},
31
+ "source": [
32
+ "**Colab edition.** This copy of the notebook runs with none of the project files present: the narrative, tables and\n",
33
+ "curves are read from the `presentation/` bundle inside the model repo on the Hugging Face Hub (reports, metrics, curves,\n",
34
+ "configs and source files, mirrored at the project's relative paths: no weights, no datasets), and every computation that\n",
35
+ "needs the 337M-parameter model is delegated to the deployed Space on ZeroGPU. Nothing trains here and no weights are\n",
36
+ "downloaded. While the two repos are private, add a Colab secret named `HF_TOKEN` (notebook access switched on). Make it a\n",
37
+ "**fine-grained read-only token scoped to the two repos** (`BuildITSystems/phishing-detector-encoder` and\n",
38
+ "`BuildITSystems/phishing-detector-demo`): `gr.load` and `gradio_client` forward the token to the Space with every proxied\n",
39
+ "call, so it should never be a write token. Once the repos are public no token is needed."
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "code",
44
+ "execution_count": null,
45
+ "id": "cell-02",
46
+ "metadata": {},
47
+ "outputs": [],
48
+ "source": [
49
+ "# The Hub identifiers the notebook depends on (change them here if the repos are renamed or forked).\n",
50
+ "MODEL_REPO = \"BuildITSystems/phishing-detector-encoder\" # model repo: bf16 weights + the presentation/ bundle\n",
51
+ "SPACE = \"BuildITSystems/phishing-detector-demo\" # ZeroGPU Space running app/app.py (weights bundled)\n",
52
+ "SPACE_HOST = \"https://builditsystems-phishing-detector-demo.hf.space\" # the Space's embed host (iframe; needs a public Space)\n",
53
+ "PRESENTATION_PREFIX = \"presentation\" # folder in MODEL_REPO mirroring the project's relative paths"
54
+ ]
55
+ },
56
+ {
57
+ "cell_type": "markdown",
58
+ "id": "cell-03",
59
+ "metadata": {},
60
+ "source": [
61
+ "**Step 1: Install Dependencies & Setup Device**\n",
62
+ "\n",
63
+ "Colab preinstalls torch, pandas and matplotlib; the two pins installed here are `gradio` 6.29.1 (the Space's own SDK\n",
64
+ "version, so `gr.load` speaks the same protocol) and `huggingface_hub` 1.33.0 (the version the project's upload script was\n",
65
+ "written against). The token is resolved in this order and only its *source* is printed, never its value: Colab secret\n",
66
+ "`HF_TOKEN` -> environment variable `HF_TOKEN` -> the `hf auth login` cache -> none (public repos still work). Then the\n",
67
+ "`presentation/` bundle (a few MB) is downloaded from the model repo and `ROOT` points at it, so every later cell reads the\n",
68
+ "same relative paths as the local notebook; a file that is not in the bundle is reported by `artifact()` as \"not available\n",
69
+ "yet\" with the command that produces it, exactly as on the workstation. \"Device\" here means the remote one: the Space's\n",
70
+ "runtime is reported next to whatever accelerator this Colab runtime happens to have, which the model never uses."
71
+ ]
72
+ },
73
+ {
74
+ "cell_type": "code",
75
+ "execution_count": null,
76
+ "id": "cell-04",
77
+ "metadata": {},
78
+ "outputs": [],
79
+ "source": [
80
+ "import os, sys, json, time, re, tempfile\n",
81
+ "from pathlib import Path\n",
82
+ "from timeit import default_timer as timer\n",
83
+ "\n",
84
+ "IN_COLAB = \"google.colab\" in sys.modules\n",
85
+ "if IN_COLAB:\n",
86
+ " %pip install -q gradio==6.29.1 huggingface_hub==1.33.0\n",
87
+ "else:\n",
88
+ " print(\"not running in Colab: no pip install, the already-installed gradio / huggingface_hub are used\")\n",
89
+ "os.environ.setdefault(\"HF_HUB_DISABLE_PROGRESS_BARS\", \"1\") # quiet downloads (must precede the huggingface_hub import)\n",
90
+ "os.environ.setdefault(\"GRADIO_ANALYTICS_ENABLED\", \"False\") # gradio / gradio_client telemetry off\n",
91
+ "os.environ.setdefault(\"HF_HUB_DISABLE_TELEMETRY\", \"1\") # huggingface_hub telemetry off\n",
92
+ "import warnings\n",
93
+ "warnings.filterwarnings(\"ignore\", message=\"IProgress not found\") # tqdm's ipywidgets nag in kernels without ipywidgets (Colab has it)\n",
94
+ "\n",
95
+ "import numpy as np\n",
96
+ "import pandas as pd\n",
97
+ "import matplotlib.pyplot as plt\n",
98
+ "import yaml\n",
99
+ "import gradio\n",
100
+ "import huggingface_hub\n",
101
+ "from huggingface_hub import HfApi, snapshot_download\n",
102
+ "from IPython.display import display, Image, Markdown\n",
103
+ "\n",
104
+ "T_NOTEBOOK_START = timer()\n",
105
+ "pd.set_option(\"display.width\", 160); pd.set_option(\"display.max_columns\", 40)\n",
106
+ "print(f\"python {sys.version.split()[0]} gradio {gradio.__version__} huggingface_hub {huggingface_hub.__version__} \"\n",
107
+ " f\"pandas {pd.__version__} running in Colab: {IN_COLAB}\")\n",
108
+ "\n",
109
+ "# Token: Colab secret HF_TOKEN -> environment variable HF_TOKEN -> `hf auth login` cache -> none. Only the SOURCE is printed.\n",
110
+ "# The token is forwarded to the Space by gr.load / gradio_client, so it should be a read-only token scoped to the two repos.\n",
111
+ "# Outside Colab the login cache usually holds the workstation's WRITE token, so that fallback is opt-in there:\n",
112
+ "# set NOTEBOOK_ALLOW_LOGIN_CACHE=1 (or export HF_TOKEN=<read token>) before running the headless test.\n",
113
+ "def resolve_token():\n",
114
+ " if IN_COLAB:\n",
115
+ " try:\n",
116
+ " from google.colab import userdata\n",
117
+ " if userdata.get(\"HF_TOKEN\"):\n",
118
+ " return userdata.get(\"HF_TOKEN\"), \"Colab secret HF_TOKEN\"\n",
119
+ " except Exception: # the secret is not defined, or notebook access is off in the Secrets panel\n",
120
+ " pass\n",
121
+ " if os.environ.get(\"HF_TOKEN\"):\n",
122
+ " return os.environ[\"HF_TOKEN\"], \"environment variable HF_TOKEN\"\n",
123
+ " if (IN_COLAB or os.environ.get(\"NOTEBOOK_ALLOW_LOGIN_CACHE\")) and huggingface_hub.get_token():\n",
124
+ " return huggingface_hub.get_token(), \"huggingface_hub login cache (hf auth login)\"\n",
125
+ " return None, \"none (anonymous: works only once the repos are public)\"\n",
126
+ "\n",
127
+ "TOKEN, TOKEN_SOURCE = resolve_token()\n",
128
+ "print(\"Hugging Face token source:\", TOKEN_SOURCE)\n",
129
+ "api = HfApi(token=TOKEN)"
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": null,
135
+ "id": "cell-05",
136
+ "metadata": {},
137
+ "outputs": [],
138
+ "source": [
139
+ "# The presentation/ bundle: reports, curves, metrics, configs and source files mirrored at the project's relative paths (no\n",
140
+ "# weights, no datasets). ROOT points at it, so every later cell reads the same paths as the local notebook.\n",
141
+ "BUNDLE_DIR = Path(\"/content/phishing_bundle\") if IN_COLAB else Path(tempfile.gettempdir()) / \"phishing_bundle\"\n",
142
+ "try:\n",
143
+ " snap = Path(snapshot_download(MODEL_REPO, repo_type=\"model\", allow_patterns=[f\"{PRESENTATION_PREFIX}/*\", \"config.json\"],\n",
144
+ " token=TOKEN, local_dir=BUNDLE_DIR))\n",
145
+ "except Exception as e:\n",
146
+ " raise RuntimeError(f\"cannot download the bundle from {MODEL_REPO} ({type(e).__name__}): while the repo is private this notebook \"\n",
147
+ " \"needs a read token for the BuildITSystems account (Colab: Secrets panel -> HF_TOKEN, notebook access on)\") from e\n",
148
+ "ROOT = snap / PRESENTATION_PREFIX\n",
149
+ "EXPORT_CONFIG = json.loads((snap / \"config.json\").read_text()) # the deployed model's config.json: labels, dials, test metrics\n",
150
+ "bundle_files = sorted(p for p in ROOT.rglob(\"*\") if p.is_file())\n",
151
+ "print(f\"bundle: {len(bundle_files)} files, {sum(p.stat().st_size for p in bundle_files) / 2**20:.1f} MiB under {ROOT}\")\n",
152
+ "\n",
153
+ "PRETRAIN_RUN = \"large-24L\" # RUN_NAME in scripts/launch_pretrain.sh\n",
154
+ "CFG = yaml.safe_load((ROOT / \"config.yaml\").read_text()) # the project config; its paths stay relative (nothing resolves them here)\n",
155
+ "\n",
156
+ "MISSING: list[tuple[str, str]] = []\n",
157
+ "def artifact(rel: str, produced_by: str):\n",
158
+ " \"\"\"Path to a project artifact in the bundle, or None (with a one-line notice) if it is not there.\"\"\"\n",
159
+ " p = ROOT / rel\n",
160
+ " if p.exists():\n",
161
+ " return p\n",
162
+ " print(f\"not available yet — {rel} (produced by: {produced_by})\")\n",
163
+ " MISSING.append((rel, produced_by))\n",
164
+ " return None\n",
165
+ "\n",
166
+ "def show(df, title=None, fmt=None):\n",
167
+ " if title:\n",
168
+ " display(Markdown(f\"**{title}**\"))\n",
169
+ " display(df.style.format(fmt) if fmt else df)"
170
+ ]
171
+ },
172
+ {
173
+ "cell_type": "code",
174
+ "execution_count": null,
175
+ "id": "cell-06",
176
+ "metadata": {},
177
+ "outputs": [],
178
+ "source": [
179
+ "# \"Device\": the model never runs in this notebook. Compute lives on the Space; the accelerator Colab gave this runtime is informational.\n",
180
+ "try:\n",
181
+ " rt = api.get_space_runtime(SPACE)\n",
182
+ " SPACE_HW = rt.hardware\n",
183
+ " print(f\"Space {SPACE}: stage {rt.stage}, hardware {rt.hardware} <- the device every prediction in this notebook runs on\")\n",
184
+ "except Exception as e:\n",
185
+ " SPACE_HW = CFG[\"hf\"][\"space_hardware\"]\n",
186
+ " print(f\"Space runtime not readable ({type(e).__name__}): private Space without a token, or the Space does not exist; \"\n",
187
+ " f\"config.yaml says hardware {SPACE_HW}\")\n",
188
+ "try:\n",
189
+ " import torch\n",
190
+ " TORCH_OK = True\n",
191
+ " local = f\"torch {torch.__version__}, cuda available: {torch.cuda.is_available()}\" + (f\" ({torch.cuda.get_device_name(0)})\" if torch.cuda.is_available() else \"\")\n",
192
+ "except Exception as e: # torch is preinstalled on Colab; elsewhere the notebook falls back to the counts recorded in config.json\n",
193
+ " TORCH_OK = False\n",
194
+ " local = f\"torch not importable ({type(e).__name__})\"\n",
195
+ "print(f\"this runtime: {local} (informational: not used for the model)\")"
196
+ ]
197
+ },
198
+ {
199
+ "cell_type": "code",
200
+ "execution_count": null,
201
+ "id": "cell-07",
202
+ "metadata": {},
203
+ "outputs": [],
204
+ "source": [
205
+ "# The GPU facts recorded by the smoke test on 2026-10-07 (the GPU is not visible to this kernel).\n",
206
+ "smoke = artifact(\"reports/smoke_test.json\", \"src/smoke_test.py\")\n",
207
+ "SMOKE = json.loads(smoke.read_text()) if smoke else {} # reused by Step 5 and Results (no retyped numbers)\n",
208
+ "if smoke:\n",
209
+ " s = SMOKE[\"device\"]\n",
210
+ " print(\"Training GPU (from reports/smoke_test.json):\")\n",
211
+ " for k in (\"device\", \"sm\", \"vram_gib\", \"bf16\", \"torch\", \"cuda_build\", \"cudnn\", \"python\"):\n",
212
+ " print(f\"{k:>10}: {s[k]}\")\n",
213
+ "\n",
214
+ "print(\"\\nPinned training environment (requirements-train.txt):\")\n",
215
+ "print((ROOT / \"requirements-train.txt\").read_text())"
216
+ ]
217
+ },
218
+ {
219
+ "cell_type": "markdown",
220
+ "id": "cell-08",
221
+ "metadata": {},
222
+ "source": [
223
+ "**Step 2: Download Data & Create DataLoaders**\n",
224
+ "\n",
225
+ "The course reference downloads FashionMNIST from `torchvision.datasets`; there is no phishing corpus in torchvision,\n",
226
+ "so our data comes from the Hugging Face Hub and is rebuilt locally by `src/data_prep.py` (download, normalise,\n",
227
+ "dedupe, decontaminate, split, assert zero leakage). The Hub datasets that were inspected:\n",
228
+ "\n",
229
+ "| dataset | role | verdict |\n",
230
+ "|---|---|---|\n",
231
+ "| `puyang2025/seven-phishing-email-datasets` | labelled emails (TREC-05/06/07, CEAS-08, Enron-Spam, SpamAssassin, Ling) | **used**; its own train/test split leaks (5.1% exact / ~40% near duplicates), so we split ourselves |\n",
232
+ "| `corbt/enron-emails` | unlabelled Enron mail for MLM pretraining | **used** after removing 52.7% exact duplicates and stripping quoted tails |\n",
233
+ "| `it4lia/PhishingEmailCuratedDatasets_Cleaned` | labelled | **excluded**: no body text, only subjects + 53 numeric features |\n",
234
+ "| `zefang-liu/phishing-email-dataset` | labelled | **excluded**: 91.9% exact subset of the primary set, lowercased, and 20% of its \"safe\" rows contain the token *enron* (a shortcut) |\n",
235
+ "| `kudzaiprichard/aura-phishing-email-corpus` (Nazario + Nigerian fraud) | phishing-only corpora for the three-way labels | **used** (added in RUN_LOG entry 7) |\n",
236
+ "| `locuoco/...-300000` | ready-made 3-class set | **rejected**: 9.9% of its rows are copies of our TREC-06 test set and its \"phish\" label is a preprocessing artifact |\n",
237
+ "\n",
238
+ "**Holdouts.** TREC-06 (16,302 rows, 23.9% positive) is the binary test set: it is the one sub-corpus that is not\n",
239
+ "Enron-derived (89% of TREC-05 ham and 86% of Enron-Spam ham appear in the pretraining corpus), and it was never seen\n",
240
+ "by the tokenizer, the pretraining or the fine-tuning. A second, clean-provenance **three-way holdout** (3,220 rows:\n",
241
+ "legitimate / phishing / spam) gives the 3x3 confusion matrix. Train and validation are split by near-duplicate\n",
242
+ "cluster so no spam template straddles the two."
243
+ ]
244
+ },
245
+ {
246
+ "cell_type": "code",
247
+ "execution_count": null,
248
+ "id": "cell-09",
249
+ "metadata": {},
250
+ "outputs": [],
251
+ "source": [
252
+ "manifest = json.loads((ROOT / \"data/processed/splits_manifest.json\").read_text())\n",
253
+ "ds = manifest[\"datasets\"]\n",
254
+ "rows = [\n",
255
+ " (\"labelled primary\", ds[\"labelled_primary\"][\"repo\"], ds[\"labelled_primary\"][\"rows_loaded\"],\n",
256
+ " f\"hub splits {ds['labelled_primary']['hub_splits_concatenated']} concatenated (the hub split leaks)\"),\n",
257
+ " (\"pretraining corpus\", ds[\"pretrain_corpus\"][\"repo\"], ds[\"pretrain_corpus\"][\"rows_total\"],\n",
258
+ " f\"-> {manifest['pretrain']['rows_out']:,} docs after dedup/quote stripping\"),\n",
259
+ "] + [(\"excluded\", r, \"-\", reason) for r, reason in zip(ds[\"excluded\"],\n",
260
+ " (\"no body text (subjects + 53 numeric features only)\", \"91.9% exact subset of the primary set, lowercased, 'enron' token shortcut\"))]\n",
261
+ "rows.append((\"phishing corpora (3-way)\", CFG[\"data\"][\"threeway\"][\"champa\"][\"repo\"],\n",
262
+ " sum(manifest[\"threeway\"][\"new_rows\"][\"loaded_by_source\"].values()), \"Nazario + Nigerian_Fraud, CC-BY-4.0\"))\n",
263
+ "show(pd.DataFrame(rows, columns=[\"role\", \"Hub dataset\", \"rows loaded\", \"note\"]).astype({\"rows loaded\": \"string\"}), \"Hub datasets\")\n",
264
+ "print(f\"data build: {manifest['timestamp']} seed {manifest['seed']} elapsed {manifest['elapsed_seconds']} s peak RSS {manifest['peak_rss_gb']} GB\")"
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "code",
269
+ "execution_count": null,
270
+ "id": "cell-10",
271
+ "metadata": {},
272
+ "outputs": [],
273
+ "source": [
274
+ "# Dedup / leakage procedure: every row is normalised (NFKC -> lowercase -> [a-z0-9]) and keyed by sha1 of the full\n",
275
+ "# text, a 300-char prefix and subject+body; near-duplicates share >= 3 of their 50-char shingles (stride 25).\n",
276
+ "# These are the checks data_prep asserts to be ZERO (recomputed independently from the written files by `check3`).\n",
277
+ "leak = manifest[\"threeway\"][\"leakage\"]\n",
278
+ "tbl = pd.DataFrame([{\"check\": k, \"count\": v[\"count\"], \"asserted zero\": v[\"asserted\"], \"pass\": v[\"pass\"], \"detail\": v.get(\"detail\", \"\")}\n",
279
+ " for k, v in leak.items()])\n",
280
+ "show(tbl[tbl[\"asserted zero\"]].reset_index(drop=True), \"Leakage checks asserted to be zero (binary gate + three-way)\")\n",
281
+ "show(tbl[~tbl[\"asserted zero\"]].reset_index(drop=True), \"Informational counters (not asserted)\")\n",
282
+ "D = manifest[\"drops\"]\n",
283
+ "drops = pd.DataFrame([\n",
284
+ " (\"labelled rows with < 20 alphanumerics (garbage / empty)\", D[\"labelled_min_alnum\"][\"rows\"]),\n",
285
+ " (\"Ling corpus excluded (academic mailing list)\", sum(D[\"ling_excluded_by_label\"].values())),\n",
286
+ " (\"identical body carrying both labels (label noise)\", D[\"pool_label_conflicts\"][\"rows_dropped\"]),\n",
287
+ " (\"exact duplicates in the labelled pool\", sum(sum(v.values()) for v in D[\"pool_exact_dups_by_source_label\"].values())),\n",
288
+ " (\"training rows that are exact/near duplicates of TREC-06 holdout rows\", D[\"decontamination_vs_holdout\"][\"by_reason\"][\"total\"]),\n",
289
+ " (\"pretraining rows: exact duplicates\", manifest[\"pretrain\"][\"exact_dups_raw\"]),\n",
290
+ " (\"pretraining rows matching a validation email\", manifest[\"pretrain\"][\"val_key_removed\"]),\n",
291
+ " (\"pretraining rows near-duplicating a holdout email\", manifest[\"pretrain\"][\"holdout_near_dup_removed\"]),\n",
292
+ "], columns=[\"removed\", \"rows\"]).set_index(\"removed\")\n",
293
+ "show(drops, \"Rows removed by the dedup / decontamination procedure\", fmt=\"{:,}\")"
294
+ ]
295
+ },
296
+ {
297
+ "cell_type": "code",
298
+ "execution_count": null,
299
+ "id": "cell-11",
300
+ "metadata": {},
301
+ "outputs": [],
302
+ "source": [
303
+ "# Rows per split by class, from the counts data_prep.py wrote into splits_manifest.json (the parquet splits themselves stay on\n",
304
+ "# the workstation: the bundle carries reports, configs and source files, never data or weights).\n",
305
+ "LABEL3_NAME = {0: \"Legitimate\", 1: \"Phishing\", 2: \"Spam\", -1: \"unwanted (unsplit)\"}\n",
306
+ "SPLITS = [\"finetune_train\", \"finetune_val\", \"test_holdout\", \"test_holdout3\"]\n",
307
+ "tw = manifest[\"threeway\"]\n",
308
+ "lab = pd.DataFrame([{\"split\": split, \"source\": src, \"class\": LABEL3_NAME[int(l3)], \"rows\": n}\n",
309
+ " for split in SPLITS for src, by_l3 in tw[\"counts\"][split].items() for l3, n in by_l3.items()])\n",
310
+ "by_split = lab.pivot_table(index=\"split\", columns=\"class\", values=\"rows\", aggfunc=\"sum\", fill_value=0).reindex(SPLITS)\n",
311
+ "by_split[\"rows\"] = by_split.sum(axis=1)\n",
312
+ "by_split[\"binary positive rate\"] = pd.Series({s: tw[\"totals\"][s][\"binary_base_rate\"] for s in SPLITS})\n",
313
+ "show(by_split, \"Rows per split by label3 (EDA, from splits_manifest.json)\")\n",
314
+ "show(lab.pivot_table(index=[\"split\", \"source\"], columns=\"class\", values=\"rows\", aggfunc=\"sum\", fill_value=0), \"Rows per split x source x class\")"
315
+ ]
316
+ },
317
+ {
318
+ "cell_type": "code",
319
+ "execution_count": null,
320
+ "id": "cell-12",
321
+ "metadata": {},
322
+ "outputs": [],
323
+ "source": [
324
+ "fig, ax = plt.subplots(figsize=(9, 3.8))\n",
325
+ "cols = [\"Legitimate\", \"Phishing\", \"Spam\", \"unwanted (unsplit)\"]\n",
326
+ "colors = [\"#2a78d6\", \"#eb6834\", \"#eda100\", \"#9aa0a6\"]\n",
327
+ "bottom = np.zeros(len(SPLITS))\n",
328
+ "for c, col in zip(cols, colors):\n",
329
+ " vals = by_split[c].reindex(SPLITS).fillna(0).to_numpy() if c in by_split else np.zeros(len(SPLITS))\n",
330
+ " ax.bar(SPLITS, vals, bottom=bottom, label=c, color=col)\n",
331
+ " bottom += vals\n",
332
+ "ax.set_ylabel(\"rows\"); ax.set_title(\"Labelled splits by class\", loc=\"left\"); ax.legend(frameon=False)\n",
333
+ "for s in (\"top\", \"right\"): ax.spines[s].set_visible(False)\n",
334
+ "plt.tight_layout(); plt.show()"
335
+ ]
336
+ },
337
+ {
338
+ "cell_type": "code",
339
+ "execution_count": null,
340
+ "id": "cell-13",
341
+ "metadata": {},
342
+ "outputs": [],
343
+ "source": [
344
+ "# Tokenizer: byte-level BPE, vocab 32,768, NFKC only (case preserved: ALL-CAPS is a spam signal), trained on the\n",
345
+ "# pretraining corpus + finetune_train ONLY (val and the holdouts are refused by name; 0 holdout hash collisions).\n",
346
+ "# tokenizer.json travels with the weights (model repo root and the Space); the report written next to it carries the\n",
347
+ "# statistics and the worked examples, so nothing is tokenised on this runtime.\n",
348
+ "tok_report = json.loads((ROOT / \"data/processed/tokenizer_report.json\").read_text())\n",
349
+ "tr = tok_report[\"train\"]\n",
350
+ "print(f\"vocab {tr['final_vocab_size']:,} specials {tr['special_token_ids']} normalizer {tr['normalizer']} \"\n",
351
+ " f\"pre-tokenizer {tr['pre_tokenizer']}\\ntrained on {list(tr['training_files'])} in {tr['train_seconds']} s; \"\n",
352
+ " f\"excluded {tr['excluded_files']}; holdout exact collisions {tr['holdout_exact_collisions']}\")\n",
353
+ "show(pd.DataFrame(tok_report[\"stats\"][\"splits\"]).set_index(\"split\")\n",
354
+ " [[\"rows\", \"mean_tokens\", \"median_tokens\", \"p95_tokens\", \"chars_per_token\", \"pct_truncated\", \"mean_tokens_by_label\"]],\n",
355
+ " \"Token-length statistics per split (tokenizer.py stats; max_seq_len 512)\")\n",
356
+ "for ex in tok_report[\"stats\"][\"examples\"][:3]:\n",
357
+ " toks = ex[\"tokens\"]\n",
358
+ " print(f\"\\n[{ex['name']}] {ex['text'][:90]!r}\\n {ex['n_tokens_with_specials']} tokens: {toks[:28]}{' ...' if len(toks) > 28 else ''}\")"
359
+ ]
360
+ },
361
+ {
362
+ "cell_type": "code",
363
+ "execution_count": null,
364
+ "id": "cell-14",
365
+ "metadata": {},
366
+ "outputs": [],
367
+ "source": [
368
+ "# Token length per split from the tokenizer report. The local notebook tokenises a seeded 5,000-row sample of finetune_train\n",
369
+ "# live and draws a histogram per class; that sample needs the parquet data, so here the per-split statistics computed by\n",
370
+ "# tokenizer.py on the workstation stand in for it.\n",
371
+ "st = pd.DataFrame(tok_report[\"stats\"][\"splits\"]).set_index(\"split\")\n",
372
+ "fig, ax = plt.subplots(figsize=(9, 3.8))\n",
373
+ "x = np.arange(len(st)); w = 0.27\n",
374
+ "for off, col, color in ((-w, \"median_tokens\", \"#2a78d6\"), (0, \"mean_tokens\", \"#eb6834\"), (w, \"p95_tokens\", \"#eda100\")):\n",
375
+ " ax.bar(x + off, st[col], width=w, color=color, label=col.replace(\"_\", \" \"))\n",
376
+ "ax.axhline(CFG[\"model\"][\"max_seq_len\"], color=\"black\", linestyle=\"--\", linewidth=1, label=\"max_seq_len 512 (truncation)\")\n",
377
+ "ax.set_xticks(x, st.index); ax.set_yscale(\"log\"); ax.set_ylabel(\"tokens per email (log)\")\n",
378
+ "ax.set_title(\"Token length per split (tokenizer.py stats)\", loc=\"left\"); ax.legend(frameon=False, fontsize=8)\n",
379
+ "for s in (\"top\", \"right\"): ax.spines[s].set_visible(False)\n",
380
+ "plt.tight_layout(); plt.show()\n",
381
+ "print(\"rows longer than 512 tokens (truncated): \" + \", \".join(f\"{s} {v:.1f}%\" for s, v in st[\"pct_truncated\"].items())\n",
382
+ " + \". Length is a known confound: legitimate mail is ~2x longer on average; the error analysis checks whether the model leans on it.\")"
383
+ ]
384
+ },
385
+ {
386
+ "cell_type": "code",
387
+ "execution_count": null,
388
+ "id": "cell-15",
389
+ "metadata": {},
390
+ "outputs": [],
391
+ "source": [
392
+ "# The DataLoaders are built by src/finetune.py on the workstation (they need the parquet splits and torch). The bundle carries\n",
393
+ "# the source, so the three definitions that make the torch.utils.data.DataLoader are shown from the file text: a seeded,\n",
394
+ "# length-bucketed batch sampler, a row-index Dataset and the padding collate.\n",
395
+ "ft_src = (ROOT / \"src/finetune.py\").read_text()\n",
396
+ "def source_of(name, text):\n",
397
+ " \"\"\"Top-level `def`/`class` <name> of a source file read as text (no import needed).\"\"\"\n",
398
+ " m = re.search(rf\"^(?:def|class) {name}\\b.*?(?=^\\S|\\Z)\", text, re.S | re.M)\n",
399
+ " return m.group(0).rstrip() if m else f\"# {name}: not found\"\n",
400
+ "for fn in (\"epoch_batches\", \"RowIndexDataset\", \"make_loader\"):\n",
401
+ " print(source_of(fn, ft_src), \"\\n\")\n",
402
+ "ft_cfg = CFG[\"finetune\"]\n",
403
+ "MULTIPLE = int(ft_cfg[\"dynamic_padding_multiple\"]); MAX_LEN = int(CFG[\"model\"][\"max_seq_len\"])\n",
404
+ "print(f\"dials: batch_size {ft_cfg['batch_size']}, eval_batch_size {ft_cfg['eval_batch_size']}, max_seq_len {MAX_LEN}, pad to a multiple of {MULTIPLE}; \"\n",
405
+ " \"padded positions carry [PAD]=0 and mask False. 'unwanted' rows (TREC/CEAS positives) allow BOTH phishing and spam: \"\n",
406
+ " \"the loss is -log(P(phishing)+P(spam)), the partial-label NLL.\")"
407
+ ]
408
+ },
409
+ {
410
+ "cell_type": "code",
411
+ "execution_count": null,
412
+ "id": "cell-16",
413
+ "metadata": {},
414
+ "outputs": [],
415
+ "source": [
416
+ "# Pretraining data: documents are tokenised once, concatenated with [SEP] after each, and cut into packed windows of\n",
417
+ "# [CLS] + 511 stream tokens (no padding, no attention mask -> the Flash SDPA kernel). Dynamic 15% masking per batch.\n",
418
+ "pt = json.loads((ROOT / \"data/processed/pretrain_tokens.json\").read_text())\n",
419
+ "print(f\"documents {pt['n_docs']:,} ({pt['n_docs_train']:,} train / {pt['n_docs_val']:,} val: {pt['val_rule']})\")\n",
420
+ "print(f\"tokens {pt['n_tokens_train']:,} train + {pt['n_tokens_val']:,} val ({pt['tokens_per_char']} tokens/char over {pt['chars']:,} chars; \"\n",
421
+ " f\"encoded in {pt['encode_seconds']} s, stored as {pt['dtype']})\")\n",
422
+ "print(f\"packing {pt['packing']}\")\n",
423
+ "summ = artifact(f\"checkpoints/pretrain/{PRETRAIN_RUN}/run_summary.json\", f\"src/pretrain.py train --run-name {PRETRAIN_RUN}\")\n",
424
+ "if summ:\n",
425
+ " d = json.loads(summ.read_text())[\"config\"]\n",
426
+ " print(f\"windows {d['data']['n_windows']:,} x 512 per epoch -> {d['steps_per_epoch']} optimizer steps at effective batch \"\n",
427
+ " f\"{d['effective_batch']} (micro-batch {d['micro_batch']} x {d['grad_accumulation']} accumulation); {d['data']['n_val_windows']:,} val windows\")"
428
+ ]
429
+ },
430
+ {
431
+ "cell_type": "markdown",
432
+ "id": "cell-17",
433
+ "metadata": {},
434
+ "source": [
435
+ "**Step 3: Define the Neural Network**\n",
436
+ "\n",
437
+ "The reference notebook defines a small CNN; ours is `src/model.py`, a from-scratch **pre-LayerNorm BERT-style\n",
438
+ "encoder** in plain PyTorch (no `transformers`, no pretrained weights): token + learned position embeddings, 24\n",
439
+ "encoder layers (`x = x + Dropout(OutProj(SDPA(QKV(LN1(x)))))`, `x = x + Dropout(FFN_down(GELU(FFN_up(LN2(x)))))`),\n",
440
+ "a final LayerNorm, and two heads. Pre-LN is chosen because a 24-layer stack trained from scratch tolerates the\n",
441
+ "learning rate and warmup far better than post-LN. Attention runs through `F.scaled_dot_product_attention` so the fused\n",
442
+ "Flash / memory-efficient kernels never materialise the 512x512 score matrix. The **MLM head** ties its decoder to the\n",
443
+ "token embedding (saves 33.5M parameters); the **classification head** reads the `[CLS]` position and outputs three\n",
444
+ "logits (legitimate / phishing / spam); rows whose corpus never separated spam from phishing train on the probability\n",
445
+ "mass of {phishing, spam}. The course requires at least 2 layers; this model has 24."
446
+ ]
447
+ },
448
+ {
449
+ "cell_type": "code",
450
+ "execution_count": null,
451
+ "id": "cell-18",
452
+ "metadata": {},
453
+ "outputs": [],
454
+ "source": [
455
+ "# src/model.py from the bundle. With torch present (Colab preinstalls it) the module is imported and inspected exactly as the\n",
456
+ "# local notebook does; without torch the same definitions are shown from the file text.\n",
457
+ "sys.path.insert(0, str(ROOT / \"src\"))\n",
458
+ "model_src = (ROOT / \"src/model.py\").read_text()\n",
459
+ "if TORCH_OK:\n",
460
+ " import inspect\n",
461
+ " from model import EncoderConfig, EncoderLayer, EncoderForMLM, EncoderForClassification, count_parameters\n",
462
+ " src_cfg = inspect.getsource(EncoderConfig).splitlines()\n",
463
+ " layer_init, layer_fwd = inspect.getsource(EncoderLayer.__init__), inspect.getsource(EncoderLayer.forward)\n",
464
+ "else:\n",
465
+ " src_cfg = source_of(\"EncoderConfig\", model_src).splitlines()\n",
466
+ " parts = re.split(r\"(?=^ def )\", source_of(\"EncoderLayer\", model_src), flags=re.M)\n",
467
+ " layer_init = next(p for p in parts if p.startswith(\" def __init__\"))\n",
468
+ " layer_fwd = next(p for p in parts if p.startswith(\" def forward\"))\n",
469
+ "print(\"class EncoderConfig: # dataclass: the dials of the encoder\")\n",
470
+ "print(\"\\n\".join(l for l in src_cfg if re.match(r\"^ \\w+: [\\w| ]+ = \", l)))\n",
471
+ "print(\"\\nclass EncoderLayer(nn.Module): # one pre-LN block; 24 of these are stacked\")\n",
472
+ "print(layer_init)\n",
473
+ "print(layer_fwd)"
474
+ ]
475
+ },
476
+ {
477
+ "cell_type": "code",
478
+ "execution_count": null,
479
+ "id": "cell-19",
480
+ "metadata": {},
481
+ "outputs": [],
482
+ "source": [
483
+ "# Parameter counts: with torch, both wrappers are instantiated on this runtime's CPU (fp32, ~1.3 GB each) and counted, as in\n",
484
+ "# the local notebook; without torch the counts recorded in the deployed model's config.json are shown.\n",
485
+ "model_cfg_d = {**CFG[\"model\"], \"num_labels\": CFG[\"finetune\"][\"num_labels\"]}\n",
486
+ "if TORCH_OK:\n",
487
+ " model_cfg = EncoderConfig.from_dict(model_cfg_d)\n",
488
+ " print(\"config:\", model_cfg.to_dict())\n",
489
+ " mlm_model = EncoderForMLM(model_cfg)\n",
490
+ " cls_model = EncoderForClassification.from_pretrained_encoder(mlm_model) # encoder weights copied, fresh 3-class head\n",
491
+ " counts = pd.DataFrame({\"EncoderForMLM (tied head)\": count_parameters(mlm_model),\n",
492
+ " \"EncoderForClassification (3 classes)\": count_parameters(cls_model)})\n",
493
+ " assert mlm_model.head.decoder.weight is mlm_model.encoder.embeddings.tok.weight, \"MLM head must be tied\"\n",
494
+ " del mlm_model, cls_model\n",
495
+ " how = \"instantiated on this runtime's CPU and counted\"\n",
496
+ "else:\n",
497
+ " from types import SimpleNamespace\n",
498
+ " model_cfg = SimpleNamespace(**model_cfg_d)\n",
499
+ " print(\"config:\", model_cfg_d)\n",
500
+ " counts = pd.DataFrame({\"EncoderForClassification (3 classes)\": EXPORT_CONFIG[\"finetune_checkpoint\"][\"params\"]})\n",
501
+ " how = \"recorded in the deployed model's config.json; torch is not importable here\"\n",
502
+ "show(counts, f\"Parameter counts ({how})\", fmt=\"{:,}\")\n",
503
+ "print(f\"the classifier has {model_cfg.n_layers} encoder layers (course minimum: 2); head: CLS -> dropout -> Linear -> tanh -> dropout -> Linear({model_cfg.num_labels})\")"
504
+ ]
505
+ },
506
+ {
507
+ "cell_type": "code",
508
+ "execution_count": null,
509
+ "id": "cell-20",
510
+ "metadata": {},
511
+ "outputs": [],
512
+ "source": [
513
+ "# Why 24 layers / d=1024 / vocab 32,768: the parameter-count table from docs/research/research_model-math.md section 1\n",
514
+ "# (configs A-F; the brief asked for ~330M parameters). Config A is the BERT-large shape and lands +2.3% over the target.\n",
515
+ "math_md = (ROOT / \"docs/research/research_model-math.md\").read_text().splitlines()\n",
516
+ "start = next(i for i, l in enumerate(math_md) if l.startswith(\"## 1.\"))\n",
517
+ "end = next(i for i, l in enumerate(math_md) if l.startswith(\"## 2.\"))\n",
518
+ "table = [l for l in math_md[start:end] if l.startswith(\"|\")]\n",
519
+ "header = [h.strip() for h in table[0].strip(\"|\").split(\"|\")]\n",
520
+ "body = [[c.strip().replace(\"**\", \"\") for c in l.strip(\"|\").split(\"|\")] for l in table[2:]]\n",
521
+ "show(pd.DataFrame(body, columns=header).set_index(\"config\"), \"Candidate configurations (research_model-math.md section 1)\")\n",
522
+ "print(f\"Chosen: A = {model_cfg.n_layers}L d{model_cfg.d_model} h{model_cfg.n_heads} f{model_cfg.d_ff} V{model_cfg.vocab_size} -> \"\n",
523
+ " f\"{counts.loc['total'].iloc[-1]:,} parameters in the classifier, counted above.\")"
524
+ ]
525
+ },
526
+ {
527
+ "cell_type": "markdown",
528
+ "id": "cell-21",
529
+ "metadata": {},
530
+ "source": [
531
+ "**Step 4: Train the Model**\n",
532
+ "\n",
533
+ "Training happens in two phases, both far too long for a notebook cell, so they run as scripts inside `tmux` on the\n",
534
+ "RTX 3090 Ti and this notebook reads their metrics. **Phase 1, MLM pretraining** (`src/pretrain.py`): AdamW, peak LR\n",
535
+ "1.5e-4, 6% warmup, cosine decay to 10%, effective batch 264 sequences x 512 tokens, bf16 autocast, 12 epochs over 107.7M\n",
536
+ "tokens (~9,552 optimizer steps). **Phase 2, fine-tuning** (`src/finetune.py`): the instructor's dials are CLI flags\n",
537
+ "(`--n-layers`, `--epochs`, `--loss cross_entropy|weighted_cross_entropy|focal`, `--lr`, `--freeze-layers`, `--synthetic`),\n",
538
+ "AdamW with linear warmup/decay, **early stopping** on the validation F1 of the unwanted roll-up (patience 2), and every\n",
539
+ "run appends one line to `reports/experiments.jsonl` so the experimentation table is generated from evidence. The six\n",
540
+ "fine-tuning experiments (loss function x3, synthetic data, 12 layers, scratch init) are queued in\n",
541
+ "`scripts/run_experiments.sh` (tmux `phish-finetune`), which waits for `final/` and then trains, evaluates and exports\n",
542
+ "each run unattended (RUN_LOG entry 9)."
543
+ ]
544
+ },
545
+ {
546
+ "cell_type": "code",
547
+ "execution_count": null,
548
+ "id": "cell-22",
549
+ "metadata": {},
550
+ "outputs": [],
551
+ "source": [
552
+ "print(\"Phase 1 launch (scripts/launch_pretrain.sh; Nathan's settings on 2026-10-08):\")\n",
553
+ "print(\" EPOCHS=12 MICRO_BATCH=24 scripts/launch_pretrain.sh # -> tmux session 'phish-pretrain'\")\n",
554
+ "print(\" tmux attach -t phish-pretrain # Ctrl-b d detaches; Ctrl-C saves a checkpoint and exits\")\n",
555
+ "print(\" RESUME=1 scripts/launch_pretrain.sh # continue from the latest checkpoint\")\n",
556
+ "print(\"Phase 2 (after final/ exists), the dials are flags:\")\n",
557
+ "print(\" .venv/bin/python src/finetune.py train --run-name ft-ce --loss cross_entropy --epochs 3 --lr 3e-5\")\n",
558
+ "print(\" .venv/bin/python src/finetune.py train --run-name ft-focal --loss focal --focal-gamma 2.0\")\n",
559
+ "print(\" .venv/bin/python src/finetune.py train --run-name ft-12L --n-layers 12 # fewer layers\")\n",
560
+ "print(\" .venv/bin/python src/finetune.py list # experiments table\\n\")\n",
561
+ "queue = ROOT / \"scripts/run_experiments.sh\"\n",
562
+ "if queue.exists():\n",
563
+ " lines = queue.read_text().splitlines()\n",
564
+ " a = next(i for i, l in enumerate(lines) if l.startswith(\"EXPERIMENTS=(\"))\n",
565
+ " b = next(i for i in range(a, len(lines)) if lines[i].strip() == \")\")\n",
566
+ " print(\"Queued experiments (scripts/run_experiments.sh, tmux 'phish-finetune'; name | flags, all --init pretrained unless scratch):\")\n",
567
+ " print(\"\\n\".join(lines[a:b + 1]), \"\\n\")\n",
568
+ "print((ROOT / \"scripts/launch_pretrain.sh\").read_text())"
569
+ ]
570
+ },
571
+ {
572
+ "cell_type": "code",
573
+ "execution_count": null,
574
+ "id": "cell-23",
575
+ "metadata": {},
576
+ "outputs": [],
577
+ "source": [
578
+ "# Phase 1 live metrics: checkpoints/pretrain/<run>/metrics.csv grows while the run trains.\n",
579
+ "mpath = artifact(f\"checkpoints/pretrain/{PRETRAIN_RUN}/metrics.csv\", f\"src/pretrain.py train --run-name {PRETRAIN_RUN}\")\n",
580
+ "final_cfg = artifact(f\"checkpoints/pretrain/{PRETRAIN_RUN}/final/config.json\",\n",
581
+ " f\"written automatically when the pretraining run completes (or: src/pretrain.py export --run-name {PRETRAIN_RUN})\")\n",
582
+ "if mpath:\n",
583
+ " # the trainer appends while we read: drop a partially written last row instead of failing on NaN\n",
584
+ " pm = pd.read_csv(mpath).dropna(subset=[\"step\", \"loss\", \"loss_avg\", \"lr\", \"tokens_per_s\", \"train_seconds_total\"])\n",
585
+ " last = pm.iloc[-1]\n",
586
+ " plan = json.loads((ROOT / f\"checkpoints/pretrain/{PRETRAIN_RUN}/run_summary.json\").read_text())[\"config\"]\n",
587
+ " status = \"COMPLETE\" if final_cfg else \"RUN IN PROGRESS\"\n",
588
+ " print(f\"[{status}] step {int(last.step):,} / {plan['total_steps']:,} epoch {last.epoch_frac:.2f} / {plan['epochs']} \"\n",
589
+ " f\"loss {last.loss:.4f} (avg {last.loss_avg:.4f}) lr {last.lr:.2e} {int(last.tokens_per_s):,} tokens/s \"\n",
590
+ " f\"peak reserved {last.max_reserved_gb} GiB\")\n",
591
+ " val = pm.dropna(subset=[\"val_mlm_loss\"])\n",
592
+ " if len(val):\n",
593
+ " v = val.iloc[-1]\n",
594
+ " print(f\"last val MLM loss {v.val_mlm_loss:.4f} val masked-token accuracy {v.val_mlm_acc:.3f} at step {int(v.step)} (ln V = {np.log(CFG['model']['vocab_size']):.2f})\")\n",
595
+ " print(f\"Total training time: {last.train_seconds_total:.2f} seconds{' so far' if not final_cfg else ''} \"\n",
596
+ " f\"({last.train_seconds_total / 3600:.2f} h; projected {last.train_seconds_total / last.step * plan['total_steps'] / 3600:.1f} h for the full plan)\")\n",
597
+ "\n",
598
+ " fig, ax = plt.subplots(figsize=(9.5, 4))\n",
599
+ " ax.plot(pm.step, pm.loss, color=\"#9ec3ee\", linewidth=0.9, label=\"train MLM loss (20-step)\")\n",
600
+ " ax.plot(pm.step, pm.loss_avg, color=\"#2a78d6\", linewidth=1.8, label=\"train MLM loss (running avg)\")\n",
601
+ " if len(val):\n",
602
+ " ax.plot(val.step, val.val_mlm_loss, \"o-\", color=\"#eb6834\", label=\"val MLM loss (every 500 steps)\")\n",
603
+ " ax.axhline(np.log(CFG[\"model\"][\"vocab_size\"]), color=\"grey\", linestyle=\":\", label=\"ln(vocab) = uniform guess\")\n",
604
+ " ax.set_xlabel(\"optimizer step\"); ax.set_ylabel(\"MLM loss\"); ax.set_title(f\"Pretraining {PRETRAIN_RUN}: {status.lower()}\", loc=\"left\")\n",
605
+ " ax2 = ax.twinx(); ax2.plot(pm.step, pm.lr, color=\"#1baf7a\", linewidth=1, label=\"learning rate\"); ax2.set_ylabel(\"lr\", color=\"#1baf7a\")\n",
606
+ " h1, l1 = ax.get_legend_handles_labels(); h2, l2 = ax2.get_legend_handles_labels(); ax.legend(h1 + h2, l1 + l2, frameon=False, fontsize=8)\n",
607
+ " for s in (\"top\",): ax.spines[s].set_visible(False); ax2.spines[s].set_visible(False)\n",
608
+ " plt.tight_layout(); plt.show()"
609
+ ]
610
+ },
611
+ {
612
+ "cell_type": "code",
613
+ "execution_count": null,
614
+ "id": "cell-24",
615
+ "metadata": {},
616
+ "outputs": [],
617
+ "source": [
618
+ "# reports/pretrain_curves.png is written by `pretrain.py plot --run-name <run>`; show it only if it belongs to this run.\n",
619
+ "png = artifact(\"reports/pretrain_curves.png\", f\"src/pretrain.py plot --run-name {PRETRAIN_RUN}\")\n",
620
+ "if png and summ:\n",
621
+ " import re as _re\n",
622
+ " S = json.loads(summ.read_text())\n",
623
+ " # run_summary.json records the finish time and the log path (pretrain-YYYYMMDD-HHMMSS.log); the launch time comes from the latter.\n",
624
+ " started = S.get(\"started\")\n",
625
+ " if not started and S.get(\"log\"):\n",
626
+ " m = _re.search(r\"(\\d{4})(\\d{2})(\\d{2})-(\\d{2})(\\d{2})(\\d{2})\", Path(S[\"log\"]).name)\n",
627
+ " if m:\n",
628
+ " started = \"{}-{}-{}T{}:{}:{}\".format(*m.groups())\n",
629
+ " started = started or S.get(\"finished\") or \"\"\n",
630
+ " if time.strftime(\"%Y-%m-%dT%H:%M:%S\", time.localtime(png.stat().st_mtime)) >= started:\n",
631
+ " display(Image(filename=str(png), width=900))\n",
632
+ " else:\n",
633
+ " print(f\"reports/pretrain_curves.png is from an earlier DEV self-test run (older than the {PRETRAIN_RUN} launch at {started}); \"\n",
634
+ " f\"regenerate with: src/pretrain.py plot --run-name {PRETRAIN_RUN}\")"
635
+ ]
636
+ },
637
+ {
638
+ "cell_type": "code",
639
+ "execution_count": null,
640
+ "id": "cell-25",
641
+ "metadata": {},
642
+ "outputs": [],
643
+ "source": [
644
+ "# Phase 2 experiments table (reports/experiments.jsonl, one JSON line per run). Dev self-test runs (tiny subsets, a\n",
645
+ "# handful of steps, scratch init) are marked: they prove the code path, they are not results.\n",
646
+ "def is_dev(rec):\n",
647
+ " # A dev self-test trains on a row subset (--limit-rows) or is named dev*; a real run capped with --max-steps\n",
648
+ " # (a legitimate time budget) is NOT a dev run.\n",
649
+ " d = rec.get(\"dials\", {})\n",
650
+ " return bool(rec.get(\"dev_run\") or d.get(\"limit_rows\") or rec[\"run_name\"].startswith(\"dev\"))\n",
651
+ "\n",
652
+ "exp_path = artifact(\"reports/experiments.jsonl\", \"src/finetune.py train ... (one line per run) and src/evaluate.py run\")\n",
653
+ "EXPERIMENTS = [json.loads(l) for l in exp_path.read_text().splitlines() if l.strip()] if exp_path else []\n",
654
+ "if exp_path and not EXPERIMENTS:\n",
655
+ " print(\"reports/experiments.jsonl exists but is empty: the dev self-test lines were removed (RUN_LOG entry 9); \"\n",
656
+ " \"the queued runs append to it (produced by: scripts/run_experiments.sh -> src/finetune.py train).\")\n",
657
+ "if EXPERIMENTS:\n",
658
+ " rows = []\n",
659
+ " for r in EXPERIMENTS:\n",
660
+ " if r.get(\"stage\") == \"evaluate\":\n",
661
+ " continue\n",
662
+ " d, v = r.get(\"dials\", {}), r.get(\"val\") or {}\n",
663
+ " rows.append({\"run\": r[\"run_name\"], \"dev run?\": \"DEV\" if is_dev(r) else \"\", \"init\": r.get(\"init\"), \"classes\": d.get(\"num_labels\", 2),\n",
664
+ " \"layers\": d.get(\"n_layers\"), \"frozen\": d.get(\"frozen_layers\"), \"epochs run/planned\": f\"{d.get('epochs_run')}/{d.get('epochs_planned')}\",\n",
665
+ " \"loss\": d.get(\"loss\"), \"lr\": d.get(\"lr\"), \"batch\": d.get(\"batch_size\"), \"synthetic\": d.get(\"synthetic\"),\n",
666
+ " \"train rows\": r.get(\"n_train\"), \"train min\": round(r.get(\"train_seconds\", 0) / 60, 1), \"peak GiB\": r.get(\"peak_vram_gb\"),\n",
667
+ " \"val acc\": v.get(\"accuracy\"), \"val P\": v.get(\"precision\"), \"val R\": v.get(\"recall\"), \"val F1\": v.get(\"f1\"),\n",
668
+ " \"val ROC-AUC\": v.get(\"roc_auc\"), \"val macro-F1 3w\": v.get(\"macro_f1_3way\"), \"stopped by\": r.get(\"stopped_by\")})\n",
669
+ " EXP_DF = pd.DataFrame(rows).round(4)\n",
670
+ " if len(EXP_DF):\n",
671
+ " show(EXP_DF.set_index(\"run\"), \"Fine-tuning experiments (finetune.py list)\")\n",
672
+ " real = [r for r in EXPERIMENTS if r.get(\"stage\") != \"evaluate\" and not is_dev(r)]\n",
673
+ " if not real:\n",
674
+ " print(\"Only DEV self-test runs exist so far: the real fine-tuning runs start once checkpoints/pretrain/large-24L/final/ exists \"\n",
675
+ " \"(produced by: scripts/run_experiments.sh -> src/finetune.py train --run-name <run> ...).\")"
676
+ ]
677
+ },
678
+ {
679
+ "cell_type": "code",
680
+ "execution_count": null,
681
+ "id": "cell-26",
682
+ "metadata": {},
683
+ "outputs": [],
684
+ "source": [
685
+ "# Fine-tuning curves of the best real run (highest val F1 of the unwanted roll-up); until one exists, the newest dev run is\n",
686
+ "# shown and labelled as such. Early stopping: evaluation every 500 steps, stop after `patience` evaluations without a\n",
687
+ "# better val F1; LR: linear warmup over 6% of the steps, then linear decay to 0 (BERT fine-tuning schedule).\n",
688
+ "real = [r for r in EXPERIMENTS if r.get(\"stage\") != \"evaluate\" and not is_dev(r) and (r.get(\"val\") or {}).get(\"f1\") is not None]\n",
689
+ "dev = [r for r in EXPERIMENTS if r.get(\"stage\") != \"evaluate\" and is_dev(r)]\n",
690
+ "best = max(real, key=lambda r: r[\"val\"][\"f1\"]) if real else None\n",
691
+ "tag = \"\"\n",
692
+ "if best is None:\n",
693
+ " artifact(\"checkpoints/finetune/<best run>/metrics.csv\", \"src/finetune.py train --run-name <run> (after pretraining completes)\")\n",
694
+ " if dev:\n",
695
+ " best, tag = dev[-1], \" (DEV self-test run, not a result)\"\n",
696
+ "BEST_FT_RUN = best[\"run_name\"] if best else None\n",
697
+ "if best:\n",
698
+ " run_dir = ROOT / \"checkpoints/finetune\" / best[\"run_name\"]\n",
699
+ " m = pd.read_csv(run_dir / \"metrics.csv\") if (run_dir / \"metrics.csv\").exists() else None\n",
700
+ " e = pd.read_csv(run_dir / \"epochs.csv\") if (run_dir / \"epochs.csv\").exists() else None\n",
701
+ " print(f\"run {best['run_name']}{tag}: init {best['init']}, loss {best['dials']['loss']}, lr {best['dials']['lr']}, \"\n",
702
+ " f\"{best['steps_run']} steps, best step {best['best_step']}, val F1 {best['val']['f1']:.4f}, stopped by {best['stopped_by']}\")\n",
703
+ " if m is not None and len(m):\n",
704
+ " fig, axes = plt.subplots(1, 2, figsize=(11, 3.6))\n",
705
+ " axes[0].plot(m.step, m.loss, color=\"#2a78d6\", label=\"train loss\"); axes[0].set_xlabel(\"step\"); axes[0].set_title(f\"{best['run_name']}{tag}: train loss\", loc=\"left\", fontsize=10)\n",
706
+ " if e is not None and len(e):\n",
707
+ " axes[1].plot(e.step, e.f1, \"o-\", color=\"#eb6834\", label=\"val F1 (unwanted roll-up)\")\n",
708
+ " axes[1].plot(e.step, e.roc_auc, \"s--\", color=\"#1baf7a\", label=\"val ROC-AUC\")\n",
709
+ " best_rows = e[e.is_best == 1]\n",
710
+ " axes[1].scatter(best_rows.step, best_rows.f1, s=90, facecolors=\"none\", edgecolors=\"black\", label=\"best (checkpoint kept)\")\n",
711
+ " axes[1].set_xlabel(\"step\"); axes[1].set_title(\"validation metrics at each evaluation\", loc=\"left\", fontsize=10); axes[1].legend(frameon=False, fontsize=8)\n",
712
+ " for ax in axes:\n",
713
+ " for s in (\"top\", \"right\"): ax.spines[s].set_visible(False)\n",
714
+ " plt.tight_layout(); plt.show()\n",
715
+ " # The queue writes reports/finetune_curves_<run>.png per run and reports/finetune_curves.png for the main run; a PNG\n",
716
+ " # older than the run's own experiments.jsonl line is a leftover from an earlier run and is not shown.\n",
717
+ " curves = artifact(f\"reports/finetune_curves_{best['run_name']}.png\", f\"src/finetune.py plot --run-name {best['run_name']} --out reports/finetune_curves_{best['run_name']}.png\")\n",
718
+ " curves = curves or (ROOT / \"reports/finetune_curves.png\" if (ROOT / \"reports/finetune_curves.png\").exists() else None)\n",
719
+ " if curves and not tag:\n",
720
+ " png_time = time.strftime(\"%Y-%m-%dT%H:%M:%S\", time.localtime(curves.stat().st_mtime))\n",
721
+ " if png_time >= best.get(\"timestamp\", \"\"):\n",
722
+ " display(Image(filename=str(curves), width=900))\n",
723
+ " else:\n",
724
+ " print(f\"{curves.relative_to(ROOT)} predates run {best['run_name']} ({png_time} < {best.get('timestamp')}): stale, not shown; \"\n",
725
+ " f\"regenerate with src/finetune.py plot --run-name {best['run_name']}\")"
726
+ ]
727
+ },
728
+ {
729
+ "cell_type": "code",
730
+ "execution_count": null,
731
+ "id": "cell-27",
732
+ "metadata": {},
733
+ "outputs": [],
734
+ "source": [
735
+ "# Loss-function comparison (cross_entropy vs weighted_cross_entropy vs focal) over the REAL runs, same dials otherwise.\n",
736
+ "real = [r for r in EXPERIMENTS if r.get(\"stage\") != \"evaluate\" and not is_dev(r) and r.get(\"val\")]\n",
737
+ "if real:\n",
738
+ " cmp = pd.DataFrame([{\"run\": r[\"run_name\"], \"loss\": r[\"dials\"][\"loss\"], \"focal_gamma\": r[\"dials\"].get(\"focal_gamma\"),\n",
739
+ " \"class_weights\": r[\"dials\"].get(\"class_weights\"), \"val F1\": r[\"val\"][\"f1\"], \"val P\": r[\"val\"][\"precision\"],\n",
740
+ " \"val R\": r[\"val\"][\"recall\"], \"val ROC-AUC\": r[\"val\"][\"roc_auc\"], \"val PR-AUC\": r[\"val\"][\"pr_auc\"],\n",
741
+ " \"val macro-F1 3w\": r[\"val\"].get(\"macro_f1_3way\")} for r in real]).sort_values(\"val F1\", ascending=False)\n",
742
+ " show(cmp.set_index(\"run\"), \"Loss-function comparison (real runs)\")\n",
743
+ "else:\n",
744
+ " artifact(\"reports/experiments.jsonl (real runs with --loss cross_entropy / weighted_cross_entropy / focal)\",\n",
745
+ " \"scripts/run_experiments.sh -> src/finetune.py train --run-name ft-ce|ft-weighted|ft-focal --loss <loss>\")\n",
746
+ " dev_losses = sorted({(r[\"dials\"].get(\"loss\"), r.get(\"steps_run\"), r.get(\"n_train\")) for r in EXPERIMENTS if r.get(\"stage\") != \"evaluate\" and is_dev(r)})\n",
747
+ " if dev_losses:\n",
748
+ " print(\"The DEV lines above (\" + \"; \".join(f\"{l}: {s} steps on {n:,} rows\" for l, s, n in dev_losses) + \") only prove the loss paths run.\")\n",
749
+ " print(\"The three loss paths share one code path (src/finetune.py); focal(gamma=0) was verified to equal cross_entropy exactly (RUN_LOG entry 4).\")"
750
+ ]
751
+ },
752
+ {
753
+ "cell_type": "markdown",
754
+ "id": "cell-28",
755
+ "metadata": {},
756
+ "source": [
757
+ "**Step 5: Test Inference Speed and Save the Model**\n",
758
+ "\n",
759
+ "The reference times `predict_and_time` on 100 images; the local notebook does the same on 100 TREC-06 emails on the\n",
760
+ "workstation CPU. Here the model is not present, so the timing is taken where the model actually serves: each of the app's\n",
761
+ "own synthetic example emails (read out of `app/app.py` in the bundle) is sent to the Space's `/predict` endpoint through\n",
762
+ "`gradio_client`, and the Space answers with the in-app **Prediction Time (s)** (tokenize + forward + softmax on the ZeroGPU,\n",
763
+ "the number the Gradio UI shows) next to the full round-trip from this runtime. The recorded workstation numbers\n",
764
+ "(`reports/latency.json`: RTX 3090 Ti bf16 and CPU fp32 on 100 holdout emails) and the instructor's 0.074 s benchmark are\n",
765
+ "shown for comparison. \"Saving the model\" happened on the workstation (`src/export.py` -> bf16 safetensors + config +\n",
766
+ "tokenizer + model card, then `scripts/upload_to_hf.py`); this notebook lists the model repo's files with their sizes instead\n",
767
+ "of writing a copy."
768
+ ]
769
+ },
770
+ {
771
+ "cell_type": "code",
772
+ "execution_count": null,
773
+ "id": "cell-29",
774
+ "metadata": {},
775
+ "outputs": [],
776
+ "source": [
777
+ "import ast\n",
778
+ "\n",
779
+ "def app_examples(path):\n",
780
+ " \"\"\"The EXAMPLES list of app/app.py, read from the file TEXT: importing app.py would import torch and spaces at module level.\"\"\"\n",
781
+ " for node in ast.parse(path.read_text()).body:\n",
782
+ " targets = node.targets if isinstance(node, ast.Assign) else [node.target] if isinstance(node, ast.AnnAssign) else []\n",
783
+ " if any(isinstance(t, ast.Name) and t.id == \"EXAMPLES\" for t in targets):\n",
784
+ " return [[str(x[0]), str(x[1])] for x in ast.literal_eval(node.value)]\n",
785
+ " raise ValueError(\"EXAMPLES not found in app.py\")\n",
786
+ "\n",
787
+ "FALLBACK_EMAILS = [ # three hard-coded synthetic emails (lure, spam, legitimate) if the extraction fails\n",
788
+ " [\"Action Required: verify your account within 24 hours\",\n",
789
+ " \"Dear customer,\\n\\nUnusual sign-in activity was detected. Verify your account within 24 hours at http://secure-login-verify.example \"\n",
790
+ " \"or it will be suspended.\\n\\nSecurity Team\"],\n",
791
+ " [\"FINAL HOURS: 70% OFF designer watches + FREE shipping!!!\",\n",
792
+ " \"CONGRATULATIONS! You were SELECTED for our VIP SALE. 70% OFF everything, FREE shipping TODAY ONLY. Shop now: http://bit.ly/vip-deal\"],\n",
793
+ " [\"Agenda for Thursday's project sync\",\n",
794
+ " \"Hi all,\\n\\nAttaching the agenda for Thursday's 10:00 sync: firewall migration status, Q4 hardware budget, onboarding checklist. \"\n",
795
+ " \"Reply by Wednesday noon with additions.\\n\\nThanks,\\nNathan\"],\n",
796
+ "]\n",
797
+ "app_py = artifact(\"app/app.py\", \"the Space source (app/ on the workstation, uploaded by scripts/upload_to_hf.py)\")\n",
798
+ "try:\n",
799
+ " SAMPLES = app_examples(app_py) if app_py else FALLBACK_EMAILS\n",
800
+ " print(f\"{len(SAMPLES)} synthetic example emails extracted from app/app.py (EXAMPLES)\" if app_py else \"using the 3 built-in synthetic emails\")\n",
801
+ "except Exception as e:\n",
802
+ " SAMPLES = FALLBACK_EMAILS\n",
803
+ " print(f\"could not extract EXAMPLES from app.py ({type(e).__name__}): using the 3 built-in synthetic emails\")\n",
804
+ "for subject, body in SAMPLES:\n",
805
+ " print(f\" - {subject[:72]!r} ({len(body)} chars)\")"
806
+ ]
807
+ },
808
+ {
809
+ "cell_type": "code",
810
+ "execution_count": null,
811
+ "id": "cell-30",
812
+ "metadata": {},
813
+ "outputs": [],
814
+ "source": [
815
+ "# Inference speed where the model runs: the Space's /predict endpoint, one call per email, through gradio_client.\n",
816
+ "from gradio_client import Client\n",
817
+ "\n",
818
+ "SPACE_STATS = []\n",
819
+ "client = None\n",
820
+ "try:\n",
821
+ " t0 = timer()\n",
822
+ " client = Client(SPACE, token=TOKEN, verbose=False, analytics_enabled=False)\n",
823
+ " print(f\"connected to {SPACE} in {timer() - t0:.1f} s (endpoint /predict: subject, body -> label, Prediction Time (s), highlights, signals)\")\n",
824
+ "except Exception as e:\n",
825
+ " print(f\"cannot connect to the Space ({type(e).__name__}: {str(e)[:160]}): private Space without a token, or the Space is not running\")\n",
826
+ "if client is not None:\n",
827
+ " for subject, body in SAMPLES:\n",
828
+ " t0 = timer()\n",
829
+ " try:\n",
830
+ " label, secs, highlights, signals = client.predict(subject, body, api_name=\"/predict\")\n",
831
+ " except Exception as e: # ZeroGPU quota / \"GPU task aborted\" / timeout: skip this email, keep the rest\n",
832
+ " print(f\"\\n=== {subject}\\n Space call failed ({type(e).__name__}: {str(e)[:160]}); skipped\")\n",
833
+ " continue\n",
834
+ " round_trip = timer() - t0\n",
835
+ " conf = {c[\"label\"]: float(c[\"confidence\"]) for c in (label.get(\"confidences\") or [])} if isinstance(label, dict) else {}\n",
836
+ " verdict = label.get(\"label\") if isinstance(label, dict) else str(label)\n",
837
+ " first_line = (str(signals).strip().splitlines() or [\"\"])[0]\n",
838
+ " SPACE_STATS.append({\"subject\": subject[:52], \"verdict\": verdict, **{f\"P({k})\": v for k, v in conf.items()},\n",
839
+ " \"Prediction Time (s), on the Space\": float(secs), \"round-trip (s)\": round_trip})\n",
840
+ " print(f\"\\n=== {subject}\\n verdict {verdict}: \" + \", \".join(f\"{k} {100 * v:.1f}%\" for k, v in conf.items())\n",
841
+ " + f\"\\n Prediction Time (s): {float(secs):.3f} on the Space round-trip from here: {round_trip:.2f} s {first_line}\")\n",
842
+ "if SPACE_STATS:\n",
843
+ " SP = pd.DataFrame(SPACE_STATS).set_index(\"subject\")\n",
844
+ " show(SP, f\"{len(SP)} synthetic emails through the Space's /predict endpoint\", fmt={c: \"{:.3f}\" for c in SP.columns if c != \"verdict\"})\n",
845
+ " t_app, t_rt = SP[\"Prediction Time (s), on the Space\"], SP[\"round-trip (s)\"]\n",
846
+ " print(f\"Average in-app prediction time: {t_app.mean():.4f} s on the Space's GPU ({SPACE_HW}; the first call, {t_app.iloc[0]:.3f} s, \"\n",
847
+ " f\"includes the ZeroGPU warm-up); average round-trip {t_rt.mean():.2f} s (queue + network + the app's saliency pass). \"\n",
848
+ " f\"Instructor benchmark: 0.074 s.\")\n",
849
+ "elif client is not None:\n",
850
+ " print(\"no successful Space call: every request was rejected (ZeroGPU quota / Space restarting); the recorded workstation latencies follow\")"
851
+ ]
852
+ },
853
+ {
854
+ "cell_type": "code",
855
+ "execution_count": null,
856
+ "id": "cell-31",
857
+ "metadata": {},
858
+ "outputs": [],
859
+ "source": [
860
+ "# The recorded workstation latencies (reports/latency.json: evaluate.py on 100 TREC-06 holdout emails) next to the Space numbers.\n",
861
+ "lat = artifact(\"reports/latency.json\", \"src/evaluate.py run --checkpoint checkpoints/finetune/<run>/best --split test,test3\")\n",
862
+ "BENCH_S = 0.074\n",
863
+ "rows = []\n",
864
+ "if lat:\n",
865
+ " L = json.loads(lat.read_text()); BENCH_S = L.get(\"instructor_benchmark_ms\", 74.0) / 1000\n",
866
+ " for k, what in ((\"gpu_bf16\", \"RTX 3090 Ti bf16\"), (\"cpu_fp32\", \"CPU fp32, 16 threads\")):\n",
867
+ " if L.get(k):\n",
868
+ " rows.append({\"source\": f\"workstation {what} (evaluate.py, {L[k]['n']} holdout emails)\", \"avg s/sample\": L[k][\"avg_ms\"] / 1000,\n",
869
+ " \"p50 s\": L[k].get(\"p50_ms\", np.nan) / 1000, \"n\": L[k][\"n\"]})\n",
870
+ "if SPACE_STATS:\n",
871
+ " rows.append({\"source\": f\"Space {SPACE_HW}, in-app Prediction Time (this notebook, synthetic emails)\", \"avg s/sample\": float(t_app.mean()),\n",
872
+ " \"p50 s\": float(t_app.median()), \"n\": len(t_app)})\n",
873
+ " rows.append({\"source\": \"Space, full round-trip from this runtime\", \"avg s/sample\": float(t_rt.mean()), \"p50 s\": float(t_rt.median()), \"n\": len(t_rt)})\n",
874
+ "rows.append({\"source\": \"instructor benchmark\", \"avg s/sample\": BENCH_S, \"p50 s\": np.nan, \"n\": None})\n",
875
+ "show(pd.DataFrame(rows).set_index(\"source\"), \"Single-email prediction time (tokenize + forward + softmax)\", fmt={\"avg s/sample\": \"{:.4f}\", \"p50 s\": \"{:.4f}\"})\n",
876
+ "if lat:\n",
877
+ " print(f\"workstation GPU {L['gpu_bf16']['avg_ms']:.1f} ms = {L['gpu_bf16']['ratio_to_benchmark']:.2f}x the {BENCH_S * 1000:.0f} ms benchmark; \"\n",
878
+ " f\"CPU {L['cpu_fp32']['avg_ms']:.0f} ms = {L['cpu_fp32']['ratio_to_benchmark']:.1f}x: hence a ZeroGPU Space rather than a CPU one.\")"
879
+ ]
880
+ },
881
+ {
882
+ "cell_type": "code",
883
+ "execution_count": null,
884
+ "id": "cell-32",
885
+ "metadata": {},
886
+ "outputs": [],
887
+ "source": [
888
+ "# \"Save the model\": the export already lives on the Hub. The model repo's own files (what the Space and anyone with access load).\n",
889
+ "info = api.repo_info(MODEL_REPO, repo_type=\"model\", files_metadata=True)\n",
890
+ "own = [s for s in info.siblings if not s.rfilename.startswith(PRESENTATION_PREFIX + \"/\")]\n",
891
+ "bundle = [s for s in info.siblings if s.rfilename.startswith(PRESENTATION_PREFIX + \"/\")]\n",
892
+ "show(pd.DataFrame([{\"file\": s.rfilename, \"MiB\": (s.size or 0) / 2**20, \"LFS\": s.lfs is not None} for s in own]).set_index(\"file\"),\n",
893
+ " f\"https://huggingface.co/{MODEL_REPO} (private: {info.private}, last modified {str(info.last_modified)[:16]})\", fmt={\"MiB\": \"{:,.1f}\"})\n",
894
+ "print(f\"+ {len(bundle)} files / {sum((s.size or 0) for s in bundle) / 2**20:.1f} MiB under {PRESENTATION_PREFIX}/ (this notebook's inputs)\")\n",
895
+ "fc = EXPORT_CONFIG[\"finetune_checkpoint\"]\n",
896
+ "print(f\"model.safetensors: {EXPORT_CONFIG['export_dtype']} export of checkpoints/finetune/{Path(EXPORT_CONFIG['source_checkpoint']).parent.name}/best \"\n",
897
+ " f\"({fc['params']['total']:,} parameters, {EXPORT_CONFIG['num_labels']} classes {fc['label_names']}), exported {EXPORT_CONFIG['exported']}; \"\n",
898
+ " f\"config.json carries the test metrics: TREC-06 F1 {EXPORT_CONFIG['metrics']['f1']:.4f}, recall {EXPORT_CONFIG['metrics']['recall']:.4f}, \"\n",
899
+ " f\"ROC-AUC {EXPORT_CONFIG['metrics']['roc_auc']:.4f}; three-way macro-F1 {EXPORT_CONFIG['metrics_threeway']['macro_f1']:.4f}.\")"
900
+ ]
901
+ },
902
+ {
903
+ "cell_type": "markdown",
904
+ "id": "cell-33",
905
+ "metadata": {},
906
+ "source": [
907
+ "**Step 6: Create the App Files (Optional)**\n",
908
+ "\n",
909
+ "Optional in the reference, not here: the Space is one of the project's deliverables. The files the reference writes\n",
910
+ "(`app.py`, `model.py`, `requirements.txt`) are the ones `scripts/upload_to_hf.py` pushed to the Space, together with the bf16\n",
911
+ "weights, `config.json` and `tokenizer.json` (bundled, so the Space needs no download and no secret at startup). The Space's\n",
912
+ "file list comes from the Hub; the first lines of `app.py`, the pinned `requirements.txt` and the Space card's front matter are\n",
913
+ "read from the bundle's copy of `app/`."
914
+ ]
915
+ },
916
+ {
917
+ "cell_type": "code",
918
+ "execution_count": null,
919
+ "id": "cell-34",
920
+ "metadata": {},
921
+ "outputs": [],
922
+ "source": [
923
+ "try:\n",
924
+ " space_files = api.list_repo_files(SPACE, repo_type=\"space\")\n",
925
+ " print(f\"Space https://huggingface.co/spaces/{SPACE} files ({len(space_files)}): \" + \", \".join(space_files))\n",
926
+ "except Exception as e:\n",
927
+ " print(f\"Space file list not readable ({type(e).__name__}): private Space without a token\")\n",
928
+ "print(\"\\n--- app/app.py (first 40 lines) ---\")\n",
929
+ "if app_py:\n",
930
+ " print(\"\\n\".join(app_py.read_text().splitlines()[:40]))\n",
931
+ "req = artifact(\"app/requirements.txt\", \"app/ on the workstation (pinned for the ZeroGPU runtime)\")\n",
932
+ "if req:\n",
933
+ " print(\"\\n--- app/requirements.txt ---\"); print(req.read_text())\n",
934
+ "card = artifact(\"app/README.md\", \"app/ on the workstation (the Space card)\")\n",
935
+ "if card:\n",
936
+ " print(\"--- app/README.md front matter (Space configuration) ---\"); print(card.read_text().split(\"---\")[1].strip())"
937
+ ]
938
+ },
939
+ {
940
+ "cell_type": "markdown",
941
+ "id": "cell-35",
942
+ "metadata": {},
943
+ "source": [
944
+ "**Step 7: Launch the Gradio Interface**\n",
945
+ "\n",
946
+ "The reference calls `demo.launch(share=True)`. Here the interface *is* the deployed Space: `gr.load(SPACE, src=\"spaces\")`\n",
947
+ "builds a Gradio app whose every call is proxied to the Space (the model never leaves ZeroGPU), and `launch()` renders it\n",
948
+ "inline in Colab (Colab shows Gradio through a temporary public link). Gradio 6.29.1 has a bug in `gr.load` for Spaces whose\n",
949
+ "`gr.Interface` carries an examples table: `Blocks.from_config` rebuilds the `Dataset` component from `{name, class_id}`\n",
950
+ "dicts and hands `class_id` on to the child `Textbox.__init__`, which rejects it. The cell first calls `gr.load` as is; if\n",
951
+ "it fails with exactly that `TypeError`, a four-line compatibility shim (drop `class_id` before the child component is\n",
952
+ "instantiated, the same thing newer Gradio releases do) is installed and `gr.load` is retried, so the interface shown is the\n",
953
+ "Space's own. Only if `gr.load` still fails does the cell fall back to a hand-built `gr.Interface` around the Step 5\n",
954
+ "`gradio_client` call. Outside Colab the cell starts and immediately stops a headless server as a smoke test. When the Space\n",
955
+ "is public, the native page is also embedded in an iframe; while it is private the iframe would only show a login page, so it\n",
956
+ "is skipped with a note."
957
+ ]
958
+ },
959
+ {
960
+ "cell_type": "code",
961
+ "execution_count": null,
962
+ "id": "cell-36",
963
+ "metadata": {},
964
+ "outputs": [],
965
+ "source": [
966
+ "import io, contextlib, warnings\n",
967
+ "import gradio as gr\n",
968
+ "\n",
969
+ "def shim_gr_load_dataset_bug():\n",
970
+ " \"\"\"gradio 6.29.1: gr.load -> Blocks.from_config rebuilds a Space's examples `Dataset` from {name, class_id} dicts and\n",
971
+ " get_component_instance passes `class_id` on to the child component's __init__, which rejects it. Drop that key.\"\"\"\n",
972
+ " import gradio.components.dataset as ds\n",
973
+ " if getattr(ds.get_component_instance, \"_class_id_shim\", False):\n",
974
+ " return\n",
975
+ " original = ds.get_component_instance\n",
976
+ " def patched(comp, *args, **kwargs):\n",
977
+ " if isinstance(comp, dict) and \"class_id\" in comp:\n",
978
+ " comp = {k: v for k, v in comp.items() if k != \"class_id\"}\n",
979
+ " return original(comp, *args, **kwargs)\n",
980
+ " patched._class_id_shim = True\n",
981
+ " ds.get_component_instance = patched\n",
982
+ "\n",
983
+ "def load_space():\n",
984
+ " with warnings.catch_warnings(), contextlib.redirect_stdout(io.StringIO()): # hide gradio's chatter + theme UserWarning\n",
985
+ " warnings.simplefilter(\"ignore\")\n",
986
+ " return gr.load(SPACE, src=\"spaces\", token=TOKEN)\n",
987
+ "\n",
988
+ "demo, demo_kind = None, None\n",
989
+ "try:\n",
990
+ " try:\n",
991
+ " demo = load_space()\n",
992
+ " demo_kind = \"gr.load(src='spaces'): the Space's own interface, every call proxied\"\n",
993
+ " except TypeError as e:\n",
994
+ " if \"class_id\" not in str(e):\n",
995
+ " raise\n",
996
+ " print(f\"gr.load hit the gradio {gr.__version__} Dataset/class_id bug ({str(e)[:90]}); installing the compat shim and retrying\")\n",
997
+ " shim_gr_load_dataset_bug()\n",
998
+ " demo = load_space()\n",
999
+ " demo_kind = \"gr.load(src='spaces') with the class_id compat shim: the Space's own interface, every call proxied\"\n",
1000
+ "except Exception as e:\n",
1001
+ " print(f\"gr.load({SPACE!r}) failed: {type(e).__name__}: {str(e)[:160]} -> hand-built proxy interface\")\n",
1002
+ "if demo is None and client is not None:\n",
1003
+ " # Fallback: the same interface built by hand around the gradio_client connection of Step 5 (labels mirror app/app.py).\n",
1004
+ " def proxy_predict(subject, body):\n",
1005
+ " label, secs, highlights, signals = client.predict(subject, body, api_name=\"/predict\")\n",
1006
+ " confidences = {c[\"label\"]: c[\"confidence\"] for c in label[\"confidences\"]} if isinstance(label, dict) and \"confidences\" in label else label\n",
1007
+ " spans = [(h[\"token\"], h.get(\"class_or_confidence\")) if isinstance(h, dict) else tuple(h) for h in highlights]\n",
1008
+ " return confidences, secs, spans, signals\n",
1009
+ " demo = gr.Interface(\n",
1010
+ " fn=proxy_predict,\n",
1011
+ " inputs=[gr.Textbox(label=\"Subject\", lines=1), gr.Textbox(label=\"Email body\", lines=14)],\n",
1012
+ " outputs=[gr.Label(label=\"Prediction\"), gr.Number(label=\"Prediction Time (s)\"),\n",
1013
+ " gr.HighlightedText(label=\"Tokens the model weighed most\", color_map={\"high\": \"#d62728\", \"medium\": \"#ff9896\"},\n",
1014
+ " combine_adjacent=True, show_label=True),\n",
1015
+ " gr.Markdown(label=\"Signals\")],\n",
1016
+ " examples=SAMPLES, cache_examples=False, title=\"BITS Phishing Email Detector (proxied to the Space)\",\n",
1017
+ " description=f\"Every prediction below runs on {SPACE} ({SPACE_HW}); this runtime only forwards the request.\")\n",
1018
+ " demo_kind = \"gr.Interface around gradio_client (gr.load fallback), every call proxied\"\n",
1019
+ "if demo is None:\n",
1020
+ " print(\"no interface: the Space is unreachable from this runtime (private without a token, or not running)\")\n",
1021
+ "else:\n",
1022
+ " print(\"interface:\", demo_kind)\n",
1023
+ "if demo is not None:\n",
1024
+ " if IN_COLAB:\n",
1025
+ " demo.launch() # inline in Colab; every prediction is proxied to the Space\n",
1026
+ " else:\n",
1027
+ " demo.launch(prevent_thread_lock=True, inline=False, share=False, quiet=True)\n",
1028
+ " print(f\"headless smoke test: the proxied interface was served at {demo.local_url} and is closed again\")\n",
1029
+ " demo.close()"
1030
+ ]
1031
+ },
1032
+ {
1033
+ "cell_type": "code",
1034
+ "execution_count": null,
1035
+ "id": "cell-37",
1036
+ "metadata": {},
1037
+ "outputs": [],
1038
+ "source": [
1039
+ "from IPython.display import IFrame\n",
1040
+ "\n",
1041
+ "try:\n",
1042
+ " space_public = api.space_info(SPACE).private is False\n",
1043
+ "except Exception:\n",
1044
+ " space_public = False\n",
1045
+ "if space_public:\n",
1046
+ " display(IFrame(SPACE_HOST, \"100%\", 900))\n",
1047
+ "else:\n",
1048
+ " print(f\"The iframe embed needs a public Space; {SPACE} is private right now. Flip it to public in the Hub settings before class \"\n",
1049
+ " f\"and the native page appears here: {SPACE_HOST}\")"
1050
+ ]
1051
+ },
1052
+ {
1053
+ "cell_type": "markdown",
1054
+ "id": "cell-38",
1055
+ "metadata": {},
1056
+ "source": [
1057
+ "**Step 8: Authenticate with Hugging Face**\n",
1058
+ "\n",
1059
+ "The reference pastes a write token into `notebook_login()`. Here the token came from the Colab secret (or the environment /\n",
1060
+ "login cache) in Step 1 and every Hub call in this notebook passes it explicitly, so `login()` is only needed when the token is\n",
1061
+ "not yet the one `huggingface_hub` resolves on its own (in huggingface_hub 1.33.0 `login(token=...)` always validates with\n",
1062
+ "`whoami` and re-saves the token to the runtime's HF cache; `skip_if_logged_in` only governs the interactive flows). When the\n",
1063
+ "token already comes from the login cache or the Colab secret, nothing is written. The only thing printed is the account name\n",
1064
+ "the Hub answers with: the token value never appears in the notebook, its outputs or the repository."
1065
+ ]
1066
+ },
1067
+ {
1068
+ "cell_type": "code",
1069
+ "execution_count": null,
1070
+ "id": "cell-39",
1071
+ "metadata": {},
1072
+ "outputs": [],
1073
+ "source": [
1074
+ "from huggingface_hub import login\n",
1075
+ "\n",
1076
+ "if TOKEN and TOKEN != huggingface_hub.get_token():\n",
1077
+ " login(token=TOKEN, add_to_git_credential=False) # validates with whoami and saves the token in this runtime's HF cache (Colab: ephemeral disk)\n",
1078
+ " print(\"token saved for this runtime's huggingface_hub\")\n",
1079
+ "elif TOKEN:\n",
1080
+ " print(\"token already active for huggingface_hub (login cache / Colab secret): nothing written\")\n",
1081
+ "else:\n",
1082
+ " print(\"no token found (anonymous): the private repos stay unreadable until they are public\")\n",
1083
+ "try:\n",
1084
+ " print(\"Logged in to Hugging Face as:\", HfApi(token=TOKEN).whoami()[\"name\"])\n",
1085
+ "except Exception as e:\n",
1086
+ " print(f\"whoami failed ({type(e).__name__}): no token / offline\")\n",
1087
+ "print(\"The token is never stored in this notebook or in the repository.\")"
1088
+ ]
1089
+ },
1090
+ {
1091
+ "cell_type": "markdown",
1092
+ "id": "cell-40",
1093
+ "metadata": {},
1094
+ "source": [
1095
+ "**Step 9: Upload the App to Hugging Face Spaces**\n",
1096
+ "\n",
1097
+ "The reference uploads a folder with `HfApi().upload_folder`. Ours is `scripts/upload_to_hf.py` (in the bundle under\n",
1098
+ "`scripts/`): it creates the private model repo and the private ZeroGPU Space named in `config.yaml`, uploads\n",
1099
+ "`model_export/ft-focal/` and `app/` (with the weights bundled into the Space, so no secret is needed) and prints both URLs.\n",
1100
+ "It is a dry run unless `--yes` is given, and the live run was done once from the workstation terminal (RUN_LOG entry 24),\n",
1101
+ "never from a notebook. This cell shows the command that was run, the two URLs, the Space's current runtime and the log entry."
1102
+ ]
1103
+ },
1104
+ {
1105
+ "cell_type": "code",
1106
+ "execution_count": null,
1107
+ "id": "cell-41",
1108
+ "metadata": {},
1109
+ "outputs": [],
1110
+ "source": [
1111
+ "hf = CFG[\"hf\"]\n",
1112
+ "print(\"Live upload (run once from the workstation terminal, 2026-10-12, RUN_LOG entry 24):\")\n",
1113
+ "print(\" .venv/bin/python scripts/upload_to_hf.py --yes --model-dir model_export/ft-focal --bundle-weights\\n\")\n",
1114
+ "print(f\"model repo : https://huggingface.co/{MODEL_REPO}\")\n",
1115
+ "print(f\"Space : https://huggingface.co/spaces/{SPACE} (sdk {hf['space_sdk']} {hf['space_sdk_version']}, hardware {hf['space_hardware']}, \"\n",
1116
+ " f\"python {hf['space_python_version']}, torch {hf['space_torch']})\")\n",
1117
+ "try:\n",
1118
+ " rt = api.get_space_runtime(SPACE)\n",
1119
+ " print(f\"Space runtime now: stage {rt.stage}, hardware {rt.hardware}\")\n",
1120
+ "except Exception as e:\n",
1121
+ " print(f\"Space runtime not readable ({type(e).__name__})\")\n",
1122
+ "log = artifact(\"RUN_LOG.md\", \"kept by hand on the workstation and mirrored into the bundle\")\n",
1123
+ "if log:\n",
1124
+ " lines = log.read_text().splitlines()\n",
1125
+ " start = next((i for i, l in enumerate(lines) if l.startswith(\"## 24.\")), None)\n",
1126
+ " if start is None:\n",
1127
+ " print(\"RUN_LOG entry 24 not found\")\n",
1128
+ " else:\n",
1129
+ " para = []\n",
1130
+ " for l in lines[start + 1:]: # the first paragraph = the first non-empty block after the header\n",
1131
+ " if l.strip():\n",
1132
+ " para.append(l)\n",
1133
+ " elif para:\n",
1134
+ " break\n",
1135
+ " display(Markdown(f\"> **{lines[start].lstrip('# ')}**\\n>\\n> \" + \"\\n> \".join(para)))"
1136
+ ]
1137
+ },
1138
+ {
1139
+ "cell_type": "markdown",
1140
+ "id": "cell-42",
1141
+ "metadata": {},
1142
+ "source": [
1143
+ "## Results\n",
1144
+ "\n",
1145
+ "Performance on the **unseen** test sets, written by `src/evaluate.py` (the only script allowed to open the holdouts).\n",
1146
+ "The headline is the binary roll-up on TREC-06: P(unwanted) = P(phishing) + P(spam) at threshold 0.5, with recall on the\n",
1147
+ "unwanted class first (a missed phish is the costly error). The three-way holdout gives the 3x3 confusion matrix; the\n",
1148
+ "phishing-vs-spam boundary follows corpus provenance and is expected to be softer than legitimate-vs-unwanted."
1149
+ ]
1150
+ },
1151
+ {
1152
+ "cell_type": "code",
1153
+ "execution_count": null,
1154
+ "id": "cell-43",
1155
+ "metadata": {},
1156
+ "outputs": [],
1157
+ "source": [
1158
+ "def metric_tables(path, title):\n",
1159
+ " M = json.loads(path.read_text())\n",
1160
+ " at, tf = M.get(\"at_threshold\", {}), M.get(\"threshold_free\", {})\n",
1161
+ " print(f\"{title}: checkpoint {M.get('checkpoint')}, {M['split_sizes']['rows']:,} rows, positive rate {M['split_sizes']['positive_rate']}, threshold {M.get('threshold')}\")\n",
1162
+ " binary = {\"recall (unwanted)\": at.get(\"recall\"), \"precision\": at.get(\"precision\"), \"F1\": at.get(\"f1\"), \"accuracy\": at.get(\"accuracy\"),\n",
1163
+ " \"specificity\": at.get(\"specificity\"), \"ROC-AUC\": tf.get(\"roc_auc\"), \"PR-AUC\": tf.get(\"pr_auc\"),\n",
1164
+ " \"recall @ 1% FPR\": (tf.get(\"recall_at_fpr_1pct\") or {}).get(\"recall\"), \"recall @ 0.1% FPR\": (tf.get(\"recall_at_fpr_0_1pct\") or {}).get(\"recall\")}\n",
1165
+ " show(pd.DataFrame(binary, index=[\"value\"]).T, f\"{title}: binary roll-up\", fmt=\"{:.4f}\")\n",
1166
+ " cm = at.get(\"confusion_matrix\")\n",
1167
+ " if cm:\n",
1168
+ " show(pd.DataFrame([[cm[\"tn\"], cm[\"fp\"]], [cm[\"fn\"], cm[\"tp\"]]], index=[\"true legitimate\", \"true unwanted\"],\n",
1169
+ " columns=[\"pred legitimate\", \"pred unwanted\"]), \"Binary confusion matrix\")\n",
1170
+ " lure = (M.get(\"credential_lure_slice\") or {}).get(\"metrics\")\n",
1171
+ " if lure:\n",
1172
+ " show(pd.DataFrame({k: lure.get(k) for k in (\"n\", \"n_positive\", \"recall\", \"precision\", \"f1\", \"accuracy\")}, index=[\"credential-lure slice\"]).T, \"Credential-lure slice\")\n",
1173
+ " tw = M.get(\"threeway\")\n",
1174
+ " if tw and tw.get(\"confusion_matrix\"):\n",
1175
+ " labs = tw[\"confusion_matrix\"][\"labels\"]\n",
1176
+ " show(pd.DataFrame(tw[\"confusion_matrix\"][\"matrix\"], index=[f\"true {l}\" for l in labs], columns=[f\"pred {l}\" for l in labs]), \"3x3 confusion matrix\")\n",
1177
+ " show(pd.DataFrame(tw[\"per_class\"]).T, f\"Per-class metrics (macro-F1 {tw.get('macro_f1'):.4f})\" if tw.get(\"macro_f1\") is not None else \"Per-class metrics\")\n",
1178
+ " return M\n",
1179
+ "\n",
1180
+ "RESULTS = {}\n",
1181
+ "for rel, title in ((\"reports/metrics.json\", \"TREC-06 binary holdout\"), (\"reports/metrics_test3.json\", \"Three-way holdout\")):\n",
1182
+ " p = artifact(rel, \"src/evaluate.py run --checkpoint checkpoints/finetune/ft-focal/best --split test,test3 (main run ft-focal, selected on validation F1)\")\n",
1183
+ " if p:\n",
1184
+ " RESULTS[rel] = metric_tables(p, title)"
1185
+ ]
1186
+ },
1187
+ {
1188
+ "cell_type": "code",
1189
+ "execution_count": null,
1190
+ "id": "cell-44",
1191
+ "metadata": {},
1192
+ "outputs": [],
1193
+ "source": [
1194
+ "# Ablations on the SAME holdouts: run_experiments.sh evaluates every queued run into reports/eval_<run>/ (one directory per\n",
1195
+ "# run, so no report overwrites another); the main run (ft-focal: best validation F1 of the unwanted roll-up and best validation\n",
1196
+ "# three-way macro-F1 among the six, chosen before looking at the test numbers) is also written to the top-level reports/ shown above.\n",
1197
+ "abl = []\n",
1198
+ "for d in sorted((ROOT / \"reports\").glob(\"eval_*\")):\n",
1199
+ " mj, m3 = d / \"metrics.json\", d / \"metrics_test3.json\"\n",
1200
+ " if mj.exists():\n",
1201
+ " M = json.loads(mj.read_text()); at, tf = M.get(\"at_threshold\", {}), M.get(\"threshold_free\", {})\n",
1202
+ " row = {\"run\": d.name[len(\"eval_\"):], \"TREC-06 recall\": at.get(\"recall\"), \"precision\": at.get(\"precision\"), \"F1\": at.get(\"f1\"),\n",
1203
+ " \"ROC-AUC\": tf.get(\"roc_auc\"), \"PR-AUC\": tf.get(\"pr_auc\"), \"recall @ 1% FPR\": (tf.get(\"recall_at_fpr_1pct\") or {}).get(\"recall\")}\n",
1204
+ " if m3.exists():\n",
1205
+ " tw = json.loads(m3.read_text()).get(\"threeway\") or {}\n",
1206
+ " row[\"3-way macro-F1\"] = tw.get(\"macro_f1\"); row[\"3-way accuracy\"] = tw.get(\"accuracy\")\n",
1207
+ " abl.append(row)\n",
1208
+ "if abl:\n",
1209
+ " show(pd.DataFrame(abl).set_index(\"run\").sort_values(\"F1\", ascending=False), \"Ablations on the unseen holdouts (reports/eval_<run>/)\", fmt=\"{:.4f}\")\n",
1210
+ "else:\n",
1211
+ " artifact(\"reports/eval_<run>/metrics.json\", \"scripts/run_experiments.sh -> src/evaluate.py run --out-dir reports/eval_<run> (one per queued run)\")"
1212
+ ]
1213
+ },
1214
+ {
1215
+ "cell_type": "code",
1216
+ "execution_count": null,
1217
+ "id": "cell-45",
1218
+ "metadata": {},
1219
+ "outputs": [],
1220
+ "source": [
1221
+ "for rel in (\"reports/confusion_matrix.png\", \"reports/roc_curve.png\", \"reports/pr_curve.png\", \"reports/confusion_matrix3_test3.png\",\n",
1222
+ " \"reports/confusion_matrix_test3.png\"):\n",
1223
+ " p = artifact(rel, \"src/evaluate.py run --split test,test3\")\n",
1224
+ " if p and p.stat().st_size < 2_000_000:\n",
1225
+ " display(Image(filename=str(p), width=520))\n",
1226
+ "for rel in (\"reports/error_analysis.md\", \"reports/error_analysis_test3.md\"):\n",
1227
+ " p = artifact(rel, \"src/evaluate.py run --split test,test3\")\n",
1228
+ " if p:\n",
1229
+ " display(Markdown(\"\\n\".join(p.read_text().splitlines()[:40]) + \"\\n\\n*(truncated; full file: \" + rel + \")*\"))"
1230
+ ]
1231
+ },
1232
+ {
1233
+ "cell_type": "code",
1234
+ "execution_count": null,
1235
+ "id": "cell-46",
1236
+ "metadata": {},
1237
+ "outputs": [],
1238
+ "source": [
1239
+ "# Latency as recorded by evaluate.py (GPU bf16 and CPU fp32 on the same 100 holdout emails) next to the benchmark.\n",
1240
+ "if lat:\n",
1241
+ " L = json.loads(lat.read_text())\n",
1242
+ " show(pd.DataFrame({k: v for k, v in L.items() if isinstance(v, dict)}).T, f\"reports/latency.json (benchmark {L.get('instructor_benchmark_ms')} ms)\")\n",
1243
+ "else:\n",
1244
+ " SL = SMOKE.get(\"latency\", {})\n",
1245
+ " g, c = SL.get(\"gpu_bf16_512_no_mask\", {}).get(\"avg_ms\"), SL.get(\"cpu_fp32_512\", {}).get(\"avg_ms\")\n",
1246
+ " print(\"latency.json not written yet (produced by: scripts/run_experiments.sh -> src/evaluate.py run); the measured numbers so far are in the \"\n",
1247
+ " f\"Step 5 table (smoke test GPU {g} ms bf16, CPU {c} ms fp32 at 512 tokens).\")"
1248
+ ]
1249
+ },
1250
+ {
1251
+ "cell_type": "markdown",
1252
+ "id": "cell-47",
1253
+ "metadata": {},
1254
+ "source": [
1255
+ "## Challenges and what we learned\n",
1256
+ "\n",
1257
+ "Distilled from `RUN_LOG.md` (entries 0-9), which records every decision, number and problem.\n",
1258
+ "\n",
1259
+ "- **Python 3.14 wheels.** The workstation runs Python 3.14.7; every pin (torch 2.14.1 cu130, tokenizers, safetensors, pyarrow, ...) had to be checked live for a cp314 wheel before the venv was built. The plain PyPI torch wheel *is* the CUDA 13.0 build, and driver 615 (CUDA 13.4) runs any 13.x build by backward compatibility; `uv --torch-backend=auto` would nevertheless have resolved an unverified cu132 build instead of the verified cu130 wheel (uv 0.12.5 iterates cu132 before cu130 for drivers >= 580), so the install pins `--torch-backend=cu130`.\n",
1260
+ "- **ZeroGPU pins the Space.** ZeroGPU supports only torch 2.8-2.13 on Python 3.12.12, no `torch.compile`, and cannot preload a private model repo, so the Space uses torch 2.13.0 and downloads the weights at startup with an `HF_TOKEN` secret. Training (torch 2.14.1 / 3.14) and serving (2.13.0 / 3.12) are different stacks; safetensors makes that safe.\n",
1261
+ "- **Datasets without body text.** `it4lia/...Cleaned` turned out to hold only subjects and 53 numeric features; `zefang-liu/...` was a lossy 92% copy of the primary set with an \"enron\" shortcut. Both were excluded before downloading anything.\n",
1262
+ "- **Hub-split leakage.** The primary set's own train/test split has 5.1% exact and ~40% near-duplicate test rows in train; we built our own split and asserted zero leakage with independent recomputation.\n",
1263
+ "- **Enron contamination.** 89% of TREC-05 ham and 86% of Enron-Spam ham are in the pretraining corpus, so neither could be the test set; TREC-06 (5 rows mention Enron) became the holdout, and 1,652 training rows that near-duplicate TREC-06 spam templates were removed.\n",
1264
+ "- **Near-duplicate decontamination.** Exact-key dedup missed quoted/forwarded 419-letter variants of holdout emails in the MLM corpus; the reviewer caught it, and 50-char shingle matching (>= 3 shared) now gates every split. 52.7% of `corbt/enron-emails` was exact duplicates.\n",
1265
+ "- **VRAM finder bug.** The first batch finder measured peak memory with one forward/backward and under-reported the real gradient-accumulation peak by one fp32 gradient copy (1.26 GiB); fixed before the long run. Checkpointing turned out unnecessary (B=16 fits with 8 GiB headroom) and costs ~25% throughput.\n",
1266
+ "- **Effective batch 264, not 256.** Micro-batch 24 does not divide 256; floor division would have reported 256 while training at 240. The trainer now uses ceil(256/24) = 11 accumulation steps and records the realised 264 everywhere, including the LR schedule.\n",
1267
+ "- **CPU latency reality.** The research estimate of ~0.34 s per 512-token email on the 5950X was 2x optimistic: measured 0.70 s (0.32 s at 128 tokens) because CPU inference is per-op-overhead bound. The 0.074 s benchmark is met on GPU (10.7 ms), hence ZeroGPU rather than a free CPU Space.\n",
1268
+ "- **The 300k three-class set was rejected.** Inspection showed it is a union of Kaggle re-packagings of corpora we already hold, 9.9% of it copies of our frozen TREC-06 test set, with a \"phish\" label that agrees with real provenance only 60% of the time and a stopword-stripping artifact that would be a near-perfect label proxy. Not one row was ingested.\n",
1269
+ "- **Three-way label scarcity.** No public corpus separates spam from phishing at scale: only 3,150 clean phishing training rows (Nazario, Nigerian fraud) plus 1,000 synthetic lures carry the distinction, while 62,805 TREC/CEAS positives are trained as \"unwanted\" with a partial-label loss. The binary roll-up on TREC-06 is the headline; the three-way split is the weaker decision and is reported as such.\n",
1270
+ "- **Reviewer-caught trainer bugs** (fixed before any real run): an `--lr` override silently discarded on resume but logged as applied; a resumed finished run training one extra step at lr 0; orphan metric rows after a hard crash; 0 occurrences of `[CLS]` in the packed MLM stream (every window now starts with `[CLS]`, so the classifier's pooled position is pretrained).\n",
1271
+ "- **DataLoader migration with an RNG side effect.** The course requires `torch.utils.data.DataLoader` for batching and shuffling; `finetune.py` had its own numpy sampler. Moving every batch through `DataLoader` (row-index Dataset + seeded length-bucketed `batch_sampler` + padding collate, Step 2) was verified loss-identical at every step on the CPU fixture, except that iterating a DataLoader draws its worker base seed from the global torch RNG and shifted the dropout stream (step-1 loss 1.1093 vs 1.1125). Passing an explicit `generator` removed the side effect.\n",
1272
+ "- **Self-test artifacts are not results.** The agents' dev checkpoints (scratch init, a handful of steps on 2,000 rows) and their `experiments.jsonl` lines were deleted before the real queue was armed, so nothing in `reports/` or `checkpoints/` can be mistaken for a result; this notebook labels anything named `dev*` and shows \"not available yet\" until the queue writes the real artifacts."
1273
+ ]
1274
+ },
1275
+ {
1276
+ "cell_type": "code",
1277
+ "execution_count": null,
1278
+ "id": "cell-48",
1279
+ "metadata": {},
1280
+ "outputs": [],
1281
+ "source": [
1282
+ "elapsed = timer() - T_NOTEBOOK_START\n",
1283
+ "print(f\"Notebook executed top-to-bottom in {elapsed:.0f} s (in Colab: {IN_COLAB}); model compute on the Space {SPACE}, \"\n",
1284
+ " f\"artifacts from {MODEL_REPO}/{PRESENTATION_PREFIX}.\")\n",
1285
+ "if MISSING:\n",
1286
+ " print(f\"\\n{len(MISSING)} artifact(s) not in the bundle:\")\n",
1287
+ " for rel, by in dict(MISSING).items():\n",
1288
+ " print(f\" - {rel}\\n produced by: {by}\")\n",
1289
+ "else:\n",
1290
+ " print(\"Every artifact of the bundle was present.\")"
1291
+ ]
1292
+ }
1293
+ ],
1294
+ "metadata": {
1295
+ "kernelspec": {
1296
+ "display_name": "Python 3 (ipykernel)",
1297
+ "language": "python",
1298
+ "name": "python3"
1299
+ },
1300
+ "language_info": {
1301
+ "name": "python"
1302
+ }
1303
+ },
1304
+ "nbformat": 4,
1305
+ "nbformat_minor": 5
1306
+ }
presentation/README.md CHANGED
@@ -435,6 +435,33 @@ CUDA_VISIBLE_DEVICES="" .venv/bin/jupyter lab
435
  CUDA_VISIBLE_DEVICES="" .venv/bin/jupyter nbconvert --to notebook --execute --inplace FinalProject_PhishingDetector.ipynb
436
  ```
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  ---
439
 
440
  ## 6. Config dials (`config.yaml`)
 
435
  CUDA_VISIBLE_DEVICES="" .venv/bin/jupyter nbconvert --to notebook --execute --inplace FinalProject_PhishingDetector.ipynb
436
  ```
437
 
438
+ ### 5.11 The Colab presentation notebook (`FinalProject_PhishingDetector_Colab.ipynb`)
439
+
440
+ Built by the same script with `--colab` (`--out PATH` picks the output file; the default mode and its output are
441
+ unchanged). Design: **the notebook is narrative, the Space is the compute.** Nothing trains or infers in Colab;
442
+ every prediction runs on the ZeroGPU Space, and all evidence is pulled from the Hub:
443
+
444
+ | Need | Where it comes from in Colab |
445
+ |---|---|
446
+ | 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` |
447
+ | 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 |
448
+ | 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 |
449
+ | 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 |
450
+ | 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 |
451
+
452
+ How Nathan runs it: upload the `.ipynb` to Colab (File -> Upload notebook, or from Drive); while the repos are
453
+ private, add a Colab Secret named `HF_TOKEN` with notebook access on, and make it a **fine-grained read token
454
+ scoped to the two repos**, because `gr.load` forwards the token to the Space with every proxied call; run all
455
+ cells. The first cell installs `gradio==6.29.1 huggingface_hub==1.33.0` (only when it detects Colab). Once both
456
+ repos are public no token is needed and the iframe appears automatically.
457
+
458
+ Verified on 2026-10-12: executed headlessly from an empty directory in a clean Python 3.12 venv (torch CPU,
459
+ gradio 6.29.1, huggingface_hub 1.33.0) with no project file reachable: 36/36 code cells ran, zero errors, six
460
+ live predictions from the Space, `gr.load` succeeded after the shim retry, the account name printed and no
461
+ token-shaped string in any output; the numbers it renders were spot-checked against `reports/metrics*.json`
462
+ (RUN_LOG 25). The Colab-only branches (`%pip`, `userdata.get`, inline `launch()`) are exercised by the
463
+ `IN_COLAB` switch but were not run on a real Colab runtime; do one rehearsal there.
464
+
465
  ---
466
 
467
  ## 6. Config dials (`config.yaml`)
presentation/RUN_LOG.md CHANGED
@@ -929,3 +929,66 @@ email ("verify your account within 24 hours", lookalike PayPal link) returned ve
929
  Spam 3.5%, Legitimate 0.9%, roll-up Unwanted 99.1%, in-app "Prediction Time (s)" 0.253 (cold ZeroGPU first
930
  call), round-trip 3.6 s, with the token highlights and the rule-based signals populated. The deployment is
931
  working end to end.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  Spam 3.5%, Legitimate 0.9%, roll-up Unwanted 99.1%, in-app "Prediction Time (s)" 0.253 (cold ZeroGPU first
930
  call), round-trip 3.6 s, with the token highlights and the rule-based signals populated. The deployment is
931
  working end to end.
932
+
933
+ ## 25. 2026-10-12 11:58 — Presentation bundle on the Hub + Colab variant of the notebook
934
+
935
+ **Why.** Nathan will present from Google Colab, where none of the project files exist and the local GPU is
936
+ irrelevant. Design: the notebook is narrative only; every prediction runs on the ZeroGPU Space
937
+ (`gr.load(..., src="spaces")` to embed the Space UI, `gradio_client` for the timing loop), and the evidence the
938
+ notebook renders (metrics, curves, experiments table, data report, run log, code) is fetched from the model repo.
939
+
940
+ **Bundle upload (approved by Nathan 2026-10-12 11:50).** `presentation/` in the private model repo
941
+ `BuildITSystems/phishing-detector-encoder`, commit `439e7b1a`: 85 files, 3.6 MB, relative paths mirrored from
942
+ the project (`reports/` JSON/PNG/MD incl. `eval_<run>/`, `checkpoints/pretrain/large-24L/{run_summary.json,
943
+ metrics.csv,final/config.json}`, `checkpoints/finetune/<run>/{metrics.csv,epochs.csv}`,
944
+ `data/processed/{splits_manifest,tokenizer_report,pretrain_tokens}.json` + `data_report.md`,
945
+ `docs/research/research_model-math.md`, `src/*.py`, `app/{app.py,model.py,requirements.txt,README.md}`,
946
+ `config.yaml`, `requirements-train.txt`, `scripts/*`, `model_export/ft-focal/{config.json,README.md,
947
+ export_manifest.json}`, `RUN_LOG.md`, `README.md`). Excluded on purpose: every parquet/npy/safetensors/log.
948
+ Scanned before upload for tokens (`hf_...`), `HF_TOKEN=` values, Nathan's e-mail address, private keys and
949
+ cloud keys: clean. The quoted emails in `error_analysis*.md` are rows of public corpora (TREC-06 etc.), as the
950
+ files themselves state. The Hub warned that `presentation/README.md` has no YAML front matter (it is the
951
+ project README, not a model card): harmless.
952
+
953
+ **Colab notebook build (workflow wf_4200da59, 8 agents, 53 min, 12:00-12:53).** `scripts/build_notebook.py`
954
+ gained `--colab` and `--out PATH`; cells are tagged `local` / `colab` / shared so both notebooks come from one
955
+ source (default mode proven byte-identical in cell sources against the executed deliverable, which was never
956
+ rewritten: `cmp` identical before and after). The Colab variant (49 cells, 36 code) keeps the nine instructor
957
+ headers and the story; Steps 2-4 and the Results read the Hub bundle; Step 5 calls the Space through
958
+ `gradio_client`; Step 7 embeds the Space with `gr.load`; Step 8 logs in only when the token is not already
959
+ active (huggingface_hub 1.33 `login(token=...)` otherwise rewrites the token files unconditionally); Step 9
960
+ quotes entry 24. Two adversarial reviews found 6 items, all fixed: per-call error handling around the Space
961
+ calls (one ZeroGPU abort must not kill the cell), the `login()` behaviour above, `analytics_enabled=False` on
962
+ the client (the env var alone does not disable gradio_client telemetry), a note that `gr.load` forwards the token
963
+ to the Space (use a fine-grained read token as the Colab secret), the login-cache fallback made opt-in outside
964
+ Colab (`NOTEBOOK_ALLOW_LOGIN_CACHE=1`), and silencing the doomed first `gr.load` attempt.
965
+
966
+ **Challenge recorded: `gr.load` bug in gradio 6.29.1.** `gr.load("BuildITSystems/phishing-detector-demo",
967
+ src="spaces")` raises `TypeError: Textbox.__init__() got an unexpected keyword argument 'class_id'`:
968
+ `Blocks.from_config` rebuilds the Interface's examples `Dataset`, whose serialised child components are
969
+ `{"name", "class_id"}` dicts, and `gradio.components.base.get_component_instance` pops only `name`. Any Space
970
+ whose `gr.Interface` has `examples=` triggers it. Reproduced independently on the workstation. Fix in the
971
+ notebook: catch the error, install an idempotent shim that drops `class_id`, retry `gr.load`; keep a hand-built
972
+ proxy `gr.Interface` over `gradio_client` as the last fallback. Verified: `gr.load` succeeds after the shim.
973
+
974
+ **Headless Colab-mimic test (clean uv venv, CPython 3.12.14, torch 2.14.1+cpu, gradio 6.29.1, gradio_client
975
+ 2.7.2, huggingface_hub 1.33.0, executed from an empty scratch directory, `HF_TOKEN` unset, token only from the
976
+ login cache via the opt-in flag).** Round 1 ran 36/36 cells with no errors but `gr.load` had fallen back to the
977
+ proxy (flagged as a failure by the harness); after the shim fix round 2 passed every check: 36/36 cells, 0
978
+ errors, 18 s wall, bundle 86 files / 3.4 MiB, six live Space predictions (in-app times 0.229 s cold then 0.009-
979
+ 0.011 s; e.g. "Microsoft 365 password expires" -> Phishing 93.7%, "FINAL HOURS: 70% OFF" -> Spam 99.0%, "Agenda
980
+ for Thursday" -> Legitimate 98.6%), `gr.load` served and closed, whoami = BuildITSystems, zero token-shaped
981
+ strings in outputs or raw file. Final verification: TREC-06 F1 0.9243 / recall 0.9741 / ROC-AUC 0.9946 and the
982
+ binary confusion 11888/520/101/3793, three-way macro-F1 0.9867 and the 3x3 matrix all match `reports/`.
983
+ Presenter notes, non-blocking: the iframe cell prints "needs a public Space" until the Space is public; the
984
+ `gr.load` cell shows the shim trace line; some outputs echo workstation absolute paths that live inside the
985
+ mirrored JSON files.
986
+
987
+ **gradio 6.30.0 checked (released after the pin):** `gr.load` fails identically (`class_id`), tested in a
988
+ throwaway venv against the Space, so bumping the pin would not remove the shim. Keep 6.29.1 (= the Space's
989
+ `sdk_version`) in the Colab notebook until a release fixes `get_component_instance`.
990
+
991
+ **Bundle refresh 2026-10-12 13:05:** re-uploaded `presentation/RUN_LOG.md`, `presentation/README.md` and
992
+ `presentation/scripts/build_notebook.py` (all changed by this entry) and added
993
+ `presentation/FinalProject_PhishingDetector_Colab.ipynb` so the notebook can be downloaded from the model repo
994
+ and uploaded to Colab in one step. Same approval as the bundle; same scan; nothing else changed on the Hub.
presentation/scripts/build_notebook.py CHANGED
@@ -1,6 +1,15 @@
1
  #!/usr/bin/env python
2
  """Build FinalProject_PhishingDetector.ipynb with nbformat (idempotent: same cells every run).
3
 
 
 
 
 
 
 
 
 
 
4
  The notebook mirrors the instructor's reference (reference/FashionMNIST_Gradio_Full.ipynb): a
5
  title cell, then EXACTLY his nine bold "**Step N: ...**" markdown headers in his order, each
6
  followed by a short explanation and code cells that load OUR artifacts, then "Results" and
@@ -13,10 +22,13 @@ go to ``<project>/.notebook_tmp`` and are removed by the last cell.
13
  .venv/bin/python scripts/build_notebook.py # write the notebook (no outputs)
14
  CUDA_VISIBLE_DEVICES="" .venv/bin/python -m jupyter nbconvert --to notebook --execute \
15
  --inplace --ExecutePreprocessor.timeout=600 FinalProject_PhishingDetector.ipynb
 
 
16
  """
17
 
18
  from __future__ import annotations
19
 
 
20
  import sys
21
  from pathlib import Path
22
 
@@ -25,16 +37,26 @@ from nbformat.v4 import new_code_cell, new_markdown_cell, new_notebook
25
 
26
  ROOT = Path(__file__).resolve().parent.parent
27
  OUT = ROOT / "FinalProject_PhishingDetector.ipynb"
 
 
 
 
 
 
28
 
29
- CELLS: list[tuple[str, str]] = []
 
 
 
 
30
 
31
 
32
  def md(text: str) -> None:
33
- CELLS.append(("markdown", text.strip("\n")))
34
 
35
 
36
  def code(text: str) -> None:
37
- CELLS.append(("code", text.strip("\n")))
38
 
39
 
40
  # =============================================================================== title
@@ -58,6 +80,26 @@ notebook and loads the real artifacts of the project; anything that is still bei
58
  trains is reported as "not available yet" together with the command that produces it.
59
  """)
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  # =============================================================================== Step 1
62
  md("""
63
  **Step 1: Install Dependencies & Setup Device**
@@ -143,6 +185,124 @@ print("\\nRuntime facts (common.print_device_info):")
143
  DEVICE_INFO = common.print_device_info()
144
  print(f"\\nCPU threads for torch: {torch.get_num_threads()}")
145
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  code("""
147
  # The GPU facts recorded by the smoke test on 2026-10-07 (the GPU is not visible to this kernel).
148
  smoke = artifact("reports/smoke_test.json", "src/smoke_test.py")
@@ -217,6 +377,7 @@ drops = pd.DataFrame([
217
  ], columns=["removed", "rows"]).set_index("removed")
218
  show(drops, "Rows removed by the dedup / decontamination procedure", fmt="{:,}")
219
  """)
 
220
  code("""
221
  import pyarrow.parquet as pq
222
 
@@ -238,6 +399,22 @@ by_split["binary positive rate"] = lab.groupby("split")["label"].mean().reindex(
238
  show(by_split, "Rows per split by label3 (EDA)")
239
  show(pd.crosstab([lab["split"], lab["source"]], lab["class"]), "Rows per split x source x class")
240
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  code("""
242
  fig, ax = plt.subplots(figsize=(9, 3.8))
243
  cols = ["Legitimate", "Phishing", "Spam", "unwanted (unsplit)"]
@@ -251,6 +428,7 @@ ax.set_ylabel("rows"); ax.set_title("Labelled splits by class", loc="left"); ax.
251
  for s in ("top", "right"): ax.spines[s].set_visible(False)
252
  plt.tight_layout(); plt.show()
253
  """)
 
254
  code("""
255
  # Tokenizer: byte-level BPE, vocab 32,768, NFKC only (case preserved: ALL-CAPS is a spam signal), trained on the
256
  # pretraining corpus + finetune_train ONLY (val and the holdouts are refused by name; 0 holdout hash collisions).
@@ -322,6 +500,59 @@ for r in range(min(6, len(idx))):
322
  f"tokens {int(mask[r].sum()):>3} text {labelled.texts[i][:60]!r}")
323
  print("\\n'unwanted' rows (TREC/CEAS positives) allow BOTH phishing and spam: the loss is -log(P(phishing)+P(spam)), the partial-label NLL.")
324
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  code("""
326
  # Pretraining data: documents are tokenised once, concatenated with [SEP] after each, and cut into packed windows of
327
  # [CLS] + 511 stream tokens (no padding, no attention mask -> the Flash SDPA kernel). Dynamic 15% masking per batch.
@@ -351,6 +582,7 @@ token embedding (saves 33.5M parameters); the **classification head** reads the
351
  logits (legitimate / phishing / spam); rows whose corpus never separated spam from phishing train on the probability
352
  mass of {phishing, spam}. The course requires at least 2 layers; this model has 24.
353
  """)
 
354
  code("""
355
  import model as phish_model
356
  from model import EncoderConfig, EncoderLayer, EncoderForMLM, EncoderForClassification, count_parameters
@@ -390,6 +622,66 @@ print(f"Chosen: A = {model_cfg.n_layers}L d{model_cfg.d_model} h{model_cfg.n_hea
390
  f"{counts.loc['total'].iloc[0]:,} (pretraining, tied head) / {counts.loc['total'].iloc[1]:,} (classifier), counted above.")
391
  """)
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  # =============================================================================== Step 4
394
  md("""
395
  **Step 4: Train the Model**
@@ -466,7 +758,7 @@ if png and summ:
466
  # run_summary.json records the finish time and the log path (pretrain-YYYYMMDD-HHMMSS.log); the launch time comes from the latter.
467
  started = S.get("started")
468
  if not started and S.get("log"):
469
- m = _re.search(r"(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})", Path(S["log"]).name)
470
  if m:
471
  started = "{}-{}-{}T{}:{}:{}".format(*m.groups())
472
  started = started or S.get("finished") or ""
@@ -572,6 +864,7 @@ else:
572
  """)
573
 
574
  # =============================================================================== Step 5
 
575
  md("""
576
  **Step 5: Test Inference Speed and Save the Model**
577
 
@@ -684,7 +977,127 @@ if classifier is not None:
684
  shutil.rmtree(save_dir, ignore_errors=True)
685
  """)
686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  # =============================================================================== Step 6
 
688
  md("""
689
  **Step 6: Create the App Files (Optional)**
690
 
@@ -722,7 +1135,35 @@ for f in ("model.safetensors", "config.json", "tokenizer.json"):
722
  artifact(f"app/{f}", "src/export.py run (copied next to app.py so the Space can also load weights locally)")
723
  """)
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  # =============================================================================== Step 7
 
726
  md("""
727
  **Step 7: Launch the Gradio Interface**
728
 
@@ -776,7 +1217,104 @@ else:
776
  print("Gradio not launched here (LAUNCH_GRADIO = False). Locally: PHISH_MODEL_DIR=model_export/<run> .venv/bin/python app/app.py")
777
  """)
778
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  # =============================================================================== Step 8
 
780
  md("""
781
  **Step 8: Authenticate with Hugging Face**
782
 
@@ -795,7 +1333,36 @@ except Exception as e: # no network, or not logged in: the notebook still runs
795
  print("The token is never stored in this notebook or in the repository (.gitignore excludes *.token and .env).")
796
  """)
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  # =============================================================================== Step 9
 
799
  md("""
800
  **Step 9: Upload the App to Hugging Face Spaces**
801
 
@@ -818,6 +1385,45 @@ print((res.stdout + res.stderr).strip()[-4000:])
818
  print("\\nLive run (Nathan, after approval): HF_TOKEN_FOR_SPACE=<read token> .venv/bin/python scripts/upload_to_hf.py --model-dir model_export/<run> --yes")
819
  """)
820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  # =============================================================================== Results
822
  md("""
823
  ## Results
@@ -920,6 +1526,7 @@ Distilled from `RUN_LOG.md` (entries 0-9), which records every decision, number
920
  - **DataLoader migration with an RNG side effect.** The course requires `torch.utils.data.DataLoader` for batching and shuffling; `finetune.py` had its own numpy sampler. Moving every batch through `DataLoader` (row-index Dataset + seeded length-bucketed `batch_sampler` + padding collate, Step 2) was verified loss-identical at every step on the CPU fixture, except that iterating a DataLoader draws its worker base seed from the global torch RNG and shifted the dropout stream (step-1 loss 1.1093 vs 1.1125). Passing an explicit `generator` removed the side effect.
921
  - **Self-test artifacts are not results.** The agents' dev checkpoints (scratch init, a handful of steps on 2,000 rows) and their `experiments.jsonl` lines were deleted before the real queue was armed, so nothing in `reports/` or `checkpoints/` can be mistaken for a result; this notebook labels anything named `dev*` and shows "not available yet" until the queue writes the real artifacts.
922
  """)
 
923
  code("""
924
  shutil.rmtree(NOTEBOOK_TMP, ignore_errors=True) # the save round-trip files and the staged symlinks (Step 5 / Step 7)
925
  elapsed = timer() - T_NOTEBOOK_START
@@ -931,27 +1538,52 @@ if MISSING:
931
  else:
932
  print("Every artifact was present: this is the final run.")
933
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
 
935
 
936
- def build() -> nbformat.NotebookNode:
937
  nb = new_notebook()
938
  nb.metadata = {
939
  "kernelspec": {"display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3"},
940
  "language_info": {"name": "python"},
941
  }
942
- for i, (kind, text) in enumerate(CELLS):
943
  cell = new_markdown_cell(text) if kind == "markdown" else new_code_cell(text)
944
  cell["id"] = f"cell-{i:02d}" # deterministic ids (nbformat would draw random ones), so rebuilds are byte-identical
945
  nb.cells.append(cell)
946
  return nb
947
 
948
 
949
- def main() -> int:
950
- nb = build()
 
 
 
 
 
951
  nbformat.validate(nb)
952
- nbformat.write(nb, OUT)
953
- n_md = sum(1 for k, _ in CELLS if k == "markdown")
954
- print(f"wrote {OUT} ({len(CELLS)} cells: {n_md} markdown, {len(CELLS) - n_md} code)")
 
955
  return 0
956
 
957
 
 
1
  #!/usr/bin/env python
2
  """Build FinalProject_PhishingDetector.ipynb with nbformat (idempotent: same cells every run).
3
 
4
+ Two variants are built from ONE cell list: every cell is tagged for the local notebook, the Colab
5
+ notebook or both (``variant("local")`` / ``variant("colab")`` / ``variant(None)`` switch the tag for
6
+ the cells appended after them), and ``build(colab=...)`` keeps the matching cells. The LOCAL variant
7
+ (default) is unchanged: it reads the project files on the workstation. The COLAB variant
8
+ (``--colab``, "notebook = narrative, Space = compute") downloads the ``presentation/`` bundle from
9
+ the model repo on the Hub (reports, curves, metrics, configs, source: no weights, no data), points
10
+ ``ROOT`` at it so the shared cells read the same relative paths, and sends every model computation to
11
+ the deployed Space through ``gradio_client`` / ``gr.load``.
12
+
13
  The notebook mirrors the instructor's reference (reference/FashionMNIST_Gradio_Full.ipynb): a
14
  title cell, then EXACTLY his nine bold "**Step N: ...**" markdown headers in his order, each
15
  followed by a short explanation and code cells that load OUR artifacts, then "Results" and
 
22
  .venv/bin/python scripts/build_notebook.py # write the notebook (no outputs)
23
  CUDA_VISIBLE_DEVICES="" .venv/bin/python -m jupyter nbconvert --to notebook --execute \
24
  --inplace --ExecutePreprocessor.timeout=600 FinalProject_PhishingDetector.ipynb
25
+ .venv/bin/python scripts/build_notebook.py --colab # -> FinalProject_PhishingDetector_Colab.ipynb
26
+ .venv/bin/python scripts/build_notebook.py --out /path/to/other.ipynb [--colab]
27
  """
28
 
29
  from __future__ import annotations
30
 
31
+ import argparse
32
  import sys
33
  from pathlib import Path
34
 
 
37
 
38
  ROOT = Path(__file__).resolve().parent.parent
39
  OUT = ROOT / "FinalProject_PhishingDetector.ipynb"
40
+ OUT_COLAB = ROOT / "FinalProject_PhishingDetector_Colab.ipynb"
41
+
42
+ # (kind, source, variant): variant None = both notebooks, "local" = workstation notebook only, "colab" = Colab notebook only.
43
+ CELLS: list[tuple[str, str, str | None]] = []
44
+ _VARIANT: str | None = None
45
+
46
 
47
+ def variant(tag: str | None) -> None:
48
+ """Tag every cell appended after this call: "local", "colab" or None (shared by both variants)."""
49
+ global _VARIANT
50
+ assert tag in (None, "local", "colab"), tag
51
+ _VARIANT = tag
52
 
53
 
54
  def md(text: str) -> None:
55
+ CELLS.append(("markdown", text.strip("\n"), _VARIANT))
56
 
57
 
58
  def code(text: str) -> None:
59
+ CELLS.append(("code", text.strip("\n"), _VARIANT))
60
 
61
 
62
  # =============================================================================== title
 
80
  trains is reported as "not available yet" together with the command that produces it.
81
  """)
82
 
83
+ variant("colab")
84
+ md("""
85
+ **Colab edition.** This copy of the notebook runs with none of the project files present: the narrative, tables and
86
+ curves are read from the `presentation/` bundle inside the model repo on the Hugging Face Hub (reports, metrics, curves,
87
+ configs and source files, mirrored at the project's relative paths: no weights, no datasets), and every computation that
88
+ needs the 337M-parameter model is delegated to the deployed Space on ZeroGPU. Nothing trains here and no weights are
89
+ downloaded. While the two repos are private, add a Colab secret named `HF_TOKEN` (notebook access switched on). Make it a
90
+ **fine-grained read-only token scoped to the two repos** (`BuildITSystems/phishing-detector-encoder` and
91
+ `BuildITSystems/phishing-detector-demo`): `gr.load` and `gradio_client` forward the token to the Space with every proxied
92
+ call, so it should never be a write token. Once the repos are public no token is needed.
93
+ """)
94
+ code("""
95
+ # The Hub identifiers the notebook depends on (change them here if the repos are renamed or forked).
96
+ MODEL_REPO = "BuildITSystems/phishing-detector-encoder" # model repo: bf16 weights + the presentation/ bundle
97
+ SPACE = "BuildITSystems/phishing-detector-demo" # ZeroGPU Space running app/app.py (weights bundled)
98
+ SPACE_HOST = "https://builditsystems-phishing-detector-demo.hf.space" # the Space's embed host (iframe; needs a public Space)
99
+ PRESENTATION_PREFIX = "presentation" # folder in MODEL_REPO mirroring the project's relative paths
100
+ """)
101
+ variant("local")
102
+
103
  # =============================================================================== Step 1
104
  md("""
105
  **Step 1: Install Dependencies & Setup Device**
 
185
  DEVICE_INFO = common.print_device_info()
186
  print(f"\\nCPU threads for torch: {torch.get_num_threads()}")
187
  """)
188
+ variant("colab")
189
+ md("""
190
+ **Step 1: Install Dependencies & Setup Device**
191
+
192
+ Colab preinstalls torch, pandas and matplotlib; the two pins installed here are `gradio` 6.29.1 (the Space's own SDK
193
+ version, so `gr.load` speaks the same protocol) and `huggingface_hub` 1.33.0 (the version the project's upload script was
194
+ written against). The token is resolved in this order and only its *source* is printed, never its value: Colab secret
195
+ `HF_TOKEN` -> environment variable `HF_TOKEN` -> the `hf auth login` cache -> none (public repos still work). Then the
196
+ `presentation/` bundle (a few MB) is downloaded from the model repo and `ROOT` points at it, so every later cell reads the
197
+ same relative paths as the local notebook; a file that is not in the bundle is reported by `artifact()` as "not available
198
+ yet" with the command that produces it, exactly as on the workstation. "Device" here means the remote one: the Space's
199
+ runtime is reported next to whatever accelerator this Colab runtime happens to have, which the model never uses.
200
+ """)
201
+ code("""
202
+ import os, sys, json, time, re, tempfile
203
+ from pathlib import Path
204
+ from timeit import default_timer as timer
205
+
206
+ IN_COLAB = "google.colab" in sys.modules
207
+ if IN_COLAB:
208
+ %pip install -q gradio==6.29.1 huggingface_hub==1.33.0
209
+ else:
210
+ print("not running in Colab: no pip install, the already-installed gradio / huggingface_hub are used")
211
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") # quiet downloads (must precede the huggingface_hub import)
212
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False") # gradio / gradio_client telemetry off
213
+ os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1") # huggingface_hub telemetry off
214
+ import warnings
215
+ warnings.filterwarnings("ignore", message="IProgress not found") # tqdm's ipywidgets nag in kernels without ipywidgets (Colab has it)
216
+
217
+ import numpy as np
218
+ import pandas as pd
219
+ import matplotlib.pyplot as plt
220
+ import yaml
221
+ import gradio
222
+ import huggingface_hub
223
+ from huggingface_hub import HfApi, snapshot_download
224
+ from IPython.display import display, Image, Markdown
225
+
226
+ T_NOTEBOOK_START = timer()
227
+ pd.set_option("display.width", 160); pd.set_option("display.max_columns", 40)
228
+ print(f"python {sys.version.split()[0]} gradio {gradio.__version__} huggingface_hub {huggingface_hub.__version__} "
229
+ f"pandas {pd.__version__} running in Colab: {IN_COLAB}")
230
+
231
+ # Token: Colab secret HF_TOKEN -> environment variable HF_TOKEN -> `hf auth login` cache -> none. Only the SOURCE is printed.
232
+ # The token is forwarded to the Space by gr.load / gradio_client, so it should be a read-only token scoped to the two repos.
233
+ # Outside Colab the login cache usually holds the workstation's WRITE token, so that fallback is opt-in there:
234
+ # set NOTEBOOK_ALLOW_LOGIN_CACHE=1 (or export HF_TOKEN=<read token>) before running the headless test.
235
+ def resolve_token():
236
+ if IN_COLAB:
237
+ try:
238
+ from google.colab import userdata
239
+ if userdata.get("HF_TOKEN"):
240
+ return userdata.get("HF_TOKEN"), "Colab secret HF_TOKEN"
241
+ except Exception: # the secret is not defined, or notebook access is off in the Secrets panel
242
+ pass
243
+ if os.environ.get("HF_TOKEN"):
244
+ return os.environ["HF_TOKEN"], "environment variable HF_TOKEN"
245
+ if (IN_COLAB or os.environ.get("NOTEBOOK_ALLOW_LOGIN_CACHE")) and huggingface_hub.get_token():
246
+ return huggingface_hub.get_token(), "huggingface_hub login cache (hf auth login)"
247
+ return None, "none (anonymous: works only once the repos are public)"
248
+
249
+ TOKEN, TOKEN_SOURCE = resolve_token()
250
+ print("Hugging Face token source:", TOKEN_SOURCE)
251
+ api = HfApi(token=TOKEN)
252
+ """)
253
+ code("""
254
+ # The presentation/ bundle: reports, curves, metrics, configs and source files mirrored at the project's relative paths (no
255
+ # weights, no datasets). ROOT points at it, so every later cell reads the same paths as the local notebook.
256
+ BUNDLE_DIR = Path("/content/phishing_bundle") if IN_COLAB else Path(tempfile.gettempdir()) / "phishing_bundle"
257
+ try:
258
+ snap = Path(snapshot_download(MODEL_REPO, repo_type="model", allow_patterns=[f"{PRESENTATION_PREFIX}/*", "config.json"],
259
+ token=TOKEN, local_dir=BUNDLE_DIR))
260
+ except Exception as e:
261
+ raise RuntimeError(f"cannot download the bundle from {MODEL_REPO} ({type(e).__name__}): while the repo is private this notebook "
262
+ "needs a read token for the BuildITSystems account (Colab: Secrets panel -> HF_TOKEN, notebook access on)") from e
263
+ ROOT = snap / PRESENTATION_PREFIX
264
+ EXPORT_CONFIG = json.loads((snap / "config.json").read_text()) # the deployed model's config.json: labels, dials, test metrics
265
+ bundle_files = sorted(p for p in ROOT.rglob("*") if p.is_file())
266
+ print(f"bundle: {len(bundle_files)} files, {sum(p.stat().st_size for p in bundle_files) / 2**20:.1f} MiB under {ROOT}")
267
+
268
+ PRETRAIN_RUN = "large-24L" # RUN_NAME in scripts/launch_pretrain.sh
269
+ CFG = yaml.safe_load((ROOT / "config.yaml").read_text()) # the project config; its paths stay relative (nothing resolves them here)
270
+
271
+ MISSING: list[tuple[str, str]] = []
272
+ def artifact(rel: str, produced_by: str):
273
+ \"\"\"Path to a project artifact in the bundle, or None (with a one-line notice) if it is not there.\"\"\"
274
+ p = ROOT / rel
275
+ if p.exists():
276
+ return p
277
+ print(f"not available yet — {rel} (produced by: {produced_by})")
278
+ MISSING.append((rel, produced_by))
279
+ return None
280
+
281
+ def show(df, title=None, fmt=None):
282
+ if title:
283
+ display(Markdown(f"**{title}**"))
284
+ display(df.style.format(fmt) if fmt else df)
285
+ """)
286
+ code("""
287
+ # "Device": the model never runs in this notebook. Compute lives on the Space; the accelerator Colab gave this runtime is informational.
288
+ try:
289
+ rt = api.get_space_runtime(SPACE)
290
+ SPACE_HW = rt.hardware
291
+ print(f"Space {SPACE}: stage {rt.stage}, hardware {rt.hardware} <- the device every prediction in this notebook runs on")
292
+ except Exception as e:
293
+ SPACE_HW = CFG["hf"]["space_hardware"]
294
+ print(f"Space runtime not readable ({type(e).__name__}): private Space without a token, or the Space does not exist; "
295
+ f"config.yaml says hardware {SPACE_HW}")
296
+ try:
297
+ import torch
298
+ TORCH_OK = True
299
+ local = f"torch {torch.__version__}, cuda available: {torch.cuda.is_available()}" + (f" ({torch.cuda.get_device_name(0)})" if torch.cuda.is_available() else "")
300
+ except Exception as e: # torch is preinstalled on Colab; elsewhere the notebook falls back to the counts recorded in config.json
301
+ TORCH_OK = False
302
+ local = f"torch not importable ({type(e).__name__})"
303
+ print(f"this runtime: {local} (informational: not used for the model)")
304
+ """)
305
+ variant(None)
306
  code("""
307
  # The GPU facts recorded by the smoke test on 2026-10-07 (the GPU is not visible to this kernel).
308
  smoke = artifact("reports/smoke_test.json", "src/smoke_test.py")
 
377
  ], columns=["removed", "rows"]).set_index("removed")
378
  show(drops, "Rows removed by the dedup / decontamination procedure", fmt="{:,}")
379
  """)
380
+ variant("local")
381
  code("""
382
  import pyarrow.parquet as pq
383
 
 
399
  show(by_split, "Rows per split by label3 (EDA)")
400
  show(pd.crosstab([lab["split"], lab["source"]], lab["class"]), "Rows per split x source x class")
401
  """)
402
+ variant("colab")
403
+ code("""
404
+ # Rows per split by class, from the counts data_prep.py wrote into splits_manifest.json (the parquet splits themselves stay on
405
+ # the workstation: the bundle carries reports, configs and source files, never data or weights).
406
+ LABEL3_NAME = {0: "Legitimate", 1: "Phishing", 2: "Spam", -1: "unwanted (unsplit)"}
407
+ SPLITS = ["finetune_train", "finetune_val", "test_holdout", "test_holdout3"]
408
+ tw = manifest["threeway"]
409
+ lab = pd.DataFrame([{"split": split, "source": src, "class": LABEL3_NAME[int(l3)], "rows": n}
410
+ for split in SPLITS for src, by_l3 in tw["counts"][split].items() for l3, n in by_l3.items()])
411
+ by_split = lab.pivot_table(index="split", columns="class", values="rows", aggfunc="sum", fill_value=0).reindex(SPLITS)
412
+ by_split["rows"] = by_split.sum(axis=1)
413
+ by_split["binary positive rate"] = pd.Series({s: tw["totals"][s]["binary_base_rate"] for s in SPLITS})
414
+ show(by_split, "Rows per split by label3 (EDA, from splits_manifest.json)")
415
+ show(lab.pivot_table(index=["split", "source"], columns="class", values="rows", aggfunc="sum", fill_value=0), "Rows per split x source x class")
416
+ """)
417
+ variant(None)
418
  code("""
419
  fig, ax = plt.subplots(figsize=(9, 3.8))
420
  cols = ["Legitimate", "Phishing", "Spam", "unwanted (unsplit)"]
 
428
  for s in ("top", "right"): ax.spines[s].set_visible(False)
429
  plt.tight_layout(); plt.show()
430
  """)
431
+ variant("local")
432
  code("""
433
  # Tokenizer: byte-level BPE, vocab 32,768, NFKC only (case preserved: ALL-CAPS is a spam signal), trained on the
434
  # pretraining corpus + finetune_train ONLY (val and the holdouts are refused by name; 0 holdout hash collisions).
 
500
  f"tokens {int(mask[r].sum()):>3} text {labelled.texts[i][:60]!r}")
501
  print("\\n'unwanted' rows (TREC/CEAS positives) allow BOTH phishing and spam: the loss is -log(P(phishing)+P(spam)), the partial-label NLL.")
502
  """)
503
+ variant("colab")
504
+ code("""
505
+ # Tokenizer: byte-level BPE, vocab 32,768, NFKC only (case preserved: ALL-CAPS is a spam signal), trained on the
506
+ # pretraining corpus + finetune_train ONLY (val and the holdouts are refused by name; 0 holdout hash collisions).
507
+ # tokenizer.json travels with the weights (model repo root and the Space); the report written next to it carries the
508
+ # statistics and the worked examples, so nothing is tokenised on this runtime.
509
+ tok_report = json.loads((ROOT / "data/processed/tokenizer_report.json").read_text())
510
+ tr = tok_report["train"]
511
+ print(f"vocab {tr['final_vocab_size']:,} specials {tr['special_token_ids']} normalizer {tr['normalizer']} "
512
+ f"pre-tokenizer {tr['pre_tokenizer']}\\ntrained on {list(tr['training_files'])} in {tr['train_seconds']} s; "
513
+ f"excluded {tr['excluded_files']}; holdout exact collisions {tr['holdout_exact_collisions']}")
514
+ show(pd.DataFrame(tok_report["stats"]["splits"]).set_index("split")
515
+ [["rows", "mean_tokens", "median_tokens", "p95_tokens", "chars_per_token", "pct_truncated", "mean_tokens_by_label"]],
516
+ "Token-length statistics per split (tokenizer.py stats; max_seq_len 512)")
517
+ for ex in tok_report["stats"]["examples"][:3]:
518
+ toks = ex["tokens"]
519
+ print(f"\\n[{ex['name']}] {ex['text'][:90]!r}\\n {ex['n_tokens_with_specials']} tokens: {toks[:28]}{' ...' if len(toks) > 28 else ''}")
520
+ """)
521
+ code("""
522
+ # Token length per split from the tokenizer report. The local notebook tokenises a seeded 5,000-row sample of finetune_train
523
+ # live and draws a histogram per class; that sample needs the parquet data, so here the per-split statistics computed by
524
+ # tokenizer.py on the workstation stand in for it.
525
+ st = pd.DataFrame(tok_report["stats"]["splits"]).set_index("split")
526
+ fig, ax = plt.subplots(figsize=(9, 3.8))
527
+ x = np.arange(len(st)); w = 0.27
528
+ for off, col, color in ((-w, "median_tokens", "#2a78d6"), (0, "mean_tokens", "#eb6834"), (w, "p95_tokens", "#eda100")):
529
+ ax.bar(x + off, st[col], width=w, color=color, label=col.replace("_", " "))
530
+ ax.axhline(CFG["model"]["max_seq_len"], color="black", linestyle="--", linewidth=1, label="max_seq_len 512 (truncation)")
531
+ ax.set_xticks(x, st.index); ax.set_yscale("log"); ax.set_ylabel("tokens per email (log)")
532
+ ax.set_title("Token length per split (tokenizer.py stats)", loc="left"); ax.legend(frameon=False, fontsize=8)
533
+ for s in ("top", "right"): ax.spines[s].set_visible(False)
534
+ plt.tight_layout(); plt.show()
535
+ print("rows longer than 512 tokens (truncated): " + ", ".join(f"{s} {v:.1f}%" for s, v in st["pct_truncated"].items())
536
+ + ". Length is a known confound: legitimate mail is ~2x longer on average; the error analysis checks whether the model leans on it.")
537
+ """)
538
+ code("""
539
+ # The DataLoaders are built by src/finetune.py on the workstation (they need the parquet splits and torch). The bundle carries
540
+ # the source, so the three definitions that make the torch.utils.data.DataLoader are shown from the file text: a seeded,
541
+ # length-bucketed batch sampler, a row-index Dataset and the padding collate.
542
+ ft_src = (ROOT / "src/finetune.py").read_text()
543
+ def source_of(name, text):
544
+ \"\"\"Top-level `def`/`class` <name> of a source file read as text (no import needed).\"\"\"
545
+ m = re.search(rf"^(?:def|class) {name}\\b.*?(?=^\\S|\\Z)", text, re.S | re.M)
546
+ return m.group(0).rstrip() if m else f"# {name}: not found"
547
+ for fn in ("epoch_batches", "RowIndexDataset", "make_loader"):
548
+ print(source_of(fn, ft_src), "\\n")
549
+ ft_cfg = CFG["finetune"]
550
+ MULTIPLE = int(ft_cfg["dynamic_padding_multiple"]); MAX_LEN = int(CFG["model"]["max_seq_len"])
551
+ print(f"dials: batch_size {ft_cfg['batch_size']}, eval_batch_size {ft_cfg['eval_batch_size']}, max_seq_len {MAX_LEN}, pad to a multiple of {MULTIPLE}; "
552
+ "padded positions carry [PAD]=0 and mask False. 'unwanted' rows (TREC/CEAS positives) allow BOTH phishing and spam: "
553
+ "the loss is -log(P(phishing)+P(spam)), the partial-label NLL.")
554
+ """)
555
+ variant(None)
556
  code("""
557
  # Pretraining data: documents are tokenised once, concatenated with [SEP] after each, and cut into packed windows of
558
  # [CLS] + 511 stream tokens (no padding, no attention mask -> the Flash SDPA kernel). Dynamic 15% masking per batch.
 
582
  logits (legitimate / phishing / spam); rows whose corpus never separated spam from phishing train on the probability
583
  mass of {phishing, spam}. The course requires at least 2 layers; this model has 24.
584
  """)
585
+ variant("local")
586
  code("""
587
  import model as phish_model
588
  from model import EncoderConfig, EncoderLayer, EncoderForMLM, EncoderForClassification, count_parameters
 
622
  f"{counts.loc['total'].iloc[0]:,} (pretraining, tied head) / {counts.loc['total'].iloc[1]:,} (classifier), counted above.")
623
  """)
624
 
625
+ variant("colab")
626
+ code("""
627
+ # src/model.py from the bundle. With torch present (Colab preinstalls it) the module is imported and inspected exactly as the
628
+ # local notebook does; without torch the same definitions are shown from the file text.
629
+ sys.path.insert(0, str(ROOT / "src"))
630
+ model_src = (ROOT / "src/model.py").read_text()
631
+ if TORCH_OK:
632
+ import inspect
633
+ from model import EncoderConfig, EncoderLayer, EncoderForMLM, EncoderForClassification, count_parameters
634
+ src_cfg = inspect.getsource(EncoderConfig).splitlines()
635
+ layer_init, layer_fwd = inspect.getsource(EncoderLayer.__init__), inspect.getsource(EncoderLayer.forward)
636
+ else:
637
+ src_cfg = source_of("EncoderConfig", model_src).splitlines()
638
+ parts = re.split(r"(?=^ def )", source_of("EncoderLayer", model_src), flags=re.M)
639
+ layer_init = next(p for p in parts if p.startswith(" def __init__"))
640
+ layer_fwd = next(p for p in parts if p.startswith(" def forward"))
641
+ print("class EncoderConfig: # dataclass: the dials of the encoder")
642
+ print("\\n".join(l for l in src_cfg if re.match(r"^ \\w+: [\\w| ]+ = ", l)))
643
+ print("\\nclass EncoderLayer(nn.Module): # one pre-LN block; 24 of these are stacked")
644
+ print(layer_init)
645
+ print(layer_fwd)
646
+ """)
647
+ code("""
648
+ # Parameter counts: with torch, both wrappers are instantiated on this runtime's CPU (fp32, ~1.3 GB each) and counted, as in
649
+ # the local notebook; without torch the counts recorded in the deployed model's config.json are shown.
650
+ model_cfg_d = {**CFG["model"], "num_labels": CFG["finetune"]["num_labels"]}
651
+ if TORCH_OK:
652
+ model_cfg = EncoderConfig.from_dict(model_cfg_d)
653
+ print("config:", model_cfg.to_dict())
654
+ mlm_model = EncoderForMLM(model_cfg)
655
+ cls_model = EncoderForClassification.from_pretrained_encoder(mlm_model) # encoder weights copied, fresh 3-class head
656
+ counts = pd.DataFrame({"EncoderForMLM (tied head)": count_parameters(mlm_model),
657
+ "EncoderForClassification (3 classes)": count_parameters(cls_model)})
658
+ assert mlm_model.head.decoder.weight is mlm_model.encoder.embeddings.tok.weight, "MLM head must be tied"
659
+ del mlm_model, cls_model
660
+ how = "instantiated on this runtime's CPU and counted"
661
+ else:
662
+ from types import SimpleNamespace
663
+ model_cfg = SimpleNamespace(**model_cfg_d)
664
+ print("config:", model_cfg_d)
665
+ counts = pd.DataFrame({"EncoderForClassification (3 classes)": EXPORT_CONFIG["finetune_checkpoint"]["params"]})
666
+ how = "recorded in the deployed model's config.json; torch is not importable here"
667
+ show(counts, f"Parameter counts ({how})", fmt="{:,}")
668
+ print(f"the classifier has {model_cfg.n_layers} encoder layers (course minimum: 2); head: CLS -> dropout -> Linear -> tanh -> dropout -> Linear({model_cfg.num_labels})")
669
+ """)
670
+ code("""
671
+ # Why 24 layers / d=1024 / vocab 32,768: the parameter-count table from docs/research/research_model-math.md section 1
672
+ # (configs A-F; the brief asked for ~330M parameters). Config A is the BERT-large shape and lands +2.3% over the target.
673
+ math_md = (ROOT / "docs/research/research_model-math.md").read_text().splitlines()
674
+ start = next(i for i, l in enumerate(math_md) if l.startswith("## 1."))
675
+ end = next(i for i, l in enumerate(math_md) if l.startswith("## 2."))
676
+ table = [l for l in math_md[start:end] if l.startswith("|")]
677
+ header = [h.strip() for h in table[0].strip("|").split("|")]
678
+ body = [[c.strip().replace("**", "") for c in l.strip("|").split("|")] for l in table[2:]]
679
+ show(pd.DataFrame(body, columns=header).set_index("config"), "Candidate configurations (research_model-math.md section 1)")
680
+ print(f"Chosen: A = {model_cfg.n_layers}L d{model_cfg.d_model} h{model_cfg.n_heads} f{model_cfg.d_ff} V{model_cfg.vocab_size} -> "
681
+ f"{counts.loc['total'].iloc[-1]:,} parameters in the classifier, counted above.")
682
+ """)
683
+ variant(None)
684
+
685
  # =============================================================================== Step 4
686
  md("""
687
  **Step 4: Train the Model**
 
758
  # run_summary.json records the finish time and the log path (pretrain-YYYYMMDD-HHMMSS.log); the launch time comes from the latter.
759
  started = S.get("started")
760
  if not started and S.get("log"):
761
+ m = _re.search(r"(\\d{4})(\\d{2})(\\d{2})-(\\d{2})(\\d{2})(\\d{2})", Path(S["log"]).name)
762
  if m:
763
  started = "{}-{}-{}T{}:{}:{}".format(*m.groups())
764
  started = started or S.get("finished") or ""
 
864
  """)
865
 
866
  # =============================================================================== Step 5
867
+ variant("local")
868
  md("""
869
  **Step 5: Test Inference Speed and Save the Model**
870
 
 
977
  shutil.rmtree(save_dir, ignore_errors=True)
978
  """)
979
 
980
+ variant("colab")
981
+ md("""
982
+ **Step 5: Test Inference Speed and Save the Model**
983
+
984
+ The reference times `predict_and_time` on 100 images; the local notebook does the same on 100 TREC-06 emails on the
985
+ workstation CPU. Here the model is not present, so the timing is taken where the model actually serves: each of the app's
986
+ own synthetic example emails (read out of `app/app.py` in the bundle) is sent to the Space's `/predict` endpoint through
987
+ `gradio_client`, and the Space answers with the in-app **Prediction Time (s)** (tokenize + forward + softmax on the ZeroGPU,
988
+ the number the Gradio UI shows) next to the full round-trip from this runtime. The recorded workstation numbers
989
+ (`reports/latency.json`: RTX 3090 Ti bf16 and CPU fp32 on 100 holdout emails) and the instructor's 0.074 s benchmark are
990
+ shown for comparison. "Saving the model" happened on the workstation (`src/export.py` -> bf16 safetensors + config +
991
+ tokenizer + model card, then `scripts/upload_to_hf.py`); this notebook lists the model repo's files with their sizes instead
992
+ of writing a copy.
993
+ """)
994
+ code("""
995
+ import ast
996
+
997
+ def app_examples(path):
998
+ \"\"\"The EXAMPLES list of app/app.py, read from the file TEXT: importing app.py would import torch and spaces at module level.\"\"\"
999
+ for node in ast.parse(path.read_text()).body:
1000
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target] if isinstance(node, ast.AnnAssign) else []
1001
+ if any(isinstance(t, ast.Name) and t.id == "EXAMPLES" for t in targets):
1002
+ return [[str(x[0]), str(x[1])] for x in ast.literal_eval(node.value)]
1003
+ raise ValueError("EXAMPLES not found in app.py")
1004
+
1005
+ FALLBACK_EMAILS = [ # three hard-coded synthetic emails (lure, spam, legitimate) if the extraction fails
1006
+ ["Action Required: verify your account within 24 hours",
1007
+ "Dear customer,\\n\\nUnusual sign-in activity was detected. Verify your account within 24 hours at http://secure-login-verify.example "
1008
+ "or it will be suspended.\\n\\nSecurity Team"],
1009
+ ["FINAL HOURS: 70% OFF designer watches + FREE shipping!!!",
1010
+ "CONGRATULATIONS! You were SELECTED for our VIP SALE. 70% OFF everything, FREE shipping TODAY ONLY. Shop now: http://bit.ly/vip-deal"],
1011
+ ["Agenda for Thursday's project sync",
1012
+ "Hi all,\\n\\nAttaching the agenda for Thursday's 10:00 sync: firewall migration status, Q4 hardware budget, onboarding checklist. "
1013
+ "Reply by Wednesday noon with additions.\\n\\nThanks,\\nNathan"],
1014
+ ]
1015
+ app_py = artifact("app/app.py", "the Space source (app/ on the workstation, uploaded by scripts/upload_to_hf.py)")
1016
+ try:
1017
+ SAMPLES = app_examples(app_py) if app_py else FALLBACK_EMAILS
1018
+ print(f"{len(SAMPLES)} synthetic example emails extracted from app/app.py (EXAMPLES)" if app_py else "using the 3 built-in synthetic emails")
1019
+ except Exception as e:
1020
+ SAMPLES = FALLBACK_EMAILS
1021
+ print(f"could not extract EXAMPLES from app.py ({type(e).__name__}): using the 3 built-in synthetic emails")
1022
+ for subject, body in SAMPLES:
1023
+ print(f" - {subject[:72]!r} ({len(body)} chars)")
1024
+ """)
1025
+ code("""
1026
+ # Inference speed where the model runs: the Space's /predict endpoint, one call per email, through gradio_client.
1027
+ from gradio_client import Client
1028
+
1029
+ SPACE_STATS = []
1030
+ client = None
1031
+ try:
1032
+ t0 = timer()
1033
+ client = Client(SPACE, token=TOKEN, verbose=False, analytics_enabled=False)
1034
+ print(f"connected to {SPACE} in {timer() - t0:.1f} s (endpoint /predict: subject, body -> label, Prediction Time (s), highlights, signals)")
1035
+ except Exception as e:
1036
+ print(f"cannot connect to the Space ({type(e).__name__}: {str(e)[:160]}): private Space without a token, or the Space is not running")
1037
+ if client is not None:
1038
+ for subject, body in SAMPLES:
1039
+ t0 = timer()
1040
+ try:
1041
+ label, secs, highlights, signals = client.predict(subject, body, api_name="/predict")
1042
+ except Exception as e: # ZeroGPU quota / "GPU task aborted" / timeout: skip this email, keep the rest
1043
+ print(f"\\n=== {subject}\\n Space call failed ({type(e).__name__}: {str(e)[:160]}); skipped")
1044
+ continue
1045
+ round_trip = timer() - t0
1046
+ conf = {c["label"]: float(c["confidence"]) for c in (label.get("confidences") or [])} if isinstance(label, dict) else {}
1047
+ verdict = label.get("label") if isinstance(label, dict) else str(label)
1048
+ first_line = (str(signals).strip().splitlines() or [""])[0]
1049
+ SPACE_STATS.append({"subject": subject[:52], "verdict": verdict, **{f"P({k})": v for k, v in conf.items()},
1050
+ "Prediction Time (s), on the Space": float(secs), "round-trip (s)": round_trip})
1051
+ print(f"\\n=== {subject}\\n verdict {verdict}: " + ", ".join(f"{k} {100 * v:.1f}%" for k, v in conf.items())
1052
+ + f"\\n Prediction Time (s): {float(secs):.3f} on the Space round-trip from here: {round_trip:.2f} s {first_line}")
1053
+ if SPACE_STATS:
1054
+ SP = pd.DataFrame(SPACE_STATS).set_index("subject")
1055
+ show(SP, f"{len(SP)} synthetic emails through the Space's /predict endpoint", fmt={c: "{:.3f}" for c in SP.columns if c != "verdict"})
1056
+ t_app, t_rt = SP["Prediction Time (s), on the Space"], SP["round-trip (s)"]
1057
+ print(f"Average in-app prediction time: {t_app.mean():.4f} s on the Space's GPU ({SPACE_HW}; the first call, {t_app.iloc[0]:.3f} s, "
1058
+ f"includes the ZeroGPU warm-up); average round-trip {t_rt.mean():.2f} s (queue + network + the app's saliency pass). "
1059
+ f"Instructor benchmark: 0.074 s.")
1060
+ elif client is not None:
1061
+ print("no successful Space call: every request was rejected (ZeroGPU quota / Space restarting); the recorded workstation latencies follow")
1062
+ """)
1063
+ code("""
1064
+ # The recorded workstation latencies (reports/latency.json: evaluate.py on 100 TREC-06 holdout emails) next to the Space numbers.
1065
+ lat = artifact("reports/latency.json", "src/evaluate.py run --checkpoint checkpoints/finetune/<run>/best --split test,test3")
1066
+ BENCH_S = 0.074
1067
+ rows = []
1068
+ if lat:
1069
+ L = json.loads(lat.read_text()); BENCH_S = L.get("instructor_benchmark_ms", 74.0) / 1000
1070
+ for k, what in (("gpu_bf16", "RTX 3090 Ti bf16"), ("cpu_fp32", "CPU fp32, 16 threads")):
1071
+ if L.get(k):
1072
+ rows.append({"source": f"workstation {what} (evaluate.py, {L[k]['n']} holdout emails)", "avg s/sample": L[k]["avg_ms"] / 1000,
1073
+ "p50 s": L[k].get("p50_ms", np.nan) / 1000, "n": L[k]["n"]})
1074
+ if SPACE_STATS:
1075
+ rows.append({"source": f"Space {SPACE_HW}, in-app Prediction Time (this notebook, synthetic emails)", "avg s/sample": float(t_app.mean()),
1076
+ "p50 s": float(t_app.median()), "n": len(t_app)})
1077
+ rows.append({"source": "Space, full round-trip from this runtime", "avg s/sample": float(t_rt.mean()), "p50 s": float(t_rt.median()), "n": len(t_rt)})
1078
+ rows.append({"source": "instructor benchmark", "avg s/sample": BENCH_S, "p50 s": np.nan, "n": None})
1079
+ show(pd.DataFrame(rows).set_index("source"), "Single-email prediction time (tokenize + forward + softmax)", fmt={"avg s/sample": "{:.4f}", "p50 s": "{:.4f}"})
1080
+ if lat:
1081
+ print(f"workstation GPU {L['gpu_bf16']['avg_ms']:.1f} ms = {L['gpu_bf16']['ratio_to_benchmark']:.2f}x the {BENCH_S * 1000:.0f} ms benchmark; "
1082
+ f"CPU {L['cpu_fp32']['avg_ms']:.0f} ms = {L['cpu_fp32']['ratio_to_benchmark']:.1f}x: hence a ZeroGPU Space rather than a CPU one.")
1083
+ """)
1084
+ code("""
1085
+ # "Save the model": the export already lives on the Hub. The model repo's own files (what the Space and anyone with access load).
1086
+ info = api.repo_info(MODEL_REPO, repo_type="model", files_metadata=True)
1087
+ own = [s for s in info.siblings if not s.rfilename.startswith(PRESENTATION_PREFIX + "/")]
1088
+ bundle = [s for s in info.siblings if s.rfilename.startswith(PRESENTATION_PREFIX + "/")]
1089
+ show(pd.DataFrame([{"file": s.rfilename, "MiB": (s.size or 0) / 2**20, "LFS": s.lfs is not None} for s in own]).set_index("file"),
1090
+ f"https://huggingface.co/{MODEL_REPO} (private: {info.private}, last modified {str(info.last_modified)[:16]})", fmt={"MiB": "{:,.1f}"})
1091
+ print(f"+ {len(bundle)} files / {sum((s.size or 0) for s in bundle) / 2**20:.1f} MiB under {PRESENTATION_PREFIX}/ (this notebook's inputs)")
1092
+ fc = EXPORT_CONFIG["finetune_checkpoint"]
1093
+ print(f"model.safetensors: {EXPORT_CONFIG['export_dtype']} export of checkpoints/finetune/{Path(EXPORT_CONFIG['source_checkpoint']).parent.name}/best "
1094
+ f"({fc['params']['total']:,} parameters, {EXPORT_CONFIG['num_labels']} classes {fc['label_names']}), exported {EXPORT_CONFIG['exported']}; "
1095
+ f"config.json carries the test metrics: TREC-06 F1 {EXPORT_CONFIG['metrics']['f1']:.4f}, recall {EXPORT_CONFIG['metrics']['recall']:.4f}, "
1096
+ f"ROC-AUC {EXPORT_CONFIG['metrics']['roc_auc']:.4f}; three-way macro-F1 {EXPORT_CONFIG['metrics_threeway']['macro_f1']:.4f}.")
1097
+ """)
1098
+
1099
  # =============================================================================== Step 6
1100
+ variant("local")
1101
  md("""
1102
  **Step 6: Create the App Files (Optional)**
1103
 
 
1135
  artifact(f"app/{f}", "src/export.py run (copied next to app.py so the Space can also load weights locally)")
1136
  """)
1137
 
1138
+ variant("colab")
1139
+ md("""
1140
+ **Step 6: Create the App Files (Optional)**
1141
+
1142
+ Optional in the reference, not here: the Space is one of the project's deliverables. The files the reference writes
1143
+ (`app.py`, `model.py`, `requirements.txt`) are the ones `scripts/upload_to_hf.py` pushed to the Space, together with the bf16
1144
+ weights, `config.json` and `tokenizer.json` (bundled, so the Space needs no download and no secret at startup). The Space's
1145
+ file list comes from the Hub; the first lines of `app.py`, the pinned `requirements.txt` and the Space card's front matter are
1146
+ read from the bundle's copy of `app/`.
1147
+ """)
1148
+ code("""
1149
+ try:
1150
+ space_files = api.list_repo_files(SPACE, repo_type="space")
1151
+ print(f"Space https://huggingface.co/spaces/{SPACE} files ({len(space_files)}): " + ", ".join(space_files))
1152
+ except Exception as e:
1153
+ print(f"Space file list not readable ({type(e).__name__}): private Space without a token")
1154
+ print("\\n--- app/app.py (first 40 lines) ---")
1155
+ if app_py:
1156
+ print("\\n".join(app_py.read_text().splitlines()[:40]))
1157
+ req = artifact("app/requirements.txt", "app/ on the workstation (pinned for the ZeroGPU runtime)")
1158
+ if req:
1159
+ print("\\n--- app/requirements.txt ---"); print(req.read_text())
1160
+ card = artifact("app/README.md", "app/ on the workstation (the Space card)")
1161
+ if card:
1162
+ print("--- app/README.md front matter (Space configuration) ---"); print(card.read_text().split("---")[1].strip())
1163
+ """)
1164
+
1165
  # =============================================================================== Step 7
1166
+ variant("local")
1167
  md("""
1168
  **Step 7: Launch the Gradio Interface**
1169
 
 
1217
  print("Gradio not launched here (LAUNCH_GRADIO = False). Locally: PHISH_MODEL_DIR=model_export/<run> .venv/bin/python app/app.py")
1218
  """)
1219
 
1220
+ variant("colab")
1221
+ md("""
1222
+ **Step 7: Launch the Gradio Interface**
1223
+
1224
+ The reference calls `demo.launch(share=True)`. Here the interface *is* the deployed Space: `gr.load(SPACE, src="spaces")`
1225
+ builds a Gradio app whose every call is proxied to the Space (the model never leaves ZeroGPU), and `launch()` renders it
1226
+ inline in Colab (Colab shows Gradio through a temporary public link). Gradio 6.29.1 has a bug in `gr.load` for Spaces whose
1227
+ `gr.Interface` carries an examples table: `Blocks.from_config` rebuilds the `Dataset` component from `{name, class_id}`
1228
+ dicts and hands `class_id` on to the child `Textbox.__init__`, which rejects it. The cell first calls `gr.load` as is; if
1229
+ it fails with exactly that `TypeError`, a four-line compatibility shim (drop `class_id` before the child component is
1230
+ instantiated, the same thing newer Gradio releases do) is installed and `gr.load` is retried, so the interface shown is the
1231
+ Space's own. Only if `gr.load` still fails does the cell fall back to a hand-built `gr.Interface` around the Step 5
1232
+ `gradio_client` call. Outside Colab the cell starts and immediately stops a headless server as a smoke test. When the Space
1233
+ is public, the native page is also embedded in an iframe; while it is private the iframe would only show a login page, so it
1234
+ is skipped with a note.
1235
+ """)
1236
+ code("""
1237
+ import io, contextlib, warnings
1238
+ import gradio as gr
1239
+
1240
+ def shim_gr_load_dataset_bug():
1241
+ \"\"\"gradio 6.29.1: gr.load -> Blocks.from_config rebuilds a Space's examples `Dataset` from {name, class_id} dicts and
1242
+ get_component_instance passes `class_id` on to the child component's __init__, which rejects it. Drop that key.\"\"\"
1243
+ import gradio.components.dataset as ds
1244
+ if getattr(ds.get_component_instance, "_class_id_shim", False):
1245
+ return
1246
+ original = ds.get_component_instance
1247
+ def patched(comp, *args, **kwargs):
1248
+ if isinstance(comp, dict) and "class_id" in comp:
1249
+ comp = {k: v for k, v in comp.items() if k != "class_id"}
1250
+ return original(comp, *args, **kwargs)
1251
+ patched._class_id_shim = True
1252
+ ds.get_component_instance = patched
1253
+
1254
+ def load_space():
1255
+ with warnings.catch_warnings(), contextlib.redirect_stdout(io.StringIO()): # hide gradio's chatter + theme UserWarning
1256
+ warnings.simplefilter("ignore")
1257
+ return gr.load(SPACE, src="spaces", token=TOKEN)
1258
+
1259
+ demo, demo_kind = None, None
1260
+ try:
1261
+ try:
1262
+ demo = load_space()
1263
+ demo_kind = "gr.load(src='spaces'): the Space's own interface, every call proxied"
1264
+ except TypeError as e:
1265
+ if "class_id" not in str(e):
1266
+ raise
1267
+ print(f"gr.load hit the gradio {gr.__version__} Dataset/class_id bug ({str(e)[:90]}); installing the compat shim and retrying")
1268
+ shim_gr_load_dataset_bug()
1269
+ demo = load_space()
1270
+ demo_kind = "gr.load(src='spaces') with the class_id compat shim: the Space's own interface, every call proxied"
1271
+ except Exception as e:
1272
+ print(f"gr.load({SPACE!r}) failed: {type(e).__name__}: {str(e)[:160]} -> hand-built proxy interface")
1273
+ if demo is None and client is not None:
1274
+ # Fallback: the same interface built by hand around the gradio_client connection of Step 5 (labels mirror app/app.py).
1275
+ def proxy_predict(subject, body):
1276
+ label, secs, highlights, signals = client.predict(subject, body, api_name="/predict")
1277
+ confidences = {c["label"]: c["confidence"] for c in label["confidences"]} if isinstance(label, dict) and "confidences" in label else label
1278
+ spans = [(h["token"], h.get("class_or_confidence")) if isinstance(h, dict) else tuple(h) for h in highlights]
1279
+ return confidences, secs, spans, signals
1280
+ demo = gr.Interface(
1281
+ fn=proxy_predict,
1282
+ inputs=[gr.Textbox(label="Subject", lines=1), gr.Textbox(label="Email body", lines=14)],
1283
+ outputs=[gr.Label(label="Prediction"), gr.Number(label="Prediction Time (s)"),
1284
+ gr.HighlightedText(label="Tokens the model weighed most", color_map={"high": "#d62728", "medium": "#ff9896"},
1285
+ combine_adjacent=True, show_label=True),
1286
+ gr.Markdown(label="Signals")],
1287
+ examples=SAMPLES, cache_examples=False, title="BITS Phishing Email Detector (proxied to the Space)",
1288
+ description=f"Every prediction below runs on {SPACE} ({SPACE_HW}); this runtime only forwards the request.")
1289
+ demo_kind = "gr.Interface around gradio_client (gr.load fallback), every call proxied"
1290
+ if demo is None:
1291
+ print("no interface: the Space is unreachable from this runtime (private without a token, or not running)")
1292
+ else:
1293
+ print("interface:", demo_kind)
1294
+ if demo is not None:
1295
+ if IN_COLAB:
1296
+ demo.launch() # inline in Colab; every prediction is proxied to the Space
1297
+ else:
1298
+ demo.launch(prevent_thread_lock=True, inline=False, share=False, quiet=True)
1299
+ print(f"headless smoke test: the proxied interface was served at {demo.local_url} and is closed again")
1300
+ demo.close()
1301
+ """)
1302
+ code("""
1303
+ from IPython.display import IFrame
1304
+
1305
+ try:
1306
+ space_public = api.space_info(SPACE).private is False
1307
+ except Exception:
1308
+ space_public = False
1309
+ if space_public:
1310
+ display(IFrame(SPACE_HOST, "100%", 900))
1311
+ else:
1312
+ print(f"The iframe embed needs a public Space; {SPACE} is private right now. Flip it to public in the Hub settings before class "
1313
+ f"and the native page appears here: {SPACE_HOST}")
1314
+ """)
1315
+
1316
  # =============================================================================== Step 8
1317
+ variant("local")
1318
  md("""
1319
  **Step 8: Authenticate with Hugging Face**
1320
 
 
1333
  print("The token is never stored in this notebook or in the repository (.gitignore excludes *.token and .env).")
1334
  """)
1335
 
1336
+ variant("colab")
1337
+ md("""
1338
+ **Step 8: Authenticate with Hugging Face**
1339
+
1340
+ The reference pastes a write token into `notebook_login()`. Here the token came from the Colab secret (or the environment /
1341
+ login cache) in Step 1 and every Hub call in this notebook passes it explicitly, so `login()` is only needed when the token is
1342
+ not yet the one `huggingface_hub` resolves on its own (in huggingface_hub 1.33.0 `login(token=...)` always validates with
1343
+ `whoami` and re-saves the token to the runtime's HF cache; `skip_if_logged_in` only governs the interactive flows). When the
1344
+ token already comes from the login cache or the Colab secret, nothing is written. The only thing printed is the account name
1345
+ the Hub answers with: the token value never appears in the notebook, its outputs or the repository.
1346
+ """)
1347
+ code("""
1348
+ from huggingface_hub import login
1349
+
1350
+ if TOKEN and TOKEN != huggingface_hub.get_token():
1351
+ login(token=TOKEN, add_to_git_credential=False) # validates with whoami and saves the token in this runtime's HF cache (Colab: ephemeral disk)
1352
+ print("token saved for this runtime's huggingface_hub")
1353
+ elif TOKEN:
1354
+ print("token already active for huggingface_hub (login cache / Colab secret): nothing written")
1355
+ else:
1356
+ print("no token found (anonymous): the private repos stay unreadable until they are public")
1357
+ try:
1358
+ print("Logged in to Hugging Face as:", HfApi(token=TOKEN).whoami()["name"])
1359
+ except Exception as e:
1360
+ print(f"whoami failed ({type(e).__name__}): no token / offline")
1361
+ print("The token is never stored in this notebook or in the repository.")
1362
+ """)
1363
+
1364
  # =============================================================================== Step 9
1365
+ variant("local")
1366
  md("""
1367
  **Step 9: Upload the App to Hugging Face Spaces**
1368
 
 
1385
  print("\\nLive run (Nathan, after approval): HF_TOKEN_FOR_SPACE=<read token> .venv/bin/python scripts/upload_to_hf.py --model-dir model_export/<run> --yes")
1386
  """)
1387
 
1388
+ variant("colab")
1389
+ md("""
1390
+ **Step 9: Upload the App to Hugging Face Spaces**
1391
+
1392
+ The reference uploads a folder with `HfApi().upload_folder`. Ours is `scripts/upload_to_hf.py` (in the bundle under
1393
+ `scripts/`): it creates the private model repo and the private ZeroGPU Space named in `config.yaml`, uploads
1394
+ `model_export/ft-focal/` and `app/` (with the weights bundled into the Space, so no secret is needed) and prints both URLs.
1395
+ It is a dry run unless `--yes` is given, and the live run was done once from the workstation terminal (RUN_LOG entry 24),
1396
+ never from a notebook. This cell shows the command that was run, the two URLs, the Space's current runtime and the log entry.
1397
+ """)
1398
+ code("""
1399
+ hf = CFG["hf"]
1400
+ print("Live upload (run once from the workstation terminal, 2026-10-12, RUN_LOG entry 24):")
1401
+ print(" .venv/bin/python scripts/upload_to_hf.py --yes --model-dir model_export/ft-focal --bundle-weights\\n")
1402
+ print(f"model repo : https://huggingface.co/{MODEL_REPO}")
1403
+ print(f"Space : https://huggingface.co/spaces/{SPACE} (sdk {hf['space_sdk']} {hf['space_sdk_version']}, hardware {hf['space_hardware']}, "
1404
+ f"python {hf['space_python_version']}, torch {hf['space_torch']})")
1405
+ try:
1406
+ rt = api.get_space_runtime(SPACE)
1407
+ print(f"Space runtime now: stage {rt.stage}, hardware {rt.hardware}")
1408
+ except Exception as e:
1409
+ print(f"Space runtime not readable ({type(e).__name__})")
1410
+ log = artifact("RUN_LOG.md", "kept by hand on the workstation and mirrored into the bundle")
1411
+ if log:
1412
+ lines = log.read_text().splitlines()
1413
+ start = next((i for i, l in enumerate(lines) if l.startswith("## 24.")), None)
1414
+ if start is None:
1415
+ print("RUN_LOG entry 24 not found")
1416
+ else:
1417
+ para = []
1418
+ for l in lines[start + 1:]: # the first paragraph = the first non-empty block after the header
1419
+ if l.strip():
1420
+ para.append(l)
1421
+ elif para:
1422
+ break
1423
+ display(Markdown(f"> **{lines[start].lstrip('# ')}**\\n>\\n> " + "\\n> ".join(para)))
1424
+ """)
1425
+ variant(None)
1426
+
1427
  # =============================================================================== Results
1428
  md("""
1429
  ## Results
 
1526
  - **DataLoader migration with an RNG side effect.** The course requires `torch.utils.data.DataLoader` for batching and shuffling; `finetune.py` had its own numpy sampler. Moving every batch through `DataLoader` (row-index Dataset + seeded length-bucketed `batch_sampler` + padding collate, Step 2) was verified loss-identical at every step on the CPU fixture, except that iterating a DataLoader draws its worker base seed from the global torch RNG and shifted the dropout stream (step-1 loss 1.1093 vs 1.1125). Passing an explicit `generator` removed the side effect.
1527
  - **Self-test artifacts are not results.** The agents' dev checkpoints (scratch init, a handful of steps on 2,000 rows) and their `experiments.jsonl` lines were deleted before the real queue was armed, so nothing in `reports/` or `checkpoints/` can be mistaken for a result; this notebook labels anything named `dev*` and shows "not available yet" until the queue writes the real artifacts.
1528
  """)
1529
+ variant("local")
1530
  code("""
1531
  shutil.rmtree(NOTEBOOK_TMP, ignore_errors=True) # the save round-trip files and the staged symlinks (Step 5 / Step 7)
1532
  elapsed = timer() - T_NOTEBOOK_START
 
1538
  else:
1539
  print("Every artifact was present: this is the final run.")
1540
  """)
1541
+ variant("colab")
1542
+ code("""
1543
+ elapsed = timer() - T_NOTEBOOK_START
1544
+ print(f"Notebook executed top-to-bottom in {elapsed:.0f} s (in Colab: {IN_COLAB}); model compute on the Space {SPACE}, "
1545
+ f"artifacts from {MODEL_REPO}/{PRESENTATION_PREFIX}.")
1546
+ if MISSING:
1547
+ print(f"\\n{len(MISSING)} artifact(s) not in the bundle:")
1548
+ for rel, by in dict(MISSING).items():
1549
+ print(f" - {rel}\\n produced by: {by}")
1550
+ else:
1551
+ print("Every artifact of the bundle was present.")
1552
+ """)
1553
+ variant(None)
1554
+
1555
+
1556
+ def cells_for(colab: bool) -> list[tuple[str, str]]:
1557
+ """The (kind, source) cells of one variant, in order."""
1558
+ keep = "colab" if colab else "local"
1559
+ return [(kind, text) for kind, text, tag in CELLS if tag is None or tag == keep]
1560
 
1561
 
1562
+ def build(colab: bool = False) -> nbformat.NotebookNode:
1563
  nb = new_notebook()
1564
  nb.metadata = {
1565
  "kernelspec": {"display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3"},
1566
  "language_info": {"name": "python"},
1567
  }
1568
+ for i, (kind, text) in enumerate(cells_for(colab)):
1569
  cell = new_markdown_cell(text) if kind == "markdown" else new_code_cell(text)
1570
  cell["id"] = f"cell-{i:02d}" # deterministic ids (nbformat would draw random ones), so rebuilds are byte-identical
1571
  nb.cells.append(cell)
1572
  return nb
1573
 
1574
 
1575
+ def main(argv: list[str] | None = None) -> int:
1576
+ ap = argparse.ArgumentParser(description="Build the project notebook (local variant by default).")
1577
+ ap.add_argument("--colab", action="store_true", help="build the Colab variant (bundle from the Hub, compute on the Space)")
1578
+ ap.add_argument("--out", type=Path, default=None, help="output path (default: FinalProject_PhishingDetector[_Colab].ipynb)")
1579
+ args = ap.parse_args(argv)
1580
+ out = args.out or (OUT_COLAB if args.colab else OUT)
1581
+ nb = build(colab=args.colab)
1582
  nbformat.validate(nb)
1583
+ nbformat.write(nb, out)
1584
+ cells = cells_for(args.colab)
1585
+ n_md = sum(1 for k, _ in cells if k == "markdown")
1586
+ print(f"wrote {out} ({len(cells)} cells: {n_md} markdown, {len(cells) - n_md} code; variant: {'colab' if args.colab else 'local'})")
1587
  return 0
1588
 
1589