proxyml 0.1.8__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxyml-0.1.8 → proxyml-0.2.0}/PKG-INFO +3 -5
- {proxyml-0.1.8 → proxyml-0.2.0}/README.md +1 -4
- {proxyml-0.1.8 → proxyml-0.2.0}/pyproject.toml +2 -1
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml/__init__.py +8 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml/client.py +109 -24
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml.egg-info/PKG-INFO +3 -5
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml.egg-info/requires.txt +1 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/tests/test_client.py +319 -4
- {proxyml-0.1.8 → proxyml-0.2.0}/LICENSE +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/setup.cfg +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml/schema.py +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml.egg-info/SOURCES.txt +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml.egg-info/dependency_links.txt +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/src/proxyml.egg-info/top_level.txt +0 -0
- {proxyml-0.1.8 → proxyml-0.2.0}/tests/test_schema.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyml
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for calling the ProxyML API
|
|
5
5
|
Author-email: ProxyML <contact@proxyml.ai>
|
|
6
6
|
License: Apache License
|
|
@@ -208,6 +208,7 @@ License-File: LICENSE
|
|
|
208
208
|
Requires-Dist: requests>=2.28
|
|
209
209
|
Requires-Dist: numpy>=1.23
|
|
210
210
|
Requires-Dist: pandas>=1.5
|
|
211
|
+
Requires-Dist: orjson>=3.9
|
|
211
212
|
Provides-Extra: dev
|
|
212
213
|
Requires-Dist: build; extra == "dev"
|
|
213
214
|
Requires-Dist: twine; extra == "dev"
|
|
@@ -220,9 +221,6 @@ Dynamic: license-file
|
|
|
220
221
|
|
|
221
222
|
Python SDK for the [ProxyML API](https://proxyml.ai).
|
|
222
223
|
|
|
223
|
-
> **Status:** Early access — server endpoints coming soon.
|
|
224
|
-
> [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
|
|
225
|
-
|
|
226
224
|
<img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
|
|
227
225
|
|
|
228
226
|
## Why ProxyML?
|
|
@@ -315,7 +313,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
315
313
|
| `put_schema(schema)` | Upload a schema to the API |
|
|
316
314
|
| `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
|
|
317
315
|
| `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
|
|
318
|
-
| `predict(
|
|
316
|
+
| `predict(sample, version)` | Score a single sample with the surrogate model |
|
|
319
317
|
| `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
|
|
320
318
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
321
319
|
|
|
@@ -2,9 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Python SDK for the [ProxyML API](https://proxyml.ai).
|
|
4
4
|
|
|
5
|
-
> **Status:** Early access — server endpoints coming soon.
|
|
6
|
-
> [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
|
|
7
|
-
|
|
8
5
|
<img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
|
|
9
6
|
|
|
10
7
|
## Why ProxyML?
|
|
@@ -97,7 +94,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
97
94
|
| `put_schema(schema)` | Upload a schema to the API |
|
|
98
95
|
| `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
|
|
99
96
|
| `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
|
|
100
|
-
| `predict(
|
|
97
|
+
| `predict(sample, version)` | Score a single sample with the surrogate model |
|
|
101
98
|
| `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
|
|
102
99
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
103
100
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyml"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python SDK for calling the ProxyML API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {file = "LICENSE"}
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"requests>=2.28",
|
|
30
30
|
"numpy>=1.23",
|
|
31
31
|
"pandas>=1.5",
|
|
32
|
+
"orjson>=3.9",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
[project.optional-dependencies]
|
|
@@ -12,12 +12,16 @@ from proxyml.client import (
|
|
|
12
12
|
interpret_counterfactual,
|
|
13
13
|
get_feature_importances,
|
|
14
14
|
get_model_summary,
|
|
15
|
+
get_model_schema,
|
|
15
16
|
diff_models,
|
|
16
17
|
list_models,
|
|
17
18
|
delete_model,
|
|
19
|
+
export_surrogate,
|
|
18
20
|
get_usage,
|
|
19
21
|
rotate_key,
|
|
20
22
|
explain_local,
|
|
23
|
+
explain_local_batch,
|
|
24
|
+
update_model,
|
|
21
25
|
)
|
|
22
26
|
from proxyml.schema import (
|
|
23
27
|
get_schema,
|
|
@@ -40,12 +44,16 @@ __all__ = [
|
|
|
40
44
|
"interpret_counterfactual",
|
|
41
45
|
"get_feature_importances",
|
|
42
46
|
"get_model_summary",
|
|
47
|
+
"get_model_schema",
|
|
43
48
|
"diff_models",
|
|
44
49
|
"list_models",
|
|
45
50
|
"delete_model",
|
|
51
|
+
"export_surrogate",
|
|
46
52
|
"get_usage",
|
|
47
53
|
"rotate_key",
|
|
48
54
|
"explain_local",
|
|
55
|
+
"explain_local_batch",
|
|
56
|
+
"update_model",
|
|
49
57
|
"get_schema",
|
|
50
58
|
"gen_continuous_schema",
|
|
51
59
|
"gen_categorical_schema",
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
logger = logging.getLogger(__name__)
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
+
import orjson
|
|
5
6
|
import requests
|
|
6
7
|
import os
|
|
7
8
|
import numpy as np
|
|
@@ -9,11 +10,13 @@ import pandas as pd
|
|
|
9
10
|
from pandas.api.types import is_float_dtype, is_integer_dtype
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
PROXYML_BASE_URL = os.getenv("PROXYML_BASE_URL", "https://api.proxyml.ai/api/v1")
|
|
13
|
-
|
|
14
13
|
_BOOL_STRINGS = {"true", "false"}
|
|
15
14
|
|
|
16
15
|
|
|
16
|
+
def _base_url() -> str:
|
|
17
|
+
return os.getenv("PROXYML_BASE_URL", "https://api.proxyml.ai/api/v1")
|
|
18
|
+
|
|
19
|
+
|
|
17
20
|
def _headers() -> dict:
|
|
18
21
|
"""
|
|
19
22
|
Constructs the request headers required for making calls to the ProxyML API.
|
|
@@ -42,8 +45,8 @@ def post(endpoint: str, payload: dict) -> requests.models.Response:
|
|
|
42
45
|
requests Response object.
|
|
43
46
|
"""
|
|
44
47
|
r = requests.post(
|
|
45
|
-
url=f'{
|
|
46
|
-
|
|
48
|
+
url=f'{_base_url()}{endpoint}',
|
|
49
|
+
data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
|
|
47
50
|
headers=_headers()
|
|
48
51
|
)
|
|
49
52
|
return r
|
|
@@ -61,8 +64,8 @@ def put(endpoint: str, payload: dict) -> requests.models.Response:
|
|
|
61
64
|
requests Response object.
|
|
62
65
|
"""
|
|
63
66
|
r = requests.put(
|
|
64
|
-
url=f'{
|
|
65
|
-
|
|
67
|
+
url=f'{_base_url()}{endpoint}',
|
|
68
|
+
data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
|
|
66
69
|
headers=_headers()
|
|
67
70
|
)
|
|
68
71
|
return r
|
|
@@ -80,13 +83,32 @@ def get(endpoint: str, params: dict) -> requests.models.Response:
|
|
|
80
83
|
requests Response object.
|
|
81
84
|
"""
|
|
82
85
|
r = requests.get(
|
|
83
|
-
url=f'{
|
|
86
|
+
url=f'{_base_url()}{endpoint}',
|
|
84
87
|
headers=_headers(),
|
|
85
88
|
params=params
|
|
86
89
|
)
|
|
87
90
|
return r
|
|
88
91
|
|
|
89
92
|
|
|
93
|
+
def patch(endpoint: str, payload: dict) -> requests.models.Response:
|
|
94
|
+
"""
|
|
95
|
+
PATCHes a request to a ProxyML API endpoint.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
endpoint (str): ProxyML API endpoint.
|
|
99
|
+
payload (dict): JSON payload to PATCH.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
requests Response object.
|
|
103
|
+
"""
|
|
104
|
+
r = requests.patch(
|
|
105
|
+
url=f'{_base_url()}{endpoint}',
|
|
106
|
+
data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
|
|
107
|
+
headers=_headers()
|
|
108
|
+
)
|
|
109
|
+
return r
|
|
110
|
+
|
|
111
|
+
|
|
90
112
|
def delete(endpoint: str) -> requests.models.Response:
|
|
91
113
|
"""
|
|
92
114
|
DELETE request to a ProxyML API endpoint.
|
|
@@ -98,7 +120,7 @@ def delete(endpoint: str) -> requests.models.Response:
|
|
|
98
120
|
requests Response object.
|
|
99
121
|
"""
|
|
100
122
|
r = requests.delete(
|
|
101
|
-
url=f'{
|
|
123
|
+
url=f'{_base_url()}{endpoint}',
|
|
102
124
|
headers=_headers()
|
|
103
125
|
)
|
|
104
126
|
return r
|
|
@@ -186,7 +208,7 @@ def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
|
|
|
186
208
|
# convert "true"/"false" strings back to booleans when appropriate
|
|
187
209
|
unique = {str(v).lower() for v in series.dropna().unique()}
|
|
188
210
|
if unique <= _BOOL_STRINGS:
|
|
189
|
-
return series.map({"true": True, "false": False,
|
|
211
|
+
return series.map(lambda v: {"true": True, "false": False}.get(str(v).lower(), v))
|
|
190
212
|
return series
|
|
191
213
|
|
|
192
214
|
|
|
@@ -205,7 +227,7 @@ def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bo
|
|
|
205
227
|
if sample is None:
|
|
206
228
|
r = post(endpoint='/synthesize/neighbors', payload={'n': num_points, 'schema_name': schema_name})
|
|
207
229
|
else:
|
|
208
|
-
r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance':
|
|
230
|
+
r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance': list(sample), 'schema_name': schema_name})
|
|
209
231
|
if r.status_code == 200:
|
|
210
232
|
payload = r.json()
|
|
211
233
|
if as_df:
|
|
@@ -331,7 +353,7 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
|
|
|
331
353
|
"""
|
|
332
354
|
payload = {
|
|
333
355
|
'instance': sample,
|
|
334
|
-
'target_label': target
|
|
356
|
+
'target_label': target,
|
|
335
357
|
'n_neighbors': n_neighbors,
|
|
336
358
|
'perturbation_scale': perturbation_scale,
|
|
337
359
|
}
|
|
@@ -339,16 +361,16 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
|
|
|
339
361
|
payload['version'] = version
|
|
340
362
|
r = post(endpoint='/explain/counterfactual', payload=payload)
|
|
341
363
|
if r.status_code == 200:
|
|
342
|
-
|
|
364
|
+
response = r.json()
|
|
343
365
|
if as_df:
|
|
344
|
-
if
|
|
345
|
-
|
|
366
|
+
if response['counterfactual'] is None:
|
|
367
|
+
logger.warning("No counterfactual found: %s", response.get('warning'))
|
|
346
368
|
return None
|
|
347
|
-
df = pd.DataFrame([
|
|
348
|
-
for col, ftype in zip(
|
|
369
|
+
df = pd.DataFrame([response['counterfactual']], columns=response['feature_names'])
|
|
370
|
+
for col, ftype in zip(response['feature_names'], response['feature_types']):
|
|
349
371
|
df[col] = _cast_column(df[col], ftype)
|
|
350
372
|
return df
|
|
351
|
-
return
|
|
373
|
+
return response
|
|
352
374
|
logger.error(
|
|
353
375
|
"Counterfactual failed with status %s: %s",
|
|
354
376
|
r.status_code,
|
|
@@ -361,7 +383,7 @@ def interpret_counterfactual(
|
|
|
361
383
|
sample: dict,
|
|
362
384
|
counterfactual: dict,
|
|
363
385
|
prediction_changed: bool,
|
|
364
|
-
exclude_from_diff: list[str] | None
|
|
386
|
+
exclude_from_diff: list[str] | None = None
|
|
365
387
|
) -> str:
|
|
366
388
|
"""
|
|
367
389
|
Simple string interpretation of a counterfactual result. No API calls are required.
|
|
@@ -453,7 +475,7 @@ def find_counterfactuals(
|
|
|
453
475
|
"""
|
|
454
476
|
payload = {
|
|
455
477
|
'instances': samples,
|
|
456
|
-
'target_label': target
|
|
478
|
+
'target_label': target,
|
|
457
479
|
'n_neighbors': n_neighbors,
|
|
458
480
|
'perturbation_scale': perturbation_scale,
|
|
459
481
|
}
|
|
@@ -469,7 +491,7 @@ def find_counterfactuals(
|
|
|
469
491
|
for item in data['results']:
|
|
470
492
|
if item['counterfactual'] is None:
|
|
471
493
|
if item.get('warning'):
|
|
472
|
-
|
|
494
|
+
logger.warning("No counterfactual found: %s", item['warning'])
|
|
473
495
|
results.append(None)
|
|
474
496
|
else:
|
|
475
497
|
df = pd.DataFrame([item['counterfactual']], columns=feature_names)
|
|
@@ -596,11 +618,19 @@ def rotate_key() -> str | None:
|
|
|
596
618
|
return None
|
|
597
619
|
|
|
598
620
|
|
|
599
|
-
def list_models() ->
|
|
600
|
-
"""Return
|
|
601
|
-
|
|
621
|
+
def list_models(limit: int = 50, offset: int = 0) -> dict | None:
|
|
622
|
+
"""Return a page of trained surrogate models, newest first.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
limit (int): maximum number of models to return (1–200, default 50).
|
|
626
|
+
offset (int): number of models to skip for pagination (default 0).
|
|
627
|
+
Returns:
|
|
628
|
+
Dict with ``models`` (list of metadata dicts) and ``total`` (int,
|
|
629
|
+
total number of surrogates regardless of pagination), or None on failure.
|
|
630
|
+
"""
|
|
631
|
+
r = get(endpoint='/surrogate/models', params={'limit': limit, 'offset': offset})
|
|
602
632
|
if r.status_code == 200:
|
|
603
|
-
return r.json()
|
|
633
|
+
return r.json()
|
|
604
634
|
logger.error(
|
|
605
635
|
"List models failed with status %s: %s",
|
|
606
636
|
r.status_code,
|
|
@@ -609,6 +639,30 @@ def list_models() -> list[dict] | None:
|
|
|
609
639
|
return None
|
|
610
640
|
|
|
611
641
|
|
|
642
|
+
def explain_local_batch(instances: list[list], version: str | None = None) -> dict | None:
|
|
643
|
+
"""Per-feature contribution breakdown for multiple instances in one call.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
instances (list[list]): list of feature vectors, each in schema order.
|
|
647
|
+
version (str): surrogate version UUID. None uses the latest version.
|
|
648
|
+
Returns:
|
|
649
|
+
Dict with ``results`` (one contribution entry per instance), ``model_version``,
|
|
650
|
+
``task``, and ``schema_warning``, or None on failure.
|
|
651
|
+
"""
|
|
652
|
+
payload: dict = {"instances": instances}
|
|
653
|
+
if version is not None:
|
|
654
|
+
payload["version"] = version
|
|
655
|
+
r = post(endpoint="/explain/local/batch", payload=payload)
|
|
656
|
+
if r.status_code == 200:
|
|
657
|
+
return r.json()
|
|
658
|
+
logger.error(
|
|
659
|
+
"Batch local explanation failed with status %s: %s",
|
|
660
|
+
r.status_code,
|
|
661
|
+
r.text,
|
|
662
|
+
)
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
|
|
612
666
|
def explain_local(instance: list, version: str | None = None) -> dict | None:
|
|
613
667
|
"""Per-feature contribution breakdown for a single instance.
|
|
614
668
|
|
|
@@ -630,6 +684,37 @@ def explain_local(instance: list, version: str | None = None) -> dict | None:
|
|
|
630
684
|
return None
|
|
631
685
|
|
|
632
686
|
|
|
687
|
+
def update_model(version: str, name: str | None = ..., comments: str | None = ...) -> dict | None:
|
|
688
|
+
"""Update the name and/or comments of a surrogate without retraining.
|
|
689
|
+
|
|
690
|
+
Pass a string to set the field, or ``None`` to clear it. Omit a parameter
|
|
691
|
+
entirely to leave that field unchanged.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
version (str): UUID of the surrogate to update.
|
|
695
|
+
name (str | None): new name, None to clear, or omit to leave unchanged.
|
|
696
|
+
comments (str | None): new comments, None to clear, or omit to leave unchanged.
|
|
697
|
+
Returns:
|
|
698
|
+
Updated model metadata dict, or None on failure.
|
|
699
|
+
"""
|
|
700
|
+
payload: dict = {}
|
|
701
|
+
if name is not ...:
|
|
702
|
+
payload["name"] = name
|
|
703
|
+
if comments is not ...:
|
|
704
|
+
payload["comments"] = comments
|
|
705
|
+
if not payload:
|
|
706
|
+
raise ValueError("Provide at least one of: name, comments")
|
|
707
|
+
r = patch(endpoint=f'/surrogate/models/{version}', payload=payload)
|
|
708
|
+
if r.status_code == 200:
|
|
709
|
+
return r.json()
|
|
710
|
+
logger.error(
|
|
711
|
+
"Update model failed with status %s: %s",
|
|
712
|
+
r.status_code,
|
|
713
|
+
r.text,
|
|
714
|
+
)
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
633
718
|
def delete_model(model_id: str) -> bool:
|
|
634
719
|
"""Delete a surrogate model by its UUID. Returns True on success, False if not found."""
|
|
635
720
|
r = delete(endpoint=f'/surrogate/models/{model_id}')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyml
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for calling the ProxyML API
|
|
5
5
|
Author-email: ProxyML <contact@proxyml.ai>
|
|
6
6
|
License: Apache License
|
|
@@ -208,6 +208,7 @@ License-File: LICENSE
|
|
|
208
208
|
Requires-Dist: requests>=2.28
|
|
209
209
|
Requires-Dist: numpy>=1.23
|
|
210
210
|
Requires-Dist: pandas>=1.5
|
|
211
|
+
Requires-Dist: orjson>=3.9
|
|
211
212
|
Provides-Extra: dev
|
|
212
213
|
Requires-Dist: build; extra == "dev"
|
|
213
214
|
Requires-Dist: twine; extra == "dev"
|
|
@@ -220,9 +221,6 @@ Dynamic: license-file
|
|
|
220
221
|
|
|
221
222
|
Python SDK for the [ProxyML API](https://proxyml.ai).
|
|
222
223
|
|
|
223
|
-
> **Status:** Early access — server endpoints coming soon.
|
|
224
|
-
> [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
|
|
225
|
-
|
|
226
224
|
<img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
|
|
227
225
|
|
|
228
226
|
## Why ProxyML?
|
|
@@ -315,7 +313,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
315
313
|
| `put_schema(schema)` | Upload a schema to the API |
|
|
316
314
|
| `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
|
|
317
315
|
| `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
|
|
318
|
-
| `predict(
|
|
316
|
+
| `predict(sample, version)` | Score a single sample with the surrogate model |
|
|
319
317
|
| `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
|
|
320
318
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
321
319
|
|
|
@@ -6,13 +6,19 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from proxyml.client import (
|
|
8
8
|
_cast_column,
|
|
9
|
+
_base_url,
|
|
9
10
|
_headers,
|
|
10
11
|
delete_model,
|
|
11
12
|
delete_schema,
|
|
12
13
|
diff_models,
|
|
14
|
+
explain_local,
|
|
15
|
+
explain_local_batch,
|
|
13
16
|
export_surrogate,
|
|
14
17
|
fetch_schema,
|
|
18
|
+
find_counterfactual,
|
|
15
19
|
find_counterfactuals,
|
|
20
|
+
get_feature_importances,
|
|
21
|
+
get_model_schema,
|
|
16
22
|
get_model_summary,
|
|
17
23
|
get_usage,
|
|
18
24
|
interpret_counterfactual,
|
|
@@ -24,6 +30,7 @@ from proxyml.client import (
|
|
|
24
30
|
rotate_key,
|
|
25
31
|
synthesize_data,
|
|
26
32
|
train_surrogate,
|
|
33
|
+
update_model,
|
|
27
34
|
)
|
|
28
35
|
|
|
29
36
|
|
|
@@ -31,6 +38,16 @@ from proxyml.client import (
|
|
|
31
38
|
# _headers
|
|
32
39
|
# ---------------------------------------------------------------------------
|
|
33
40
|
|
|
41
|
+
def test_base_url_default(monkeypatch):
|
|
42
|
+
monkeypatch.delenv("PROXYML_BASE_URL", raising=False)
|
|
43
|
+
assert _base_url() == "https://api.proxyml.ai/api/v1"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_base_url_reads_env_at_call_time(monkeypatch):
|
|
47
|
+
monkeypatch.setenv("PROXYML_BASE_URL", "https://custom.example.com/api/v1")
|
|
48
|
+
assert _base_url() == "https://custom.example.com/api/v1"
|
|
49
|
+
|
|
50
|
+
|
|
34
51
|
def test_headers_raises_without_api_key(monkeypatch):
|
|
35
52
|
monkeypatch.delenv("PROXYML_API_KEY", raising=False)
|
|
36
53
|
with pytest.raises(EnvironmentError, match="PROXYML_API_KEY"):
|
|
@@ -72,6 +89,18 @@ def test_cast_column_categorical_bool_strings():
|
|
|
72
89
|
assert result.tolist() == [True, False, True]
|
|
73
90
|
|
|
74
91
|
|
|
92
|
+
def test_cast_column_categorical_bool_strings_capitalized():
|
|
93
|
+
s = pd.Series(["True", "False", "True"])
|
|
94
|
+
result = _cast_column(s, "categorical")
|
|
95
|
+
assert result.tolist() == [True, False, True]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_cast_column_categorical_python_bools():
|
|
99
|
+
s = pd.Series([True, False, True])
|
|
100
|
+
result = _cast_column(s, "categorical")
|
|
101
|
+
assert result.tolist() == [True, False, True]
|
|
102
|
+
|
|
103
|
+
|
|
75
104
|
def test_cast_column_categorical_passthrough():
|
|
76
105
|
s = pd.Series(["a", "b", "c"])
|
|
77
106
|
result = _cast_column(s, "categorical")
|
|
@@ -318,11 +347,24 @@ def test_train_surrogate_omits_none_metadata(mock_post):
|
|
|
318
347
|
def test_list_models_success(mock_get):
|
|
319
348
|
models = [{"version": "abc-123", "task": "regression", "name": "v1",
|
|
320
349
|
"comments": None, "feature_names": None, "metrics": {"r2": 0.9},
|
|
321
|
-
"trained_at": "2026-04-19T12:00:00"}]
|
|
322
|
-
mock_get.return_value = _mock_response(200, {"models": models})
|
|
350
|
+
"trained_at": "2026-04-19T12:00:00", "mlflow_run_id": None}]
|
|
351
|
+
mock_get.return_value = _mock_response(200, {"models": models, "total": 1})
|
|
323
352
|
result = list_models()
|
|
324
|
-
assert result == models
|
|
325
|
-
mock_get.assert_called_once_with(endpoint="/surrogate/models", params={})
|
|
353
|
+
assert result == {"models": models, "total": 1}
|
|
354
|
+
mock_get.assert_called_once_with(endpoint="/surrogate/models", params={"limit": 50, "offset": 0})
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@patch("proxyml.client.get")
|
|
358
|
+
def test_list_models_pagination(mock_get):
|
|
359
|
+
models = [{"version": f"v{i}", "task": "regression", "name": None,
|
|
360
|
+
"comments": None, "feature_names": None, "metrics": None,
|
|
361
|
+
"trained_at": "2026-05-01T00:00:00", "mlflow_run_id": None}
|
|
362
|
+
for i in range(10)]
|
|
363
|
+
mock_get.return_value = _mock_response(200, {"models": models, "total": 42})
|
|
364
|
+
result = list_models(limit=10, offset=20)
|
|
365
|
+
assert result["total"] == 42
|
|
366
|
+
assert len(result["models"]) == 10
|
|
367
|
+
mock_get.assert_called_once_with(endpoint="/surrogate/models", params={"limit": 10, "offset": 20})
|
|
326
368
|
|
|
327
369
|
|
|
328
370
|
@patch("proxyml.client.get")
|
|
@@ -587,3 +629,276 @@ def test_export_surrogate_calls_correct_endpoint(mock_get):
|
|
|
587
629
|
def test_export_surrogate_failure_returns_none(mock_get):
|
|
588
630
|
mock_get.return_value = _mock_response(404, {"detail": "model not found"})
|
|
589
631
|
assert export_surrogate(version="no-such-version") is None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# find_counterfactual
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
_CF_RESPONSE = {
|
|
639
|
+
"counterfactual": [1.5, "yes"],
|
|
640
|
+
"feature_names": ["f_cont", "f_cat"],
|
|
641
|
+
"feature_types": ["continuous", "categorical"],
|
|
642
|
+
"outlier_score": 0.1,
|
|
643
|
+
"warning": None,
|
|
644
|
+
"task": "classification",
|
|
645
|
+
"target_label": "high",
|
|
646
|
+
"model_version": "surrogate-abc-classification",
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
_CF_RESPONSE_NONE = {
|
|
650
|
+
"counterfactual": None,
|
|
651
|
+
"feature_names": ["f_cont", "f_cat"],
|
|
652
|
+
"feature_types": ["continuous", "categorical"],
|
|
653
|
+
"outlier_score": 0.9,
|
|
654
|
+
"warning": "no counterfactual found",
|
|
655
|
+
"task": "classification",
|
|
656
|
+
"target_label": "high",
|
|
657
|
+
"model_version": "surrogate-abc-classification",
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@patch("proxyml.client.post")
|
|
662
|
+
def test_find_counterfactual_as_df(mock_post):
|
|
663
|
+
mock_post.return_value = _mock_response(200, _CF_RESPONSE)
|
|
664
|
+
result = find_counterfactual(sample=[1.0, "no"], target="high")
|
|
665
|
+
assert isinstance(result, pd.DataFrame)
|
|
666
|
+
assert list(result.columns) == ["f_cont", "f_cat"]
|
|
667
|
+
assert result["f_cont"].iloc[0] == 1.5
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@patch("proxyml.client.post")
|
|
671
|
+
def test_find_counterfactual_raw(mock_post):
|
|
672
|
+
mock_post.return_value = _mock_response(200, _CF_RESPONSE)
|
|
673
|
+
result = find_counterfactual(sample=[1.0, "no"], target="high", as_df=False)
|
|
674
|
+
assert result == _CF_RESPONSE
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@patch("proxyml.client.post")
|
|
678
|
+
def test_find_counterfactual_none_returns_none(mock_post):
|
|
679
|
+
mock_post.return_value = _mock_response(200, _CF_RESPONSE_NONE)
|
|
680
|
+
result = find_counterfactual(sample=[1.0, "no"], target="high")
|
|
681
|
+
assert result is None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@patch("proxyml.client.post")
|
|
685
|
+
def test_find_counterfactual_payload(mock_post):
|
|
686
|
+
mock_post.return_value = _mock_response(200, _CF_RESPONSE)
|
|
687
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
688
|
+
find_counterfactual(
|
|
689
|
+
sample=[1.0, "no"], target="high",
|
|
690
|
+
n_neighbors=500, perturbation_scale=0.2, version=uid,
|
|
691
|
+
)
|
|
692
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
693
|
+
assert payload["instance"] == [1.0, "no"]
|
|
694
|
+
assert payload["target_label"] == "high"
|
|
695
|
+
assert payload["n_neighbors"] == 500
|
|
696
|
+
assert payload["perturbation_scale"] == 0.2
|
|
697
|
+
assert payload["version"] == uid
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@patch("proxyml.client.post")
|
|
701
|
+
def test_find_counterfactual_no_version_in_payload(mock_post):
|
|
702
|
+
mock_post.return_value = _mock_response(200, _CF_RESPONSE)
|
|
703
|
+
find_counterfactual(sample=[1.0, "no"], target="high")
|
|
704
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
705
|
+
assert "version" not in payload
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@patch("proxyml.client.post")
|
|
709
|
+
def test_find_counterfactual_failure_returns_none(mock_post):
|
|
710
|
+
mock_post.return_value = _mock_response(404, {"detail": "no surrogate"})
|
|
711
|
+
assert find_counterfactual(sample=[1.0, "no"], target="high") is None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# get_feature_importances
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
_IMPORTANCES_RESPONSE = {
|
|
719
|
+
"feature_importances": [
|
|
720
|
+
{"feature": "MedInc", "coefficient": 0.82, "abs_coefficient": 0.82},
|
|
721
|
+
{"feature": "Latitude", "coefficient": -0.61, "abs_coefficient": 0.61},
|
|
722
|
+
],
|
|
723
|
+
"per_class_importances": None,
|
|
724
|
+
"model_version": "abc-123",
|
|
725
|
+
"task": "regression",
|
|
726
|
+
"note": "Coefficients are in the scaled feature space.",
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@patch("proxyml.client.get")
|
|
731
|
+
def test_get_feature_importances_success(mock_get):
|
|
732
|
+
mock_get.return_value = _mock_response(200, _IMPORTANCES_RESPONSE)
|
|
733
|
+
result = get_feature_importances()
|
|
734
|
+
assert result == _IMPORTANCES_RESPONSE
|
|
735
|
+
mock_get.assert_called_once_with(endpoint="/explain/importance", params={})
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@patch("proxyml.client.get")
|
|
739
|
+
def test_get_feature_importances_with_version(mock_get):
|
|
740
|
+
mock_get.return_value = _mock_response(200, _IMPORTANCES_RESPONSE)
|
|
741
|
+
get_feature_importances(version="abc-123")
|
|
742
|
+
mock_get.assert_called_once_with(endpoint="/explain/importance", params={"version": "abc-123"})
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@patch("proxyml.client.get")
|
|
746
|
+
def test_get_feature_importances_failure_returns_none(mock_get):
|
|
747
|
+
mock_get.return_value = _mock_response(404, {"detail": "not found"})
|
|
748
|
+
assert get_feature_importances() is None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
# get_model_schema
|
|
753
|
+
# ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
_MODEL_SCHEMA_RESPONSE = {
|
|
756
|
+
"features": [
|
|
757
|
+
{"type": "continuous", "name": "age", "mean": 35.0, "std": 10.0, "min": 18.0, "max": 90.0},
|
|
758
|
+
{"type": "categorical", "name": "gender", "valid_categories": {"M": 0.5, "F": 0.5}},
|
|
759
|
+
]
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@patch("proxyml.client.get")
|
|
764
|
+
def test_get_model_schema_success(mock_get):
|
|
765
|
+
mock_get.return_value = _mock_response(200, _MODEL_SCHEMA_RESPONSE)
|
|
766
|
+
result = get_model_schema(version="abc-123")
|
|
767
|
+
assert result == _MODEL_SCHEMA_RESPONSE
|
|
768
|
+
mock_get.assert_called_once_with(endpoint="/surrogate/models/abc-123/schema", params={})
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@patch("proxyml.client.get")
|
|
772
|
+
def test_get_model_schema_failure_returns_none(mock_get):
|
|
773
|
+
mock_get.return_value = _mock_response(404, {"detail": "not found"})
|
|
774
|
+
assert get_model_schema(version="no-such-version") is None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# ---------------------------------------------------------------------------
|
|
778
|
+
# explain_local
|
|
779
|
+
# ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
_EXPLAIN_LOCAL_RESPONSE = {
|
|
782
|
+
"prediction": 1,
|
|
783
|
+
"feature_contributions": [
|
|
784
|
+
{"feature": "MedInc", "contribution": 0.5, "abs_contribution": 0.5},
|
|
785
|
+
{"feature": "Latitude", "contribution": -0.2, "abs_contribution": 0.2},
|
|
786
|
+
],
|
|
787
|
+
"intercept": 0.1,
|
|
788
|
+
"probabilities": [0.2, 0.8],
|
|
789
|
+
"per_class_contributions": None,
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
@patch("proxyml.client.post")
|
|
794
|
+
def test_explain_local_success(mock_post):
|
|
795
|
+
mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_RESPONSE)
|
|
796
|
+
result = explain_local(instance=[1.0, 2.0])
|
|
797
|
+
assert result == _EXPLAIN_LOCAL_RESPONSE
|
|
798
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
799
|
+
assert payload["instance"] == [1.0, 2.0]
|
|
800
|
+
assert "version" not in payload
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@patch("proxyml.client.post")
|
|
804
|
+
def test_explain_local_with_version(mock_post):
|
|
805
|
+
mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_RESPONSE)
|
|
806
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
807
|
+
explain_local(instance=[1.0, 2.0], version=uid)
|
|
808
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
809
|
+
assert payload["version"] == uid
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@patch("proxyml.client.post")
|
|
813
|
+
def test_explain_local_failure_returns_none(mock_post):
|
|
814
|
+
mock_post.return_value = _mock_response(422, {"detail": "bad input"})
|
|
815
|
+
assert explain_local(instance=[1.0, 2.0]) is None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# ---------------------------------------------------------------------------
|
|
819
|
+
# explain_local_batch
|
|
820
|
+
# ---------------------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
_EXPLAIN_LOCAL_BATCH_RESPONSE = {
|
|
823
|
+
"results": [_EXPLAIN_LOCAL_RESPONSE, _EXPLAIN_LOCAL_RESPONSE],
|
|
824
|
+
"model_version": "surrogate-abc-regression",
|
|
825
|
+
"task": "regression",
|
|
826
|
+
"schema_warning": None,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@patch("proxyml.client.post")
|
|
831
|
+
def test_explain_local_batch_success(mock_post):
|
|
832
|
+
mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_BATCH_RESPONSE)
|
|
833
|
+
instances = [[1.0, 2.0], [3.0, 4.0]]
|
|
834
|
+
result = explain_local_batch(instances=instances)
|
|
835
|
+
assert result == _EXPLAIN_LOCAL_BATCH_RESPONSE
|
|
836
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
837
|
+
assert payload["instances"] == instances
|
|
838
|
+
assert "version" not in payload
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@patch("proxyml.client.post")
|
|
842
|
+
def test_explain_local_batch_with_version(mock_post):
|
|
843
|
+
mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_BATCH_RESPONSE)
|
|
844
|
+
uid = "550e8400-e29b-41d4-a716-446655440000"
|
|
845
|
+
explain_local_batch(instances=[[1.0, 2.0]], version=uid)
|
|
846
|
+
payload = mock_post.call_args.kwargs["payload"]
|
|
847
|
+
assert payload["version"] == uid
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@patch("proxyml.client.post")
|
|
851
|
+
def test_explain_local_batch_failure_returns_none(mock_post):
|
|
852
|
+
mock_post.return_value = _mock_response(422, {"detail": "bad input"})
|
|
853
|
+
assert explain_local_batch(instances=[[1.0, 2.0]]) is None
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# ---------------------------------------------------------------------------
|
|
857
|
+
# update_model
|
|
858
|
+
# ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
@patch("proxyml.client.patch")
|
|
861
|
+
def test_update_model_name(mock_patch):
|
|
862
|
+
meta = {"version": "abc-123", "task": "regression", "name": "new name",
|
|
863
|
+
"comments": None, "feature_names": None, "metrics": None,
|
|
864
|
+
"trained_at": "2026-05-07T00:00:00", "mlflow_run_id": None}
|
|
865
|
+
mock_patch.return_value = _mock_response(200, meta)
|
|
866
|
+
result = update_model("abc-123", name="new name")
|
|
867
|
+
assert result == meta
|
|
868
|
+
payload = mock_patch.call_args.kwargs["payload"]
|
|
869
|
+
assert payload == {"name": "new name"}
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
@patch("proxyml.client.patch")
|
|
873
|
+
def test_update_model_comments(mock_patch):
|
|
874
|
+
mock_patch.return_value = _mock_response(200, {})
|
|
875
|
+
update_model("abc-123", comments="some notes")
|
|
876
|
+
payload = mock_patch.call_args.kwargs["payload"]
|
|
877
|
+
assert payload == {"comments": "some notes"}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@patch("proxyml.client.patch")
|
|
881
|
+
def test_update_model_both_fields(mock_patch):
|
|
882
|
+
mock_patch.return_value = _mock_response(200, {})
|
|
883
|
+
update_model("abc-123", name="prod", comments="v2 data")
|
|
884
|
+
payload = mock_patch.call_args.kwargs["payload"]
|
|
885
|
+
assert payload == {"name": "prod", "comments": "v2 data"}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@patch("proxyml.client.patch")
|
|
889
|
+
def test_update_model_clear_field(mock_patch):
|
|
890
|
+
mock_patch.return_value = _mock_response(200, {})
|
|
891
|
+
update_model("abc-123", comments=None)
|
|
892
|
+
payload = mock_patch.call_args.kwargs["payload"]
|
|
893
|
+
assert payload == {"comments": None}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def test_update_model_no_fields_raises():
|
|
897
|
+
with pytest.raises(ValueError):
|
|
898
|
+
update_model("abc-123")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@patch("proxyml.client.patch")
|
|
902
|
+
def test_update_model_failure_returns_none(mock_patch):
|
|
903
|
+
mock_patch.return_value = _mock_response(404, {"detail": "not found"})
|
|
904
|
+
assert update_model("abc-123", name="x") is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|