proxyml 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {proxyml-0.1.2 → proxyml-0.1.3}/PKG-INFO +1 -1
- {proxyml-0.1.2 → proxyml-0.1.3}/pyproject.toml +1 -1
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml/__init__.py +13 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml/client.py +177 -44
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml.egg-info/PKG-INFO +1 -1
- proxyml-0.1.3/tests/test_client.py +422 -0
- proxyml-0.1.2/tests/test_client.py +0 -193
- {proxyml-0.1.2 → proxyml-0.1.3}/LICENSE +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/README.md +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/setup.cfg +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml/schema.py +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml.egg-info/SOURCES.txt +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml.egg-info/dependency_links.txt +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml.egg-info/requires.txt +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/src/proxyml.egg-info/top_level.txt +0 -0
- {proxyml-0.1.2 → proxyml-0.1.3}/tests/test_schema.py +0 -0
|
@@ -3,9 +3,15 @@ from proxyml.client import (
|
|
|
3
3
|
synthesize_data,
|
|
4
4
|
train_surrogate,
|
|
5
5
|
predict,
|
|
6
|
+
predict_batch,
|
|
6
7
|
find_counterfactual,
|
|
8
|
+
find_counterfactuals,
|
|
7
9
|
interpret_counterfactual,
|
|
8
10
|
get_feature_importances,
|
|
11
|
+
get_model_summary,
|
|
12
|
+
diff_models,
|
|
13
|
+
list_models,
|
|
14
|
+
delete_model,
|
|
9
15
|
)
|
|
10
16
|
from proxyml.schema import (
|
|
11
17
|
get_schema,
|
|
@@ -19,8 +25,15 @@ __all__ = [
|
|
|
19
25
|
"synthesize_data",
|
|
20
26
|
"train_surrogate",
|
|
21
27
|
"predict",
|
|
28
|
+
"predict_batch",
|
|
22
29
|
"find_counterfactual",
|
|
30
|
+
"find_counterfactuals",
|
|
23
31
|
"interpret_counterfactual",
|
|
32
|
+
"get_feature_importances",
|
|
33
|
+
"get_model_summary",
|
|
34
|
+
"diff_models",
|
|
35
|
+
"list_models",
|
|
36
|
+
"delete_model",
|
|
24
37
|
"get_schema",
|
|
25
38
|
"gen_continuous_schema",
|
|
26
39
|
"gen_categorical_schema",
|
|
@@ -53,6 +53,14 @@ def get(endpoint: str, params: dict) -> requests.models.Response:
|
|
|
53
53
|
return r
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def delete(endpoint: str) -> requests.models.Response:
|
|
57
|
+
r = requests.delete(
|
|
58
|
+
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
59
|
+
headers=_headers()
|
|
60
|
+
)
|
|
61
|
+
return r
|
|
62
|
+
|
|
63
|
+
|
|
56
64
|
def put_schema(schema: dict):
|
|
57
65
|
r = put(endpoint='/schema', payload=schema)
|
|
58
66
|
if r.status_code == 200:
|
|
@@ -66,48 +74,61 @@ def put_schema(schema: dict):
|
|
|
66
74
|
return None
|
|
67
75
|
|
|
68
76
|
|
|
69
|
-
def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
|
|
70
|
-
if ftype in ("continuous",):
|
|
71
|
-
return series.astype(float)
|
|
72
|
-
if ftype in ("count", "numeric_ordinal"):
|
|
77
|
+
def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
|
|
78
|
+
if ftype in ("continuous",):
|
|
79
|
+
return series.astype(float)
|
|
80
|
+
if ftype in ("count", "numeric_ordinal"):
|
|
73
81
|
return series.astype(int)
|
|
74
|
-
if ftype in ("categorical", "categorical_ordinal"):
|
|
82
|
+
if ftype in ("categorical", "categorical_ordinal"):
|
|
75
83
|
# convert "true"/"false" strings back to booleans when appropriate
|
|
76
|
-
unique = {str(v).lower() for v in series.dropna().unique()}
|
|
77
|
-
if unique <= _BOOL_STRINGS:
|
|
78
|
-
return series.map({"true": True, "false": False, True: True, False: False})
|
|
79
|
-
return series
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
unique = {str(v).lower() for v in series.dropna().unique()}
|
|
85
|
+
if unique <= _BOOL_STRINGS:
|
|
86
|
+
return series.map({"true": True, "false": False, True: True, False: False})
|
|
87
|
+
return series
|
|
88
|
+
|
|
89
|
+
|
|
82
90
|
def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bool = True):
|
|
83
|
-
if sample is None:
|
|
84
|
-
r = post(endpoint='/synthesize/neighbors', payload={'n': num_points})
|
|
91
|
+
if sample is None:
|
|
92
|
+
r = post(endpoint='/synthesize/neighbors', payload={'n': num_points})
|
|
85
93
|
else:
|
|
86
|
-
r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance': [col.item() for col in sample]})
|
|
87
|
-
if r.status_code == 200:
|
|
88
|
-
payload = r.json()
|
|
89
|
-
if as_df:
|
|
94
|
+
r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance': [col.item() for col in sample]})
|
|
95
|
+
if r.status_code == 200:
|
|
96
|
+
payload = r.json()
|
|
97
|
+
if as_df:
|
|
90
98
|
df = pd.DataFrame(payload['samples'], columns=payload['feature_names'])
|
|
91
|
-
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
99
|
+
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
92
100
|
df[col] = _cast_column(df[col], ftype)
|
|
93
|
-
return df
|
|
101
|
+
return df
|
|
94
102
|
return payload
|
|
95
103
|
logger.error(
|
|
96
104
|
"Data synthesis failed with status %s: %s",
|
|
97
105
|
r.status_code,
|
|
98
106
|
r.text
|
|
99
107
|
)
|
|
100
|
-
return None
|
|
108
|
+
return None
|
|
101
109
|
|
|
102
110
|
|
|
103
111
|
def train_surrogate(
|
|
104
|
-
samples: list,
|
|
105
|
-
predictions: list,
|
|
112
|
+
samples: list,
|
|
113
|
+
predictions: list,
|
|
106
114
|
feature_names: list[str] | None,
|
|
107
|
-
task: str = 'auto',
|
|
108
|
-
test_size: float = 0.2
|
|
115
|
+
task: str = 'auto',
|
|
116
|
+
test_size: float = 0.2,
|
|
117
|
+
name: str | None = None,
|
|
118
|
+
comments: str | None = None,
|
|
109
119
|
):
|
|
110
|
-
|
|
120
|
+
payload = {
|
|
121
|
+
'samples': samples,
|
|
122
|
+
'predictions': predictions,
|
|
123
|
+
'feature_names': feature_names,
|
|
124
|
+
'task': task,
|
|
125
|
+
'test_size': test_size,
|
|
126
|
+
}
|
|
127
|
+
if name is not None:
|
|
128
|
+
payload['name'] = name
|
|
129
|
+
if comments is not None:
|
|
130
|
+
payload['comments'] = comments
|
|
131
|
+
r = post(endpoint='/surrogate/train', payload=payload)
|
|
111
132
|
if r.status_code == 200:
|
|
112
133
|
return r.json()
|
|
113
134
|
logger.error(
|
|
@@ -115,13 +136,13 @@ def train_surrogate(
|
|
|
115
136
|
r.status_code,
|
|
116
137
|
r.text
|
|
117
138
|
)
|
|
118
|
-
return None
|
|
139
|
+
return None
|
|
119
140
|
|
|
120
141
|
|
|
121
|
-
def predict(
|
|
122
|
-
payload = {'inputs':
|
|
123
|
-
if version
|
|
124
|
-
payload['version'] = version
|
|
142
|
+
def predict(sample: list, version: str | None = None):
|
|
143
|
+
payload = {'inputs': sample}
|
|
144
|
+
if version is not None:
|
|
145
|
+
payload['version'] = version
|
|
125
146
|
r = post(endpoint='/surrogate/predict', payload=payload)
|
|
126
147
|
if r.status_code == 200:
|
|
127
148
|
return r.json()
|
|
@@ -130,21 +151,21 @@ def predict(samples: list, version: int | None): # Defaults to latest version i
|
|
|
130
151
|
r.status_code,
|
|
131
152
|
r.text
|
|
132
153
|
)
|
|
133
|
-
return None
|
|
154
|
+
return None
|
|
134
155
|
|
|
135
156
|
|
|
136
|
-
def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version:
|
|
157
|
+
def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version: str | None = None, as_df: bool = True):
|
|
137
158
|
payload = {
|
|
138
159
|
'instance': sample,
|
|
139
|
-
'target_label': target.item() if hasattr(target, 'item') else target,
|
|
160
|
+
'target_label': target.item() if hasattr(target, 'item') else target,
|
|
140
161
|
'n_neighbors': n_neighbors,
|
|
141
162
|
'perturbation_scale': perturbation_scale,
|
|
142
|
-
}
|
|
143
|
-
if version
|
|
144
|
-
payload['version'] = version
|
|
163
|
+
}
|
|
164
|
+
if version is not None:
|
|
165
|
+
payload['version'] = version
|
|
145
166
|
r = post(endpoint='/explain/counterfactual', payload=payload)
|
|
146
167
|
if r.status_code == 200:
|
|
147
|
-
payload = r.json()
|
|
168
|
+
payload = r.json()
|
|
148
169
|
if as_df:
|
|
149
170
|
if payload['counterfactual'] is None:
|
|
150
171
|
print(f"No counterfactual found: {payload.get('warning')}")
|
|
@@ -159,7 +180,7 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
|
|
|
159
180
|
r.status_code,
|
|
160
181
|
r.text
|
|
161
182
|
)
|
|
162
|
-
return None
|
|
183
|
+
return None
|
|
163
184
|
|
|
164
185
|
|
|
165
186
|
def interpret_counterfactual(
|
|
@@ -176,15 +197,15 @@ def interpret_counterfactual(
|
|
|
176
197
|
if sample[k] != counterfactual[k]
|
|
177
198
|
and k not in exclude_from_diff
|
|
178
199
|
}
|
|
179
|
-
|
|
200
|
+
|
|
180
201
|
if not diffs:
|
|
181
202
|
return "No meaningful differences found between sample and counterfactual."
|
|
182
|
-
|
|
203
|
+
|
|
183
204
|
changes = ", ".join(
|
|
184
205
|
f"{feature} from {original} to {cf_value}"
|
|
185
206
|
for feature, (original, cf_value) in diffs.items()
|
|
186
207
|
)
|
|
187
|
-
|
|
208
|
+
|
|
188
209
|
if prediction_changed:
|
|
189
210
|
return (
|
|
190
211
|
f"Changing {changes} may result in a different prediction. "
|
|
@@ -199,8 +220,91 @@ def interpret_counterfactual(
|
|
|
199
220
|
)
|
|
200
221
|
|
|
201
222
|
|
|
202
|
-
def
|
|
203
|
-
|
|
223
|
+
def predict_batch(samples: list[list], version: str | None = None):
|
|
224
|
+
payload = {'inputs': samples}
|
|
225
|
+
if version is not None:
|
|
226
|
+
payload['version'] = version
|
|
227
|
+
r = post(endpoint='/surrogate/predict/batch', payload=payload)
|
|
228
|
+
if r.status_code == 200:
|
|
229
|
+
return r.json()
|
|
230
|
+
logger.error(
|
|
231
|
+
"Batch prediction failed with status %s: %s",
|
|
232
|
+
r.status_code,
|
|
233
|
+
r.text,
|
|
234
|
+
)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def find_counterfactuals(
|
|
239
|
+
samples: list[list],
|
|
240
|
+
target,
|
|
241
|
+
n_neighbors: int = 10000,
|
|
242
|
+
perturbation_scale: float = 0.1,
|
|
243
|
+
version: str | None = None,
|
|
244
|
+
as_dfs: bool = True,
|
|
245
|
+
):
|
|
246
|
+
payload = {
|
|
247
|
+
'instances': samples,
|
|
248
|
+
'target_label': target.item() if hasattr(target, 'item') else target,
|
|
249
|
+
'n_neighbors': n_neighbors,
|
|
250
|
+
'perturbation_scale': perturbation_scale,
|
|
251
|
+
}
|
|
252
|
+
if version is not None:
|
|
253
|
+
payload['version'] = version
|
|
254
|
+
r = post(endpoint='/explain/counterfactual/batch', payload=payload)
|
|
255
|
+
if r.status_code == 200:
|
|
256
|
+
data = r.json()
|
|
257
|
+
if as_dfs:
|
|
258
|
+
feature_names = data['feature_names']
|
|
259
|
+
feature_types = data['feature_types']
|
|
260
|
+
results = []
|
|
261
|
+
for item in data['results']:
|
|
262
|
+
if item['counterfactual'] is None:
|
|
263
|
+
if item.get('warning'):
|
|
264
|
+
print(f"No counterfactual found: {item['warning']}")
|
|
265
|
+
results.append(None)
|
|
266
|
+
else:
|
|
267
|
+
df = pd.DataFrame([item['counterfactual']], columns=feature_names)
|
|
268
|
+
for col, ftype in zip(feature_names, feature_types):
|
|
269
|
+
df[col] = _cast_column(df[col], ftype)
|
|
270
|
+
results.append(df)
|
|
271
|
+
return results
|
|
272
|
+
return data
|
|
273
|
+
logger.error(
|
|
274
|
+
"Batch counterfactual failed with status %s: %s",
|
|
275
|
+
r.status_code,
|
|
276
|
+
r.text,
|
|
277
|
+
)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_model_summary(version: str | None = None):
|
|
282
|
+
params = {'version': version} if version is not None else {}
|
|
283
|
+
r = get(endpoint='/explain/summary', params=params)
|
|
284
|
+
if r.status_code == 200:
|
|
285
|
+
return r.json()
|
|
286
|
+
logger.error(
|
|
287
|
+
"Model summary failed with status %s: %s",
|
|
288
|
+
r.status_code,
|
|
289
|
+
r.text,
|
|
290
|
+
)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def diff_models(version_a: str, version_b: str):
|
|
295
|
+
r = get(endpoint='/explain/diff', params={'version_a': version_a, 'version_b': version_b})
|
|
296
|
+
if r.status_code == 200:
|
|
297
|
+
return r.json()
|
|
298
|
+
logger.error(
|
|
299
|
+
"Model diff failed with status %s: %s",
|
|
300
|
+
r.status_code,
|
|
301
|
+
r.text,
|
|
302
|
+
)
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def get_feature_importances(version: str | None = None):
|
|
307
|
+
params = {'version': version} if version is not None else {}
|
|
204
308
|
r = get(endpoint='/explain/importance', params=params)
|
|
205
309
|
if r.status_code == 200:
|
|
206
310
|
return r.json()
|
|
@@ -209,4 +313,33 @@ def get_feature_importances(version: int | None = None, ):
|
|
|
209
313
|
r.status_code,
|
|
210
314
|
r.text,
|
|
211
315
|
)
|
|
212
|
-
return None
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def list_models() -> list[dict] | None:
|
|
320
|
+
"""Return metadata for all trained surrogate models, newest first."""
|
|
321
|
+
r = get(endpoint='/surrogate/models', params={})
|
|
322
|
+
if r.status_code == 200:
|
|
323
|
+
return r.json()['models']
|
|
324
|
+
logger.error(
|
|
325
|
+
"List models failed with status %s: %s",
|
|
326
|
+
r.status_code,
|
|
327
|
+
r.text,
|
|
328
|
+
)
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def delete_model(model_id: str) -> bool:
|
|
333
|
+
"""Delete a surrogate model by its UUID. Returns True on success, False if not found."""
|
|
334
|
+
r = delete(endpoint=f'/surrogate/models/{model_id}')
|
|
335
|
+
if r.status_code == 204:
|
|
336
|
+
return True
|
|
337
|
+
if r.status_code == 404:
|
|
338
|
+
logger.warning("Surrogate model %s not found", model_id)
|
|
339
|
+
return False
|
|
340
|
+
logger.error(
|
|
341
|
+
"Delete model failed with status %s: %s",
|
|
342
|
+
r.status_code,
|
|
343
|
+
r.text,
|
|
344
|
+
)
|
|
345
|
+
return False
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from proxyml.client import (
|
|
8
|
+
_cast_column,
|
|
9
|
+
_headers,
|
|
10
|
+
delete_model,
|
|
11
|
+
diff_models,
|
|
12
|
+
find_counterfactuals,
|
|
13
|
+
get_model_summary,
|
|
14
|
+
interpret_counterfactual,
|
|
15
|
+
list_models,
|
|
16
|
+
predict,
|
|
17
|
+
predict_batch,
|
|
18
|
+
put_schema,
|
|
19
|
+
synthesize_data,
|
|
20
|
+
train_surrogate,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# _headers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def test_headers_raises_without_api_key(monkeypatch):
|
|
29
|
+
monkeypatch.delenv("PROXYML_API_KEY", raising=False)
|
|
30
|
+
with pytest.raises(EnvironmentError, match="PROXYML_API_KEY"):
|
|
31
|
+
_headers()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_headers_returns_correct_dict(monkeypatch):
|
|
35
|
+
monkeypatch.setenv("PROXYML_API_KEY", "test-key")
|
|
36
|
+
h = _headers()
|
|
37
|
+
assert h["X-API-KEY"] == "test-key"
|
|
38
|
+
assert h["Content-Type"] == "application/json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# _cast_column
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def test_cast_column_continuous():
|
|
46
|
+
s = pd.Series(["1.5", "2.5", "3.5"])
|
|
47
|
+
result = _cast_column(s, "continuous")
|
|
48
|
+
assert result.dtype == float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_cast_column_count():
|
|
52
|
+
s = pd.Series([1.0, 2.0, 3.0])
|
|
53
|
+
result = _cast_column(s, "count")
|
|
54
|
+
assert result.dtype == int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_cast_column_numeric_ordinal():
|
|
58
|
+
s = pd.Series([1.0, 2.0, 3.0])
|
|
59
|
+
result = _cast_column(s, "numeric_ordinal")
|
|
60
|
+
assert result.dtype == int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_cast_column_categorical_bool_strings():
|
|
64
|
+
s = pd.Series(["true", "false", "true"])
|
|
65
|
+
result = _cast_column(s, "categorical")
|
|
66
|
+
assert result.tolist() == [True, False, True]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_cast_column_categorical_passthrough():
|
|
70
|
+
s = pd.Series(["a", "b", "c"])
|
|
71
|
+
result = _cast_column(s, "categorical")
|
|
72
|
+
assert result.tolist() == ["a", "b", "c"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_cast_column_unknown_type_passthrough():
|
|
76
|
+
s = pd.Series([1, 2, 3])
|
|
77
|
+
result = _cast_column(s, "unknown_type")
|
|
78
|
+
assert result.tolist() == [1, 2, 3]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# interpret_counterfactual
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def test_interpret_counterfactual_prediction_changed():
|
|
86
|
+
result = interpret_counterfactual(
|
|
87
|
+
sample={"age": 30, "income": 50000},
|
|
88
|
+
counterfactual={"age": 30, "income": 75000},
|
|
89
|
+
prediction_changed=True,
|
|
90
|
+
exclude_from_diff=None,
|
|
91
|
+
)
|
|
92
|
+
assert "income from 50000 to 75000" in result
|
|
93
|
+
assert "different prediction" in result
|
|
94
|
+
assert "surrogate model" in result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_interpret_counterfactual_prediction_unchanged():
|
|
98
|
+
result = interpret_counterfactual(
|
|
99
|
+
sample={"age": 30, "income": 50000},
|
|
100
|
+
counterfactual={"age": 30, "income": 75000},
|
|
101
|
+
prediction_changed=False,
|
|
102
|
+
exclude_from_diff=None,
|
|
103
|
+
)
|
|
104
|
+
assert "income from 50000 to 75000" in result
|
|
105
|
+
assert "did not change" in result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_interpret_counterfactual_no_diffs():
|
|
109
|
+
result = interpret_counterfactual(
|
|
110
|
+
sample={"age": 30},
|
|
111
|
+
counterfactual={"age": 30},
|
|
112
|
+
prediction_changed=True,
|
|
113
|
+
exclude_from_diff=None,
|
|
114
|
+
)
|
|
115
|
+
assert result == "No meaningful differences found between sample and counterfactual."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_interpret_counterfactual_exclude_from_diff():
|
|
119
|
+
result = interpret_counterfactual(
|
|
120
|
+
sample={"age": 30, "id": 999},
|
|
121
|
+
counterfactual={"age": 35, "id": 888},
|
|
122
|
+
prediction_changed=True,
|
|
123
|
+
exclude_from_diff=["id"],
|
|
124
|
+
)
|
|
125
|
+
assert "id" not in result
|
|
126
|
+
assert "age from 30 to 35" in result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# API functions (mocked HTTP)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _mock_response(status_code, json_body):
|
|
134
|
+
r = MagicMock()
|
|
135
|
+
r.status_code = status_code
|
|
136
|
+
r.json.return_value = json_body
|
|
137
|
+
r.text = str(json_body)
|
|
138
|
+
return r
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@patch("proxyml.client.put")
|
|
142
|
+
def test_put_schema_success(mock_put):
|
|
143
|
+
mock_put.return_value = _mock_response(200, {"status": "ok"})
|
|
144
|
+
result = put_schema({"features": []})
|
|
145
|
+
assert result == {"status": "ok"}
|
|
146
|
+
mock_put.assert_called_once_with(endpoint="/schema", payload={"features": []})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@patch("proxyml.client.put")
|
|
150
|
+
def test_put_schema_failure(mock_put):
|
|
151
|
+
mock_put.return_value = _mock_response(422, {"detail": "invalid"})
|
|
152
|
+
result = put_schema({"features": []})
|
|
153
|
+
assert result is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@patch("proxyml.client.post")
|
|
157
|
+
def test_predict_success(mock_post):
|
|
158
|
+
mock_post.return_value = _mock_response(200, {"prediction": 1, "probability": 0.9})
|
|
159
|
+
result = predict(sample=[1.0, 2.0, 3.0], version=None)
|
|
160
|
+
assert result == {"prediction": 1, "probability": 0.9}
|
|
161
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
162
|
+
assert payload["inputs"] == [1.0, 2.0, 3.0]
|
|
163
|
+
assert "version" not in payload
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@patch("proxyml.client.post")
|
|
167
|
+
def test_predict_with_version(mock_post):
|
|
168
|
+
mock_post.return_value = _mock_response(200, {"prediction": 0})
|
|
169
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
170
|
+
predict(sample=[1.0, 2.0], version=uid)
|
|
171
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
172
|
+
assert payload["version"] == uid
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@patch("proxyml.client.post")
|
|
176
|
+
def test_predict_failure_returns_none(mock_post):
|
|
177
|
+
mock_post.return_value = _mock_response(422, {"detail": "bad input"})
|
|
178
|
+
result = predict(sample=[1.0, 2.0], version=None)
|
|
179
|
+
assert result is None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@patch("proxyml.client.post")
|
|
183
|
+
def test_synthesize_data_no_sample(mock_post):
|
|
184
|
+
mock_post.return_value = _mock_response(200, {
|
|
185
|
+
"samples": [[1.0, "true"], [2.5, "false"]],
|
|
186
|
+
"feature_names": ["f_cont", "f_cat"],
|
|
187
|
+
"feature_types": ["continuous", "categorical"],
|
|
188
|
+
})
|
|
189
|
+
df = synthesize_data(num_points=2, sample=None)
|
|
190
|
+
assert isinstance(df, pd.DataFrame)
|
|
191
|
+
assert list(df.columns) == ["f_cont", "f_cat"]
|
|
192
|
+
mock_post.assert_called_once_with(
|
|
193
|
+
endpoint="/synthesize/neighbors", payload={"n": 2}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@patch("proxyml.client.post")
|
|
198
|
+
def test_synthesize_data_failure_returns_none(mock_post):
|
|
199
|
+
mock_post.return_value = _mock_response(500, {})
|
|
200
|
+
result = synthesize_data(num_points=10, sample=None)
|
|
201
|
+
assert result is None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@patch("proxyml.client.post")
|
|
205
|
+
def test_train_surrogate_with_metadata(mock_post):
|
|
206
|
+
mock_post.return_value = _mock_response(200, {
|
|
207
|
+
"version": "abc-123", "trained_at": "2026-04-19T12:00:00",
|
|
208
|
+
"task": "regression", "name": "v1", "comments": "test run",
|
|
209
|
+
"feature_names": None, "metrics": {"r2": 0.95}, "warning": None,
|
|
210
|
+
})
|
|
211
|
+
result = train_surrogate(
|
|
212
|
+
samples=[[1.0, 2.0]], predictions=[3.0],
|
|
213
|
+
feature_names=None, name="v1", comments="test run",
|
|
214
|
+
)
|
|
215
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
216
|
+
assert payload["name"] == "v1"
|
|
217
|
+
assert payload["comments"] == "test run"
|
|
218
|
+
assert result["version"] == "abc-123"
|
|
219
|
+
assert result["trained_at"] == "2026-04-19T12:00:00"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@patch("proxyml.client.post")
|
|
223
|
+
def test_train_surrogate_omits_none_metadata(mock_post):
|
|
224
|
+
mock_post.return_value = _mock_response(200, {"version": "abc-123"})
|
|
225
|
+
train_surrogate(samples=[[1.0]], predictions=[1.0], feature_names=None)
|
|
226
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
227
|
+
assert "name" not in payload
|
|
228
|
+
assert "comments" not in payload
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@patch("proxyml.client.get")
|
|
232
|
+
def test_list_models_success(mock_get):
|
|
233
|
+
models = [{"version": "abc-123", "task": "regression", "name": "v1",
|
|
234
|
+
"comments": None, "feature_names": None, "metrics": {"r2": 0.9},
|
|
235
|
+
"trained_at": "2026-04-19T12:00:00"}]
|
|
236
|
+
mock_get.return_value = _mock_response(200, {"models": models})
|
|
237
|
+
result = list_models()
|
|
238
|
+
assert result == models
|
|
239
|
+
mock_get.assert_called_once_with(endpoint="/surrogate/models", params={})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@patch("proxyml.client.get")
|
|
243
|
+
def test_list_models_failure_returns_none(mock_get):
|
|
244
|
+
mock_get.return_value = _mock_response(401, {"detail": "unauthorized"})
|
|
245
|
+
assert list_models() is None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@patch("proxyml.client.delete")
|
|
249
|
+
def test_delete_model_success(mock_delete):
|
|
250
|
+
mock_delete.return_value = _mock_response(204, None)
|
|
251
|
+
assert delete_model("abc-123") is True
|
|
252
|
+
mock_delete.assert_called_once_with(endpoint="/surrogate/models/abc-123")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@patch("proxyml.client.delete")
|
|
256
|
+
def test_delete_model_not_found(mock_delete):
|
|
257
|
+
mock_delete.return_value = _mock_response(404, {"detail": "not found"})
|
|
258
|
+
assert delete_model("no-such-id") is False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# predict_batch
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@patch("proxyml.client.post")
|
|
266
|
+
def test_predict_batch_success(mock_post):
|
|
267
|
+
mock_post.return_value = _mock_response(200, {
|
|
268
|
+
"predictions": [0.74, 0.31],
|
|
269
|
+
"model_version": "surrogate-abc-regression",
|
|
270
|
+
})
|
|
271
|
+
result = predict_batch(samples=[[1.0, 2.0], [3.0, 4.0]])
|
|
272
|
+
assert result["predictions"] == [0.74, 0.31]
|
|
273
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
274
|
+
assert payload["inputs"] == [[1.0, 2.0], [3.0, 4.0]]
|
|
275
|
+
assert "version" not in payload
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@patch("proxyml.client.post")
|
|
279
|
+
def test_predict_batch_with_version(mock_post):
|
|
280
|
+
mock_post.return_value = _mock_response(200, {"predictions": [1], "model_version": "surrogate-abc-classification"})
|
|
281
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
282
|
+
predict_batch(samples=[[1.0]], version=uid)
|
|
283
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
284
|
+
assert payload["version"] == uid
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@patch("proxyml.client.post")
|
|
288
|
+
def test_predict_batch_failure_returns_none(mock_post):
|
|
289
|
+
mock_post.return_value = _mock_response(422, {"detail": "bad input"})
|
|
290
|
+
assert predict_batch(samples=[[1.0, 2.0]]) is None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# find_counterfactuals
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
_BATCH_CF_RESPONSE = {
|
|
298
|
+
"results": [
|
|
299
|
+
{"counterfactual": [1.5, "yes"], "outlier_score": 0.1, "warning": None},
|
|
300
|
+
{"counterfactual": None, "outlier_score": 0.9, "warning": "no CF found"},
|
|
301
|
+
],
|
|
302
|
+
"feature_names": ["f_cont", "f_cat"],
|
|
303
|
+
"feature_types": ["continuous", "categorical"],
|
|
304
|
+
"task": "classification",
|
|
305
|
+
"target_label": "high",
|
|
306
|
+
"model_version": "surrogate-abc-classification",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@patch("proxyml.client.post")
|
|
311
|
+
def test_find_counterfactuals_as_df(mock_post):
|
|
312
|
+
mock_post.return_value = _mock_response(200, _BATCH_CF_RESPONSE)
|
|
313
|
+
results = find_counterfactuals(samples=[[1.0, "no"], [2.0, "no"]], target="high")
|
|
314
|
+
assert len(results) == 2
|
|
315
|
+
assert isinstance(results[0], pd.DataFrame)
|
|
316
|
+
assert results[0]["f_cont"].iloc[0] == 1.5
|
|
317
|
+
assert results[1] is None # no counterfactual for second instance
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@patch("proxyml.client.post")
|
|
321
|
+
def test_find_counterfactuals_raw(mock_post):
|
|
322
|
+
mock_post.return_value = _mock_response(200, _BATCH_CF_RESPONSE)
|
|
323
|
+
result = find_counterfactuals(samples=[[1.0, "no"]], target="high", as_dfs=False)
|
|
324
|
+
assert result == _BATCH_CF_RESPONSE
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@patch("proxyml.client.post")
|
|
328
|
+
def test_find_counterfactuals_payload(mock_post):
|
|
329
|
+
mock_post.return_value = _mock_response(200, _BATCH_CF_RESPONSE)
|
|
330
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
331
|
+
find_counterfactuals(
|
|
332
|
+
samples=[[1.0, "no"]], target="high",
|
|
333
|
+
n_neighbors=500, perturbation_scale=0.2, version=uid, as_dfs=False,
|
|
334
|
+
)
|
|
335
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
336
|
+
assert payload["instances"] == [[1.0, "no"]]
|
|
337
|
+
assert payload["target_label"] == "high"
|
|
338
|
+
assert payload["n_neighbors"] == 500
|
|
339
|
+
assert payload["perturbation_scale"] == 0.2
|
|
340
|
+
assert payload["version"] == uid
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@patch("proxyml.client.post")
|
|
344
|
+
def test_find_counterfactuals_failure_returns_none(mock_post):
|
|
345
|
+
mock_post.return_value = _mock_response(404, {"detail": "no surrogate"})
|
|
346
|
+
assert find_counterfactuals(samples=[[1.0]], target="high") is None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# get_model_summary
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
_SUMMARY_RESPONSE = {
|
|
354
|
+
"model_version": "abc-123",
|
|
355
|
+
"task": "regression",
|
|
356
|
+
"trained_at": "2026-04-20T10:00:00",
|
|
357
|
+
"name": "v2",
|
|
358
|
+
"comments": None,
|
|
359
|
+
"feature_names": ["MedInc", "Latitude"],
|
|
360
|
+
"metrics": {"r2": 0.92},
|
|
361
|
+
"feature_importances": [
|
|
362
|
+
{"feature": "MedInc", "coefficient": 0.82, "abs_coefficient": 0.82},
|
|
363
|
+
{"feature": "Latitude", "coefficient": -0.61, "abs_coefficient": 0.61},
|
|
364
|
+
],
|
|
365
|
+
"per_class_importances": None,
|
|
366
|
+
"note": "Coefficients are in the scaled feature space.",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@patch("proxyml.client.get")
|
|
371
|
+
def test_get_model_summary_success(mock_get):
|
|
372
|
+
mock_get.return_value = _mock_response(200, _SUMMARY_RESPONSE)
|
|
373
|
+
result = get_model_summary()
|
|
374
|
+
assert result == _SUMMARY_RESPONSE
|
|
375
|
+
mock_get.assert_called_once_with(endpoint="/explain/summary", params={})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@patch("proxyml.client.get")
|
|
379
|
+
def test_get_model_summary_with_version(mock_get):
|
|
380
|
+
mock_get.return_value = _mock_response(200, _SUMMARY_RESPONSE)
|
|
381
|
+
get_model_summary(version="abc-123")
|
|
382
|
+
mock_get.assert_called_once_with(endpoint="/explain/summary", params={"version": "abc-123"})
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@patch("proxyml.client.get")
|
|
386
|
+
def test_get_model_summary_failure_returns_none(mock_get):
|
|
387
|
+
mock_get.return_value = _mock_response(404, {"detail": "not found"})
|
|
388
|
+
assert get_model_summary() is None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# diff_models
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
_DIFF_RESPONSE = {
|
|
396
|
+
"version_a": "aaa-111",
|
|
397
|
+
"version_b": "bbb-222",
|
|
398
|
+
"task": "regression",
|
|
399
|
+
"metric_diff": {"r2": {"a": 0.87, "b": 0.92, "delta": 0.05}},
|
|
400
|
+
"coefficient_diff": [
|
|
401
|
+
{"feature": "MedInc", "a": 0.82, "b": 0.76, "delta": -0.06},
|
|
402
|
+
],
|
|
403
|
+
"features_added": [],
|
|
404
|
+
"features_removed": ["Population"],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@patch("proxyml.client.get")
|
|
409
|
+
def test_diff_models_success(mock_get):
|
|
410
|
+
mock_get.return_value = _mock_response(200, _DIFF_RESPONSE)
|
|
411
|
+
result = diff_models(version_a="aaa-111", version_b="bbb-222")
|
|
412
|
+
assert result == _DIFF_RESPONSE
|
|
413
|
+
mock_get.assert_called_once_with(
|
|
414
|
+
endpoint="/explain/diff",
|
|
415
|
+
params={"version_a": "aaa-111", "version_b": "bbb-222"},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@patch("proxyml.client.get")
|
|
420
|
+
def test_diff_models_failure_returns_none(mock_get):
|
|
421
|
+
mock_get.return_value = _mock_response(422, {"detail": "different tasks"})
|
|
422
|
+
assert diff_models(version_a="aaa-111", version_b="bbb-222") is None
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from unittest.mock import MagicMock, patch
|
|
3
|
-
|
|
4
|
-
import pandas as pd
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from proxyml.client import (
|
|
8
|
-
_cast_column,
|
|
9
|
-
_headers,
|
|
10
|
-
interpret_counterfactual,
|
|
11
|
-
predict,
|
|
12
|
-
put_schema,
|
|
13
|
-
synthesize_data,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
# ---------------------------------------------------------------------------
|
|
18
|
-
# _headers
|
|
19
|
-
# ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
def test_headers_raises_without_api_key(monkeypatch):
|
|
22
|
-
monkeypatch.delenv("PROXYML_API_KEY", raising=False)
|
|
23
|
-
with pytest.raises(EnvironmentError, match="PROXYML_API_KEY"):
|
|
24
|
-
_headers()
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_headers_returns_correct_dict(monkeypatch):
|
|
28
|
-
monkeypatch.setenv("PROXYML_API_KEY", "test-key")
|
|
29
|
-
h = _headers()
|
|
30
|
-
assert h["X-API-KEY"] == "test-key"
|
|
31
|
-
assert h["Content-Type"] == "application/json"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
# _cast_column
|
|
36
|
-
# ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
def test_cast_column_continuous():
|
|
39
|
-
s = pd.Series(["1.5", "2.5", "3.5"])
|
|
40
|
-
result = _cast_column(s, "continuous")
|
|
41
|
-
assert result.dtype == float
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_cast_column_count():
|
|
45
|
-
s = pd.Series([1.0, 2.0, 3.0])
|
|
46
|
-
result = _cast_column(s, "count")
|
|
47
|
-
assert result.dtype == int
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_cast_column_numeric_ordinal():
|
|
51
|
-
s = pd.Series([1.0, 2.0, 3.0])
|
|
52
|
-
result = _cast_column(s, "numeric_ordinal")
|
|
53
|
-
assert result.dtype == int
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_cast_column_categorical_bool_strings():
|
|
57
|
-
s = pd.Series(["true", "false", "true"])
|
|
58
|
-
result = _cast_column(s, "categorical")
|
|
59
|
-
assert result.tolist() == [True, False, True]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_cast_column_categorical_passthrough():
|
|
63
|
-
s = pd.Series(["a", "b", "c"])
|
|
64
|
-
result = _cast_column(s, "categorical")
|
|
65
|
-
assert result.tolist() == ["a", "b", "c"]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_cast_column_unknown_type_passthrough():
|
|
69
|
-
s = pd.Series([1, 2, 3])
|
|
70
|
-
result = _cast_column(s, "unknown_type")
|
|
71
|
-
assert result.tolist() == [1, 2, 3]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# ---------------------------------------------------------------------------
|
|
75
|
-
# interpret_counterfactual
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
def test_interpret_counterfactual_prediction_changed():
|
|
79
|
-
result = interpret_counterfactual(
|
|
80
|
-
sample={"age": 30, "income": 50000},
|
|
81
|
-
counterfactual={"age": 30, "income": 75000},
|
|
82
|
-
prediction_changed=True,
|
|
83
|
-
exclude_from_diff=None,
|
|
84
|
-
)
|
|
85
|
-
assert "income from 50000 to 75000" in result
|
|
86
|
-
assert "different prediction" in result
|
|
87
|
-
assert "surrogate model" in result
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_interpret_counterfactual_prediction_unchanged():
|
|
91
|
-
result = interpret_counterfactual(
|
|
92
|
-
sample={"age": 30, "income": 50000},
|
|
93
|
-
counterfactual={"age": 30, "income": 75000},
|
|
94
|
-
prediction_changed=False,
|
|
95
|
-
exclude_from_diff=None,
|
|
96
|
-
)
|
|
97
|
-
assert "income from 50000 to 75000" in result
|
|
98
|
-
assert "did not change" in result
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_interpret_counterfactual_no_diffs():
|
|
102
|
-
result = interpret_counterfactual(
|
|
103
|
-
sample={"age": 30},
|
|
104
|
-
counterfactual={"age": 30},
|
|
105
|
-
prediction_changed=True,
|
|
106
|
-
exclude_from_diff=None,
|
|
107
|
-
)
|
|
108
|
-
assert result == "No meaningful differences found between sample and counterfactual."
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def test_interpret_counterfactual_exclude_from_diff():
|
|
112
|
-
result = interpret_counterfactual(
|
|
113
|
-
sample={"age": 30, "id": 999},
|
|
114
|
-
counterfactual={"age": 35, "id": 888},
|
|
115
|
-
prediction_changed=True,
|
|
116
|
-
exclude_from_diff=["id"],
|
|
117
|
-
)
|
|
118
|
-
assert "id" not in result
|
|
119
|
-
assert "age from 30 to 35" in result
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# ---------------------------------------------------------------------------
|
|
123
|
-
# API functions (mocked HTTP)
|
|
124
|
-
# ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
def _mock_response(status_code, json_body):
|
|
127
|
-
r = MagicMock()
|
|
128
|
-
r.status_code = status_code
|
|
129
|
-
r.json.return_value = json_body
|
|
130
|
-
r.text = str(json_body)
|
|
131
|
-
return r
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
@patch("proxyml.client.put")
|
|
135
|
-
def test_put_schema_success(mock_put):
|
|
136
|
-
mock_put.return_value = _mock_response(200, {"status": "ok"})
|
|
137
|
-
result = put_schema({"features": []})
|
|
138
|
-
assert result == {"status": "ok"}
|
|
139
|
-
mock_put.assert_called_once_with(endpoint="/schema", payload={"features": []})
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
@patch("proxyml.client.put")
|
|
143
|
-
def test_put_schema_failure(mock_put):
|
|
144
|
-
mock_put.return_value = _mock_response(422, {"detail": "invalid"})
|
|
145
|
-
result = put_schema({"features": []})
|
|
146
|
-
assert result is None
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@patch("proxyml.client.post")
|
|
150
|
-
def test_predict_success(mock_post):
|
|
151
|
-
mock_post.return_value = _mock_response(200, {"prediction": 1, "probability": 0.9})
|
|
152
|
-
result = predict(samples=[1.0, 2.0, 3.0], version=None)
|
|
153
|
-
assert result == {"prediction": 1, "probability": 0.9}
|
|
154
|
-
payload = mock_post.call_args.kwargs["payload"]
|
|
155
|
-
assert payload["inputs"] == [1.0, 2.0, 3.0]
|
|
156
|
-
assert "version" not in payload
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
@patch("proxyml.client.post")
|
|
160
|
-
def test_predict_with_version(mock_post):
|
|
161
|
-
mock_post.return_value = _mock_response(200, {"prediction": 0})
|
|
162
|
-
predict(samples=[1.0, 2.0], version=3)
|
|
163
|
-
payload = mock_post.call_args.kwargs["payload"]
|
|
164
|
-
assert payload["version"] == 3
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
@patch("proxyml.client.post")
|
|
168
|
-
def test_predict_failure_returns_none(mock_post):
|
|
169
|
-
mock_post.return_value = _mock_response(422, {"detail": "bad input"})
|
|
170
|
-
result = predict(samples=[1.0, 2.0], version=None)
|
|
171
|
-
assert result is None
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
@patch("proxyml.client.post")
|
|
175
|
-
def test_synthesize_data_no_sample(mock_post):
|
|
176
|
-
mock_post.return_value = _mock_response(200, {
|
|
177
|
-
"samples": [[1.0, "true"], [2.5, "false"]],
|
|
178
|
-
"feature_names": ["f_cont", "f_cat"],
|
|
179
|
-
"feature_types": ["continuous", "categorical"],
|
|
180
|
-
})
|
|
181
|
-
df = synthesize_data(num_points=2, sample=None)
|
|
182
|
-
assert isinstance(df, pd.DataFrame)
|
|
183
|
-
assert list(df.columns) == ["f_cont", "f_cat"]
|
|
184
|
-
mock_post.assert_called_once_with(
|
|
185
|
-
endpoint="/synthesize/neighbors", payload={"n": 2}
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
@patch("proxyml.client.post")
|
|
190
|
-
def test_synthesize_data_failure_returns_none(mock_post):
|
|
191
|
-
mock_post.return_value = _mock_response(500, {})
|
|
192
|
-
result = synthesize_data(num_points=10, sample=None)
|
|
193
|
-
assert result is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|