|
15 | 15 | detection. It is intentionally NOT keyed on the partition cells, the cell |
16 | 16 | construction is out of scope here; this gate only needs the input embeddings and |
17 | 17 | the surrogate's predicted label. |
| 18 | +
|
| 19 | +The caller is responsible for passing fit_ood_gate() and ood_mask() embeddings |
| 20 | +on the same scale (whatever that is -- raw, L2-normalized, or otherwise). This |
| 21 | +module does not rescale its input: doing so would silently change what "far" |
| 22 | +means (angular vs. magnitude-sensitive distance), which is a real behavioural |
| 23 | +choice, not something to bake in unconditionally. See api.py/router.py: the |
| 24 | +training embeddings used to calibrate this gate are saved and reloaded as their |
| 25 | +own dedicated artifact, kept independent of whatever the (possibly |
| 26 | +cosine-normalizing) FAISS index build does to its own copy, specifically so the |
| 27 | +two can never silently drift out of scale with each other. |
18 | 28 | """ |
19 | 29 |
|
20 | 30 | from __future__ import annotations |
@@ -52,20 +62,42 @@ def fit_ood_gate(X_train: np.ndarray, pred_labels, k: int = 10, |
52 | 62 | "global_thr": global_thr, "per_label_thr": per_label} |
53 | 63 |
|
54 | 64 |
|
55 | | -def ood_mask(X_query: np.ndarray, X_train: np.ndarray, query_labels, gate) -> np.ndarray: |
56 | | - """Boolean array, True where a query is out-of-distribution (should defer). |
| 65 | +def build_ood_index(X_train: np.ndarray, gate: Optional[dict]): |
| 66 | + """Fit a reusable k-NN index over the training embeddings. |
57 | 67 |
|
58 | | - Uses the same mean-k-NN-distance rule the gate was calibrated with, comparing |
59 | | - against the per-predicted-label threshold (global fallback).""" |
60 | | - X_query = np.asarray(X_query, dtype=np.float32) |
61 | | - if gate is None or X_train is None or len(X_train) == 0 or len(X_query) == 0: |
62 | | - return np.zeros(len(X_query), dtype=bool) |
| 68 | + A caller that invokes ``ood_mask`` repeatedly against the same X_train -- |
| 69 | + e.g. a live Router calling it once per prediction -- should build this |
| 70 | + once (at load time) and pass it back in via ``ood_mask(..., index=...)``, |
| 71 | + instead of paying the index-fit cost fresh on every single call. |
| 72 | + """ |
| 73 | + if gate is None or X_train is None or len(X_train) == 0: |
| 74 | + return None |
63 | 75 | from sklearn.neighbors import NearestNeighbors |
64 | 76 | k = int(min(gate.get("k", 10), len(X_train))) |
65 | 77 | if k < 1: |
| 78 | + return None |
| 79 | + return NearestNeighbors(n_neighbors=k).fit(np.asarray(X_train, dtype=np.float32)) |
| 80 | + |
| 81 | + |
| 82 | +def ood_mask(X_query: np.ndarray, X_train: np.ndarray, query_labels, gate, |
| 83 | + index=None) -> np.ndarray: |
| 84 | + """Boolean array, True where a query is out-of-distribution (should defer). |
| 85 | +
|
| 86 | + Uses the same mean-k-NN-distance rule the gate was calibrated with, comparing |
| 87 | + against the per-predicted-label threshold (global fallback). |
| 88 | +
|
| 89 | + ``index``: an optional pre-fit index from ``build_ood_index()``. If omitted, |
| 90 | + one is built fresh from X_train on every call (fine for one-off/batch use, |
| 91 | + wasteful for a live router serving one prediction at a time). |
| 92 | + """ |
| 93 | + X_query = np.asarray(X_query, dtype=np.float32) |
| 94 | + if gate is None or len(X_query) == 0 or (index is None and (X_train is None or len(X_train) == 0)): |
66 | 95 | return np.zeros(len(X_query), dtype=bool) |
67 | | - nn = NearestNeighbors(n_neighbors=k).fit(np.asarray(X_train, dtype=np.float32)) |
68 | | - d, _ = nn.kneighbors(X_query) |
| 96 | + if index is None: |
| 97 | + index = build_ood_index(X_train, gate) |
| 98 | + if index is None: |
| 99 | + return np.zeros(len(X_query), dtype=bool) |
| 100 | + d, _ = index.kneighbors(X_query) |
69 | 101 | mean_d = d.mean(axis=1) |
70 | 102 | per = gate.get("per_label_thr", {}) |
71 | 103 | g = float(gate.get("global_thr", np.inf)) |
|
0 commit comments