python-rerouting-library 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from time import perf_counter
6
+ from typing import Any
7
+
8
+ import joblib
9
+
10
+
11
+ DEFAULT_EMBEDDING_MODEL = (
12
+ "sentence-transformers/all-MiniLM-L6-v2"
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RouteDecision:
18
+ label: str
19
+ confidence: float | None
20
+ complex_probability: float | None
21
+ latency_ms: float
22
+
23
+
24
+ class Router:
25
+ def __init__(
26
+ self,
27
+ classifier_path: str | Path,
28
+ *,
29
+ embedding_model_name: str | None = None,
30
+ simple_threshold: float = 0.40,
31
+ complex_threshold: float = 0.60,
32
+ device: str = "cpu",
33
+ ) -> None:
34
+ self.classifier_path = Path(
35
+ classifier_path
36
+ )
37
+
38
+ if not self.classifier_path.is_file():
39
+ raise FileNotFoundError(
40
+ "Router classifier not found: "
41
+ f"{self.classifier_path}. "
42
+ "python-rerouting-library does not ship "
43
+ "with a default complexity classifier. "
44
+ "Train one with "
45
+ "'python -m python_rerouting_library.training' "
46
+ "or provide a compatible classifier_path."
47
+ )
48
+
49
+ if not (
50
+ 0.0
51
+ < simple_threshold
52
+ < complex_threshold
53
+ < 1.0
54
+ ):
55
+ raise ValueError(
56
+ "Thresholds must satisfy: "
57
+ "0 < simple_threshold "
58
+ "< complex_threshold < 1"
59
+ )
60
+
61
+ self.simple_threshold = float(
62
+ simple_threshold
63
+ )
64
+
65
+ self.complex_threshold = float(
66
+ complex_threshold
67
+ )
68
+
69
+ self.device = device
70
+
71
+ artifact = joblib.load(
72
+ self.classifier_path
73
+ )
74
+
75
+ if isinstance(artifact, dict):
76
+ if "classifier" not in artifact:
77
+ raise ValueError(
78
+ "Router artifact does not "
79
+ "contain a 'classifier'."
80
+ )
81
+
82
+ self.classifier = artifact[
83
+ "classifier"
84
+ ]
85
+
86
+ artifact_model_name = artifact.get(
87
+ "embedding_model_name",
88
+ DEFAULT_EMBEDDING_MODEL,
89
+ )
90
+
91
+ else:
92
+ self.classifier = artifact
93
+
94
+ artifact_model_name = (
95
+ DEFAULT_EMBEDDING_MODEL
96
+ )
97
+
98
+ self.embedding_model_name = (
99
+ embedding_model_name
100
+ or artifact_model_name
101
+ or DEFAULT_EMBEDDING_MODEL
102
+ )
103
+
104
+ self._embedding_model: Any | None = None
105
+
106
+ def _get_embedding_model(
107
+ self,
108
+ ) -> Any:
109
+ if self._embedding_model is None:
110
+ try:
111
+ from sentence_transformers import (
112
+ SentenceTransformer,
113
+ )
114
+ except ImportError as exc:
115
+ raise RuntimeError(
116
+ "Sentence Transformers could not be "
117
+ "loaded. Semantic routing requires the "
118
+ "'sentence-transformers' dependency and "
119
+ "a compatible Python environment."
120
+ ) from exc
121
+
122
+ self._embedding_model = (
123
+ SentenceTransformer(
124
+ self.embedding_model_name,
125
+ device=self.device,
126
+ )
127
+ )
128
+
129
+ return self._embedding_model
130
+
131
+ def warmup(self) -> None:
132
+ model = self._get_embedding_model()
133
+
134
+ model.encode(
135
+ ["router warmup"],
136
+ normalize_embeddings=True,
137
+ show_progress_bar=False,
138
+ )
139
+
140
+ def _get_complex_probability(
141
+ self,
142
+ embedding,
143
+ ) -> float:
144
+ probabilities = (
145
+ self.classifier.predict_proba(
146
+ embedding
147
+ )[0]
148
+ )
149
+
150
+ classes = list(
151
+ self.classifier.classes_
152
+ )
153
+
154
+ if "complex" not in classes:
155
+ raise ValueError(
156
+ "Classifier does not contain "
157
+ "the 'complex' class."
158
+ )
159
+
160
+ complex_index = classes.index(
161
+ "complex"
162
+ )
163
+
164
+ return float(
165
+ probabilities[complex_index]
166
+ )
167
+
168
+ def route(
169
+ self,
170
+ query: str,
171
+ ) -> RouteDecision:
172
+ if not isinstance(query, str):
173
+ raise TypeError(
174
+ "Query must be a string."
175
+ )
176
+
177
+ query = query.strip()
178
+
179
+ if not query:
180
+ raise ValueError(
181
+ "Query must not be empty."
182
+ )
183
+
184
+ start = perf_counter()
185
+
186
+ model = self._get_embedding_model()
187
+
188
+ embedding = model.encode(
189
+ [query],
190
+ normalize_embeddings=True,
191
+ show_progress_bar=False,
192
+ )
193
+
194
+ probability = (
195
+ self._get_complex_probability(
196
+ embedding
197
+ )
198
+ )
199
+
200
+ if probability < self.simple_threshold:
201
+ label = "simple"
202
+ confidence = (
203
+ 1.0 - probability
204
+ )
205
+
206
+ elif probability > self.complex_threshold:
207
+ label = "complex"
208
+ confidence = probability
209
+
210
+ else:
211
+ label = "uncertain"
212
+
213
+ confidence = (
214
+ abs(probability - 0.5)
215
+ * 2.0
216
+ )
217
+
218
+ latency_ms = (
219
+ perf_counter() - start
220
+ ) * 1000.0
221
+
222
+ return RouteDecision(
223
+ label=label,
224
+ confidence=confidence,
225
+ complex_probability=probability,
226
+ latency_ms=latency_ms,
227
+ )
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ from pathlib import Path
6
+
7
+ import joblib
8
+ from sentence_transformers import SentenceTransformer
9
+ from sklearn.linear_model import LogisticRegression
10
+
11
+ from .router import DEFAULT_EMBEDDING_MODEL
12
+
13
+
14
+ def load_training_data(
15
+ csv_path: Path,
16
+ ) -> tuple[list[str], list[str]]:
17
+ queries: list[str] = []
18
+ labels: list[str] = []
19
+
20
+ with csv_path.open(
21
+ "r",
22
+ encoding="utf-8",
23
+ newline="",
24
+ ) as f:
25
+ reader = csv.DictReader(f)
26
+
27
+ required = {"label", "query"}
28
+
29
+ if not required.issubset(
30
+ set(reader.fieldnames or [])
31
+ ):
32
+ raise ValueError(
33
+ "CSV must contain 'label' and 'query' columns."
34
+ )
35
+
36
+ for row in reader:
37
+ label = row["label"].strip().lower()
38
+ query = row["query"].strip()
39
+
40
+ if label not in {"simple", "complex"}:
41
+ raise ValueError(
42
+ f"Invalid label: {label!r}"
43
+ )
44
+
45
+ if not query:
46
+ raise ValueError(
47
+ "Encountered an empty query."
48
+ )
49
+
50
+ labels.append(label)
51
+ queries.append(query)
52
+
53
+ if not queries:
54
+ raise ValueError(
55
+ "Training CSV is empty."
56
+ )
57
+
58
+ return queries, labels
59
+
60
+
61
+ def train_router(
62
+ csv_path: str | Path,
63
+ output_path: str | Path,
64
+ *,
65
+ embedding_model_name: str = DEFAULT_EMBEDDING_MODEL,
66
+ ) -> Path:
67
+ csv_path = Path(csv_path)
68
+ output_path = Path(output_path)
69
+
70
+ queries, labels = load_training_data(
71
+ csv_path
72
+ )
73
+
74
+ print(
75
+ f"Loaded {len(queries)} training examples: "
76
+ f"{labels.count('simple')} simple / "
77
+ f"{labels.count('complex')} complex"
78
+ )
79
+
80
+ print(
81
+ f"Loading embedding model: "
82
+ f"{embedding_model_name}"
83
+ )
84
+
85
+ encoder = SentenceTransformer(
86
+ embedding_model_name,
87
+ device="cpu",
88
+ )
89
+
90
+ print("Generating embeddings...")
91
+
92
+ embeddings = encoder.encode(
93
+ queries,
94
+ convert_to_numpy=True,
95
+ normalize_embeddings=True,
96
+ show_progress_bar=True,
97
+ )
98
+
99
+ print(
100
+ "Training Logistic Regression classifier..."
101
+ )
102
+
103
+ classifier = LogisticRegression(
104
+ max_iter=1000,
105
+ class_weight="balanced",
106
+ solver="liblinear",
107
+ random_state=42,
108
+ )
109
+
110
+ classifier.fit(
111
+ embeddings,
112
+ labels,
113
+ )
114
+
115
+ artifact = {
116
+ "classifier": classifier,
117
+ "embedding_model_name": embedding_model_name,
118
+ "training_examples": len(queries),
119
+ "classes": list(classifier.classes_),
120
+ }
121
+
122
+ output_path.parent.mkdir(
123
+ parents=True,
124
+ exist_ok=True,
125
+ )
126
+
127
+ joblib.dump(
128
+ artifact,
129
+ output_path,
130
+ )
131
+
132
+ print(
133
+ f"Saved router classifier to: "
134
+ f"{output_path.resolve()}"
135
+ )
136
+
137
+ return output_path
138
+
139
+
140
+ def main() -> None:
141
+ parser = argparse.ArgumentParser(
142
+ description=(
143
+ "Train the MiniLM + Logistic Regression "
144
+ "simple/complex router."
145
+ )
146
+ )
147
+
148
+ parser.add_argument(
149
+ "--csv",
150
+ type=Path,
151
+ required=True,
152
+ )
153
+
154
+ parser.add_argument(
155
+ "--output",
156
+ type=Path,
157
+ default=Path(
158
+ "artifacts/router_classifier.joblib"
159
+ ),
160
+ )
161
+
162
+ parser.add_argument(
163
+ "--model",
164
+ default=DEFAULT_EMBEDDING_MODEL,
165
+ )
166
+
167
+ args = parser.parse_args()
168
+
169
+ train_router(
170
+ csv_path=args.csv,
171
+ output_path=args.output,
172
+ embedding_model_name=args.model,
173
+ )
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()