Prince012 commited on
Commit
72e2b6e
·
1 Parent(s): eada910

feat: initial deployment

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +32 -0
  2. README.md +13 -3
  3. api/__init__.py +0 -0
  4. api/__pycache__/__init__.cpython-312.pyc +0 -0
  5. api/__pycache__/main.cpython-312.pyc +0 -0
  6. api/__pycache__/schemas.cpython-312.pyc +0 -0
  7. api/main.py +54 -0
  8. api/routes/__init__.py +0 -0
  9. api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  10. api/routes/__pycache__/models.cpython-312.pyc +0 -0
  11. api/routes/__pycache__/monitoring.cpython-312.pyc +0 -0
  12. api/routes/__pycache__/predict.cpython-312.pyc +0 -0
  13. api/routes/models.py +22 -0
  14. api/routes/monitoring.py +47 -0
  15. api/routes/predict.py +78 -0
  16. api/schemas.py +58 -0
  17. app.py +95 -0
  18. app/__pycache__/api_client.cpython-312.pyc +0 -0
  19. app/api_client.py +62 -0
  20. app/pages/compare.py +100 -0
  21. app/pages/monitoring.py +90 -0
  22. app/pages/predict.py +67 -0
  23. app/streamlit_app.py +14 -0
  24. artifacts/label_map.json +153 -0
  25. configs/classical.yaml +25 -0
  26. configs/neural.yaml +41 -0
  27. configs/transformer.yaml +29 -0
  28. requirements.txt +19 -0
  29. src/__init__.py +0 -0
  30. src/__pycache__/__init__.cpython-312.pyc +0 -0
  31. src/data/__init__.py +0 -0
  32. src/data/__pycache__/__init__.cpython-312.pyc +0 -0
  33. src/data/__pycache__/loader.cpython-312.pyc +0 -0
  34. src/data/__pycache__/preprocessor.cpython-312.pyc +0 -0
  35. src/data/loader.py +45 -0
  36. src/data/preprocessor.py +62 -0
  37. src/evaluation/__init__.py +0 -0
  38. src/evaluation/__pycache__/__init__.cpython-312.pyc +0 -0
  39. src/evaluation/__pycache__/metrics.cpython-312.pyc +0 -0
  40. src/evaluation/evaluate_all.py +417 -0
  41. src/evaluation/metrics.py +47 -0
  42. src/features/__init__.py +0 -0
  43. src/features/__pycache__/__init__.cpython-312.pyc +0 -0
  44. src/features/__pycache__/tfidf.cpython-312.pyc +0 -0
  45. src/features/tfidf.py +50 -0
  46. src/models/__init__.py +0 -0
  47. src/models/__pycache__/__init__.cpython-312.pyc +0 -0
  48. src/models/__pycache__/classical.cpython-312.pyc +0 -0
  49. src/models/__pycache__/neural.cpython-312.pyc +0 -0
  50. 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: purple
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: 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