Prototype6239 commited on
Commit
a229747
Β·
verified Β·
1 Parent(s): 9925ac4

Upload folder using huggingface_hub

Browse files
Files changed (43) hide show
  1. Dockerfile +27 -0
  2. README.md +14 -5
  3. api.py +456 -0
  4. compare_results.py +132 -0
  5. config.py +85 -0
  6. data_loader.py +144 -0
  7. database.py +383 -0
  8. download_dataset.py +49 -0
  9. ensemble.py +349 -0
  10. error_analysis.py +211 -0
  11. hyperparameter_search.py +365 -0
  12. predict.py +267 -0
  13. pyproject.toml +25 -0
  14. pytest.ini +6 -0
  15. quantize_model.py +285 -0
  16. requirements.txt +43 -0
  17. saved_models/distilbert_base_uncased/config.json +39 -0
  18. saved_models/distilbert_base_uncased/model.safetensors +3 -0
  19. saved_models/distilbert_base_uncased/tokenizer.json +0 -0
  20. saved_models/distilbert_base_uncased/tokenizer_config.json +15 -0
  21. saved_models/distilbert_base_uncased/training_args.bin +3 -0
  22. saved_models/distilbert_base_uncased_int8/config.json +39 -0
  23. saved_models/distilbert_base_uncased_int8/model_int8.pt +3 -0
  24. saved_models/distilbert_base_uncased_int8/quantization_info.json +7 -0
  25. saved_models/distilbert_base_uncased_int8/tokenizer.json +0 -0
  26. saved_models/distilbert_base_uncased_int8/tokenizer_config.json +15 -0
  27. saved_models/traditional_lr.joblib +3 -0
  28. saved_models/traditional_lr_optimized.joblib +3 -0
  29. saved_models/traditional_svm.joblib +3 -0
  30. saved_models/traditional_svm_optimized.joblib +3 -0
  31. tests/__init__.py +1 -0
  32. tests/conftest.py +113 -0
  33. tests/test_api.py +176 -0
  34. tests/test_config.py +26 -0
  35. tests/test_data_loader.py +39 -0
  36. tests/test_database.py +87 -0
  37. tests/test_traditional_model.py +42 -0
  38. tests/test_transformer_model.py +33 -0
  39. traditional_model.py +204 -0
  40. train_multi.py +118 -0
  41. train_traditional.py +81 -0
  42. train_transformer.py +72 -0
  43. transformer_model.py +350 -0
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install system dependencies (needed for git-lfs and health checks)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ curl \
6
+ git \
7
+ git-lfs \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ WORKDIR /app
11
+
12
+ # Copy requirements and install torch CPU version
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy all source files
18
+ COPY . .
19
+
20
+ # Create logs directory
21
+ RUN mkdir -p logs
22
+
23
+ # Expose the default port for Hugging Face Spaces
24
+ EXPOSE 7860
25
+
26
+ # Run uvicorn server on port 7860
27
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,19 @@
1
  ---
