Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- Dockerfile +27 -0
- README.md +14 -5
- api.py +456 -0
- compare_results.py +132 -0
- config.py +85 -0
- data_loader.py +144 -0
- database.py +383 -0
- download_dataset.py +49 -0
- ensemble.py +349 -0
- error_analysis.py +211 -0
- hyperparameter_search.py +365 -0
- predict.py +267 -0
- pyproject.toml +25 -0
- pytest.ini +6 -0
- quantize_model.py +285 -0
- requirements.txt +43 -0
- saved_models/distilbert_base_uncased/config.json +39 -0
- saved_models/distilbert_base_uncased/model.safetensors +3 -0
- saved_models/distilbert_base_uncased/tokenizer.json +0 -0
- saved_models/distilbert_base_uncased/tokenizer_config.json +15 -0
- saved_models/distilbert_base_uncased/training_args.bin +3 -0
- saved_models/distilbert_base_uncased_int8/config.json +39 -0
- saved_models/distilbert_base_uncased_int8/model_int8.pt +3 -0
- saved_models/distilbert_base_uncased_int8/quantization_info.json +7 -0
- saved_models/distilbert_base_uncased_int8/tokenizer.json +0 -0
- saved_models/distilbert_base_uncased_int8/tokenizer_config.json +15 -0
- saved_models/traditional_lr.joblib +3 -0
- saved_models/traditional_lr_optimized.joblib +3 -0
- saved_models/traditional_svm.joblib +3 -0
- saved_models/traditional_svm_optimized.joblib +3 -0
- tests/__init__.py +1 -0
- tests/conftest.py +113 -0
- tests/test_api.py +176 -0
- tests/test_config.py +26 -0
- tests/test_data_loader.py +39 -0
- tests/test_database.py +87 -0
- tests/test_traditional_model.py +42 -0
- tests/test_transformer_model.py +33 -0
- traditional_model.py +204 -0
- train_multi.py +118 -0
- train_traditional.py +81 -0
- train_transformer.py +72 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|