Refresh presentation bundle (RUN_LOG 25, README 5.11, builder --colab) and add the Colab notebook
Browse files- presentation/FinalProject_PhishingDetector_Colab.ipynb +1306 -0
- presentation/README.md +27 -0
- presentation/RUN_LOG.md +63 -0
- presentation/scripts/build_notebook.py +643 -11
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
nbformat.validate(nb)
|
| 952 |
-
nbformat.write(nb,
|
| 953 |
-
|
| 954 |
-
|
|
|
|
| 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 |
|