2
- title: Nexa Classify Api
3
- emoji: 🐒
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Nexa Classify API
3
+ emoji: πŸš€
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Nexa Classify FastAPI Backend
12
+
13
+ This is the production-ready FastAPI backend for document classification, containerized for Hugging Face Spaces.
14
+
15
+ ## Setup & Running locally
16
+ If you want to run this backend locally, use:
17
+ ```bash
18
+ conda run -n docclassifier uvicorn api:app --host 0.0.0.0 --port 8000 --reload
19
+ ```
api.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.py
3
+ ──────
4
+ Production-ready REST API for document classification.
5
+ Supports any combination of saved models via lazy loading.
6
+
7
+ Usage
8
+ ─────
9
+ pip install fastapi uvicorn[standard] pydantic (already in requirements.txt)
10
+
11
+ # Start the server (from project root, venv active)
12
+ uvicorn api:app --host 0.0.0.0 --port 8000 --reload
13
+
14
+ # Health check
15
+ curl http://localhost:8000/health
16
+
17
+ # Single prediction
18
+ curl -X POST http://localhost:8000/predict \
19
+ -H "Content-Type: application/json" \
20
+ -d '{"text": "Fed raises interest rates by 50 bps", "model_name": "roberta_base"}'
21
+
22
+ # Batch prediction
23
+ curl -X POST http://localhost:8000/batch_predict \
24
+ -H "Content-Type: application/json" \
25
+ -d '{"texts": ["Apple unveils M5 chip", "Ronaldo scores again"], "model_name": "roberta_base"}'
26
+
27
+ # Explore interactive docs at: http://localhost:8000/docs
28
+ """
29
+ import logging
30
+ import os
31
+ import time
32
+ from contextlib import asynccontextmanager
33
+ from typing import Dict, List, Optional
34
+ from uuid import uuid4
35
+
36
+ import numpy as np
37
+ import torch
38
+ from fastapi import FastAPI, HTTPException, Request
39
+ from pydantic import BaseModel, Field
40
+
41
+ from config import CFG
42
+ import database
43
+
44
+ logger = logging.getLogger("api")
45
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
46
+
47
+
48
+ # ── Pydantic schemas ──────────────────────────────────────────────────────────
49
+
50
+ class PredictRequest(BaseModel):
51
+ text: str = Field(..., min_length=1, max_length=10_000,
52
+ example="Apple launches a groundbreaking AI chip.")
53
+ model_name: str = Field(default="roberta_base",
54
+ example="roberta_base",
55
+ description="Directory name in saved_models/. "
56
+ "Examples: 'roberta_base', 'lr', 'svm'.")
57
+
58
+
59
+ class BatchPredictRequest(BaseModel):
60
+ texts: List[str] = Field(..., min_length=1, max_length=256)
61
+ model_name: str = Field(default="roberta_base")
62
+
63
+
64
+ class Prediction(BaseModel):
65
+ text: str
66
+ request_id: str
67
+ label_id: int
68
+ label: str
69
+ probabilities: Optional[Dict[str, float]] = None
70
+ is_low_confidence: bool
71
+ latency_ms: float
72
+
73
+
74
+ class BatchResponse(BaseModel):
75
+ predictions: List[Prediction]
76
+ count: int
77
+ total_latency_ms: float
78
+
79
+
80
+ class HealthResponse(BaseModel):
81
+ status: str
82
+ loaded_models: List[str]
83
+ quantized: Dict[str, bool]
84
+ device: str
85
+ version: str = "1.0.0"
86
+
87
+
88
+ # ── Model registry ────────────────────────────────────────────────────────────
89
+
90
+ _registry: Dict[str, Dict] = {} # model_name β†’ {"obj": ..., "kind": str, "quantized": bool}
91
+
92
+
93
+ def _load_model(model_name: str):
94
+ """Lazy-load a model on first request, then cache it in _registry."""
95
+ # Normalise model name mapping (case-insensitive & support aliases)
96
+ name_lower = model_name.lower()
97
+ if name_lower in ("lr", "svm"):
98
+ model_name = name_lower
99
+ elif "distilbert" in name_lower:
100
+ model_name = "distilbert_base_uncased"
101
+ elif "roberta" in name_lower:
102
+ model_name = "roberta_base"
103
+ elif "bert" in name_lower:
104
+ model_name = "bert_base_uncased"
105
+
106
+ if model_name in _registry:
107
+ entry = _registry[model_name]
108
+ return entry["obj"], entry["kind"], entry["quantized"]
109
+
110
+ if model_name in ("lr", "svm"):
111
+ import joblib
112
+ path = os.path.join(CFG.models_dir, f"traditional_{model_name}.joblib")
113
+ if not os.path.exists(path):
114
+ raise FileNotFoundError(f"No model file: {path}")
115
+ obj = joblib.load(path)
116
+ kind = "sklearn"
117
+ quantized = False
118
+ else:
119
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
120
+ from transformer_model import _checkpoint_to_dir
121
+
122
+ path = os.path.join(CFG.models_dir, model_name)
123
+ if not os.path.isdir(path):
124
+ alt = os.path.join(CFG.models_dir, _checkpoint_to_dir(model_name))
125
+ if os.path.isdir(alt):
126
+ path = alt
127
+ else:
128
+ raise FileNotFoundError(
129
+ f"No model directory: {path}\n"
130
+ f"Hint: check saved_models/ for available directories."
131
+ )
132
+
133
+ int8_path = f"{path}_int8"
134
+ int8_file = os.path.join(int8_path, "model_int8.pt")
135
+ if os.path.exists(int8_file):
136
+ try:
137
+ torch.backends.quantized.engine = "qnnpack"
138
+ except Exception:
139
+ pass
140
+ try:
141
+ model = torch.load(int8_file, map_location="cpu", weights_only=False)
142
+ except TypeError:
143
+ model = torch.load(int8_file, map_location="cpu")
144
+ tokenizer = AutoTokenizer.from_pretrained(int8_path)
145
+ quantized = True
146
+ else:
147
+ model = AutoModelForSequenceClassification.from_pretrained(path)
148
+ tokenizer = AutoTokenizer.from_pretrained(path)
149
+ quantized = False
150
+
151
+ model.eval()
152
+ obj = (model, tokenizer)
153
+ kind = "transformer"
154
+
155
+ _registry[model_name] = {"obj": obj, "kind": kind, "quantized": quantized}
156
+ q = "int8" if quantized else "fp32"
157
+ logger.info(f"Model cached: {model_name} [{kind}:{q}]")
158
+ return obj, kind, quantized
159
+
160
+
161
+ def _infer_single(text: str, obj, kind: str) -> Dict:
162
+ if kind == "transformer":
163
+ model, tokenizer = obj
164
+ enc = tokenizer(text, truncation=True,
165
+ max_length=CFG.max_length, return_tensors="pt")
166
+ with torch.no_grad():
167
+ probs = torch.softmax(model(**enc).logits[0], dim=-1).numpy()
168
+ pred_id = int(np.argmax(probs))
169
+ conf = float(np.max(probs))
170
+ return {
171
+ "label_id": pred_id,
172
+ "label": CFG.label_names[pred_id],
173
+ "probabilities": {
174
+ CFG.label_names[i]: round(float(p), 4)
175
+ for i, p in enumerate(probs)
176
+ },
177
+ "confidence": conf,
178
+ }
179
+ # sklearn
180
+ pred_id = int(obj.predict([text])[0])
181
+ result = {"label_id": pred_id, "label": CFG.label_names[pred_id],
182
+ "probabilities": None, "confidence": 1.0}
183
+ clf = list(obj.named_steps.values())[-1]
184
+ if hasattr(clf, "predict_proba"):
185
+ probs = obj.predict_proba([text])[0]
186
+ result["probabilities"] = {
187
+ CFG.label_names[i]: round(float(p), 4) for i, p in enumerate(probs)
188
+ }
189
+ result["confidence"] = float(np.max(probs))
190
+ elif hasattr(clf, "decision_function"):
191
+ scores = obj.decision_function([text])
192
+ scores = np.asarray(scores, dtype=np.float64).reshape(1, -1)
193
+ scores = scores - np.max(scores, axis=1, keepdims=True)
194
+ exps = np.exp(scores)
195
+ probs = exps / np.sum(exps, axis=1, keepdims=True)
196
+ result["confidence"] = float(np.max(probs))
197
+ return result
198
+
199
+
200
+ def _infer_batch(texts: List[str], obj, kind: str) -> List[Dict]:
201
+ if kind == "transformer":
202
+ model, tokenizer = obj
203
+ results = []
204
+ batch_size = 16
205
+ for i in range(0, len(texts), batch_size):
206
+ batch = texts[i : i + batch_size]
207
+ enc = tokenizer(batch, truncation=True, max_length=CFG.max_length,
208
+ padding=True, return_tensors="pt")
209
+ with torch.no_grad():
210
+ logits = model(**enc).logits
211
+ probs_batch = torch.softmax(logits, dim=-1).numpy()
212
+ for text, probs in zip(batch, probs_batch):
213
+ pred_id = int(np.argmax(probs))
214
+ conf = float(np.max(probs))
215
+ results.append({
216
+ "label_id": pred_id,
217
+ "label": CFG.label_names[pred_id],
218
+ "probabilities": {
219
+ CFG.label_names[i]: round(float(p), 4)
220
+ for i, p in enumerate(probs)
221
+ },
222
+ "confidence": conf,
223
+ "text": text,
224
+ })
225
+ return results
226
+ # sklearn batch
227
+ preds = obj.predict(texts)
228
+ clf = list(obj.named_steps.values())[-1]
229
+ confidences = np.ones(len(texts), dtype=np.float64)
230
+ if hasattr(clf, "predict_proba"):
231
+ probs = obj.predict_proba(texts)
232
+ confidences = np.max(probs, axis=1)
233
+ elif hasattr(clf, "decision_function"):
234
+ scores = obj.decision_function(texts)
235
+ scores = np.asarray(scores, dtype=np.float64)
236
+ if scores.ndim == 1:
237
+ scores = np.stack([-scores, scores], axis=1)
238
+ scores = scores - np.max(scores, axis=1, keepdims=True)
239
+ exps = np.exp(scores)
240
+ probs = exps / np.sum(exps, axis=1, keepdims=True)
241
+ confidences = np.max(probs, axis=1)
242
+
243
+ results = []
244
+ for p, t, c in zip(preds, texts, confidences):
245
+ results.append(
246
+ {
247
+ "label_id": int(p),
248
+ "label": CFG.label_names[int(p)],
249
+ "probabilities": None,
250
+ "confidence": float(c),
251
+ "text": t,
252
+ }
253
+ )
254
+ return results
255
+
256
+
257
+ # ── FastAPI app ───────────────────────────────────────────────────────────────
258
+
259
+ @asynccontextmanager
260
+ async def lifespan(app: FastAPI):
261
+ """Pre-warm the default model on server startup."""
262
+ try:
263
+ database.init_db()
264
+ _load_model("roberta_base")
265
+ logger.info("Default model (roberta_base) pre-loaded.")
266
+ except FileNotFoundError:
267
+ logger.warning("Default model not found; will load on first request.")
268
+ yield
269
+ _registry.clear()
270
+ logger.info("Model registry cleared.")
271
+
272
+
273
+ app = FastAPI(
274
+ title="Document Classifier API",
275
+ description=(
276
+ "Multi-class news text classification over four categories: "
277
+ "World Β· Sports Β· Business Β· Sci/Tech. "
278
+ "Supports traditional ML and transformer models."
279
+ ),
280
+ version="1.0.0",
281
+ lifespan=lifespan,
282
+ )
283
+
284
+ from fastapi.middleware.cors import CORSMiddleware
285
+
286
+ app.add_middleware(
287
+ CORSMiddleware,
288
+ allow_origins=["*"],
289
+ allow_credentials=True,
290
+ allow_methods=["*"],
291
+ allow_headers=["*"],
292
+ )
293
+
294
+ @app.middleware("http")
295
+ async def add_private_network_header(request: Request, call_next):
296
+ response = await call_next(request)
297
+ if "access-control-request-private-network" in request.headers:
298
+ response.headers["Access-Control-Allow-Private-Network"] = "true"
299
+ origin = request.headers.get("origin")
300
+ if origin:
301
+ response.headers["Access-Control-Allow-Origin"] = origin
302
+ return response
303
+
304
+
305
+
306
+ @app.get("/health", response_model=HealthResponse, tags=["Status"])
307
+ async def health():
308
+ """Confirm the API is running, list loaded models, and report device."""
309
+ return HealthResponse(
310
+ status="ok",
311
+ loaded_models=list(_registry.keys()),
312
+ quantized={k: bool(v.get("quantized")) for k, v in _registry.items()},
313
+ device=CFG.device,
314
+ )
315
+
316
+
317
+ @app.get("/labels", tags=["Status"])
318
+ async def get_labels():
319
+ """Return the four classification labels."""
320
+ return {
321
+ "labels": [
322
+ {"id": i, "name": n} for i, n in enumerate(CFG.label_names)
323
+ ]
324
+ }
325
+
326
+
327
+ @app.get("/models", tags=["Status"])
328
+ async def list_available_models():
329
+ """List all models that exist in saved_models/ and are ready to load."""
330
+ available = []
331
+ if os.path.isdir(CFG.models_dir):
332
+ for name in os.listdir(CFG.models_dir):
333
+ path = os.path.join(CFG.models_dir, name)
334
+ if name.endswith("_int8"):
335
+ continue
336
+ if os.path.isdir(path) and os.path.exists(
337
+ os.path.join(path, "config.json")
338
+ ):
339
+ int8_file = os.path.join(f"{path}_int8", "model_int8.pt")
340
+ available.append(
341
+ {
342
+ "name": name,
343
+ "type": "transformer",
344
+ "quantized": bool(os.path.exists(int8_file)),
345
+ }
346
+ )
347
+ for fname in os.listdir(CFG.models_dir):
348
+ if fname.startswith("traditional_") and fname.endswith(".joblib"):
349
+ short = fname.replace("traditional_", "").replace(".joblib", "")
350
+ available.append({"name": short, "type": "sklearn", "quantized": False})
351
+ return {"models": available, "count": len(available)}
352
+
353
+
354
+ @app.post("/predict", response_model=Prediction, tags=["Inference"])
355
+ async def predict(req: PredictRequest):
356
+ """Classify a single text document and return label + probabilities."""
357
+ t0 = time.perf_counter()
358
+ request_id = str(uuid4())
359
+ try:
360
+ obj, kind, _ = _load_model(req.model_name)
361
+ except FileNotFoundError as exc:
362
+ raise HTTPException(status_code=404, detail=str(exc))
363
+ result = _infer_single(req.text, obj, kind)
364
+ latency = (time.perf_counter() - t0) * 1000
365
+ confidence = float(result.get("confidence", 1.0))
366
+ is_low = bool(confidence < float(CFG.low_confidence_threshold))
367
+ database.log_request(
368
+ request_id=request_id,
369
+ model_name=req.model_name,
370
+ input_text=req.text,
371
+ predicted_label=str(result["label"]),
372
+ predicted_label_id=int(result["label_id"]),
373
+ confidence=confidence,
374
+ latency_ms=float(latency),
375
+ is_batch=False,
376
+ )
377
+ return Prediction(
378
+ text=req.text[:200],
379
+ request_id=request_id,
380
+ is_low_confidence=is_low,
381
+ latency_ms=round(latency, 2),
382
+ label_id=result["label_id"],
383
+ label=result["label"],
384
+ probabilities=result.get("probabilities"),
385
+ )
386
+
387
+
388
+ @app.post("/batch_predict", response_model=BatchResponse, tags=["Inference"])
389
+ async def batch_predict(req: BatchPredictRequest):
390
+ """Classify a list of documents in one call (up to 256 texts)."""
391
+ t0 = time.perf_counter()
392
+ try:
393
+ obj, kind, _ = _load_model(req.model_name)
394
+ except FileNotFoundError as exc:
395
+ raise HTTPException(status_code=404, detail=str(exc))
396
+ raw_results = _infer_batch(req.texts, obj, kind)
397
+ total_ms = (time.perf_counter() - t0) * 1000
398
+ per_item_ms = (total_ms / len(req.texts)) if req.texts else 0.0
399
+ predictions = [
400
+ Prediction(
401
+ text=r["text"][:200],
402
+ request_id=str(uuid4()),
403
+ label_id=r["label_id"],
404
+ label=r["label"],
405
+ probabilities=r.get("probabilities"),
406
+ is_low_confidence=bool(float(r.get("confidence", 1.0)) < float(CFG.low_confidence_threshold)),
407
+ latency_ms=round(per_item_ms, 2),
408
+ )
409
+ for r in raw_results
410
+ ]
411
+ for r, pred in zip(raw_results, predictions):
412
+ database.log_request(
413
+ request_id=pred.request_id,
414
+ model_name=req.model_name,
415
+ input_text=r["text"],
416
+ predicted_label=str(r["label"]),
417
+ predicted_label_id=int(r["label_id"]),
418
+ confidence=float(r.get("confidence", 1.0)),
419
+ latency_ms=float(per_item_ms),
420
+ is_batch=True,
421
+ )
422
+ return BatchResponse(
423
+ predictions=predictions,
424
+ count=len(predictions),
425
+ total_latency_ms=round(total_ms, 2),
426
+ )
427
+
428
+
429
+ @app.get("/analytics/summary", tags=["Analytics"])
430
+ async def analytics_summary(model_name: Optional[str] = None, days: int = 7):
431
+ return database.get_summary(model_name=model_name, days=days)
432
+
433
+
434
+ @app.get("/analytics/history", tags=["Analytics"])
435
+ async def analytics_history(limit: int = 50, offset: int = 0):
436
+ return database.get_request_history(limit=limit, offset=offset)
437
+
438
+
439
+ @app.get("/analytics/low_confidence", tags=["Analytics"])
440
+ async def analytics_low_confidence(reviewed: bool = False, limit: int = 50):
441
+ return database.get_low_confidence_flags(reviewed=reviewed, limit=limit)
442
+
443
+
444
+ class ReviewBody(BaseModel):
445
+ note: Optional[str] = None
446
+
447
+
448
+ @app.patch("/analytics/review/{request_id}", tags=["Analytics"])
449
+ async def analytics_mark_reviewed(request_id: str, body: ReviewBody):
450
+ database.mark_reviewed(request_id=request_id, note=body.note)
451
+ return {"request_id": request_id, "reviewed": True}
452
+
453
+
454
+ @app.post("/analytics/export_flags", tags=["Analytics"])
455
+ async def analytics_export_flags():
456
+ return database.export_low_confidence_to_folder()
compare_results.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ compare_results.py
3
+ ──────────────────
4
+ Auto-discovers and evaluates all saved models side-by-side.
5
+ Handles multiple transformer architectures in saved_models/.
6
+
7
+ Usage
8
+ ─────
9
+ python compare_results.py
10
+ """
11
+ import logging
12
+ import os
13
+ from typing import Dict, List
14
+
15
+ import numpy as np
16
+ import torch
17
+ from sklearn.metrics import accuracy_score, f1_score
18
+
19
+ from config import CFG
20
+ from data_loader import load_test_only
21
+ import traditional_model as tm
22
+ import transformer_model as trm
23
+
24
+ logging.basicConfig(level=logging.WARNING)
25
+
26
+
27
+ # ── Discovery ─────────────────────────────────────────────────────────────────
28
+
29
+ def _discover_transformer_models() -> List[str]:
30
+ """Return directory names of all saved transformer models."""
31
+ found = []
32
+ if not os.path.isdir(CFG.models_dir):
33
+ return found
34
+ for name in sorted(os.listdir(CFG.models_dir)):
35
+ path = os.path.join(CFG.models_dir, name)
36
+ if os.path.isdir(path) and os.path.exists(os.path.join(path, "config.json")):
37
+ found.append(name)
38
+ return found
39
+
40
+
41
+ # ── Evaluation ────────────────────────────────────────────────────────────────
42
+
43
+ def _eval_traditional(name: str, X_test: List[str], y_test: List[int]) -> Dict:
44
+ try:
45
+ pipeline = tm.load_model(name)
46
+ preds = list(pipeline.predict(X_test))
47
+ return {
48
+ "accuracy": accuracy_score(y_test, preds),
49
+ "f1_macro": f1_score(y_test, preds, average="macro"),
50
+ }
51
+ except FileNotFoundError:
52
+ return {}
53
+
54
+
55
+ def _eval_transformer(model_dir: str, X_test: List[str], y_test: List[int]) -> Dict:
56
+ path = os.path.join(CFG.models_dir, model_dir)
57
+ try:
58
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
59
+ model = AutoModelForSequenceClassification.from_pretrained(path)
60
+ tokenizer = AutoTokenizer.from_pretrained(path)
61
+ model.eval()
62
+
63
+ preds = []
64
+ batch_size = 32
65
+
66
+ for i in range(0, len(X_test), batch_size):
67
+ batch = X_test[i : i + batch_size]
68
+ enc = tokenizer(batch, truncation=True, max_length=CFG.max_length,
69
+ padding=True, return_tensors="pt")
70
+ with torch.no_grad():
71
+ logits = model(**enc).logits
72
+ preds.extend(logits.argmax(dim=-1).tolist())
73
+
74
+ return {
75
+ "accuracy": accuracy_score(y_test, preds),
76
+ "f1_macro": f1_score(y_test, preds, average="macro"),
77
+ }
78
+ except FileNotFoundError:
79
+ return {}
80
+ except Exception as exc:
81
+ print(f" [{model_dir}] Error: {exc}")
82
+ return {}
83
+
84
+
85
+ # ── Main ──────────────────────────────────────────────────────────────────────
86
+
87
+ def main() -> None:
88
+ print("\n Loading AG News test set …")
89
+ X_test, y_test = load_test_only()
90
+ print(f" Loaded {len(X_test):,} examples.\n")
91
+
92
+ results: Dict[str, Dict] = {}
93
+
94
+ # Traditional models
95
+ for name in ["lr", "svm"]:
96
+ print(f" Evaluating {name.upper()} …")
97
+ r = _eval_traditional(name, X_test, y_test)
98
+ if r:
99
+ results[name.upper()] = r
100
+ else:
101
+ print(f" [{name.upper()}] not found β€” skipping.")
102
+
103
+ # All saved transformer models
104
+ transformer_dirs = _discover_transformer_models()
105
+ if not transformer_dirs:
106
+ print(" No transformer models found in saved_models/.")
107
+ for model_dir in transformer_dirs:
108
+ display_name = model_dir.replace("_", "-")
109
+ print(f" Evaluating {display_name} …")
110
+ r = _eval_transformer(model_dir, X_test, y_test)
111
+ if r:
112
+ results[display_name] = r
113
+
114
+ if not results:
115
+ print("\n No models found. Train at least one model first.\n")
116
+ return
117
+
118
+ # Print table
119
+ print("\n" + "═" * 58)
120
+ print(f" {'Model':<22} {'Accuracy':>10} {'F1-Macro':>10}")
121
+ print("─" * 58)
122
+ for name, m in sorted(results.items(), key=lambda x: x[1]["accuracy"], reverse=True):
123
+ star = " β—€ best" if name == max(results, key=lambda k: results[k]["accuracy"]) else ""
124
+ print(
125
+ f" {name:<22} {m['accuracy']*100:>9.2f}% "
126
+ f"{m['f1_macro']:>10.4f}{star}"
127
+ )
128
+ print("═" * 58 + "\n")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
config.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py
3
+ ─────────
4
+ Updated for Mac M4 / Apple Silicon MPS.
5
+
6
+ Key changes vs Windows version:
7
+ - Auto device detection: MPS β†’ CUDA β†’ CPU
8
+ - Sample caps removed (full 120 K dataset now feasible)
9
+ - batch_size 16 (grad_accum_steps=2 β†’ effective batch 32)
10
+ - max_length 128 (AG News headlines fit comfortably; saves ~60% VRAM vs 256)
11
+ - Default model upgraded to 'roberta-base'
12
+ - label_smoothing added for better calibration
13
+ - gradient_checkpointing enabled by default (MPS OOM safeguard)
14
+ """
15
+ import os
16
+ import torch
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Optional
19
+
20
+
21
+ def _auto_device() -> str:
22
+ """Detect the best available compute device at import time."""
23
+ if torch.backends.mps.is_available() and torch.backends.mps.is_built():
24
+ return "mps"
25
+ if torch.cuda.is_available():
26
+ return "cuda"
27
+ return "cpu"
28
+
29
+
30
+ @dataclass
31
+ class Config:
32
+ # ── Dataset ──────────────────────────────────────────────────────────────
33
+ dataset_name: str = "ag_news"
34
+ num_labels: int = 4
35
+ label_names: List[str] = field(
36
+ default_factory=lambda: ["World", "Sports", "Business", "Sci/Tech"]
37
+ )
38
+
39
+ # Full dataset β€” no caps needed on M4 MPS
40
+ max_train_samples: Optional[int] = None # 120,000
41
+ max_eval_samples: Optional[int] = None # ~12,000
42
+ max_test_samples: Optional[int] = None # 7,600
43
+
44
+ # ── Model ─────────────────────────────────────────────────────────────────
45
+ # Supported checkpoints (swap as needed):
46
+ # "distilbert-base-uncased" β€” 66M params β€” fastest (~45–70 min on M4)
47
+ # "bert-base-uncased" β€” 110M params β€” balanced (~90–120 min on M4)
48
+ # "roberta-base" β€” 125M params β€” best acc (~90–150 min on M4)
49
+ model_checkpoint: str = "roberta-base"
50
+ max_length: int = 128 # 128 is ample for AG News; saves ~60% VRAM vs 256
51
+
52
+ # ── Training Hyper-parameters ─────────────────────────────────────────────
53
+ batch_size: int = 16 # 16 Γ— grad_accum_steps=2 β†’ effective batch 32
54
+ num_epochs: int = 3 # Safe training epochs
55
+ learning_rate: float = 2e-5
56
+ warmup_ratio: float = 0.06
57
+ weight_decay: float = 0.01
58
+ grad_accum_steps: int = 2 # Accumulate 2 steps β†’ effective batch 32
59
+ label_smoothing: float = 0.1 # Regularisation: prevents over-confidence
60
+ use_gradient_checkpointing: bool = True # ON by default β€” critical MPS OOM safeguard
61
+
62
+ # ── Hardware (auto-detected) ───────────────────────────────────────────────
63
+ device: str = field(default_factory=_auto_device)
64
+ # num_workers=0 is safest with HuggingFace datasets in torch format on Mac
65
+ num_workers: int = 0
66
+ seed: int = 42
67
+ low_confidence_threshold: float = 0.60
68
+
69
+ # ── Paths ─────────────────────────────────────────────────────────────────
70
+ data_dir: str = "data"
71
+ models_dir: str = "saved_models"
72
+ outputs_dir: str = "outputs"
73
+ logs_dir: str = os.path.join("outputs", "logs")
74
+
75
+ def __post_init__(self) -> None:
76
+ for d in [self.data_dir, self.models_dir, self.outputs_dir, self.logs_dir]:
77
+ os.makedirs(d, exist_ok=True)
78
+ device_label = (
79
+ "MPS β€” Apple Metal (M4)" if self.device == "mps" else self.device.upper()
80
+ )
81
+ print(f"[Config] Device: {device_label} | Model: {self.model_checkpoint}")
82
+
83
+
84
+ # Module-level singleton β€” imported by all modules
85
+ CFG = Config()
data_loader.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data_loader.py
3
+ ──────────────
4
+ Handles all dataset loading, validation splitting, preprocessing and tokenisation.
5
+
6
+ AG News label scheme:
7
+ 0 = World 1 = Sports 2 = Business 3 = Sci/Tech
8
+ """
9
+ import logging
10
+ from typing import List, Optional, Tuple
11
+
12
+ from datasets import load_dataset, DatasetDict
13
+ from transformers import AutoTokenizer, PreTrainedTokenizerBase
14
+
15
+ from config import CFG
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format="%(asctime)s %(levelname)-8s %(message)s",
20
+ datefmt="%H:%M:%S",
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ── Public API ────────────────────────────────────────────────────────────────
26
+
27
+ def load_ag_news(
28
+ max_train: Optional[int] = CFG.max_train_samples,
29
+ max_eval: Optional[int] = CFG.max_eval_samples,
30
+ max_test: Optional[int] = CFG.max_test_samples,
31
+ ) -> DatasetDict:
32
+ """
33
+ Load AG News from the HuggingFace datasets cache (downloads on first call).
34
+
35
+ AG News ships with 'train' (120 K) and 'test' (7.6 K) only.
36
+ We carve out a stratified 10 % of 'train' as the validation set.
37
+
38
+ Returns
39
+ -------
40
+ DatasetDict with splits: 'train', 'validation', 'test'
41
+ """
42
+ logger.info("Loading AG News dataset …")
43
+ raw = load_dataset("ag_news")
44
+
45
+ # Stratified 90/10 train β†’ train + validation
46
+ tv = raw["train"].train_test_split(
47
+ test_size=0.10,
48
+ seed=CFG.seed,
49
+ stratify_by_column="label",
50
+ )
51
+ dataset = DatasetDict({
52
+ "train": tv["train"],
53
+ "validation": tv["test"],
54
+ "test": raw["test"],
55
+ })
56
+
57
+ # Optional down-sampling (speeds up CPU training significantly)
58
+ if max_train is not None:
59
+ n = min(max_train, len(dataset["train"]))
60
+ dataset["train"] = (
61
+ dataset["train"].shuffle(seed=CFG.seed).select(range(n))
62
+ )
63
+ if max_eval is not None:
64
+ n = min(max_eval, len(dataset["validation"]))
65
+ dataset["validation"] = (
66
+ dataset["validation"].shuffle(seed=CFG.seed).select(range(n))
67
+ )
68
+ if max_test is not None:
69
+ n = min(max_test, len(dataset["test"]))
70
+ dataset["test"] = dataset["test"].select(range(n))
71
+
72
+ logger.info(
73
+ f" train={len(dataset['train']):,} "
74
+ f"val={len(dataset['validation']):,} "
75
+ f"test={len(dataset['test']):,}"
76
+ )
77
+ return dataset
78
+
79
+
80
+ def load_test_only() -> Tuple[List[str], List[int]]:
81
+ """
82
+ Load only the test split (fast, no stratified split overhead).
83
+ Used by compare_results.py.
84
+ """
85
+ raw = load_dataset("ag_news")
86
+ return list(raw["test"]["text"]), list(raw["test"]["label"])
87
+
88
+
89
+ def get_raw_splits(dataset: DatasetDict) -> Tuple:
90
+ """
91
+ Return plain Python lists of (texts, labels) for all three splits.
92
+ Used by the scikit-learn traditional ML pipeline.
93
+ """
94
+ X_train = list(dataset["train"]["text"])
95
+ y_train = list(dataset["train"]["label"])
96
+ X_val = list(dataset["validation"]["text"])
97
+ y_val = list(dataset["validation"]["label"])
98
+ X_test = list(dataset["test"]["text"])
99
+ y_test = list(dataset["test"]["label"])
100
+ return X_train, y_train, X_val, y_val, X_test, y_test
101
+
102
+
103
+ def get_tokenizer() -> PreTrainedTokenizerBase:
104
+ """Download (or load from local HuggingFace cache) the DistilBERT tokeniser."""
105
+ logger.info(f"Loading tokeniser: {CFG.model_checkpoint}")
106
+ return AutoTokenizer.from_pretrained(CFG.model_checkpoint)
107
+
108
+
109
+ def tokenise_dataset(
110
+ dataset: DatasetDict,
111
+ tokenizer: PreTrainedTokenizerBase,
112
+ ) -> DatasetDict:
113
+ """
114
+ Tokenise all splits for the HuggingFace Trainer.
115
+
116
+ Design decisions:
117
+ - padding=False β†’ pads at collation time via DataCollatorWithPadding
118
+ (more memory-efficient than padding all to max_length)
119
+ - num_proc=1 β†’ required on Windows; fork-based multi-processing
120
+ causes issues with PyTorch on Windows
121
+ """
122
+ def _tokenise(batch: dict) -> dict:
123
+ return tokenizer(
124
+ batch["text"],
125
+ truncation=True,
126
+ max_length=CFG.max_length,
127
+ padding=False,
128
+ )
129
+
130
+ logger.info("Tokenising dataset …")
131
+ tokenised = dataset.map(
132
+ _tokenise,
133
+ batched=True,
134
+ batch_size=1_000,
135
+ num_proc=1,
136
+ remove_columns=["text"],
137
+ desc="Tokenising",
138
+ )
139
+
140
+ # HuggingFace Trainer requires the label column to be named 'labels'
141
+ tokenised = tokenised.rename_column("label", "labels")
142
+ tokenised.set_format("torch", columns=["input_ids", "attention_mask", "labels"])
143
+
144
+ return tokenised
database.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sqlite3
3
+ import threading
4
+ import uuid
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from config import CFG
9
+
10
+
11
+ _WRITE_LOCK = threading.Lock()
12
+
13
+
14
+ def _logs_dir() -> str:
15
+ path = os.path.join("logs")
16
+ os.makedirs(path, exist_ok=True)
17
+ return path
18
+
19
+
20
+ def _default_db_path() -> str:
21
+ return os.path.join(_logs_dir(), "api_requests.db")
22
+
23
+
24
+ def _connect(db_path: Optional[str] = None) -> sqlite3.Connection:
25
+ conn = sqlite3.connect(db_path or _default_db_path(), timeout=30, check_same_thread=False)
26
+ conn.row_factory = sqlite3.Row
27
+ conn.execute("PRAGMA journal_mode=WAL;")
28
+ conn.execute("PRAGMA synchronous=NORMAL;")
29
+ conn.execute("PRAGMA foreign_keys=ON;")
30
+ return conn
31
+
32
+
33
+ def _now_iso() -> str:
34
+ return datetime.now(timezone.utc).isoformat()
35
+
36
+
37
+ def _today_ymd() -> str:
38
+ return datetime.now(timezone.utc).date().isoformat()
39
+
40
+
41
+ def init_db(db_path: Optional[str] = None) -> None:
42
+ with _WRITE_LOCK:
43
+ conn = _connect(db_path=db_path)
44
+ try:
45
+ conn.execute(
46
+ """
47
+ CREATE TABLE IF NOT EXISTS requests (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ request_id TEXT UNIQUE NOT NULL,
50
+ timestamp TEXT NOT NULL,
51
+ model_name TEXT NOT NULL,
52
+ input_text TEXT NOT NULL,
53
+ input_length INTEGER,
54
+ predicted_label TEXT NOT NULL,
55
+ predicted_label_id INTEGER NOT NULL,
56
+ confidence REAL NOT NULL,
57
+ is_low_confidence INTEGER NOT NULL DEFAULT 0,
58
+ latency_ms REAL NOT NULL,
59
+ is_batch INTEGER NOT NULL DEFAULT 0
60
+ );
61
+ """
62
+ )
63
+ conn.execute(
64
+ """
65
+ CREATE TABLE IF NOT EXISTS model_stats (
66
+ model_name TEXT NOT NULL,
67
+ date TEXT NOT NULL,
68
+ total_requests INTEGER DEFAULT 0,
69
+ avg_confidence REAL DEFAULT 0.0,
70
+ avg_latency_ms REAL DEFAULT 0.0,
71
+ low_conf_count INTEGER DEFAULT 0,
72
+ PRIMARY KEY (model_name, date)
73
+ );
74
+ """
75
+ )
76
+ conn.execute(
77
+ """
78
+ CREATE TABLE IF NOT EXISTS low_confidence_flags (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ request_id TEXT NOT NULL,
81
+ timestamp TEXT NOT NULL,
82
+ input_text TEXT NOT NULL,
83
+ predicted_label TEXT NOT NULL,
84
+ confidence REAL NOT NULL,
85
+ reviewed INTEGER NOT NULL DEFAULT 0,
86
+ review_note TEXT,
87
+ FOREIGN KEY (request_id) REFERENCES requests(request_id)
88
+ );
89
+ """
90
+ )
91
+ conn.execute(
92
+ "CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp);"
93
+ )
94
+ conn.execute(
95
+ "CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model_name);"
96
+ )
97
+ conn.execute(
98
+ "CREATE INDEX IF NOT EXISTS idx_flags_reviewed ON low_confidence_flags(reviewed);"
99
+ )
100
+ conn.commit()
101
+ finally:
102
+ conn.close()
103
+
104
+
105
+ def new_request_id() -> str:
106
+ return str(uuid.uuid4())
107
+
108
+
109
+ def log_request(
110
+ request_id: str,
111
+ model_name: str,
112
+ input_text: str,
113
+ predicted_label: str,
114
+ predicted_label_id: int,
115
+ confidence: float,
116
+ latency_ms: float,
117
+ is_batch: bool,
118
+ db_path: Optional[str] = None,
119
+ ) -> None:
120
+ ts = _now_iso()
121
+ original_len = len(input_text)
122
+ stored_text = input_text[:500]
123
+ is_low = 1 if float(confidence) < float(CFG.low_confidence_threshold) else 0
124
+ batch_int = 1 if is_batch else 0
125
+
126
+ with _WRITE_LOCK:
127
+ conn = _connect(db_path=db_path)
128
+ try:
129
+ conn.execute(
130
+ """
131
+ INSERT INTO requests (
132
+ request_id, timestamp, model_name, input_text, input_length,
133
+ predicted_label, predicted_label_id, confidence, is_low_confidence,
134
+ latency_ms, is_batch
135
+ )
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
137
+ """,
138
+ (
139
+ request_id,
140
+ ts,
141
+ model_name,
142
+ stored_text,
143
+ original_len,
144
+ predicted_label,
145
+ int(predicted_label_id),
146
+ float(confidence),
147
+ int(is_low),
148
+ float(latency_ms),
149
+ int(batch_int),
150
+ ),
151
+ )
152
+
153
+ if is_low:
154
+ conn.execute(
155
+ """
156
+ INSERT INTO low_confidence_flags (
157
+ request_id, timestamp, input_text, predicted_label, confidence, reviewed, review_note
158
+ )
159
+ VALUES (?, ?, ?, ?, ?, 0, NULL);
160
+ """,
161
+ (request_id, ts, stored_text, predicted_label, float(confidence)),
162
+ )
163
+
164
+ date = _today_ymd()
165
+ row = conn.execute(
166
+ """
167
+ SELECT total_requests, avg_confidence, avg_latency_ms, low_conf_count
168
+ FROM model_stats
169
+ WHERE model_name=? AND date=?;
170
+ """,
171
+ (model_name, date),
172
+ ).fetchone()
173
+ if row is None:
174
+ conn.execute(
175
+ """
176
+ INSERT INTO model_stats (
177
+ model_name, date, total_requests, avg_confidence, avg_latency_ms, low_conf_count
178
+ )
179
+ VALUES (?, ?, 1, ?, ?, ?);
180
+ """,
181
+ (model_name, date, float(confidence), float(latency_ms), int(is_low)),
182
+ )
183
+ else:
184
+ n = int(row["total_requests"])
185
+ new_n = n + 1
186
+ new_avg_conf = (float(row["avg_confidence"]) * n + float(confidence)) / new_n
187
+ new_avg_lat = (float(row["avg_latency_ms"]) * n + float(latency_ms)) / new_n
188
+ new_low = int(row["low_conf_count"]) + int(is_low)
189
+ conn.execute(
190
+ """
191
+ UPDATE model_stats
192
+ SET total_requests=?, avg_confidence=?, avg_latency_ms=?, low_conf_count=?
193
+ WHERE model_name=? AND date=?;
194
+ """,
195
+ (new_n, new_avg_conf, new_avg_lat, new_low, model_name, date),
196
+ )
197
+
198
+ conn.commit()
199
+ finally:
200
+ conn.close()
201
+
202
+
203
+ def get_request_history(
204
+ db_path: Optional[str] = None, limit: int = 100, offset: int = 0
205
+ ) -> List[Dict[str, Any]]:
206
+ conn = _connect(db_path=db_path)
207
+ try:
208
+ rows = conn.execute(
209
+ """
210
+ SELECT *
211
+ FROM requests
212
+ ORDER BY id DESC
213
+ LIMIT ? OFFSET ?;
214
+ """,
215
+ (int(limit), int(offset)),
216
+ ).fetchall()
217
+ return [dict(r) for r in rows]
218
+ finally:
219
+ conn.close()
220
+
221
+
222
+ def get_low_confidence_flags(
223
+ db_path: Optional[str] = None, reviewed: bool = False, limit: int = 50
224
+ ) -> List[Dict[str, Any]]:
225
+ conn = _connect(db_path=db_path)
226
+ try:
227
+ rows = conn.execute(
228
+ """
229
+ SELECT *
230
+ FROM low_confidence_flags
231
+ WHERE reviewed=?
232
+ ORDER BY id DESC
233
+ LIMIT ?;
234
+ """,
235
+ (1 if reviewed else 0, int(limit)),
236
+ ).fetchall()
237
+ return [dict(r) for r in rows]
238
+ finally:
239
+ conn.close()
240
+
241
+
242
+ def mark_reviewed(request_id: str, note: Optional[str] = None, db_path: Optional[str] = None) -> None:
243
+ with _WRITE_LOCK:
244
+ conn = _connect(db_path=db_path)
245
+ try:
246
+ conn.execute(
247
+ """
248
+ UPDATE low_confidence_flags
249
+ SET reviewed=1, review_note=?
250
+ WHERE request_id=?;
251
+ """,
252
+ (note, request_id),
253
+ )
254
+ conn.commit()
255
+ finally:
256
+ conn.close()
257
+
258
+
259
+ def get_model_leaderboard(db_path: Optional[str] = None) -> List[Tuple[str, int, float, float]]:
260
+ conn = _connect(db_path=db_path)
261
+ try:
262
+ rows = conn.execute(
263
+ """
264
+ SELECT
265
+ model_name,
266
+ COUNT(*) AS total_requests,
267
+ AVG(confidence) AS avg_confidence,
268
+ AVG(latency_ms) AS avg_latency_ms
269
+ FROM requests
270
+ GROUP BY model_name
271
+ ORDER BY total_requests DESC;
272
+ """
273
+ ).fetchall()
274
+ return [
275
+ (
276
+ str(r["model_name"]),
277
+ int(r["total_requests"]),
278
+ float(r["avg_confidence"] or 0.0),
279
+ float(r["avg_latency_ms"] or 0.0),
280
+ )
281
+ for r in rows
282
+ ]
283
+ finally:
284
+ conn.close()
285
+
286
+
287
+ def get_summary(
288
+ db_path: Optional[str] = None, model_name: Optional[str] = None, days: int = 7
289
+ ) -> Dict[str, Any]:
290
+ conn = _connect(db_path=db_path)
291
+ try:
292
+ start_ts = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
293
+ params: List[Any] = [start_ts]
294
+ where = "WHERE timestamp >= ?"
295
+ if model_name:
296
+ where += " AND model_name = ?"
297
+ params.append(model_name)
298
+
299
+ row = conn.execute(
300
+ f"""
301
+ SELECT
302
+ COUNT(*) AS total_requests,
303
+ AVG(confidence) AS avg_confidence,
304
+ AVG(latency_ms) AS avg_latency_ms,
305
+ SUM(is_low_confidence) AS low_confidence_count
306
+ FROM requests
307
+ {where};
308
+ """,
309
+ tuple(params),
310
+ ).fetchone()
311
+
312
+ total_requests = int(row["total_requests"] or 0)
313
+ avg_confidence = float(row["avg_confidence"] or 0.0)
314
+ avg_latency_ms = float(row["avg_latency_ms"] or 0.0)
315
+ low_conf_count = int(row["low_confidence_count"] or 0)
316
+ rate = (low_conf_count / total_requests) * 100.0 if total_requests > 0 else 0.0
317
+
318
+ params2: List[Any] = list(params)
319
+ where2 = where
320
+
321
+ models = conn.execute(
322
+ f"""
323
+ SELECT DISTINCT model_name
324
+ FROM requests
325
+ {where2}
326
+ ORDER BY model_name;
327
+ """,
328
+ tuple(params2),
329
+ ).fetchall()
330
+ models_used = [str(r["model_name"]) for r in models]
331
+
332
+ label_rows = conn.execute(
333
+ f"""
334
+ SELECT predicted_label, COUNT(*) AS c
335
+ FROM requests
336
+ {where2}
337
+ GROUP BY predicted_label;
338
+ """,
339
+ tuple(params2),
340
+ ).fetchall()
341
+ predictions_by_label = {str(r["predicted_label"]): int(r["c"]) for r in label_rows}
342
+
343
+ return {
344
+ "period_days": int(days),
345
+ "total_requests": total_requests,
346
+ "models_used": models_used,
347
+ "avg_confidence": round(avg_confidence, 3),
348
+ "avg_latency_ms": round(avg_latency_ms, 2),
349
+ "low_confidence_count": low_conf_count,
350
+ "low_confidence_rate": f"{rate:.2f}%",
351
+ "predictions_by_label": predictions_by_label,
352
+ }
353
+ finally:
354
+ conn.close()
355
+
356
+
357
+ def export_low_confidence_to_folder(
358
+ output_dir: str = os.path.join("logs", "low_confidence_review"),
359
+ ) -> Dict[str, Any]:
360
+ os.makedirs(output_dir, exist_ok=True)
361
+ flags = get_low_confidence_flags(reviewed=False, limit=10_000)
362
+ exported = 0
363
+ for f in flags:
364
+ request_id = str(f["request_id"])
365
+ ts = str(f["timestamp"]).replace(":", "-")
366
+ filename = f"{ts}_{request_id}.txt"
367
+ path = os.path.join(output_dir, filename)
368
+ if os.path.exists(path):
369
+ continue
370
+ content = "\n".join(
371
+ [
372
+ f"request_id: {request_id}",
373
+ f"timestamp: {f['timestamp']}",
374
+ f"predicted_label: {f['predicted_label']}",
375
+ f"confidence: {float(f['confidence']):.4f}",
376
+ "",
377
+ str(f["input_text"]),
378
+ ]
379
+ )
380
+ with open(path, "w", encoding="utf-8") as out:
381
+ out.write(content)
382
+ exported += 1
383
+ return {"exported": exported, "folder": output_dir}
download_dataset.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""
2
+ download_dataset.py
3
+ ───────────────────
4
+ One-time script to download and cache the AG News dataset from HuggingFace Hub.
5
+
6
+ Run this BEFORE training to ensure the dataset is fully cached locally.
7
+ The dataset is ~30 MB and is stored in:
8
+ Windows: C:\Users\<you>\.cache\huggingface\datasets\ag_news\
9
+
10
+ Usage
11
+ ─────
12
+ python download_dataset.py
13
+ """
14
+ import logging
15
+ from datasets import load_dataset
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format="%(asctime)s %(levelname)s %(message)s",
20
+ datefmt="%H:%M:%S",
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def main() -> None:
26
+ print("\n" + "─" * 60)
27
+ print(" Downloading AG News dataset from HuggingFace Hub")
28
+ print(" Size: ~30 MB | Cached after first download")
29
+ print("─" * 60 + "\n")
30
+
31
+ ds = load_dataset("ag_news")
32
+
33
+ print("\n βœ“ Download complete!")
34
+ print(f"\n Split Count")
35
+ print(f" ────── ──────────")
36
+ print(f" train {len(ds['train']):>10,}")
37
+ print(f" test {len(ds['test']):>10,}")
38
+ print(f"\n Labels: 0=World 1=Sports 2=Business 3=Sci/Tech\n")
39
+
40
+ # Show one sample to confirm it loaded correctly
41
+ sample = ds["train"][42]
42
+ print(" Sample record (index 42):")
43
+ print(f" text : {sample['text'][:110]}…")
44
+ print(f" label : {sample['label']}")
45
+ print()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
ensemble.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ensemble.py
3
+ -----------
4
+ Soft-voting ensemble that combines all trained classifiers.
5
+ Each model's class probabilities are weighted and summed for a final prediction.
6
+
7
+ Usage
8
+ -----
9
+ # Interactive predictions
10
+ python ensemble.py --interactive
11
+
12
+ # Single prediction
13
+ python ensemble.py --text "Tesla stock hits all-time high after earnings beat"
14
+
15
+ # Custom weights (must sum to 1.0)
16
+ python ensemble.py --text "..." --weights 0.05 0.10 0.85
17
+
18
+ # Use optimised weights from optimal_weights.json
19
+ python ensemble.py --text "..." --optimal
20
+ """
21
+ import argparse
22
+ import json
23
+ import logging
24
+ import os
25
+ import sys
26
+ from typing import Dict, List, Optional, Tuple
27
+
28
+ import numpy as np
29
+ import torch
30
+
31
+ from config import CFG
32
+ import traditional_model as tm
33
+ import transformer_model as trm
34
+
35
+ logging.basicConfig(level=logging.WARNING)
36
+
37
+ # Path where optimize_ensemble.py saves the best weights
38
+ _OPTIMAL_WEIGHTS_FILE = os.path.join(
39
+ CFG.outputs_dir, "ensemble_cache", "optimal_weights.json"
40
+ )
41
+
42
+ # Default model names used in this ensemble
43
+ _DEFAULT_MODELS = ["lr", "svm", "distilbert_base_uncased"]
44
+ _DEFAULT_WEIGHTS = [0.10, 0.15, 0.75]
45
+
46
+
47
+ # -- Probability helpers ------------------------------------------------------
48
+
49
+ def _proba_sklearn(text: str, pipeline) -> np.ndarray:
50
+ clf = list(pipeline.named_steps.values())[-1]
51
+ if hasattr(clf, "predict_proba"):
52
+ return pipeline.predict_proba([text])[0]
53
+ # LinearSVC: pseudo-probabilities via softmax over decision scores
54
+ scores = pipeline.decision_function([text])[0]
55
+ scores -= scores.max()
56
+ exp = np.exp(scores)
57
+ return exp / exp.sum()
58
+
59
+
60
+ def _proba_transformer(text: str, model, tokenizer) -> np.ndarray:
61
+ enc = tokenizer(
62
+ text,
63
+ truncation=True,
64
+ max_length=CFG.max_length,
65
+ return_tensors="pt",
66
+ )
67
+ with torch.no_grad():
68
+ logits = model(**enc).logits[0]
69
+ return torch.softmax(logits, dim=-1).numpy()
70
+
71
+
72
+ # -- Optimal weights loader ---------------------------------------------------
73
+
74
+ def load_optimal_weights(
75
+ model_names: List[str],
76
+ ) -> Optional[Dict[str, float]]:
77
+ """
78
+ Attempt to load optimised weights from optimal_weights.json.
79
+
80
+ Returns a dict mapping model_name -> weight, or None if the file is
81
+ missing or malformed.
82
+ """
83
+ if not os.path.exists(_OPTIMAL_WEIGHTS_FILE):
84
+ logging.warning(
85
+ f"[Ensemble] Optimal weights file not found at "
86
+ f"'{_OPTIMAL_WEIGHTS_FILE}'. "
87
+ f"Run: python optimize_ensemble.py"
88
+ )
89
+ return None
90
+ try:
91
+ with open(_OPTIMAL_WEIGHTS_FILE) as fh:
92
+ data = json.load(fh)
93
+ weights = {name: data[name] for name in model_names if name in data}
94
+ if len(weights) != len(model_names):
95
+ logging.warning(
96
+ "[Ensemble] optimal_weights.json does not contain weights "
97
+ "for all requested models. Falling back to manual weights."
98
+ )
99
+ return None
100
+ logging.info(
101
+ f"[Ensemble] Loaded optimal weights (method={data.get('method')}, "
102
+ f"val_f1={data.get('val_f1_macro')}): {weights}"
103
+ )
104
+ return weights
105
+ except Exception as exc:
106
+ logging.warning(
107
+ f"[Ensemble] Could not load optimal_weights.json: {exc}. "
108
+ f"Falling back to manual weights."
109
+ )
110
+ return None
111
+
112
+
113
+ # -- Ensemble class -----------------------------------------------------------
114
+
115
+ class Ensemble:
116
+ """
117
+ Weighted soft-voting ensemble.
118
+
119
+ Parameters
120
+ ----------
121
+ model_weights : list of (model_name, weight) tuples.
122
+ Weights are normalised automatically.
123
+ model_name must match a key in saved_models/
124
+ ('lr', 'svm', 'distilbert_base_uncased', etc.)
125
+ use_optimal_weights : bool, default True
126
+ If True, attempt to load weights from
127
+ outputs/ensemble_cache/optimal_weights.json and
128
+ override the provided model_weights.
129
+ Falls back to the provided weights if the file is
130
+ missing or malformed.
131
+
132
+ Example
133
+ -------
134
+ >>> e = Ensemble([("lr", 0.10), ("svm", 0.15), ("distilbert_base_uncased", 0.75)])
135
+ >>> e.predict("Apple M5 chip breaks all benchmarks")
136
+
137
+ >>> # Load with auto-optimised weights
138
+ >>> e = Ensemble.from_optimal()
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ model_weights: List[Tuple[str, float]],
144
+ use_optimal_weights: bool = True,
145
+ ):
146
+ # Attempt to override with optimised weights
147
+ if use_optimal_weights:
148
+ names = [name for name, _ in model_weights]
149
+ optimal = load_optimal_weights(names)
150
+ if optimal is not None:
151
+ model_weights = [(name, optimal[name]) for name in names]
152
+ print(
153
+ f" [Ensemble] Using optimal weights from "
154
+ f"{_OPTIMAL_WEIGHTS_FILE}"
155
+ )
156
+
157
+ total = sum(w for _, w in model_weights)
158
+ self._weights: Dict[str, float] = {
159
+ name: w / total for name, w in model_weights
160
+ }
161
+ self._loaded: Dict = {}
162
+ self._kinds: Dict = {}
163
+ self._load_all()
164
+
165
+ # -- Class methods --------------------------------------------------------
166
+
167
+ @classmethod
168
+ def from_optimal(cls, fallback_weights: Optional[List[Tuple[str, float]]] = None):
169
+ """
170
+ Build an Ensemble using weights from optimal_weights.json.
171
+
172
+ If the file is missing, falls back to `fallback_weights` (or the
173
+ module-level defaults).
174
+
175
+ Parameters
176
+ ----------
177
+ fallback_weights : list of (model_name, weight) tuples, optional.
178
+ Used when optimal_weights.json cannot be loaded.
179
+
180
+ Returns
181
+ -------
182
+ Ensemble instance
183
+ """
184
+ if fallback_weights is None:
185
+ fallback_weights = list(zip(_DEFAULT_MODELS, _DEFAULT_WEIGHTS))
186
+
187
+ # Try loading the optimal weights file directly
188
+ optimal = load_optimal_weights([name for name, _ in fallback_weights])
189
+ if optimal is not None:
190
+ weights = [(name, optimal[name]) for name, _ in fallback_weights]
191
+ else:
192
+ weights = fallback_weights
193
+
194
+ # Pass use_optimal_weights=False to avoid double-loading
195
+ return cls(weights, use_optimal_weights=False)
196
+
197
+ # -- Internal helpers -----------------------------------------------------
198
+
199
+ def _load_all(self) -> None:
200
+ for name in self._weights:
201
+ print(f" Loading: {name} ...")
202
+ if name in ("lr", "svm"):
203
+ self._loaded[name] = tm.load_model(name)
204
+ self._kinds[name] = "sklearn"
205
+ else:
206
+ # Transformer: name is the directory under saved_models/
207
+ self._loaded[name] = trm.load_model(name)
208
+ self._kinds[name] = "transformer"
209
+ print()
210
+
211
+ def _proba(self, text: str, name: str) -> np.ndarray:
212
+ if self._kinds[name] == "sklearn":
213
+ return _proba_sklearn(text, self._loaded[name])
214
+ model, tokenizer = self._loaded[name]
215
+ return _proba_transformer(text, model, tokenizer)
216
+
217
+ # -- Public API -----------------------------------------------------------
218
+
219
+ def predict(self, text: str) -> Dict:
220
+ """
221
+ Compute the weighted ensemble prediction for a single text.
222
+
223
+ Returns predicted label, ensemble probabilities, and per-model
224
+ debug info.
225
+ """
226
+ combined = np.zeros(CFG.num_labels, dtype=float)
227
+ model_probs = {}
228
+
229
+ for name, weight in self._weights.items():
230
+ p = self._proba(text, name)
231
+ combined += weight * p
232
+ model_probs[name] = {
233
+ CFG.label_names[i]: round(float(p[i]), 4)
234
+ for i in range(CFG.num_labels)
235
+ }
236
+
237
+ pred_id = int(np.argmax(combined))
238
+ return {
239
+ "text": text,
240
+ "label_id": pred_id,
241
+ "label": CFG.label_names[pred_id],
242
+ "confidence": round(float(combined[pred_id]), 4),
243
+ "ensemble_probabilities": {
244
+ CFG.label_names[i]: round(float(combined[i]), 4)
245
+ for i in range(CFG.num_labels)
246
+ },
247
+ "per_model": model_probs,
248
+ }
249
+
250
+ @property
251
+ def weights(self) -> Dict[str, float]:
252
+ """Return the normalised per-model weights."""
253
+ return dict(self._weights)
254
+
255
+
256
+ # -- Display ------------------------------------------------------------------
257
+
258
+ def display(result: Dict) -> None:
259
+ snippet = result["text"][:88] + ("..." if len(result["text"]) > 88 else "")
260
+ print(f"\n Input : {snippet}")
261
+ print(f" Label : [{result['label_id']}] {result['label']}")
262
+ print(f" Confidence : {result['confidence']:.4f}")
263
+ print(" Ensemble Scores:")
264
+ for label, prob in sorted(
265
+ result["ensemble_probabilities"].items(),
266
+ key=lambda x: x[1],
267
+ reverse=True,
268
+ ):
269
+ bar = "#" * round(prob * 28)
270
+ print(f" {label:<12} [{bar:<28}] {prob:.4f}")
271
+ print()
272
+
273
+
274
+ # -- CLI ----------------------------------------------------------------------
275
+
276
+ def main() -> None:
277
+ parser = argparse.ArgumentParser(description="Ensemble Document Classifier")
278
+ parser.add_argument(
279
+ "--text", type=str, default=None, help="Single text to classify"
280
+ )
281
+ parser.add_argument(
282
+ "--interactive",
283
+ action="store_true",
284
+ help="Enter interactive prediction loop",
285
+ )
286
+ parser.add_argument(
287
+ "--weights",
288
+ nargs=3,
289
+ type=float,
290
+ default=_DEFAULT_WEIGHTS,
291
+ metavar=("LR_W", "SVM_W", "DISTILBERT_W"),
292
+ help="Weights for LR, SVM, DistilBERT (auto-normalised)",
293
+ )
294
+ parser.add_argument(
295
+ "--optimal",
296
+ action="store_true",
297
+ default=False,
298
+ help="Load weights from optimal_weights.json (ignores --weights)",
299
+ )
300
+ parser.add_argument(
301
+ "--no-optimal",
302
+ dest="optimal",
303
+ action="store_false",
304
+ help="Disable automatic loading of optimal weights",
305
+ )
306
+ args = parser.parse_args()
307
+
308
+ print("\n Building Ensemble ...")
309
+ model_weights = [
310
+ ("lr", args.weights[0]),
311
+ ("svm", args.weights[1]),
312
+ ("distilbert_base_uncased", args.weights[2]),
313
+ ]
314
+
315
+ # --optimal flag forces loading optimal weights; otherwise honour
316
+ # use_optimal_weights=True default (auto-load if file exists)
317
+ use_optimal = True # always attempt; falls back gracefully
318
+ if args.optimal:
319
+ ensemble = Ensemble.from_optimal(fallback_weights=model_weights)
320
+ else:
321
+ ensemble = Ensemble(model_weights, use_optimal_weights=use_optimal)
322
+
323
+ print(f" Ensemble ready. Active weights: {ensemble.weights}\n")
324
+
325
+ if args.interactive:
326
+ print(" Ensemble -- Interactive Mode | Type 'q' to exit\n")
327
+ while True:
328
+ try:
329
+ text = input(" >> ").strip()
330
+ except (KeyboardInterrupt, EOFError):
331
+ print("\n Bye.")
332
+ break
333
+ if not text:
334
+ continue
335
+ if text.lower() in {"q", "quit", "exit"}:
336
+ print(" Bye.")
337
+ break
338
+ display(ensemble.predict(text))
339
+
340
+ elif args.text:
341
+ display(ensemble.predict(args.text))
342
+
343
+ else:
344
+ parser.print_help()
345
+ sys.exit(1)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()
error_analysis.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ error_analysis.py
3
+ ─────────────────
4
+ Detailed analysis of model errors on the test set.
5
+ Generates confidence distributions, per-class accuracy bars,
6
+ and a CSV of the hardest misclassified examples.
7
+
8
+ Usage
9
+ ─────
10
+ python error_analysis.py --model roberta-base
11
+ python error_analysis.py --model lr
12
+ python error_analysis.py --model svm
13
+ """
14
+ import argparse
15
+ import logging
16
+ import os
17
+ from typing import List, Tuple
18
+
19
+ import matplotlib
20
+ matplotlib.use('Agg')
21
+ import matplotlib.pyplot as plt
22
+ import numpy as np
23
+ import pandas as pd
24
+ import seaborn as sns
25
+ import torch
26
+ from sklearn.metrics import accuracy_score
27
+
28
+ from config import CFG
29
+ from data_loader import load_test_only
30
+ import traditional_model as tm
31
+ import transformer_model as trm
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format="%(asctime)s %(levelname)-8s %(message)s",
36
+ datefmt="%H:%M:%S",
37
+ )
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ── Probability extraction ─────────────────────────────────────────────────────
42
+
43
+ def _proba_sklearn(text_list: List[str], pipeline) -> np.ndarray:
44
+ clf = list(pipeline.named_steps.values())[-1]
45
+ if hasattr(clf, "predict_proba"):
46
+ return pipeline.predict_proba(text_list)
47
+ # LinearSVC: convert decision scores to pseudo-probabilities via softmax
48
+ scores = pipeline.decision_function(text_list)
49
+ scores -= scores.max(axis=1, keepdims=True)
50
+ exp = np.exp(scores)
51
+ return exp / exp.sum(axis=1, keepdims=True)
52
+
53
+
54
+ def _proba_transformer(text_list: List[str], model, tokenizer) -> np.ndarray:
55
+ all_probs = []
56
+ batch_size = 32
57
+ for i in range(0, len(text_list), batch_size):
58
+ batch = text_list[i : i + batch_size]
59
+ enc = tokenizer(batch, truncation=True, max_length=CFG.max_length,
60
+ padding=True, return_tensors="pt")
61
+ with torch.no_grad():
62
+ logits = model(**enc).logits
63
+ all_probs.append(torch.softmax(logits, dim=-1).numpy())
64
+ return np.vstack(all_probs)
65
+
66
+
67
+ # ── Main analysis ─────────────────────────────────────────────────────────────
68
+
69
+ def analyse(model_name: str, save_dir: str = None) -> pd.DataFrame:
70
+ """
71
+ Full error analysis pipeline.
72
+
73
+ Returns
74
+ -------
75
+ DataFrame of all misclassified examples.
76
+ """
77
+ logger.info("Loading test set …")
78
+ X_test, y_test = load_test_only()
79
+
80
+ logger.info(f"Running predictions with: {model_name}")
81
+ if model_name in ("lr", "svm"):
82
+ pipeline = tm.load_model(model_name)
83
+ proba = _proba_sklearn(X_test, pipeline)
84
+ preds = proba.argmax(axis=1).tolist()
85
+ else:
86
+ model, tokenizer = trm.load_model(model_name)
87
+ proba = _proba_transformer(X_test, model, tokenizer)
88
+ preds = proba.argmax(axis=1).tolist()
89
+
90
+ acc = accuracy_score(y_test, preds)
91
+ logger.info(f"Test accuracy: {acc * 100:.2f}%")
92
+
93
+ # Build analysis DataFrame
94
+ df = pd.DataFrame({
95
+ "text": X_test,
96
+ "true_label": [CFG.label_names[y] for y in y_test],
97
+ "pred_label": [CFG.label_names[p] for p in preds],
98
+ "confidence": proba.max(axis=1),
99
+ "correct": [int(y) == int(p) for y, p in zip(y_test, preds)],
100
+ })
101
+ for i, name in enumerate(CFG.label_names):
102
+ df[f"prob_{name}"] = proba[:, i]
103
+
104
+ errors = df[~df["correct"].astype(bool)]
105
+ corrects = df[df["correct"].astype(bool)]
106
+
107
+ # ── Console report ───────────────────────────────────────────────────────
108
+ print("\n" + "═" * 60)
109
+ print(f" ERROR ANALYSIS β€” {model_name.upper()}")
110
+ print("═" * 60)
111
+ print(f" Total : {len(df):,}")
112
+ print(f" Correct : {len(corrects):,} ({len(corrects)/len(df)*100:.2f}%)")
113
+ print(f" Errors : {len(errors):,} ({len(errors)/len(df)*100:.2f}%)")
114
+
115
+ print("\n Errors by true class:")
116
+ for label in CFG.label_names:
117
+ n = len(errors[errors["true_label"] == label])
118
+ print(f" {label:<12} {n:>4} errors")
119
+
120
+ print("\n Top confused pairs (True β†’ Predicted):")
121
+ confused = (
122
+ errors.groupby(["true_label", "pred_label"])
123
+ .size()
124
+ .sort_values(ascending=False)
125
+ .head(6)
126
+ )
127
+ for (true, pred), count in confused.items():
128
+ print(f" {true:<12} β†’ {pred:<12} {count:>4} times")
129
+
130
+ print("\n 5 Hardest Errors (lowest confidence):")
131
+ for _, row in errors.nsmallest(5, "confidence").iterrows():
132
+ snippet = row["text"][:75] + "…"
133
+ print(f" [{row['true_label']} β†’ {row['pred_label']} conf={row['confidence']:.3f}]")
134
+ print(f" {snippet}\n")
135
+
136
+ # ── Plots ──────────────���─────────────────────────────────────────────────
137
+ _plot_analysis(df, model_name, save_dir)
138
+
139
+ # ── Save CSV ─────────────────────────────────────────────────────────────
140
+ if save_dir:
141
+ os.makedirs(save_dir, exist_ok=True)
142
+ csv_path = os.path.join(save_dir, f"errors_{model_name.replace('-','_')}.csv")
143
+ errors.to_csv(csv_path, index=False)
144
+ logger.info(f"Error CSV β†’ {csv_path}")
145
+
146
+ return errors
147
+
148
+
149
+ def _plot_analysis(df: pd.DataFrame, model_name: str, save_dir: str = None) -> None:
150
+ """Two-panel figure: confidence distribution + per-class accuracy bars."""
151
+ fig, axes = plt.subplots(1, 2, figsize=(13, 5))
152
+ fig.suptitle(f"Error Analysis β€” {model_name}", fontsize=14, fontweight="bold")
153
+
154
+ # Panel 1: Confidence histograms
155
+ correct_conf = df[df["correct"].astype(bool)]["confidence"]
156
+ error_conf = df[~df["correct"].astype(bool)]["confidence"]
157
+ axes[0].hist(correct_conf, bins=30, alpha=0.75, color="#27ae60",
158
+ label=f"Correct (n={len(correct_conf):,})")
159
+ axes[0].hist(error_conf, bins=30, alpha=0.75, color="#e74c3c",
160
+ label=f"Incorrect (n={len(error_conf):,})")
161
+ axes[0].set_xlabel("Prediction Confidence", fontsize=11)
162
+ axes[0].set_ylabel("Count", fontsize=11)
163
+ axes[0].set_title("Confidence Distribution", fontsize=12)
164
+ axes[0].legend(fontsize=10)
165
+ axes[0].axvline(correct_conf.mean(), color="#27ae60", linestyle="--", linewidth=1.2,
166
+ label=f"Mean correct: {correct_conf.mean():.3f}")
167
+ axes[0].axvline(error_conf.mean(), color="#e74c3c", linestyle="--", linewidth=1.2,
168
+ label=f"Mean error: {error_conf.mean():.3f}")
169
+
170
+ # Panel 2: Per-class accuracy
171
+ colours = ["#3498db", "#27ae60", "#e67e22", "#9b59b6"]
172
+ class_accs = [
173
+ df[df["true_label"] == lbl]["correct"].astype(float).mean() * 100
174
+ for lbl in CFG.label_names
175
+ ]
176
+ bars = axes[1].bar(CFG.label_names, class_accs, color=colours,
177
+ edgecolor="white", linewidth=1.5)
178
+ axes[1].set_ylim(80, 100)
179
+ axes[1].set_xlabel("Class", fontsize=11)
180
+ axes[1].set_ylabel("Accuracy (%)", fontsize=11)
181
+ axes[1].set_title("Per-Class Accuracy", fontsize=12)
182
+ for bar, acc in zip(bars, class_accs):
183
+ axes[1].text(bar.get_x() + bar.get_width() / 2,
184
+ bar.get_height() + 0.3,
185
+ f"{acc:.1f}%", ha="center", va="bottom", fontsize=11, fontweight="bold")
186
+
187
+ plt.tight_layout()
188
+
189
+ if save_dir:
190
+ os.makedirs(save_dir, exist_ok=True)
191
+ path = os.path.join(save_dir, f"analysis_{model_name.replace('-','_')}.png")
192
+ plt.savefig(path, dpi=150)
193
+ logger.info(f"Plot β†’ {path}")
194
+
195
+ plt.show()
196
+ plt.close(fig)
197
+
198
+
199
+ def main() -> None:
200
+ parser = argparse.ArgumentParser(description="Document classifier error analysis")
201
+ parser.add_argument(
202
+ "--model", default="roberta-base",
203
+ help="Model name: 'lr', 'svm', or transformer checkpoint (e.g. 'roberta-base')"
204
+ )
205
+ args = parser.parse_args()
206
+ save_dir = os.path.join(CFG.outputs_dir, "error_analysis")
207
+ analyse(args.model, save_dir=save_dir)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
hyperparameter_search.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import os
4
+ import warnings
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Literal, Tuple
7
+
8
+ import joblib
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import optuna
12
+ import seaborn as sns
13
+ from sklearn.exceptions import ConvergenceWarning
14
+ from sklearn.feature_extraction.text import TfidfVectorizer
15
+ from sklearn.linear_model import LogisticRegression
16
+ from sklearn.metrics import accuracy_score, confusion_matrix, f1_score
17
+ from sklearn.pipeline import Pipeline
18
+ from sklearn.svm import LinearSVC
19
+ from tqdm import tqdm
20
+
21
+ from config import CFG
22
+ from data_loader import get_raw_splits, load_ag_news
23
+
24
+ ModelType = Literal["lr", "svm"]
25
+
26
+ BASELINE_TEST_ACCURACY: Dict[ModelType, float] = {"lr": 0.9045, "svm": 0.9089}
27
+ DEFAULT_MAX_TRAIN = 40_000
28
+
29
+
30
+ def _storage_url() -> str:
31
+ db_path = os.path.abspath(os.path.join(CFG.outputs_dir, "optuna_studies.db"))
32
+ return f"sqlite:///{Path(db_path).as_posix()}"
33
+
34
+
35
+ def _hp_outputs_dir() -> str:
36
+ path = os.path.join(CFG.outputs_dir, "hp_search")
37
+ os.makedirs(path, exist_ok=True)
38
+ return path
39
+
40
+
41
+ def _trial_params(trial: optuna.trial.Trial, model_type: ModelType) -> Dict[str, Any]:
42
+ params: Dict[str, Any] = {}
43
+ params["max_features"] = trial.suggest_int(
44
+ "max_features", 20_000, 100_000, step=10_000
45
+ )
46
+ params["ngram_min"] = trial.suggest_int("ngram_min", 1, 1)
47
+ params["ngram_max"] = trial.suggest_int("ngram_max", 1, 3)
48
+ params["min_df"] = trial.suggest_int("min_df", 1, 5)
49
+ params["sublinear_tf"] = trial.suggest_categorical("sublinear_tf", [True, False])
50
+
51
+ if model_type == "lr":
52
+ params["C"] = trial.suggest_float("C", 0.1, 20.0, log=True)
53
+ params["solver"] = trial.suggest_categorical("solver", ["saga", "lbfgs"])
54
+ else:
55
+ params["C"] = trial.suggest_float("C", 0.01, 10.0, log=True)
56
+
57
+ return params
58
+
59
+
60
+ def _build_pipeline(model_type: ModelType, params: Dict[str, Any]) -> Pipeline:
61
+ tfidf = TfidfVectorizer(
62
+ max_features=int(params["max_features"]),
63
+ ngram_range=(int(params["ngram_min"]), int(params["ngram_max"])),
64
+ sublinear_tf=bool(params["sublinear_tf"]),
65
+ min_df=int(params["min_df"]),
66
+ strip_accents="unicode",
67
+ analyzer="word",
68
+ token_pattern=r"\w{1,}",
69
+ dtype=np.float32,
70
+ )
71
+
72
+ if model_type == "lr":
73
+ clf = LogisticRegression(
74
+ C=float(params["C"]),
75
+ solver=str(params["solver"]),
76
+ max_iter=2_000,
77
+ n_jobs=-1,
78
+ random_state=CFG.seed,
79
+ )
80
+ else:
81
+ clf = LinearSVC(
82
+ C=float(params["C"]),
83
+ max_iter=3_000,
84
+ random_state=CFG.seed,
85
+ )
86
+
87
+ return Pipeline([("tfidf", tfidf), (model_type, clf)])
88
+
89
+
90
+ def _make_objective(
91
+ model_type: ModelType,
92
+ X_train,
93
+ y_train,
94
+ X_val,
95
+ y_val,
96
+ ):
97
+ def objective(trial: optuna.trial.Trial) -> float:
98
+ params = _trial_params(trial, model_type)
99
+ pipeline = _build_pipeline(model_type, params)
100
+
101
+ with warnings.catch_warnings():
102
+ warnings.filterwarnings("ignore", category=ConvergenceWarning)
103
+ warnings.filterwarnings("ignore", category=FutureWarning)
104
+ pipeline.fit(X_train, y_train)
105
+
106
+ val_preds = pipeline.predict(X_val)
107
+ return float(f1_score(y_val, val_preds, average="macro"))
108
+
109
+ return objective
110
+
111
+
112
+ def _save_confusion_matrix(
113
+ cm,
114
+ title: str,
115
+ save_path: str,
116
+ ) -> None:
117
+ fig, ax = plt.subplots(figsize=(7, 6))
118
+ sns.heatmap(
119
+ cm,
120
+ annot=True,
121
+ fmt="d",
122
+ cmap="Blues",
123
+ xticklabels=CFG.label_names,
124
+ yticklabels=CFG.label_names,
125
+ linewidths=0.5,
126
+ ax=ax,
127
+ )
128
+ ax.set_xlabel("Predicted Label", fontsize=11)
129
+ ax.set_ylabel("True Label", fontsize=11)
130
+ ax.set_title(title, fontsize=13, fontweight="bold")
131
+ plt.tight_layout()
132
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
133
+ plt.savefig(save_path, dpi=150)
134
+ plt.close(fig)
135
+
136
+
137
+ def _save_optuna_plots(study: optuna.Study, model_type: ModelType) -> Tuple[str, str]:
138
+ out_dir = _hp_outputs_dir()
139
+ import optuna.visualization.matplotlib as ovm
140
+
141
+ with warnings.catch_warnings():
142
+ warnings.filterwarnings("ignore", category=optuna.exceptions.ExperimentalWarning)
143
+ warnings.filterwarnings("ignore", category=UserWarning)
144
+ ax1 = ovm.plot_parallel_coordinate(study)
145
+ fig1 = ax1.figure
146
+ fig1.tight_layout()
147
+ p1 = os.path.join(out_dir, f"{model_type}_parallel_coords.png")
148
+ fig1.savefig(p1, dpi=150)
149
+ plt.close(fig1)
150
+
151
+ p2 = os.path.join(out_dir, f"{model_type}_param_importance.png")
152
+ try:
153
+ with warnings.catch_warnings():
154
+ warnings.filterwarnings("ignore", category=optuna.exceptions.ExperimentalWarning)
155
+ warnings.filterwarnings("ignore", category=UserWarning)
156
+ ax2 = ovm.plot_param_importances(study)
157
+ fig2 = ax2.figure
158
+ fig2.tight_layout()
159
+ fig2.savefig(p2, dpi=150)
160
+ plt.close(fig2)
161
+ except ValueError:
162
+ p2 = ""
163
+
164
+ return p1, p2
165
+
166
+
167
+ def _create_or_reset_study(
168
+ study_name: str,
169
+ storage: str,
170
+ resume: bool,
171
+ ) -> optuna.Study:
172
+ sampler = optuna.samplers.TPESampler(seed=CFG.seed)
173
+
174
+ if not resume:
175
+ try:
176
+ optuna.delete_study(study_name=study_name, storage=storage)
177
+ except KeyError:
178
+ pass
179
+
180
+ return optuna.create_study(
181
+ direction="maximize",
182
+ study_name=study_name,
183
+ storage=storage,
184
+ load_if_exists=True,
185
+ sampler=sampler,
186
+ )
187
+
188
+
189
+ def _run_model_search(
190
+ model_type: ModelType,
191
+ n_trials_total: int,
192
+ full: bool,
193
+ resume: bool,
194
+ ) -> Dict[str, Any]:
195
+ max_train = None if full else DEFAULT_MAX_TRAIN
196
+
197
+ dataset = load_ag_news(max_train=max_train, max_eval=None, max_test=None)
198
+ X_train, y_train, X_val, y_val, X_test, y_test = get_raw_splits(dataset)
199
+
200
+ storage = _storage_url()
201
+ study_name = f"{model_type}_hyperparams"
202
+ study = _create_or_reset_study(study_name=study_name, storage=storage, resume=resume)
203
+
204
+ remaining = max(0, int(n_trials_total) - len(study.trials))
205
+ if remaining == 0:
206
+ best_params = study.best_params
207
+ best_val_f1 = float(study.best_value)
208
+ else:
209
+ objective = _make_objective(model_type, X_train, y_train, X_val, y_val)
210
+ pbar = tqdm(total=remaining, desc=f"{model_type.upper()} Optuna", unit="trial")
211
+
212
+ def _cb(_study: optuna.Study, _trial: optuna.trial.FrozenTrial) -> None:
213
+ pbar.update(1)
214
+
215
+ study.optimize(
216
+ objective,
217
+ n_trials=remaining,
218
+ callbacks=[_cb],
219
+ gc_after_trial=True,
220
+ show_progress_bar=False,
221
+ )
222
+ pbar.close()
223
+ best_params = study.best_params
224
+ best_val_f1 = float(study.best_value)
225
+
226
+ pipeline = _build_pipeline(model_type, best_params)
227
+ with warnings.catch_warnings():
228
+ warnings.filterwarnings("ignore", category=ConvergenceWarning)
229
+ warnings.filterwarnings("ignore", category=FutureWarning)
230
+ if full:
231
+ X_final = list(X_train) + list(X_val)
232
+ y_final = list(y_train) + list(y_val)
233
+ pipeline.fit(X_final, y_final)
234
+ else:
235
+ pipeline.fit(X_train, y_train)
236
+
237
+ test_preds = pipeline.predict(X_test)
238
+ test_acc = float(accuracy_score(y_test, test_preds))
239
+ cm = confusion_matrix(y_test, test_preds)
240
+
241
+ out_dir = _hp_outputs_dir()
242
+ cm_path = os.path.join(out_dir, f"{model_type}_confusion_matrix.png")
243
+ _save_confusion_matrix(
244
+ cm,
245
+ title=f"Optimized {model_type.upper()} β€” Confusion Matrix",
246
+ save_path=cm_path,
247
+ )
248
+
249
+ model_path = os.path.join(CFG.models_dir, f"traditional_{model_type}_optimized.joblib")
250
+ joblib.dump(pipeline, model_path)
251
+
252
+ p1, p2 = _save_optuna_plots(study, model_type=model_type)
253
+
254
+ return {
255
+ "model": model_type,
256
+ "study_name": study_name,
257
+ "storage": storage,
258
+ "max_train": max_train,
259
+ "best_val_f1_macro": best_val_f1,
260
+ "best_params": best_params,
261
+ "test_accuracy": test_acc,
262
+ "confusion_matrix_path": cm_path,
263
+ "model_path": model_path,
264
+ "plot_parallel_coords_path": p1,
265
+ "plot_param_importance_path": p2,
266
+ }
267
+
268
+
269
+ def _print_summary(results: Dict[ModelType, Dict[str, Any]]) -> None:
270
+ def _fmt_params(d: Dict[str, Any]) -> str:
271
+ s = json.dumps(d, sort_keys=True)
272
+ return s if len(s) <= 110 else s[:107] + "..."
273
+
274
+ headers = ["Model", "Best Val F1", "Best Params", "Improvement vs Baseline"]
275
+ rows = []
276
+ for model_type, r in results.items():
277
+ baseline = BASELINE_TEST_ACCURACY[model_type]
278
+ improvement_pp = (float(r["test_accuracy"]) - baseline) * 100.0
279
+ rows.append(
280
+ [
281
+ model_type.upper(),
282
+ f"{float(r['best_val_f1_macro']):.4f}",
283
+ _fmt_params(r["best_params"]),
284
+ f"{improvement_pp:+.2f} pp",
285
+ ]
286
+ )
287
+
288
+ col_widths = [
289
+ max(len(headers[i]), max(len(str(row[i])) for row in rows)) for i in range(4)
290
+ ]
291
+ fmt = " | ".join(f"{{:<{w}}}" for w in col_widths)
292
+ sep = "-+-".join("-" * w for w in col_widths)
293
+
294
+ print("\n" + fmt.format(*headers))
295
+ print(sep)
296
+ for row in rows:
297
+ print(fmt.format(*row))
298
+
299
+
300
+ def main() -> None:
301
+ parser = argparse.ArgumentParser(description="Optuna hyperparameter search for TF-IDF + LR/SVM.")
302
+ parser.add_argument(
303
+ "--model",
304
+ choices=["lr", "svm", "all"],
305
+ default="all",
306
+ help="Which model study to run.",
307
+ )
308
+ parser.add_argument(
309
+ "--n-trials",
310
+ type=int,
311
+ default=30,
312
+ help="Total trials per model study (respects --resume).",
313
+ )
314
+ parser.add_argument(
315
+ "--full",
316
+ action="store_true",
317
+ help="Use full 120K training examples (much slower per trial).",
318
+ )
319
+ parser.add_argument(
320
+ "--resume",
321
+ action="store_true",
322
+ help="Resume from the SQLite study DB (otherwise, resets the study).",
323
+ )
324
+ args = parser.parse_args()
325
+
326
+ optuna.logging.set_verbosity(optuna.logging.WARNING)
327
+
328
+ train_note = (
329
+ "FULL (120K)"
330
+ if args.full
331
+ else f"CAPPED ({DEFAULT_MAX_TRAIN:,} max_train for i3 CPU)"
332
+ )
333
+ print(
334
+ f"[HP Search] Train size: {train_note}. "
335
+ f"Override with --full to use the complete dataset."
336
+ )
337
+ print(f"[HP Search] Storage: {_storage_url()}")
338
+
339
+ results: Dict[ModelType, Dict[str, Any]] = {}
340
+ if args.model in ("lr", "all"):
341
+ results["lr"] = _run_model_search(
342
+ model_type="lr",
343
+ n_trials_total=args.n_trials,
344
+ full=args.full,
345
+ resume=args.resume,
346
+ )
347
+ if args.model in ("svm", "all"):
348
+ results["svm"] = _run_model_search(
349
+ model_type="svm",
350
+ n_trials_total=args.n_trials,
351
+ full=args.full,
352
+ resume=args.resume,
353
+ )
354
+
355
+ _print_summary(results)
356
+
357
+ out_dir = _hp_outputs_dir()
358
+ best_params_path = os.path.join(out_dir, "best_params.json")
359
+ with open(best_params_path, "w", encoding="utf-8") as f:
360
+ json.dump(results, f, indent=2)
361
+ print(f"\n[HP Search] Best params -> {best_params_path}")
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
predict.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ predict.py
3
+ ──────────
4
+ Run inference on new text with any trained classifier.
5
+
6
+ Usage
7
+ ─────
8
+ # Single prediction β€” traditional models
9
+ python predict.py --model lr --text "Federal Reserve cuts rates to near zero"
10
+ python predict.py --model svm --text "Ronaldo scores hat-trick in Champions League"
11
+
12
+ # Single prediction β€” transformer (FP32)
13
+ python predict.py --model transformer --text "NASA launches James Webb successor"
14
+
15
+ # Single prediction β€” INT8 quantized transformer (fast CPU inference)
16
+ python predict.py --model transformer_quantized --text "Federal Reserve raises rates"
17
+ python predict.py --model transformer_quantized --checkpoint roberta-base --text "..."
18
+
19
+ # Interactive loop
20
+ python predict.py --model transformer --interactive
21
+ python predict.py --model transformer_quantized --interactive
22
+ python predict.py --model lr --interactive
23
+ """
24
+ import argparse
25
+ import logging
26
+ import sys
27
+ from typing import Dict, Optional
28
+
29
+ import numpy as np
30
+ import torch
31
+
32
+ from config import CFG
33
+ import traditional_model as tm
34
+ import transformer_model as trm
35
+
36
+ # Suppress INFO logs during interactive prediction
37
+ logging.basicConfig(level=logging.WARNING)
38
+
39
+
40
+ # ── Prediction functions ──────────────────────────────────────────────────────
41
+
42
+ def predict_traditional(text: str, model_name: str) -> Dict:
43
+ """Run a single prediction with a saved sklearn pipeline."""
44
+ pipeline = tm.load_model(model_name)
45
+ pred_id = int(pipeline.predict([text])[0])
46
+
47
+ result: Dict = {
48
+ "text": text,
49
+ "label_id": pred_id,
50
+ "label": CFG.label_names[pred_id],
51
+ }
52
+
53
+ # Logistic Regression supports predict_proba; LinearSVC does not
54
+ clf = pipeline.named_steps[model_name]
55
+ if hasattr(clf, "predict_proba"):
56
+ probs = pipeline.predict_proba([text])[0]
57
+ result["probabilities"] = {
58
+ CFG.label_names[i]: round(float(p), 4)
59
+ for i, p in enumerate(probs)
60
+ }
61
+
62
+ return result
63
+
64
+
65
+ def predict_transformer(
66
+ text: str,
67
+ model=None,
68
+ tokenizer=None,
69
+ ) -> Dict:
70
+ """Run a single prediction with a fine-tuned transformer (FP32, MPS/CPU)."""
71
+ if model is None or tokenizer is None:
72
+ model, tokenizer = trm.load_model()
73
+
74
+ encoding = tokenizer(
75
+ text,
76
+ truncation=True,
77
+ max_length=CFG.max_length,
78
+ return_tensors="pt",
79
+ )
80
+
81
+ with torch.no_grad():
82
+ logits = model(**encoding).logits[0]
83
+
84
+ probs = torch.softmax(logits, dim=-1).numpy()
85
+ pred_id = int(np.argmax(probs))
86
+
87
+ return {
88
+ "text": text,
89
+ "label_id": pred_id,
90
+ "label": CFG.label_names[pred_id],
91
+ "probabilities": {
92
+ CFG.label_names[i]: round(float(p), 4)
93
+ for i, p in enumerate(probs)
94
+ },
95
+ }
96
+
97
+
98
+ def predict_transformer_quantized(
99
+ text: str,
100
+ model=None,
101
+ tokenizer=None,
102
+ is_quantized: bool = True,
103
+ ) -> Dict:
104
+ """Run inference with the INT8 quantized model (CPU only)."""
105
+ if model is None or tokenizer is None:
106
+ raise ValueError("Pass a pre-loaded model and tokenizer.")
107
+
108
+ encoding = tokenizer(
109
+ text,
110
+ truncation=True,
111
+ max_length=CFG.max_length,
112
+ return_tensors="pt",
113
+ )
114
+ # INT8 quantized kernels only run on CPU
115
+ encoding = {k: v.to("cpu") for k, v in encoding.items()}
116
+
117
+ with torch.inference_mode():
118
+ logits = model(**encoding).logits[0]
119
+
120
+ probs = torch.softmax(logits, dim=-1).numpy()
121
+ pred_id = int(np.argmax(probs))
122
+
123
+ label = "[INT8] " if is_quantized else "[FP32] "
124
+ return {
125
+ "text": text,
126
+ "label_id": pred_id,
127
+ "label": CFG.label_names[pred_id],
128
+ "model_type": label.strip(),
129
+ "probabilities": {
130
+ CFG.label_names[i]: round(float(p), 4)
131
+ for i, p in enumerate(probs)
132
+ },
133
+ }
134
+
135
+
136
+ # ── Display ───────────────────────────────────────────────────────────────────
137
+
138
+ def display_result(result: Dict) -> None:
139
+ """Print a formatted prediction result to the terminal."""
140
+ snippet = result["text"]
141
+ if len(snippet) > 90:
142
+ snippet = snippet[:90] + "…"
143
+
144
+ model_tag = f" [{result['model_type']}]" if "model_type" in result else ""
145
+ print(f"\n Input : {snippet}")
146
+ print(f" Label : [{result['label_id']}] {result['label']}{model_tag}")
147
+
148
+ if "probabilities" in result:
149
+ print(" Scores :")
150
+ sorted_probs = sorted(
151
+ result["probabilities"].items(),
152
+ key=lambda x: x[1],
153
+ reverse=True,
154
+ )
155
+ for label, prob in sorted_probs:
156
+ bar = "β–ˆ" * round(prob * 28)
157
+ blank = " " * (28 - round(prob * 28))
158
+ print(f" {label:<12} [{bar}{blank}] {prob:.4f}")
159
+ print()
160
+
161
+
162
+ # ── CLI ───────────────────────────────────────────────────────────────────────
163
+
164
+ def build_parser() -> argparse.ArgumentParser:
165
+ p = argparse.ArgumentParser(
166
+ description="Document Classifier β€” Inference",
167
+ formatter_class=argparse.RawDescriptionHelpFormatter,
168
+ )
169
+ p.add_argument(
170
+ "--model",
171
+ default="transformer",
172
+ help="Which saved model to load: lr, svm, transformer, transformer_quantized, or a specific variant like distilbert_quantized (default: transformer)",
173
+ )
174
+ p.add_argument(
175
+ "--checkpoint",
176
+ type=str,
177
+ default="distilbert-base-uncased",
178
+ help="HuggingFace checkpoint name for transformer/transformer_quantized "
179
+ "(default: distilbert-base-uncased)",
180
+ )
181
+ p.add_argument(
182
+ "--text",
183
+ type=str,
184
+ default=None,
185
+ help="Single text string to classify",
186
+ )
187
+ p.add_argument(
188
+ "--interactive",
189
+ action="store_true",
190
+ help="Enter an interactive prediction loop",
191
+ )
192
+ return p
193
+
194
+
195
+ def main() -> None:
196
+ args = build_parser().parse_args()
197
+
198
+ # Normalise model selection and automatically extract checkpoint if specified
199
+ model_lower = args.model.lower()
200
+ is_quant = "quant" in model_lower or "int8" in model_lower
201
+
202
+ if "distilbert" in model_lower:
203
+ args.checkpoint = "distilbert-base-uncased"
204
+ elif "roberta" in model_lower:
205
+ args.checkpoint = "roberta-base"
206
+ elif "bert" in model_lower:
207
+ args.checkpoint = "bert-base-uncased"
208
+
209
+ if is_quant:
210
+ args.model = "transformer_quantized"
211
+ elif args.model not in ["lr", "svm"]:
212
+ args.model = "transformer"
213
+
214
+ # Pre-load the model once (avoids reloading on every prediction in loops)
215
+ cached_model = None
216
+ cached_tokenizer = None
217
+ cached_quantized = False
218
+
219
+ if args.model == "transformer":
220
+ print(f" Loading transformer model ({args.checkpoint}) …")
221
+ cached_model, cached_tokenizer = trm.load_model(args.checkpoint)
222
+ print(" Model ready.\n")
223
+
224
+ elif args.model == "transformer_quantized":
225
+ print(f" Loading quantized model ({args.checkpoint}) …")
226
+ cached_model, cached_tokenizer, cached_quantized = trm.load_quantized_model(
227
+ args.checkpoint
228
+ )
229
+ tag = "INT8 quantized" if cached_quantized else "FP32 (INT8 not found, fell back)"
230
+ print(f" Model ready β€” {tag}.\n")
231
+
232
+ def _predict(text: str) -> Dict:
233
+ if args.model == "transformer":
234
+ return predict_transformer(text, cached_model, cached_tokenizer)
235
+ if args.model == "transformer_quantized":
236
+ return predict_transformer_quantized(
237
+ text, cached_model, cached_tokenizer, is_quantized=cached_quantized
238
+ )
239
+ return predict_traditional(text, args.model)
240
+
241
+ if args.interactive:
242
+ print(" Document Classifier β€” Interactive Mode")
243
+ print(" Type text and press Enter. Type 'q' or 'quit' to exit.\n")
244
+ while True:
245
+ try:
246
+ text = input(" >> ").strip()
247
+ except (KeyboardInterrupt, EOFError):
248
+ print("\n Exiting.")
249
+ break
250
+ if not text:
251
+ continue
252
+ if text.lower() in {"q", "quit", "exit"}:
253
+ print(" Goodbye.")
254
+ break
255
+ display_result(_predict(text))
256
+
257
+ elif args.text:
258
+ display_result(_predict(args.text))
259
+
260
+ else:
261
+ print(" Error: provide --text <string> or use --interactive.\n")
262
+ build_parser().print_help()
263
+ sys.exit(1)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.black]
2
+ line-length = 100
3
+ target-version = ["py311"]
4
+ exclude = '''
5
+ /(
6
+ \.venv | frontend | outputs | data | saved_models | \.git
7
+ )/
8
+ '''
9
+
10
+ [tool.isort]
11
+ profile = "black"
12
+ line_length = 100
13
+ skip_glob = [".venv/*", "frontend/*"]
14
+
15
+ [tool.flake8]
16
+ max-line-length = 100
17
+ extend-ignore = ["E203", "W503"]
18
+ exclude = [".venv", "frontend", "__pycache__", "outputs", "data", "saved_models"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+ markers = [
23
+ "slow: marks tests that load real datasets or ML models",
24
+ ]
25
+ addopts = "-v --tb=short"
pytest.ini ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ markers =
4
+ slow: marks tests that load real datasets (deselect with '-m "not slow"')
5
+ addopts = -v --tb=short
6
+
quantize_model.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import os
4
+ import shutil
5
+ import time
6
+ from typing import Dict, List, Tuple
7
+
8
+ import numpy as np
9
+ import torch
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
11
+
12
+ from config import CFG
13
+ from data_loader import load_test_only
14
+ from transformer_model import _checkpoint_to_dir
15
+
16
+
17
+ def _dir_size_bytes(path: str) -> int:
18
+ total = 0
19
+ for root, _, files in os.walk(path):
20
+ for f in files:
21
+ fp = os.path.join(root, f)
22
+ try:
23
+ total += os.path.getsize(fp)
24
+ except OSError:
25
+ pass
26
+ return total
27
+
28
+
29
+ def _mb(n_bytes: int) -> float:
30
+ return float(n_bytes) / (1024.0 * 1024.0)
31
+
32
+
33
+ def _copy_tokenizer_files(src_dir: str, dst_dir: str) -> List[str]:
34
+ os.makedirs(dst_dir, exist_ok=True)
35
+ whitelist = {
36
+ "tokenizer.json",
37
+ "tokenizer_config.json",
38
+ "special_tokens_map.json",
39
+ "vocab.txt",
40
+ "merges.txt",
41
+ "added_tokens.json",
42
+ "sentencepiece.bpe.model",
43
+ "spiece.model",
44
+ "config.json",
45
+ }
46
+ copied: List[str] = []
47
+ for name in os.listdir(src_dir):
48
+ src = os.path.join(src_dir, name)
49
+ dst = os.path.join(dst_dir, name)
50
+ if not os.path.isfile(src):
51
+ continue
52
+ if name in whitelist or name.startswith("tokenizer"):
53
+ shutil.copy2(src, dst)
54
+ copied.append(name)
55
+ return copied
56
+
57
+
58
+ def _load_fp32_model(fp32_dir: str):
59
+ model = AutoModelForSequenceClassification.from_pretrained(fp32_dir)
60
+ tokenizer = AutoTokenizer.from_pretrained(fp32_dir)
61
+ model.eval()
62
+ model.to("cpu")
63
+ return model, tokenizer
64
+
65
+
66
+ def _quantize_dynamic_int8(model_fp32: torch.nn.Module) -> torch.nn.Module:
67
+ # Apple Silicon (ARM) requires qnnpack; x86 defaults to fbgemm which is unavailable on MPS.
68
+ torch.backends.quantized.engine = "qnnpack"
69
+ model_int8 = torch.quantization.quantize_dynamic(
70
+ model_fp32,
71
+ {torch.nn.Linear},
72
+ dtype=torch.qint8,
73
+ )
74
+ model_int8.eval()
75
+ return model_int8
76
+
77
+
78
+ def _batched(iterable: List[str], batch_size: int):
79
+ for i in range(0, len(iterable), batch_size):
80
+ yield iterable[i : i + batch_size]
81
+
82
+
83
+ def _predict(
84
+ model: torch.nn.Module,
85
+ tokenizer,
86
+ texts: List[str],
87
+ batch_size: int,
88
+ ) -> np.ndarray:
89
+ preds: List[int] = []
90
+ with torch.inference_mode():
91
+ for batch in _batched(texts, batch_size):
92
+ enc = tokenizer(
93
+ batch,
94
+ truncation=True,
95
+ max_length=CFG.max_length,
96
+ padding=True,
97
+ return_tensors="pt",
98
+ )
99
+ enc = {k: v.to("cpu") for k, v in enc.items()}
100
+ logits = model(**enc).logits
101
+ batch_preds = torch.argmax(logits, dim=-1).cpu().numpy().tolist()
102
+ preds.extend(batch_preds)
103
+ return np.asarray(preds, dtype=np.int64)
104
+
105
+
106
+ def _accuracy(
107
+ model: torch.nn.Module,
108
+ tokenizer,
109
+ X_test: List[str],
110
+ y_test: List[int],
111
+ batch_size: int = 32,
112
+ ) -> float:
113
+ y_pred = _predict(model, tokenizer, X_test, batch_size=batch_size)
114
+ y_true = np.asarray(y_test, dtype=np.int64)
115
+ return float((y_pred == y_true).mean())
116
+
117
+
118
+ def _benchmark_latency_ms(
119
+ model: torch.nn.Module,
120
+ tokenizer,
121
+ sample_texts: List[str],
122
+ batch_size: int,
123
+ runs: int = 50,
124
+ warmup: int = 5,
125
+ ) -> float:
126
+ per_text_ms: List[float] = []
127
+ for i in range(runs):
128
+ t0 = time.perf_counter()
129
+ _predict(model, tokenizer, sample_texts, batch_size=batch_size)
130
+ dt = time.perf_counter() - t0
131
+ if i >= warmup:
132
+ per_text_ms.append((dt / len(sample_texts)) * 1000.0)
133
+ return float(np.median(per_text_ms))
134
+
135
+
136
+ def _save_quantized_model(
137
+ model_int8: torch.nn.Module,
138
+ fp32_dir: str,
139
+ int8_dir: str,
140
+ checkpoint_dir_name: str,
141
+ ) -> Dict:
142
+ os.makedirs(int8_dir, exist_ok=True)
143
+ model_path = os.path.join(int8_dir, "model_int8.pt")
144
+ torch.save(model_int8, model_path)
145
+ _copy_tokenizer_files(fp32_dir, int8_dir)
146
+
147
+ original_size_mb = _mb(_dir_size_bytes(fp32_dir))
148
+ quantized_size_mb = _mb(_dir_size_bytes(int8_dir))
149
+ compression_ratio = (
150
+ float(original_size_mb) / float(quantized_size_mb)
151
+ if quantized_size_mb > 0
152
+ else 0.0
153
+ )
154
+
155
+ info = {
156
+ "original_model": checkpoint_dir_name,
157
+ "quantization_type": "dynamic_int8",
158
+ "original_size_mb": round(original_size_mb, 2),
159
+ "quantized_size_mb": round(quantized_size_mb, 2),
160
+ "compression_ratio": round(compression_ratio, 3),
161
+ }
162
+ info_path = os.path.join(int8_dir, "quantization_info.json")
163
+ with open(info_path, "w", encoding="utf-8") as f:
164
+ json.dump(info, f, indent=2)
165
+ return {"model_path": model_path, "info_path": info_path, "info": info}
166
+
167
+
168
+ def _print_table(
169
+ fp32_size_mb: float,
170
+ int8_size_mb: float,
171
+ fp32_single_ms: float,
172
+ int8_single_ms: float,
173
+ fp32_batch16_ms: float,
174
+ int8_batch16_ms: float,
175
+ fp32_acc: float,
176
+ int8_acc: float,
177
+ ) -> None:
178
+ size_change_pct = 100.0 * (1.0 - (int8_size_mb / fp32_size_mb)) if fp32_size_mb > 0 else 0.0
179
+ single_speedup = (fp32_single_ms / int8_single_ms) if int8_single_ms > 0 else 0.0
180
+ batch_speedup = (fp32_batch16_ms / int8_batch16_ms) if int8_batch16_ms > 0 else 0.0
181
+ acc_delta_pp = (int8_acc - fp32_acc) * 100.0
182
+
183
+ def line(a: str, b: str, c: str, d: str) -> str:
184
+ return f"β”‚ {a:<15} β”‚ {b:<10} β”‚ {c:<11} β”‚ {d:<17} β”‚"
185
+
186
+ print("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”")
187
+ print(line("Metric", "FP32 Model", "INT8 Model", "Change"))
188
+ print("β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€")
189
+ print(
190
+ line(
191
+ "Model size",
192
+ f"{fp32_size_mb:.1f} MB",
193
+ f"{int8_size_mb:.1f} MB",
194
+ f"-{size_change_pct:.1f}% smaller",
195
+ )
196
+ )
197
+ print(
198
+ line(
199
+ "Single-text ms",
200
+ f"{fp32_single_ms:.2f} ms",
201
+ f"{int8_single_ms:.2f} ms",
202
+ f"{single_speedup:.2f}x faster",
203
+ )
204
+ )
205
+ print(
206
+ line(
207
+ "Batch-16 ms",
208
+ f"{fp32_batch16_ms:.2f} ms",
209
+ f"{int8_batch16_ms:.2f} ms",
210
+ f"{batch_speedup:.2f}x faster",
211
+ )
212
+ )
213
+ print(
214
+ line(
215
+ "Test accuracy",
216
+ f"{fp32_acc * 100:.2f}%",
217
+ f"{int8_acc * 100:.2f}%",
218
+ f"{acc_delta_pp:+.2f} pp",
219
+ )
220
+ )
221
+ print("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜")
222
+
223
+
224
+ def main() -> None:
225
+ parser = argparse.ArgumentParser(description="Dynamic INT8 quantization for transformer inference on CPU.")
226
+ parser.add_argument("--model", type=str, default="distilbert-base-uncased")
227
+ parser.add_argument("--benchmark-only", action="store_true")
228
+ args = parser.parse_args()
229
+
230
+ dir_name = _checkpoint_to_dir(args.model)
231
+ fp32_dir = os.path.join(CFG.models_dir, dir_name)
232
+ int8_dir = os.path.join(CFG.models_dir, f"{dir_name}_int8")
233
+
234
+ if not os.path.isdir(fp32_dir):
235
+ raise FileNotFoundError(
236
+ f"FP32 model directory not found: {fp32_dir}\n"
237
+ f"Expected a fine-tuned model saved via save_pretrained() under saved_models/."
238
+ )
239
+
240
+ print(f"[Quantize] Loading FP32 model from: {fp32_dir}")
241
+ model_fp32, tokenizer_fp32 = _load_fp32_model(fp32_dir)
242
+
243
+ print("[Quantize] Applying dynamic INT8 quantization (Linear layers)...")
244
+ model_int8 = _quantize_dynamic_int8(model_fp32)
245
+
246
+ if not args.benchmark_only:
247
+ saved = _save_quantized_model(model_int8, fp32_dir, int8_dir, checkpoint_dir_name=dir_name)
248
+ print(f"[Quantize] Saved INT8 model -> {saved['model_path']}")
249
+ print(f"[Quantize] Saved metadata -> {saved['info_path']}")
250
+ else:
251
+ os.makedirs(int8_dir, exist_ok=True)
252
+
253
+ X_test, y_test = load_test_only()
254
+ rng = np.random.default_rng(CFG.seed)
255
+ sample_idx = rng.choice(len(X_test), size=min(100, len(X_test)), replace=False).tolist()
256
+ sample_texts = [X_test[i] for i in sample_idx]
257
+
258
+ print("[Benchmark] Measuring latency (median ms per text)...")
259
+ fp32_single = _benchmark_latency_ms(model_fp32, tokenizer_fp32, sample_texts, batch_size=1)
260
+ int8_single = _benchmark_latency_ms(model_int8, tokenizer_fp32, sample_texts, batch_size=1)
261
+ fp32_b16 = _benchmark_latency_ms(model_fp32, tokenizer_fp32, sample_texts, batch_size=16)
262
+ int8_b16 = _benchmark_latency_ms(model_int8, tokenizer_fp32, sample_texts, batch_size=16)
263
+
264
+ print("[Eval] Computing test accuracy on 7,600 examples...")
265
+ fp32_acc = _accuracy(model_fp32, tokenizer_fp32, X_test, y_test, batch_size=32)
266
+ int8_acc = _accuracy(model_int8, tokenizer_fp32, X_test, y_test, batch_size=32)
267
+
268
+ fp32_size_mb = _mb(_dir_size_bytes(fp32_dir))
269
+ int8_size_mb = _mb(_dir_size_bytes(int8_dir))
270
+
271
+ _print_table(
272
+ fp32_size_mb=fp32_size_mb,
273
+ int8_size_mb=int8_size_mb,
274
+ fp32_single_ms=fp32_single,
275
+ int8_single_ms=int8_single,
276
+ fp32_batch16_ms=fp32_b16,
277
+ int8_batch16_ms=int8_b16,
278
+ fp32_acc=fp32_acc,
279
+ int8_acc=int8_acc,
280
+ )
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
285
+
requirements.txt ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PyTorch β€” install separately for your platform:
2
+ # pip install torch torchvision torchaudio
3
+ # (MPS support is built-in on Apple Silicon; no extra steps needed)
4
+
5
+ # ── HuggingFace Stack ────────────────────────────────────────────────────────
6
+ transformers>=4.41.2
7
+ datasets>=2.19.2
8
+ evaluate>=0.4.2
9
+ accelerate>=0.30.1
10
+ huggingface-hub>=0.23.2
11
+ tokenizers>=0.19.1
12
+
13
+ # ── Traditional ML ───────────────────────────────────────────────────────────
14
+ scikit-learn>=1.5.0
15
+ joblib>=1.4.2
16
+
17
+ # ── Data & Numerics ──────────────────────────────────────────────────────────
18
+ numpy>=1.26.4
19
+ pandas>=2.2.2
20
+
21
+ # ── Visualisation ────────────────────────────────────────────────────────────
22
+ # NOTE: matplotlib==3.9.0 fails to build from source on Apple clang 17
23
+ # (freetype-2.6.1 zconf.h Byte type conflict). Use >=3.9.0 to get
24
+ # a pre-built wheel for Python 3.13 arm64.
25
+ matplotlib>=3.9.0
26
+ seaborn>=0.13.2
27
+
28
+ # ── API Server ───────────────────────────────────────────────────────────────
29
+ fastapi>=0.111.0
30
+ uvicorn[standard]>=0.30.1
31
+ pydantic>=2.7.1
32
+
33
+ # ── Optimisation ─────────────────────────────────────────────────────────────
34
+ scipy>=1.14.0
35
+ optuna==3.6.1
36
+
37
+ # ── Misc ─────────────────────────────────────────────────────────────────────
38
+ tqdm>=4.66.4
39
+
40
+ # ── Testing ──────────────────────────────────────────────────────────────────
41
+ pytest==8.2.2
42
+ pytest-cov==5.0.0
43
+ httpx==0.27.0
saved_models/distilbert_base_uncased/config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "activation": "gelu",
3
+ "architectures": [
4
+ "DistilBertForSequenceClassification"
5
+ ],
6
+ "attention_dropout": 0.1,
7
+ "bos_token_id": null,
8
+ "dim": 768,
9
+ "dropout": 0.1,
10
+ "dtype": "float32",
11
+ "eos_token_id": null,
12
+ "hidden_dim": 3072,
13
+ "id2label": {
14
+ "0": "World",
15
+ "1": "Sports",
16
+ "2": "Business",
17
+ "3": "Sci/Tech"
18
+ },
19
+ "initializer_range": 0.02,
20
+ "label2id": {
21
+ "Business": 2,
22
+ "Sci/Tech": 3,
23
+ "Sports": 1,
24
+ "World": 0
25
+ },
26
+ "max_position_embeddings": 512,
27
+ "model_type": "distilbert",
28
+ "n_heads": 12,
29
+ "n_layers": 6,
30
+ "pad_token_id": 0,
31
+ "qa_dropout": 0.1,
32
+ "seq_classif_dropout": 0.2,
33
+ "sinusoidal_pos_embds": false,
34
+ "tie_weights_": true,
35
+ "tie_word_embeddings": true,
36
+ "transformers_version": "5.12.0",
37
+ "use_cache": false,
38
+ "vocab_size": 30522
39
+ }
saved_models/distilbert_base_uncased/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1579bcd6f3bd4d449ff1819d57a848097d37ba21ff1d4750bddbd61295b497de
3
+ size 267838720
saved_models/distilbert_base_uncased/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
saved_models/distilbert_base_uncased/tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": true,
5
+ "is_local": false,
6
+ "local_files_only": false,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }
saved_models/distilbert_base_uncased/training_args.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:890e66b5a5ba897ccba31dc331ded90ea3933d7f8a010d0bec5b79c36c87b23f
3
+ size 5201
saved_models/distilbert_base_uncased_int8/config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "activation": "gelu",
3
+ "architectures": [
4
+ "DistilBertForSequenceClassification"
5
+ ],
6
+ "attention_dropout": 0.1,
7
+ "bos_token_id": null,
8
+ "dim": 768,
9
+ "dropout": 0.1,
10
+ "dtype": "float32",
11
+ "eos_token_id": null,
12
+ "hidden_dim": 3072,
13
+ "id2label": {
14
+ "0": "World",
15
+ "1": "Sports",
16
+ "2": "Business",
17
+ "3": "Sci/Tech"
18
+ },
19
+ "initializer_range": 0.02,
20
+ "label2id": {
21
+ "Business": 2,
22
+ "Sci/Tech": 3,
23
+ "Sports": 1,
24
+ "World": 0
25
+ },
26
+ "max_position_embeddings": 512,
27
+ "model_type": "distilbert",
28
+ "n_heads": 12,
29
+ "n_layers": 6,
30
+ "pad_token_id": 0,
31
+ "qa_dropout": 0.1,
32
+ "seq_classif_dropout": 0.2,
33
+ "sinusoidal_pos_embds": false,
34
+ "tie_weights_": true,
35
+ "tie_word_embeddings": true,
36
+ "transformers_version": "5.12.0",
37
+ "use_cache": false,
38
+ "vocab_size": 30522
39
+ }
saved_models/distilbert_base_uncased_int8/model_int8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e77e69d402e37565c1ac4657663f065e211fcfde995e63f11cf1a0fcb055d9a3
3
+ size 138718266
saved_models/distilbert_base_uncased_int8/quantization_info.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "original_model": "distilbert_base_uncased",
3
+ "quantization_type": "dynamic_int8",
4
+ "original_size_mb": 256.12,
5
+ "quantized_size_mb": 132.97,
6
+ "compression_ratio": 1.926
7
+ }
saved_models/distilbert_base_uncased_int8/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
saved_models/distilbert_base_uncased_int8/tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": true,
5
+ "is_local": false,
6
+ "local_files_only": false,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }
saved_models/traditional_lr.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:76900d222138d705ba86d8337d9341179aa389064c95fd88651e3af6cc24128b
3
+ size 4283180
saved_models/traditional_lr_optimized.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f343f5337ee10b32d436b598dbfbda2664d72d2e03495e28431cc458b19fc8f0
3
+ size 4137004
saved_models/traditional_svm.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6254d1792546d51cc880d50a6a6e081bdb1c9830eed7073184d8df790fb25f66
3
+ size 4283064
saved_models/traditional_svm_optimized.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:50c72cd8331e30ba51fa4883dbf232cdf912cbb52799c4dffe60f84e898c585b
3
+ size 5418824
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
tests/conftest.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi import FastAPI
3
+ from fastapi.testclient import TestClient
4
+ import joblib
5
+ from sklearn.feature_extraction.text import TfidfVectorizer
6
+ from sklearn.linear_model import LogisticRegression
7
+ from sklearn.pipeline import Pipeline
8
+ from sklearn.svm import LinearSVC
9
+
10
+ import api
11
+ import database
12
+ from config import CFG
13
+
14
+
15
+ @pytest.fixture
16
+ def sample_texts():
17
+ return [
18
+ "UN peace talks resume as leaders meet to discuss ceasefire plans.",
19
+ "Local team wins championship after a dramatic overtime goal.",
20
+ "Stocks rally as the central bank signals a pause in rate hikes.",
21
+ "New smartphone chip boosts performance while reducing power use.",
22
+ "World leaders condemn attacks and call for immediate humanitarian aid.",
23
+ "Star striker sidelined with injury ahead of weekend match.",
24
+ "Company reports record quarterly earnings despite weak consumer demand.",
25
+ "Scientists discover a new exoplanet that may support liquid water.",
26
+ "Oil prices rise on supply concerns and geopolitical tensions.",
27
+ "Tech firm faces scrutiny after data breach exposes user accounts.",
28
+ ]
29
+
30
+
31
+ @pytest.fixture
32
+ def sample_labels():
33
+ return [0, 1, 2, 3, 0, 1, 2, 3, 2, 3]
34
+
35
+
36
+ @pytest.fixture
37
+ def mock_pipeline(sample_texts, sample_labels):
38
+ tfidf = TfidfVectorizer(
39
+ max_features=200,
40
+ ngram_range=(1, 1),
41
+ min_df=1,
42
+ sublinear_tf=False,
43
+ )
44
+ clf = LogisticRegression(
45
+ max_iter=200,
46
+ solver="lbfgs",
47
+ random_state=CFG.seed,
48
+ )
49
+ pipe = Pipeline([("tfidf", tfidf), ("lr", clf)])
50
+ pipe.fit(sample_texts, sample_labels)
51
+ return pipe
52
+
53
+
54
+ @pytest.fixture
55
+ def test_db_path(tmp_path):
56
+ return tmp_path / "test_requests.db"
57
+
58
+
59
+ @pytest.fixture
60
+ def api_client(monkeypatch, tmp_path, test_db_path):
61
+ models_dir = tmp_path / "models"
62
+ models_dir.mkdir(parents=True, exist_ok=True)
63
+ monkeypatch.setattr(CFG, "models_dir", str(models_dir))
64
+ monkeypatch.setattr(database, "_default_db_path", lambda: str(test_db_path))
65
+ database.init_db(db_path=str(test_db_path))
66
+
67
+ api._registry.clear()
68
+ test_app = FastAPI()
69
+ test_app.include_router(api.app.router)
70
+ return TestClient(test_app)
71
+
72
+
73
+ @pytest.fixture
74
+ def api_client_with_models(api_client, mock_pipeline, tmp_path, monkeypatch):
75
+ models_dir = tmp_path / "models"
76
+ monkeypatch.setattr(CFG, "models_dir", str(models_dir))
77
+ joblib.dump(mock_pipeline, models_dir / "traditional_lr.joblib")
78
+ svm = Pipeline(
79
+ [
80
+ ("tfidf", TfidfVectorizer(max_features=200, ngram_range=(1, 1), min_df=1)),
81
+ ("svm", LinearSVC(random_state=CFG.seed, max_iter=1000)),
82
+ ]
83
+ )
84
+ svm.fit(
85
+ [
86
+ "UN talks continue amid international pressure",
87
+ "Team wins match after extra time",
88
+ "Shares climb after earnings beat expectations",
89
+ "New processor improves phone battery life",
90
+ "Markets react to inflation report and central bank comments",
91
+ "Scientists unveil new telescope instrument",
92
+ "Player scores hat-trick in league game",
93
+ "Company announces merger in tech sector",
94
+ ],
95
+ [0, 1, 2, 3, 2, 3, 1, 2],
96
+ )
97
+ joblib.dump(svm, models_dir / "traditional_svm.joblib")
98
+ return api_client
99
+
100
+
101
+ @pytest.fixture
102
+ def api_client_with_startup(monkeypatch, tmp_path):
103
+ test_db_path = tmp_path / "startup_requests.db"
104
+ monkeypatch.setattr(database, "_default_db_path", lambda: str(test_db_path))
105
+ monkeypatch.setattr(CFG, "models_dir", str(tmp_path / "models"))
106
+ api._registry.clear()
107
+
108
+ def _fake_load_model(model_name: str):
109
+ raise FileNotFoundError("default model not available")
110
+
111
+ monkeypatch.setattr(api, "_load_model", _fake_load_model)
112
+ with TestClient(api.app) as client:
113
+ yield client
tests/test_api.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_health_returns_ok(api_client):
2
+ resp = api_client.get("/health")
3
+ assert resp.status_code == 200
4
+ assert resp.json()["status"] == "ok"
5
+
6
+
7
+ def test_labels_returns_four_classes(api_client):
8
+ resp = api_client.get("/labels")
9
+ assert resp.status_code == 200
10
+ assert len(resp.json()["labels"]) == 4
11
+
12
+
13
+ def test_predict_missing_model_returns_404(api_client):
14
+ resp = api_client.post(
15
+ "/predict", json={"text": "Some news article", "model_name": "nonexistent_model"}
16
+ )
17
+ assert resp.status_code == 404
18
+
19
+
20
+ def test_predict_empty_text_returns_422(api_client):
21
+ resp = api_client.post("/predict", json={"text": "", "model_name": "lr"})
22
+ assert resp.status_code == 422
23
+
24
+
25
+ def test_batch_predict_too_many_texts_returns_422(api_client):
26
+ resp = api_client.post(
27
+ "/batch_predict", json={"texts": ["text"] * 257, "model_name": "lr"}
28
+ )
29
+ assert resp.status_code == 422
30
+
31
+
32
+ def test_analytics_summary_returns_valid_json(api_client):
33
+ resp = api_client.get("/analytics/summary")
34
+ assert resp.status_code == 200
35
+ assert "total_requests" in resp.json()
36
+
37
+
38
+ def test_models_endpoint_lists_sklearn_model(api_client_with_models):
39
+ resp = api_client_with_models.get("/models")
40
+ assert resp.status_code == 200
41
+ payload = resp.json()
42
+ names = {m["name"] for m in payload["models"]}
43
+ assert "lr" in names
44
+
45
+
46
+ def test_predict_lr_success_logs_request(api_client_with_models, test_db_path):
47
+ import database
48
+
49
+ resp = api_client_with_models.post(
50
+ "/predict", json={"text": "Fed raises interest rates by 50 bps", "model_name": "lr"}
51
+ )
52
+ assert resp.status_code == 200
53
+ body = resp.json()
54
+ assert "request_id" in body
55
+ assert "is_low_confidence" in body
56
+ assert "latency_ms" in body
57
+
58
+ history = database.get_request_history(db_path=str(test_db_path), limit=10)
59
+ assert len(history) == 1
60
+ assert history[0]["request_id"] == body["request_id"]
61
+
62
+
63
+ def test_predict_lr_missing_joblib_returns_404(api_client):
64
+ resp = api_client.post(
65
+ "/predict", json={"text": "Fed raises rates", "model_name": "lr"}
66
+ )
67
+ assert resp.status_code == 404
68
+
69
+
70
+ def test_batch_predict_lr_logs_each_item(api_client_with_models, test_db_path):
71
+ import database
72
+
73
+ texts = ["Apple unveils new AI chip", "Team wins the final match in overtime"]
74
+ resp = api_client_with_models.post(
75
+ "/batch_predict", json={"texts": texts, "model_name": "lr"}
76
+ )
77
+ assert resp.status_code == 200
78
+ body = resp.json()
79
+ assert body["count"] == 2
80
+ assert len(body["predictions"]) == 2
81
+
82
+ history = database.get_request_history(db_path=str(test_db_path), limit=10)
83
+ assert len(history) == 2
84
+ assert all(int(r["is_batch"]) == 1 for r in history)
85
+
86
+
87
+ def test_predict_svm_success_uses_decision_function(api_client_with_models):
88
+ resp = api_client_with_models.post(
89
+ "/predict", json={"text": "Championship match ends in overtime", "model_name": "svm"}
90
+ )
91
+ assert resp.status_code == 200
92
+ body = resp.json()
93
+ assert body["probabilities"] is None
94
+ assert 0.0 <= float(body.get("latency_ms", 0.0))
95
+
96
+
97
+ def test_batch_predict_svm_success(api_client_with_models):
98
+ resp = api_client_with_models.post(
99
+ "/batch_predict",
100
+ json={"texts": ["Markets rise on earnings", "New chip released"], "model_name": "svm"},
101
+ )
102
+ assert resp.status_code == 200
103
+ body = resp.json()
104
+ assert body["count"] == 2
105
+
106
+
107
+ def test_predict_lr_cached_path(api_client_with_models, test_db_path):
108
+ import database
109
+
110
+ r1 = api_client_with_models.post(
111
+ "/predict", json={"text": "Stocks fall on inflation data", "model_name": "lr"}
112
+ )
113
+ r2 = api_client_with_models.post(
114
+ "/predict", json={"text": "Stocks fall on inflation data", "model_name": "lr"}
115
+ )
116
+ assert r1.status_code == 200
117
+ assert r2.status_code == 200
118
+ history = database.get_request_history(db_path=str(test_db_path), limit=10)
119
+ assert len(history) == 2
120
+
121
+
122
+ def test_low_confidence_review_flow(api_client_with_models, test_db_path, monkeypatch):
123
+ import database
124
+ from config import CFG
125
+
126
+ monkeypatch.setattr(CFG, "low_confidence_threshold", 0.99)
127
+ resp = api_client_with_models.post(
128
+ "/predict", json={"text": "Mixed signals from markets after earnings report", "model_name": "lr"}
129
+ )
130
+ assert resp.status_code == 200
131
+ request_id = resp.json()["request_id"]
132
+
133
+ flags = api_client_with_models.get("/analytics/low_confidence").json()
134
+ assert any(f["request_id"] == request_id for f in flags)
135
+
136
+ patch_resp = api_client_with_models.patch(
137
+ f"/analytics/review/{request_id}", json={"note": "needs review"}
138
+ )
139
+ assert patch_resp.status_code == 200
140
+
141
+ reviewed_flags = api_client_with_models.get("/analytics/low_confidence?reviewed=true").json()
142
+ match = [f for f in reviewed_flags if f["request_id"] == request_id]
143
+ assert match
144
+ assert int(match[0]["reviewed"]) == 1
145
+ assert match[0]["review_note"] == "needs review"
146
+
147
+ history = database.get_request_history(db_path=str(test_db_path), limit=10)
148
+ assert history
149
+
150
+
151
+ def test_export_flags_creates_files(api_client_with_models, tmp_path, monkeypatch):
152
+ import database
153
+ from config import CFG
154
+
155
+ monkeypatch.setattr(CFG, "low_confidence_threshold", 0.99)
156
+ api_client_with_models.post(
157
+ "/predict", json={"text": "Unclear headline with mixed topics", "model_name": "lr"}
158
+ )
159
+
160
+ out_dir = tmp_path / "low_confidence_review"
161
+
162
+ original = database.export_low_confidence_to_folder
163
+
164
+ def _export_override():
165
+ return original(output_dir=str(out_dir))
166
+
167
+ monkeypatch.setattr(database, "export_low_confidence_to_folder", _export_override)
168
+ resp = api_client_with_models.post("/analytics/export_flags")
169
+ assert resp.status_code == 200
170
+ payload = resp.json()
171
+ assert payload["exported"] >= 1
172
+
173
+
174
+ def test_health_with_startup_runs(api_client_with_startup):
175
+ resp = api_client_with_startup.get("/health")
176
+ assert resp.status_code == 200
tests/test_config.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from config import CFG
4
+
5
+
6
+ def test_device_string_valid():
7
+ assert CFG.device in {"mps", "cuda", "cpu"}
8
+
9
+
10
+ def test_num_labels_matches_label_names():
11
+ assert CFG.num_labels == len(CFG.label_names)
12
+
13
+
14
+ def test_dirs_created_on_init():
15
+ assert os.path.isdir(CFG.models_dir)
16
+ assert os.path.isdir(CFG.outputs_dir)
17
+ assert os.path.isdir(CFG.logs_dir)
18
+
19
+
20
+ def test_label_names_correct():
21
+ assert CFG.label_names == ["World", "Sports", "Business", "Sci/Tech"]
22
+
23
+
24
+ def test_max_length_positive():
25
+ assert CFG.max_length > 0
26
+
tests/test_data_loader.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from data_loader import get_raw_splits, load_ag_news, load_test_only
4
+
5
+
6
+ pytestmark = pytest.mark.slow
7
+
8
+
9
+ def test_load_test_only_returns_correct_types():
10
+ X_test, y_test = load_test_only()
11
+ assert isinstance(X_test, list)
12
+ assert isinstance(y_test, list)
13
+
14
+
15
+ def test_load_test_only_length():
16
+ X_test, y_test = load_test_only()
17
+ assert len(X_test) == 7600
18
+ assert len(y_test) == 7600
19
+
20
+
21
+ def test_load_test_only_labels_valid():
22
+ _, y_test = load_test_only()
23
+ assert all(y in {0, 1, 2, 3} for y in y_test)
24
+
25
+
26
+ def test_load_ag_news_with_caps():
27
+ dataset = load_ag_news(max_train=100, max_eval=50, max_test=50)
28
+ assert len(dataset["train"]) == 100
29
+ assert len(dataset["validation"]) == 50
30
+ assert len(dataset["test"]) == 50
31
+
32
+
33
+ def test_get_raw_splits_shapes():
34
+ dataset = load_ag_news(max_train=100, max_eval=50, max_test=50)
35
+ X_train, y_train, X_val, y_val, X_test, y_test = get_raw_splits(dataset)
36
+ assert len(X_train) == len(y_train) == 100
37
+ assert len(X_val) == len(y_val) == 50
38
+ assert len(X_test) == len(y_test) == 50
39
+
tests/test_database.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+
3
+ import database
4
+
5
+
6
+ def _tables(db_path: str):
7
+ conn = sqlite3.connect(db_path)
8
+ try:
9
+ rows = conn.execute(
10
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
11
+ ).fetchall()
12
+ return [r[0] for r in rows]
13
+ finally:
14
+ conn.close()
15
+
16
+
17
+ def test_init_db_creates_tables(test_db_path):
18
+ database.init_db(db_path=str(test_db_path))
19
+ tables = _tables(str(test_db_path))
20
+ assert "requests" in tables
21
+ assert "model_stats" in tables
22
+ assert "low_confidence_flags" in tables
23
+
24
+
25
+ def test_log_request_creates_row(test_db_path):
26
+ database.init_db(db_path=str(test_db_path))
27
+ database.log_request(
28
+ db_path=str(test_db_path),
29
+ request_id="abc-123",
30
+ model_name="lr",
31
+ input_text="Fed raises rates",
32
+ predicted_label="Business",
33
+ predicted_label_id=2,
34
+ confidence=0.91,
35
+ latency_ms=12.4,
36
+ is_batch=False,
37
+ )
38
+ history = database.get_request_history(db_path=str(test_db_path), limit=10)
39
+ assert len(history) == 1
40
+ assert history[0]["predicted_label"] == "Business"
41
+
42
+
43
+ def test_low_confidence_flag(test_db_path, monkeypatch):
44
+ monkeypatch.setattr(database.CFG, "low_confidence_threshold", 0.60)
45
+ database.init_db(db_path=str(test_db_path))
46
+ database.log_request(
47
+ db_path=str(test_db_path),
48
+ request_id="low-1",
49
+ model_name="lr",
50
+ input_text="Fed raises rates",
51
+ predicted_label="Business",
52
+ predicted_label_id=2,
53
+ confidence=0.45,
54
+ latency_ms=12.4,
55
+ is_batch=False,
56
+ )
57
+ flags = database.get_low_confidence_flags(db_path=str(test_db_path), reviewed=False)
58
+ assert len(flags) == 1
59
+ assert abs(float(flags[0]["confidence"]) - 0.45) < 1e-9
60
+
61
+
62
+ def test_mark_reviewed(test_db_path, monkeypatch):
63
+ monkeypatch.setattr(database.CFG, "low_confidence_threshold", 0.60)
64
+ database.init_db(db_path=str(test_db_path))
65
+ database.log_request(
66
+ db_path=str(test_db_path),
67
+ request_id="low-2",
68
+ model_name="lr",
69
+ input_text="Fed raises rates",
70
+ predicted_label="Business",
71
+ predicted_label_id=2,
72
+ confidence=0.45,
73
+ latency_ms=12.4,
74
+ is_batch=False,
75
+ )
76
+ database.mark_reviewed("low-2", note="reviewed", db_path=str(test_db_path))
77
+ flags = database.get_low_confidence_flags(db_path=str(test_db_path), reviewed=True)
78
+ assert len(flags) == 1
79
+ assert int(flags[0]["reviewed"]) == 1
80
+ assert flags[0]["review_note"] == "reviewed"
81
+
82
+
83
+ def test_get_summary_empty(test_db_path):
84
+ database.init_db(db_path=str(test_db_path))
85
+ summary = database.get_summary(db_path=str(test_db_path), days=7)
86
+ assert summary["total_requests"] == 0
87
+
tests/test_traditional_model.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from sklearn.pipeline import Pipeline
3
+
4
+ from config import CFG
5
+ from traditional_model import build_pipeline, load_model, save_model, train
6
+
7
+
8
+ def test_build_pipeline_lr():
9
+ pipe = build_pipeline("lr")
10
+ assert isinstance(pipe, Pipeline)
11
+ assert len(pipe.steps) == 2
12
+
13
+
14
+ def test_build_pipeline_svm():
15
+ pipe = build_pipeline("svm")
16
+ assert isinstance(pipe, Pipeline)
17
+ assert len(pipe.steps) == 2
18
+
19
+
20
+ def test_build_pipeline_invalid_type():
21
+ with pytest.raises(ValueError):
22
+ build_pipeline("xgb")
23
+
24
+
25
+ def test_train_returns_pipeline_and_float(sample_texts, sample_labels):
26
+ model, val_acc = train(sample_texts, sample_labels, sample_texts, sample_labels, "lr")
27
+ assert isinstance(model, Pipeline)
28
+ assert 0.0 <= float(val_acc) <= 1.0
29
+
30
+
31
+ def test_pipeline_predict_returns_valid_labels(mock_pipeline, sample_texts):
32
+ preds = mock_pipeline.predict(sample_texts)
33
+ assert all(int(p) in {0, 1, 2, 3} for p in preds)
34
+
35
+
36
+ def test_save_and_load_model(mock_pipeline, tmp_path, monkeypatch):
37
+ monkeypatch.setattr(CFG, "models_dir", str(tmp_path))
38
+ save_model(mock_pipeline, "test")
39
+ loaded = load_model("test")
40
+ out = loaded.predict(["test text"])
41
+ assert out is not None
42
+
tests/test_transformer_model.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from config import CFG
5
+ from transformer_model import _checkpoint_to_dir, compute_metrics, load_model
6
+
7
+
8
+ def test_checkpoint_to_dir_hyphen():
9
+ assert _checkpoint_to_dir("roberta-base") == "roberta_base"
10
+
11
+
12
+ def test_checkpoint_to_dir_slash():
13
+ assert _checkpoint_to_dir("org/model") == "org_model"
14
+
15
+
16
+ def test_compute_metrics_perfect():
17
+ eval_pred = (np.array([[10, 0, 0, 0], [0, 10, 0, 0]]), np.array([0, 1]))
18
+ result = compute_metrics(eval_pred)
19
+ assert result["accuracy"] == 1.0
20
+ assert result["f1_macro"] == 1.0
21
+
22
+
23
+ def test_compute_metrics_all_wrong():
24
+ eval_pred = (np.array([[0, 10, 0, 0], [0, 0, 10, 0]]), np.array([0, 1]))
25
+ result = compute_metrics(eval_pred)
26
+ assert result["accuracy"] == 0.0
27
+
28
+
29
+ def test_load_model_missing_raises(tmp_path, monkeypatch):
30
+ monkeypatch.setattr(CFG, "models_dir", str(tmp_path))
31
+ with pytest.raises(FileNotFoundError):
32
+ load_model("nonexistent-model")
33
+
traditional_model.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ traditional_model.py
3
+ ────────────────────
4
+ Approach A: TF-IDF vectorisation + scikit-learn classifiers.
5
+
6
+ Provides two classifier options:
7
+ 'lr' β†’ Logistic Regression (supports probability scores)
8
+ 'svm' β†’ Linear SVM (slightly faster, no probability output)
9
+ """
10
+ import logging
11
+ import os
12
+ import time
13
+ from typing import Dict, Literal, Tuple
14
+
15
+ import joblib
16
+ import matplotlib
17
+ matplotlib.use('Agg')
18
+ import matplotlib.pyplot as plt
19
+ import numpy as np
20
+ import seaborn as sns
21
+ from sklearn.feature_extraction.text import TfidfVectorizer
22
+ from sklearn.linear_model import LogisticRegression
23
+ from sklearn.metrics import (
24
+ accuracy_score,
25
+ classification_report,
26
+ confusion_matrix,
27
+ )
28
+ from sklearn.pipeline import Pipeline
29
+ from sklearn.svm import LinearSVC
30
+
31
+ from config import CFG
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # ── Pipeline factory ──────────────────────────────────────────────────────────
37
+
38
+ def build_pipeline(model_type: Literal["lr", "svm"] = "lr") -> Pipeline:
39
+ """
40
+ Build a TF-IDF β†’ classifier sklearn Pipeline.
41
+
42
+ TF-IDF settings:
43
+ - max_features=60_000 : vocabulary cap; covers ~99 % of AG News tokens
44
+ - ngram_range=(1, 2) : unigrams + bigrams capture short phrases
45
+ - sublinear_tf=True : apply log(TF) to dampen very frequent terms
46
+ - min_df=2 : discard hapax legomena (appear only once)
47
+ """
48
+ tfidf = TfidfVectorizer(
49
+ max_features=60_000,
50
+ ngram_range=(1, 2),
51
+ sublinear_tf=True,
52
+ min_df=2,
53
+ strip_accents="unicode",
54
+ analyzer="word",
55
+ token_pattern=r"\w{1,}",
56
+ )
57
+
58
+ if model_type == "lr":
59
+ clf = LogisticRegression(
60
+ C=5.0,
61
+ max_iter=1_000,
62
+ solver="saga",
63
+ n_jobs=-1,
64
+ random_state=CFG.seed,
65
+ )
66
+ elif model_type == "svm":
67
+ clf = LinearSVC(
68
+ C=1.0,
69
+ max_iter=2_000,
70
+ random_state=CFG.seed,
71
+ )
72
+ else:
73
+ raise ValueError(
74
+ f"Unknown model_type '{model_type}'. Valid choices: 'lr', 'svm'."
75
+ )
76
+
77
+ pipeline = Pipeline([("tfidf", tfidf), (model_type, clf)])
78
+ logger.info(f"Pipeline: TF-IDF -> {clf.__class__.__name__}")
79
+ return pipeline
80
+
81
+
82
+ # ── Training ──────────────────────────────────────────────────────────────────
83
+
84
+ def train(
85
+ X_train, y_train,
86
+ X_val, y_val,
87
+ model_type: str = "lr",
88
+ ) -> Tuple[Pipeline, float]:
89
+ """
90
+ Fit the pipeline and report validation accuracy.
91
+
92
+ Returns
93
+ -------
94
+ (fitted_pipeline, validation_accuracy)
95
+ """
96
+ pipeline = build_pipeline(model_type)
97
+
98
+ logger.info(f"Training {model_type.upper()} on {len(X_train):,} samples ...")
99
+ t0 = time.perf_counter()
100
+ pipeline.fit(X_train, y_train)
101
+ elapsed = time.perf_counter() - t0
102
+ logger.info(f"Training complete in {elapsed:.1f}s")
103
+
104
+ val_preds = pipeline.predict(X_val)
105
+ val_acc = accuracy_score(y_val, val_preds)
106
+ logger.info(f"Validation accuracy: {val_acc * 100:.2f}%")
107
+
108
+ return pipeline, val_acc
109
+
110
+
111
+ # ── Evaluation ────────────────────────────────────────────────────────────────
112
+
113
+ def evaluate(
114
+ pipeline: Pipeline,
115
+ X_test,
116
+ y_test,
117
+ save_dir: str = None,
118
+ ) -> Dict:
119
+ """
120
+ Run the pipeline on the test set, print a full report and save the
121
+ confusion matrix.
122
+
123
+ Returns
124
+ -------
125
+ dict with keys: accuracy, report, confusion_matrix
126
+ """
127
+ preds = pipeline.predict(X_test)
128
+ acc = accuracy_score(y_test, preds)
129
+ cm = confusion_matrix(y_test, preds)
130
+ report = classification_report(
131
+ y_test, preds,
132
+ target_names=CFG.label_names,
133
+ digits=4,
134
+ )
135
+
136
+ print("\n" + "=" * 60)
137
+ print(" TRADITIONAL MODEL -- TEST SET RESULTS")
138
+ print("=" * 60)
139
+ print(f" Accuracy : {acc * 100:.2f}%\n")
140
+ print(report)
141
+
142
+ _plot_confusion_matrix(
143
+ cm,
144
+ title="Traditional Model -- Confusion Matrix",
145
+ save_dir=save_dir,
146
+ )
147
+
148
+ return {"accuracy": acc, "report": report, "confusion_matrix": cm}
149
+
150
+
151
+ # ── Persistence ───────────────────────────────────────────────────────────────
152
+
153
+ def save_model(pipeline: Pipeline, name: str = "lr") -> str:
154
+ """Serialise the pipeline with joblib."""
155
+ path = os.path.join(CFG.models_dir, f"traditional_{name}.joblib")
156
+ joblib.dump(pipeline, path)
157
+ logger.info(f"Model saved -> {path}")
158
+ return path
159
+
160
+
161
+ def load_model(name: str = "lr") -> Pipeline:
162
+ """Deserialise a saved pipeline."""
163
+ path = os.path.join(CFG.models_dir, f"traditional_{name}.joblib")
164
+ if not os.path.exists(path):
165
+ raise FileNotFoundError(
166
+ f"No saved model at '{path}'. "
167
+ f"Run: python train_traditional.py --model {name}"
168
+ )
169
+ pipeline = joblib.load(path)
170
+ logger.info(f"Model loaded <- {path}")
171
+ return pipeline
172
+
173
+
174
+ # ── Internal helpers ──────────────────────────────────────────────────────────
175
+
176
+ def _plot_confusion_matrix(
177
+ cm: np.ndarray,
178
+ title: str,
179
+ save_dir: str = None,
180
+ ) -> None:
181
+ fig, ax = plt.subplots(figsize=(7, 6))
182
+ sns.heatmap(
183
+ cm,
184
+ annot=True,
185
+ fmt="d",
186
+ cmap="Blues",
187
+ xticklabels=CFG.label_names,
188
+ yticklabels=CFG.label_names,
189
+ linewidths=0.5,
190
+ ax=ax,
191
+ )
192
+ ax.set_xlabel("Predicted Label", fontsize=11)
193
+ ax.set_ylabel("True Label", fontsize=11)
194
+ ax.set_title(title, fontsize=13, fontweight="bold")
195
+ plt.tight_layout()
196
+
197
+ if save_dir:
198
+ os.makedirs(save_dir, exist_ok=True)
199
+ fig_path = os.path.join(save_dir, "confusion_matrix.png")
200
+ plt.savefig(fig_path, dpi=150)
201
+ logger.info(f"Confusion matrix -> {fig_path}")
202
+
203
+ plt.show()
204
+ plt.close(fig)
train_multi.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_multi.py
3
+ ──────────────
4
+ Train multiple transformer architectures back-to-back.
5
+ Ideal for an overnight run on Mac M4 β€” each model saves independently.
6
+ Results are compared at the end in a clean table.
7
+
8
+ Usage
9
+ ─────
10
+ # Train DistilBERT + BERT + RoBERTa (default)
11
+ python train_multi.py
12
+
13
+ # Train only DistilBERT and RoBERTa
14
+ python train_multi.py --models distilbert-base-uncased roberta-base
15
+
16
+ # Single model
17
+ python train_multi.py --models roberta-base
18
+ """
19
+ import argparse
20
+ import logging
21
+ import time
22
+
23
+ from config import CFG
24
+ from data_loader import load_ag_news, get_tokenizer, tokenise_dataset
25
+ import transformer_model as trm
26
+
27
+ logging.basicConfig(
28
+ level=logging.INFO,
29
+ format="%(asctime)s %(levelname)-8s %(message)s",
30
+ datefmt="%H:%M:%S",
31
+ )
32
+
33
+ DEFAULT_MODELS = [
34
+ "distilbert-base-uncased",
35
+ "bert-base-uncased",
36
+ "roberta-base",
37
+ ]
38
+
39
+
40
+ def train_single_architecture(checkpoint: str) -> dict:
41
+ """
42
+ End-to-end train and evaluate one transformer checkpoint.
43
+ Mutates CFG.model_checkpoint for the duration of the run.
44
+ """
45
+ print(f"\n{'━' * 60}")
46
+ print(f" Model: {checkpoint}")
47
+ print(f"{'━' * 60}")
48
+
49
+ CFG.model_checkpoint = checkpoint # Override for this run
50
+
51
+ dataset = load_ag_news() # Full 120K (no cap in updated config)
52
+ tokenizer = get_tokenizer()
53
+ tokenised = tokenise_dataset(dataset, tokenizer)
54
+
55
+ t0 = time.perf_counter()
56
+ trainer = trm.train(tokenised, tokenizer, checkpoint=checkpoint)
57
+ elapsed = time.perf_counter() - t0
58
+
59
+ save_dir = f"outputs/{trm._checkpoint_to_dir(checkpoint)}"
60
+ results = trm.evaluate(trainer, tokenised,
61
+ checkpoint=checkpoint, save_dir=save_dir)
62
+ trm.save_model(trainer, tokenizer, checkpoint=checkpoint)
63
+
64
+ h, rem = divmod(int(elapsed), 3600)
65
+ m, s = divmod(rem, 60)
66
+
67
+ return {
68
+ "checkpoint": checkpoint,
69
+ "accuracy": results["accuracy"],
70
+ "f1_macro": results["metrics"].get("test_f1_macro", 0.0),
71
+ "time": f"{h}h {m}m {s}s",
72
+ }
73
+
74
+
75
+ def main() -> None:
76
+ parser = argparse.ArgumentParser(
77
+ description="Train multiple transformer architectures sequentially"
78
+ )
79
+ parser.add_argument(
80
+ "--models", nargs="+", default=DEFAULT_MODELS,
81
+ help="Space-separated list of HuggingFace checkpoint names",
82
+ )
83
+ args = parser.parse_args()
84
+
85
+ device_label = "MPS (Apple Metal)" if CFG.device == "mps" else CFG.device.upper()
86
+ print(f"\n Multi-Architecture Training Session")
87
+ print(f" Device : {device_label}")
88
+ print(f" Models : {', '.join(args.models)}")
89
+ print(f" Dataset : AG News β€” full 120,000 training examples\n")
90
+
91
+ all_results = []
92
+ session_t0 = time.perf_counter()
93
+
94
+ for checkpoint in args.models:
95
+ result = train_single_architecture(checkpoint)
96
+ all_results.append(result)
97
+ print(f"\n βœ“ {checkpoint} β€” acc={result['accuracy']*100:.2f}% time={result['time']}\n")
98
+
99
+ session_elapsed = time.perf_counter() - session_t0
100
+ h, rem = divmod(int(session_elapsed), 3600)
101
+ m, s = divmod(rem, 60)
102
+
103
+ print(f"\n{'═' * 66}")
104
+ print(f" {'Architecture':<28} {'Accuracy':>10} {'F1-Macro':>10} {'Time':>10}")
105
+ print(f"{'─' * 66}")
106
+ for r in sorted(all_results, key=lambda x: x["accuracy"], reverse=True):
107
+ name = r["checkpoint"].split("/")[-1]
108
+ star = " β—€ best" if r == max(all_results, key=lambda x: x["accuracy"]) else ""
109
+ print(
110
+ f" {name:<28} {r['accuracy']*100:>9.2f}% "
111
+ f"{r['f1_macro']:>10.4f} {r['time']:>10}{star}"
112
+ )
113
+ print(f"{'═' * 66}")
114
+ print(f"\n Total session time: {h}h {m}m {s}s\n")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
train_traditional.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_traditional.py
3
+ ────────────────────
4
+ Entry-point: trains TF-IDF + Logistic Regression or Linear SVM.
5
+
6
+ Usage
7
+ ─────
8
+ python train_traditional.py # Logistic Regression (default)
9
+ python train_traditional.py --model svm # Linear SVM
10
+ python train_traditional.py --full # Use all 120 K training samples
11
+ python train_traditional.py --model svm --full
12
+ """
13
+ import argparse
14
+ import logging
15
+
16
+ from config import CFG
17
+ from data_loader import load_ag_news, get_raw_splits
18
+ import traditional_model as tm
19
+
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s %(levelname)-8s %(message)s",
23
+ datefmt="%H:%M:%S",
24
+ )
25
+
26
+
27
+ def parse_args() -> argparse.Namespace:
28
+ p = argparse.ArgumentParser(
29
+ description="Train traditional ML document classifier (TF-IDF + LR or SVM)"
30
+ )
31
+ p.add_argument(
32
+ "--model",
33
+ default="lr",
34
+ choices=["lr", "svm"],
35
+ help="'lr' = Logistic Regression | 'svm' = Linear SVM (default: lr)",
36
+ )
37
+ p.add_argument(
38
+ "--full",
39
+ action="store_true",
40
+ help="Disable sample cap; use all 120 K training examples (slower)",
41
+ )
42
+ return p.parse_args()
43
+
44
+
45
+ def main() -> None:
46
+ args = parse_args()
47
+
48
+ max_train = None if args.full else CFG.max_train_samples
49
+ max_eval = None if args.full else CFG.max_eval_samples
50
+ sample_label = "Full dataset" if (args.full or max_train is None) else f"{max_train:,} samples (subset)"
51
+
52
+ print(f"\n{'-' * 60}")
53
+ print(f" Document Classifier -- Traditional ML Training")
54
+ print(f" Model : {args.model.upper()}")
55
+ print(f" Samples : {sample_label}")
56
+ print(f"{'-' * 60}\n")
57
+
58
+ # Load data
59
+ dataset = load_ag_news(max_train=max_train, max_eval=max_eval, max_test=None)
60
+ X_train, y_train, X_val, y_val, X_test, y_test = get_raw_splits(dataset)
61
+
62
+ # Train
63
+ pipeline, val_acc = tm.train(
64
+ X_train, y_train,
65
+ X_val, y_val,
66
+ model_type=args.model,
67
+ )
68
+
69
+ # Evaluate on test set
70
+ save_dir = f"outputs/{args.model}"
71
+ results = tm.evaluate(pipeline, X_test, y_test, save_dir=save_dir)
72
+
73
+ # Save the model
74
+ tm.save_model(pipeline, name=args.model)
75
+
76
+ print(f"\n Final test accuracy : {results['accuracy'] * 100:.2f}%")
77
+ print(f" Model saved to : saved_models/traditional_{args.model}.joblib\n")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
train_transformer.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_transformer.py
3
+ ────────────────────
4
+ Entry-point: fine-tunes DistilBERT on AG News.
5
+
6
+ Usage
7
+ ─────
8
+ python train_transformer.py # 20 K subset β‰ˆ 1.5–2.5 hrs on i3
9
+ python train_transformer.py --full # 120 K full β‰ˆ 8–10 hrs on i3
10
+
11
+ Tip: Start with the subset to verify everything works, then run --full overnight.
12
+ """
13
+ import argparse
14
+ import logging
15
+
16
+ from config import CFG
17
+ from data_loader import load_ag_news, get_tokenizer, tokenise_dataset
18
+ import transformer_model as trm
19
+
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s %(levelname)-8s %(message)s",
23
+ datefmt="%H:%M:%S",
24
+ )
25
+
26
+
27
+ def parse_args() -> argparse.Namespace:
28
+ p = argparse.ArgumentParser(
29
+ description="Fine-tune DistilBERT document classifier"
30
+ )
31
+ p.add_argument(
32
+ "--full",
33
+ action="store_true",
34
+ help="Use the full 120 K training set instead of the 20 K subset",
35
+ )
36
+ return p.parse_args()
37
+
38
+
39
+ def main() -> None:
40
+ args = parse_args()
41
+
42
+ max_train = None if args.full else CFG.max_train_samples
43
+ max_eval = None if args.full else CFG.max_eval_samples
44
+ sample_label = "Full dataset" if args.full else f"{max_train:,} samples (subset)"
45
+
46
+ print(f"\n{'─' * 60}")
47
+ print(f" Document Classifier β€” DistilBERT Fine-Tuning")
48
+ print(f" Base model : {CFG.model_checkpoint}")
49
+ print(f" Samples : {sample_label}")
50
+ print(f"{'─' * 60}\n")
51
+
52
+ # Load and tokenise dataset
53
+ dataset = load_ag_news(max_train=max_train, max_eval=max_eval, max_test=None)
54
+ tokenizer = get_tokenizer()
55
+ tokenised = tokenise_dataset(dataset, tokenizer)
56
+
57
+ # Train
58
+ trainer = trm.train(tokenised, tokenizer)
59
+
60
+ # Evaluate on test set
61
+ save_dir = "outputs/transformer"
62
+ results = trm.evaluate(trainer, tokenised, save_dir=save_dir)
63
+
64
+ # Save best checkpoint + tokeniser
65
+ trm.save_model(trainer, tokenizer)
66
+
67
+ print(f"\n Final test accuracy : {results['accuracy'] * 100:.2f}%")
68
+ print(f" Model saved to : saved_models/transformer/\n")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
transformer_model.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ transformer_model.py
3
+ ────────────────────
4
+ Updated for Mac M4 / Apple Silicon MPS.
5
+
6
+ Key changes vs Windows version:
7
+ - Removed use_cpu=True β†’ Trainer auto-detects MPS on Mac
8
+ - Added label_smoothing_factor
9
+ - Model-specific output directories (supports multiple architectures)
10
+ - Gradient checkpointing toggle
11
+ - Cleaned up device handling for inference
12
+ """
13
+ import logging
14
+ import os
15
+ import time
16
+ from typing import Dict, Optional, Tuple
17
+
18
+ import numpy as np
19
+ import torch
20
+ import matplotlib
21
+ matplotlib.use('Agg')
22
+ import matplotlib.pyplot as plt
23
+ import seaborn as sns
24
+ from sklearn.metrics import (
25
+ accuracy_score,
26
+ classification_report,
27
+ confusion_matrix,
28
+ f1_score,
29
+ )
30
+ from transformers import (
31
+ AutoModelForSequenceClassification,
32
+ AutoTokenizer,
33
+ DataCollatorWithPadding,
34
+ EarlyStoppingCallback,
35
+ PreTrainedTokenizerBase,
36
+ Trainer,
37
+ TrainingArguments,
38
+ )
39
+
40
+ from config import CFG
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # ── Helper ────────────────────────────────────────────────────────────────────
46
+
47
+ def _checkpoint_to_dir(checkpoint: str) -> str:
48
+ """Convert a HuggingFace checkpoint name to a safe directory name.
49
+
50
+ Examples:
51
+ 'roberta-base' β†’ 'roberta_base'
52
+ 'distilbert-base-uncased' β†’ 'distilbert_base_uncased'
53
+ """
54
+ return checkpoint.replace("/", "_").replace("-", "_")
55
+
56
+
57
+ # ── Model factory ─────────────────────────────────────────────────────────────
58
+
59
+ def build_model(checkpoint: str = None) -> AutoModelForSequenceClassification:
60
+ """Load a pre-trained encoder with a randomly-initialised classification head."""
61
+ if checkpoint is None:
62
+ checkpoint = CFG.model_checkpoint
63
+
64
+ model = AutoModelForSequenceClassification.from_pretrained(
65
+ checkpoint,
66
+ num_labels=CFG.num_labels,
67
+ id2label={i: n for i, n in enumerate(CFG.label_names)},
68
+ label2id={n: i for i, n in enumerate(CFG.label_names)},
69
+ )
70
+
71
+ if CFG.use_gradient_checkpointing:
72
+ model.gradient_checkpointing_enable()
73
+ logger.info("Gradient checkpointing: ON")
74
+
75
+ total = sum(p.numel() for p in model.parameters())
76
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
77
+ logger.info(f"Model: {checkpoint} | total={total:,} trainable={trainable:,}")
78
+ return model
79
+
80
+
81
+ # ── Training arguments ────────────────────────────────────────────────────────
82
+
83
+ def get_training_args(checkpoint: str = None, output_dir: str = None) -> TrainingArguments:
84
+ """
85
+ Build MPS-safe TrainingArguments for the HuggingFace Trainer.
86
+
87
+ Critical Mac M4 notes
88
+ ─────────────────────
89
+ β€’ Do NOT set use_cpu=True β€” the Trainer auto-detects MPS on Mac
90
+ β€’ fp16=False β€” MPS lacks full float16 operator coverage
91
+ β€’ bf16=False β€” Keep False for reliability (can try True on M2+)
92
+ β€’ dataloader_pin_memory=False β€” pin_memory only benefits CUDA
93
+ β€’ dataloader_num_workers=0 β€” HuggingFace torch datasets + multiprocessing
94
+ can be unstable on Mac; 0 is safest
95
+
96
+ Transformers 5.x deprecations handled here
97
+ ──────────────────────────────────────────
98
+ β€’ warmup_ratio β†’ warmup_steps (computed manually below)
99
+ β€’ logging_dir β†’ TENSORBOARD_LOGGING_DIR env var
100
+ """
101
+ if checkpoint is None:
102
+ checkpoint = CFG.model_checkpoint
103
+ if output_dir is None:
104
+ output_dir = os.path.join(CFG.outputs_dir, _checkpoint_to_dir(checkpoint))
105
+
106
+ # Compute warmup_steps from ratio (replaces deprecated warmup_ratio arg)
107
+ total_steps = (108_000 // CFG.batch_size) * CFG.num_epochs // CFG.grad_accum_steps
108
+ warmup_steps = int(total_steps * CFG.warmup_ratio)
109
+
110
+ # Set TensorBoard log dir via env var (replaces deprecated logging_dir arg)
111
+ os.environ["TENSORBOARD_LOGGING_DIR"] = CFG.logs_dir
112
+
113
+ return TrainingArguments(
114
+ output_dir=output_dir,
115
+
116
+ # ── Schedule ────────────────────────────────────────────────────────
117
+ num_train_epochs=CFG.num_epochs,
118
+ per_device_train_batch_size=CFG.batch_size,
119
+ per_device_eval_batch_size=CFG.batch_size * 2,
120
+ gradient_accumulation_steps=CFG.grad_accum_steps,
121
+
122
+ # ── Optimiser ──────────────────────────────────────���─────────────────
123
+ learning_rate=CFG.learning_rate,
124
+ weight_decay=CFG.weight_decay,
125
+ warmup_steps=warmup_steps, # replaces deprecated warmup_ratio
126
+ lr_scheduler_type="cosine",
127
+ label_smoothing_factor=CFG.label_smoothing,
128
+
129
+ # ── Evaluation & checkpointing ───────────────────────────────────────
130
+ eval_strategy="epoch",
131
+ save_strategy="epoch",
132
+ load_best_model_at_end=True,
133
+ metric_for_best_model="accuracy",
134
+ greater_is_better=True,
135
+ save_total_limit=2,
136
+
137
+ # ── Logging ──────────────────────────────────────────────────────────
138
+ # logging_dir is deprecated in transformers 5.x; use TENSORBOARD_LOGGING_DIR env var instead
139
+ logging_steps=100,
140
+ report_to="none",
141
+
142
+ # ── MPS / Mac-specific ────────────────────────────────────────────────
143
+ # NOTE: No use_cpu=True here β€” MPS is auto-detected on Mac M4
144
+ fp16=False,
145
+ bf16=False,
146
+ dataloader_num_workers=CFG.num_workers,
147
+ dataloader_pin_memory=False,
148
+
149
+ # ── Reproducibility ──────────────────────────────────────────────────
150
+ seed=CFG.seed,
151
+ data_seed=CFG.seed,
152
+ push_to_hub=False,
153
+ )
154
+
155
+
156
+ # ── Metrics ───────────────────────────────────────────────────────────────────
157
+
158
+ def compute_metrics(eval_pred) -> Dict[str, float]:
159
+ """Called by Trainer after every validation epoch."""
160
+ logits, labels = eval_pred
161
+ preds = np.argmax(logits, axis=-1)
162
+ return {
163
+ "accuracy": float(accuracy_score(labels, preds)),
164
+ "f1_macro": float(f1_score(labels, preds, average="macro")),
165
+ }
166
+
167
+
168
+ # ── Training pipeline ─────────────────────────────────────────────────────────
169
+
170
+ def train(tokenised_dataset, tokenizer: PreTrainedTokenizerBase,
171
+ checkpoint: str = None) -> Trainer:
172
+ """
173
+ Fine-tune a transformer encoder and return the Trainer
174
+ with the best checkpoint loaded.
175
+ """
176
+ if checkpoint is None:
177
+ checkpoint = CFG.model_checkpoint
178
+
179
+ model = build_model(checkpoint)
180
+ training_args = get_training_args(checkpoint)
181
+ data_collator = DataCollatorWithPadding(tokenizer, return_tensors="pt")
182
+
183
+ trainer = Trainer(
184
+ model=model,
185
+ args=training_args,
186
+ train_dataset=tokenised_dataset["train"],
187
+ eval_dataset=tokenised_dataset["validation"],
188
+ processing_class=tokenizer,
189
+ data_collator=data_collator,
190
+ compute_metrics=compute_metrics,
191
+ callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
192
+ )
193
+
194
+ steps_per_epoch = len(tokenised_dataset["train"]) // CFG.batch_size
195
+ device_label = "MPS (Metal)" if CFG.device == "mps" else CFG.device.upper()
196
+
197
+ logger.info("═" * 60)
198
+ logger.info(f" Fine-Tuning: {checkpoint}")
199
+ logger.info(f" Device : {device_label}")
200
+ logger.info(f" train : {len(tokenised_dataset['train']):,}")
201
+ logger.info(f" val : {len(tokenised_dataset['validation']):,}")
202
+ logger.info(f" epochs : {CFG.num_epochs}")
203
+ logger.info(f" batch : {CFG.batch_size}")
204
+ logger.info(f" steps/ep : {steps_per_epoch:,}")
205
+ logger.info(f" max_length : {CFG.max_length}")
206
+ logger.info("═" * 60)
207
+
208
+ t0 = time.perf_counter()
209
+ trainer.train()
210
+ elapsed = time.perf_counter() - t0
211
+
212
+ h, rem = divmod(int(elapsed), 3600)
213
+ m, s = divmod(rem, 60)
214
+ logger.info(f"Training complete: {h}h {m}m {s}s")
215
+ return trainer
216
+
217
+
218
+ # ── Evaluation ────────────────────────────────────────────────────────────────
219
+
220
+ def evaluate(trainer: Trainer, tokenised_dataset,
221
+ checkpoint: str = None, save_dir: str = None) -> Dict:
222
+ """Run predictions on the test split and print full report."""
223
+ if checkpoint is None:
224
+ checkpoint = CFG.model_checkpoint
225
+
226
+ logger.info(f"Evaluating {checkpoint} on test set …")
227
+ predictions = trainer.predict(tokenised_dataset["test"])
228
+
229
+ preds = np.argmax(predictions.predictions, axis=-1)
230
+ labels = predictions.label_ids
231
+
232
+ acc = accuracy_score(labels, preds)
233
+ report = classification_report(labels, preds,
234
+ target_names=CFG.label_names, digits=4)
235
+ cm = confusion_matrix(labels, preds)
236
+
237
+ print("\n" + "═" * 60)
238
+ print(f" {checkpoint.upper()} β€” TEST SET RESULTS")
239
+ print("═" * 60)
240
+ print(f" Accuracy : {acc * 100:.2f}%")
241
+ print(f" Metrics : {predictions.metrics}\n")
242
+ print(report)
243
+
244
+ _plot_cm(cm, f"{checkpoint} β€” Confusion Matrix",
245
+ save_dir=save_dir, cmap="Greens")
246
+
247
+ return {
248
+ "accuracy": acc,
249
+ "report": report,
250
+ "confusion_matrix": cm,
251
+ "metrics": predictions.metrics,
252
+ }
253
+
254
+
255
+ # ── Persistence ───────────────────────────────────────────────────────────────
256
+
257
+ def save_model(trainer: Trainer, tokenizer: PreTrainedTokenizerBase,
258
+ checkpoint: str = None) -> str:
259
+ """Save best checkpoint + tokeniser to saved_models/<model_dir>/."""
260
+ if checkpoint is None:
261
+ checkpoint = CFG.model_checkpoint
262
+ path = os.path.join(CFG.models_dir, _checkpoint_to_dir(checkpoint))
263
+ trainer.save_model(path)
264
+ tokenizer.save_pretrained(path)
265
+ logger.info(f"Model saved β†’ {path}")
266
+ return path
267
+
268
+
269
+ def load_model(checkpoint: str = None) -> Tuple:
270
+ """
271
+ Load a saved fine-tuned model and its tokeniser.
272
+
273
+ Parameters
274
+ ----------
275
+ checkpoint : HuggingFace checkpoint name, e.g. 'roberta-base'.
276
+ If None, uses CFG.model_checkpoint.
277
+
278
+ Returns
279
+ -------
280
+ (model, tokenizer) β€” model is in eval mode
281
+ """
282
+ if checkpoint is None:
283
+ checkpoint = CFG.model_checkpoint
284
+ path = os.path.join(CFG.models_dir, _checkpoint_to_dir(checkpoint))
285
+ if not os.path.isdir(path):
286
+ raise FileNotFoundError(
287
+ f"No saved model at '{path}'.\n"
288
+ f"Run: python train_transformer.py (or python train_multi.py)"
289
+ )
290
+ model = AutoModelForSequenceClassification.from_pretrained(path)
291
+ tokenizer = AutoTokenizer.from_pretrained(path)
292
+ model.eval()
293
+ logger.info(f"Model loaded ← {path}")
294
+ return model, tokenizer
295
+
296
+
297
+ def load_quantized_model(checkpoint: str = "distilbert-base-uncased") -> Tuple:
298
+ """
299
+ Load the INT8 dynamically quantized version of a model.
300
+ Falls back to the FP32 model if the INT8 version is not found.
301
+
302
+ Returns
303
+ -------
304
+ (model, tokenizer, is_quantized)
305
+ """
306
+ dir_name = _checkpoint_to_dir(checkpoint)
307
+ int8_path = os.path.join(CFG.models_dir, f"{dir_name}_int8")
308
+ fp32_path = os.path.join(CFG.models_dir, dir_name)
309
+ model_file = os.path.join(int8_path, "model_int8.pt")
310
+
311
+ if os.path.exists(model_file):
312
+ # Apple Silicon/ARM requires the qengine (qnnpack) to be set before deserialising
313
+ try:
314
+ torch.backends.quantized.engine = "qnnpack"
315
+ except Exception:
316
+ pass
317
+
318
+ try:
319
+ model = torch.load(model_file, map_location="cpu", weights_only=False)
320
+ except TypeError:
321
+ model = torch.load(model_file, map_location="cpu")
322
+ tokenizer = AutoTokenizer.from_pretrained(int8_path)
323
+ model.eval()
324
+ logger.info(f"INT8 quantized model loaded ← {int8_path}")
325
+ return model, tokenizer, True
326
+
327
+ logger.warning(f"INT8 model not found at {int8_path}. Falling back to FP32.")
328
+ model, tokenizer = load_model(checkpoint)
329
+ return model, tokenizer, False
330
+
331
+
332
+ # ── Helpers ───────────────────────────────────────────────────────────────────
333
+
334
+ def _plot_cm(cm: np.ndarray, title: str,
335
+ save_dir: str = None, cmap: str = "Blues") -> None:
336
+ fig, ax = plt.subplots(figsize=(7, 6))
337
+ sns.heatmap(cm, annot=True, fmt="d", cmap=cmap,
338
+ xticklabels=CFG.label_names, yticklabels=CFG.label_names,
339
+ linewidths=0.5, ax=ax)
340
+ ax.set_xlabel("Predicted Label", fontsize=11)
341
+ ax.set_ylabel("True Label", fontsize=11)
342
+ ax.set_title(title, fontsize=13, fontweight="bold")
343
+ plt.tight_layout()
344
+ if save_dir:
345
+ os.makedirs(save_dir, exist_ok=True)
346
+ path = os.path.join(save_dir, "confusion_matrix.png")
347
+ plt.savefig(path, dpi=150)
348
+ logger.info(f"Confusion matrix β†’ {path}")
349
+ plt.show()
350
+ plt.close(fig)