proxyml 0.6.0__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.6.0
3
+ Version: 0.7.0
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.6.0"
7
+ version = "0.7.0"
8
8
  description = "Python SDK for calling the ProxyML API"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -10,6 +10,8 @@ training target was real ground truth or a black box's predictions.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import hashlib
14
+ import json
13
15
  from dataclasses import dataclass, replace
14
16
  from enum import Enum
15
17
  from importlib.metadata import version as _pkg_version
@@ -121,9 +123,23 @@ class TrainedChallenger:
121
123
  n_samples_total: int
122
124
  n_samples_dropped_unlabeled: int
123
125
  population_note: str
126
+ target_fingerprint: str
124
127
  champion_metrics: dict[str, float] | None = None
125
128
 
126
129
 
130
+ def _fingerprint_values(values: np.ndarray | list) -> str:
131
+ """Hash an array of labels, deterministically, without the data ever leaving this process.
132
+
133
+ ``.tolist()`` converts numpy scalars to native Python types before
134
+ serializing, so the hash doesn't drift across numpy versions with
135
+ different scalar repr behavior. Order is preserved (not sorted) since
136
+ it encodes row alignment — that's exactly what a "same data?" check
137
+ needs to be sensitive to.
138
+ """
139
+ canonical = json.dumps(np.asarray(values).tolist())
140
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
141
+
142
+
127
143
  def _population_note(target_name: str, n_total: int, n_labeled: int, n_dropped: int) -> str:
128
144
  if n_dropped == 0:
129
145
  return f"Evaluated on all {n_total} row(s) — '{target_name}' had no missing values."
