proxyml 0.1.1__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.1 → proxyml-0.1.3}/PKG-INFO +1 -1
- {proxyml-0.1.1 → proxyml-0.1.3}/pyproject.toml +1 -1
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml/__init__.py +13 -0
- proxyml-0.1.3/src/proxyml/client.py +345 -0
- {proxyml-0.1.1 → 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.1/src/proxyml/client.py +0 -212
- proxyml-0.1.1/tests/test_client.py +0 -193
- {proxyml-0.1.1 → proxyml-0.1.3}/LICENSE +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/README.md +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/setup.cfg +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml/schema.py +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml.egg-info/SOURCES.txt +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml.egg-info/dependency_links.txt +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml.egg-info/requires.txt +0 -0
- {proxyml-0.1.1 → proxyml-0.1.3}/src/proxyml.egg-info/top_level.txt +0 -0
- {proxyml-0.1.1 → 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",
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
logger = logging.getLogger(__name__)
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import os
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from pandas.api.types import is_float_dtype, is_integer_dtype
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PROXYML_BASE_URL = os.getenv("PROXYML_BASE_URL", "https://api.proxyml.ai/api/v1")
|
|
12
|
+
|
|
13
|
+
_BOOL_STRINGS = {"true", "false"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _headers() -> dict:
|
|
17
|
+
api_key = os.getenv("PROXYML_API_KEY", "")
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise EnvironmentError(
|
|
20
|
+
"PROXYML_API_KEY is not set. "
|
|
21
|
+
"Set the environment variable before using the SDK."
|
|
22
|
+
)
|
|
23
|
+
return {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
'X-API-KEY': api_key,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def post(endpoint: str, payload: dict) -> requests.models.Response:
|
|
30
|
+
r = requests.post(
|
|
31
|
+
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
32
|
+
json=payload,
|
|
33
|
+
headers=_headers()
|
|
34
|
+
)
|
|
35
|
+
return r
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def put(endpoint: str, payload: dict) -> requests.models.Response:
|
|
39
|
+
r = requests.put(
|
|
40
|
+
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
41
|
+
json=payload,
|
|
42
|
+
headers=_headers()
|
|
43
|
+
)
|
|
44
|
+
return r
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get(endpoint: str, params: dict) -> requests.models.Response:
|
|
48
|
+
r = requests.get(
|
|
49
|
+
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
50
|
+
headers=_headers(),
|
|
51
|
+
params=params
|
|
52
|
+
)
|
|
53
|
+
return r
|
|
54
|
+
|
|
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
|
+
|
|
64
|
+
def put_schema(schema: dict):
|
|
65
|
+
r = put(endpoint='/schema', payload=schema)
|
|
66
|
+
if r.status_code == 200:
|
|
67
|
+
logger.info("Schema uploaded successfully")
|
|
68
|
+
return r.json()
|
|
69
|
+
logger.error(
|
|
70
|
+
"Schema upload failed with status %s: %s",
|
|
71
|
+
r.status_code,
|
|
72
|
+
r.text
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
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"):
|
|
81
|
+
return series.astype(int)
|
|
82
|
+
if ftype in ("categorical", "categorical_ordinal"):
|
|
83
|
+
# convert "true"/"false" strings back to booleans when appropriate
|
|
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
|
+
|
|
90
|
+
def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bool = True):
|
|
91
|
+
if sample is None:
|
|
92
|
+
r = post(endpoint='/synthesize/neighbors', payload={'n': num_points})
|
|
93
|
+
else:
|
|
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:
|
|
98
|
+
df = pd.DataFrame(payload['samples'], columns=payload['feature_names'])
|
|
99
|
+
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
100
|
+
df[col] = _cast_column(df[col], ftype)
|
|
101
|
+
return df
|
|
102
|
+
return payload
|
|
103
|
+
logger.error(
|
|
104
|
+
"Data synthesis failed with status %s: %s",
|
|
105
|
+
r.status_code,
|
|
106
|
+
r.text
|
|
107
|
+
)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def train_surrogate(
|
|
112
|
+
samples: list,
|
|
113
|
+
predictions: list,
|
|
114
|
+
feature_names: list[str] | None,
|
|
115
|
+
task: str = 'auto',
|
|
116
|
+
test_size: float = 0.2,
|
|
117
|
+
name: str | None = None,
|
|
118
|
+
comments: str | None = None,
|
|
119
|
+
):
|
|
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)
|
|
132
|
+
if r.status_code == 200:
|
|
133
|
+
return r.json()
|
|
134
|
+
logger.error(
|
|
135
|
+
"Surrogate training failed with status %s: %s",
|
|
136
|
+
r.status_code,
|
|
137
|
+
r.text
|
|
138
|
+
)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def predict(sample: list, version: str | None = None):
|
|
143
|
+
payload = {'inputs': sample}
|
|
144
|
+
if version is not None:
|
|
145
|
+
payload['version'] = version
|
|
146
|
+
r = post(endpoint='/surrogate/predict', payload=payload)
|
|
147
|
+
if r.status_code == 200:
|
|
148
|
+
return r.json()
|
|
149
|
+
logger.error(
|
|
150
|
+
"Surrogate prediction failed with status %s: %s",
|
|
151
|
+
r.status_code,
|
|
152
|
+
r.text
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version: str | None = None, as_df: bool = True):
|
|
158
|
+
payload = {
|
|
159
|
+
'instance': sample,
|
|
160
|
+
'target_label': target.item() if hasattr(target, 'item') else target,
|
|
161
|
+
'n_neighbors': n_neighbors,
|
|
162
|
+
'perturbation_scale': perturbation_scale,
|
|
163
|
+
}
|
|
164
|
+
if version is not None:
|
|
165
|
+
payload['version'] = version
|
|
166
|
+
r = post(endpoint='/explain/counterfactual', payload=payload)
|
|
167
|
+
if r.status_code == 200:
|
|
168
|
+
payload = r.json()
|
|
169
|
+
if as_df:
|
|
170
|
+
if payload['counterfactual'] is None:
|
|
171
|
+
print(f"No counterfactual found: {payload.get('warning')}")
|
|
172
|
+
return None
|
|
173
|
+
df = pd.DataFrame([payload['counterfactual']], columns=payload['feature_names'])
|
|
174
|
+
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
175
|
+
df[col] = _cast_column(df[col], ftype)
|
|
176
|
+
return df
|
|
177
|
+
return payload
|
|
178
|
+
logger.error(
|
|
179
|
+
"Counterfactual failed with status %s: %s",
|
|
180
|
+
r.status_code,
|
|
181
|
+
r.text
|
|
182
|
+
)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def interpret_counterfactual(
|
|
187
|
+
sample: dict,
|
|
188
|
+
counterfactual: dict,
|
|
189
|
+
prediction_changed: bool,
|
|
190
|
+
exclude_from_diff: list[str] | None
|
|
191
|
+
) -> str:
|
|
192
|
+
if not exclude_from_diff:
|
|
193
|
+
exclude_from_diff = list()
|
|
194
|
+
diffs = {
|
|
195
|
+
k: (sample[k], counterfactual[k])
|
|
196
|
+
for k in sample
|
|
197
|
+
if sample[k] != counterfactual[k]
|
|
198
|
+
and k not in exclude_from_diff
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if not diffs:
|
|
202
|
+
return "No meaningful differences found between sample and counterfactual."
|
|
203
|
+
|
|
204
|
+
changes = ", ".join(
|
|
205
|
+
f"{feature} from {original} to {cf_value}"
|
|
206
|
+
for feature, (original, cf_value) in diffs.items()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if prediction_changed:
|
|
210
|
+
return (
|
|
211
|
+
f"Changing {changes} may result in a different prediction. "
|
|
212
|
+
f"Note: this is based on a surrogate model and should be treated as approximate."
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
return (
|
|
216
|
+
f"A counterfactual was found suggesting {changes}, however "
|
|
217
|
+
f"the original model's prediction did not change. "
|
|
218
|
+
f"This may indicate the surrogate model does not fully capture "
|
|
219
|
+
f"the original model's decision boundary in this region."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
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 {}
|
|
308
|
+
r = get(endpoint='/explain/importance', params=params)
|
|
309
|
+
if r.status_code == 200:
|
|
310
|
+
return r.json()
|
|
311
|
+
logger.error(
|
|
312
|
+
"Feature importances failed with status %s: %s",
|
|
313
|
+
r.status_code,
|
|
314
|
+
r.text,
|
|
315
|
+
)
|
|
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,212 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
logger = logging.getLogger(__name__)
|
|
3
|
-
|
|
4
|
-
import requests
|
|
5
|
-
import os
|
|
6
|
-
import numpy as np
|
|
7
|
-
import pandas as pd
|
|
8
|
-
from pandas.api.types import is_float_dtype, is_integer_dtype
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
PROXYML_BASE_URL = os.getenv("PROXYML_BASE_URL", "https://api.proxyml.ai/api/v1")
|
|
12
|
-
|
|
13
|
-
_BOOL_STRINGS = {"true", "false"}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _headers() -> dict:
|
|
17
|
-
api_key = os.getenv("PROXYML_API_KEY", "")
|
|
18
|
-
if not api_key:
|
|
19
|
-
raise EnvironmentError(
|
|
20
|
-
"PROXYML_API_KEY is not set. "
|
|
21
|
-
"Set the environment variable before using the SDK."
|
|
22
|
-
)
|
|
23
|
-
return {
|
|
24
|
-
'Content-Type': 'application/json',
|
|
25
|
-
'X-API-KEY': api_key,
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def post(endpoint: str, payload: dict) -> requests.models.Response:
|
|
30
|
-
r = requests.post(
|
|
31
|
-
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
32
|
-
json=payload,
|
|
33
|
-
headers=_headers()
|
|
34
|
-
)
|
|
35
|
-
return r
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def put(endpoint: str, payload: dict) -> requests.models.Response:
|
|
39
|
-
r = requests.put(
|
|
40
|
-
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
41
|
-
json=payload,
|
|
42
|
-
headers=_headers()
|
|
43
|
-
)
|
|
44
|
-
return r
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def get(endpoint: str, params: dict) -> requests.models.Response:
|
|
48
|
-
r = requests.get(
|
|
49
|
-
url=f'{PROXYML_BASE_URL}{endpoint}',
|
|
50
|
-
headers=_headers(),
|
|
51
|
-
params=params
|
|
52
|
-
)
|
|
53
|
-
return r
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def put_schema(schema: dict):
|
|
57
|
-
r = put(endpoint='/schema', payload=schema)
|
|
58
|
-
if r.status_code == 200:
|
|
59
|
-
logger.info("Schema uploaded successfully")
|
|
60
|
-
return r.json()
|
|
61
|
-
logger.error(
|
|
62
|
-
"Schema upload failed with status %s: %s",
|
|
63
|
-
r.status_code,
|
|
64
|
-
r.text
|
|
65
|
-
)
|
|
66
|
-
return None
|
|
67
|
-
|
|
68
|
-
|
|
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"):
|
|
73
|
-
return series.astype(int)
|
|
74
|
-
if ftype in ("categorical", "categorical_ordinal"):
|
|
75
|
-
# 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
|
-
|
|
82
|
-
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})
|
|
85
|
-
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:
|
|
90
|
-
df = pd.DataFrame(payload['samples'], columns=payload['feature_names'])
|
|
91
|
-
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
92
|
-
df[col] = _cast_column(df[col], ftype)
|
|
93
|
-
return df
|
|
94
|
-
return payload
|
|
95
|
-
logger.error(
|
|
96
|
-
"Data synthesis failed with status %s: %s",
|
|
97
|
-
r.status_code,
|
|
98
|
-
r.text
|
|
99
|
-
)
|
|
100
|
-
return None
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def train_surrogate(
|
|
104
|
-
samples: list,
|
|
105
|
-
predictions: list,
|
|
106
|
-
feature_names: list[str] | None,
|
|
107
|
-
task: str = 'auto',
|
|
108
|
-
test_size: float = 0.2
|
|
109
|
-
):
|
|
110
|
-
r = post(endpoint='/surrogate/train', payload={'samples': samples, 'predictions': predictions, 'feature_names': feature_names, 'task': task, 'test_size': test_size})
|
|
111
|
-
if r.status_code == 200:
|
|
112
|
-
return r.json()
|
|
113
|
-
logger.error(
|
|
114
|
-
"Surrogate training failed with status %s: %s",
|
|
115
|
-
r.status_code,
|
|
116
|
-
r.text
|
|
117
|
-
)
|
|
118
|
-
return None
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def predict(samples: list, version: int | None): # Defaults to latest version if not specified
|
|
122
|
-
payload = {'inputs': samples}
|
|
123
|
-
if version: # Also rejects version 0 (versions start at 1)
|
|
124
|
-
payload['version'] = version
|
|
125
|
-
r = post(endpoint='/surrogate/predict', payload=payload)
|
|
126
|
-
if r.status_code == 200:
|
|
127
|
-
return r.json()
|
|
128
|
-
logger.error(
|
|
129
|
-
"Surrogate prediction failed with status %s: %s",
|
|
130
|
-
r.status_code,
|
|
131
|
-
r.text
|
|
132
|
-
)
|
|
133
|
-
return None
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version: int | None = None, as_df: bool = True):
|
|
137
|
-
payload = {
|
|
138
|
-
'instance': sample,
|
|
139
|
-
'target_label': target,
|
|
140
|
-
'n_neighbors': n_neighbors,
|
|
141
|
-
'perturbation_scale': perturbation_scale,
|
|
142
|
-
}
|
|
143
|
-
if version: # Also rejects version 0 (versions start at 1)
|
|
144
|
-
payload['version'] = version
|
|
145
|
-
r = post(endpoint='/explain/counterfactual', payload=payload)
|
|
146
|
-
if r.status_code == 200:
|
|
147
|
-
payload = r.json()
|
|
148
|
-
if as_df:
|
|
149
|
-
if payload['counterfactual'] is None:
|
|
150
|
-
print(f"No counterfactual found: {payload.get('warning')}")
|
|
151
|
-
return None
|
|
152
|
-
df = pd.DataFrame([payload['counterfactual']], columns=payload['feature_names'])
|
|
153
|
-
for col, ftype in zip(payload['feature_names'], payload['feature_types']):
|
|
154
|
-
df[col] = _cast_column(df[col], ftype)
|
|
155
|
-
return df
|
|
156
|
-
return payload
|
|
157
|
-
logger.error(
|
|
158
|
-
"Counterfactual failed with status %s: %s",
|
|
159
|
-
r.status_code,
|
|
160
|
-
r.text
|
|
161
|
-
)
|
|
162
|
-
return None
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def interpret_counterfactual(
|
|
166
|
-
sample: dict,
|
|
167
|
-
counterfactual: dict,
|
|
168
|
-
prediction_changed: bool,
|
|
169
|
-
exclude_from_diff: list[str] | None
|
|
170
|
-
) -> str:
|
|
171
|
-
if not exclude_from_diff:
|
|
172
|
-
exclude_from_diff = list()
|
|
173
|
-
diffs = {
|
|
174
|
-
k: (sample[k], counterfactual[k])
|
|
175
|
-
for k in sample
|
|
176
|
-
if sample[k] != counterfactual[k]
|
|
177
|
-
and k not in exclude_from_diff
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if not diffs:
|
|
181
|
-
return "No meaningful differences found between sample and counterfactual."
|
|
182
|
-
|
|
183
|
-
changes = ", ".join(
|
|
184
|
-
f"{feature} from {original} to {cf_value}"
|
|
185
|
-
for feature, (original, cf_value) in diffs.items()
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
if prediction_changed:
|
|
189
|
-
return (
|
|
190
|
-
f"Changing {changes} may result in a different prediction. "
|
|
191
|
-
f"Note: this is based on a surrogate model and should be treated as approximate."
|
|
192
|
-
)
|
|
193
|
-
else:
|
|
194
|
-
return (
|
|
195
|
-
f"A counterfactual was found suggesting {changes}, however "
|
|
196
|
-
f"the original model's prediction did not change. "
|
|
197
|
-
f"This may indicate the surrogate model does not fully capture "
|
|
198
|
-
f"the original model's decision boundary in this region."
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def get_feature_importances(version: int | None = None, ):
|
|
203
|
-
params = {'version': version} if version else {}
|
|
204
|
-
r = get(endpoint='/explain/importance', params=params)
|
|
205
|
-
if r.status_code == 200:
|
|
206
|
-
return r.json()
|
|
207
|
-
logger.error(
|
|
208
|
-
"Feature importances failed with status %s: %s",
|
|
209
|
-
r.status_code,
|
|
210
|
-
r.text,
|
|
211
|
-
)
|
|
212
|
-
return 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
|