proxyml 0.1.7__tar.gz → 0.1.9__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.7
3
+ Version: 0.1.9
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"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxyml"
7
- version = "0.1.7"
7
+ version = "0.1.9"
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,9 +12,11 @@ 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,
@@ -40,9 +42,11 @@ __all__ = [
40
42
  "interpret_counterfactual",
41
43
  "get_feature_importances",
42
44
  "get_model_summary",
45
+ "get_model_schema",
43
46
  "diff_models",
44
47
  "list_models",
45
48
  "delete_model",
49
+ "export_surrogate",
46
50
  "get_usage",
47
51
  "rotate_key",
48
52
  "explain_local",
@@ -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'{PROXYML_BASE_URL}{endpoint}',
46
- json=payload,
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'{PROXYML_BASE_URL}{endpoint}',
65
- json=payload,
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,7 +83,7 @@ def get(endpoint: str, params: dict) -> requests.models.Response:
80
83
  requests Response object.
81
84
  """
82
85
  r = requests.get(
83
- url=f'{PROXYML_BASE_URL}{endpoint}',
86
+ url=f'{_base_url()}{endpoint}',
84
87
  headers=_headers(),
85
88
  params=params
86
89
  )
@@ -98,7 +101,7 @@ def delete(endpoint: str) -> requests.models.Response:
98
101
  requests Response object.
99
102
  """
100
103
  r = requests.delete(
101
- url=f'{PROXYML_BASE_URL}{endpoint}',
104
+ url=f'{_base_url()}{endpoint}',
102
105
  headers=_headers()
103
106
  )
104
107
  return r
@@ -186,7 +189,7 @@ def _cast_column(series: pd.Series, ftype: str) -> pd.Series:
186
189
  # convert "true"/"false" strings back to booleans when appropriate
187
190
  unique = {str(v).lower() for v in series.dropna().unique()}
188
191
  if unique <= _BOOL_STRINGS:
189
- return series.map({"true": True, "false": False, True: True, False: False})
192
+ return series.map(lambda v: {"true": True, "false": False}.get(str(v).lower(), v))
190
193
  return series
191
194
 
192
195
 
@@ -205,7 +208,7 @@ def synthesize_data(num_points: int = 100, sample: list | None = None, as_df: bo
205
208
  if sample is None:
206
209
  r = post(endpoint='/synthesize/neighbors', payload={'n': num_points, 'schema_name': schema_name})
207
210
  else:
208
- r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance': [col.item() for col in sample], 'schema_name': schema_name})
211
+ r = post(endpoint='/synthesize/blended', payload={'n': num_points, 'instance': list(sample), 'schema_name': schema_name})
209
212
  if r.status_code == 200:
210
213
  payload = r.json()
211
214
  if as_df:
@@ -270,6 +273,26 @@ def train_surrogate(
270
273
  return None
271
274
 
272
275
 
276
+ def export_surrogate(version: str) -> Any:
277
+ """
278
+ Exports a surrogate model to JSON e.g., classes, intercept, per_class_intercepts, features, scalers, etc.; everything required
279
+ to reconstruct the surrogate.
280
+
281
+ Args:
282
+ version (str): name of the surrogate to export
283
+ Returns:
284
+ JSON object representing the surrogate, or None if an error occurred.
285
+ """
286
+ r = get(endpoint=f'/surrogate/models/{version}/export', params=dict())
287
+ if r.status_code == 200:
288
+ return r.json()
289
+ logger.error(
290
+ "Surrogate export failed (version=%s, status=%s): %s",
291
+ version, r.status_code, r.text,
292
+ )
293
+ return None
294
+
295
+
273
296
  def predict(sample: list, version: str | None = None) -> Any:
274
297
  """
275
298
  Calls a surrogate model to make a single prediction.
@@ -311,7 +334,7 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
311
334
  """
312
335
  payload = {
313
336
  'instance': sample,
314
- 'target_label': target.item() if hasattr(target, 'item') else target,
337
+ 'target_label': target,
315
338
  'n_neighbors': n_neighbors,
316
339
  'perturbation_scale': perturbation_scale,
317
340
  }
@@ -319,16 +342,16 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
319
342
  payload['version'] = version
320
343
  r = post(endpoint='/explain/counterfactual', payload=payload)
321
344
  if r.status_code == 200:
322
- payload = r.json()
345
+ response = r.json()
323
346
  if as_df:
324
- if payload['counterfactual'] is None:
325
- print(f"No counterfactual found: {payload.get('warning')}")
347
+ if response['counterfactual'] is None:
348
+ logger.warning("No counterfactual found: %s", response.get('warning'))
326
349
  return None
327
- df = pd.DataFrame([payload['counterfactual']], columns=payload['feature_names'])
328
- for col, ftype in zip(payload['feature_names'], payload['feature_types']):
350
+ df = pd.DataFrame([response['counterfactual']], columns=response['feature_names'])
351
+ for col, ftype in zip(response['feature_names'], response['feature_types']):
329
352
  df[col] = _cast_column(df[col], ftype)
330
353
  return df
331
- return payload
354
+ return response
332
355
  logger.error(
333
356
  "Counterfactual failed with status %s: %s",
334
357
  r.status_code,
@@ -341,7 +364,7 @@ def interpret_counterfactual(
341
364
  sample: dict,
342
365
  counterfactual: dict,
343
366
  prediction_changed: bool,
344
- exclude_from_diff: list[str] | None
367
+ exclude_from_diff: list[str] | None = None
345
368
  ) -> str:
346
369
  """
347
370
  Simple string interpretation of a counterfactual result. No API calls are required.
@@ -433,7 +456,7 @@ def find_counterfactuals(
433
456
  """
434
457
  payload = {
435
458
  'instances': samples,
436
- 'target_label': target.item() if hasattr(target, 'item') else target,
459
+ 'target_label': target,
437
460
  'n_neighbors': n_neighbors,
438
461
  'perturbation_scale': perturbation_scale,
439
462
  }
@@ -449,7 +472,7 @@ def find_counterfactuals(
449
472
  for item in data['results']:
450
473
  if item['counterfactual'] is None:
451
474
  if item.get('warning'):
452
- print(f"No counterfactual found: {item['warning']}")
475
+ logger.warning("No counterfactual found: %s", item['warning'])
453
476
  results.append(None)
454
477
  else:
455
478
  df = pd.DataFrame([item['counterfactual']], columns=feature_names)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.7
3
+ Version: 0.1.9
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"
@@ -1,6 +1,7 @@
1
1
  requests>=2.28
2
2
  numpy>=1.23
3
3
  pandas>=1.5
4
+ orjson>=3.9
4
5
 
5
6
  [dev]
6
7
  build
@@ -6,12 +6,18 @@ 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
+ export_surrogate,
13
16
  fetch_schema,
17
+ find_counterfactual,
14
18
  find_counterfactuals,
19
+ get_feature_importances,
20
+ get_model_schema,
15
21
  get_model_summary,
16
22
  get_usage,
17
23
  interpret_counterfactual,
@@ -30,6 +36,16 @@ from proxyml.client import (
30
36
  # _headers
31
37
  # ---------------------------------------------------------------------------
32
38
 
39
+ def test_base_url_default(monkeypatch):
40
+ monkeypatch.delenv("PROXYML_BASE_URL", raising=False)
41
+ assert _base_url() == "https://api.proxyml.ai/api/v1"
42
+
43
+
44
+ def test_base_url_reads_env_at_call_time(monkeypatch):
45
+ monkeypatch.setenv("PROXYML_BASE_URL", "https://custom.example.com/api/v1")
46
+ assert _base_url() == "https://custom.example.com/api/v1"
47
+
48
+
33
49
  def test_headers_raises_without_api_key(monkeypatch):
34
50
  monkeypatch.delenv("PROXYML_API_KEY", raising=False)
35
51
  with pytest.raises(EnvironmentError, match="PROXYML_API_KEY"):
@@ -71,6 +87,18 @@ def test_cast_column_categorical_bool_strings():
71
87
  assert result.tolist() == [True, False, True]
72
88
 
73
89
 
90
+ def test_cast_column_categorical_bool_strings_capitalized():
91
+ s = pd.Series(["True", "False", "True"])
92
+ result = _cast_column(s, "categorical")
93
+ assert result.tolist() == [True, False, True]
94
+
95
+
96
+ def test_cast_column_categorical_python_bools():
97
+ s = pd.Series([True, False, True])
98
+ result = _cast_column(s, "categorical")
99
+ assert result.tolist() == [True, False, True]
100
+
101
+
74
102
  def test_cast_column_categorical_passthrough():
75
103
  s = pd.Series(["a", "b", "c"])
76
104
  result = _cast_column(s, "categorical")
@@ -552,3 +580,221 @@ def test_rotate_key_success(mock_post):
552
580
  def test_rotate_key_failure_returns_none(mock_post):
553
581
  mock_post.return_value = _mock_response(403, {"detail": "Key rotation is not available for test accounts"})
554
582
  assert rotate_key() is None
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # export_surrogate
587
+ # ---------------------------------------------------------------------------
588
+
589
+ _EXPORT_RESPONSE = {
590
+ "version": "abc-123",
591
+ "classes": [0, 1],
592
+ "intercept": [0.5],
593
+ "per_class_intercepts": None,
594
+ "features": [{"name": "age", "type": "continuous"}],
595
+ "scalers": {"age": {"mean": 35.0, "scale": 10.0}},
596
+ }
597
+
598
+
599
+ @patch("proxyml.client.get")
600
+ def test_export_surrogate_success(mock_get):
601
+ mock_get.return_value = _mock_response(200, _EXPORT_RESPONSE)
602
+ result = export_surrogate(version="abc-123")
603
+ assert result == _EXPORT_RESPONSE
604
+
605
+
606
+ @patch("proxyml.client.get")
607
+ def test_export_surrogate_calls_correct_endpoint(mock_get):
608
+ mock_get.return_value = _mock_response(200, _EXPORT_RESPONSE)
609
+ export_surrogate(version="abc-123")
610
+ mock_get.assert_called_once_with(endpoint="/surrogate/models/abc-123/export", params={})
611
+
612
+
613
+ @patch("proxyml.client.get")
614
+ def test_export_surrogate_failure_returns_none(mock_get):
615
+ mock_get.return_value = _mock_response(404, {"detail": "model not found"})
616
+ assert export_surrogate(version="no-such-version") is None
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # find_counterfactual
621
+ # ---------------------------------------------------------------------------
622
+
623
+ _CF_RESPONSE = {
624
+ "counterfactual": [1.5, "yes"],
625
+ "feature_names": ["f_cont", "f_cat"],
626
+ "feature_types": ["continuous", "categorical"],
627
+ "outlier_score": 0.1,
628
+ "warning": None,
629
+ "task": "classification",
630
+ "target_label": "high",
631
+ "model_version": "surrogate-abc-classification",
632
+ }
633
+
634
+ _CF_RESPONSE_NONE = {
635
+ "counterfactual": None,
636
+ "feature_names": ["f_cont", "f_cat"],
637
+ "feature_types": ["continuous", "categorical"],
638
+ "outlier_score": 0.9,
639
+ "warning": "no counterfactual found",
640
+ "task": "classification",
641
+ "target_label": "high",
642
+ "model_version": "surrogate-abc-classification",
643
+ }
644
+
645
+
646
+ @patch("proxyml.client.post")
647
+ def test_find_counterfactual_as_df(mock_post):
648
+ mock_post.return_value = _mock_response(200, _CF_RESPONSE)
649
+ result = find_counterfactual(sample=[1.0, "no"], target="high")
650
+ assert isinstance(result, pd.DataFrame)
651
+ assert list(result.columns) == ["f_cont", "f_cat"]
652
+ assert result["f_cont"].iloc[0] == 1.5
653
+
654
+
655
+ @patch("proxyml.client.post")
656
+ def test_find_counterfactual_raw(mock_post):
657
+ mock_post.return_value = _mock_response(200, _CF_RESPONSE)
658
+ result = find_counterfactual(sample=[1.0, "no"], target="high", as_df=False)
659
+ assert result == _CF_RESPONSE
660
+
661
+
662
+ @patch("proxyml.client.post")
663
+ def test_find_counterfactual_none_returns_none(mock_post):
664
+ mock_post.return_value = _mock_response(200, _CF_RESPONSE_NONE)
665
+ result = find_counterfactual(sample=[1.0, "no"], target="high")
666
+ assert result is None
667
+
668
+
669
+ @patch("proxyml.client.post")
670
+ def test_find_counterfactual_payload(mock_post):
671
+ mock_post.return_value = _mock_response(200, _CF_RESPONSE)
672
+ uid = "550e8400-e29b-41d4-a716-446655440000"
673
+ find_counterfactual(
674
+ sample=[1.0, "no"], target="high",
675
+ n_neighbors=500, perturbation_scale=0.2, version=uid,
676
+ )
677
+ payload = mock_post.call_args.kwargs["payload"]
678
+ assert payload["instance"] == [1.0, "no"]
679
+ assert payload["target_label"] == "high"
680
+ assert payload["n_neighbors"] == 500
681
+ assert payload["perturbation_scale"] == 0.2
682
+ assert payload["version"] == uid
683
+
684
+
685
+ @patch("proxyml.client.post")
686
+ def test_find_counterfactual_no_version_in_payload(mock_post):
687
+ mock_post.return_value = _mock_response(200, _CF_RESPONSE)
688
+ find_counterfactual(sample=[1.0, "no"], target="high")
689
+ payload = mock_post.call_args.kwargs["payload"]
690
+ assert "version" not in payload
691
+
692
+
693
+ @patch("proxyml.client.post")
694
+ def test_find_counterfactual_failure_returns_none(mock_post):
695
+ mock_post.return_value = _mock_response(404, {"detail": "no surrogate"})
696
+ assert find_counterfactual(sample=[1.0, "no"], target="high") is None
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # get_feature_importances
701
+ # ---------------------------------------------------------------------------
702
+
703
+ _IMPORTANCES_RESPONSE = {
704
+ "feature_importances": [
705
+ {"feature": "MedInc", "coefficient": 0.82, "abs_coefficient": 0.82},
706
+ {"feature": "Latitude", "coefficient": -0.61, "abs_coefficient": 0.61},
707
+ ],
708
+ "per_class_importances": None,
709
+ "model_version": "abc-123",
710
+ "task": "regression",
711
+ "note": "Coefficients are in the scaled feature space.",
712
+ }
713
+
714
+
715
+ @patch("proxyml.client.get")
716
+ def test_get_feature_importances_success(mock_get):
717
+ mock_get.return_value = _mock_response(200, _IMPORTANCES_RESPONSE)
718
+ result = get_feature_importances()
719
+ assert result == _IMPORTANCES_RESPONSE
720
+ mock_get.assert_called_once_with(endpoint="/explain/importance", params={})
721
+
722
+
723
+ @patch("proxyml.client.get")
724
+ def test_get_feature_importances_with_version(mock_get):
725
+ mock_get.return_value = _mock_response(200, _IMPORTANCES_RESPONSE)
726
+ get_feature_importances(version="abc-123")
727
+ mock_get.assert_called_once_with(endpoint="/explain/importance", params={"version": "abc-123"})
728
+
729
+
730
+ @patch("proxyml.client.get")
731
+ def test_get_feature_importances_failure_returns_none(mock_get):
732
+ mock_get.return_value = _mock_response(404, {"detail": "not found"})
733
+ assert get_feature_importances() is None
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # get_model_schema
738
+ # ---------------------------------------------------------------------------
739
+
740
+ _MODEL_SCHEMA_RESPONSE = {
741
+ "features": [
742
+ {"type": "continuous", "name": "age", "mean": 35.0, "std": 10.0, "min": 18.0, "max": 90.0},
743
+ {"type": "categorical", "name": "gender", "valid_categories": {"M": 0.5, "F": 0.5}},
744
+ ]
745
+ }
746
+
747
+
748
+ @patch("proxyml.client.get")
749
+ def test_get_model_schema_success(mock_get):
750
+ mock_get.return_value = _mock_response(200, _MODEL_SCHEMA_RESPONSE)
751
+ result = get_model_schema(version="abc-123")
752
+ assert result == _MODEL_SCHEMA_RESPONSE
753
+ mock_get.assert_called_once_with(endpoint="/surrogate/models/abc-123/schema", params={})
754
+
755
+
756
+ @patch("proxyml.client.get")
757
+ def test_get_model_schema_failure_returns_none(mock_get):
758
+ mock_get.return_value = _mock_response(404, {"detail": "not found"})
759
+ assert get_model_schema(version="no-such-version") is None
760
+
761
+
762
+ # ---------------------------------------------------------------------------
763
+ # explain_local
764
+ # ---------------------------------------------------------------------------
765
+
766
+ _EXPLAIN_LOCAL_RESPONSE = {
767
+ "prediction": 1,
768
+ "feature_contributions": [
769
+ {"feature": "MedInc", "contribution": 0.5, "abs_contribution": 0.5},
770
+ {"feature": "Latitude", "contribution": -0.2, "abs_contribution": 0.2},
771
+ ],
772
+ "intercept": 0.1,
773
+ "probabilities": [0.2, 0.8],
774
+ "per_class_contributions": None,
775
+ }
776
+
777
+
778
+ @patch("proxyml.client.post")
779
+ def test_explain_local_success(mock_post):
780
+ mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_RESPONSE)
781
+ result = explain_local(instance=[1.0, 2.0])
782
+ assert result == _EXPLAIN_LOCAL_RESPONSE
783
+ payload = mock_post.call_args.kwargs["payload"]
784
+ assert payload["instance"] == [1.0, 2.0]
785
+ assert "version" not in payload
786
+
787
+
788
+ @patch("proxyml.client.post")
789
+ def test_explain_local_with_version(mock_post):
790
+ mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_RESPONSE)
791
+ uid = "550e8400-e29b-41d4-a716-446655440000"
792
+ explain_local(instance=[1.0, 2.0], version=uid)
793
+ payload = mock_post.call_args.kwargs["payload"]
794
+ assert payload["version"] == uid
795
+
796
+
797
+ @patch("proxyml.client.post")
798
+ def test_explain_local_failure_returns_none(mock_post):
799
+ mock_post.return_value = _mock_response(422, {"detail": "bad input"})
800
+ assert explain_local(instance=[1.0, 2.0]) is None
File without changes
File without changes
File without changes
File without changes
File without changes