File size: 10,927 Bytes
49bd096
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""
Samsung Health Chart Intent β€” Inference + OOD Detection
========================================================
Loads the fine-tuned DistilBERT model and an embedding-based OOD detector.
Run this directly to test queries in the terminal, or import the
`full_predict` function into app.py.

Usage:
    python test.py                        # interactive mode
    python test.py --query "plot my HR"   # single query
    python test.py --batch queries.txt    # one query per line in a text file

Requirements:
    pip install torch transformers sentence-transformers pandas
"""

import argparse
import pandas as pd
import numpy as np
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from sentence_transformers import SentenceTransformer

# ─────────────────────────────────────────────────────────
# Config
# ─────────────────────────────────────────────────────────

MODEL_DIR       = "./chart_intent_model"
TRAINING_DATA   = "./samsung_health_intent.csv"
MAX_LENGTH      = 64
OOD_PERCENTILE  = 95    # 95th percentile of training distances as threshold
CONF_THRESHOLD  = 0.70  # below this β†’ uncertain even if in-domain

# ─────────────────────────────────────────────────────────
# Device
# ─────────────────────────────────────────────────────────

def get_device():
    if torch.cuda.is_available():
        return torch.device("cuda"), "CUDA"
    elif torch.backends.mps.is_available():
        return torch.device("mps"), "Apple MPS"
    return torch.device("cpu"), "CPU"

# ─────────────────────────────────────────────────────────
# OOD Detector
# ─────────────────────────────────────────────────────────

class OODDetector:
    """
    Embedding-distance OOD detector.
    Fits on training data. At inference, rejects inputs whose
    cosine distance from the training centroid exceeds the threshold.
    """
    def __init__(self, percentile: int = 95):
        self.percentile = percentile
        print("  Loading sentence encoder...")
        self.encoder = SentenceTransformer(
            "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
        )
        self.centroid  = None
        self.threshold = None

    def fit(self, texts: list):
        print(f"  Fitting OOD detector on {len(texts)} training examples...")
        embs = self.encoder.encode(texts, normalize_embeddings=True, show_progress_bar=False)

        # Centroid of in-domain distribution
        self.centroid = embs.mean(axis=0)
        self.centroid /= np.linalg.norm(self.centroid)

        # Distance of each training example from centroid
        dists = 1 - (embs @ self.centroid)          # cosine distance, 0=identical

        # Threshold = Nth percentile β†’ 95% of training samples are within it
        self.threshold = float(np.percentile(dists, self.percentile))
        print(f"  OOD threshold ({self.percentile}th pct): {self.threshold:.4f}")
        print(f"  Training distance range: [{dists.min():.4f}, {dists.max():.4f}]")

    def check(self, text: str) -> tuple:
        """Returns (is_in_domain: bool, distance: float)."""
        emb  = self.encoder.encode([text], normalize_embeddings=True)[0]
        dist = float(1 - (emb @ self.centroid))
        return dist <= self.threshold, dist

# ─────────────────────────────────────────────────────────
# Classifier
# ─────────────────────────────────────────────────────────

class ChartIntentClassifier:
    def __init__(self, model_dir: str, device):
        self.device    = device
        self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
        self.model     = AutoModelForSequenceClassification.from_pretrained(model_dir)
        self.model.to(device)
        self.model.eval()

    @torch.no_grad()
    def predict(self, text: str) -> dict:
        inputs = self.tokenizer(
            text,
            return_tensors="pt",
            truncation=True,
            max_length=MAX_LENGTH,
            padding=True,
        ).to(self.device)

        inputs.pop("token_type_ids", None)   # DistilBERT has no token_type_ids
        logits = self.model(**inputs).logits
        probs  = torch.softmax(logits, dim=-1).cpu().numpy()[0]
        label  = int(probs.argmax())

        return {
            "label":         label,
            "intent":        "chart" if label == 1 else "no_chart",
            "prob_chart":    float(probs[1]),
            "prob_no_chart": float(probs[0]),
            "confidence":    float(probs[label]),
        }

# ─────────────────────────────────────────────────────────
# Full pipeline
# ─────────────────────────────────────────────────────────

def full_predict(text: str, ood: OODDetector, clf: ChartIntentClassifier) -> dict:
    """
    Two-stage inference:
      1. OOD check  β†’ reject if not a health query
      2. Classifier β†’ chart vs no_chart
    """
    # Stage 1: domain gate
    in_domain, dist = ood.check(text)
    if not in_domain:
        return {
            "text":      text,
            "intent":    "out_of_domain",
            "label":     -1,
            "confident": False,
            "distance":  dist,
            "message":   "Not a health query β€” rejected by OOD detector",
        }

    # Stage 2: classify
    result = clf.predict(text)
    uncertain = result["confidence"] < CONF_THRESHOLD

    return {
        "text":      text,
        "intent":    result["intent"],
        "label":     result["label"],
        "confident": not uncertain,
        "confidence":    result["confidence"],
        "prob_chart":    result["prob_chart"],
        "prob_no_chart": result["prob_no_chart"],
        "distance":  dist,
        "message":   "uncertain β€” confidence below threshold" if uncertain else "ok",
    }

# ─────────────────────────────────────────────────────────
# Pretty print
# ─────────────────────────────────────────────────────────

def print_result(r: dict):
    intent = r["intent"]

    if intent == "out_of_domain":
        icon   = "🚫"
        label  = "OUT OF DOMAIN"
        detail = f"distance={r['distance']:.4f} (exceeds OOD threshold)"
    elif not r["confident"]:
        icon   = "⚠️ "
        label  = f"UNCERTAIN β†’ {intent.upper()}"
        detail = f"confidence={r['confidence']:.1%}  prob_chart={r['prob_chart']:.3f}  dist={r['distance']:.4f}"
    elif intent == "chart":
        icon   = "πŸ“Š"
        label  = "CHART"
        detail = f"confidence={r['confidence']:.1%}  prob_chart={r['prob_chart']:.3f}  dist={r['distance']:.4f}"
    else:
        icon   = "πŸ’¬"
        label  = "NO CHART"
        detail = f"confidence={r['confidence']:.1%}  prob_no_chart={r['prob_no_chart']:.3f}  dist={r['distance']:.4f}"

    print(f"\n  {icon}  [{label}]")
    print(f"      Query : {r['text']}")
    print(f"      Detail: {detail}")

# ─────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────

def build_pipeline():
    """Load everything. Call once, reuse the returned objects."""
    device, device_name = get_device()
    print(f"\n── Device: {device_name}")

    # Load training data for OOD fitting
    print(f"\n── Loading training data: {TRAINING_DATA}")
    df  = pd.read_csv(TRAINING_DATA)
    texts = df["text"].tolist()

    # OOD detector
    print("\n── Building OOD detector")
    ood = OODDetector(percentile=OOD_PERCENTILE)
    ood.fit(texts)

    # Classifier
    print(f"\n── Loading classifier: {MODEL_DIR}")
    clf = ChartIntentClassifier(MODEL_DIR, device)
    print("  Model loaded")

    return ood, clf


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--query", type=str, default=None,
                        help="Single query to classify")
    parser.add_argument("--batch", type=str, default=None,
                        help="Path to a .txt file with one query per line")
    args = parser.parse_args()

    ood, clf = build_pipeline()

    # ── Single query from CLI ──────────────────────────────
    if args.query:
        r = full_predict(args.query, ood, clf)
        print_result(r)
        return

    # ── Batch from file ────────────────────────────────────
    if args.batch:
        with open(args.batch) as f:
            queries = [l.strip() for l in f if l.strip()]
        print(f"\n── Batch: {len(queries)} queries")
        print("─" * 60)
        for q in queries:
            print_result(full_predict(q, ood, clf))
        return

    # ── Interactive mode ───────────────────────────────────
    print("\n── Interactive mode  (type 'quit' to exit)")
    print(f"    OOD threshold percentile : {OOD_PERCENTILE}")
    print(f"    Confidence threshold     : {CONF_THRESHOLD:.0%}")
    print("─" * 60)

    while True:
        try:
            query = input("\nQuery: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nBye.")
            break
        if not query:
            continue
        if query.lower() in ("quit", "exit", "q"):
            print("Bye.")
            break
        print_result(full_predict(query, ood, clf))


if __name__ == "__main__":
    main()