Skip to content

S4: numpy AP and MTD kernels alongside scikit-learn, with the full parity suite — no dispatch change yet (#64 gate, part 1) #70

Description

@Polichinel

Epic: #66 · Wave 1 · Gates: S5 (#64) · Blocked by: none · Ships no behaviour change

Background

#64's gate: scikit-learn stays the running implementation until a comprehensive parity suite exists, passes against it, and has been seen failing on a deliberately broken numpy kernel. This story is that gate. It adds the numpy kernels as private functions next to _crps_ensemble_numpy (native_metric_calculators.py:46-77, the precedent) and the parity suite, and changes nothing that dispatches.

Measured on scikit-learn 1.7.2 (2026-09-17), the semantics to reproduce:

  • AP_binary_clf_curve: order = argsort(y_score, kind="mergesort")[::-1]; y_true == 1; thresholds at np.where(np.diff(sorted_scores))[0] plus the last index (ties collapse to one operating point); tps = cumsum(float64)[idx], fps = 1 + idx - tps. precision_recall_curve: precision tps/(tps+fps) with zeros where the denominator is 0; recall tps/tps[-1], or all-ones with warnings.warn("No positive class found in y_true, recall is set to one for all thresholds.") (plain UserWarning) when tps[-1] == 0; reversed, with (1, 0) appended. AP = float(max(0.0, -np.sum(np.diff(recall) * precision[:-1]))). A literal transcription matched sklearn to 0.0 on 300 tie-heavy random cases. Accepted y_true: at most two distinct values and, if two, 1 among them ({1,2} computes with 1 as positive; {0,1,2} and continuous raise).
  • MTD — p<0: 2*(where(y>0,y,0)^(2-p)/((1-p)(2-p)) - y*mu^(1-p)/(1-p) + mu^(2-p)/(2-p)); p==0: (y-mu)^2; p==1: 2*(xlogy(y, y/mu) - y + mu) with xlogy = where(x==0, 0, x*log(y)) under errstate(divide="ignore", invalid="ignore"); p==2: 2*(log(mu/y) + y/mu - 1); else the generic form; plain mean; Python float. Domain: p<0 → ValueError("Mean Tweedie deviance error with power={power} can only be used on strictly positive y_pred."); 1<=p<2 → "…non-negative y and strictly positive y_pred."; p>=2 → "…strictly positive y and y_pred."; p==0 no check; 0<p<1 → ValueError. Matched to 0.0 over 11 powers × 200 cases on float64.

Maintainer decisions (2026-09-17): float64 always (sklearn computes float32 in when all inputs are float32, ~3e-7 off — documented deviation); clean ValueError for non-binary AP truth, naming the observed labels, while accepting exactly sklearn's input set.

The work

  1. _average_precision_numpy(y_true_flat, y_score_flat) -> float and _tweedie_deviance_numpy(y_true_flat, y_pred_flat, power) -> float in native_metric_calculators.py, on the flattened form the kernels already use (np.repeat(y_true, S), y_pred.flatten()). Not called by anything yet. sklearn import untouched.
  2. TestAPParityWithSklearn and TestMTDParityWithSklearn in tests/test_metric_calculators.py, modelled on TestCRPSParityWithProperscoring (function-local from sklearn.metrics import … oracle, pytest.approx(abs=1e-10), seeded RandomState). Content is the S5: Switch AP and MTD to the numpy kernels; scikit-learn to dev deps; import-purity guard; close C-05 (#64 gate, part 2) #64 gate list: AP — perfect, inverted, all-tied, all-positive, all-negative (value and warning message), single positive, N=1, N=2, duplicated scores with mixed labels, S in {1, 10, 100}, 200 seeded draws over N in {5, 50, 5000} × S × prevalence 1%–99%, {-1,1} and {1,2} label sets, and the raise cases ({0,1,2}, continuous) asserting ValueError on both sides. MTD — p in {-2, -1, -0.5, 0, 1, 1.0001, 1.5, 1.9999, 2, 2.0001, 2.5, 3, 5}; exact zeros in y where permitted; y == mu (deviance exactly 0); y ≪ mu and y ≫ mu; S in {1, 10, 100}; 200 seeded draws per branch; every domain rule as a raise test with the message text; float32 inputs at abs=1e-6 with the deviation named in the docstring.
  3. Warning parity: each random case run under warnings.catch_warnings(record=True) on both paths, asserting the same warning categories — this is what catches a stray numpy RuntimeWarning from the Poisson branch.
  4. Extreme values (1e6, 1e-6) for both.

This story does not change dispatch, pyproject.toml, docs, or the register. grep -rn sklearn views_evaluation/ still finds the import when it merges.

Acceptance criteria

  • Both numpy functions exist, private, uncalled by production code
  • Parity suite passes at abs=1e-10 (float64) on every listed case; float32 cases at abs=1e-6
  • Seen red, recorded in the PR: three injected defects each made the suite fail — drop [::-1] on the sort; skip the tie collapse (use every index as a threshold); flip a sign in the generic Tweedie branch. All reverted
  • Existing test_ap_oracle_sklearn and test_mtd_known_tweedie untouched and green
  • Zero behaviour change: full suite green with no test modified other than additions; ruff clean

Validation

conda run --name views_pipeline pytest tests/test_metric_calculators.py -q
conda run --name views_pipeline pytest tests/ -q
conda run --name views_pipeline ruff check views_evaluation/ tests/
git diff --stat -- views_evaluation/   # native_metric_calculators.py only, additions only

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    implementationCode changestoryA single, independently implementable and reviewable unit of an epictestingTest coverage or test infrastructure

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions