Spaces:
Sleeping
Sleeping
feat: initial deployment
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +32 -0
- README.md +13 -3
- api/__init__.py +0 -0
- api/__pycache__/__init__.cpython-312.pyc +0 -0
- api/__pycache__/main.cpython-312.pyc +0 -0
- api/__pycache__/schemas.cpython-312.pyc +0 -0
- api/main.py +54 -0
- api/routes/__init__.py +0 -0
- api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- api/routes/__pycache__/models.cpython-312.pyc +0 -0
- api/routes/__pycache__/monitoring.cpython-312.pyc +0 -0
- api/routes/__pycache__/predict.cpython-312.pyc +0 -0
- api/routes/models.py +22 -0
- api/routes/monitoring.py +47 -0
- api/routes/predict.py +78 -0
- api/schemas.py +58 -0
- app.py +95 -0
- app/__pycache__/api_client.cpython-312.pyc +0 -0
- app/api_client.py +62 -0
- app/pages/compare.py +100 -0
- app/pages/monitoring.py +90 -0
- app/pages/predict.py +67 -0
- app/streamlit_app.py +14 -0
- artifacts/label_map.json +153 -0
- configs/classical.yaml +25 -0
- configs/neural.yaml +41 -0
- configs/transformer.yaml +29 -0
- requirements.txt +19 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/data/__init__.py +0 -0
- src/data/__pycache__/__init__.cpython-312.pyc +0 -0
- src/data/__pycache__/loader.cpython-312.pyc +0 -0
- src/data/__pycache__/preprocessor.cpython-312.pyc +0 -0
- src/data/loader.py +45 -0
- src/data/preprocessor.py +62 -0
- src/evaluation/__init__.py +0 -0
- src/evaluation/__pycache__/__init__.cpython-312.pyc +0 -0
- src/evaluation/__pycache__/metrics.cpython-312.pyc +0 -0
- src/evaluation/evaluate_all.py +417 -0
- src/evaluation/metrics.py +47 -0
- src/features/__init__.py +0 -0
- src/features/__pycache__/__init__.cpython-312.pyc +0 -0
- src/features/__pycache__/tfidf.cpython-312.pyc +0 -0
- src/features/tfidf.py +50 -0
- src/models/__init__.py +0 -0
- src/models/__pycache__/__init__.cpython-312.pyc +0 -0
- src/models/__pycache__/classical.cpython-312.pyc +0 -0
- src/models/__pycache__/neural.cpython-312.pyc +0 -0
- src/models/__pycache__/transformer.cpython-312.pyc +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
curl \
|
| 7 |
+
git \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
RUN pip install uv --no-cache-dir
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY src/ src/
|
| 16 |
+
COPY api/ api/
|
| 17 |
+
COPY app/ app/
|
| 18 |
+
COPY configs/ configs/
|
| 19 |
+
COPY app.py .
|
| 20 |
+
|
| 21 |
+
RUN mkdir -p artifacts/models/distilbert \
|
| 22 |
+
artifacts/vectorizers \
|
| 23 |
+
artifacts/monitoring \
|
| 24 |
+
data/raw
|
| 25 |
+
|
| 26 |
+
ENV PYTHONPATH=/app
|
| 27 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 28 |
+
ENV PYTHONUNBUFFERED=1
|
| 29 |
+
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
| 1 |
---
|
| 2 |
title: Intent Classifier
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Intent Classifier
|
| 3 |
+
emoji: 🧠
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Intent Classifier
|
| 12 |
+
|
| 13 |
+
End-to-end intent classification system with full MLOps pipeline.
|
| 14 |
+
|
| 15 |
+
Classifies user queries into 151 intent categories using CLINC150 dataset.
|
| 16 |
+
Compares 6 model architectures: LogReg, SVM, TextCNN, RNN, LSTM, DistilBERT.
|
| 17 |
+
|
| 18 |
+
## Source
|
| 19 |
+
|
| 20 |
+
[github.com/princ0301/intent-classifier](https://github.com/princ0301/intent-classifier)
|
api/__init__.py
ADDED
|
File without changes
|
api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (139 Bytes). View file
|
|
|
api/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (2.47 kB). View file
|
|
|
api/__pycache__/schemas.cpython-312.pyc
ADDED
|
Binary file (2.97 kB). View file
|
|
|
api/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
from api.routes import models, monitoring, predict
|
| 7 |
+
from api.schemas import HealthResponse
|
| 8 |
+
from src.serving.ab_router import ABRouter
|
| 9 |
+
from src.serving.predictor import Predictor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@asynccontextmanager
|
| 13 |
+
async def lifespan(app: FastAPI):
|
| 14 |
+
print("loading predictors...")
|
| 15 |
+
app.state.predictors = {
|
| 16 |
+
"classical": Predictor("classical"),
|
| 17 |
+
"svm": Predictor("svm"),
|
| 18 |
+
"transformer": Predictor("transformer"),
|
| 19 |
+
}
|
| 20 |
+
print("loading AB router...")
|
| 21 |
+
app.state.ab_router = ABRouter()
|
| 22 |
+
print("startup complete")
|
| 23 |
+
|
| 24 |
+
yield
|
| 25 |
+
|
| 26 |
+
app.state.predictors.clear()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
app = FastAPI(
|
| 30 |
+
title="Intent Classifier API",
|
| 31 |
+
description="End-to-end intent classification with classical ML, neural networks, and fine-tuned transformers",
|
| 32 |
+
version="1.0.0",
|
| 33 |
+
lifespan=lifespan,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
app.add_middleware(
|
| 37 |
+
CORSMiddleware,
|
| 38 |
+
allow_origins=["*"],
|
| 39 |
+
allow_credentials=True,
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
app.include_router(predict.router, tags=["predict"])
|
| 45 |
+
app.include_router(models.router, tags=["models"])
|
| 46 |
+
app.include_router(monitoring.router, tags=["monitoring"])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.get("/health", response_model=HealthResponse)
|
| 50 |
+
def health(request: Request) -> HealthResponse:
|
| 51 |
+
return HealthResponse(
|
| 52 |
+
status="ok",
|
| 53 |
+
models_loaded=list(request.app.state.predictors.keys()),
|
| 54 |
+
)
|
api/routes/__init__.py
ADDED
|
File without changes
|
api/routes/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
api/routes/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (1.37 kB). View file
|
|
|
api/routes/__pycache__/monitoring.cpython-312.pyc
ADDED
|
Binary file (2.26 kB). View file
|
|
|
api/routes/__pycache__/predict.cpython-312.pyc
ADDED
|
Binary file (3.94 kB). View file
|
|
|
api/routes/models.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request
|
| 2 |
+
|
| 3 |
+
from api.schemas import ModelInfo, ModelsResponse
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
REGISTERED_MODELS = [
|
| 8 |
+
{"name": "intent-classifier-logreg", "alias": "challenger"},
|
| 9 |
+
{"name": "intent-classifier-svm", "alias": "challenger"},
|
| 10 |
+
{"name": "intent-classifier-distilbert", "alias": "champion"},
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/models", response_model=ModelsResponse)
|
| 15 |
+
def list_models() -> ModelsResponse:
|
| 16 |
+
return ModelsResponse(models=[ModelInfo(name=m["name"], alias=m["alias"]) for m in REGISTERED_MODELS])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/models/loaded")
|
| 20 |
+
def list_loaded_models(request: Request) -> dict:
|
| 21 |
+
predictors = request.app.state.predictors
|
| 22 |
+
return {"loaded_models": list(predictors.keys())}
|
api/routes/monitoring.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 2 |
+
|
| 3 |
+
from api.schemas import ABStatsResponse, DriftResponse
|
| 4 |
+
from src.monitoring.drift import (
|
| 5 |
+
get_confidence_drift,
|
| 6 |
+
get_drift_summary,
|
| 7 |
+
get_oos_rate_drift,
|
| 8 |
+
load_current_data,
|
| 9 |
+
load_reference_data,
|
| 10 |
+
run_drift_report,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/monitoring/drift", response_model=DriftResponse)
|
| 17 |
+
def get_drift() -> DriftResponse:
|
| 18 |
+
try:
|
| 19 |
+
reference_df = load_reference_data()
|
| 20 |
+
current_df = load_current_data()
|
| 21 |
+
except FileNotFoundError as e:
|
| 22 |
+
raise HTTPException(status_code=404, detail=str(e))
|
| 23 |
+
|
| 24 |
+
snapshot = run_drift_report(reference_df, current_df)
|
| 25 |
+
drift_summary = get_drift_summary(snapshot)
|
| 26 |
+
confidence_drift = get_confidence_drift(reference_df, current_df)
|
| 27 |
+
oos_rate_drift = get_oos_rate_drift(reference_df, current_df)
|
| 28 |
+
|
| 29 |
+
return DriftResponse(
|
| 30 |
+
drift_summary=drift_summary,
|
| 31 |
+
confidence_drift=confidence_drift,
|
| 32 |
+
oos_rate_drift=oos_rate_drift,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/monitoring/ab-stats", response_model=ABStatsResponse)
|
| 37 |
+
def get_ab_stats(request: Request) -> ABStatsResponse:
|
| 38 |
+
ab_router = request.app.state.ab_router
|
| 39 |
+
stats = ab_router.get_stats()
|
| 40 |
+
return ABStatsResponse(**stats)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post("/monitoring/reset")
|
| 44 |
+
def reset_monitoring(request: Request) -> dict:
|
| 45 |
+
ab_router = request.app.state.ab_router
|
| 46 |
+
ab_router.reset_stats()
|
| 47 |
+
return {"status": "reset"}
|
api/routes/predict.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 2 |
+
|
| 3 |
+
from api.schemas import (
|
| 4 |
+
BatchRequest,
|
| 5 |
+
BatchResponse,
|
| 6 |
+
PredictRequest,
|
| 7 |
+
PredictResponse,
|
| 8 |
+
TopIntent,
|
| 9 |
+
)
|
| 10 |
+
from src.monitoring.drift import append_current_data
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _to_predict_response(result, ab_variant: str | None = None) -> PredictResponse:
|
| 16 |
+
return PredictResponse(
|
| 17 |
+
intent=result.intent,
|
| 18 |
+
confidence=result.confidence,
|
| 19 |
+
top5=[TopIntent(**item) for item in result.top5],
|
| 20 |
+
latency_ms=result.latency_ms,
|
| 21 |
+
is_oos=result.is_oos,
|
| 22 |
+
model_used=result.model_used,
|
| 23 |
+
ab_variant=ab_variant,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/predict", response_model=PredictResponse)
|
| 28 |
+
def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
| 29 |
+
predictors = request.app.state.predictors
|
| 30 |
+
ab_router = request.app.state.ab_router
|
| 31 |
+
|
| 32 |
+
if payload.model_type:
|
| 33 |
+
if payload.model_type not in predictors:
|
| 34 |
+
raise HTTPException(status_code=400, detail=f"unknown model_type: {payload.model_type}")
|
| 35 |
+
result = predictors[payload.model_type].predict(payload.text)
|
| 36 |
+
response = _to_predict_response(result)
|
| 37 |
+
else:
|
| 38 |
+
result_dict = ab_router.predict(payload.text)
|
| 39 |
+
response = PredictResponse(
|
| 40 |
+
intent=result_dict["intent"],
|
| 41 |
+
confidence=result_dict["confidence"],
|
| 42 |
+
top5=[TopIntent(**item) for item in result_dict["top5"]],
|
| 43 |
+
latency_ms=result_dict["latency_ms"],
|
| 44 |
+
is_oos=result_dict["is_oos"],
|
| 45 |
+
model_used=result_dict["model_used"],
|
| 46 |
+
ab_variant=result_dict["ab_variant"],
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
append_current_data(
|
| 50 |
+
texts=[payload.text],
|
| 51 |
+
predictions=[response.intent],
|
| 52 |
+
confidences=[response.confidence],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return response
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("/predict/batch", response_model=BatchResponse)
|
| 59 |
+
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
| 60 |
+
predictors = request.app.state.predictors
|
| 61 |
+
|
| 62 |
+
model_type = payload.model_type or "transformer"
|
| 63 |
+
if model_type not in predictors:
|
| 64 |
+
raise HTTPException(status_code=400, detail=f"unknown model_type: {model_type}")
|
| 65 |
+
|
| 66 |
+
predictor = predictors[model_type]
|
| 67 |
+
results = predictor.predict_batch(payload.texts)
|
| 68 |
+
responses = [_to_predict_response(r) for r in results]
|
| 69 |
+
|
| 70 |
+
total_latency = sum(r.latency_ms for r in responses)
|
| 71 |
+
|
| 72 |
+
append_current_data(
|
| 73 |
+
texts=payload.texts,
|
| 74 |
+
predictions=[r.intent for r in responses],
|
| 75 |
+
confidences=[r.confidence for r in responses],
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return BatchResponse(predictions=responses, total_latency_ms=round(total_latency, 3))
|
api/schemas.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PredictRequest(BaseModel):
|
| 5 |
+
text: str = Field(..., min_length=1, max_length=500)
|
| 6 |
+
model_type: str | None = None
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TopIntent(BaseModel):
|
| 10 |
+
intent: str
|
| 11 |
+
confidence: float
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PredictResponse(BaseModel):
|
| 15 |
+
intent: str
|
| 16 |
+
confidence: float
|
| 17 |
+
top5: list[TopIntent]
|
| 18 |
+
latency_ms: float
|
| 19 |
+
is_oos: bool
|
| 20 |
+
model_used: str
|
| 21 |
+
ab_variant: str | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class BatchRequest(BaseModel):
|
| 25 |
+
texts: list[str] = Field(..., min_length=1, max_length=100)
|
| 26 |
+
model_type: str | None = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class BatchResponse(BaseModel):
|
| 30 |
+
predictions: list[PredictResponse]
|
| 31 |
+
total_latency_ms: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ModelInfo(BaseModel):
|
| 35 |
+
name: str
|
| 36 |
+
alias: str
|
| 37 |
+
version: str | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ModelsResponse(BaseModel):
|
| 41 |
+
models: list[ModelInfo]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class HealthResponse(BaseModel):
|
| 45 |
+
status: str
|
| 46 |
+
models_loaded: list[str]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class DriftResponse(BaseModel):
|
| 50 |
+
drift_summary: dict
|
| 51 |
+
confidence_drift: dict
|
| 52 |
+
oos_rate_drift: dict
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ABStatsResponse(BaseModel):
|
| 56 |
+
model_a: dict
|
| 57 |
+
model_b: dict
|
| 58 |
+
split: float
|
app.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def start_api():
|
| 8 |
+
subprocess.run(
|
| 9 |
+
[
|
| 10 |
+
"uvicorn",
|
| 11 |
+
"api.main:app",
|
| 12 |
+
"--host",
|
| 13 |
+
"0.0.0.0",
|
| 14 |
+
"--port",
|
| 15 |
+
"8000",
|
| 16 |
+
],
|
| 17 |
+
check=False,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def wait_for_api(timeout: int = 120) -> bool:
|
| 22 |
+
import requests
|
| 23 |
+
|
| 24 |
+
start = time.time()
|
| 25 |
+
while time.time() - start < timeout:
|
| 26 |
+
try:
|
| 27 |
+
requests.get("http://localhost:8000/health", timeout=2)
|
| 28 |
+
return True
|
| 29 |
+
except Exception:
|
| 30 |
+
time.sleep(2)
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def download_models():
|
| 35 |
+
from src.storage.s3 import download_artifact
|
| 36 |
+
|
| 37 |
+
print("downloading models from S3...")
|
| 38 |
+
|
| 39 |
+
os.makedirs("artifacts/models/distilbert", exist_ok=True)
|
| 40 |
+
os.makedirs("artifacts/vectorizers", exist_ok=True)
|
| 41 |
+
|
| 42 |
+
download_artifact("classical/logreg.pkl", "artifacts/models/logreg.pkl")
|
| 43 |
+
download_artifact("classical/svm.pkl", "artifacts/models/svm.pkl")
|
| 44 |
+
download_artifact("classical/tfidf.pkl", "artifacts/vectorizers/tfidf.pkl")
|
| 45 |
+
download_artifact("neural/vocab.pkl", "artifacts/models/vocab.pkl")
|
| 46 |
+
download_artifact("transformer/distilbert/config.json", "artifacts/models/distilbert/config.json")
|
| 47 |
+
download_artifact("transformer/distilbert/model.safetensors", "artifacts/models/distilbert/model.safetensors")
|
| 48 |
+
download_artifact("transformer/distilbert/tokenizer.json", "artifacts/models/distilbert/tokenizer.json")
|
| 49 |
+
download_artifact("transformer/distilbert/tokenizer_config.json", "artifacts/models/distilbert/tokenizer_config.json")
|
| 50 |
+
download_artifact("transformer/distilbert/special_tokens_map.json", "artifacts/models/distilbert/special_tokens_map.json")
|
| 51 |
+
download_artifact("transformer/distilbert/vocab.txt", "artifacts/models/distilbert/vocab.txt")
|
| 52 |
+
|
| 53 |
+
print("models downloaded")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def generate_label_map():
|
| 57 |
+
from src.data.loader import load_clinc150, save_splits
|
| 58 |
+
from src.data.preprocessor import preprocess
|
| 59 |
+
|
| 60 |
+
label_map_path = "artifacts/label_map.json"
|
| 61 |
+
if os.path.exists(label_map_path):
|
| 62 |
+
print("label map already exists")
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
print("generating label map...")
|
| 66 |
+
splits = load_clinc150("plus")
|
| 67 |
+
save_splits(splits)
|
| 68 |
+
preprocess(splits)
|
| 69 |
+
print("label map generated")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
download_models()
|
| 74 |
+
generate_label_map()
|
| 75 |
+
|
| 76 |
+
api_thread = threading.Thread(target=start_api, daemon=True)
|
| 77 |
+
api_thread.start()
|
| 78 |
+
|
| 79 |
+
print("waiting for API to start...")
|
| 80 |
+
if wait_for_api():
|
| 81 |
+
print("API ready")
|
| 82 |
+
else:
|
| 83 |
+
print("API startup timed out — continuing anyway")
|
| 84 |
+
|
| 85 |
+
os.execv(
|
| 86 |
+
"/usr/bin/streamlit",
|
| 87 |
+
[
|
| 88 |
+
"streamlit",
|
| 89 |
+
"run",
|
| 90 |
+
"app/streamlit_app.py",
|
| 91 |
+
"--server.port=7860",
|
| 92 |
+
"--server.address=0.0.0.0",
|
| 93 |
+
"--server.headless=true",
|
| 94 |
+
],
|
| 95 |
+
)
|
app/__pycache__/api_client.cpython-312.pyc
ADDED
|
Binary file (3.09 kB). View file
|
|
|
app/api_client.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def api_predict(text: str, model_type: str | None = None) -> dict:
|
| 10 |
+
payload = {"text": text}
|
| 11 |
+
if model_type:
|
| 12 |
+
payload["model_type"] = model_type
|
| 13 |
+
response = requests.post(f"{API_BASE_URL}/predict", json=payload, timeout=30)
|
| 14 |
+
response.raise_for_status()
|
| 15 |
+
return response.json()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def api_health() -> dict:
|
| 19 |
+
response = requests.get(f"{API_BASE_URL}/health", timeout=10)
|
| 20 |
+
response.raise_for_status()
|
| 21 |
+
return response.json()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def api_models() -> dict:
|
| 25 |
+
response = requests.get(f"{API_BASE_URL}/models", timeout=10)
|
| 26 |
+
response.raise_for_status()
|
| 27 |
+
return response.json()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def api_ab_stats() -> dict:
|
| 31 |
+
response = requests.get(f"{API_BASE_URL}/monitoring/ab-stats", timeout=10)
|
| 32 |
+
response.raise_for_status()
|
| 33 |
+
return response.json()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def api_drift() -> dict:
|
| 37 |
+
response = requests.get(f"{API_BASE_URL}/monitoring/drift", timeout=30)
|
| 38 |
+
response.raise_for_status()
|
| 39 |
+
return response.json()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def api_reset_monitoring() -> dict:
|
| 43 |
+
response = requests.post(f"{API_BASE_URL}/monitoring/reset", timeout=10)
|
| 44 |
+
response.raise_for_status()
|
| 45 |
+
return response.json()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def check_api_connection() -> bool:
|
| 49 |
+
try:
|
| 50 |
+
api_health()
|
| 51 |
+
return True
|
| 52 |
+
except requests.exceptions.RequestException:
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def require_api():
|
| 57 |
+
if not check_api_connection():
|
| 58 |
+
st.error(
|
| 59 |
+
f"Cannot connect to API at {API_BASE_URL}. "
|
| 60 |
+
"Start it with: `uv run uvicorn api.main:app --reload --port 8000`"
|
| 61 |
+
)
|
| 62 |
+
st.stop()
|
app/pages/compare.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
from app.api_client import api_predict, require_api
|
| 10 |
+
|
| 11 |
+
require_api()
|
| 12 |
+
|
| 13 |
+
st.title("Compare Models")
|
| 14 |
+
st.caption("Side-by-side comparison of all trained models on CLINC150 test set.")
|
| 15 |
+
|
| 16 |
+
RESULTS = {
|
| 17 |
+
"logreg": {
|
| 18 |
+
"accuracy": 0.8058,
|
| 19 |
+
"macro_f1": 0.8416,
|
| 20 |
+
"weighted_f1": 0.7985,
|
| 21 |
+
"latency_p50_ms": 0.12,
|
| 22 |
+
},
|
| 23 |
+
"svm": {
|
| 24 |
+
"accuracy": 0.8002,
|
| 25 |
+
"macro_f1": 0.8474,
|
| 26 |
+
"weighted_f1": 0.7795,
|
| 27 |
+
"latency_p50_ms": 0.14,
|
| 28 |
+
},
|
| 29 |
+
"textcnn": {
|
| 30 |
+
"accuracy": 0.7740,
|
| 31 |
+
"macro_f1": 0.8244,
|
| 32 |
+
"weighted_f1": 0.7646,
|
| 33 |
+
"latency_p50_ms": 97.6,
|
| 34 |
+
},
|
| 35 |
+
"rnn": {
|
| 36 |
+
"accuracy": 0.1818,
|
| 37 |
+
"macro_f1": 0.0020,
|
| 38 |
+
"weighted_f1": 0.0560,
|
| 39 |
+
"latency_p50_ms": 124.5,
|
| 40 |
+
},
|
| 41 |
+
"lstm": {
|
| 42 |
+
"accuracy": 0.7669,
|
| 43 |
+
"macro_f1": 0.8113,
|
| 44 |
+
"weighted_f1": 0.7555,
|
| 45 |
+
"latency_p50_ms": 249.2,
|
| 46 |
+
},
|
| 47 |
+
"distilbert": {
|
| 48 |
+
"accuracy": 0.8765,
|
| 49 |
+
"macro_f1": 0.9019,
|
| 50 |
+
"weighted_f1": 0.8722,
|
| 51 |
+
"latency_p50_ms": 9.6,
|
| 52 |
+
},
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
df = pd.DataFrame(RESULTS).T
|
| 56 |
+
df.index.name = "model"
|
| 57 |
+
|
| 58 |
+
st.subheader("Metrics Table")
|
| 59 |
+
st.dataframe(
|
| 60 |
+
df.style.format(
|
| 61 |
+
{
|
| 62 |
+
"accuracy": "{:.2%}",
|
| 63 |
+
"macro_f1": "{:.2%}",
|
| 64 |
+
"weighted_f1": "{:.2%}",
|
| 65 |
+
"latency_p50_ms": "{:.2f} ms",
|
| 66 |
+
}
|
| 67 |
+
).background_gradient(subset=["accuracy", "macro_f1", "weighted_f1"], cmap="Greens"),
|
| 68 |
+
use_container_width=True,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
st.subheader("Accuracy & F1 Comparison")
|
| 72 |
+
chart_df = df[["accuracy", "macro_f1", "weighted_f1"]]
|
| 73 |
+
st.bar_chart(chart_df)
|
| 74 |
+
|
| 75 |
+
st.subheader("Latency Comparison")
|
| 76 |
+
st.bar_chart(df[["latency_p50_ms"]])
|
| 77 |
+
|
| 78 |
+
st.divider()
|
| 79 |
+
st.subheader("Side-by-Side Prediction")
|
| 80 |
+
st.caption("Run the same input through multiple models at once.")
|
| 81 |
+
|
| 82 |
+
text = st.text_input("Enter a query to compare", placeholder="e.g. cancel my hotel reservation")
|
| 83 |
+
selected_models = st.multiselect(
|
| 84 |
+
"Models to compare",
|
| 85 |
+
options=["classical", "svm", "transformer"],
|
| 86 |
+
default=["classical", "transformer"],
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if st.button("Compare", type="primary", disabled=not (text and selected_models)):
|
| 90 |
+
cols = st.columns(len(selected_models))
|
| 91 |
+
for col, model_type in zip(cols, selected_models):
|
| 92 |
+
with col:
|
| 93 |
+
with st.spinner(f"running {model_type}..."):
|
| 94 |
+
result = api_predict(text, model_type)
|
| 95 |
+
st.markdown(f"**{model_type}**")
|
| 96 |
+
st.metric("Intent", result["intent"])
|
| 97 |
+
st.metric("Confidence", f"{result['confidence']:.2%}")
|
| 98 |
+
st.metric("Latency", f"{result['latency_ms']:.1f} ms")
|
| 99 |
+
if result["is_oos"]:
|
| 100 |
+
st.warning("flagged OOS")
|
app/pages/monitoring.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
from app.api_client import api_ab_stats, api_drift, api_reset_monitoring, require_api
|
| 9 |
+
|
| 10 |
+
require_api()
|
| 11 |
+
|
| 12 |
+
st.title("Monitoring Dashboard")
|
| 13 |
+
st.caption("Live A/B test stats and data drift detection.")
|
| 14 |
+
|
| 15 |
+
st.subheader("A/B Test Stats")
|
| 16 |
+
|
| 17 |
+
if st.button("Refresh stats"):
|
| 18 |
+
st.rerun()
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
stats = api_ab_stats()
|
| 22 |
+
|
| 23 |
+
col1, col2 = st.columns(2)
|
| 24 |
+
|
| 25 |
+
with col1:
|
| 26 |
+
st.markdown(f"### Model A — `{stats['model_a']['model_name']}`")
|
| 27 |
+
st.metric("Requests", stats["model_a"]["request_count"])
|
| 28 |
+
st.metric("Avg Latency", f"{stats['model_a']['avg_latency_ms']:.2f} ms")
|
| 29 |
+
st.metric("Avg Confidence", f"{stats['model_a']['avg_confidence']:.2%}")
|
| 30 |
+
st.metric("OOS Rate", f"{stats['model_a']['oos_rate']:.2%}")
|
| 31 |
+
|
| 32 |
+
with col2:
|
| 33 |
+
st.markdown(f"### Model B — `{stats['model_b']['model_name']}`")
|
| 34 |
+
st.metric("Requests", stats["model_b"]["request_count"])
|
| 35 |
+
st.metric("Avg Latency", f"{stats['model_b']['avg_latency_ms']:.2f} ms")
|
| 36 |
+
st.metric("Avg Confidence", f"{stats['model_b']['avg_confidence']:.2%}")
|
| 37 |
+
st.metric("OOS Rate", f"{stats['model_b']['oos_rate']:.2%}")
|
| 38 |
+
|
| 39 |
+
st.caption(f"Configured split: {stats['split']:.0%} to model B")
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
st.warning(f"No A/B stats yet. Make some predictions on the Predict page first. ({e})")
|
| 43 |
+
|
| 44 |
+
if st.button("Reset A/B stats"):
|
| 45 |
+
api_reset_monitoring()
|
| 46 |
+
st.success("Stats reset.")
|
| 47 |
+
st.rerun()
|
| 48 |
+
|
| 49 |
+
st.divider()
|
| 50 |
+
st.subheader("Data Drift Report")
|
| 51 |
+
st.caption("Compares reference traffic (initial baseline) against recent production traffic.")
|
| 52 |
+
|
| 53 |
+
if st.button("Run drift check"):
|
| 54 |
+
try:
|
| 55 |
+
with st.spinner("computing drift..."):
|
| 56 |
+
drift = api_drift()
|
| 57 |
+
|
| 58 |
+
col1, col2, col3 = st.columns(3)
|
| 59 |
+
col1.metric(
|
| 60 |
+
"Drifted Columns",
|
| 61 |
+
int(drift["drift_summary"].get("drifted_columns_count", 0)),
|
| 62 |
+
)
|
| 63 |
+
col2.metric(
|
| 64 |
+
"Confidence Drop",
|
| 65 |
+
f"{drift['confidence_drift']['confidence_drop']:.2%}",
|
| 66 |
+
delta=f"{-drift['confidence_drift']['confidence_drop']:.2%}",
|
| 67 |
+
)
|
| 68 |
+
col3.metric(
|
| 69 |
+
"OOS Rate Increase",
|
| 70 |
+
f"{drift['oos_rate_drift']['oos_rate_increase']:.2%}",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if drift["confidence_drift"]["is_degraded"]:
|
| 74 |
+
st.error("Confidence has degraded significantly vs reference traffic.")
|
| 75 |
+
else:
|
| 76 |
+
st.success("Confidence is stable vs reference traffic.")
|
| 77 |
+
|
| 78 |
+
if drift["oos_rate_drift"]["is_anomalous"]:
|
| 79 |
+
st.error("OOS rate has increased anomalously — possible topic drift in incoming queries.")
|
| 80 |
+
else:
|
| 81 |
+
st.success("OOS rate is within normal range.")
|
| 82 |
+
|
| 83 |
+
with st.expander("Raw drift details"):
|
| 84 |
+
st.json(drift)
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
st.warning(
|
| 88 |
+
"No reference/current data available yet. Run `verify_phase11.py` first to "
|
| 89 |
+
f"generate baseline monitoring data. ({e})"
|
| 90 |
+
)
|
app/pages/predict.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
from app.api_client import api_predict, require_api
|
| 10 |
+
|
| 11 |
+
require_api()
|
| 12 |
+
|
| 13 |
+
st.title("Predict Intent")
|
| 14 |
+
st.caption("Test the intent classifier live, switching between models or letting the A/B router decide.")
|
| 15 |
+
|
| 16 |
+
col1, col2 = st.columns([3, 1])
|
| 17 |
+
|
| 18 |
+
with col1:
|
| 19 |
+
text = st.text_input("Enter a query", placeholder="e.g. what is my account balance")
|
| 20 |
+
|
| 21 |
+
with col2:
|
| 22 |
+
model_choice = st.selectbox(
|
| 23 |
+
"Model",
|
| 24 |
+
options=["A/B Router", "Classical (LogReg)", "SVM", "Transformer (DistilBERT)"],
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
model_map = {
|
| 28 |
+
"A/B Router": None,
|
| 29 |
+
"Classical (LogReg)": "classical",
|
| 30 |
+
"SVM": "svm",
|
| 31 |
+
"Transformer (DistilBERT)": "transformer",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if st.button("Predict", type="primary", disabled=not text):
|
| 35 |
+
with st.spinner("predicting..."):
|
| 36 |
+
result = api_predict(text, model_map[model_choice])
|
| 37 |
+
|
| 38 |
+
col_a, col_b, col_c = st.columns(3)
|
| 39 |
+
col_a.metric("Intent", result["intent"])
|
| 40 |
+
col_b.metric("Confidence", f"{result['confidence']:.2%}")
|
| 41 |
+
col_c.metric("Latency", f"{result['latency_ms']:.1f} ms")
|
| 42 |
+
|
| 43 |
+
if result["is_oos"]:
|
| 44 |
+
st.warning("This query was flagged as out-of-scope (low confidence).")
|
| 45 |
+
|
| 46 |
+
if result.get("ab_variant"):
|
| 47 |
+
st.info(f"Served by A/B variant **{result['ab_variant']}** using model `{result['model_used']}`")
|
| 48 |
+
else:
|
| 49 |
+
st.info(f"Served by model `{result['model_used']}`")
|
| 50 |
+
|
| 51 |
+
st.subheader("Top 5 Predictions")
|
| 52 |
+
df = pd.DataFrame(result["top5"])
|
| 53 |
+
df["confidence"] = df["confidence"].astype(float)
|
| 54 |
+
st.bar_chart(df.set_index("intent")["confidence"], horizontal=True)
|
| 55 |
+
|
| 56 |
+
st.divider()
|
| 57 |
+
st.caption("Sample queries to try:")
|
| 58 |
+
samples = [
|
| 59 |
+
"what is my account balance",
|
| 60 |
+
"book a flight to new york",
|
| 61 |
+
"set an alarm for 7am",
|
| 62 |
+
"tell me about quantum physics",
|
| 63 |
+
"write me a poem about the ocean",
|
| 64 |
+
]
|
| 65 |
+
cols = st.columns(len(samples))
|
| 66 |
+
for col, sample in zip(cols, samples):
|
| 67 |
+
col.code(sample, language=None)
|
app/streamlit_app.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
|
| 3 |
+
st.set_page_config(
|
| 4 |
+
page_title="Intent Classifier",
|
| 5 |
+
page_icon=":material/psychology:",
|
| 6 |
+
layout="wide",
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
predict_page = st.Page("pages/predict.py", title="Predict", icon=":material/chat:")
|
| 10 |
+
compare_page = st.Page("pages/compare.py", title="Compare Models", icon=":material/bar_chart:")
|
| 11 |
+
monitoring_page = st.Page("pages/monitoring.py", title="Monitoring", icon=":material/monitoring:")
|
| 12 |
+
|
| 13 |
+
pg = st.navigation([predict_page, compare_page, monitoring_page])
|
| 14 |
+
pg.run()
|
artifacts/label_map.json
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"accept_reservations": 0,
|
| 3 |
+
"account_blocked": 1,
|
| 4 |
+
"alarm": 2,
|
| 5 |
+
"application_status": 3,
|
| 6 |
+
"apr": 4,
|
| 7 |
+
"are_you_a_bot": 5,
|
| 8 |
+
"balance": 6,
|
| 9 |
+
"bill_balance": 7,
|
| 10 |
+
"bill_due": 8,
|
| 11 |
+
"book_flight": 9,
|
| 12 |
+
"book_hotel": 10,
|
| 13 |
+
"calculator": 11,
|
| 14 |
+
"calendar": 12,
|
| 15 |
+
"calendar_update": 13,
|
| 16 |
+
"calories": 14,
|
| 17 |
+
"cancel": 15,
|
| 18 |
+
"cancel_reservation": 16,
|
| 19 |
+
"car_rental": 17,
|
| 20 |
+
"card_declined": 18,
|
| 21 |
+
"carry_on": 19,
|
| 22 |
+
"change_accent": 20,
|
| 23 |
+
"change_ai_name": 21,
|
| 24 |
+
"change_language": 22,
|
| 25 |
+
"change_speed": 23,
|
| 26 |
+
"change_user_name": 24,
|
| 27 |
+
"change_volume": 25,
|
| 28 |
+
"confirm_reservation": 26,
|
| 29 |
+
"cook_time": 27,
|
| 30 |
+
"credit_limit": 28,
|
| 31 |
+
"credit_limit_change": 29,
|
| 32 |
+
"credit_score": 30,
|
| 33 |
+
"current_location": 31,
|
| 34 |
+
"damaged_card": 32,
|
| 35 |
+
"date": 33,
|
| 36 |
+
"definition": 34,
|
| 37 |
+
"direct_deposit": 35,
|
| 38 |
+
"directions": 36,
|
| 39 |
+
"distance": 37,
|
| 40 |
+
"do_you_have_pets": 38,
|
| 41 |
+
"exchange_rate": 39,
|
| 42 |
+
"expiration_date": 40,
|
| 43 |
+
"find_phone": 41,
|
| 44 |
+
"flight_status": 42,
|
| 45 |
+
"flip_coin": 43,
|
| 46 |
+
"food_last": 44,
|
| 47 |
+
"freeze_account": 45,
|
| 48 |
+
"fun_fact": 46,
|
| 49 |
+
"gas": 47,
|
| 50 |
+
"gas_type": 48,
|
| 51 |
+
"goodbye": 49,
|
| 52 |
+
"greeting": 50,
|
| 53 |
+
"how_busy": 51,
|
| 54 |
+
"how_old_are_you": 52,
|
| 55 |
+
"improve_credit_score": 53,
|
| 56 |
+
"income": 54,
|
| 57 |
+
"ingredient_substitution": 55,
|
| 58 |
+
"ingredients_list": 56,
|
| 59 |
+
"insurance": 57,
|
| 60 |
+
"insurance_change": 58,
|
| 61 |
+
"interest_rate": 59,
|
| 62 |
+
"international_fees": 60,
|
| 63 |
+
"international_visa": 61,
|
| 64 |
+
"jump_start": 62,
|
| 65 |
+
"last_maintenance": 63,
|
| 66 |
+
"lost_luggage": 64,
|
| 67 |
+
"make_call": 65,
|
| 68 |
+
"maybe": 66,
|
| 69 |
+
"meal_suggestion": 67,
|
| 70 |
+
"meaning_of_life": 68,
|
| 71 |
+
"measurement_conversion": 69,
|
| 72 |
+
"meeting_schedule": 70,
|
| 73 |
+
"min_payment": 71,
|
| 74 |
+
"mpg": 72,
|
| 75 |
+
"new_card": 73,
|
| 76 |
+
"next_holiday": 74,
|
| 77 |
+
"next_song": 75,
|
| 78 |
+
"no": 76,
|
| 79 |
+
"nutrition_info": 77,
|
| 80 |
+
"oil_change_how": 78,
|
| 81 |
+
"oil_change_when": 79,
|
| 82 |
+
"oos": 80,
|
| 83 |
+
"order": 81,
|
| 84 |
+
"order_checks": 82,
|
| 85 |
+
"order_status": 83,
|
| 86 |
+
"pay_bill": 84,
|
| 87 |
+
"payday": 85,
|
| 88 |
+
"pin_change": 86,
|
| 89 |
+
"play_music": 87,
|
| 90 |
+
"plug_type": 88,
|
| 91 |
+
"pto_balance": 89,
|
| 92 |
+
"pto_request": 90,
|
| 93 |
+
"pto_request_status": 91,
|
| 94 |
+
"pto_used": 92,
|
| 95 |
+
"recipe": 93,
|
| 96 |
+
"redeem_rewards": 94,
|
| 97 |
+
"reminder": 95,
|
| 98 |
+
"reminder_update": 96,
|
| 99 |
+
"repeat": 97,
|
| 100 |
+
"replacement_card_duration": 98,
|
| 101 |
+
"report_fraud": 99,
|
| 102 |
+
"report_lost_card": 100,
|
| 103 |
+
"reset_settings": 101,
|
| 104 |
+
"restaurant_reservation": 102,
|
| 105 |
+
"restaurant_reviews": 103,
|
| 106 |
+
"restaurant_suggestion": 104,
|
| 107 |
+
"rewards_balance": 105,
|
| 108 |
+
"roll_dice": 106,
|
| 109 |
+
"rollover_401k": 107,
|
| 110 |
+
"routing": 108,
|
| 111 |
+
"schedule_maintenance": 109,
|
| 112 |
+
"schedule_meeting": 110,
|
| 113 |
+
"share_location": 111,
|
| 114 |
+
"shopping_list": 112,
|
| 115 |
+
"shopping_list_update": 113,
|
| 116 |
+
"smart_home": 114,
|
| 117 |
+
"spelling": 115,
|
| 118 |
+
"spending_history": 116,
|
| 119 |
+
"sync_device": 117,
|
| 120 |
+
"taxes": 118,
|
| 121 |
+
"tell_joke": 119,
|
| 122 |
+
"text": 120,
|
| 123 |
+
"thank_you": 121,
|
| 124 |
+
"time": 122,
|
| 125 |
+
"timer": 123,
|
| 126 |
+
"timezone": 124,
|
| 127 |
+
"tire_change": 125,
|
| 128 |
+
"tire_pressure": 126,
|
| 129 |
+
"todo_list": 127,
|
| 130 |
+
"todo_list_update": 128,
|
| 131 |
+
"traffic": 129,
|
| 132 |
+
"transactions": 130,
|
| 133 |
+
"transfer": 131,
|
| 134 |
+
"translate": 132,
|
| 135 |
+
"travel_alert": 133,
|
| 136 |
+
"travel_notification": 134,
|
| 137 |
+
"travel_suggestion": 135,
|
| 138 |
+
"uber": 136,
|
| 139 |
+
"update_playlist": 137,
|
| 140 |
+
"user_name": 138,
|
| 141 |
+
"vaccines": 139,
|
| 142 |
+
"w2": 140,
|
| 143 |
+
"weather": 141,
|
| 144 |
+
"what_are_your_hobbies": 142,
|
| 145 |
+
"what_can_i_ask_you": 143,
|
| 146 |
+
"what_is_your_name": 144,
|
| 147 |
+
"what_song": 145,
|
| 148 |
+
"where_are_you_from": 146,
|
| 149 |
+
"whisper_mode": 147,
|
| 150 |
+
"who_do_you_work_for": 148,
|
| 151 |
+
"who_made_you": 149,
|
| 152 |
+
"yes": 150
|
| 153 |
+
}
|
configs/classical.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
type: logistic_regression
|
| 3 |
+
C: 1.0
|
| 4 |
+
max-iter: 1000
|
| 5 |
+
random_state: 42
|
| 6 |
+
|
| 7 |
+
tfidf:
|
| 8 |
+
max-features: 50000
|
| 9 |
+
ngram_range: [1, 2]
|
| 10 |
+
min_df: 2
|
| 11 |
+
|
| 12 |
+
data:
|
| 13 |
+
dataset: clinc_oos
|
| 14 |
+
subset: plus
|
| 15 |
+
test_size: 0.1
|
| 16 |
+
val_size: 0.1
|
| 17 |
+
random_state: 42
|
| 18 |
+
|
| 19 |
+
mlflow:
|
| 20 |
+
experiment_name: intent-classifier
|
| 21 |
+
run_name: logistic_regression
|
| 22 |
+
|
| 23 |
+
s3:
|
| 24 |
+
bucket: intent-classifier145
|
| 25 |
+
prefix: classical
|
configs/neural.yaml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
textcnn:
|
| 3 |
+
embedding_dim: 128
|
| 4 |
+
num_filters: 128
|
| 5 |
+
kernel_sizes: [2, 3, 4]
|
| 6 |
+
dropout: 0.3
|
| 7 |
+
|
| 8 |
+
rnn:
|
| 9 |
+
embedding_dim: 128
|
| 10 |
+
hidden_dim: 256
|
| 11 |
+
num_layers: 2
|
| 12 |
+
dropout: 0.3
|
| 13 |
+
|
| 14 |
+
lstm:
|
| 15 |
+
embedding_dim: 128
|
| 16 |
+
hidden_dim: 256
|
| 17 |
+
num_layers: 2
|
| 18 |
+
dropout: 0.3
|
| 19 |
+
|
| 20 |
+
training:
|
| 21 |
+
epochs: 50
|
| 22 |
+
batch_size: 64
|
| 23 |
+
learning_rate: 0.001
|
| 24 |
+
random_state: 42
|
| 25 |
+
patience: 5
|
| 26 |
+
grad_clip: 1.0
|
| 27 |
+
|
| 28 |
+
data:
|
| 29 |
+
dataset: clinc_oos
|
| 30 |
+
subset: plus
|
| 31 |
+
test_size: 0.1
|
| 32 |
+
val_size: 0.1
|
| 33 |
+
random_state: 42
|
| 34 |
+
|
| 35 |
+
mlflow:
|
| 36 |
+
experiment_name: intent-classifier
|
| 37 |
+
run_name: textcnn
|
| 38 |
+
|
| 39 |
+
s3:
|
| 40 |
+
bucket: intent-classifier145
|
| 41 |
+
prefix: neural
|
configs/transformer.yaml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
type: distilbert
|
| 3 |
+
name: distilbert-base-uncased
|
| 4 |
+
num_labels: 151
|
| 5 |
+
dropout: 0.1
|
| 6 |
+
|
| 7 |
+
training:
|
| 8 |
+
epochs: 5
|
| 9 |
+
batch_size: 32
|
| 10 |
+
learning_rate: 0.00002
|
| 11 |
+
warmup_steps: 500
|
| 12 |
+
weight_decay: 0.01
|
| 13 |
+
random_state: 42
|
| 14 |
+
|
| 15 |
+
data:
|
| 16 |
+
dataset: clinc_oos
|
| 17 |
+
subset: plus
|
| 18 |
+
max_length: 128
|
| 19 |
+
test_size: 0.1
|
| 20 |
+
val_size: 0.1
|
| 21 |
+
random_state: 42
|
| 22 |
+
|
| 23 |
+
mlflow:
|
| 24 |
+
experiment_name: intent-classifier
|
| 25 |
+
run_name: distilbert-finetuned
|
| 26 |
+
|
| 27 |
+
s3:
|
| 28 |
+
bucket: intent-classifier145
|
| 29 |
+
prefix: transformer
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.6.0
|
| 2 |
+
transformers==4.51.3
|
| 3 |
+
accelerate==1.5.2
|
| 4 |
+
datasets==3.3.2
|
| 5 |
+
numpy==2.2.3
|
| 6 |
+
pandas==2.2.3
|
| 7 |
+
mlflow==2.21.3
|
| 8 |
+
fastapi==0.115.12
|
| 9 |
+
uvicorn==0.34.0
|
| 10 |
+
streamlit==1.43.2
|
| 11 |
+
pyyaml==6.0.2
|
| 12 |
+
python-dotenv==1.0.1
|
| 13 |
+
boto3==1.37.38
|
| 14 |
+
evidently==0.7.5
|
| 15 |
+
httpx==0.28.1
|
| 16 |
+
pydantic-settings==2.8.1
|
| 17 |
+
matplotlib==3.10.1
|
| 18 |
+
seaborn==0.13.2
|
| 19 |
+
scikit-learn==1.6.1
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (139 Bytes). View file
|
|
|
src/data/__init__.py
ADDED
|
File without changes
|
src/data/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (144 Bytes). View file
|
|
|
src/data/__pycache__/loader.cpython-312.pyc
ADDED
|
Binary file (2.14 kB). View file
|
|
|
src/data/__pycache__/preprocessor.cpython-312.pyc
ADDED
|
Binary file (3.6 kB). View file
|
|
|
src/data/loader.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datasets import load_dataset
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def load_clinc150(subset: str = "plus") -> dict[str, pd.DataFrame]:
|
| 8 |
+
dataset = load_dataset("clinc/clinc_oos", subset)
|
| 9 |
+
|
| 10 |
+
intent_feature = dataset["train"].features["intent"]
|
| 11 |
+
|
| 12 |
+
splits = {}
|
| 13 |
+
for split_name in ["train", "validation", "test"]:
|
| 14 |
+
records = [
|
| 15 |
+
{
|
| 16 |
+
"text": item["text"],
|
| 17 |
+
"label": intent_feature.int2str(item["intent"]),
|
| 18 |
+
}
|
| 19 |
+
for item in dataset[split_name]
|
| 20 |
+
]
|
| 21 |
+
splits[split_name] = pd.DataFrame(records)
|
| 22 |
+
|
| 23 |
+
return splits
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def save_splits(splits: dict[str, pd.DataFrame], save_dir: str = "data/raw") -> None:
|
| 27 |
+
path = Path(save_dir)
|
| 28 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
for split_name, df in splits.items():
|
| 31 |
+
df.to_csv(path / f"{split_name}.csv", index=False)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_splits(load_dir: str = "data/raw") -> dict[str, pd.DataFrame]:
|
| 35 |
+
path = Path(load_dir)
|
| 36 |
+
split_names = ["train", "validation", "test"]
|
| 37 |
+
|
| 38 |
+
splits = {}
|
| 39 |
+
for split_name in split_names:
|
| 40 |
+
file_path = path / f"{split_name}.csv"
|
| 41 |
+
if not file_path.exists():
|
| 42 |
+
raise FileNotFoundError(f"Split not found: {file_path}")
|
| 43 |
+
splits[split_name] = pd.read_csv(file_path)
|
| 44 |
+
|
| 45 |
+
return splits
|
src/data/preprocessor.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def clean_text(text: str) -> str:
|
| 9 |
+
text = text.lower().strip()
|
| 10 |
+
text = re.sub(r"\s+", " ", text)
|
| 11 |
+
return text
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_label_map(train_df: pd.DataFrame, label_col: str = "label") -> dict[str, int]:
|
| 15 |
+
labels = sorted(train_df[label_col].unique().tolist())
|
| 16 |
+
return {label: idx for idx, label in enumerate(labels)}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def encode_labels(
|
| 20 |
+
df: pd.DataFrame,
|
| 21 |
+
label_map: dict[str, int],
|
| 22 |
+
label_col: str = "label",
|
| 23 |
+
) -> pd.DataFrame:
|
| 24 |
+
df = df.copy()
|
| 25 |
+
df["label_id"] = df[label_col].map(label_map)
|
| 26 |
+
return df
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def save_label_map(label_map: dict[str, int], save_path: str = "artifacts/label_map.json") -> None:
|
| 30 |
+
path = Path(save_path)
|
| 31 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
with open(path, "w") as f:
|
| 33 |
+
json.dump(label_map, f, indent=2)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def load_label_map(load_path: str = "artifacts/label_map.json") -> dict[str, int]:
|
| 37 |
+
path = Path(load_path)
|
| 38 |
+
if not path.exists():
|
| 39 |
+
raise FileNotFoundError(f"Label map not found: {path}")
|
| 40 |
+
with open(path) as f:
|
| 41 |
+
return json.load(f)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_id_to_label(label_map: dict[str, int]) -> dict[int, str]:
|
| 45 |
+
return {idx: label for label, idx in label_map.items()}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def preprocess(
|
| 49 |
+
splits: dict[str, pd.DataFrame],
|
| 50 |
+
) -> tuple[dict[str, pd.DataFrame], dict[str, int]]:
|
| 51 |
+
label_map = build_label_map(splits["train"])
|
| 52 |
+
|
| 53 |
+
processed = {}
|
| 54 |
+
for split_name, df in splits.items():
|
| 55 |
+
df = df.copy()
|
| 56 |
+
df["text"] = df["text"].apply(clean_text)
|
| 57 |
+
df = encode_labels(df, label_map)
|
| 58 |
+
processed[split_name] = df
|
| 59 |
+
|
| 60 |
+
save_label_map(label_map)
|
| 61 |
+
|
| 62 |
+
return processed, label_map
|
src/evaluation/__init__.py
ADDED
|
File without changes
|
src/evaluation/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (150 Bytes). View file
|
|
|
src/evaluation/__pycache__/metrics.cpython-312.pyc
ADDED
|
Binary file (2.84 kB). View file
|
|
|
src/evaluation/evaluate_all.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import mlflow
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import torch
|
| 9 |
+
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix
|
| 10 |
+
from torch.utils.data import DataLoader
|
| 11 |
+
|
| 12 |
+
from src.data.loader import load_splits
|
| 13 |
+
from src.data.preprocessor import preprocess
|
| 14 |
+
from src.features.tfidf import load_vectorizer, transform
|
| 15 |
+
from src.models.classical import LogisticRegressionModel, SVMModel
|
| 16 |
+
from src.models.neural import IntentDatasetNN, LSTMModel, TextCNN, Vocabulary
|
| 17 |
+
from src.models.transformer import IntentDatasetHF, TransformerModel
|
| 18 |
+
from src.utils.config import load_config
|
| 19 |
+
from src.utils.mlflow_utils import get_or_create_experiment
|
| 20 |
+
from src.utils.settings import settings
|
| 21 |
+
|
| 22 |
+
VECTORIZER_PATH = "artifacts/vectorizers/tfidf.pkl"
|
| 23 |
+
VOCAB_PATH = "artifacts/models/vocab.pkl"
|
| 24 |
+
LOGREG_PATH = "artifacts/models/logreg.pkl"
|
| 25 |
+
SVM_PATH = "artifacts/models/svm.pkl"
|
| 26 |
+
TEXTCNN_PATH = "artifacts/models/textcnn.pt"
|
| 27 |
+
RNN_PATH = "artifacts/models/rnn.pt"
|
| 28 |
+
LSTM_PATH = "artifacts/models/lstm.pt"
|
| 29 |
+
DISTILBERT_DIR = "artifacts/models/distilbert"
|
| 30 |
+
MAX_LENGTH_NN = 32
|
| 31 |
+
MAX_LENGTH_HF = 128
|
| 32 |
+
REPORT_DIR = Path("artifacts/evaluation")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_data():
|
| 36 |
+
splits = load_splits("data/raw")
|
| 37 |
+
processed, label_map = preprocess(splits)
|
| 38 |
+
id_to_label = {v: k for k, v in label_map.items()}
|
| 39 |
+
label_names = [id_to_label[i] for i in range(len(label_map))]
|
| 40 |
+
return processed, label_map, label_names
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
| 44 |
+
from sklearn.metrics import accuracy_score, f1_score
|
| 45 |
+
|
| 46 |
+
return {
|
| 47 |
+
"accuracy": round(accuracy_score(y_true, y_pred), 4),
|
| 48 |
+
"macro_f1": round(f1_score(y_true, y_pred, average="macro", zero_division=0), 4),
|
| 49 |
+
"weighted_f1": round(f1_score(y_true, y_pred, average="weighted", zero_division=0), 4),
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def measure_latency(predict_fn, input_data, n_runs: int = 50) -> dict:
|
| 54 |
+
latencies = []
|
| 55 |
+
for _ in range(n_runs):
|
| 56 |
+
start = time.perf_counter()
|
| 57 |
+
predict_fn(input_data)
|
| 58 |
+
latencies.append((time.perf_counter() - start) * 1000)
|
| 59 |
+
latencies = np.array(latencies)
|
| 60 |
+
return {
|
| 61 |
+
"latency_mean_ms": round(float(np.mean(latencies)), 3),
|
| 62 |
+
"latency_p50_ms": round(float(np.percentile(latencies, 50)), 3),
|
| 63 |
+
"latency_p95_ms": round(float(np.percentile(latencies, 95)), 3),
|
| 64 |
+
"latency_p99_ms": round(float(np.percentile(latencies, 99)), 3),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def eval_logreg(processed: dict, label_names: list[str]) -> tuple[dict, np.ndarray]:
|
| 69 |
+
print(" evaluating logistic regression...")
|
| 70 |
+
vectorizer = load_vectorizer(VECTORIZER_PATH)
|
| 71 |
+
X_test = transform(vectorizer, processed["test"]["text"].tolist())
|
| 72 |
+
y_test = processed["test"]["label_id"].values
|
| 73 |
+
|
| 74 |
+
model = LogisticRegressionModel()
|
| 75 |
+
model.load(LOGREG_PATH)
|
| 76 |
+
|
| 77 |
+
y_pred = model.predict(X_test)
|
| 78 |
+
metrics = compute_metrics(y_test, y_pred)
|
| 79 |
+
latency = measure_latency(model.predict, X_test[:100])
|
| 80 |
+
return {**metrics, **latency}, y_pred
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def eval_svm(processed: dict, label_names: list[str]) -> tuple[dict, np.ndarray]:
|
| 84 |
+
print(" evaluating svm...")
|
| 85 |
+
vectorizer = load_vectorizer(VECTORIZER_PATH)
|
| 86 |
+
X_test = transform(vectorizer, processed["test"]["text"].tolist())
|
| 87 |
+
y_test = processed["test"]["label_id"].values
|
| 88 |
+
|
| 89 |
+
model = SVMModel()
|
| 90 |
+
model.load(SVM_PATH)
|
| 91 |
+
|
| 92 |
+
y_pred = model.predict(X_test)
|
| 93 |
+
metrics = compute_metrics(y_test, y_pred)
|
| 94 |
+
latency = measure_latency(model.predict, X_test[:100])
|
| 95 |
+
return {**metrics, **latency}, y_pred
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def eval_neural(
|
| 99 |
+
model_type: str,
|
| 100 |
+
model_path: str,
|
| 101 |
+
processed: dict,
|
| 102 |
+
label_map: dict,
|
| 103 |
+
) -> tuple[dict, np.ndarray]:
|
| 104 |
+
print(f" evaluating {model_type}...")
|
| 105 |
+
vocab = Vocabulary.load(VOCAB_PATH)
|
| 106 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 107 |
+
num_classes = len(label_map)
|
| 108 |
+
|
| 109 |
+
neural_config = load_config("neural")
|
| 110 |
+
model_cfg = neural_config["model"][model_type]
|
| 111 |
+
|
| 112 |
+
if model_type == "textcnn":
|
| 113 |
+
model = TextCNN(
|
| 114 |
+
vocab_size=len(vocab),
|
| 115 |
+
embedding_dim=model_cfg["embedding_dim"],
|
| 116 |
+
num_filters=model_cfg["num_filters"],
|
| 117 |
+
kernel_sizes=model_cfg["kernel_sizes"],
|
| 118 |
+
num_classes=num_classes,
|
| 119 |
+
dropout=model_cfg["dropout"],
|
| 120 |
+
)
|
| 121 |
+
elif model_type == "rnn":
|
| 122 |
+
from src.models.neural import RNNModel
|
| 123 |
+
|
| 124 |
+
model = RNNModel(
|
| 125 |
+
vocab_size=len(vocab),
|
| 126 |
+
embedding_dim=model_cfg["embedding_dim"],
|
| 127 |
+
hidden_dim=model_cfg["hidden_dim"],
|
| 128 |
+
num_layers=model_cfg["num_layers"],
|
| 129 |
+
num_classes=num_classes,
|
| 130 |
+
dropout=model_cfg["dropout"],
|
| 131 |
+
)
|
| 132 |
+
elif model_type == "lstm":
|
| 133 |
+
model = LSTMModel(
|
| 134 |
+
vocab_size=len(vocab),
|
| 135 |
+
embedding_dim=model_cfg["embedding_dim"],
|
| 136 |
+
hidden_dim=model_cfg["hidden_dim"],
|
| 137 |
+
num_layers=model_cfg["num_layers"],
|
| 138 |
+
num_classes=num_classes,
|
| 139 |
+
dropout=model_cfg["dropout"],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
model.load(model_path)
|
| 143 |
+
model.to(device)
|
| 144 |
+
model.eval()
|
| 145 |
+
|
| 146 |
+
dataset = IntentDatasetNN(
|
| 147 |
+
processed["test"]["text"].tolist(),
|
| 148 |
+
processed["test"]["label_id"].tolist(),
|
| 149 |
+
vocab,
|
| 150 |
+
MAX_LENGTH_NN,
|
| 151 |
+
)
|
| 152 |
+
loader = DataLoader(dataset, batch_size=64, shuffle=False)
|
| 153 |
+
|
| 154 |
+
all_preds = []
|
| 155 |
+
y_test = processed["test"]["label_id"].values
|
| 156 |
+
|
| 157 |
+
with torch.no_grad():
|
| 158 |
+
for inputs, labels in loader:
|
| 159 |
+
inputs = inputs.to(device)
|
| 160 |
+
logits = model(inputs)
|
| 161 |
+
preds = logits.argmax(dim=1).cpu().numpy()
|
| 162 |
+
all_preds.extend(preds)
|
| 163 |
+
|
| 164 |
+
y_pred = np.array(all_preds)
|
| 165 |
+
metrics = compute_metrics(y_test, y_pred)
|
| 166 |
+
|
| 167 |
+
def predict_fn(loader):
|
| 168 |
+
with torch.no_grad():
|
| 169 |
+
for inputs, _ in loader:
|
| 170 |
+
model(inputs.to(device))
|
| 171 |
+
|
| 172 |
+
latency = measure_latency(predict_fn, loader, n_runs=20)
|
| 173 |
+
return {**metrics, **latency}, y_pred
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def eval_distilbert(processed: dict, label_map: dict) -> tuple[dict, np.ndarray]:
|
| 177 |
+
print(" evaluating distilbert...")
|
| 178 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 179 |
+
transformer = TransformerModel(
|
| 180 |
+
model_name=DISTILBERT_DIR,
|
| 181 |
+
num_labels=len(label_map),
|
| 182 |
+
)
|
| 183 |
+
transformer.model.to(device)
|
| 184 |
+
transformer.model.eval()
|
| 185 |
+
|
| 186 |
+
dataset = IntentDatasetHF(
|
| 187 |
+
processed["test"]["text"].tolist(),
|
| 188 |
+
processed["test"]["label_id"].tolist(),
|
| 189 |
+
transformer.tokenizer,
|
| 190 |
+
MAX_LENGTH_HF,
|
| 191 |
+
)
|
| 192 |
+
loader = DataLoader(dataset, batch_size=32, shuffle=False)
|
| 193 |
+
|
| 194 |
+
all_preds = []
|
| 195 |
+
y_test = processed["test"]["label_id"].values
|
| 196 |
+
|
| 197 |
+
with torch.no_grad():
|
| 198 |
+
for batch in loader:
|
| 199 |
+
input_ids = batch["input_ids"].to(device)
|
| 200 |
+
attention_mask = batch["attention_mask"].to(device)
|
| 201 |
+
outputs = transformer.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 202 |
+
preds = outputs.logits.argmax(dim=1).cpu().numpy()
|
| 203 |
+
all_preds.extend(preds)
|
| 204 |
+
|
| 205 |
+
y_pred = np.array(all_preds)
|
| 206 |
+
metrics = compute_metrics(y_test, y_pred)
|
| 207 |
+
|
| 208 |
+
single_text = processed["test"]["text"].iloc[:1].tolist()
|
| 209 |
+
single_dataset = IntentDatasetHF(
|
| 210 |
+
single_text,
|
| 211 |
+
[0],
|
| 212 |
+
transformer.tokenizer,
|
| 213 |
+
MAX_LENGTH_HF,
|
| 214 |
+
)
|
| 215 |
+
single_loader = DataLoader(single_dataset, batch_size=1)
|
| 216 |
+
|
| 217 |
+
def predict_fn(loader):
|
| 218 |
+
with torch.no_grad():
|
| 219 |
+
for batch in loader:
|
| 220 |
+
transformer.model(
|
| 221 |
+
input_ids=batch["input_ids"].to(device),
|
| 222 |
+
attention_mask=batch["attention_mask"].to(device),
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
latency = measure_latency(predict_fn, single_loader, n_runs=50)
|
| 226 |
+
return {**metrics, **latency}, y_pred
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def plot_comparison(results: dict, save_path: str) -> None:
|
| 230 |
+
df = pd.DataFrame(results).T.reset_index()
|
| 231 |
+
df.columns = ["model"] + list(df.columns[1:])
|
| 232 |
+
|
| 233 |
+
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
|
| 234 |
+
|
| 235 |
+
metrics = ["accuracy", "macro_f1", "weighted_f1"]
|
| 236 |
+
titles = ["Accuracy", "Macro F1", "Weighted F1"]
|
| 237 |
+
colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2", "#937860"]
|
| 238 |
+
|
| 239 |
+
for ax, metric, title in zip(axes, metrics, titles):
|
| 240 |
+
bars = ax.bar(df["model"], df[metric].astype(float), color=colors)
|
| 241 |
+
ax.set_title(title, fontsize=14)
|
| 242 |
+
ax.set_ylim(0, 1.1)
|
| 243 |
+
ax.set_xticks(range(len(df["model"])))
|
| 244 |
+
ax.set_xticklabels(df["model"], rotation=30, ha="right")
|
| 245 |
+
for bar, val in zip(bars, df[metric].astype(float)):
|
| 246 |
+
ax.text(
|
| 247 |
+
bar.get_x() + bar.get_width() / 2,
|
| 248 |
+
bar.get_height() + 0.01,
|
| 249 |
+
f"{val:.3f}",
|
| 250 |
+
ha="center",
|
| 251 |
+
va="bottom",
|
| 252 |
+
fontsize=9,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
plt.suptitle("Model Comparison — CLINC150", fontsize=16, y=1.02)
|
| 256 |
+
plt.tight_layout()
|
| 257 |
+
plt.savefig(save_path, dpi=100, bbox_inches="tight")
|
| 258 |
+
plt.close()
|
| 259 |
+
print(f" saved: {save_path}")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def plot_latency(results: dict, save_path: str) -> None:
|
| 263 |
+
models = list(results.keys())
|
| 264 |
+
p50 = [results[m]["latency_p50_ms"] for m in models]
|
| 265 |
+
p95 = [results[m]["latency_p95_ms"] for m in models]
|
| 266 |
+
|
| 267 |
+
x = np.arange(len(models))
|
| 268 |
+
width = 0.35
|
| 269 |
+
|
| 270 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
| 271 |
+
ax.bar(x - width / 2, p50, width, label="P50", color="#4C72B0")
|
| 272 |
+
ax.bar(x + width / 2, p95, width, label="P95", color="#DD8452")
|
| 273 |
+
ax.set_yscale("log")
|
| 274 |
+
ax.set_ylabel("Latency (ms) — log scale")
|
| 275 |
+
ax.set_title("Inference Latency Comparison")
|
| 276 |
+
ax.set_xticks(x)
|
| 277 |
+
ax.set_xticklabels(models, rotation=30, ha="right")
|
| 278 |
+
ax.legend()
|
| 279 |
+
plt.tight_layout()
|
| 280 |
+
plt.savefig(save_path, dpi=100, bbox_inches="tight")
|
| 281 |
+
plt.close()
|
| 282 |
+
print(f" saved: {save_path}")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def get_top_confused_pairs(
|
| 286 |
+
y_true: np.ndarray,
|
| 287 |
+
y_pred: np.ndarray,
|
| 288 |
+
label_names: list[str],
|
| 289 |
+
top_n: int = 20,
|
| 290 |
+
) -> pd.DataFrame:
|
| 291 |
+
cm = confusion_matrix(y_true, y_pred)
|
| 292 |
+
np.fill_diagonal(cm, 0)
|
| 293 |
+
|
| 294 |
+
pairs = []
|
| 295 |
+
for i in range(cm.shape[0]):
|
| 296 |
+
for j in range(cm.shape[1]):
|
| 297 |
+
if cm[i, j] > 0:
|
| 298 |
+
pairs.append(
|
| 299 |
+
{
|
| 300 |
+
"true_label": label_names[i],
|
| 301 |
+
"predicted_label": label_names[j],
|
| 302 |
+
"count": int(cm[i, j]),
|
| 303 |
+
}
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
df = pd.DataFrame(pairs).sort_values("count", ascending=False).head(top_n)
|
| 307 |
+
return df.reset_index(drop=True)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def plot_top_confused_pairs(df: pd.DataFrame, model_name: str, save_path: str) -> None:
|
| 311 |
+
fig, ax = plt.subplots(figsize=(10, max(6, len(df) * 0.35)))
|
| 312 |
+
labels = [f"{row.true_label} -> {row.predicted_label}" for row in df.itertuples()]
|
| 313 |
+
ax.barh(labels, df["count"], color="#C44E52")
|
| 314 |
+
ax.invert_yaxis()
|
| 315 |
+
ax.set_xlabel("Misclassification Count")
|
| 316 |
+
ax.set_title(f"Top Confused Pairs — {model_name}")
|
| 317 |
+
plt.tight_layout()
|
| 318 |
+
plt.savefig(save_path, dpi=100, bbox_inches="tight")
|
| 319 |
+
plt.close()
|
| 320 |
+
print(f" saved: {save_path}")
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def plot_oos_binary_confusion(
|
| 324 |
+
y_true: np.ndarray,
|
| 325 |
+
y_pred: np.ndarray,
|
| 326 |
+
label_map: dict,
|
| 327 |
+
model_name: str,
|
| 328 |
+
save_path: str,
|
| 329 |
+
) -> None:
|
| 330 |
+
oos_id = label_map.get("oos")
|
| 331 |
+
y_true_binary = (y_true == oos_id).astype(int)
|
| 332 |
+
y_pred_binary = (y_pred == oos_id).astype(int)
|
| 333 |
+
|
| 334 |
+
cm = confusion_matrix(y_true_binary, y_pred_binary)
|
| 335 |
+
fig, ax = plt.subplots(figsize=(5, 5))
|
| 336 |
+
disp = ConfusionMatrixDisplay(
|
| 337 |
+
confusion_matrix=cm,
|
| 338 |
+
display_labels=["in-scope", "oos"],
|
| 339 |
+
)
|
| 340 |
+
disp.plot(ax=ax, colorbar=False, cmap="Blues")
|
| 341 |
+
ax.set_title(f"OOS Detection — {model_name}")
|
| 342 |
+
plt.tight_layout()
|
| 343 |
+
plt.savefig(save_path, dpi=100, bbox_inches="tight")
|
| 344 |
+
plt.close()
|
| 345 |
+
print(f" saved: {save_path}")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def main():
|
| 349 |
+
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
| 350 |
+
|
| 351 |
+
print("loading data...")
|
| 352 |
+
processed, label_map, label_names = load_data()
|
| 353 |
+
y_test = processed["test"]["label_id"].values
|
| 354 |
+
|
| 355 |
+
print("\nevaluating all models...")
|
| 356 |
+
results = {}
|
| 357 |
+
predictions = {}
|
| 358 |
+
|
| 359 |
+
results["logreg"], predictions["logreg"] = eval_logreg(processed, label_names)
|
| 360 |
+
results["svm"], predictions["svm"] = eval_svm(processed, label_names)
|
| 361 |
+
results["textcnn"], predictions["textcnn"] = eval_neural("textcnn", TEXTCNN_PATH, processed, label_map)
|
| 362 |
+
results["rnn"], predictions["rnn"] = eval_neural("rnn", RNN_PATH, processed, label_map)
|
| 363 |
+
results["lstm"], predictions["lstm"] = eval_neural("lstm", LSTM_PATH, processed, label_map)
|
| 364 |
+
results["distilbert"], predictions["distilbert"] = eval_distilbert(processed, label_map)
|
| 365 |
+
|
| 366 |
+
print("\ngenerating plots...")
|
| 367 |
+
plot_comparison(results, str(REPORT_DIR / "model_comparison.png"))
|
| 368 |
+
plot_latency(results, str(REPORT_DIR / "latency_comparison.png"))
|
| 369 |
+
|
| 370 |
+
for model_name, y_pred in predictions.items():
|
| 371 |
+
confused_df = get_top_confused_pairs(y_test, y_pred, label_names, top_n=20)
|
| 372 |
+
confused_csv_path = REPORT_DIR / f"{model_name}_top_confused_pairs.csv"
|
| 373 |
+
confused_df.to_csv(confused_csv_path, index=False)
|
| 374 |
+
print(f" saved: {confused_csv_path}")
|
| 375 |
+
|
| 376 |
+
plot_top_confused_pairs(
|
| 377 |
+
confused_df,
|
| 378 |
+
model_name,
|
| 379 |
+
str(REPORT_DIR / f"{model_name}_top_confused_pairs.png"),
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
plot_oos_binary_confusion(
|
| 383 |
+
y_test,
|
| 384 |
+
y_pred,
|
| 385 |
+
label_map,
|
| 386 |
+
model_name,
|
| 387 |
+
str(REPORT_DIR / f"{model_name}_oos_binary.png"),
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
print("\nlogging to MLflow...")
|
| 391 |
+
mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
|
| 392 |
+
experiment_id = get_or_create_experiment("intent-classifier")
|
| 393 |
+
|
| 394 |
+
with mlflow.start_run(experiment_id=experiment_id, run_name="unified-evaluation"):
|
| 395 |
+
for model_name, metrics in results.items():
|
| 396 |
+
for metric_name, value in metrics.items():
|
| 397 |
+
mlflow.log_metric(f"{model_name}.{metric_name}", value)
|
| 398 |
+
|
| 399 |
+
mlflow.log_artifact(str(REPORT_DIR / "model_comparison.png"))
|
| 400 |
+
mlflow.log_artifact(str(REPORT_DIR / "latency_comparison.png"))
|
| 401 |
+
|
| 402 |
+
print("\nfinal results:")
|
| 403 |
+
print(f"{'model':<14} {'accuracy':<12} {'macro_f1':<12} {'weighted_f1':<14} {'p50_ms':<12} {'p95_ms'}")
|
| 404 |
+
print("-" * 78)
|
| 405 |
+
for model_name, metrics in results.items():
|
| 406 |
+
print(
|
| 407 |
+
f"{model_name:<14} "
|
| 408 |
+
f"{metrics['accuracy']:<12} "
|
| 409 |
+
f"{metrics['macro_f1']:<12} "
|
| 410 |
+
f"{metrics['weighted_f1']:<14} "
|
| 411 |
+
f"{metrics['latency_p50_ms']:<12} "
|
| 412 |
+
f"{metrics['latency_p95_ms']}"
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
if __name__ == "__main__":
|
| 417 |
+
main()
|
src/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from sklearn.metrics import accuracy_score, classification_report, f1_score
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def compute_classification_metrics(
|
| 9 |
+
y_true: np.ndarray,
|
| 10 |
+
y_pred: np.ndarray,
|
| 11 |
+
) -> dict:
|
| 12 |
+
return {
|
| 13 |
+
"accuracy": round(accuracy_score(y_true, y_pred), 4),
|
| 14 |
+
"macro_f1": round(f1_score(y_true, y_pred, average="macro", zero_division=0), 4),
|
| 15 |
+
"weighted_f1": round(f1_score(y_true, y_pred, average="weighted", zero_division=0), 4),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def compute_latency(predict_fn, inputs, n_runs: int = 100) -> dict:
|
| 20 |
+
latencies = []
|
| 21 |
+
for _ in range(n_runs):
|
| 22 |
+
start = time.perf_counter()
|
| 23 |
+
predict_fn(inputs)
|
| 24 |
+
latencies.append((time.perf_counter() - start) * 1000)
|
| 25 |
+
|
| 26 |
+
latencies = np.array(latencies)
|
| 27 |
+
return {
|
| 28 |
+
"latency_mean_ms": round(float(np.mean(latencies)), 3),
|
| 29 |
+
"latency_p50_ms": round(float(np.percentile(latencies, 50)), 3),
|
| 30 |
+
"latency_p95_ms": round(float(np.percentile(latencies, 95)), 3),
|
| 31 |
+
"latency_p99_ms": round(float(np.percentile(latencies, 99)), 3),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_classification_report(
|
| 36 |
+
y_true: np.ndarray,
|
| 37 |
+
y_pred: np.ndarray,
|
| 38 |
+
label_names: list[str],
|
| 39 |
+
) -> str:
|
| 40 |
+
return classification_report(y_true, y_pred, target_names=label_names, zero_division=0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def compare_models(results: dict) -> pd.DataFrame:
|
| 44 |
+
rows = []
|
| 45 |
+
for model_name, metrics in results.items():
|
| 46 |
+
rows.append({"model": model_name, **metrics})
|
| 47 |
+
return pd.DataFrame(rows).set_index("model")
|
src/features/__init__.py
ADDED
|
File without changes
|
src/features/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (148 Bytes). View file
|
|
|
src/features/__pycache__/tfidf.cpython-312.pyc
ADDED
|
Binary file (2.76 kB). View file
|
|
|
src/features/tfidf.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pickle
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import scipy.sparse as sp
|
| 5 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _get_tfidf_value(tfidf_config: dict, key: str):
|
| 9 |
+
kebab_key = key.replace("_", "-")
|
| 10 |
+
if key in tfidf_config:
|
| 11 |
+
return tfidf_config[key]
|
| 12 |
+
if kebab_key in tfidf_config:
|
| 13 |
+
return tfidf_config[kebab_key]
|
| 14 |
+
raise KeyError(key)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def fit_tfidf(train_texts: list[str], config: dict) -> TfidfVectorizer:
|
| 18 |
+
tfidf_config = config["tfidf"]
|
| 19 |
+
vectorizer = TfidfVectorizer(
|
| 20 |
+
max_features=_get_tfidf_value(tfidf_config, "max_features"),
|
| 21 |
+
ngram_range=tuple(_get_tfidf_value(tfidf_config, "ngram_range")),
|
| 22 |
+
min_df=_get_tfidf_value(tfidf_config, "min_df"),
|
| 23 |
+
)
|
| 24 |
+
vectorizer.fit(train_texts)
|
| 25 |
+
return vectorizer
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def transform(vectorizer: TfidfVectorizer, texts: list[str]) -> sp.csr_matrix:
|
| 29 |
+
return vectorizer.transform(texts)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def save_vectorizer(vectorizer: TfidfVectorizer, save_path: str = "artifacts/vectorizers/tfidf.pkl") -> None:
|
| 33 |
+
path = Path(save_path)
|
| 34 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
with open(path, "wb") as f:
|
| 36 |
+
pickle.dump(vectorizer, f)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_vectorizer(
|
| 40 |
+
load_path: str = "artifacts/vectorizers/tfidf.pkl",
|
| 41 |
+
) -> TfidfVectorizer:
|
| 42 |
+
path = Path(load_path)
|
| 43 |
+
if not path.exists():
|
| 44 |
+
raise FileNotFoundError(f"Vectorizer not found: {path}")
|
| 45 |
+
with open(path, "rb") as f:
|
| 46 |
+
return pickle.load(f)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_feature_names(vectorizer: TfidfVectorizer) -> list[str]:
|
| 50 |
+
return vectorizer.get_feature_names_out().tolist()
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
src/models/__pycache__/classical.cpython-312.pyc
ADDED
|
Binary file (4.55 kB). View file
|
|
|
src/models/__pycache__/neural.cpython-312.pyc
ADDED
|
Binary file (13.1 kB). View file
|
|
|
src/models/__pycache__/transformer.cpython-312.pyc
ADDED
|
Binary file (4.36 kB). View file
|
|
|