@@ -186,6 +202,7 @@ def train_challenger(
186
202
  and the result is attached as ``TrainedChallenger.champion_metrics``.
187
203
  """
188
204
  target_arr = np.asarray(target)
205
+ target_fingerprint = _fingerprint_values(target_arr)
189
206
  if champion_predictions is not None and len(champion_predictions) != len(target_arr):
190
207
  raise ValueError(
191
208
  f"champion_predictions must have one entry per row of target "
@@ -264,6 +281,7 @@ def train_challenger(
264
281
  n_samples_total=n_total,
265
282
  n_samples_dropped_unlabeled=n_dropped,
266
283
  population_note=_population_note(target_name, n_total, n_labeled, n_dropped),
284
+ target_fingerprint=target_fingerprint,
267
285
  champion_metrics=champion_metrics,
268
286
  )
269
287
 
@@ -286,6 +304,13 @@ def score_champion(
286
304
 
287
305
  Returns ``{"f1":..., "accuracy":...}`` for classification or ``{"r2":...}``
288
306
  for regression — the same shape as ``TrainedChallenger.metrics``.
307
+
308
+ If you're calling this decoupled from ``train_challenger()`` (i.e. not
309
+ via its ``champion_predictions=`` param), pass the same ``labels`` you
310
+ used here as ``champion_labels=`` to ``to_challenger_upload()`` — that
311
+ lets the upload endpoint confirm the challenger and champion were
312
+ actually scored on the same data, catching an accidental mismatched
313
+ file before it silently produces a misleading comparison.
289
314
  """
290
315
  return score_predictions(np.asarray(labels), np.asarray(predictions), task=task)
291
316
 
@@ -295,6 +320,7 @@ def to_challenger_upload(
295
320
  *,
296
321
  n_samples: int | None = None,
297
322
  champion_metrics: dict[str, float] | None = None,
323
+ champion_labels: np.ndarray | list | None = None,
298
324
  sdk_version: str | None = None,
299
325
  proxyml_core_version: str | None = None,
300
326
  ) -> dict[str, Any]:
@@ -327,11 +353,21 @@ def to_challenger_upload(
327
353
  champion_metrics: the champion's real-world performance, from
328
354
  ``score_champion()`` — same metric keys as ``result.metrics``.
329
355
  Defaults to ``result.champion_metrics``.
356
+ champion_labels: the ``labels`` array you passed to a standalone
357
+ ``score_champion()`` call, if ``champion_metrics`` didn't come
358
+ from ``train_challenger()``'s internal ``champion_predictions=``
359
+ path. Used only to compute ``champion_data_fingerprint`` — the
360
+ labels themselves are never included in the payload. If
361
+ ``champion_metrics`` resolves from ``result.champion_metrics``
362
+ instead, the fingerprint defaults to ``result.target_fingerprint``
363
+ (guaranteed identical, since that internal path scores against
364
+ the exact same data).
330
365
  sdk_version: defaults to the installed ``proxyml`` version.
331
366
  proxyml_core_version: defaults to the installed ``proxyml-core`` version.
332
367
  """
333
368
  if n_samples is None:
334
369
  n_samples = result.n_samples_total - result.n_samples_dropped_unlabeled
370
+ used_internal_champion_metrics = champion_metrics is None
335
371
  if champion_metrics is None:
336
372
  champion_metrics = result.champion_metrics
337
373
  if sdk_version is None:
@@ -352,6 +388,11 @@ def to_challenger_upload(
352
388
  }
353
389
  if champion_metrics is not None:
354
390
  payload["champion_metrics"] = champion_metrics
391
+ payload["challenger_data_fingerprint"] = result.target_fingerprint
392
+ if champion_labels is not None:
393
+ payload["champion_data_fingerprint"] = _fingerprint_values(champion_labels)
394
+ elif used_internal_champion_metrics:
395
+ payload["champion_data_fingerprint"] = result.target_fingerprint
355
396
  return payload
356
397
 
357
398
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -402,3 +402,81 @@ def test_train_auto_challenger_passes_immutable_cols_to_get_schema():
402
402
  called_kwargs = mock_get_schema.call_args.kwargs
403
403
  assert list(called_df.columns) == list(features_df.columns)
404
404
  assert called_kwargs["immutable_cols"] == ["age"]
405
+
406
+
407
+ def test_target_fingerprint_is_deterministic_for_identical_data():
408
+ df = _labeled_df(seed=29)
409
+ result_a = train_auto_challenger(df, "approved", task="classification")
410
+ result_b = train_auto_challenger(df, "approved", task="classification")
411
+
412
+ assert result_a.target_fingerprint == result_b.target_fingerprint
413
+
414
+
415
+ def test_target_fingerprint_differs_for_different_data():
416
+ df_a = _labeled_df(seed=29)
417
+ df_b = _labeled_df(seed=30)
418
+ result_a = train_auto_challenger(df_a, "approved", task="classification")
419
+ result_b = train_auto_challenger(df_b, "approved", task="classification")
420
+
421
+ assert result_a.target_fingerprint != result_b.target_fingerprint
422
+
423
+
424
+ def test_to_challenger_upload_includes_matching_fingerprints_on_internal_champion_path():
425
+ df = _labeled_df(seed=31)
426
+ champion_predictions = df["approved"].tolist()
427
+ result = train_auto_challenger(
428
+ df, "approved", task="classification", champion_predictions=champion_predictions
429
+ )
430
+
431
+ payload = to_challenger_upload(result)
432
+
433
+ assert payload["challenger_data_fingerprint"] == result.target_fingerprint
434
+ assert payload["champion_data_fingerprint"] == result.target_fingerprint
435
+
436
+
437
+ def test_to_challenger_upload_champion_labels_fingerprint_for_decoupled_path():
438
+ df = _labeled_df(seed=32)
439
+ target = df["approved"]
440
+ result = train_challenger(df, target, _schema(), task="classification")
441
+ champion_metrics = score_champion(target, target, task="classification")
442
+
443
+ payload = to_challenger_upload(result, champion_metrics=champion_metrics, champion_labels=target)
444
+
445
+ assert payload["challenger_data_fingerprint"] == result.target_fingerprint
446
+ assert payload["champion_data_fingerprint"] == result.target_fingerprint
447
+
448
+
449
+ def test_to_challenger_upload_champion_labels_fingerprint_differs_for_different_data():
450
+ df = _labeled_df(seed=33)
451
+ target = df["approved"]
452
+ result = train_challenger(df, target, _schema(), task="classification")
453
+ champion_metrics = score_champion(target, target, task="classification")
454
+ other_labels = ~target
455
+
456
+ payload = to_challenger_upload(
457
+ result, champion_metrics=champion_metrics, champion_labels=other_labels
458
+ )
459
+
460
+ assert payload["challenger_data_fingerprint"] != payload["champion_data_fingerprint"]
461
+
462
+
463
+ def test_to_challenger_upload_omits_champion_fingerprint_without_labels_or_internal_path():
464
+ df = _labeled_df(seed=34)
465
+ target = df["approved"]
466
+ result = train_challenger(df, target, _schema(), task="classification")
467
+ champion_metrics = score_champion(target, target, task="classification")
468
+
469
+ payload = to_challenger_upload(result, champion_metrics=champion_metrics)
470
+
471
+ assert payload["challenger_data_fingerprint"] == result.target_fingerprint
472
+ assert "champion_data_fingerprint" not in payload
473
+
474
+
475
+ def test_to_challenger_upload_without_champion_metrics_omits_fingerprints():
476
+ df = _labeled_df(seed=35)
477
+ result = train_auto_challenger(df, "approved", task="classification")
478
+
479
+ payload = to_challenger_upload(result)
480
+
481
+ assert "challenger_data_fingerprint" not in payload
482
+ assert "champion_data_fingerprint" not in payload
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes