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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxyml"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Python SDK for calling the ProxyML API"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -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