Skip to content

Fix continuous ROC for binary observations with out-of-range forecasts (#442) - #450

Merged
aaronspring merged 18 commits into
mainfrom
fix-roc-continuous-binary-obs-442
Oct 2, 2026
Merged

aaronspring merged 18 commits into
mainfrom
fix-roc-continuous-binary-obs-442

Conversation

@aaronspring

Copy link
Copy Markdown
Collaborator

Summary

Fixes #442. xss.roc(..., bin_edges="continuous") returned ROC AUC values that diverged sharply from sklearn.metrics.roc_auc_score — down to 0.0 where sklearn returns 1.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 threshold i:

dichotomous_category_edges = np.array([-np.inf, i, np.inf])
Contingency(observations, forecasts,
            dichotomous_category_edges,   # observation edges
            dichotomous_category_edges,   # forecast edges
            dim=dim)

The thresholds i are 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 is 0 at 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 above 1, 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.5 and vary only the forecast threshold. Output now matches sklearn for every point in the reproducer (error 0.0 across all 10 points), independent of forecast magnitude or input dim order.

Tests

  • Added test_roc_continuous_forecast_out_of_unit_range_against_sklearn (parametrized over drop_intermediate), using the multi-dimensional, transposed, out-of-range data from the issue and asserting per-point agreement with roc_auc_score. It fails on main and passes with this change.
  • Full test_probabilistic.py, test_contingency.py, and accessor suites pass (500 passed, 1 skipped); doctests pass.

🤖 Generated with Claude Code

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

codecov Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.78%. Comparing base (e457985) to head (6649726).
⚠️ Report is 19 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@aaronspring

aaronspring commented Jun 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

@paololucchino do you mind to review this PR fixing your issue #442 ?

@aaronspring

Copy link
Copy Markdown
Collaborator Author

Verification: is this a continuous-only bug, or does explicit bin_edges also need it?

I checked whether, on main (without this PR), passing explicit bin_edges instead of "continuous" would already match sklearn.metrics.roc_auc_score. Per the API docs, bin_edges "defines categorization boundaries for observations and forecasts" — i.e. the explicit path categorises both inputs with the same edges (hence the o = f.copy() doctest).

Result on main (no PR):

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.

aaronspring and others added 16 commits June 17, 2026 10:20
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>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@aaronspring
aaronspring merged commit 42bbeba into main Oct 2, 2026
4 of 5 checks passed
@aaronspring
aaronspring deleted the fix-roc-continuous-binary-obs-442 branch October 2, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

roc bug, possibly due to dimension names?

3 participants