Repository navigation
Fix continuous ROC for binary observations with out-of-range forecasts (#442) - #450
Conversation
In `roc(..., bin_edges="continuous")` the binary observations were categorised with the forecast-derived threshold `i`. When forecast values fall outside the [0, 1] range of the observations, no observation ever lands in the event category, so the hit rate is always 0 and the area collapses (e.g. 0.0 instead of 1.0). Categorise observations with a fixed split at 0.5 in continuous mode, varying only the forecast threshold. Results now match `sklearn.metrics.roc_auc_score` regardless of forecast magnitude or input dimension ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #450 +/- ##
==========================================
+ Coverage 94.57% 94.78% +0.20%
==========================================
Files 27 27
Lines 2841 2973 +132
==========================================
+ Hits 2687 2818 +131
- Misses 154 155 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@paololucchino do you mind to review this PR fixing your issue #442 ? |
Verification: is this a
|
| Case | sklearn | xs explicit bin_edges |
xs "continuous" |
|---|---|---|---|
| A) Issue #442 data, forecast out of [0,1] (point 9, fc∈[1.23,1.81]) | 1.0000 | 0.0000 ❌ | 0.0000 ❌ |
| B) Probability forecast within [0,1] | 0.9843 | 0.9843 ✅ | 0.9843 ✅ |
So on main, explicit bin_edges matches sklearn only when the forecast (and the edges) lie within the [0,1] range of the binary observations (case B). For the out-of-range forecasts in #442 the explicit path is broken in the exact same way as continuous (point 9 → 0.0 instead of 1.0).
Why: both paths feed the same bin_edges to the observation categorisation. The binary observations (0/1) only fall into the "event" category if an edge sits between 0 and 1. Once forecasts — and thus the derived/passed edges — exceed 1, no observation is ever an "event", the hit rate is 0 at every threshold, and the area collapses. The bug is therefore not unique to continuous; it stems from categorising binary observations against forecast-scale edges.
Implication for this PR (confirms the scope is right): with the fix, "continuous" matches sklearn for all 10 points (case A), and the explicit bin_edges path is intentionally left unchanged. That's correct: the explicit path is a documented generalised multi-category ROC where observations and forecasts share categories — forcing a fixed 0.5 observation split there would break its documented behaviour (e.g. the o = f.copy(), category_edges = np.linspace(-2, 2, 5) doctest). The sklearn-equivalent binary-observation ROC is precisely what "continuous" mode is for, which is where this PR applies the fix.
Bottom line: explicit bin_edges on main would have matched sklearn only for in-range forecasts; for #442's out-of-range case it would not, so the fix belongs in the continuous path.
Delete docs/source/geophysical-data.ipynb and its toctree entry. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Drop Python 3.9 support and bump GHA actions to Node.js 24 - Remove Python 3.9 from the test matrix, classifiers, requires-python (now >=3.10), and black target-version - Drop the 3.9-only `numpy==1.24` install step and bump docs/install jobs to Python 3.10 - Raise the conda env floors to `python >=3.10` - Bump GitHub Actions to Node.js 24 runtimes: checkout@v5, setup-python@v6, upload-artifact@v6, download-artifact@v7, github-script@v8, setup-micromamba@v3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Replace deprecated ::set-output with $GITHUB_OUTPUT The ::set-output workflow command is deprecated and slated for removal from GitHub Actions runners. Write to the $GITHUB_OUTPUT file instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Make docs discoverable by LLMs and coding agents (llms.txt) Following Jacob Tomlinson's PyData talk on marketing open source projects to LLMs (https://youtu.be/bRDtplyhRYw), make xskillscore documentation easy for coding agents to discover and parse: - Generate llms.txt (summary) and llms-full.txt (full docs as a single context-efficient text file) during the Sphinx HTML build via the sphinx-llms-txt extension, with a project title/summary and absolute links driven by html_baseurl. - Add documentation breadcrumbs to the package __init__ so agents reading the source are pointed at the docs and the llms.txt files. - Add sphinx-llms-txt to the docs build environments (ci/doc.yml and the `complete` optional-dependencies extra). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Use NVIDIA sphinx-llm to generate llms.txt / llms-full.txt Switch from sphinx-llms-txt to NVIDIA's sphinx-llm package (the `sphinx_llm.txt` extension). It uses the markdown builder to emit true markdown: llms.txt (sitemap), llms-full.txt (full docs) and per-page .md files, following the llms.txt standard. - conf.py: enable `sphinx_llm.txt`; set `llms_txt_description` (title comes from the project name). Drop the html_baseurl and llms_txt_title/summary keys that were specific to sphinx-llms-txt. - ci/doc.yml and the `complete` extra: depend on sphinx-llm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add Context7 docs badge to README and docs landing page Link to https://context7.com/xarray-contrib/xskillscore so users and coding agents can find the LLM-indexed documentation. Uses a shields.io static badge in Context7 brand colour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The finite-ensemble CRPS estimator from properscoring is biased for
ensemble size M, so scores from ensembles of different size are not
comparable. fair=True applies the Ferro (2014) adjustment, replacing the
M**2 denominator of the spread term by M*(M-1).
Implemented via the reconstruction
fair = skill - (skill - crps_std) * M / (M - 1)
which avoids materializing the M x M pairwise difference array. M counts
non-NaN members per forecast, matching properscoring's NaN handling.
Raises for member_weights not None (fair is only defined for equally
weighted members) and for fewer than 2 members.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code review findings on #462: - With fair=True and keep_attrs=True the result carried the attributes of forecasts instead of observations, unlike the fair=False path and the documented behaviour ('copied from the first input'). The existing keep_attrs tests could not catch this because the o and f_prob fixtures share identical attrs. Rewriting the reconstruction as the algebraically identical res - (skill - res) / (M - 1) keeps res as the left operand, so attrs come from observations again, and drops one multiplication. - Added test_crps_ensemble_keep_attrs_from_observations with distinct attrs on observations and forecasts as a regression guard for both fair values. - CHANGELOG entry now cites :pr:`462` alongside :issue:`461`, matching every other entry in the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.7 to 6.5.8. - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](tornadoweb/tornado@v6.5.7...v6.5.8) --- updated-dependencies: - dependency-name: tornado dependency-version: 6.5.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [soupsieve](https://github.com/facelessuser/soupsieve) from 2.8.4 to 2.9. - [Release notes](https://github.com/facelessuser/soupsieve/releases) - [Commits](facelessuser/soupsieve@2.8.4...2.9) --- updated-dependencies: - dependency-name: soupsieve dependency-version: '2.9' dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](python-pillow/Pillow@12.2.0...12.3.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.3.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aaron Spring <12237157+aaronspring@users.noreply.github.com>
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.1 to 3.3.0. - [Release notes](https://github.com/lepture/mistune/releases) - [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst) - [Commits](lepture/mistune@v3.2.1...v3.3.0) --- updated-dependencies: - dependency-name: mistune dependency-version: 3.3.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aaron Spring <12237157+aaronspring@users.noreply.github.com>
…467) RTD resolved Python 3.13 + Sphinx 9, where sphinx-autosummary-accessors 2025.3.1 fails at import (Autosummary.create_documenter was removed in Sphinx 9), aborting every docs build since Sphinx 9 was released. Pin sphinx >=6.0.0,<9 in ci/doc.yml, ci/dev.yml, ci/docs_notebooks.yml and the complete extra, and refresh uv.lock. Also re-locks mistune to 3.3.4, closing GHSA-6m44-fpc8-c3rq, which the 3.3.0 bump in #455 left open. Verified by the Read the Docs build on this PR.
updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.9 → v0.15.20](astral-sh/ruff-pre-commit@v0.15.9...v0.15.20) - [github.com/pre-commit/mirrors-mypy: v1.20.0 → v2.1.0](pre-commit/mirrors-mypy@v1.20.0...v2.1.0) - [github.com/python-jsonschema/check-jsonschema: 0.37.1 → 0.37.4](python-jsonschema/check-jsonschema@0.37.1...0.37.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Aaron Spring <12237157+aaronspring@users.noreply.github.com>
Co-authored-by: Aaron Spring <12237157+aaronspring@users.noreply.github.com>
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
Summary
Fixes #442.
xss.roc(..., bin_edges="continuous")returned ROC AUC values that diverged sharply fromsklearn.metrics.roc_auc_score— down to0.0where sklearn returns1.0.Root cause
In continuous mode the observations are binary (
0/1), but the code categorised both observations and forecasts with the same forecast-derived thresholdi:The thresholds
iare drawn from forecast magnitudes. When forecasts fall outside the[0, 1]range of the binary observations, the binary observations never land in the "event" category, so the hit rate is0at every threshold and the area collapses.This was previously masked because typical probability forecasts (and the existing sklearn test, which
clip(0, 1)s the forecast) stay within[0, 1]. The reproducer in the issue shifts forecasts above1, exposing the bug. It is not a dimension-ordering problem — it reproduces in pure 1-D.Fix
In continuous mode, categorise observations with a fixed split at
0.5and vary only the forecast threshold. Output now matches sklearn for every point in the reproducer (error0.0across all 10 points), independent of forecast magnitude or input dim order.Tests
test_roc_continuous_forecast_out_of_unit_range_against_sklearn(parametrized overdrop_intermediate), using the multi-dimensional, transposed, out-of-range data from the issue and asserting per-point agreement withroc_auc_score. It fails onmainand passes with this change.test_probabilistic.py,test_contingency.py, and accessor suites pass (500 passed, 1 skipped); doctests pass.🤖 Generated with Claude Code