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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.2
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.2"
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",
@@ -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
- r = post(endpoint='/surrogate/train', payload={'samples': samples, 'predictions': predictions, 'feature_names': feature_names, 'task': task, 'test_size': test_size})
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(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
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: int | None = None, as_df: bool = True):
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, # handle numpy scalars
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: # Also rejects version 0 (versions start at 1)
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 get_feature_importances(version: int | None = None, ):
203
- params = {'version': version} if version else {}
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.2
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,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