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.
- {proxyml-0.6.0 → proxyml-0.7.0}/PKG-INFO +1 -1
- {proxyml-0.6.0 → proxyml-0.7.0}/pyproject.toml +1 -1
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml/local/challenger.py +41 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml.egg-info/PKG-INFO +1 -1
- {proxyml-0.6.0 → proxyml-0.7.0}/tests/test_local_challenger.py +78 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/LICENSE +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/README.md +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/setup.cfg +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml/__init__.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml/client.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml/local/__init__.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml/schema_builder.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml.egg-info/SOURCES.txt +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml.egg-info/dependency_links.txt +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml.egg-info/requires.txt +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/src/proxyml.egg-info/top_level.txt +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/tests/test_client.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/tests/test_dependency_boundaries.py +0 -0
- {proxyml-0.6.0 → proxyml-0.7.0}/tests/test_schema_builder.py +0 -0
|
@@ -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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|