proxyml 0.1.6__tar.gz → 0.1.8__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.6
3
+ Version: 0.1.8
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -223,6 +223,8 @@ Python SDK for the [ProxyML API](https://proxyml.ai).
223
223
  > **Status:** Early access — server endpoints coming soon.
224
224
  > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
225
225
 
226
+ <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
227
+
226
228
  ## Why ProxyML?
227
229
 
228
230
  Most explainability tools require sending your data to a third-party server.
@@ -5,6 +5,8 @@ Python SDK for the [ProxyML API](https://proxyml.ai).
5
5
  > **Status:** Early access — server endpoints coming soon.
6
6
  > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
7
7
 
8
+ <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
9
+
8
10
  ## Why ProxyML?
9
11
 
10
12
  Most explainability tools require sending your data to a third-party server.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxyml"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Python SDK for calling the ProxyML API"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  logger = logging.getLogger(__name__)
3
+ from typing import Any
3
4
 
4
5
  import requests
5
6
  import os
@@ -14,6 +15,9 @@ _BOOL_STRINGS = {"true", "false"}
14
15
 
15
16
 
16
17
  def _headers() -> dict:
18
+ """
19
+ Constructs the request headers required for making calls to the ProxyML API.
20
+ """
17
21
  api_key = os.getenv("PROXYML_API_KEY", "")
18
22
  if not api_key:
19
23
  raise EnvironmentError(
@@ -27,6 +31,16 @@ def _headers() -> dict:
27
31
 
28
32
 
29
33
  def post(endpoint: str, payload: dict) -> requests.models.Response:
34
+ """
35
+ POSTs a request to a ProxyML API endpoint.
36
+
37
+ Args:
38
+ endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
39
+ payload (dict): JSON payload to POST.
40
+
41
+ Returns:
42
+ requests Response object.
43
+ """
30
44
  r = requests.post(
31
45
  url=f'{PROXYML_BASE_URL}{endpoint}',
32
46
  json=payload,
@@ -36,6 +50,16 @@ def post(endpoint: str, payload: dict) -> requests.models.Response:
36
50
 
37
51
 
38
52
  def put(endpoint: str, payload: dict) -> requests.models.Response:
53
+ """
54
+ PUTs a request to a ProxyML API endpoint.
55
+
56
+ Args:
57
+ endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
58
+ payload (dict): JSON payload to POST.
59
+
60
+ Returns:
61
+ requests Response object.
62
+ """
39
63
  r = requests.put(
40
64
  url=f'{PROXYML_BASE_URL}{endpoint}',
41
65
  json=payload,
@@ -45,6 +69,16 @@ def put(endpoint: str, payload: dict) -> requests.models.Response:
45
69
 
46
70
 
47
71
  def get(endpoint: str, params: dict) -> requests.models.Response:
72
+ """
73
+ GETs a request to a ProxyML API endpoint.
74
+
75
+ Args:
76
+ endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
77
+ params (dict): JSON payload to POST.
78
+
79
+ Returns:
80
+ requests Response object.
81
+ """
48
82
  r = requests.get(
49
83
  url=f'{PROXYML_BASE_URL}{endpoint}',
50
84
  headers=_headers(),
@@ -54,6 +88,15 @@ def get(endpoint: str, params: dict) -> requests.models.Response:
54
88
 
55
89
 
56
90
  def delete(endpoint: str) -> requests.models.Response:
91
+ """
92
+ DELETE request to a ProxyML API endpoint.
93
+
94
+ Args:
95
+ endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
96
+
97
+ Returns:
98
+ requests Response object.
99
+ """
57
100
  r = requests.delete(
58
101
  url=f'{PROXYML_BASE_URL}{endpoint}',
59
102
  headers=_headers()
@@ -61,7 +104,16 @@ def delete(endpoint: str) -> requests.models.Response:
61
104
  return r
62
105
 
63
106
 
64
- def put_schema(schema: dict, name: str = "default"):
107
+ def put_schema(schema: dict, name: str = "default") -> Any:
108
+ """
109
+ Uploads a data schema.
110
+
111
+ Args:
112
+ schema (dict): data schema object
113
+ name (str): optional name for the schema, defaults to "default".
114
+ Returns:
115
+ API response JSON object, or None if the return code was not 200.
116
+ """
65
117
  r = put(endpoint=f'/schema/{name}', payload=schema)
66
118
  if r.status_code == 200:
67
119
  logger.info("Schema '%s' uploaded successfully", name)
@@ -117,6 +169,15 @@ def delete_schema(name: str) -> bool:
117
169
 
118
170
 
119
171
  def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
172
+ """
173
+ Casts a pandas Series to a Python type based on a specified ftype.
174
+
175
+ Args:
176
+ series (pd.Series): data to cast
177
+ ftype (str): type of conversion, one of "continuous", "count", "numeric_ordinal", "categorical", or "categorical_ordinal".
178
+ Returns:
179
+ pd.Series cast to specific ftype.
180
+ """
120
181
  if ftype in ("continuous",):
121
182
  return series.astype(float)
122
183
  if ftype in ("count", "numeric_ordinal"):
@@ -129,7 +190,18 @@ def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
129
190
  return series
130
191
 
131
192
 
132
- def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bool = True, schema_name: str = "default"):
193
+ def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bool = True, schema_name: str = "default") -> Any:
194
+ """
195
+ Synthesizes data based on a data schema.
196
+
197
+ Args:
198
+ num_points (int): number of samples to synthesize, defaults to 100.
199
+ sample (list): if specified, synthesized data will be a blend of samples generated from the schema and a number of perturbations around this sample. If not specified (default), all synthesized data is generated by sampling from the data schema.
200
+ as_df (bool): if True (default), data are returned as a pandas DataFrame.
201
+ schema_name (str): name of the data schema to use to generate the data, defaults to "default."
202
+ Returns:
203
+ JSON object representing the synthesized data if as_df=False, a pandas DataFrame if as_df=True, or None if an error occurred.
204
+ """
133
205
  if sample is None:
134
206
  r = post(endpoint='/synthesize/neighbors', payload={'n': num_points, 'schema_name': schema_name})
135
207
  else:
@@ -159,7 +231,22 @@ def train_surrogate(
159
231
  schema_name: str = "default",
160
232
  name: str | None = None,
161
233
  comments: str | None = None,
162
- ):
234
+ ) -> Any:
235
+ """
236
+ Trains a surrogate model to predict a "black box" model's predictions.
237
+
238
+ Args:
239
+ samples (list): list of samples that were provided to the black box for inference, e.g. the output from synthesize_data().
240
+ predictions (list): the inferences on the samples, made by the black box model.
241
+ feature_names (list): names of the features (columns) in the data.
242
+ task (str): specifies the modeling task, one of "classification," "regression," or "auto" in which case ProxyML will attempt to automatically determine the modeling task.
243
+ test_size (float): fraction of the data to set aside for test data, defaults to 0.2.
244
+ schema_name (str): name of the data schema to use, defaults to "default."
245
+ name (str): an optional name for the surrogate.
246
+ comments (str): an optional comment string for the surrogate.
247
+ Returns:
248
+ JSON object denoting surrogate training result, or None if an error occurred.
249
+ """
163
250
  payload = {
164
251
  'samples': samples,
165
252
  'predictions': predictions,
@@ -183,7 +270,36 @@ def train_surrogate(
183
270
  return None
184
271
 
185
272
 
186
- def predict(sample: list, version: str | None = None):
273
+ def export_surrogate(version: str) -> Any:
274
+ """
275
+ Exports a surrogate model to JSON e.g., classes, intercept, per_class_intercepts, features, scalers, etc.; everything required
276
+ to reconstruct the surrogate.
277
+
278
+ Args:
279
+ version (str): name of the surrogate to export
280
+ Returns:
281
+ JSON object representing the surrogate, or None if an error occurred.
282
+ """
283
+ r = get(endpoint=f'/surrogate/models/{version}/export', params=dict())
284
+ if r.status_code == 200:
285
+ return r.json()
286
+ logger.error(
287
+ "Surrogate export failed (version=%s, status=%s): %s",
288
+ version, r.status_code, r.text,
289
+ )
290
+ return None
291
+
292
+
293
+ def predict(sample: list, version: str | None = None) -> Any:
294
+ """
295
+ Calls a surrogate model to make a single prediction.
296
+
297
+ Args:
298
+ sample (list): a single sample
299
+ version (str): name of the surrogate model to use.
300
+ Returns:
301
+ JSON object denoting result of the surrogate prediction, or None if an error occurred.
302
+ """
187
303
  payload = {'inputs': sample}
188
304
  if version is not None:
189
305
  payload['version'] = version
@@ -198,7 +314,21 @@ def predict(sample: list, version: str | None = None):
198
314
  return None
199
315
 
200
316
 
201
- def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version: str | None = None, as_df: bool = True):
317
+ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_scale: float = 0.1, version: str | None = None, as_df: bool = True) -> Any:
318
+ """
319
+ Attempts to find a counterfactual sample: a sample that is near to a given sample in featurespace, but with a specified prediction. Counterfactuals are
320
+ not guaranteed i.e., it's possible to not be able to find a nearby sample with the desired label / target value.
321
+
322
+ Args:
323
+ sample (list): point around which neighbors will be found.
324
+ target (Any): the desired prediction outcome, can be a string or number for classification or a target predicted value for regression.
325
+ n_neighbors (int): the number of neighbors to search, defaults to 10000.
326
+ pertubation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
327
+ version (str): name of the surrogate model to run, defaults to "default."
328
+ as_df (bool): if True, return the counterfactual as a dataframe. If False, returns JSON object.
329
+ Returns:
330
+ JSON counterfactual if as_df=False, pandas DataFrame if as_df=True, None if an error occurred.
331
+ """
202
332
  payload = {
203
333
  'instance': sample,
204
334
  'target_label': target.item() if hasattr(target, 'item') else target,
@@ -233,6 +363,17 @@ def interpret_counterfactual(
233
363
  prediction_changed: bool,
234
364
  exclude_from_diff: list[str] | None
235
365
  ) -> str:
366
+ """
367
+ Simple string interpretation of a counterfactual result. No API calls are required.
368
+
369
+ Args:
370
+ sample (dict): the original prediction i.e. the sample from which counterfactuals were drawn.
371
+ counterfactual (dict): the counterfactual returned by ProxyML.
372
+ prediction_changed (bool): True if the prediction changed, False if it did not (or didn't change as much as desired).
373
+ exclude_from_diff (list): list of keys that should not be considered in interpreting the counterfactual.
374
+ Returns:
375
+ String summarizing the differences (if any) observed between sample and counterfactual.
376
+ """
236
377
  if not exclude_from_diff:
237
378
  exclude_from_diff = list()
238
379
  diffs = {
@@ -264,7 +405,16 @@ def interpret_counterfactual(
264
405
  )
265
406
 
266
407
 
267
- def predict_batch(samples: list[list], version: str | None = None):
408
+ def predict_batch(samples: list[list], version: str | None = None) -> Any:
409
+ """
410
+ Calls a surrogate model to make batch predictions.
411
+
412
+ Args:
413
+ sample (list): a list of samples
414
+ version (str): name of the surrogate model to use.
415
+ Returns:
416
+ JSON object denoting result of the surrogate prediction, or None if an error occurred.
417
+ """
268
418
  payload = {'inputs': samples}
269
419
  if version is not None:
270
420
  payload['version'] = version
@@ -286,7 +436,21 @@ def find_counterfactuals(
286
436
  perturbation_scale: float = 0.1,
287
437
  version: str | None = None,
288
438
  as_dfs: bool = True,
289
- ):
439
+ ) -> Any:
440
+ """
441
+ Attempts to find counterfactual samples: samples that are near a given sample in featurespace, but with a specified prediction. Counterfactuals are
442
+ not guaranteed i.e., it's possible to not be able to find a nearby sample with the desired label / target value.
443
+
444
+ Args:
445
+ samples (list): list of points around which neighbors will be found.
446
+ target (Any): the desired prediction outcome, can be a string or number for classification or a target predicted value for regression.
447
+ n_neighbors (int): the number of neighbors to search, defaults to 10000.
448
+ pertubation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
449
+ version (str): name of the surrogate model to run, defaults to "default."
450
+ as_df (bool): if True, return the counterfactual as a dataframe. If False, returns JSON object.
451
+ Returns:
452
+ JSON counterfactuals if as_df=False, pandas DataFrame if as_df=True, None if an error occurred.
453
+ """
290
454
  payload = {
291
455
  'instances': samples,
292
456
  'target_label': target.item() if hasattr(target, 'item') else target,
@@ -322,7 +486,15 @@ def find_counterfactuals(
322
486
  return None
323
487
 
324
488
 
325
- def get_model_summary(version: str | None = None):
489
+ def get_model_summary(version: str | None = None) -> Any:
490
+ """
491
+ Retrieves a summary of a given surrogate.
492
+
493
+ Args:
494
+ version (str): name of the surrogate model to retrieve.
495
+ Returns:
496
+ JSON object summarizing the surrogate, or None if an error occurred.
497
+ """
326
498
  params = {'version': version} if version is not None else {}
327
499
  r = get(endpoint='/explain/summary', params=params)
328
500
  if r.status_code == 200:
@@ -335,7 +507,37 @@ def get_model_summary(version: str | None = None):
335
507
  return None
336
508
 
337
509
 
338
- def diff_models(version_a: str, version_b: str):
510
+ def get_model_schema(version: str) -> Any:
511
+ """
512
+ Retrieves the schema associated with a particular surrogate.
513
+
514
+ Args:
515
+ version (str): name of the surrogate model.
516
+ Returns:
517
+ JSON object representation of the model's schema, or None if an error occurred.
518
+ """
519
+ r = get(endpoint=f'/surrogate/models/{version}/schema', params=dict())
520
+ if r.status_code == 200:
521
+ return r.json()
522
+ logger.error(
523
+ "Schema retrieval failed with status %s: %s",
524
+ r.status_code,
525
+ r.text,
526
+ )
527
+ return None
528
+
529
+
530
+ def diff_models(version_a: str, version_b: str) -> Any:
531
+ """
532
+ Returns a summary of the differences between two surrogates. The surrogates must have the same model task (i.e. a classification model can't be compared with a regression model), and they must
533
+ have at least one feature in common.
534
+
535
+ Args:
536
+ version_a (str): name of one surrogate
537
+ version_b (str): name of the other surrogate
538
+ Returns:
539
+ JSON object summarizing the difference between version_a and version_b, or None if an error occurred.
540
+ """
339
541
  r = get(endpoint='/explain/diff', params={'version_a': version_a, 'version_b': version_b})
340
542
  if r.status_code == 200:
341
543
  return r.json()
@@ -347,7 +549,15 @@ def diff_models(version_a: str, version_b: str):
347
549
  return None
348
550
 
349
551
 
350
- def get_feature_importances(version: str | None = None):
552
+ def get_feature_importances(version: str | None = None) -> Any:
553
+ """
554
+ Convenience function to directly retrieve feature importances from a surrogate model.
555
+
556
+ Args:
557
+ version (str): ID of the surrogate
558
+ Returns:
559
+ JSON object of feature importances, or None if an error occurred.
560
+ """
351
561
  params = {'version': version} if version is not None else {}
352
562
  r = get(endpoint='/explain/importance', params=params)
353
563
  if r.status_code == 200:
@@ -8,6 +8,15 @@ from pandas.api.types import is_float_dtype, is_integer_dtype
8
8
 
9
9
 
10
10
  def gen_continuous_schema(s: pd.Series, name: str | None = None) -> dict:
11
+ """
12
+ Generates a schema entry for continuous data.
13
+
14
+ Args:
15
+ s (pandas Series): data to characterize
16
+ name (str): optional name for the entry, defaults to s.name.
17
+ Returns:
18
+ schema dict
19
+ """
11
20
  return {
12
21
  'type': 'continuous',
13
22
  'name': name or s.name,
@@ -19,6 +28,15 @@ def gen_continuous_schema(s: pd.Series, name: str | None = None) -> dict:
19
28
 
20
29
 
21
30
  def gen_categorical_schema(s: pd.Series, name: str | None = None) -> dict:
31
+ """
32
+ Generates a schema entry for categorical data.
33
+
34
+ Args:
35
+ s (pandas Series): data to characterize
36
+ name (str): optional name for the entry, defaults to s.name.
37
+ Returns:
38
+ schema dict
39
+ """
22
40
  counts = s.value_counts(normalize=True)
23
41
  return {
24
42
  'type': 'categorical',
@@ -28,6 +46,15 @@ def gen_categorical_schema(s: pd.Series, name: str | None = None) -> dict:
28
46
 
29
47
 
30
48
  def gen_discrete_schema(s: pd.Series, name: str | None = None) -> dict:
49
+ """
50
+ Generates a schema entry for discrete data.
51
+
52
+ Args:
53
+ s (pandas Series): data to characterize
54
+ name (str): optional name for the entry, defaults to s.name.
55
+ Returns:
56
+ schema dict
57
+ """
31
58
  return {
32
59
  'type': 'count',
33
60
  'name': name or s.name,
@@ -37,6 +64,15 @@ def gen_discrete_schema(s: pd.Series, name: str | None = None) -> dict:
37
64
 
38
65
 
39
66
  def get_schema(df: pd.DataFrame, immutable_cols: list[str] | None) -> dict:
67
+ """
68
+ Generates a data schema for a pandas DataFrame, based on the data types of the columns.
69
+
70
+ Args:
71
+ df (pandas DataFrame): data to characterize
72
+ immutable_cols (list): list of columns to consider immutable. These columns will have schema entries, but the surrogate will _not_ use them for inference.
73
+ Returns:
74
+ data schema as dict. Review and adjust as needed.
75
+ """
40
76
  schema = {
41
77
  'features': list(),
42
78
  '_note': (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -223,6 +223,8 @@ Python SDK for the [ProxyML API](https://proxyml.ai).
223
223
  > **Status:** Early access — server endpoints coming soon.
224
224
  > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
225
225
 
226
+ <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
227
+
226
228
  ## Why ProxyML?
227
229
 
228
230
  Most explainability tools require sending your data to a third-party server.
@@ -10,6 +10,7 @@ from proxyml.client import (
10
10
  delete_model,
11
11
  delete_schema,
12
12
  diff_models,
13
+ export_surrogate,
13
14
  fetch_schema,
14
15
  find_counterfactuals,
15
16
  get_model_summary,
@@ -552,3 +553,37 @@ def test_rotate_key_success(mock_post):
552
553
  def test_rotate_key_failure_returns_none(mock_post):
553
554
  mock_post.return_value = _mock_response(403, {"detail": "Key rotation is not available for test accounts"})
554
555
  assert rotate_key() is None
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # export_surrogate
560
+ # ---------------------------------------------------------------------------
561
+
562
+ _EXPORT_RESPONSE = {
563
+ "version": "abc-123",
564
+ "classes": [0, 1],
565
+ "intercept": [0.5],
566
+ "per_class_intercepts": None,
567
+ "features": [{"name": "age", "type": "continuous"}],
568
+ "scalers": {"age": {"mean": 35.0, "scale": 10.0}},
569
+ }
570
+
571
+
572
+ @patch("proxyml.client.get")
573
+ def test_export_surrogate_success(mock_get):
574
+ mock_get.return_value = _mock_response(200, _EXPORT_RESPONSE)
575
+ result = export_surrogate(version="abc-123")
576
+ assert result == _EXPORT_RESPONSE
577
+
578
+
579
+ @patch("proxyml.client.get")
580
+ def test_export_surrogate_calls_correct_endpoint(mock_get):
581
+ mock_get.return_value = _mock_response(200, _EXPORT_RESPONSE)
582
+ export_surrogate(version="abc-123")
583
+ mock_get.assert_called_once_with(endpoint="/surrogate/models/abc-123/export", params={})
584
+
585
+
586
+ @patch("proxyml.client.get")
587
+ def test_export_surrogate_failure_returns_none(mock_get):
588
+ mock_get.return_value = _mock_response(404, {"detail": "model not found"})
589
+ assert export_surrogate(version="no-such-version") is None
File without changes
File without changes
File without changes
File without changes