Skip to content

Commit 5b698f6

Browse files
committed
add new levels and script to get level info
1 parent a4e41c6 commit 5b698f6

2 files changed

Lines changed: 455 additions & 59 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
#!/usr/bin/env python3
2+
"""
3+
MNIST Speed-Run — Per-Batch Full Eval (Data-Only Levels) — v7 (PyTorch)
4+
5+
What this does
6+
--------------
7+
- Trains a simple CNN or Logistic Regression on MNIST.
8+
- After **every training batch**, runs a **full validation pass** (test split).
9+
- Uses **exactly 30 thresholds** for validation CE (nats), evenly spaced from **2.3 → 1.0**.
10+
- When the current val CE drops below the next threshold, it **unlocks that level** and writes:
11+
levels/L{idx}_{threshold}/
12+
├── metrics.json (no titles/insights; just numbers)
13+
└── confusion_matrix.json (10×10 integers)
14+
- By default, **exits early** once all 30 levels are captured.
15+
Use `--keep-training-after-complete` to continue training anyway.
16+
- Final run summary saved at the run root: `metrics.json` and the final `confusion_matrix.json`.
17+
18+
Notes
19+
-----
20+
- Loss is in **nats** (PyTorch CrossEntropyLoss uses ln).
21+
- This is compute-heavy (full eval per batch). Reduce epochs or batch size on CPU.
22+
"""
23+
24+
import argparse
25+
import os
26+
import json
27+
import time
28+
import random
29+
from datetime import datetime
30+
from typing import List, Dict, Any
31+
32+
import numpy as np
33+
import torch
34+
import torch.nn as nn
35+
import torch.nn.functional as F
36+
from torch.utils.data import DataLoader
37+
from torchvision import datasets, transforms
38+
39+
# -----------------------------
40+
# Thresholds (30 evenly spaced in [2.3, 1.0], desc)
41+
# -----------------------------
42+
43+
44+
def build_thresholds() -> List[float]:
45+
vals = np.linspace(2.3, 1.0, 30).tolist()
46+
# sort descending (high→low) for unlocking order
47+
return sorted([float(v) for v in vals], reverse=True)
48+
49+
50+
# -----------------------------
51+
# Models (NO dropout anywhere)
52+
# -----------------------------
53+
54+
55+
class LogisticRegression(nn.Module):
56+
def __init__(self):
57+
super().__init__()
58+
self.fc = nn.Linear(28 * 28, 10)
59+
60+
def forward(self, x):
61+
x = x.view(x.size(0), -1)
62+
return self.fc(x)
63+
64+
65+
class SimpleCNN(nn.Module):
66+
def __init__(self):
67+
super().__init__()
68+
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
69+
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
70+
self.pool = nn.MaxPool2d(2, 2) # 28→14
71+
self.fc1 = nn.Linear(64 * 14 * 14, 128)
72+
self.fc2 = nn.Linear(128, 10)
73+
74+
def forward(self, x):
75+
x = F.relu(self.conv1(x))
76+
x = F.relu(self.conv2(x))
77+
x = self.pool(x)
78+
x = torch.flatten(x, 1)
79+
x = F.relu(self.fc1(x))
80+
x = self.fc2(x)
81+
return x
82+
83+
84+
# -----------------------------
85+
# Data
86+
# -----------------------------
87+
88+
89+
def make_loaders(batch_size: int, data_root: str, seed: int, workers: int = 2):
90+
g = torch.Generator().manual_seed(seed)
91+
tfm = transforms.Compose(
92+
[transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]
93+
)
94+
train = datasets.MNIST(root=data_root, train=True, download=True, transform=tfm)
95+
test = datasets.MNIST(root=data_root, train=False, download=True, transform=tfm)
96+
train_loader = DataLoader(
97+
train,
98+
batch_size=batch_size,
99+
shuffle=True,
100+
generator=g,
101+
num_workers=workers,
102+
pin_memory=True,
103+
)
104+
test_loader = DataLoader(
105+
test, batch_size=1024, shuffle=False, num_workers=workers, pin_memory=True
106+
)
107+
return train_loader, test_loader
108+
109+
110+
# -----------------------------
111+
# Evaluation
112+
# -----------------------------
113+
114+
115+
@torch.no_grad()
116+
def evaluate_full(model, loader, device) -> Dict[str, Any]:
117+
model.eval()
118+
total = 0
119+
loss_sum = 0.0
120+
correct = 0
121+
cm = np.zeros((10, 10), dtype=np.int64)
122+
for x, y in loader:
123+
x, y = x.to(device), y.to(device)
124+
logits = model(x)
125+
loss = F.cross_entropy(logits, y, reduction="sum")
126+
loss_sum += loss.item()
127+
total += y.size(0)
128+
pred = logits.argmax(dim=1)
129+
correct += (pred == y).sum().item()
130+
for yt, pt in zip(y.view(-1).tolist(), pred.view(-1).tolist()):
131+
cm[yt, pt] += 1
132+
return {"val_ce": loss_sum / total, "val_acc": correct / total, "cm": cm.tolist()}
133+
134+
135+
# -----------------------------
136+
# I/O helpers
137+
# -----------------------------
138+
139+
140+
def write_json(path: str, payload: Dict[str, Any]):
141+
os.makedirs(os.path.dirname(path), exist_ok=True)
142+
with open(path, "w") as f:
143+
json.dump(payload, f, indent=2)
144+
145+
146+
# -----------------------------
147+
# Training loop (per-batch eval, data-only levels)
148+
# -----------------------------
149+
150+
151+
def train(args):
152+
device = torch.device(
153+
"cuda" if (torch.cuda.is_available() and not args.cpu) else "cpu"
154+
)
155+
torch.manual_seed(args.seed)
156+
np.random.seed(args.seed)
157+
random.seed(args.seed)
158+
if device.type == "cuda":
159+
torch.cuda.manual_seed_all(args.seed)
160+
torch.backends.cudnn.deterministic = True
161+
torch.backends.cudnn.benchmark = False
162+
163+
train_loader, test_loader = make_loaders(
164+
args.batch_size, args.data_root, args.seed, args.num_workers
165+
)
166+
167+
model = LogisticRegression() if args.model == "logreg" else SimpleCNN()
168+
model = model.to(device)
169+
opt = torch.optim.Adam(model.parameters(), lr=args.lr)
170+
scaler = torch.cuda.amp.GradScaler(enabled=(args.amp and device.type == "cuda"))
171+
172+
thresholds = build_thresholds() # 30 values, high→low
173+
next_idx = 0 # next threshold index to unlock
174+
175+
# Output structure
176+
os.makedirs(args.outdir, exist_ok=True)
177+
run_dir = os.path.join(
178+
args.outdir, f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{args.model}"
179+
)
180+
os.makedirs(run_dir, exist_ok=True)
181+
levels_dir = os.path.join(run_dir, "levels")
182+
os.makedirs(levels_dir, exist_ok=True)
183+
write_json(os.path.join(run_dir, "thresholds.json"), {"thresholds": thresholds})
184+
185+
per_step = []
186+
global_step = 0
187+
188+
print(
189+
f"[run] device={device} model={args.model} epochs={args.epochs} batch={args.batch_size} lr={args.lr} amp={args.amp}"
190+
)
191+
for epoch in range(1, args.epochs + 1):
192+
model.train()
193+
running_sum = 0.0
194+
seen = 0
195+
196+
for xb, yb in train_loader:
197+
xb, yb = xb.to(device), yb.to(device)
198+
opt.zero_grad(set_to_none=True)
199+
with torch.cuda.amp.autocast(enabled=(args.amp and device.type == "cuda")):
200+
logits = model(xb)
201+
loss = F.cross_entropy(logits, yb, reduction="mean")
202+
scaler.scale(loss).backward()
203+
scaler.step(opt)
204+
scaler.update()
205+
206+
running_sum += loss.item() * yb.size(0)
207+
seen += yb.size(0)
208+
global_step += 1
209+
210+
# Full validation after each batch
211+
stats = evaluate_full(model, test_loader, device)
212+
train_ce_running = running_sum / max(seen, 1)
213+
per_step.append(
214+
{
215+
"epoch": epoch,
216+
"global_step": global_step,
217+
"train_ce_running": train_ce_running,
218+
"val_ce": stats["val_ce"],
219+
"val_acc": stats["val_acc"],
220+
}
221+
)
222+
print(
223+
f"[eval@step {global_step}] val_ce={stats['val_ce']:.4f} val_acc={stats['val_acc']*100:.2f}%"
224+
)
225+
226+
# Unlock next threshold if reached
227+
if next_idx < len(thresholds) and stats["val_ce"] <= thresholds[next_idx]:
228+
th = thresholds[next_idx]
229+
lvl_dir = os.path.join(levels_dir, f"L{next_idx+1:02d}_{th:.3f}")
230+
os.makedirs(lvl_dir, exist_ok=True)
231+
# Write metrics.json (data only)
232+
write_json(
233+
os.path.join(lvl_dir, "metrics.json"),
234+
{
235+
"level_index": next_idx + 1,
236+
"threshold": th,
237+
"epoch": epoch,
238+
"global_step": global_step,
239+
"train_ce_running": train_ce_running,
240+
"val_ce": stats["val_ce"],
241+
"val_acc": stats["val_acc"],
242+
"timestamp": time.time(),
243+
},
244+
)
245+
# Write confusion matrix as JSON
246+
write_json(
247+
os.path.join(lvl_dir, "confusion_matrix.json"),
248+
{"matrix": stats["cm"]},
249+
)
250+
print(
251+
f"[level] unlocked L{next_idx+1:02d} at threshold ≤ {th:.3f} (step {global_step})"
252+
)
253+
next_idx += 1
254+
255+
# Early exit if all 30 levels captured
256+
if (
257+
next_idx >= len(thresholds)
258+
and not args.keep_training_after_complete
259+
):
260+
print("[levels] All 30 thresholds captured — exiting early.")
261+
final = stats
262+
# Final summary
263+
write_json(
264+
os.path.join(run_dir, "metrics.json"),
265+
{
266+
"model": args.model,
267+
"device": str(device),
268+
"epochs_seen": epoch,
269+
"batch_size": args.batch_size,
270+
"lr": args.lr,
271+
"amp": args.amp,
272+
"final": {
273+
"val_ce": final["val_ce"],
274+
"val_acc": final["val_acc"],
275+
},
276+
"levels_captured": next_idx,
277+
"thresholds": thresholds,
278+
"per_step": per_step,
279+
},
280+
)
281+
# Also dump final confusion matrix JSON
282+
write_json(
283+
os.path.join(run_dir, "confusion_matrix.json"),
284+
{"matrix": final["cm"]},
285+
)
286+
print("[done] artifacts saved to:", run_dir)
287+
return
288+
289+
# End-of-epoch progress line
290+
print(
291+
f"[epoch {epoch}] avg_train_ce={running_sum/max(seen,1):.4f} levels_captured={next_idx}/30"
292+
)
293+
294+
# If not early-exited, write final summary at the end
295+
final = evaluate_full(model, test_loader, device)
296+
write_json(
297+
os.path.join(run_dir, "metrics.json"),
298+
{
299+
"model": args.model,
300+
"device": str(device),
301+
"epochs_seen": args.epochs,
302+
"batch_size": args.batch_size,
303+
"lr": args.lr,
304+
"amp": args.amp,
305+
"final": {"val_ce": final["val_ce"], "val_acc": final["val_acc"]},
306+
"levels_captured": next_idx,
307+
"thresholds": thresholds,
308+
"per_step": per_step,
309+
},
310+
)
311+
write_json(os.path.join(run_dir, "confusion_matrix.json"), {"matrix": final["cm"]})
312+
print(
313+
"[final] val_ce={:.4f} val_acc={:.2f}%".format(
314+
final["val_ce"], final["val_acc"] * 100.0
315+
)
316+
)
317+
print("[done] artifacts saved to:", run_dir)
318+
319+
320+
def main():
321+
ap = argparse.ArgumentParser()
322+
ap.add_argument("--model", choices=["logreg", "cnn"], default="cnn")
323+
ap.add_argument("--epochs", type=int, default=2)
324+
ap.add_argument("--batch-size", type=int, default=256)
325+
ap.add_argument("--lr", type=float, default=1e-3)
326+
ap.add_argument("--seed", type=int, default=42)
327+
ap.add_argument("--data-root", type=str, default="./data")
328+
ap.add_argument("--num-workers", type=int, default=2)
329+
ap.add_argument(
330+
"--amp", action="store_true", help="Enable mixed precision (CUDA only)"
331+
)
332+
ap.add_argument(
333+
"--cpu", action="store_true", help="Force CPU even if CUDA is available"
334+
)
335+
ap.add_argument("--outdir", type=str, default="runs")
336+
ap.add_argument(
337+
"--keep-training-after-complete",
338+
action="store_true",
339+
help="Do NOT exit early when all 30 levels are captured",
340+
)
341+
args = ap.parse_args()
342+
train(args)
343+
344+
345+
if __name__ == "__main__":
346+
main()

0 commit comments

Comments
 (0)