diff --git a/.circleci/config.yml b/.circleci/config.yml index 8e9f19b5c6878..aa696d06d66ec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -57,6 +57,7 @@ jobs: doc: docker: - image: cimg/base:current-22.04 + resource_class: medium+ environment: - MKL_NUM_THREADS: 2 - OPENBLAS_NUM_THREADS: 2 diff --git a/.gitattributes b/.gitattributes index f45e0f29ccfa2..952138b08adba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,5 @@ .* export-ignore asv_benchmarks export-ignore -azure-pipelines.yml export-ignore benchmarks export-ignore build_tools export-ignore maint_tools export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bc8e5b5ff70d1..5ee5ad58b1889 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,9 +10,11 @@ body: addressed by searching through [the past issues](https://github.com/scikit-learn/scikit-learn/issues). - type: textarea attributes: - label: Describe the bug + label: Describe the bug and give evidence about its user-facing impact description: > - A clear and concise description of what the bug is. + A clear and concise description of what the bug is and **how it affects you as a scikit-learn user**. Please give a few details about the context of the discovery, why you care about getting it fixed. Please do not create issues for problems you don't actually care about. + + The scikit-learn issue tracker is swamped by reports and pull-requests. Stating the expected user impact is critical to help maintainers and other contributors focus time and effort to review meaningful contributions. validations: required: true - type: textarea @@ -36,13 +38,15 @@ body: model = lda_model.fit(lda_features) ``` + If possible, craft a reproducer that only uses the public scikit-learn API or justify why you had to use some private API to trigger the problem. This helps us assess the user-facing impact of the bug. + If the code is too long, feel free to put it in a public gist and link it in the issue: https://gist.github.com. - In short, **we are going to copy-paste your code** to run it and we expect to get the same result as you. + In short, **we need to be able to quickly copy-paste your code** to run it without modification and we expect to get the same result as you. We acknowledge that crafting a [minimal reproducible code example](https://scikit-learn.org/dev/developers/minimal_reproducer.html) requires some effort on your side but it really helps the maintainers quickly reproduce the problem and analyze its cause without any ambiguity. Ambiguous bug reports tend to be slower to fix because they will require more effort and back and forth discussion between the maintainers and the reporter to pin-point the precise conditions necessary to reproduce the problem. placeholder: | - ``` + ```python Sample code to reproduce the problem ``` validations: @@ -89,6 +93,14 @@ body: ``` validations: required: true +- type: textarea + attributes: + label: Interest in fixing the bug + description: > + If your issue is triaged by project maintainers as a bug that can be reproduced, would you be interested in working on a PR to resolve it? + And if you already have an idea, please explain your analysis of the root cause of the bug and a strategy for a possible fix, but please do not open a PR as long as the issue has not been triaged. + validations: + required: true - type: markdown attributes: value: > diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dda65568b4a29..86dce2e796499 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,33 +25,28 @@ is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests #### What does this implement/fix? Explain your changes. +#### AI usage disclosure + +I used AI assistance for: +- [ ] Code generation (e.g., when writing an implementation or fixing a bug) +- [ ] Test/benchmark generation +- [ ] Documentation (including examples) +- [ ] Research and understanding + + #### Any other comments? - - diff --git a/.github/scripts/add_or_remove_no_pr_warning.py b/.github/scripts/add_or_remove_no_pr_warning.py new file mode 100644 index 0000000000000..a32a9e551d9b9 --- /dev/null +++ b/.github/scripts/add_or_remove_no_pr_warning.py @@ -0,0 +1,50 @@ +# Used in Github Action .github/workflows/not_ready_for_pr_warning.yml +import argparse +import os + +from github import Auth, Github + +parser = argparse.ArgumentParser( + description="Add or remove no-contribution warning from issue body" +) +parser.add_argument( + "--mode", choices=["add", "remove"], help="Whether to add or remove warning" +) +args = parser.parse_args() + +# env variables are defined in .github/workflows/not_ready_for_pr_warning.yml +g = Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) +repo = g.get_repo(os.environ["GITHUB_REPO"]) +issue = repo.get_issue(number=int(os.environ["ISSUE_NUMBER"])) + +body_text = str(issue.body) if issue.body else "" + +message = ( + "> [!WARNING]\n" + "> This issue is not yet ready for a PR. If you are interested in contributing to " + "scikit-learn, please have a look at our [contributing guidelines]" + "(https://scikit-learn.org/dev/developers/contributing.html), and in particular " + "the sections for [new contributors]" + "(https://scikit-learn.org/dev/developers/contributing.html#new-contributors) and " + 'the ["Needs triage"](https://scikit-learn.org/dev/developers/contributing.html#' + "issues-tagged-needs-triage) label." +) + +if args.mode == "add": + if not body_text.startswith(message): + new_body = f"{message}\n\n{body_text}" + issue.edit(body=new_body) + print(f"Added warning to issue: {os.environ['GITHUB_REPO']}#{issue.number}") + +else: + still_needs_something = any( + label.name.startswith("Needs") or label.name == "RFC" for label in issue.labels + ) + if not still_needs_something: + if body_text.startswith(message): + new_body = body_text.removeprefix(f"{message}\n\n") + issue.edit(body=new_body) + print( + "Removed warning from issue: " + f"{os.environ['GITHUB_REPO']}#{issue.number}" + ) diff --git a/.github/workflows/autoclose-comment.yml b/.github/workflows/autoclose-comment.yml index 619933b1940c1..a22eb28829b8e 100644 --- a/.github/workflows/autoclose-comment.yml +++ b/.github/workflows/autoclose-comment.yml @@ -39,36 +39,29 @@ jobs: Thank you for your contribution to scikit-learn and for the effort you have put into this PR. This pull request does not yet meet the quality and - clarity needed for an effective review. Reviewing time is limited, and our - goal is to prioritize well-prepared contributions to keep scikit-learn - maintainable. Unless this PR is improved, it will be automatically closed - after two weeks. + clarity needed for an effective review. Project maintainers have limited + time for code reviews, and our goal is to prioritize well-prepared + contributions to keep scikit-learn maintainable. - To avoid autoclose and increase the chance of a productive review, please: + To increase the chance of a productive review, please refer to: [How do I + improve my issue or pull + request?](https://scikit-learn.org/dev/faq.html#how-do-i-improve-my-issue-or-pull-request) + As the author, you are responsible for driving this PR, which entails doing + necessary background research as well as presenting its context and your + thought process. If you are a [new + contributor](https://scikit-learn.org/dev/developers/contributing.html#new-contributors), + or do not know how to fulfill these requirements, we recommend that you + familiarise yourself with scikit-learn's development conventions via other + contribution types (e.g., reviewing PRs) before submitting code. - - Ensure your contribution aligns with our - [contribution guide](https://scikit-learn.org/dev/developers/contributing.html). - - Include a clear motivation and concise explanation in the pull request - description of why you chose this solution. + Scikit-learn maintainers cannot provide one-to-one guidance on this PR. + However, if you ask focused, well-researched questions, a community + member may be willing to help. ๐Ÿ’ฌ - - Make sure the code runs and passes tests locally (`pytest`) and in the CI. - - Submit only code you can explain and maintain; reviewers will ask for - clarifications and changes. Disclose any AI assistance per our - [Automated Contributions Policy](https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy). - - - Keep the changes minimal and directly relevant to the described issue or - enhancement. - - - We cannot provide one-to-one guidance on every PR, though we - encourage you to ask focused, actionable questions that show you have tried - to explore the problem and are interested to engage with the project. ๐Ÿ’ฌ - Sometimes a maintainer or someone else from the community might be able to - offer pointers. - - - If you improve your PR within the two-week window, the `autoclose` label can - be removed by maintainers. + If you substantially improve this PR within two weeks, a team member may + remove the `autoclose` label and the PR stays open. Cosmetic changes or + incomplete fixes will not be sufficient. Maintainers will assess + improvements on their own schedule. Please do not ping (`@`) maintainers. diff --git a/.github/workflows/autoclose-schedule.yml b/.github/workflows/autoclose-schedule.yml index 4507f6685c275..77a8eeebfc168 100644 --- a/.github/workflows/autoclose-schedule.yml +++ b/.github/workflows/autoclose-schedule.yml @@ -18,8 +18,9 @@ jobs: autoclose: name: autoclose labeled PRs runs-on: ubuntu-latest + if: github.repository == 'scikit-learn/scikit-learn' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.13' @@ -27,7 +28,7 @@ jobs: run: pip install -Uq PyGithub - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Close PRs labeled more than 14 days ago run: | diff --git a/.github/workflows/bot-lint-comment.yml b/.github/workflows/bot-lint-comment.yml index 36c29ad3e0b84..cc694e8ad5969 100644 --- a/.github/workflows/bot-lint-comment.yml +++ b/.github/workflows/bot-lint-comment.yml @@ -23,7 +23,7 @@ jobs: run: mkdir -p "$ARTIFACTS_DIR" - name: Download artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: lint-log path: ${{ runner.temp }}/artifacts @@ -48,7 +48,7 @@ jobs: --jq '"PR_NUMBER=\(.number)"' \ >> $GITHUB_ENV - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: sparse-checkout: build_tools/get_comment.py @@ -58,7 +58,7 @@ jobs: python-version: 3.11 - name: Install dependencies - run: python -m pip install requests + run: python -m pip install PyGithub - name: Create/update GitHub comment env: diff --git a/.github/workflows/check-changelog.yml b/.github/workflows/check-changelog.yml index 7ba1bb5af2fa9..ae35483a9a614 100644 --- a/.github/workflows/check-changelog.yml +++ b/.github/workflows/check-changelog.yml @@ -14,7 +14,7 @@ jobs: name: A reviewer will let you know if it is required or can be bypassed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: '0' - name: Check if tests have changed diff --git a/.github/workflows/check-sdist.yml b/.github/workflows/check-sdist.yml index ca886ea9aca2b..2990611cce4ef 100644 --- a/.github/workflows/check-sdist.yml +++ b/.github/workflows/check-sdist.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.11' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1981d3138e48b..c180fb3e10942 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index fc927c4cc3cc9..55fe4fceb5f79 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@v1 - name: Codespell diff --git a/.github/workflows/cuda-ci.yml b/.github/workflows/cuda-ci.yml index 935e5b187a8ae..4404015e8e738 100644 --- a/.github/workflows/cuda-ci.yml +++ b/.github/workflows/cuda-ci.yml @@ -15,17 +15,17 @@ jobs: runs-on: "ubuntu-latest" name: Build wheel for Pull Request steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Build wheels - uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 + uses: pypa/cibuildwheel@298ed2fb2c105540f5ed055e8a6ad78d82dd3a7e # v3.3.1 env: - CIBW_BUILD: cp313-manylinux_x86_64 + CIBW_BUILD: cp314-manylinux_x86_64 CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 CIBW_BUILD_VERBOSITY: 1 CIBW_ARCHS: x86_64 - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: name: cibw-wheels path: ./wheelhouse/*.whl @@ -40,7 +40,7 @@ jobs: timeout-minutes: 20 name: Run Array API unit tests steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v8 with: pattern: cibw-wheels path: ~/dist @@ -51,7 +51,7 @@ jobs: # https://github.com/actions/setup-python/issues/886 python-version: '3.12.3' - name: Checkout main repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install miniforge run: bash build_tools/github/create_gpu_environment.sh - name: Install scikit-learn @@ -66,6 +66,16 @@ jobs: conda activate sklearn python -c "import sklearn; sklearn.show_versions()" - SCIPY_ARRAY_API=1 pytest --pyargs sklearn -k 'array_api' -v + # Since we are billed GPU usage by the minute, we only run the tests that + # are likely to exercise the CUDA GPU and rely on free CI runners to run + # the tests with PyTorch on non-CUDA devices. + SCIPY_ARRAY_API=1 pytest --pyargs sklearn -k 'cuda or cupy' -vl # Run in /home/runner to not load sklearn from the checkout repo working-directory: /home/runner + + - name: Run doctests in doc/modules/array_api.rst + run: | + source "${HOME}/conda/etc/profile.d/conda.sh" + conda activate sklearn + cd doc + SCIPY_ARRAY_API=1 pytest --doctest-modules modules/array_api.rst diff --git a/.github/workflows/emscripten.yml b/.github/workflows/emscripten.yml index 2349f44b18135..9d5f250e45707 100644 --- a/.github/workflows/emscripten.yml +++ b/.github/workflows/emscripten.yml @@ -35,7 +35,7 @@ jobs: build: ${{ steps.check_build_trigger.outputs.build }} steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -63,11 +63,11 @@ jobs: if: needs.check_build_trigger.outputs.build steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - - uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 + - uses: pypa/cibuildwheel@298ed2fb2c105540f5ed055e8a6ad78d82dd3a7e # v3.3.1 env: CIBW_PLATFORM: pyodide SKLEARN_SKIP_OPENMP_TEST: "true" @@ -77,7 +77,7 @@ jobs: CIBW_TEST_COMMAND: "python -m pytest -sra --pyargs sklearn --durations 20 --showlocals" - name: Upload wheel artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: pyodide_wheel path: ./wheelhouse/*.whl @@ -94,13 +94,13 @@ jobs: if: github.repository == 'scikit-learn/scikit-learn' && github.event_name != 'pull_request' steps: - name: Download wheel artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: path: wheelhouse/ merge-multiple: true - name: Push to Anaconda PyPI index - uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2 + uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3 with: artifacts_path: wheelhouse/ anaconda_nightly_upload_token: ${{ secrets.SCIKIT_LEARN_NIGHTLY_UPLOAD_TOKEN }} diff --git a/.github/workflows/labeler-title-regex.yml b/.github/workflows/labeler-title-regex.yml index 798a9ea4a493a..b589e0de70c06 100644 --- a/.github/workflows/labeler-title-regex.yml +++ b/.github/workflows/labeler-title-regex.yml @@ -15,7 +15,7 @@ jobs: labeler: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d7de560ace6c..593faf026b2e0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} @@ -48,7 +48,7 @@ jobs: - name: Upload Artifact if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: lint-log path: | diff --git a/.github/workflows/needs-decision.yml b/.github/workflows/needs-decision.yml index 592b24c925107..8079a39cdab36 100644 --- a/.github/workflows/needs-decision.yml +++ b/.github/workflows/needs-decision.yml @@ -33,14 +33,16 @@ jobs: /repos/$GH_REPO/issues/$ISSUE_NUMBER/comments \ -f "body=$BODY" env: - BODY: | + BODY: > Thanks for the work you've done so far. The goal of this comment is to set expectations. + Deciding on new features or substantial changes is a lengthy process. It frequently happens that no maintainer is available to take on this task right now. + Please do not create a Pull Request before a decision has been made regarding the proposed work. Making this decision can often take a significant amount of time and effort. diff --git a/.github/workflows/not-ready-for-pr-warning.yml b/.github/workflows/not-ready-for-pr-warning.yml new file mode 100644 index 0000000000000..e8ad48949ee8d --- /dev/null +++ b/.github/workflows/not-ready-for-pr-warning.yml @@ -0,0 +1,57 @@ +# Add a warning to the issue description when the issue is labeled "Needs ..." +# and doesn't have a warning yet, and remove it again when all such labels are removed. +name: "Add or remove not-ready-for-PR warning" + +permissions: + contents: read + issues: write + +on: + issues: + types: [opened, labeled, unlabeled] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + +jobs: + + add_not_ready_for_PR_warning: + runs-on: ubuntu-latest + if: github.event.action == 'opened' || (github.event.action == 'labeled' && + (startsWith(github.event.label.name, 'Needs') || github.event.label.name == 'RFC')) + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install PyGithub + run: pip install -Uq PyGithub + - name: Checkout workflow script + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts/add_or_remove_no_pr_warning.py + sparse-checkout-cone-mode: false # false for files/ true for directories + - name: Add not-ready-for-PR warning + run: python .github/scripts/add_or_remove_no_pr_warning.py --mode="add" + + remove_not_ready_for_PR_warning: + runs-on: ubuntu-latest + if: github.event.action == 'unlabeled' && + (startsWith(github.event.label.name, 'Needs') || + github.event.label.name == 'RFC') + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install PyGithub + run: pip install -Uq PyGithub + - name: Checkout workflow script + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts/add_or_remove_no_pr_warning.py + sparse-checkout-cone-mode: false # false for files/ true for directories + - name: Remove not-ready-for-PR warning + run: python .github/scripts/add_or_remove_no_pr_warning.py --mode="remove" diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index b65bd4a67ef54..07db8cfe47c66 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -18,7 +18,7 @@ jobs: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.8' diff --git a/.github/workflows/unassign.yml b/.github/workflows/unassign.yml deleted file mode 100644 index 94a50d49839d6..0000000000000 --- a/.github/workflows/unassign.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Unassign -#Runs when a contributor has unassigned themselves from the issue and adds 'help wanted' -on: - issues: - types: unassigned - -# Restrict the permissions granted to the use of secrets.GITHUB_TOKEN in this -# github actions workflow: -# https://docs.github.com/en/actions/security-guides/automatic-token-authentication -permissions: - issues: write - -jobs: - one: - runs-on: ubuntu-latest - steps: - - name: - if: github.event.issue.state == 'open' - run: | - echo "Marking issue ${{ github.event.issue.number }} as help wanted" - gh issue edit $ISSUE --add-label "help wanted" - env: - GH_TOKEN: ${{ github.token }} - ISSUE: ${{ github.event.issue.html_url }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2a2ce57eaefb7..948065cddbc16 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,6 +5,11 @@ permissions: on: push: pull_request: + schedule: + # Nightly build at 02:30 UTC + - cron: "30 2 * * *" + # Manual run + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -15,6 +20,7 @@ env: TEST_DIR: ${{ github.workspace }}/tmp_folder CCACHE_DIR: ${{ github.workspace }}/ccache COVERAGE: 'true' + JUNITXML: 'test-data.xml' jobs: lint: @@ -24,7 +30,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.12' @@ -48,7 +54,7 @@ jobs: outputs: message: ${{ steps.git-log.outputs.message }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - id: git-log @@ -66,7 +72,7 @@ jobs: } >> "${GITHUB_OUTPUT}" retrieve-selected-tests: - # Parse the commit message to check if `build_tools/azure/test_script.sh` should run + # Parse the commit message to check if `build_tools/github/test_script.sh` should run # only specific tests. # # If so, selected tests will be run with SKLEARN_TESTS_GLOBAL_RANDOM_SEED="all". @@ -120,55 +126,332 @@ jobs: os: ubuntu-24.04-arm DISTRIB: conda LOCK_FILE: build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock + + - name: Linux x86-64 pylatest_conda_forge_mkl + os: ubuntu-22.04 + DISTRIB: conda + LOCK_FILE: build_tools/github/pylatest_conda_forge_mkl_linux-64_conda.lock + COVERAGE: true + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 42 # default global random seed + SCIPY_ARRAY_API: 1 + # Tests that require large downloads over the networks are skipped in CI. + # Here we make sure, that they are still run on a regular basis. + SKLEARN_SKIP_NETWORK_TESTS: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '0' || '1' }} + + # Check compilation with Ubuntu 22.04 LTS (Jammy Jellyfish) and scipy from conda-forge + - name: Linux x86-64 pymin_conda_forge_openblas_ubuntu_2204 + os: ubuntu-22.04 + DISTRIB: conda + LOCK_FILE: build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock + SKLEARN_WARNINGS_AS_ERRORS: 1 + COVERAGE: false + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 0 # non-default seed + + # Linux build with minimum supported version of dependencies + - name: Linux x86-64 pymin_conda_forge_openblas_min_dependencies + os: ubuntu-22.04 + DISTRIB: conda + LOCK_FILE: build_tools/github/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock + # Enable debug Cython directives to capture IndexError exceptions in + # combination with the -Werror::pytest.PytestUnraisableExceptionWarning + # flag for pytest. + # https://github.com/scikit-learn/scikit-learn/pull/24438 + SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: 1 + SKLEARN_RUN_FLOAT32_TESTS: 1 + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 2 # non-default seed + + # Linux environment to test the latest available dependencies. + # It runs tests requiring lightgbm, pandas and PyAMG. + - name: Linux pylatest_pip_openblas_pandas + os: ubuntu-24.04 + DISTRIB: conda + LOCK_FILE: build_tools/github/pylatest_pip_openblas_pandas_linux-64_conda.lock + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 3 # non-default seed + SCIPY_ARRAY_API: 1 + CHECK_PYTEST_SOFT_DEPENDENCY: true + SKLEARN_WARNINGS_AS_ERRORS: 1 + # disable pytest-xdist to have 1 job where OpenMP and BLAS are not single + # threaded because by default the tests configuration (sklearn/conftest.py) + # makes sure that they are single threaded in each xdist subprocess. + PYTEST_XDIST_VERSION: none + PIP_BUILD_ISOLATION: true + + # Linux environment to test that scikit-learn can be built against + # versions of numpy, scipy with ATLAS that comes with Ubuntu 24.04 + # Noble Numbat i.e. numpy 1.26.4 and scipy 1.11.4 + - name: Linux x86-64 ubuntu_atlas + os: ubuntu-24.04 + DISTRIB: ubuntu + LOCK_FILE: build_tools/github/ubuntu_atlas_lock.txt + COVERAGE: false + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 1 # non-default seed + - name: macOS pylatest_conda_forge_arm - os: macOS-15 + os: macos-15 DISTRIB: conda - LOCK_FILE: build_tools/azure/pylatest_conda_forge_osx-arm64_conda.lock + LOCK_FILE: build_tools/github/pylatest_conda_forge_osx-arm64_conda.lock SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 5 # non-default seed SCIPY_ARRAY_API: 1 PYTORCH_ENABLE_MPS_FALLBACK: 1 - CHECK_PYTEST_SOFT_DEPENDENCY: 'true' + CHECK_PYTEST_SOFT_DEPENDENCY: true + + - name: macOS x86-64 pylatest_conda_forge_mkl_no_openmp + os: macos-15-intel + DISTRIB: conda + LOCK_FILE: build_tools/github/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock + SKLEARN_TEST_NO_OPENMP: true + SKLEARN_SKIP_OPENMP_TEST: true + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 6 # non-default seed + + - name: Windows x64 pymin_conda_forge_openblas + os: windows-latest + DISTRIB: conda + LOCK_FILE: build_tools/github/pymin_conda_forge_openblas_win-64_conda.lock + SKLEARN_WARNINGS_AS_ERRORS: 1 + # The Windows runner is typically much slower than other CI runners + # due to the lack of compiler cache. Running the tests with coverage + # enabled makes them run extra slow. Since very few parts of the + # code should have windows-specific code branches, code coverage + # collection is only done for the non-windows runners. + COVERAGE: false + # Enable debug Cython directives to capture IndexError exceptions in + # combination with the -Werror::pytest.PytestUnraisableExceptionWarning + # flag for pytest. + # https://github.com/scikit-learn/scikit-learn/pull/24438 + SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: 1 + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 7 # non-default seed env: ${{ matrix }} - steps: + steps: &unit-tests-steps - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 + + # This step is necessary to access the job name the same way in both matrix and + # non-matrix jobs (like free-threaded or scipy-dev builds). + - name: Set JOB_NAME variable + shell: bash + run: | + if [[ -z "$JOB_NAME" ]]; then + echo "JOB_NAME=${{ matrix.name }}" >> $GITHUB_ENV + fi - name: Create cache for ccache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ env.CCACHE_DIR }} - key: ccache-v1-${{ matrix.name }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', matrix.LOCK_FILE)) }} - restore-keys: ccache-${{ matrix.name }} + key: ccache-v1-${{ env.JOB_NAME }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', env.LOCK_FILE)) }} + restore-keys: ccache-${{ env.JOB_NAME }} - name: Set up conda uses: conda-incubator/setup-miniconda@v3 + if: ${{ startsWith(env.DISTRIB, 'conda') }} with: miniforge-version: latest - auto-activate-base: true + auto-activate: true activate-environment: "" - name: Build scikit-learn - run: bash -l build_tools/azure/install.sh + run: bash -l build_tools/github/install.sh + + # Enable global random seed randomization to discover seed-sensitive tests + # only on nightly builds. + # https://scikit-learn.org/stable/computing/parallelism.html#sklearn-tests-global-random-seed + - name: Set random global random seed for nightly/manual runs + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + shell: bash + run: | + SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$((RANDOM % 20)) + echo "SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$SKLEARN_TESTS_GLOBAL_RANDOM_SEED" >> $GITHUB_ENV + echo "To reproduce this test run, set the following environment variable:" + echo " SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$SKLEARN_TESTS_GLOBAL_RANDOM_SEED" + echo "See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed" + + # Enable global dtype fixture for all nightly builds to discover + # numerical-sensitive tests. + # https://scikit-learn.org/stable/computing/parallelism.html#sklearn-run-float32-tests + - name: Run float32 tests for nightly/manual runs + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + shell: bash + run: | + echo SKLEARN_RUN_FLOAT32_TESTS=1 >> $GITHUB_ENV - name: Run tests env: COMMIT_MESSAGE: ${{ needs.retrieve-commit-message.outputs.message }} SELECTED_TESTS: ${{ needs.retrieve-selected-tests.outputs.tests }} COVERAGE: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} - run: bash -l build_tools/azure/test_script.sh + run: bash -l build_tools/github/test_script.sh + + - name: Run doctests in .py and .rst files + run: bash -l build_tools/github/test_docs.sh + if: ${{ needs.retrieve-selected-tests.outputs.tests == ''}} + + - name: Run pytest soft dependency test + run: bash -l build_tools/github/test_pytest_soft_dependency.sh + if: ${{ env.CHECK_PYTEST_SOFT_DEPENDENCY == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} + + - name: Combine coverage reports from parallel test runners + run: bash -l build_tools/github/combine_coverage_reports.sh + if: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} + + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v5 + if: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} + with: + files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + disable_search: true + + - name: Update tracking issue + if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')}} + shell: bash + run: | + set -ex + + pip install defusedxml PyGithub + python maint_tools/update_tracking_issue.py \ + ${{ secrets.BOT_GITHUB_TOKEN }} \ + "$GITHUB_WORKFLOW $JOB_NAME" \ + "$GITHUB_REPOSITORY" \ + https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID \ + --junit-file $TEST_DIR/$JUNITXML \ + --auto-close false \ + --job-name "$JOB_NAME" + + free-threaded: + name: &free-threaded-job-name + Linux x86-64 pylatest_free_threaded + runs-on: ubuntu-latest + needs: [lint, retrieve-commit-message, retrieve-selected-tests] + if: contains(needs.retrieve-commit-message.outputs.message, '[free-threaded]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + env: + DISTRIB: conda-free-threaded + LOCK_FILE: build_tools/github/pylatest_free_threaded_linux-64_conda.lock + COVERAGE: false + # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel + PYTEST_XDIST_VERSION: none + # To be able to access the job name in the steps, it must be set as an env variable. + JOB_NAME: *free-threaded-job-name + steps: *unit-tests-steps + + scipy-dev: + name: &scipy-dev-job-name + Linux x86-64 pylatest_pip_scipy_dev + runs-on: ubuntu-22.04 + needs: [lint, retrieve-commit-message, retrieve-selected-tests] + if: contains(needs.retrieve-commit-message.outputs.message, '[scipy-dev]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + env: + DISTRIB: conda-pip-scipy-dev + LOCK_FILE: build_tools/github/pylatest_pip_scipy_dev_linux-64_conda.lock + SKLEARN_WARNINGS_AS_ERRORS: 1 + CHECK_PYTEST_SOFT_DEPENDENCY: true + # To be able to access the job name in the steps, it must be set as an env variable. + JOB_NAME: *scipy-dev-job-name + steps: *unit-tests-steps + + debian-32bit: + name: &debian-32bit-job-name + Linux i386 debian_32bit + runs-on: ubuntu-24.04 + needs: [lint, retrieve-commit-message, retrieve-selected-tests] + env: + DISTRIB: debian-32 + LOCK_FILE: build_tools/github/debian_32bit_lock.txt + SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 4 # non-default seed + DOCKER_CONTAINER: i386/debian:trixie + # To be able to access the job name in the steps, it must be set as an env variable. + JOB_NAME: *debian-32bit-job-name + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create cache for ccache + uses: actions/cache@v5 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-v1-${{ env.JOB_NAME }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', env.LOCK_FILE)) }} + restore-keys: ccache-${{ env.JOB_NAME }} + + - name: Set up conda + uses: conda-incubator/setup-miniconda@v3 + if: ${{ startsWith(env.DISTRIB, 'conda') }} + with: + miniforge-version: latest + auto-activate: true + activate-environment: "" + + # Enable global random seed randomization to discover seed-sensitive tests + # only on nightly builds. + # https://scikit-learn.org/stable/computing/parallelism.html#sklearn-tests-global-random-seed + - name: Set random global random seed for nightly/manual runs + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + shell: bash + run: | + SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$((RANDOM % 20)) + echo "SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$SKLEARN_TESTS_GLOBAL_RANDOM_SEED" >> $GITHUB_ENV + echo "To reproduce this test run, set the following environment variable:" + echo " SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$SKLEARN_TESTS_GLOBAL_RANDOM_SEED" + echo "See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed" + + # Enable global dtype fixture for all nightly builds to discover + # numerical-sensitive tests. + # https://scikit-learn.org/stable/computing/parallelism.html#sklearn-run-float32-tests + - name: Run float32 tests for nightly/manual runs + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + shell: bash + run: | + echo SKLEARN_RUN_FLOAT32_TESTS=1 >> $GITHUB_ENV + + - name: Start container + # Environment variable are passed when starting the container rather + # than in "Run tests step" for more standard jobs + env: + COMMIT_MESSAGE: ${{ needs.retrieve-commit-message.outputs.message }} + SELECTED_TESTS: ${{ needs.retrieve-selected-tests.outputs.tests }} + COVERAGE: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} + run: > + docker container run --rm + --volume $TEST_DIR:/temp_dir + --volume $PWD:/scikit-learn + --volume $CCACHE_DIR:/ccache + -w /scikit-learn + --detach + --name skcontainer + -e TEST_DIR=/temp_dir + -e CCACHE_DIR=/ccache + -e COVERAGE + -e DISTRIB + -e LOCK_FILE + -e JUNITXML + -e VIRTUALENV + -e PYTEST_XDIST_VERSION + -e SKLEARN_SKIP_NETWORK_TESTS + -e SELECTED_TESTS + -e CCACHE_COMPRESS + -e COMMIT_MESSAGE + -e JOB_NAME + -e SKLEARN_TESTS_GLOBAL_RANDOM_SEED + -e SKLEARN_RUN_FLOAT32_TESTS + $DOCKER_CONTAINER + sleep 1000000 + + - name: Build scikit-learn + run: docker exec skcontainer bash -l build_tools/github/install.sh + + - name: Run tests + run: docker exec skcontainer bash -l build_tools/github/test_script.sh - name: Run doctests in .py and .rst files - run: bash -l build_tools/azure/test_docs.sh + run: docker exec skcontainer bash -l build_tools/github/test_docs.sh if: ${{ needs.retrieve-selected-tests.outputs.tests == ''}} - name: Run pytest soft dependency test - run: bash -l build_tools/azure/test_pytest_soft_dependency.sh + run: docker exec skcontainer build_tools/github/test_pytest_soft_dependency.sh if: ${{ env.CHECK_PYTEST_SOFT_DEPENDENCY == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} - name: Combine coverage reports from parallel test runners - run: bash -l build_tools/azure/combine_coverage_reports.sh + run: docker exec skcontainer bash -l build_tools/github/combine_coverage_reports.sh if: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}} - name: Upload coverage report to Codecov @@ -178,3 +461,31 @@ jobs: files: ./coverage.xml token: ${{ secrets.CODECOV_TOKEN }} disable_search: true + + - name: Update tracking issue + if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')}} + run: | + set -ex + + pip install defusedxml PyGithub + python maint_tools/update_tracking_issue.py \ + ${{ secrets.BOT_GITHUB_TOKEN }} \ + "$GITHUB_WORKFLOW $JOB_NAME" \ + "$GITHUB_REPOSITORY" \ + https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID \ + --junit-file $TEST_DIR/$JUNITXML \ + --auto-close false \ + --job-name "$JOB_NAME" + + # This aggregates all unit test jobs statuses into a single status. This is + # useful to be able to have a single required check in GitHub. + check-job-statuses: + name: Check all job statuses + runs-on: ubuntu-latest + if: always() && github.repository == 'scikit-learn/scikit-learn' + needs: [lint, unit-tests, debian-32bit, free-threaded, scipy-dev] + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + allowed-skips: free-threaded,scipy-dev diff --git a/.github/workflows/update-lock-files.yml b/.github/workflows/update-lock-files.yml index b6e916851f586..c11d7a03a52f8 100644 --- a/.github/workflows/update-lock-files.yml +++ b/.github/workflows/update-lock-files.yml @@ -31,7 +31,7 @@ jobs: update_script_args: "--select-tag cuda" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Generate lock files run: | source build_tools/shared.sh @@ -45,7 +45,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.BOT_GITHUB_TOKEN }} push-to-fork: scikit-learn-bot/scikit-learn diff --git a/.github/workflows/update_tracking_issue.yml b/.github/workflows/update_tracking_issue.yml index 00db4f4493cbd..207446143a278 100644 --- a/.github/workflows/update_tracking_issue.yml +++ b/.github/workflows/update_tracking_issue.yml @@ -27,9 +27,9 @@ on: jobs: update_tracking_issue: runs-on: ubuntu-latest - if: github.repository == 'scikit-learn/scikit-learn' && github.event_name == 'schedule' + if: github.repository == 'scikit-learn/scikit-learn' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.9' diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index db0bc4da3f2cb..4fdd7426d9371 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} @@ -69,10 +69,6 @@ jobs: - os: windows-latest python: 313 platform_id: win_amd64 - - os: windows-latest - python: 313t - platform_id: win_amd64 - cibw_enable: cpython-freethreading - os: windows-latest python: 314 platform_id: win_amd64 @@ -90,10 +86,6 @@ jobs: - os: windows-11-arm python: 313 platform_id: win_arm64 - - os: windows-11-arm - python: 313t - platform_id: win_arm64 - cibw_enable: cpython-freethreading - os: windows-11-arm python: 314 platform_id: win_arm64 @@ -114,11 +106,6 @@ jobs: python: 313 platform_id: manylinux_x86_64 manylinux_image: manylinux_2_28 - - os: ubuntu-latest - python: 313t - platform_id: manylinux_x86_64 - manylinux_image: manylinux_2_28 - cibw_enable: cpython-freethreading - os: ubuntu-latest python: 314 platform_id: manylinux_x86_64 @@ -141,11 +128,6 @@ jobs: python: 313 platform_id: manylinux_aarch64 manylinux_image: manylinux_2_28 - - os: ubuntu-24.04-arm - python: 313t - platform_id: manylinux_aarch64 - manylinux_image: manylinux_2_28 - cibw_enable: cpython-freethreading - os: ubuntu-24.04-arm python: 314 platform_id: manylinux_aarch64 @@ -165,10 +147,6 @@ jobs: - os: macos-15-intel python: 313 platform_id: macosx_x86_64 - - os: macos-15-intel - python: 313t - platform_id: macosx_x86_64 - cibw_enable: cpython-freethreading - os: macos-15-intel python: 314 platform_id: macosx_x86_64 @@ -186,10 +164,6 @@ jobs: - os: macos-14 python: 313 platform_id: macosx_arm64 - - os: macos-14 - python: 313t - platform_id: macosx_arm64 - cibw_enable: cpython-freethreading - os: macos-14 python: 314 platform_id: macosx_arm64 @@ -199,7 +173,7 @@ jobs: steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 @@ -213,7 +187,6 @@ jobs: - name: Build and test wheels env: - CIBW_ENABLE: ${{ matrix.cibw_enable }} CIBW_ENVIRONMENT: SKLEARN_SKIP_NETWORK_TESTS=1 CIBW_BUILD: cp${{ matrix.python }}-${{ matrix.platform_id }} CIBW_ARCHS: all @@ -228,10 +201,7 @@ jobs: CIBW_BEFORE_TEST_WINDOWS: bash build_tools/github/build_minimal_windows_image.sh ${{ matrix.python }} ${{matrix.platform_id}} CIBW_ENVIRONMENT_PASS_LINUX: RUNNER_OS # TODO Put back pandas when there is a pandas release with Python 3.14 wheels - # TODO Remove scipy<1.16.2 when hang on macOS_x86_64 has been fixed. - # See https://github.com/scikit-learn/scikit-learn/issues/32279 for - # more details. - CIBW_TEST_REQUIRES: ${{ contains(matrix.python, '314') && 'pytest' || 'pytest pandas' }} scipy<1.16.2 + CIBW_TEST_REQUIRES: ${{ contains(matrix.python, '314') && 'pytest' || 'pytest pandas' }} scipy # On Windows, we use a custom Docker image and CIBW_TEST_REQUIRES_WINDOWS # does not make sense because it would install dependencies in the host # rather than inside the Docker image @@ -243,7 +213,7 @@ jobs: run: bash build_tools/wheels/build_wheels.sh - name: Store artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: cibw-wheels-cp${{ matrix.python }}-${{ matrix.platform_id }} path: wheelhouse/*.whl @@ -266,7 +236,7 @@ jobs: steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 @@ -282,7 +252,7 @@ jobs: SKLEARN_SKIP_NETWORK_TESTS: 1 - name: Store artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: cibw-sdist path: dist/*.tar.gz @@ -298,10 +268,10 @@ jobs: steps: - name: Checkout scikit-learn - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Download artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: pattern: cibw-* path: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8bdb3e9eefd36..4c9be22b6a660 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,8 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit + # WARNING if you update ruff version here, remember to update + # sklearn/_min_dependencies.py and doc .rst files mentioning ruff== rev: v0.12.2 hooks: - id: ruff-check diff --git a/AGENTS.md b/AGENTS.md index 79d71164c33ec..76f23d4b35587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS Instruction -This file contains is additional guidance for AI agents and other AI editors. +This file contains additional guidance for AI agents and other AI editors. ## **REQUIRED: AI/Agent Disclosure** diff --git a/COPYING b/COPYING index e1cd01d584578..3d7ee432c15b6 100644 --- a/COPYING +++ b/COPYING @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2007-2024 The scikit-learn developers. +Copyright (c) 2007-2026 The scikit-learn developers. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index cd93589a64448..d88ab46cd6aff 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,10 @@ .. -*- mode: rst -*- -|Azure| |Codecov| |CircleCI| |Nightly wheels| |Ruff| |PythonVersion| |PyPI| |DOI| |Benchmark| +|GitHubActions| |Codecov| |CircleCI| |Nightly wheels| |Ruff| |PythonVersion| |PyPI| |DOI| |Benchmark| -.. |Azure| image:: https://dev.azure.com/scikit-learn/scikit-learn/_apis/build/status/scikit-learn.scikit-learn?branchName=main - :target: https://dev.azure.com/scikit-learn/scikit-learn/_build/latest?definitionId=1&branchName=main + +.. |GitHubActions| image:: https://github.com/scikit-learn/scikit-learn/actions/workflows/unit-tests.yml/badge.svg? + :target: https://github.com/scikit-learn/scikit-learn/actions/workflows/unit-tests.yml?query=branch%3Amain .. |CircleCI| image:: https://circleci.com/gh/scikit-learn/scikit-learn/tree/main.svg?style=shield :target: https://circleci.com/gh/scikit-learn/scikit-learn @@ -14,16 +15,16 @@ .. |Nightly wheels| image:: https://github.com/scikit-learn/scikit-learn/actions/workflows/wheels.yml/badge.svg?event=schedule :target: https://github.com/scikit-learn/scikit-learn/actions?query=workflow%3A%22Wheel+builder%22+event%3Aschedule -.. |Ruff| image:: https://img.shields.io/badge/code%20style-ruff-000000.svg +.. |Ruff| image:: https://img.shields.io/badge/code%20style-ruff-000000.svg? :target: https://github.com/astral-sh/ruff -.. |PythonVersion| image:: https://img.shields.io/pypi/pyversions/scikit-learn.svg +.. |PythonVersion| image:: https://img.shields.io/pypi/pyversions/scikit-learn.svg? :target: https://pypi.org/project/scikit-learn/ .. |PyPI| image:: https://img.shields.io/pypi/v/scikit-learn :target: https://pypi.org/project/scikit-learn -.. |DOI| image:: https://zenodo.org/badge/21369/scikit-learn/scikit-learn.svg +.. |DOI| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.17880109.svg? :target: https://zenodo.org/badge/latestdoi/21369/scikit-learn/scikit-learn .. |Benchmark| image:: https://img.shields.io/badge/Benchmarked%20by-asv-blue @@ -33,6 +34,7 @@ .. |NumPyMinVersion| replace:: 1.24.1 .. |SciPyMinVersion| replace:: 1.10.0 .. |JoblibMinVersion| replace:: 1.3.0 +.. |NarwhalsMinVersion| replace:: 2.0.1 .. |ThreadpoolctlMinVersion| replace:: 3.2.0 .. |MatplotlibMinVersion| replace:: 3.6.1 .. |Scikit-ImageMinVersion| replace:: 0.22.0 @@ -67,6 +69,7 @@ scikit-learn requires: - Python (>= |PythonMinVersion|) - NumPy (>= |NumPyMinVersion|) - SciPy (>= |SciPyMinVersion|) +- Narwhals (>= |NarwhalsMinVersion|) - joblib (>= |JoblibMinVersion|) - threadpoolctl (>= |ThreadpoolctlMinVersion|) diff --git a/SECURITY.md b/SECURITY.md index 9760e345b3e47..961e8e2e195c4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------------- | ------------------ | -| 1.7.2 | :white_check_mark: | -| < 1.7.2 | :x: | +| 1.8.0 | :white_check_mark: | +| < 1.8.0 | :x: | ## Reporting a Vulnerability diff --git a/asv_benchmarks/asv.conf.json b/asv_benchmarks/asv.conf.json index 8da45b58b27bc..00aee64ce0d75 100644 --- a/asv_benchmarks/asv.conf.json +++ b/asv_benchmarks/asv.conf.json @@ -70,6 +70,7 @@ "scipy": ["1.14.0"], "cython": ["3.1.2"], "joblib": ["1.3.2"], + "narwhals": ["2.0.1"], "threadpoolctl": ["3.2.0"], "pandas": ["2.2.2"] }, diff --git a/asv_benchmarks/benchmarks/config.json b/asv_benchmarks/benchmarks/config.json index b5a10b930e60b..49f74865546f1 100644 --- a/asv_benchmarks/benchmarks/config.json +++ b/asv_benchmarks/benchmarks/config.json @@ -1,5 +1,5 @@ { - // "regular": Bencharks are run on small to medium datasets. Each benchmark + // "regular": Benchmarks are run on small to medium datasets. Each benchmark // is run multiple times and averaged. // "fast": Benchmarks are run on small to medium datasets. Each benchmark // is run only once. May provide unstable benchmarks. diff --git a/asv_benchmarks/benchmarks/datasets.py b/asv_benchmarks/benchmarks/datasets.py index bbf5029062448..3bbfd143ba51b 100644 --- a/asv_benchmarks/benchmarks/datasets.py +++ b/asv_benchmarks/benchmarks/datasets.py @@ -1,7 +1,6 @@ from pathlib import Path import numpy as np -import scipy.sparse as sp from joblib import Memory from sklearn.datasets import ( @@ -17,6 +16,7 @@ from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.preprocessing import MaxAbsScaler, StandardScaler +from sklearn.utils.fixes import _sparse_random_array # memory location for caching datasets M = Memory(location=str(Path(__file__).resolve().parent / "cache")) @@ -100,12 +100,12 @@ def _synth_regression_dataset(n_samples=100000, n_features=100, dtype=np.float32 def _synth_regression_sparse_dataset( n_samples=10000, n_features=10000, density=0.01, dtype=np.float32 ): - X = sp.random( - m=n_samples, n=n_features, density=density, format="csr", random_state=0 + X = _sparse_random_array( + (n_samples, n_features), density=density, format="csr", random_state=0 ) X.data = np.random.RandomState(0).randn(X.getnnz()) X = X.astype(dtype, copy=False) - coefs = sp.random(m=n_features, n=1, density=0.5, random_state=0) + coefs = _sparse_random_array((n_features, 1), density=0.5, random_state=0) coefs.data = np.random.RandomState(0).randn(coefs.getnnz()) y = X.dot(coefs.toarray()).reshape(-1) y += 0.2 * y.std() * np.random.randn(n_samples) @@ -155,9 +155,8 @@ def _random_dataset( X = np.random.RandomState(0).random_sample((n_samples, n_features)) X = X.astype(dtype, copy=False) else: - X = sp.random( - n_samples, - n_features, + X = _sparse_random_array( + (n_samples, n_features), density=0.05, format="csr", dtype=dtype, diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index eca3683253ff7..0000000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,266 +0,0 @@ -# Adapted from https://github.com/pandas-dev/pandas/blob/master/azure-pipelines.yml -schedules: -- cron: "30 2 * * *" - displayName: Run nightly build - branches: - include: - - main - always: true - -jobs: -- job: git_commit - displayName: Get Git Commit - pool: - vmImage: ubuntu-24.04 - steps: - - bash: python build_tools/azure/get_commit_message.py - name: commit - displayName: Get source version message - -- job: linting - dependsOn: [git_commit] - condition: | - and( - succeeded(), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[lint skip]')), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - displayName: Linting - pool: - vmImage: ubuntu-24.04 - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.12' - - bash: | - source build_tools/shared.sh - # Include pytest compatibility with mypy - pip install pytest $(get_dep ruff min) $(get_dep mypy min) cython-lint - displayName: Install linters - - bash: | - ./build_tools/linting.sh - displayName: Run linters - - bash: | - pip install ninja meson scipy - python build_tools/check-meson-openmp-dependencies.py - displayName: Run Meson OpenMP checks - - -- template: build_tools/azure/posix.yml - parameters: - name: Linux_Nightly - vmImage: ubuntu-22.04 - dependsOn: [git_commit, linting] - condition: | - and( - succeeded(), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')), - or(eq(variables['Build.Reason'], 'Schedule'), - contains(dependencies['git_commit']['outputs']['commit.message'], '[scipy-dev]' - ) - ) - ) - matrix: - pylatest_pip_scipy_dev: - DISTRIB: 'conda-pip-scipy-dev' - LOCK_FILE: './build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock' - SKLEARN_WARNINGS_AS_ERRORS: '1' - CHECK_PYTEST_SOFT_DEPENDENCY: 'true' - -- template: build_tools/azure/posix.yml - # CPython free-threaded build - parameters: - name: Linux_free_threaded - vmImage: ubuntu-22.04 - dependsOn: [git_commit, linting] - condition: | - and( - succeeded(), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')), - or(eq(variables['Build.Reason'], 'Schedule'), - contains(dependencies['git_commit']['outputs']['commit.message'], '[free-threaded]' - ) - ) - ) - matrix: - pylatest_free_threaded: - DISTRIB: 'conda-free-threaded' - LOCK_FILE: './build_tools/azure/pylatest_free_threaded_linux-64_conda.lock' - COVERAGE: 'false' - # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel - PYTEST_XDIST_VERSION: 'none' - SKLEARN_FAULTHANDLER_TIMEOUT: '1800' # 30 * 60 seconds - -# Will run all the time regardless of linting outcome. -- template: build_tools/azure/posix.yml - parameters: - name: Linux_Runs - vmImage: ubuntu-22.04 - dependsOn: [git_commit] - condition: | - and( - succeeded(), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - pylatest_conda_forge_mkl: - DISTRIB: 'conda' - LOCK_FILE: './build_tools/azure/pylatest_conda_forge_mkl_linux-64_conda.lock' - COVERAGE: 'true' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '42' # default global random seed - # Tests that require large downloads over the networks are skipped in CI. - # Here we make sure, that they are still run on a regular basis. - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: - SKLEARN_SKIP_NETWORK_TESTS: '0' - SCIPY_ARRAY_API: '1' - -# Check compilation with Ubuntu 22.04 LTS (Jammy Jellyfish) and scipy from conda-forge -# By default the CI is sequential, where `Ubuntu_Jammy_Jellyfish` runs first and -# the others jobs are run only if `Ubuntu_Jammy_Jellyfish` succeeds. -# When "[azure parallel]" is in the commit message, `Ubuntu_Jammy_Jellyfish` will -# run in parallel with the rest of the jobs. On Azure, the job's name will be -# `Ubuntu_Jammy_Jellyfish_Parallel`. -- template: build_tools/azure/posix-all-parallel.yml - parameters: - name: Ubuntu_Jammy_Jellyfish - vmImage: ubuntu-22.04 - dependsOn: [git_commit, linting] - condition: | - and( - succeeded(), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - commitMessage: dependencies['git_commit']['outputs']['commit.message'] - matrix: - pymin_conda_forge_openblas_ubuntu_2204: - DISTRIB: 'conda' - LOCK_FILE: './build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock' - SKLEARN_WARNINGS_AS_ERRORS: '1' - COVERAGE: 'false' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '0' # non-default seed - -- template: build_tools/azure/posix.yml - parameters: - name: Ubuntu_Atlas - vmImage: ubuntu-24.04 - dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish] - # Runs when dependencies succeeded or skipped - condition: | - and( - not(or(failed(), canceled())), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - # Linux environment to test that scikit-learn can be built against - # versions of numpy, scipy with ATLAS that comes with Ubuntu 24.04 Noble Numbat - # i.e. numpy 1.26.4 and scipy 1.11.4 - ubuntu_atlas: - DISTRIB: 'ubuntu' - LOCK_FILE: './build_tools/azure/ubuntu_atlas_lock.txt' - COVERAGE: 'false' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '1' # non-default seed - -- template: build_tools/azure/posix.yml - parameters: - name: Linux - vmImage: ubuntu-22.04 - dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish] - # Runs when dependencies succeeded or skipped - condition: | - and( - not(or(failed(), canceled())), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - # Linux build with minimum supported version of dependencies - pymin_conda_forge_openblas_min_dependencies: - DISTRIB: 'conda' - LOCK_FILE: './build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock' - # Enable debug Cython directives to capture IndexError exceptions in - # combination with the -Werror::pytest.PytestUnraisableExceptionWarning - # flag for pytest. - # https://github.com/scikit-learn/scikit-learn/pull/24438 - SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: '1' - SKLEARN_RUN_FLOAT32_TESTS: '1' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '2' # non-default seed - # Linux environment to test the latest available dependencies. - # It runs tests requiring lightgbm, pandas and PyAMG. - pylatest_pip_openblas_pandas: - DISTRIB: 'conda-pip-latest' - LOCK_FILE: './build_tools/azure/pylatest_pip_openblas_pandas_linux-64_conda.lock' - CHECK_PYTEST_SOFT_DEPENDENCY: 'true' - SKLEARN_WARNINGS_AS_ERRORS: '1' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '3' # non-default seed - # disable pytest-xdist to have 1 job where OpenMP and BLAS are not single - # threaded because by default the tests configuration (sklearn/conftest.py) - # makes sure that they are single threaded in each xdist subprocess. - PYTEST_XDIST_VERSION: 'none' - PIP_BUILD_ISOLATION: 'true' - SCIPY_ARRAY_API: '1' - -- template: build_tools/azure/posix-docker.yml - parameters: - name: Linux_Docker - vmImage: ubuntu-24.04 - dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish] - # Runs when dependencies succeeded or skipped - condition: | - and( - not(or(failed(), canceled())), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - debian_32bit: - DOCKER_CONTAINER: 'i386/debian:trixie' - DISTRIB: 'debian-32' - COVERAGE: "true" - LOCK_FILE: './build_tools/azure/debian_32bit_lock.txt' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '4' # non-default seed - -- template: build_tools/azure/posix.yml - parameters: - name: macOS - vmImage: macOS-15 - dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish] - # Runs when dependencies succeeded or skipped - condition: | - and( - not(or(failed(), canceled())), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - pylatest_conda_forge_mkl_no_openmp: - DISTRIB: 'conda' - LOCK_FILE: './build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock' - SKLEARN_TEST_NO_OPENMP: 'true' - SKLEARN_SKIP_OPENMP_TEST: 'true' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '6' # non-default seed - -- template: build_tools/azure/windows.yml - parameters: - name: Windows - vmImage: windows-latest - dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish] - # Runs when dependencies succeeded or skipped - condition: | - and( - not(or(failed(), canceled())), - not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')) - ) - matrix: - pymin_conda_forge_openblas: - DISTRIB: 'conda' - LOCK_FILE: ./build_tools/azure/pymin_conda_forge_openblas_win-64_conda.lock - SKLEARN_WARNINGS_AS_ERRORS: '1' - # The Azure Windows runner is typically much slower than other CI - # runners due to the lack of compiler cache. Running the tests with - # coverage enabled make them run extra slower. Since very few parts of - # code should have windows-specific code branches, it should be enable - # to restrict the code coverage collection to the non-windows runners. - COVERAGE: 'false' - # Enable debug Cython directives to capture IndexError exceptions in - # combination with the -Werror::pytest.PytestUnraisableExceptionWarning - # flag for pytest. - # https://github.com/scikit-learn/scikit-learn/pull/24438 - SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: '1' - SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '7' # non-default seed diff --git a/benchmarks/bench_feature_expansions.py b/benchmarks/bench_feature_expansions.py index b9d9efbdea4f1..e3d972c891233 100644 --- a/benchmarks/bench_feature_expansions.py +++ b/benchmarks/bench_feature_expansions.py @@ -2,9 +2,9 @@ import matplotlib.pyplot as plt import numpy as np -import scipy.sparse as sparse from sklearn.preprocessing import PolynomialFeatures +from sklearn.utils.fixes import _sparse_random_array degree = 2 trials = 3 @@ -21,7 +21,7 @@ for density in densities: for dim_index, dim in enumerate(dimensionalities): print(trial, density, dim) - X_csr = sparse.random(num_rows, dim, density).tocsr() + X_csr = _sparse_random_array((num_rows, dim), density=density, format="csr") X_dense = X_csr.toarray() # CSR t0 = time() diff --git a/benchmarks/bench_kernel_pca_solvers_time_vs_n_samples.py b/benchmarks/bench_kernel_pca_solvers_time_vs_n_samples.py index cae74c6f442ff..2c59e795208bf 100644 --- a/benchmarks/bench_kernel_pca_solvers_time_vs_n_samples.py +++ b/benchmarks/bench_kernel_pca_solvers_time_vs_n_samples.py @@ -38,7 +38,8 @@ of examples is fixed, and the desired number of components varies. """ -# Author: Sylvain MARIE, Schneider Electric +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause import time diff --git a/benchmarks/bench_multilabel_metrics.py b/benchmarks/bench_multilabel_metrics.py deleted file mode 100755 index 1b8449a24da51..0000000000000 --- a/benchmarks/bench_multilabel_metrics.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env python -""" -A comparison of multilabel target formats and metrics over them -""" - -import argparse -import itertools -import sys -from functools import partial -from timeit import timeit - -import matplotlib.pyplot as plt -import numpy as np -import scipy.sparse as sp - -from sklearn.datasets import make_multilabel_classification -from sklearn.metrics import ( - accuracy_score, - f1_score, - hamming_loss, - jaccard_similarity_score, -) -from sklearn.utils._testing import ignore_warnings - -METRICS = { - "f1": partial(f1_score, average="micro"), - "f1-by-sample": partial(f1_score, average="samples"), - "accuracy": accuracy_score, - "hamming": hamming_loss, - "jaccard": jaccard_similarity_score, -} - -FORMATS = { - "sequences": lambda y: [list(np.flatnonzero(s)) for s in y], - "dense": lambda y: y, - "csr": sp.csr_matrix, - "csc": sp.csc_matrix, -} - - -@ignore_warnings -def benchmark( - metrics=tuple(v for k, v in sorted(METRICS.items())), - formats=tuple(v for k, v in sorted(FORMATS.items())), - samples=1000, - classes=4, - density=0.2, - n_times=5, -): - """Times metric calculations for a number of inputs - - Parameters - ---------- - metrics : array-like of callables (1d or 0d) - The metric functions to time. - - formats : array-like of callables (1d or 0d) - These may transform a dense indicator matrix into multilabel - representation. - - samples : array-like of ints (1d or 0d) - The number of samples to generate as input. - - classes : array-like of ints (1d or 0d) - The number of classes in the input. - - density : array-like of ints (1d or 0d) - The density of positive labels in the input. - - n_times : int - Time calling the metric n_times times. - - Returns - ------- - array of floats shaped like (metrics, formats, samples, classes, density) - Time in seconds. - """ - metrics = np.atleast_1d(metrics) - samples = np.atleast_1d(samples) - classes = np.atleast_1d(classes) - density = np.atleast_1d(density) - formats = np.atleast_1d(formats) - out = np.zeros( - (len(metrics), len(formats), len(samples), len(classes), len(density)), - dtype=float, - ) - it = itertools.product(samples, classes, density) - for i, (s, c, d) in enumerate(it): - _, y_true = make_multilabel_classification( - n_samples=s, n_features=1, n_classes=c, n_labels=d * c, random_state=42 - ) - _, y_pred = make_multilabel_classification( - n_samples=s, n_features=1, n_classes=c, n_labels=d * c, random_state=84 - ) - for j, f in enumerate(formats): - f_true = f(y_true) - f_pred = f(y_pred) - for k, metric in enumerate(metrics): - t = timeit(partial(metric, f_true, f_pred), number=n_times) - - out[k, j].flat[i] = t - return out - - -def _tabulate(results, metrics, formats): - """Prints results by metric and format - - Uses the last ([-1]) value of other fields - """ - column_width = max(max(len(k) for k in formats) + 1, 8) - first_width = max(len(k) for k in metrics) - head_fmt = "{:<{fw}s}" + "{:>{cw}s}" * len(formats) - row_fmt = "{:<{fw}s}" + "{:>{cw}.3f}" * len(formats) - print(head_fmt.format("Metric", *formats, cw=column_width, fw=first_width)) - for metric, row in zip(metrics, results[:, :, -1, -1, -1]): - print(row_fmt.format(metric, *row, cw=column_width, fw=first_width)) - - -def _plot( - results, - metrics, - formats, - title, - x_ticks, - x_label, - format_markers=("x", "|", "o", "+"), - metric_colors=("c", "m", "y", "k", "g", "r", "b"), -): - """ - Plot the results by metric, format and some other variable given by - x_label - """ - fig = plt.figure("scikit-learn multilabel metrics benchmarks") - plt.title(title) - ax = fig.add_subplot(111) - for i, metric in enumerate(metrics): - for j, format in enumerate(formats): - ax.plot( - x_ticks, - results[i, j].flat, - label="{}, {}".format(metric, format), - marker=format_markers[j], - color=metric_colors[i % len(metric_colors)], - ) - ax.set_xlabel(x_label) - ax.set_ylabel("Time (s)") - ax.legend() - plt.show() - - -if __name__ == "__main__": - ap = argparse.ArgumentParser() - ap.add_argument( - "metrics", - nargs="*", - default=sorted(METRICS), - help="Specifies metrics to benchmark, defaults to all. Choices are: {}".format( - sorted(METRICS) - ), - ) - ap.add_argument( - "--formats", - nargs="+", - choices=sorted(FORMATS), - help="Specifies multilabel formats to benchmark (defaults to all).", - ) - ap.add_argument( - "--samples", type=int, default=1000, help="The number of samples to generate" - ) - ap.add_argument("--classes", type=int, default=10, help="The number of classes") - ap.add_argument( - "--density", - type=float, - default=0.2, - help="The average density of labels per sample", - ) - ap.add_argument( - "--plot", - choices=["classes", "density", "samples"], - default=None, - help=( - "Plot time with respect to this parameter varying up to the specified value" - ), - ) - ap.add_argument( - "--n-steps", default=10, type=int, help="Plot this many points for each metric" - ) - ap.add_argument( - "--n-times", default=5, type=int, help="Time performance over n_times trials" - ) - args = ap.parse_args() - - if args.plot is not None: - max_val = getattr(args, args.plot) - if args.plot in ("classes", "samples"): - min_val = 2 - else: - min_val = 0 - steps = np.linspace(min_val, max_val, num=args.n_steps + 1)[1:] - if args.plot in ("classes", "samples"): - steps = np.unique(np.round(steps).astype(int)) - setattr(args, args.plot, steps) - - if args.metrics is None: - args.metrics = sorted(METRICS) - if args.formats is None: - args.formats = sorted(FORMATS) - - results = benchmark( - [METRICS[k] for k in args.metrics], - [FORMATS[k] for k in args.formats], - args.samples, - args.classes, - args.density, - args.n_times, - ) - - _tabulate(results, args.metrics, args.formats) - - if args.plot is not None: - print("Displaying plot", file=sys.stderr) - title = "Multilabel metrics with %s" % ", ".join( - "{0}={1}".format(field, getattr(args, field)) - for field in ["samples", "classes", "density"] - if args.plot != field - ) - _plot(results, args.metrics, args.formats, title, steps, args.plot) diff --git a/benchmarks/bench_plot_randomized_svd.py b/benchmarks/bench_plot_randomized_svd.py index e955be64cdee3..7e5f4cdf01466 100644 --- a/benchmarks/bench_plot_randomized_svd.py +++ b/benchmarks/bench_plot_randomized_svd.py @@ -188,7 +188,7 @@ def get_data(dataset_name): data = np.repeat(data, 10) row = np.random.uniform(0, small_size, sparsity) col = np.random.uniform(0, small_size, sparsity) - X = sp.sparse.csr_matrix((data, (row, col)), shape=(size, small_size)) + X = sp.sparse.csr_array((data, (row, col)), shape=(size, small_size)) del data del row del col diff --git a/benchmarks/bench_random_projections.py b/benchmarks/bench_random_projections.py index 6551de690994b..8ec4b264be76c 100644 --- a/benchmarks/bench_random_projections.py +++ b/benchmarks/bench_random_projections.py @@ -70,7 +70,7 @@ def bench_scikit_transformer(X, transformer): # Gaussian distributed values def make_sparse_random_data(n_samples, n_features, n_nonzeros, random_state=None): rng = np.random.RandomState(random_state) - data_coo = sp.coo_matrix( + data_coo = sp.coo_array( ( rng.randn(n_nonzeros), ( diff --git a/benchmarks/bench_saga.py b/benchmarks/bench_saga.py index e376b481b5a94..f815d7d9835cd 100644 --- a/benchmarks/bench_saga.py +++ b/benchmarks/bench_saga.py @@ -1,9 +1,11 @@ -"""Author: Arthur Mensch, Nelle Varoquaux - +""" Benchmarks of sklearn SAGA vs lightning SAGA vs Liblinear. Shows the gain in using multinomial logistic regression in term of learning time. """ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + import json import os import time @@ -118,15 +120,15 @@ def fit_single( scores = [] for X, y in [(X_train, y_train), (X_test, y_test)]: try: - y_pred = lr.predict_proba(X) + y_proba = lr.predict_proba(X) except NotImplementedError: # Lightning predict_proba is not implemented for n_classes > 2 - y_pred = _predict_proba(lr, X) + y_proba = _predict_proba(lr, X) if isinstance(lr, OneVsRestClassifier): coef = np.concatenate([est.coef_ for est in lr.estimators_]) else: coef = lr.coef_ - score = log_loss(y, y_pred, normalize=False) / n_samples + score = log_loss(y, y_proba, normalize=False) / n_samples score += 0.5 * alpha * np.sum(coef**2) + beta * np.sum(np.abs(coef)) scores.append(score) train_score, test_score = tuple(scores) diff --git a/benchmarks/bench_tsne_mnist.py b/benchmarks/bench_tsne_mnist.py index 8649c7a46b629..4eba94828434a 100644 --- a/benchmarks/bench_tsne_mnist.py +++ b/benchmarks/bench_tsne_mnist.py @@ -15,6 +15,7 @@ import numpy as np from joblib import Memory +from sklearn.utils._openmp_helpers import _openmp_effective_n_threads from sklearn.datasets import fetch_openml from sklearn.decomposition import PCA @@ -22,7 +23,6 @@ from sklearn.neighbors import NearestNeighbors from sklearn.utils import check_array from sklearn.utils import shuffle as _shuffle -from sklearn.utils._openmp_helpers import _openmp_effective_n_threads LOG_DIR = "mnist_tsne_output" if not os.path.exists(LOG_DIR): diff --git a/build_tools/azure/debian_32bit_lock.txt b/build_tools/azure/debian_32bit_lock.txt deleted file mode 100644 index d78b1d3cde84f..0000000000000 --- a/build_tools/azure/debian_32bit_lock.txt +++ /dev/null @@ -1,46 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --output-file=build_tools/azure/debian_32bit_lock.txt build_tools/azure/debian_32bit_requirements.txt -# -coverage[toml]==7.12.0 - # via pytest-cov -cython==3.2.1 - # via -r build_tools/azure/debian_32bit_requirements.txt -execnet==2.1.2 - # via pytest-xdist -iniconfig==2.3.0 - # via pytest -joblib==1.5.2 - # via -r build_tools/azure/debian_32bit_requirements.txt -meson==1.9.1 - # via meson-python -meson-python==0.18.0 - # via -r build_tools/azure/debian_32bit_requirements.txt -ninja==1.13.0 - # via -r build_tools/azure/debian_32bit_requirements.txt -packaging==25.0 - # via - # meson-python - # pyproject-metadata - # pytest -pluggy==1.6.0 - # via - # pytest - # pytest-cov -pygments==2.19.2 - # via pytest -pyproject-metadata==0.10.0 - # via meson-python -pytest==9.0.1 - # via - # -r build_tools/azure/debian_32bit_requirements.txt - # pytest-cov - # pytest-xdist -pytest-cov==6.3.0 - # via -r build_tools/azure/debian_32bit_requirements.txt -pytest-xdist==3.8.0 - # via -r build_tools/azure/debian_32bit_requirements.txt -threadpoolctl==3.6.0 - # via -r build_tools/azure/debian_32bit_requirements.txt diff --git a/build_tools/azure/get_commit_message.py b/build_tools/azure/get_commit_message.py deleted file mode 100644 index f110697c2b24f..0000000000000 --- a/build_tools/azure/get_commit_message.py +++ /dev/null @@ -1,72 +0,0 @@ -import argparse -import os -import subprocess - - -def get_commit_message(): - """Retrieve the commit message.""" - - if "COMMIT_MESSAGE" in os.environ or "BUILD_SOURCEVERSIONMESSAGE" not in os.environ: - raise RuntimeError( - "This legacy script should only be used on Azure. " - "On GitHub actions, use the 'COMMIT_MESSAGE' environment variable" - ) - - build_source_version_message = os.environ["BUILD_SOURCEVERSIONMESSAGE"] - - if os.environ["BUILD_REASON"] == "PullRequest": - # By default pull requests use refs/pull/PULL_ID/merge as the source branch - # which has a "Merge ID into ID" as a commit message. The latest commit - # message is the second to last commit - commit_id = build_source_version_message.split()[1] - git_cmd = ["git", "log", commit_id, "-1", "--pretty=%B"] - commit_message = subprocess.run( - git_cmd, capture_output=True, text=True - ).stdout.strip() - else: - commit_message = build_source_version_message - - # Sanitize the commit message to avoid introducing a vulnerability: a PR - # submitter could include the "##vso" special marker in their commit - # message to attempt to obfuscate the injection of arbitrary commands in - # the Azure pipeline. - # - # This can be a problem if the PR reviewers do not pay close enough - # attention to the full commit message prior to clicking the merge button - # and as a result make the inject code run in a protected branch with - # elevated access to CI secrets. On a protected branch, Azure - # already sanitizes `BUILD_SOURCEVERSIONMESSAGE`, but the message - # will still be sanitized here out of precaution. - commit_message = commit_message.replace("##vso", "..vso") - - return commit_message - - -def parsed_args(): - parser = argparse.ArgumentParser( - description=( - "Show commit message that triggered the build in Azure DevOps pipeline" - ) - ) - parser.add_argument( - "--only-show-message", - action="store_true", - default=False, - help=( - "Only print commit message. Useful for direct use in scripts rather than" - " setting output variable of the Azure job" - ), - ) - return parser.parse_args() - - -if __name__ == "__main__": - args = parsed_args() - commit_message = get_commit_message() - - if args.only_show_message: - print(commit_message) - else: - # set the environment variable to be propagated to other steps - print(f"##vso[task.setvariable variable=message;isOutput=true]{commit_message}") - print(f"commit message: {commit_message}") # helps debugging diff --git a/build_tools/azure/get_selected_tests.py b/build_tools/azure/get_selected_tests.py deleted file mode 100644 index 177d42604a5b2..0000000000000 --- a/build_tools/azure/get_selected_tests.py +++ /dev/null @@ -1,42 +0,0 @@ -import os - -from get_commit_message import get_commit_message - - -def get_selected_tests(): - """Parse the commit message to check if pytest should run only specific tests. - - If so, selected tests will be run with SKLEARN_TESTS_GLOBAL_RANDOM_SEED="all". - - The commit message must take the form: - [all random seeds] - <test_name_1> - <test_name_2> - ... - """ - if "SELECTED_TESTS" in os.environ: - raise RuntimeError( - "This legacy script should only be used on Azure. " - "On GitHub actions, use the 'SELECTED_TESTS' environment variable" - ) - - commit_message = get_commit_message() - - if "[all random seeds]" in commit_message: - selected_tests = commit_message.split("[all random seeds]")[1].strip() - selected_tests = selected_tests.replace("\n", " or ") - else: - selected_tests = "" - - return selected_tests - - -if __name__ == "__main__": - # set the environment variable to be propagated to other steps - selected_tests = get_selected_tests() - - if selected_tests: - print(f"##vso[task.setvariable variable=SELECTED_TESTS]'{selected_tests}'") - print(f"selected tests: {selected_tests}") # helps debugging - else: - print("no selected tests") diff --git a/build_tools/azure/install_setup_conda.sh b/build_tools/azure/install_setup_conda.sh deleted file mode 100755 index e57d7dbe155be..0000000000000 --- a/build_tools/azure/install_setup_conda.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -set -e -set -x - -PLATFORM=$(uname) -if [[ "$PLATFORM" =~ MINGW|MSYS ]]; then - PLATFORM=Windows -fi -if [[ "$PLATFORM" == "Windows" ]]; then - EXTENSION="exe" -else - EXTENSION="sh" -fi -INSTALLER="miniforge.$EXTENSION" -MINIFORGE_URL="https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$PLATFORM-$(uname -m).$EXTENSION" -curl -L ${MINIFORGE_URL} -o "$INSTALLER" - -MINIFORGE_DIR="$HOME/miniforge3" -if [[ "$PLATFORM" == "Windows" ]]; then - WIN_MINIFORGE_DIR=$(cygpath -w "$MINIFORGE_DIR") - cmd "/C $INSTALLER /InstallationType=JustMe /RegisterPython=0 /S /D=$WIN_MINIFORGE_DIR" -else - bash "$INSTALLER" -b -u -p $MINIFORGE_DIR -fi - -# Add conda to the PATH so that it can be used in further Azure CI steps. -# Need set +x for ##vso Azure magic otherwise it may add a quote in the PATH. -# For more details, see https://github.com/microsoft/azure-pipelines-tasks/issues/10331 -set +x -if [[ "$PLATFORM" == "Windows" ]]; then - echo "##vso[task.prependpath]$MINIFORGE_DIR/Scripts" -else - echo "##vso[task.prependpath]$MINIFORGE_DIR/bin" -fi -set -x diff --git a/build_tools/azure/posix-all-parallel.yml b/build_tools/azure/posix-all-parallel.yml deleted file mode 100644 index 45d2b4569110f..0000000000000 --- a/build_tools/azure/posix-all-parallel.yml +++ /dev/null @@ -1,50 +0,0 @@ -# This configuration allows enables a job based on `posix.yml` to have two modes: -# -# 1. When `[azure parallel]` *is not* in the commit message, then this job will -# run first. If this job succeeds, then all dependent jobs can run. -# 2. When `[azure parallel]` *is* in the commit message, then this job will -# run with name `{{ parameters.name }}_Parallel` along with all other jobs. -# -# To enable this template, all dependent jobs should check if this job succeeded -# or skipped by using: -# dependsOn: in(dependencies[{{ parameters.name }}]['result'], 'Succeeded', 'Skipped') - -parameters: - name: '' - vmImage: '' - matrix: [] - dependsOn: [] - condition: '' - commitMessage: '' - -jobs: - -# When [azure parallel] *is not* in the commit message, this job will run -# first. -- template: posix.yml - parameters: - name: ${{ parameters.name }} - vmImage: ${{ parameters.vmImage }} - matrix: ${{ parameters.matrix }} - dependsOn: ${{ parameters.dependsOn }} - condition: | - and( - ${{ parameters.condition }}, - not(contains(${{ parameters.commitMessage }}, '[azure parallel]')) - ) - -# When [azure parallel] *is* in the commit message, this job and dependent -# jobs will run in parallel. Implementation-wise, the job above is skipped and -# this job, named ${{ parameters.name }}_Parallel, will run in parallel with -# the other jobs. -- template: posix.yml - parameters: - name: ${{ parameters.name }}_Parallel - vmImage: ${{ parameters.vmImage }} - matrix: ${{ parameters.matrix }} - dependsOn: ${{ parameters.dependsOn }} - condition: | - and( - ${{ parameters.condition }}, - contains(${{ parameters.commitMessage }}, '[azure parallel]') - ) diff --git a/build_tools/azure/posix-docker.yml b/build_tools/azure/posix-docker.yml deleted file mode 100644 index 8cf4fb75b8345..0000000000000 --- a/build_tools/azure/posix-docker.yml +++ /dev/null @@ -1,134 +0,0 @@ -parameters: - name: '' - vmImage: '' - matrix: [] - dependsOn: [] - condition: ne(variables['Build.Reason'], 'Schedule') - -jobs: -- job: ${{ parameters.name }} - dependsOn: ${{ parameters.dependsOn }} - condition: ${{ parameters.condition }} - timeoutInMinutes: 120 - pool: - vmImage: ${{ parameters.vmImage }} - variables: - VIRTUALENV: 'testvenv' - TEST_DIR: '$(Agent.WorkFolder)/tmp_folder' - JUNITXML: 'test-data.xml' - SKLEARN_SKIP_NETWORK_TESTS: '1' - PYTEST_XDIST_VERSION: 'latest' - COVERAGE: 'false' - # Set in azure-pipelines.yml - DISTRIB: '' - DOCKER_CONTAINER: '' - CREATE_ISSUE_ON_TRACKER: 'true' - CCACHE_DIR: $(Pipeline.Workspace)/ccache - CCACHE_COMPRESS: '1' - strategy: - matrix: - ${{ insert }}: ${{ parameters.matrix }} - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.9' - addToPath: false - name: pyTools - displayName: Select python version to run CI python scripts - - bash: $(pyTools.pythonLocation)/bin/python build_tools/azure/get_selected_tests.py - displayName: Check selected tests for all random seeds - condition: eq(variables['Build.Reason'], 'PullRequest') - - task: Cache@2 - inputs: - key: '"ccache-v1" | "$(Agent.JobName)" | "$(Build.BuildNumber)"' - restoreKeys: | - "ccache-v1" | "$(Agent.JobName)" - path: $(CCACHE_DIR) - displayName: ccache - continueOnError: true - - script: > - mkdir -p $CCACHE_DIR - # Container is detached and sleeping, allowing steps to run commands - # in the container. The TEST_DIR is mapped allowing the host to access - # the JUNITXML file - - script: > - docker container run --rm - --volume $TEST_DIR:/temp_dir - --volume $BUILD_REPOSITORY_LOCALPATH:/repo_localpath - --volume $PWD:/scikit-learn - --volume $CCACHE_DIR:/ccache - -w /scikit-learn - --detach - --name skcontainer - -e BUILD_SOURCESDIRECTORY=/scikit-learn - -e TEST_DIR=/temp_dir - -e CCACHE_DIR=/ccache - -e BUILD_REPOSITORY_LOCALPATH=/repo_localpath - -e COVERAGE - -e DISTRIB - -e LOCK_FILE - -e JUNITXML - -e VIRTUALENV - -e PYTEST_XDIST_VERSION - -e SKLEARN_SKIP_NETWORK_TESTS - -e SELECTED_TESTS - -e CCACHE_COMPRESS - -e BUILD_SOURCEVERSIONMESSAGE - -e BUILD_REASON - $DOCKER_CONTAINER - sleep 1000000 - displayName: 'Start container' - - script: > - docker exec skcontainer ./build_tools/azure/install.sh - displayName: 'Install' - - script: > - docker exec skcontainer ./build_tools/azure/test_script.sh - displayName: 'Test Library' - - script: > - docker exec skcontainer ./build_tools/azure/combine_coverage_reports.sh - condition: and(succeeded(), eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Combine coverage' - - task: PublishTestResults@2 - inputs: - testResultsFiles: '$(TEST_DIR)/$(JUNITXML)' - testRunTitle: ${{ format('{0}-$(Agent.JobName)', parameters.name) }} - displayName: 'Publish Test Results' - condition: succeededOrFailed() - - script: > - docker container stop skcontainer - displayName: 'Stop container' - condition: always() - - bash: | - set -ex - if [[ $(BOT_GITHUB_TOKEN) == "" ]]; then - echo "GitHub Token is not set. Issue tracker will not be updated." - exit - fi - - LINK_TO_RUN="https://dev.azure.com/$BUILD_REPOSITORY_NAME/_build/results?buildId=$BUILD_BUILDID&view=logs&j=$SYSTEM_JOBID" - CI_NAME="$SYSTEM_JOBIDENTIFIER" - ISSUE_REPO="$BUILD_REPOSITORY_NAME" - - $(pyTools.pythonLocation)/bin/pip install defusedxml PyGithub - $(pyTools.pythonLocation)/bin/python maint_tools/update_tracking_issue.py \ - $(BOT_GITHUB_TOKEN) \ - $CI_NAME \ - $ISSUE_REPO \ - $LINK_TO_RUN \ - --junit-file $JUNIT_FILE \ - --auto-close false - displayName: 'Update issue tracker' - env: - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) - condition: and(succeededOrFailed(), eq(variables['CREATE_ISSUE_ON_TRACKER'], 'true'), - eq(variables['Build.Reason'], 'Schedule')) - - bash: bash build_tools/azure/upload_codecov.sh - condition: and(succeeded(), eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Upload To Codecov' - retryCountOnTaskFailure: 5 - env: - CODECOV_TOKEN: $(CODECOV_TOKEN) - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) diff --git a/build_tools/azure/posix.yml b/build_tools/azure/posix.yml deleted file mode 100644 index e0f504ba540db..0000000000000 --- a/build_tools/azure/posix.yml +++ /dev/null @@ -1,109 +0,0 @@ -parameters: - name: '' - vmImage: '' - matrix: [] - dependsOn: [] - condition: '' - -jobs: -- job: ${{ parameters.name }} - dependsOn: ${{ parameters.dependsOn }} - condition: ${{ parameters.condition }} - timeoutInMinutes: 120 - pool: - vmImage: ${{ parameters.vmImage }} - variables: - TEST_DIR: '$(Agent.WorkFolder)/tmp_folder' - VIRTUALENV: 'testvenv' - JUNITXML: 'test-data.xml' - SKLEARN_SKIP_NETWORK_TESTS: '1' - CCACHE_DIR: $(Pipeline.Workspace)/ccache - CCACHE_COMPRESS: '1' - PYTEST_XDIST_VERSION: 'latest' - COVERAGE: 'true' - CREATE_ISSUE_ON_TRACKER: 'true' - strategy: - matrix: - ${{ insert }}: ${{ parameters.matrix }} - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.9' - addToPath: false - name: pyTools - displayName: Select python version to run CI python scripts - - bash: $(pyTools.pythonLocation)/bin/python build_tools/azure/get_selected_tests.py - displayName: Check selected tests for all random seeds - condition: eq(variables['Build.Reason'], 'PullRequest') - - bash: build_tools/azure/install_setup_conda.sh - displayName: Install conda if necessary and set it up - condition: startsWith(variables['DISTRIB'], 'conda') - - task: Cache@2 - inputs: - key: '"ccache-v1" | "$(Agent.JobName)" | "$(Build.BuildNumber)"' - restoreKeys: | - "ccache-v1" | "$(Agent.JobName)" - path: $(CCACHE_DIR) - displayName: ccache - continueOnError: true - - script: | - build_tools/azure/install.sh - displayName: 'Install' - - script: | - build_tools/azure/test_script.sh - displayName: 'Test Library' - - script: | - build_tools/azure/test_docs.sh - displayName: 'Test Docs' - condition: and(succeeded(), eq(variables['SELECTED_TESTS'], '')) - - script: | - build_tools/azure/test_pytest_soft_dependency.sh - displayName: 'Test Soft Dependency' - condition: and(succeeded(), - eq(variables['CHECK_PYTEST_SOFT_DEPENDENCY'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - - script: | - build_tools/azure/combine_coverage_reports.sh - condition: and(succeeded(), eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Combine coverage' - - task: PublishTestResults@2 - inputs: - testResultsFiles: '$(TEST_DIR)/$(JUNITXML)' - testRunTitle: ${{ format('{0}-$(Agent.JobName)', parameters.name) }} - displayName: 'Publish Test Results' - condition: succeededOrFailed() - - bash: | - set -ex - if [[ $(BOT_GITHUB_TOKEN) == "" ]]; then - echo "GitHub Token is not set. Issue tracker will not be updated." - exit - fi - - LINK_TO_RUN="https://dev.azure.com/$BUILD_REPOSITORY_NAME/_build/results?buildId=$BUILD_BUILDID&view=logs&j=$SYSTEM_JOBID" - CI_NAME="$SYSTEM_JOBIDENTIFIER" - ISSUE_REPO="$BUILD_REPOSITORY_NAME" - - $(pyTools.pythonLocation)/bin/pip install defusedxml PyGithub - $(pyTools.pythonLocation)/bin/python maint_tools/update_tracking_issue.py \ - $(BOT_GITHUB_TOKEN) \ - $CI_NAME \ - $ISSUE_REPO \ - $LINK_TO_RUN \ - --junit-file $JUNIT_FILE \ - --auto-close false - displayName: 'Update issue tracker' - env: - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) - condition: and(succeededOrFailed(), eq(variables['CREATE_ISSUE_ON_TRACKER'], 'true'), - eq(variables['Build.Reason'], 'Schedule')) - - script: | - build_tools/azure/upload_codecov.sh - condition: and(succeeded(), eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Upload To Codecov' - retryCountOnTaskFailure: 5 - env: - CODECOV_TOKEN: $(CODECOV_TOKEN) - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) diff --git a/build_tools/azure/pylatest_conda_forge_mkl_linux-64_conda.lock b/build_tools/azure/pylatest_conda_forge_mkl_linux-64_conda.lock deleted file mode 100644 index 9f3b309640118..0000000000000 --- a/build_tools/azure/pylatest_conda_forge_mkl_linux-64_conda.lock +++ /dev/null @@ -1,275 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: 8ce26fc3e7f7c42668742c679f3353940cac0b6a9ba3bda1f28086a5048ba326 -@EXPLICIT -https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 -https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 -https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb -https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 -https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.21.0-ha770c72_1.conda#9e298d76f543deb06eb0f3413675e13a -https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.0-hf2ce2f3_462.conda#0ec3505e9b16acc124d1ec6e5ae8207c -https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda#16c2a0e9c4a166e53632cfca4f68d020 -https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-4-hd8ed1ab_3.tar.bz2#878f923dd6acc8aeb47a75da6c4098be -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 -https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.6-h4922eb0_0.conda#7a0b9ce502e0ed62195e02891dfcd704 -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-6_kmp_llvm.conda#197811678264cb9da0d2ea0726a70661 -https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab -https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 -https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda#76df83c2a9035c54df5d04ff81bcc02d -https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.5-hb03c661_1.conda#f1d45413e1c41a7eff162bf702c02cea -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.5-hb9d3cd8_0.conda#f7f0d6cc2dc986d42ac2689ec88192be -https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h09219d5_0.conda#9b3117ec960b823815b02190b41c0484 -https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 -https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda#c7e925f37e3b40d893459e625f6a53f1 -https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 -https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.1-hfe17d71_0.conda#765c7e0005659d5154cdd33dc529e0a5 -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 -https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118 -https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 -https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e -https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f -https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.10-h346e085_1.conda#7e6b378cfb6ad918a5fa52bd7741ab20 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.1-h7e655bb_8.conda#1baf55dfcc138d98d437309e9aba2635 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h7e655bb_3.conda#70e83d2429b7edb595355316927dfbea -https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.7-h7e655bb_4.conda#83a6e0fc73a7f18a8024fc89455da81c -https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda#bfd56492d8346d669010eccafe0ba058 -https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda#d411fc29e338efb48c5fd4576d71d881 -https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda#83b160d4da3e1e847bf044997621ed63 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hd53d788_0.conda#c183787d2b228775dece45842abbbe53 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h02bd7ab_0.conda#b7a924e3e9ebc7938ffc7d94fe603ed3 -https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 -https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b -https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 -https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda#a1cfcc585f0c42bf8d5546bb1dfb668d -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e -https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e -https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 -https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc -https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 -https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 -https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda#7fa07cb0fb1b625a089ccc01218ee5b1 -https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/s2n-1.6.0-h8399546_1.conda#8dbc626b1b11e7feb40a14498567b954 -https://conda.anaconda.org/conda-forge/linux-64/sleef-3.9.0-ha0421bc_0.conda#e8a0b4f5e82ecacffaa5e805020473cb -https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda#035da2e4f5770f036ff704fa17aace24 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 -https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda#c9f075ab2f33b3bbee9e62d4ad0a6cd8 -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.23.3-ha76f1cc_3.conda#14d9fc6b1c7a823fca6cf65f595ff70d -https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hf2c8021_0.conda#5304333319a6124a2737d9f128cbc4ed -https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda#ff862eebdfeb2fd048ae9dc92510baca -https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda#c94a5994ef49749880a8139cf9afcbe1 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda#3f43953b7d3fb3aaa1d0d0723d91e368 -https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2#c965a5aa0d5c1c37ffc62dff36e28400 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda#0cb0612bc9cb30c62baf41f9d600611b -https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda#b499ce4b026493a13774bcf0f4c33849 -https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_2.conda#94cb88daa0892171457d9fdc69f43eca -https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h7b12aa8_0.conda#a30848ebf39327ea078cf26d114cff53 -https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h454ac66_1.conda#8ed82d90e6b1686f5e98f8b7825a15ef -https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 -https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda#db038ce880f100acc74dba10302b5630 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.5.6-h3cb25bf_6.conda#874d910adf3debe908b1e8e5847e0014 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.7-hc5c8343_4.conda#b6fdadda34f2a60870980607ef469e39 -https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-h41a2e66_0.conda#4ddfd44e473c676cb8e80548ba4aa704 -https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda#cae723309a49399d2949362f4ab5c9e4 -https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda#679616eb5ad4e521c83da4650860aba7 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda#d4a250da4737ee127fb1fa6452a9002e -https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_0.conda#01e149d4a53185622dc2e788281961f2 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 -https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda#e7733bc6785ec009e47a224a71917e84 -https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.1-h90cbb55_3.conda#2eeb50cab6652538eee8fc0bc3340c81 -https://conda.anaconda.org/conda-forge/linux-64/nodejs-24.9.0-heeeca48_0.conda#8a2a73951c1ea275e76fb1b92d97ff3e -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/orc-2.2.1-hd747db4_0.conda#ddab8b2af55b88d63469c040377bd37e -https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_0.conda#0227d04521bc3d28c7995c7e1f99a721 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 -https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda#71ae752a748962161b4740eaff510258 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda#febbab7d15033c913d53c7a2c102309d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e -https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.9.1-h7ca4310_7.conda#6e91a9182506f6715c25c3ab80990653 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.13.3-h3a25ec9_10.conda#f329cc15f3b4559cab20646245c3fc9b -https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.1-h3a458e0_0.conda#1d4e0d37da5f3c22ecd44033f673feba -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda#4afc585cd97ba8a23809406cd8a9eda8 -https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a -https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.73.1-h3288cfb_1.conda#ff63bb12ac31c176ff257e3289f20770 -https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda#e512be7dc1f84966d50959e900ca121f -https://conda.anaconda.org/conda-forge/linux-64/mpc-1.3.1-h24ddda3_1.conda#aa14b9a5196a6d8dd364164b7ce56acf -https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda#2e5bf4f1da39c0b32778561c3c4e5878 -https://conda.anaconda.org/conda-forge/linux-64/playwright-1.56.1-h5585027_0.conda#5e6fc54576b97242f1eb5a5deb411eca -https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda#a83f6a2fdc079e643237887a37460668 -https://conda.anaconda.org/conda-forge/linux-64/python-3.13.9-hc97d973_101_cp313.conda#4780fe896e961722d0623fa91d0d3378 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda#4d1fc190b99912ed557a8236e958c559 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda#d3c295b50f092ab525ffe3c2aa4b7413 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda#2ccd714aa2242315acaf0a67faea780b -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda#17dcc85db3c7886650b8908b183d6876 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda#2de7f99d6581a4a7adbff607b5c278ca -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda#5efa5fa6243a622445fdfd72aee15efa -https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.10.1-hcb69869_2.conda#3bcec65152e70e02e8d17d296c056a82 -https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.2-h3a5f585_1.conda#4e921d9c85e6559c60215497978b3cdb -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.11.0-h3d7a050_1.conda#89985ba2a3742f34be6aafd6a8f3af8c -https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py313h09d1b84_0.conda#dfd94363b679c74937b3926731ee861a -https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda#96a02a5c1a65470a7e4eedb644c872fd -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda#a22d1fd9bf98827e280a02875d9a007a -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda#367133808e89325690562099851529c8 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.1-py313hc80a56d_0.conda#1617960e1d8164f837ed5d0996603b88 -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda#66b8b26023b8efdf8fcb23bac4b6325d -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda#8f5b0b297b59e1ac160ad4beec99dbee -https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.10.0-pyhd8ed1ab_0.conda#d18004c37182f83b9818b714825a7627 -https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.2.1-py313h86d8783_2.conda#d904f240d2d2500d4906361c67569217 -https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.2.4-py313h7033f15_1.conda#54e4dec31235bbc794d091af9afcd845 -https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e -https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac -https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda#53abe63df7e10a6ba605dc5f9f961d36 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py313hc8edb43_2.conda#3e0e65595330e26515e31b7fc6d933c7 -https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.39.0-hdb79228_0.conda#a2e30ccd49f753fd30de0d30b1569789 -https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.1-default_h7f8ec31_1002.conda#c01021ae525a76fe62720c7346212d74 -https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.6-hf7376ad_0.conda#8aa154f30e0bc616cbde9794710e0be2 -https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.21.0-hb9b0907_1.conda#1c0320794855f457dea27d35c4c71e23 -https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_1.conda#638350cf5da41f3651958876a2104992 -https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda#372a62464d47d9e966b630ffae3abe73 -https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.0-hca5e8e5_0.conda#aa65b4add9574bb1d23c76560c5efd4c -https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda#87e6096ec6d542d1c1f8b33245fe8300 -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_0.conda#c14389156310b8ed3520d84f854be1ee -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b -https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/networkx-3.5-pyhe01879c_0.conda#16bff3d37a4f99e3aa089c36c2b8d650 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py313h50355cd_0.conda#8a96eab78687362de3e102a15c4747a8 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pybind11-global-2.13.6-pyh217bc35_3.conda#730a5284e26d6bdb73332dafb26aec82 -https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda#23b4ba5619c4752976eb7ba1f5acb7e8 -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda#7824f18e343d1f846dcde7b23c9bf31a -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f -https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.35.2-h2ceb62e_4.conda#363b3e12e49cecf931338d10114945e9 -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.15.0-h2a74896_1.conda#ffd553ff98ce5d74d3d89ac269153149 -https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda#09262e66b19567aff4f592fb53b28760 -https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda#d0616e7935acab407d1543b28c446f6f -https://conda.anaconda.org/conda-forge/linux-64/coverage-7.12.0-py313h3dea7bd_0.conda#8ef99d298907bfd688a95cc714662ae7 -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.60.1-py313h3dea7bd_0.conda#904860fc0d57532d28e9c6c4501f19a9 -https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.6-default_h99862b1_0.conda#0fcc9b4d3fc5e5010a7098318d9b7971 -https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.6-default_h746c552_0.conda#f5b64315835b284c7eb5332202b1e14b -https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.39.0-hdbdcf42_0.conda#bd21962ff8a9d1ce4720d42a35a4af40 -https://conda.anaconda.org/conda-forge/noarch/pybind11-2.13.6-pyhc790b64_3.conda#1594696beebf1ecb6d29a1136f859a74 -https://conda.anaconda.org/conda-forge/noarch/pyee-13.0.0-pyhd8ed1ab_0.conda#ec33a030c3bc90f0131305a8eba5f8a3 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda#f41e3c1125e292e6bfcea8392a3de3d8 -https://conda.anaconda.org/conda-forge/noarch/python-slugify-8.0.4-pyhd8ed1ab_1.conda#a4059bc12930bddeb41aef71537ffaed -https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_105.conda#8c09fac3785696e1c477156192d64b91 -https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda#e3259be3341da4bc06c5b7a78c8bf1bd -https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 -https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f -https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.606-hd6e39bc_7.conda#0f7a1d2e2c6cdfc3864c4c0b16ade511 -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.13.0-hf38f1be_1.conda#f10b9303c7239fbce3580a60a92bcf97 -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda#b8690f53007e9b5ee2c2178dd4ac778c -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.0-h0e700b2_462.conda#a2e8e73f7132ea5ea70fda6f3cf05578 -https://conda.anaconda.org/conda-forge/linux-64/optree-0.18.0-py313h7037e92_0.conda#33901d2cb4969c6b57eefe767d69fa69 -https://conda.anaconda.org/conda-forge/noarch/playwright-python-1.56.0-pyhcf101f3_0.conda#d0753cdc3baeacf68e697f457749a58b -https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda#da0c42269086f5170e2b296878ec13a6 -https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_1.conda#710d4663806d0f72b2fb414e936223b5 -https://conda.anaconda.org/conda-forge/linux-64/libarrow-22.0.0-h773bc41_4_cpu.conda#9d89be0b1ca8be7eedf821a365926338 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-2_h5875eb1_mkl.conda#6a1a4ec47263069b2dae3cfba106320c -https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.0-ha770c72_462.conda#619188d87dc94ed199e790d906d74bc3 -https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.35.2-py310hffdcd12_0.conda#2b90c3aaf73a5b6028b068cf3c76e0b7 -https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d -https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_1.conda#762af6d08fdfa7a45346b1466740bacd -https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda#436c165519e140cb08d246a4472a9d6a -https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-22.0.0-h8c2c5c3_4_cpu.conda#fdecd3d6168561098fa87d767de05171 -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-2_hfef963f_mkl.conda#62ffd188ee5c953c2d6ac54662c158a7 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-2_h5e43f62_mkl.conda#4f33d79eda3c82c95a54e8c2981adddb -https://conda.anaconda.org/conda-forge/linux-64/libparquet-22.0.0-h7376487_4_cpu.conda#5e9383b1d25179787aff71aaad8208aa -https://conda.anaconda.org/conda-forge/noarch/polars-1.35.2-pyh6a1acc5_0.conda#24e8f78d79881b3c035f89f4b83c565c -https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py313h85046ba_1.conda#bb7ac52bfa917611096023598a7df152 -https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda#db0c6b99149880c8ba515cf4abe93ee4 -https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-22.0.0-h635bf11_4_cpu.conda#20f1a4625bce6e9b41e01232895450d9 -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-2_hdba1596_mkl.conda#96dea51ff1435bd823020e25fd02da59 -https://conda.anaconda.org/conda-forge/linux-64/libtorch-2.8.0-cpu_mkl_h09b866c_102.conda#0194f4ea9e74964548ddb220b61d4712 -https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py313hf6604e3_0.conda#15f43bcd12c90186e78801fafc53d89b -https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-22.0.0-py313he109ebe_0_cpu.conda#0b4a0a9ab270b275eb6da8671edb9458 -https://conda.anaconda.org/conda-forge/noarch/pytest-base-url-2.1.0-pyhd8ed1ab_1.conda#057f32e4c376ce0c4c4a32a9f06bf34e -https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.4.1-pyhe01879c_0.conda#648e253c455718227c61e26f4a4ce701 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-2_hcf00494_mkl.conda#77b464e7c3b853268dec4c82b21dca5a -https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py313h7037e92_3.conda#6186382cb34a9953bf2a18fc763dc346 -https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-22.0.0-h635bf11_4_cpu.conda#6389644214f7707ab05f17f464863ed3 -https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py313h08cd8bf_1.conda#9e87d4bda0c2711161d765332fa38781 -https://conda.anaconda.org/conda-forge/noarch/pytest-playwright-0.7.2-pyhd8ed1ab_0.conda#e6475f566489789e65ebd5544db36b3e -https://conda.anaconda.org/conda-forge/linux-64/pytorch-2.8.0-cpu_mkl_py313_h19d87ba_102.conda#755f7ca398f27fdab5c5842cdd7b0e89 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py313h11c21cd_1.conda#26b089b9e5fcdcdca714b01f8008d808 -https://conda.anaconda.org/conda-forge/noarch/scipy-doctest-2.0.1-pyhe01879c_0.conda#303ec962addf1b6016afd536e9db6bc6 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.302-mkl.conda#9c83adee9e1069446e6cc92b8ea19797 -https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-22.0.0-h3f74fd7_4_cpu.conda#6f07bf204431fb87d8f827807d752662 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py313h683a580_0.conda#ffe67570e1a9192d2f4c189b27f75f89 -https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py313hfaae9d9_1.conda#6d308eafec3de495f6b06ebe69c990ed -https://conda.anaconda.org/conda-forge/linux-64/pytorch-cpu-2.8.0-cpu_mkl_hc60beec_102.conda#2b401c2d6c6b2f0d6c4e1862b4291247 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py313h78bf25f_0.conda#85bce686dd57910d533807562204e16b -https://conda.anaconda.org/conda-forge/linux-64/pyarrow-22.0.0-py313h78bf25f_0.conda#dfe7289ae9ad7aa091979a7c5e6a55c7 diff --git a/build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock b/build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock deleted file mode 100644 index 8743a76f7e824..0000000000000 --- a/build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock +++ /dev/null @@ -1,105 +0,0 @@ -# Generated by conda-lock. -# platform: osx-64 -# input_hash: 262fddb7141c0c7e6efbe8b721d4175e7b7ee34fa4ed3e1e2fed9057463df129 -@EXPLICIT -https://conda.anaconda.org/conda-forge/osx-64/mkl-include-2023.2.0-h694c41f_50502.conda#f394610725ab086080230c5d8fd96cd4 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda#97c4b3bd8a90722104798175a1bdddbf -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h105ed1c_0.conda#61c2b02435758f1c6926b3733d34ea08 -https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.6-h3d58e20_0.conda#866af4d7269cd8c9b70f5b49ad6173aa -https://conda.anaconda.org/conda-forge/osx-64/libdeflate-1.25-h517ebb2_0.conda#31aa65919a729dc48180893f62c25221 -https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda#222e0732a1d0780a622926265bee14ef -https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-h750e83c_0.conda#d214916b24c625bcc459b245d509f22e -https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda#210a85a1119f97ea7887188d176db135 -https://conda.anaconda.org/conda-forge/osx-64/libjpeg-turbo-3.1.2-h8616949_0.conda#48dda187f169f5a8f1e5e07701d5cdd9 -https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.1-hd471939_2.conda#8468beea04b9065b9807fc8b9cdc5894 -https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-h6e16a3a_0.conda#18b81186a6adb43f000ad19ed7b70381 -https://conda.anaconda.org/conda-forge/osx-64/libwebp-base-1.6.0-hb807250_0.conda#7bb6608cf1f83578587297a158a6630b -https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda#003a54a4e32b02f7355b50a837e699da -https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-21.1.6-h472b3d1_0.conda#d002bb48f35085405e90a62ffeebebfb -https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda#ced34dd9929f491ca6dab6a2927aff25 -https://conda.anaconda.org/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda#8bcf980d2c6b17094961198284b8e862 -https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda#47f1b8b4a76ebd0cd22bd7153e54a4dc -https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda#435446d9d7db8e094d2c989766cfb146 -https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-6_kmp_llvm.conda#f699f090723c4948e11bfbb4a23e87f9 -https://conda.anaconda.org/conda-forge/osx-64/lerc-4.0.0-hcca01a6_1.conda#21f765ced1a0ef4070df53cb425e1967 -https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h660c9da_0.conda#c8f29cbebccb17826d805c15282c7e8b -https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h2338291_0.conda#57b746e8ed03d56fe908fd050c517299 -https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-h336fb69_1.conda#b6331e2dcc025fc79cd578f4c181d6f2 -https://conda.anaconda.org/conda-forge/osx-64/libpng-1.6.51-h380d223_0.conda#d54babdd92ec19c27af739b53e189335 -https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.0-h86bffb9_0.conda#1ee9b74571acd6dd87e6a0f783989426 -https://conda.anaconda.org/conda-forge/osx-64/libxcb-1.17.0-hf1f96e2_0.conda#bbeca862892e2898bdb45792a61c4afc -https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.1-h0ad03eb_0.conda#8487998051f3d300fef701a49c27f282 -https://conda.anaconda.org/conda-forge/osx-64/ninja-1.13.2-hfc0b2d5_0.conda#afda563484aa0017278866707807a335 -https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.0-h230baf5_0.conda#3f50cdf9a97d0280655758b735781096 -https://conda.anaconda.org/conda-forge/osx-64/qhull-2020.2-h3c5361c_5.conda#dd1ea9ff27c93db7c01a7b7656bd4ad4 -https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h7cca4af_2.conda#342570f8e02f2f022147a7f841475784 -https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda#bd9f1de651dbd80b51281c694827f78f -https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.2.5-h55e386d_0.conda#692a62051af2270eb9c24e8f09e88db6 -https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h8210216_2.conda#cd60a4a5a8d6a476b30d8aa4bb49251a -https://conda.anaconda.org/conda-forge/osx-64/brotli-bin-1.2.0-h5c1846c_0.conda#e3b4a50ddfcda3835379b10c5b0c951b -https://conda.anaconda.org/conda-forge/osx-64/libfreetype6-2.14.1-h6912278_0.conda#dfbdc8fd781dc3111541e4234c19fdbd -https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h306097a_1.conda#cd5393330bff47a00d37a117c65b65d0 -https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.7.1-ha0a348c_1.conda#9d4344f94de4ab1330cdc41c40152ea6 -https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.1-h23bb396_0.conda#65dd26de1eea407dda59f0da170aed22 -https://conda.anaconda.org/conda-forge/osx-64/python-3.14.0-hf88997e_102_cp314.conda#7917d1205eed3e72366a3397dca8a2af -https://conda.anaconda.org/conda-forge/osx-64/brotli-1.2.0-hb27157a_0.conda#01fd35c4b0b4641d3174d5ebb6065d96 -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/osx-64/cython-3.2.1-py314h9fad922_0.conda#ed199501ba2943766cc51a898650cccd -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/osx-64/kiwisolver-1.4.9-py314hf3ac25a_2.conda#28a77c52c425fa9c6d914c609c626b1a -https://conda.anaconda.org/conda-forge/osx-64/lcms2-2.17-h72f5680_0.conda#bf210d0c63f2afb9e414a858b79f0eaa -https://conda.anaconda.org/conda-forge/osx-64/libfreetype-2.14.1-h694c41f_0.conda#e0e2edaf5e0c71b843e25a7ecc451cc9 -https://conda.anaconda.org/conda-forge/osx-64/libhiredis-1.0.2-h2beb688_0.tar.bz2#524282b2c46c9dedf051b3bc2ae05494 -https://conda.anaconda.org/conda-forge/osx-64/libhwloc-2.12.1-default_h094e1f9_1002.conda#4d9e9610b6a16291168144842cd9cae2 -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h87e8dc5_0.conda#a67d3517ebbf615b91ef9fdc99934e0c -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.2-py314h6482030_2.conda#d97f0d30ffb1b03fa8d09ef8ba0fdd7c -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.0-py314h6482030_1.conda#d69097de15cbad36f1eaafda0bad598a -https://conda.anaconda.org/conda-forge/osx-64/ccache-4.11.3-h33566b8_0.conda#b65cad834bd6c1f660c101cca09430bf -https://conda.anaconda.org/conda-forge/osx-64/coverage-7.12.0-py314hb9c7d66_0.conda#d8805ca5ce27c9a2182baf03a16209ab -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/noarch/fonttools-4.60.1-pyh7db6752_0.conda#85c6b2f3ae5044dd279dc0970f882cd9 -https://conda.anaconda.org/conda-forge/osx-64/freetype-2.14.1-h694c41f_0.conda#ca641fdf8b7803f4b7212b6d66375930 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/osx-64/pillow-12.0.0-py314h0a84944_0.conda#95252d1cf079f62c4d0ea90eb5cd7219 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/osx-64/tbb-2021.13.0-hf0c99ee_4.conda#411c95470bff187ae555120702f28c0e -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/osx-64/mkl-2023.2.0-h694c41f_50502.conda#0bdfc939c8542e0bc6041cbd9a900219 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/osx-64/libblas-3.9.0-20_osx64_mkl.conda#160fdc97a51d66d51dc782fb67d35205 -https://conda.anaconda.org/conda-forge/osx-64/mkl-devel-2023.2.0-h694c41f_50502.conda#045f993e4434eaa02518d780fdca34ae -https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d -https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.9.0-20_osx64_mkl.conda#51089a4865eb4aec2bc5c7468bd07f9f -https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.9.0-20_osx64_mkl.conda#58f08e12ad487fac4a08f90ff0b87aec -https://conda.anaconda.org/conda-forge/osx-64/liblapacke-3.9.0-20_osx64_mkl.conda#124ae8e384268a8da66f1d64114a1eda -https://conda.anaconda.org/conda-forge/osx-64/numpy-2.3.5-py314hf08249b_0.conda#5c9e4bc0c170115fd3602d7377c9e8da -https://conda.anaconda.org/conda-forge/osx-64/blas-devel-3.9.0-20_osx64_mkl.conda#cc3260179093918b801e373c6e888e02 -https://conda.anaconda.org/conda-forge/osx-64/contourpy-1.3.3-py314h00ed6fe_3.conda#761aa19f97a0dd5dedb9a0a6003707c1 -https://conda.anaconda.org/conda-forge/osx-64/pandas-2.3.3-py314hc4308db_1.conda#21a858b49f91ac1f5a7b8d0ab61f8e7d -https://conda.anaconda.org/conda-forge/osx-64/scipy-1.16.3-py314h9d854bd_1.conda#017b471251f1d7401ed1dd63370bad2f -https://conda.anaconda.org/conda-forge/osx-64/blas-2.120-mkl.conda#b041a7677a412f3d925d8208936cb1e2 -https://conda.anaconda.org/conda-forge/osx-64/matplotlib-base-3.10.8-py314hd47142c_0.conda#91d76a5937b47f7f0894857ce88feb9f -https://conda.anaconda.org/conda-forge/osx-64/pyamg-5.3.0-py314h81027db_1.conda#47390f4299f43bcdae539d454178596e -https://conda.anaconda.org/conda-forge/osx-64/matplotlib-3.10.8-py314hee6578b_0.conda#7fdf446de012e1750bf465b76412928d diff --git a/build_tools/azure/pylatest_conda_forge_osx-arm64_conda.lock b/build_tools/azure/pylatest_conda_forge_osx-arm64_conda.lock deleted file mode 100644 index 9aa61ae3d9577..0000000000000 --- a/build_tools/azure/pylatest_conda_forge_osx-arm64_conda.lock +++ /dev/null @@ -1,155 +0,0 @@ -# Generated by conda-lock. -# platform: osx-arm64 -# input_hash: d46bd759507c1840244b89fad70be8f2ef116029a21e0229b0568103b6759398 -@EXPLICIT -https://conda.anaconda.org/conda-forge/noarch/libgfortran-devel_osx-arm64-14.3.0-hc965647_1.conda#c1b69e537b3031d0f5af780b432ce511 -https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2#9a66894dfd07c4510beb6b3f9672ccc0 -https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-4-hd8ed1ab_3.tar.bz2#878f923dd6acc8aeb47a75da6c4098be -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda#58fd217444c2a5701a44244faf518206 -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda#5eb22c1d7b3fc4abb50d92d621583137 -https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h87ba0bc_0.conda#07d43b5e2b6f4a73caed8238b60fabf5 -https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.6-hf598326_0.conda#3ea79e55a64bff6c3cbd4588c89a527a -https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda#a6130c709305cd9828b4e1bd9ba0000c -https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda#b79875dbb5b1db9a4a22a4520f918e1a -https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda#411ff7cd5d1472bba0f55c0faf04453b -https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda#4d5a7445f0b25b6a3ddbb56e790f5251 -https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.2-hc919400_0.conda#f0695fbecf1006f27f4395d64bd0c4b8 -https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda#d6df911d4564d77c4374b02552cb17d1 -https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda#85ccccb47823dd9f7a99d2c7f530342f -https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda#c0d87c3c8e075daf1daf6c31b53e8083 -https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda#e5e7d467f80da752be17796b87fe6385 -https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda#369964e85dc26bfe78f41399b366c435 -https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-21.1.6-h4a912ad_0.conda#4a274d80967416bce3c7d89bf43923ec -https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda#068d497125e4bf8a66bf707254fff5ae -https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hd74edd7_1002.conda#415816daf82e0b23a736a069a75e9da7 -https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda#78b548eed8227a689f93775d5d23ae09 -https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda#9d1299ace1924aa8f4e0bc8e71dd0cf7 -https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda#eed7278dfbab727b56f2c0b64330814b -https://conda.anaconda.org/conda-forge/osx-arm64/isl-0.26-imath32_h347afa1_101.conda#e80e44a3f4862b1da870dc0557f8cf3b -https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda#a74332d9b60b62905e3d30709df08bf1 -https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20250512.1-cxx17_hd41c47c_0.conda#360dbb413ee2c170a0a684a33c4fc6b8 -https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h95a88de_0.conda#39d47dac85038e73b5f199f2b594a547 -https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hb1b9735_0.conda#4e3fec2238527187566e26a5ddbc2f83 -https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-devel-19.1.7-h6dc3340_1.conda#1399af81db60d441e7c6577307d5cf82 -https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-h742603c_1.conda#afccf412b03ce2f309f875ff88419173 -https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.51-hfab5511_0.conda#06efb9eace7676738ced2f9661c59fb8 -https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.0-h8adb53f_0.conda#5fb1945dbc6380e6fe7e939a62267772 -https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda#af523aae2eca6dfa1c8eec693f5b9a79 -https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda#438c97d1e9648dd7342f86049dd44638 -https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h49c215f_0.conda#175809cc57b2c67f27a0f238bd7f069d -https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda#b34dc4172653c13dcf453862f251af2b -https://conda.anaconda.org/conda-forge/osx-arm64/qhull-2020.2-h420ef59_5.conda#6483b1f59526e05d7d894e466b5b6924 -https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda#63ef3f6e6d6d5c589e64f11263dc5676 -https://conda.anaconda.org/conda-forge/osx-arm64/sleef-3.9.0-hb028509_0.conda#68f833178f171cfffdd18854c0e9b7f9 -https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1300.6.5-h03f4b80_0.conda#b703bc3e6cba5943acf0e5f987b5d0e2 -https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda#a73d54a5abba6543cb2f0af1bfbd6851 -https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.1-h8359307_2.conda#e3170d898ca6cb48f1bb567afb92f775 -https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.2.5-h3470cca_0.conda#c86493f35e79c93b04ff0279092b53e2 -https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda#e6f69c7bcccdefa417f056fa593b40f0 -https://conda.anaconda.org/conda-forge/osx-arm64/brotli-bin-1.2.0-hce9b42c_0.conda#2695046c2e5875fee19438aa752924a5 -https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda#6d4ede03e2a8e20eb51f7f681d2a2550 -https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-hfcf01ff_1.conda#f699348e3f4f924728e33551b1920f79 -https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.31.1-h658db43_2.conda#155d3d17eaaf49ddddfe6c73842bc671 -https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda#e2a72ab2fa54ecb6abab2b26cde93500 -https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.1-h9329255_0.conda#fb5ce61da27ee937751162f86beba6d1 -https://conda.anaconda.org/conda-forge/osx-arm64/mpfr-4.2.1-hb693164_3.conda#4e4ea852d54cc2b869842de5044662fb -https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.9-hfc2f54d_101_cp313.conda#a4241bce59eecc74d4d2396e108c93b8 -https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-0.1.3-h44b9a77_0.tar.bz2#4a2cac04f86a4540b8c9b8d8f597848f -https://conda.anaconda.org/conda-forge/osx-arm64/brotli-1.2.0-hca488c2_0.conda#3673e631cdf1fa81c9f5cc3da763a07e -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda#367133808e89325690562099851529c8 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/osx-arm64/cython-3.2.1-py313h66a7184_0.conda#e9970e29bc5029e981fedcd31cff310a -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda#66b8b26023b8efdf8fcb23bac4b6325d -https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.10.0-pyhd8ed1ab_0.conda#d18004c37182f83b9818b714825a7627 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/osx-arm64/kiwisolver-1.4.9-py313h7add70c_2.conda#9583687276aaa393e723f3b7970be69f -https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.17-h7eeda09_0.conda#92a61fd30b19ebd5c1621a5bfe6d8b5f -https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-2_h8d724d3_accelerate.conda#143e99fafc3cdd43c917ff8183f6a219 -https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda#f35fb38e89e2776994131fbf961fa44b -https://conda.anaconda.org/conda-forge/osx-arm64/libhiredis-1.0.2-hbec66e7_0.tar.bz2#37ca71a16015b17397da4a5e6883f66f -https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda#d1d9b233830f6631800acc1e081a9444 -https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py313h7d74516_0.conda#3df5979cc0b761dda0053ffdb0bca3ea -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/osx-arm64/mpc-1.3.1-h8f1351a_1.conda#a5635df796b71f6ca400fc7026f50701 -https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b -https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/networkx-3.5-pyhe01879c_0.conda#16bff3d37a4f99e3aa089c36c2b8d650 -https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hbfb3c88_0.conda#6bf3d24692c157a41c01ce0bd17daeea -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pybind11-global-2.13.6-pyh217bc35_3.conda#730a5284e26d6bdb73332dafb26aec82 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.2-py313h6535dbc_2.conda#c7fea1e31871009ff882a327ba4b7d9a -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/osx-arm64/ccache-4.11.3-hd7c7cec_0.conda#7fe1ee81492f43731ea583b4bee50b8b -https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.12.0-py313h7d74516_0.conda#35d87ef273c80581a7f73172b757e4e2 -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/osx-arm64/fonttools-4.60.1-py313h7d74516_0.conda#107233e5dccf267cfc6fd551a10aea4e -https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda#1ec9a1ee7a2c9339774ad9bb6fe6caec -https://conda.anaconda.org/conda-forge/osx-arm64/gmpy2-2.2.1-py313hc1c22ca_2.conda#08bbc47d90ccee895465f61b8692e236 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-955.13-llvm19_1_h6922315_9.conda#6725e9298bc2bc60c2dd48cc470db59b -https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-2_h752f6bc_accelerate.conda#e0e6e7e33c7bc6b61471ee1014b7d4a9 -https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_h73dfc95_5.conda#0b1110de04b80ea62e93fef6f8056fbb -https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-2_hcb0d94e_accelerate.conda#cc5238dd60dec488f46a164cdba0a0f5 -https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda#8237b150fcd7baf65258eef9a0fc76ef -https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.0.0-py313h54da0cd_0.conda#fe80ca21c7be92922c5718a46ec50959 -https://conda.anaconda.org/conda-forge/noarch/pybind11-2.13.6-pyhc790b64_3.conda#1594696beebf1ecb6d29a1136f859a74 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 -https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_h73dfc95_5.conda#561b822bdb2c1bb41e16e59a090f1e36 -https://conda.anaconda.org/conda-forge/osx-arm64/ld64-955.13-he86490a_9.conda#279533a0a5e350ee3c736837114f9aaf -https://conda.anaconda.org/conda-forge/osx-arm64/liblapacke-3.11.0-2_hbdd07e9_accelerate.conda#790ab9dc92e3f2374a848a27d3ea3be1 -https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda#3e3ac06efc5fdc1aa675ca30bf7d53df -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.3.5-py313h9771d21_0.conda#3f8330206033158d3e443120500af416 -https://conda.anaconda.org/conda-forge/osx-arm64/optree-0.18.0-py313ha61f8ec_0.conda#08c825d0a6cde154eb8c4729563114e7 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_105.conda#8c09fac3785696e1c477156192d64b91 -https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.4.1-pyhe01879c_0.conda#648e253c455718227c61e26f4a4ce701 -https://conda.anaconda.org/conda-forge/osx-arm64/blas-devel-3.11.0-2_h55bc449_accelerate.conda#a9d1c17bf0b35053727c05235be9b7ba -https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1024.3-llvm19_1_h8c76c84_9.conda#89b4c077857b4cfd7220a32e7f96f8e1 -https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_5.conda#6773a2b7d7d1b0a8d0e0f3bf4e928936 -https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py313ha61f8ec_3.conda#5643cff3e9ab77999fba139465156e35 -https://conda.anaconda.org/conda-forge/osx-arm64/libtorch-2.8.0-cpu_generic_hf67e7d3_2.conda#cebb78a08e92e7a1639d6e0a645c917a -https://conda.anaconda.org/conda-forge/osx-arm64/pandas-2.3.3-py313h7d16b84_1.conda#5ddddcc319d3aee21cc4fe4640a61f8a -https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d -https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.16.3-py313h0d10b07_1.conda#55c947938346fb644c2752383c40f935 -https://conda.anaconda.org/conda-forge/osx-arm64/blas-2.302-accelerate.conda#cce50d5ad6fc1de3752d42d71af96b6c -https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1024.3-hd01ab73_9.conda#3819ebcafd8ade70c3c20dd3e368b699 -https://conda.anaconda.org/conda-forge/osx-arm64/clangxx-19.1.7-default_h36137df_5.conda#c11a3a5a0cdb74d8ce58c6eac8d1f662 -https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda#8d99c82e0f5fed6cc36fcf66a11e03f0 -https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_impl_osx-arm64-14.3.0-h6d03799_1.conda#1e9ec88ecc684d92644a45c6df2399d0 -https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-base-3.10.8-py313h58042b9_0.conda#745c18472bc6d3dc9146c3dec18bb740 -https://conda.anaconda.org/conda-forge/osx-arm64/pyamg-5.3.0-py313h28ea3aa_1.conda#51a353d043e612a8f520627cf0e73653 -https://conda.anaconda.org/conda-forge/osx-arm64/pytorch-2.8.0-cpu_generic_py313_h1ee2325_2.conda#fce43a59b1180cdcb1ca67f5f45b72ac -https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda#39451684370ae65667fa5c11222e43f7 -https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-3.10.8-py313h39782a4_0.conda#bae471007cbebf097a19e851c219d56a -https://conda.anaconda.org/conda-forge/osx-arm64/pytorch-cpu-2.8.0-cpu_generic_py313_h510b526_2.conda#a8282f13e5e3abcc96a78154f0f25ae3 -https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-h76e6a08_25.conda#a4e2f211f7c3cf582a6cb447bee2cad9 -https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h07b0088_25.conda#1b53cb5305ae53b5aeba20e58c625d96 -https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda#148516e0c9edf4e9331a4d53ae806a9b -https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_impl_osx-arm64-19.1.7-h276745f_25.conda#5eeaa7b2dd32f62eb3beb0d6ba1e664f -https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_osx-arm64-14.3.0-h3c33bd0_0.conda#8db8c0061c0f3701444b7b9cc9966511 -https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_osx-arm64-19.1.7-h07b0088_25.conda#4e09188aa8def7d8b3ae149aa856c0e5 -https://conda.anaconda.org/conda-forge/osx-arm64/gfortran-14.3.0-h3ef1dbf_0.conda#e148e0bc9bbc90b6325a479a5501786d -https://conda.anaconda.org/conda-forge/osx-arm64/cxx-compiler-1.11.0-h88570a1_0.conda#043afed05ca5a0f2c18252ae4378bdee -https://conda.anaconda.org/conda-forge/osx-arm64/fortran-compiler-1.11.0-h81a4f41_0.conda#d221c62af175b83186f96d8b0880bff6 -https://conda.anaconda.org/conda-forge/osx-arm64/compilers-1.11.0-hce30654_0.conda#aac0d423ecfd95bde39582d0de9ca657 diff --git a/build_tools/azure/pylatest_free_threaded_linux-64_conda.lock b/build_tools/azure/pylatest_free_threaded_linux-64_conda.lock deleted file mode 100644 index 8628cfb70b54a..0000000000000 --- a/build_tools/azure/pylatest_free_threaded_linux-64_conda.lock +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: 7f842ff628171ca53fc79777d1a71909440a7c3af69979c721418352753a843a -@EXPLICIT -https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#d7c89558ba9fa0495403155b64376d81 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314t.conda#3251796e09870c978e0f69fa05e38fb6 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2#73aaf86a425cc6e73fcf236a5a46396d -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda#c7e925f37e3b40d893459e625f6a53f1 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e -https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda#be43915efc66345cccb3c310b6ed0374 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-2_h4a7cf45_openblas.conda#6146bf1b7f58113d54614c6ec683c14a -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-2_h0358290_openblas.conda#a84b2b7ed34206d14739fb8d29cd2799 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-2_h47877c9_openblas.conda#9fb20e74a7436dc94dd39d9a9decddc3 -https://conda.anaconda.org/conda-forge/linux-64/python-3.14.0-he1279bd_2_cp314t.conda#f82ece6dbaba8c6bf8ed6122eb273b9d -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.0-py314hd8ed1ab_2.conda#86fdc2e15c6f0efb98804a2c461f30b6 -https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.1-py314h3f98dc2_0.conda#eebd4c060e488edb97488858f1293190 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py314hd4f4903_0.conda#f9c8cd3ab6c388232550c806379856d5 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-freethreading-3.14.0-h92d6c8b_2.conda#bbd6d97a4f90042d5ae148217d3110a6 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py314hf5b80f4_1.conda#b010b4d97f99c579c759996db97e53c0 -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/noarch/pytest-run-parallel-0.7.1-pyhd8ed1ab_0.conda#1277cda67d2764e7b19d6b0bed02c812 diff --git a/build_tools/azure/pylatest_pip_openblas_pandas_linux-64_conda.lock b/build_tools/azure/pylatest_pip_openblas_pandas_linux-64_conda.lock deleted file mode 100644 index d9fcd7de5fc54..0000000000000 --- a/build_tools/azure/pylatest_pip_openblas_pandas_linux-64_conda.lock +++ /dev/null @@ -1,92 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: 87b9773659dff9019bf908b8a2c3c6529e7126ff500be1e050cce880641009dc -@EXPLICIT -https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#d7c89558ba9fa0495403155b64376d81 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2#73aaf86a425cc6e73fcf236a5a46396d -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda#c7e925f37e3b40d893459e625f6a53f1 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/python-3.13.9-hc97d973_101_cp313.conda#4780fe896e961722d0623fa91d0d3378 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -# pip alabaster @ https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl#sha256=fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b -# pip babel @ https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl#sha256=4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 -# pip certifi @ https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl#sha256=97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b -# pip charset-normalizer @ https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 -# pip coverage @ https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl#sha256=bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211 -# pip cycler @ https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl#sha256=85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 -# pip cython @ https://files.pythonhosted.org/packages/f9/33/5d9ca6abba0e77e1851b843dd1b3c4095fbc6373166935e83c4414f80e88/cython-3.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=f5a54a757d01ca6a260b02ce5baf17d9db1c2253566ab5844ee4966ff2a69c19 -# pip docutils @ https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl#sha256=dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 -# pip execnet @ https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl#sha256=67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec -# pip fonttools @ https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl#sha256=b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c -# pip idna @ https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl#sha256=771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea -# pip imagesize @ https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl#sha256=0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b -# pip iniconfig @ https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl#sha256=f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 -# pip joblib @ https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl#sha256=4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241 -# pip kiwisolver @ https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098 -# pip markupsafe @ https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 -# pip meson @ https://files.pythonhosted.org/packages/9c/07/b48592d325cb86682829f05216e4efb2dc881762b8f1bafb48b57442307a/meson-1.9.1-py3-none-any.whl#sha256=f824ab770c041a202f532f69e114c971918ed2daff7ea56583d80642564598d0 -# pip ninja @ https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa -# pip numpy @ https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017 -# pip packaging @ https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl#sha256=29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 -# pip pillow @ https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 -# pip pluggy @ https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl#sha256=e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 -# pip pygments @ https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl#sha256=86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b -# pip pyparsing @ https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl#sha256=e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e -# pip pytz @ https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl#sha256=5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 -# pip roman-numerals-py @ https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl#sha256=9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c -# pip six @ https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl#sha256=4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 -# pip snowballstemmer @ https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl#sha256=6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 -# pip sphinxcontrib-applehelp @ https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl#sha256=4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 -# pip sphinxcontrib-devhelp @ https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl#sha256=aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2 -# pip sphinxcontrib-htmlhelp @ https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl#sha256=166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 -# pip sphinxcontrib-jsmath @ https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl#sha256=2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 -# pip sphinxcontrib-qthelp @ https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl#sha256=b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb -# pip sphinxcontrib-serializinghtml @ https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl#sha256=6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 -# pip tabulate @ https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl#sha256=024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f -# pip threadpoolctl @ https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl#sha256=43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb -# pip tzdata @ https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl#sha256=1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 -# pip urllib3 @ https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl#sha256=e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc -# pip array-api-strict @ https://files.pythonhosted.org/packages/e1/7b/81bef4348db9705d829c58b9e563c78eddca24438f1ce1108d709e6eed55/array_api_strict-2.4.1-py3-none-any.whl#sha256=22198ceb47cd3d9c0534c50650d265848d0da6ff71707171215e6678ce811ca5 -# pip contourpy @ https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9 -# pip jinja2 @ https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl#sha256=85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 -# pip pyproject-metadata @ https://files.pythonhosted.org/packages/c0/57/e69a1de45ec7a99a707e9f1a5defa035a48de0cae2d8582451c72d2db456/pyproject_metadata-0.10.0-py3-none-any.whl#sha256=b1e439a9f7560f9792ee5975dcf5e89d2510b1fc84a922d7e5d665aa9102d966 -# pip pytest @ https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl#sha256=67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad -# pip python-dateutil @ https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl#sha256=a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 -# pip requests @ https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl#sha256=2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 -# pip scipy @ https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88 -# pip lightgbm @ https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl#sha256=cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d -# pip matplotlib @ https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1 -# pip meson-python @ https://files.pythonhosted.org/packages/28/58/66db620a8a7ccb32633de9f403fe49f1b63c68ca94e5c340ec5cceeb9821/meson_python-0.18.0-py3-none-any.whl#sha256=3b0fe051551cc238f5febb873247c0949cd60ded556efa130aa57021804868e2 -# pip pandas @ https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl#sha256=318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac -# pip pyamg @ https://files.pythonhosted.org/packages/63/f3/c13ae1422434baeefe4d4f306a1cc77f024fe96d2abab3c212cfa1bf3ff8/pyamg-5.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl#sha256=5cc223c66a7aca06fba898eb5e8ede6bb7974a9ddf7b8a98f56143c829e63631 -# pip pytest-cov @ https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl#sha256=440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 -# pip pytest-xdist @ https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl#sha256=202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 -# pip scipy-doctest @ https://files.pythonhosted.org/packages/f5/99/a17f725f45e57efcf5a84494687bba7176e0b5cba7ca0f69161a063fa86d/scipy_doctest-2.0.1-py3-none-any.whl#sha256=7725b1cb5f4722ab2a77b39f0aadd39726266e682b19e40f96663d7afb2d46b1 -# pip sphinx @ https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl#sha256=4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3 -# pip numpydoc @ https://files.pythonhosted.org/packages/6c/45/56d99ba9366476cd8548527667f01869279cedb9e66b28eb4dfb27701679/numpydoc-1.8.0-py3-none-any.whl#sha256=72024c7fd5e17375dec3608a27c03303e8ad00c81292667955c6fea7a3ccf541 diff --git a/build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock b/build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock deleted file mode 100644 index 521720e99c03a..0000000000000 --- a/build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: ddd5063484c104d6d6a6a54471148d6838f0475cd44c46b8a3a7e74476a68343 -@EXPLICIT -https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#d7c89558ba9fa0495403155b64376d81 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2#73aaf86a425cc6e73fcf236a5a46396d -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda#c7e925f37e3b40d893459e625f6a53f1 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/python-3.14.0-h32b2ec7_102_cp314.conda#0a19d2cc6eb15881889b0c6fa7d6a78d -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -# pip alabaster @ https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl#sha256=fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b -# pip babel @ https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl#sha256=4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 -# pip certifi @ https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl#sha256=97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b -# pip charset-normalizer @ https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 -# pip coverage @ https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl#sha256=fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d -# pip docutils @ https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl#sha256=dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 -# pip execnet @ https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl#sha256=67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec -# pip idna @ https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl#sha256=771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea -# pip imagesize @ https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl#sha256=0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b -# pip iniconfig @ https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl#sha256=f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 -# pip markupsafe @ https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 -# pip meson @ https://files.pythonhosted.org/packages/9c/07/b48592d325cb86682829f05216e4efb2dc881762b8f1bafb48b57442307a/meson-1.9.1-py3-none-any.whl#sha256=f824ab770c041a202f532f69e114c971918ed2daff7ea56583d80642564598d0 -# pip ninja @ https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa -# pip packaging @ https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl#sha256=29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 -# pip platformdirs @ https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl#sha256=e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3 -# pip pluggy @ https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl#sha256=e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 -# pip pygments @ https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl#sha256=86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b -# pip roman-numerals-py @ https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl#sha256=9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c -# pip six @ https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl#sha256=4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 -# pip snowballstemmer @ https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl#sha256=6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 -# pip sphinxcontrib-applehelp @ https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl#sha256=4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 -# pip sphinxcontrib-devhelp @ https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl#sha256=aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2 -# pip sphinxcontrib-htmlhelp @ https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl#sha256=166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 -# pip sphinxcontrib-jsmath @ https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl#sha256=2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 -# pip sphinxcontrib-qthelp @ https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl#sha256=b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb -# pip sphinxcontrib-serializinghtml @ https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl#sha256=6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 -# pip tabulate @ https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl#sha256=024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f -# pip threadpoolctl @ https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl#sha256=43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb -# pip urllib3 @ https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl#sha256=e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc -# pip jinja2 @ https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl#sha256=85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 -# pip pyproject-metadata @ https://files.pythonhosted.org/packages/c0/57/e69a1de45ec7a99a707e9f1a5defa035a48de0cae2d8582451c72d2db456/pyproject_metadata-0.10.0-py3-none-any.whl#sha256=b1e439a9f7560f9792ee5975dcf5e89d2510b1fc84a922d7e5d665aa9102d966 -# pip pytest @ https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl#sha256=67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad -# pip python-dateutil @ https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl#sha256=a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 -# pip requests @ https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl#sha256=2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 -# pip meson-python @ https://files.pythonhosted.org/packages/28/58/66db620a8a7ccb32633de9f403fe49f1b63c68ca94e5c340ec5cceeb9821/meson_python-0.18.0-py3-none-any.whl#sha256=3b0fe051551cc238f5febb873247c0949cd60ded556efa130aa57021804868e2 -# pip pooch @ https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl#sha256=3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47 -# pip pytest-cov @ https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl#sha256=440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 -# pip pytest-xdist @ https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl#sha256=202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 -# pip sphinx @ https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl#sha256=4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3 -# pip numpydoc @ https://files.pythonhosted.org/packages/6c/45/56d99ba9366476cd8548527667f01869279cedb9e66b28eb4dfb27701679/numpydoc-1.8.0-py3-none-any.whl#sha256=72024c7fd5e17375dec3608a27c03303e8ad00c81292667955c6fea7a3ccf541 diff --git a/build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock b/build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock deleted file mode 100644 index a6903bbe4eef5..0000000000000 --- a/build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock +++ /dev/null @@ -1,119 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: 80fba64a729753c6d1d7ebd81fd1f2c83ac6c3177861bc7a1b93e668e0b4f6ee -@EXPLICIT -https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#d7c89558ba9fa0495403155b64376d81 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2#73aaf86a425cc6e73fcf236a5a46396d -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 -https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 -https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e -https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 -https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc -https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.30-pthreads_h94d23a6_4.conda#be43915efc66345cccb3c310b6ed0374 -https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-2_h4a7cf45_openblas.conda#6146bf1b7f58113d54614c6ec683c14a -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/openblas-0.3.30-pthreads_h6ec200e_4.conda#379ec5261b0b8fc54f2e7bd055360b0c -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-2_h0358290_openblas.conda#a84b2b7ed34206d14739fb8d29cd2799 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-2_h47877c9_openblas.conda#9fb20e74a7436dc94dd39d9a9decddc3 -https://conda.anaconda.org/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda#c4202a55b4486314fbb8c11bc43a29a0 -https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a -https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h7c6b74e_0.conda#645bc783bc723d67a294a51bc860762d -https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda#96a02a5c1a65470a7e4eedb644c872fd -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda#a22d1fd9bf98827e280a02875d9a007a -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.1-py311h0daaf2c_0.conda#1be85c7845e9ba143f3cef9fd5780dc3 -https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e -https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac -https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda#53abe63df7e10a6ba605dc5f9f961d36 -https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2#7de5386c8fea29e76b303f37dde4c352 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-2_h6ae95b6_openblas.conda#35d16498d50b73886cb30014c2741726 -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_0.conda#0954f1a6a26df4a510b54f73b2a0345c -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py311h2e04523_0.conda#01da92ddaf561cabebd06019ae521510 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py311h07c5bb8_0.conda#51f505a537b2d216a1b36b823df80995 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/noarch/roman-numerals-py-3.1.0-pyhd8ed1ab_0.conda#5f0f24f8032c2c1bb33f59b75974f5fc -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda#755cf22df8693aa0d1aec1c123fa5863 -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb -https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_2.conda#959484a66b4b76befcddc4fa97c95567 -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 -https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda#0a01c169f0ab0f91b26e77a3301fbfe4 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-2_h1ea3ea9_openblas.conda#7cee1860b6bf5a1deb8a62a6b2dfcfbd -https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py311h03d9500_1.conda#3912e4373de46adafd8f1e97e4bd166b -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py311h1e13796_1.conda#e1947291b713cb0afa949e1bcda1f935 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.302-openblas.conda#fa34398c7f1c68bec5f00b0a841d2d05 -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py311hed34c8f_1.conda#72e3452bf0ff08132e86de0272f2fbb0 -https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py311h1d5f577_1.conda#65b9997185d6db9b8be75ccb11664de5 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda#ca45bfd4871af957aaa5035593d5efd2 -https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda#436c165519e140cb08d246a4472a9d6a -https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda#db0c6b99149880c8ba515cf4abe93ee4 -https://conda.anaconda.org/conda-forge/noarch/numpydoc-1.8.0-pyhd8ed1ab_1.conda#5af206d64d18d6c8dfb3122b4d9e643b -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 -https://conda.anaconda.org/conda-forge/noarch/sphinx-8.2.3-pyhd8ed1ab_0.conda#f7af826063ed569bb13f7207d6f949b0 -https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 diff --git a/build_tools/azure/pymin_conda_forge_openblas_win-64_conda.lock b/build_tools/azure/pymin_conda_forge_openblas_win-64_conda.lock deleted file mode 100644 index 507b357f67636..0000000000000 --- a/build_tools/azure/pymin_conda_forge_openblas_win-64_conda.lock +++ /dev/null @@ -1,119 +0,0 @@ -# Generated by conda-lock. -# platform: win-64 -# input_hash: 3aaf3eda4e528698421b31452dbf3227c6c3928b2b93c666c997c928b9ad8a61 -@EXPLICIT -https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 -https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 -https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb -https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda#71b24316859acd00bdb8b38f5e2ce328 -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda#f98fb7db808b94bc1ec5b0e62f9f1069 -https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda#8a86073cf3b343b87d03f41790d8b4e5 -https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_32.conda#58f67b437acbf2764317ba273d731f1d -https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab -https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h1383e82_7.conda#7f970a7f9801622add7746aa3cbc24d5 -https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_32.conda#378d5dcec45eaea8d303da6f00447ac0 -https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-2_gnu.conda#37e16618af5c4851a3f3d66dd0e11141 -https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h2b53caa_32.conda#ef02bbe151253a72b8eda264a935db66 -https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda#1077e9333c41ff0be8edd1a5ec0ddace -https://conda.anaconda.org/conda-forge/win-64/double-conversion-3.3.1-he0c23c2_0.conda#e9a1402439c18a4e3c7a52e4246e9e1c -https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.14-hac47afa_2.conda#b785694dd3ec77a011ccf0c24725382b -https://conda.anaconda.org/conda-forge/win-64/icu-75.1-he0c23c2_0.conda#8579b6bb8d18be7c0b27fb08adeeeb40 -https://conda.anaconda.org/conda-forge/win-64/lerc-4.0.0-h6470a55_1.conda#c1b81da6d29a14b542da14a36c9fbf3f -https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hc82b238_0.conda#a5607006c2135402ca3bb96ff9b87896 -https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda#e77030e67343e28b084fabd7db0ce43e -https://conda.anaconda.org/conda-forge/win-64/libexpat-2.7.3-hac47afa_0.conda#8c9e4f1a0e688eef2e95711178061a0f -https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h52bdfb6_0.conda#ba4ad812d2afc22b9a34ce8327a0930f -https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h1383e82_7.conda#926a82fc4fa5b284b1ca1fb74f20dee2 -https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda#64571d1dd6cdcfa25d0664a5950fdaa2 -https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.2-hfd05255_0.conda#56a686f92ac0273c0f6af58858a3f013 -https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.1-h2466b09_2.conda#c15148b2e18da456f5108ccb5e411446 -https://conda.anaconda.org/conda-forge/win-64/libopenblas-0.3.30-pthreads_h877e47f_4.conda#f551f8ae0ae6535be1ffde181f9377f3 -https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.51.0-hf5d6505_0.conda#d2c9300ebd2848862929b18c264d1b1e -https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.328.1-h477610d_0.conda#4403eae6c81f448d63a7f66c0b330536 -https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda#f9bbae5e2537e3b06e0f7310ba76c893 -https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.1-h2466b09_2.conda#41fbfac52c601159df6c01f875de31b9 -https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_0.conda#7ecb9f2f112c66f959d2bb7dbdb89b67 -https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.0-h725018a_0.conda#84f8fb4afd1157f59098f618cd2437e4 -https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda#08c8fa3b419df480d985e304f7884d35 -https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda#854fbdff64b572b5c0b470f334d34c11 -https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h2c6b04d_3.conda#7cb36e506a7dba4817970f8adb6396f9 -https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.2.5-h32d8bfd_0.conda#dec092b1a069abafc38655ded65a7b29 -https://conda.anaconda.org/conda-forge/win-64/krb5-1.21.3-hdf4eb48_0.conda#31aec030344e962fbd7dbbbbd68e60a9 -https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-2_h0adab6e_openblas.conda#95fa206f4ffdc2993fa6a48b07b4c77d -https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-h431afc6_0.conda#edc47a5d0ec6d95efefab3e99d0f4df0 -https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-ha521d6b_0.conda#f780291507a3f91d93a7147daea082f8 -https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda#2cf0cf76cc15d360dfa2f17fd6cf9772 -https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.51-h7351971_0.conda#5b98079b7e86c25c7e70ed7fd7da7da5 -https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.1-h06f855e_0.conda#4a5ea6ec2055ab0dfd09fd0c498f834a -https://conda.anaconda.org/conda-forge/win-64/openblas-0.3.30-pthreads_h4a7f399_4.conda#482e61f83248a880d180629bf8ed36b2 -https://conda.anaconda.org/conda-forge/win-64/pcre2-10.46-h3402e2f_0.conda#889053e920d15353c2665fa6310d7a7a -https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda#3c8f2573569bb816483e5cf57efbbe29 -https://conda.anaconda.org/conda-forge/win-64/python-3.11.14-h0159041_2_cpython.conda#02a9ba5950d8b78e6c9862d6ba7a5045 -https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda#8436cab9a76015dfe7208d3c9f97c156 -https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda#a7c03e38aa9c0e84d41881b9236eacfb -https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-hbeecb71_2.conda#21f56217d6125fb30c3c3f10c786d751 -https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-h6910e44_0.conda#c3a73d78af195cb2621e9e16426f7bba -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/win-64/cython-3.2.1-py311h9990397_0.conda#012d47877f130af0cf3434dbda810e96 -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.4.9-py311h275cad7_2.conda#e9eb24a8d111be48179bf82a9e0e13ca -https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-2_h2a8eebe_openblas.conda#ffc9f6913d7436e558b9d85a1c380591 -https://conda.anaconda.org/conda-forge/win-64/libclang13-21.1.6-default_ha2db4b5_0.conda#32b0f9f52f859396db50d738d50b4a82 -https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.1-hdbac1cb_0.conda#6e7c5c5ab485057b5d07fd8188ba5c28 -https://conda.anaconda.org/conda-forge/win-64/libglib-2.86.2-hd9c3897_0.conda#fbd144e60009d93f129f0014a76512d3 -https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-2_hd232482_openblas.conda#b42a971e4cef38ee91a7a42cdb224be4 -https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda#549845d5133100142452812feb9ba2e8 -https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda#a69bbf778a462da324489976c84cfc8c -https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.1-ha29bfb0_0.conda#87116b9de9c1825c3fd4ef92c984877b -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.2-py311h3485c13_2.conda#56b468f7a48593bc555c35e4a610d1f2 -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/win-64/unicodedata2-17.0.0-py311h3485c13_1.conda#a30a6a70ab7754dbf0b06fe1a96af9cb -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 -https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h17ff524_0.conda#60c575ea855a6aa03393aa3be2af0414 -https://conda.anaconda.org/conda-forge/win-64/coverage-7.12.0-py311h3f79411_0.conda#5eb14cad407cb102cc678fcaba4b0ee3 -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/win-64/lcms2-2.17-hbcf6048_0.conda#3538827f77b82a837fa681a4579e37a1 -https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.1-h57928b3_0.conda#3235024fe48d4087721797ebd6c9d28c -https://conda.anaconda.org/conda-forge/win-64/liblapacke-3.11.0-2_hbb0e6ff_openblas.conda#d0bc7a5338ff7d95e210a3f7e1264ed9 -https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda#46034d9d983edc21e84c0b36f1b4ba61 -https://conda.anaconda.org/conda-forge/win-64/numpy-2.3.5-py311h80b3fa1_0.conda#1e0fb210584b09130000c4404b77f0f6 -https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h24db6dd_0.conda#5af852046226bb3cb15c7f61c2ac020a -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/win-64/blas-devel-3.11.0-2_ha590de0_openblas.conda#2faff8da7caa95fedbebd4029c815910 -https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py311h3fd045d_3.conda#5e7e380c470e9f4683b3129fedafbcdf -https://conda.anaconda.org/conda-forge/win-64/fonttools-4.60.1-py311h3f79411_0.conda#00f530a3767510908b89b6c0f2698479 -https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.1-h57928b3_0.conda#d69c21967f35eb2ce7f1f85d6b6022d3 -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/win-64/pillow-12.0.0-py311hf7ee305_0.conda#c1e7a1806f85aac047cbadd6d4dfae41 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/win-64/scipy-1.16.3-py311hf127856_1.conda#48d562b3a3fb120d7c3f5e6af6d4b3e9 -https://conda.anaconda.org/conda-forge/win-64/blas-2.302-openblas.conda#9a3d6e4359ba0ce36b6dea7b6c32bd94 -https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.15.0-h765892d_1.conda#9bb0026a2131b09404c59c4290c697cd -https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.10.8-py311h1675fdf_0.conda#57671b98b86015c8b28551cdb09ee294 -https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d -https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h5782bbf_0.conda#20e32ced54300292aff690a69c5e7b97 -https://conda.anaconda.org/conda-forge/win-64/harfbuzz-12.2.0-h5f2951f_0.conda#e798ef748fc564e42f381d3d276850f0 -https://conda.anaconda.org/conda-forge/win-64/qt6-main-6.9.3-ha0de62e_1.conda#ca2bfad3a24794a0f7cf413b03906ade -https://conda.anaconda.org/conda-forge/win-64/pyside6-6.9.3-py311hf70c7b4_1.conda#db3dc429d8fa0cb3562eca20d94af620 -https://conda.anaconda.org/conda-forge/win-64/matplotlib-3.10.8-py311h1ea47a8_0.conda#64fe28aa2486e41918239d385336e88e diff --git a/build_tools/azure/ubuntu_atlas_lock.txt b/build_tools/azure/ubuntu_atlas_lock.txt deleted file mode 100644 index 6db4c2cd12771..0000000000000 --- a/build_tools/azure/ubuntu_atlas_lock.txt +++ /dev/null @@ -1,39 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# pip-compile --output-file=build_tools/azure/ubuntu_atlas_lock.txt build_tools/azure/ubuntu_atlas_requirements.txt -# -cython==3.1.2 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt -execnet==2.1.2 - # via pytest-xdist -iniconfig==2.3.0 - # via pytest -joblib==1.3.0 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt -meson==1.9.1 - # via meson-python -meson-python==0.18.0 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt -ninja==1.13.0 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt -packaging==25.0 - # via - # meson-python - # pyproject-metadata - # pytest -pluggy==1.6.0 - # via pytest -pygments==2.19.2 - # via pytest -pyproject-metadata==0.10.0 - # via meson-python -pytest==9.0.1 - # via - # -r build_tools/azure/ubuntu_atlas_requirements.txt - # pytest-xdist -pytest-xdist==3.8.0 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt -threadpoolctl==3.2.0 - # via -r build_tools/azure/ubuntu_atlas_requirements.txt diff --git a/build_tools/azure/upload_codecov.sh b/build_tools/azure/upload_codecov.sh deleted file mode 100755 index 4c3db8fe8bbd6..0000000000000 --- a/build_tools/azure/upload_codecov.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -set -e - -# Do not upload to codecov on forks -if [[ "$BUILD_REPOSITORY_NAME" != "scikit-learn/scikit-learn" ]]; then - exit 0 -fi - -# When we update the codecov uploader version, we need to update the checksums. -# The checksum for each codecov binary is available at -# https://cli.codecov.io e.g. for linux -# https://cli.codecov.io/v10.2.1/linux/codecov.SHA256SUM. - -# Instead of hardcoding a specific version and signature in this script, it -# would be possible to use the "latest" symlink URL but then we need to -# download both the codecov.SHA256SUM files each time and check the signatures -# with the codecov gpg key as well, see: -# https://docs.codecov.com/docs/codecov-uploader#integrity-checking-the-uploader -# However this approach would yield a larger number of downloads from -# codecov.io and keybase.io, therefore increasing the risk of running into -# network failures. -CODECOV_CLI_VERSION=10.2.1 -CODECOV_BASE_URL="https://cli.codecov.io/v$CODECOV_CLI_VERSION" - -# Check that the git repo is located at the expected location: -if [[ ! -d "$BUILD_REPOSITORY_LOCALPATH/.git" ]]; then - echo "Could not find the git checkout at $BUILD_REPOSITORY_LOCALPATH" - exit 1 -fi - -# Check that the combined coverage file exists at the expected location: -export COVERAGE_XML="$BUILD_REPOSITORY_LOCALPATH/coverage.xml" -if [[ ! -f "$COVERAGE_XML" ]]; then - echo "Could not find the combined coverage file at $COVERAGE_XML" - exit 1 -fi - -if [[ $OSTYPE == *"linux"* ]]; then - curl -Os "$CODECOV_BASE_URL/linux/codecov" - SHA256SUM="39dd112393680356daf701c07f375303aef5de62f06fc80b466b5c3571336014 codecov" - echo "$SHA256SUM" | shasum -a256 -c - chmod +x codecov - ./codecov upload-coverage -t ${CODECOV_TOKEN} -f coverage.xml -Z - ./codecov do-upload --disable-search --report-type test_results --file $JUNIT_FILE -elif [[ $OSTYPE == *"darwin"* ]]; then - curl -Os "$CODECOV_BASE_URL/macos/codecov" - SHA256SUM="01183f6367c7baff4947cce389eaa511b7a6d938e37ae579b08a86b51f769fd9 codecov" - echo "$SHA256SUM" | shasum -a256 -c - chmod +x codecov - ./codecov upload-coverage -t ${CODECOV_TOKEN} -f coverage.xml -Z - ./codecov do-upload --disable-search --report-type test_results --file $JUNIT_FILE -else - curl -Os "$CODECOV_BASE_URL/windows/codecov.exe" - SHA256SUM="e54e9520428701a510ef451001db56b56fb17f9b0484a266f184b73dd27b77e7 codecov.exe" - echo "$SHA256SUM" | sha256sum -c - ./codecov.exe upload-coverage -t ${CODECOV_TOKEN} -f coverage.xml -Z - ./codecov.exe do-upload --disable-search --report-type test_results --file $JUNIT_FILE -fi diff --git a/build_tools/azure/windows.yml b/build_tools/azure/windows.yml deleted file mode 100644 index b1c512c345a4c..0000000000000 --- a/build_tools/azure/windows.yml +++ /dev/null @@ -1,86 +0,0 @@ - -parameters: - name: '' - vmImage: '' - matrix: [] - dependsOn: [] - condition: ne(variables['Build.Reason'], 'Schedule') - -jobs: -- job: ${{ parameters.name }} - dependsOn: ${{ parameters.dependsOn }} - condition: ${{ parameters.condition }} - pool: - vmImage: ${{ parameters.vmImage }} - variables: - VIRTUALENV: 'testvenv' - JUNITXML: 'test-data.xml' - SKLEARN_SKIP_NETWORK_TESTS: '1' - PYTEST_XDIST_VERSION: 'latest' - TEST_DIR: '$(Agent.WorkFolder)/tmp_folder' - SHOW_SHORT_SUMMARY: 'false' - strategy: - matrix: - ${{ insert }}: ${{ parameters.matrix }} - - steps: - - bash: python build_tools/azure/get_selected_tests.py - displayName: Check selected tests for all random seeds - condition: eq(variables['Build.Reason'], 'PullRequest') - - bash: build_tools/azure/install_setup_conda.sh - displayName: Install conda if necessary and set it up - condition: startsWith(variables['DISTRIB'], 'conda') - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(PYTHON_VERSION)' - addToPath: true - architecture: 'x86' - displayName: Use 32 bit System Python - condition: and(succeeded(), eq(variables['PYTHON_ARCH'], '32')) - - bash: ./build_tools/azure/install.sh - displayName: 'Install' - - bash: ./build_tools/azure/test_script.sh - displayName: 'Test Library' - - bash: ./build_tools/azure/combine_coverage_reports.sh - condition: and(succeeded(), eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Combine coverage' - - task: PublishTestResults@2 - inputs: - testResultsFiles: '$(TEST_DIR)/$(JUNITXML)' - testRunTitle: ${{ format('{0}-$(Agent.JobName)', parameters.name) }} - displayName: 'Publish Test Results' - condition: succeededOrFailed() - - bash: | - set -ex - if [[ $(BOT_GITHUB_TOKEN) == "" ]]; then - echo "GitHub Token is not set. Issue tracker will not be updated." - exit - fi - - LINK_TO_RUN="https://dev.azure.com/$BUILD_REPOSITORY_NAME/_build/results?buildId=$BUILD_BUILDID&view=logs&j=$SYSTEM_JOBID" - CI_NAME="$SYSTEM_JOBIDENTIFIER" - ISSUE_REPO="$BUILD_REPOSITORY_NAME" - - $(pyTools.pythonLocation)/bin/pip install defusedxml PyGithub - $(pyTools.pythonLocation)/bin/python maint_tools/update_tracking_issue.py \ - $(BOT_GITHUB_TOKEN) \ - $CI_NAME \ - $ISSUE_REPO \ - $LINK_TO_RUN \ - --junit-file $JUNIT_FILE \ - --auto-close false - displayName: 'Update issue tracker' - env: - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) - condition: and(succeededOrFailed(), eq(variables['CREATE_ISSUE_ON_TRACKER'], 'true'), - eq(variables['Build.Reason'], 'Schedule')) - - bash: ./build_tools/azure/upload_codecov.sh - condition: and(succeeded(), - eq(variables['COVERAGE'], 'true'), - eq(variables['SELECTED_TESTS'], '')) - displayName: 'Upload To Codecov' - retryCountOnTaskFailure: 5 - env: - CODECOV_TOKEN: $(CODECOV_TOKEN) - JUNIT_FILE: $(TEST_DIR)/$(JUNITXML) diff --git a/build_tools/circle/doc_environment.yml b/build_tools/circle/doc_environment.yml index be39197894b58..32400419e0343 100644 --- a/build_tools/circle/doc_environment.yml +++ b/build_tools/circle/doc_environment.yml @@ -4,12 +4,13 @@ channels: - conda-forge dependencies: - - python=3.11 + - python=3.14 - numpy - blas - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas @@ -27,10 +28,10 @@ dependencies: - sphinx - sphinx-gallery - sphinx-copybutton - - numpydoc<1.9.0 + - numpydoc - sphinx-prompt - plotly - - polars=1.34.0 + - polars - pooch - sphinxext-opengraph - sphinx-remove-toctrees diff --git a/build_tools/circle/doc_linux-64_conda.lock b/build_tools/circle/doc_linux-64_conda.lock index 7aa32a4589b35..54940e3c25ddb 100644 --- a/build_tools/circle/doc_linux-64_conda.lock +++ b/build_tools/circle/doc_linux-64_conda.lock @@ -1,344 +1,343 @@ # Generated by conda-lock. # platform: linux-64 -# input_hash: ca6b5567d8c939295b5b4408ecaa611380022818d7f626c2732e529c500271e7 +# input_hash: 4b12513912fca83ea30e42ca19b201d8c46abb3f039e637f03ebba1cbd7d3f25 @EXPLICIT https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 -https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_8.conda#ff007ab0f0fdc53d245972bba8a6d40c -https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.0-hf2ce2f3_462.conda#0ec3505e9b16acc124d1ec6e5ae8207c -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa +https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda#86d9cba083cd041bfbf242a01a7a1999 +https://conda.anaconda.org/conda-forge/linux-64/onemkl-license-2025.3.1-hf2ce2f3_12.conda#95321ce2d03500a23a6e80034cbd4804 +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-h85bb3a7_107.conda#84915638a998fae4d495fa038683a73e +https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda#7d517e32d656a8880d98c0e4fc8ddc2c https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h85bb3a7_107.conda#eaf0f047b048c4d86a4b8c60c0e95f38 -https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.6-h4922eb0_0.conda#7a0b9ce502e0ed62195e02891dfcd704 -https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_8.conda#1bad93f0aa428d618875ef3a588a889e -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-6_kmp_llvm.conda#197811678264cb9da0d2ea0726a70661 -https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45-bootstrap_h59bd682_3.conda#5f1f949fc9c875458b5bc02a0c856f18 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda#d1a866495b9654ccfef5392b8541dc58 +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.5-h4922eb0_1.conda#f66101d2eb5de2924c10a63bbfa2926e +https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.1-hf2ce2f3_12.conda#c6e7262ad8afd5fe1d64554cfa456060 +https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda#13dc3adbc692664cd3beabd216434749 +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda#887b70e1d607fba7957aa02f9ee0d939 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 -https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45-bootstrap_h8a22499_3.conda#e39cc547941ee90dd512bfbe3d2a02d7 -https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45-bootstrap_h8a22499_3.conda#c990e32bb7fce8b93d78b67f5eb26117 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda#76df83c2a9035c54df5d04ff81bcc02d -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda#dcdc58c15961dbf17a0621312b01f5cb +https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda#8165352fdce2d2025bf884dc0ee85700 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h09219d5_0.conda#9b3117ec960b823815b02190b41c0484 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.21-h280c20c_3.conda#7af961ef4aa2c1136e11dd43ded245ab +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e -https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.7.1-h8fae777_3.conda#2c42649888aac645608191ffdc80d13a +https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda#d83958768626b3c8471ce032e28afcd3 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda#aa8d21be4b461ce612d8f5fb791decae https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda#a77f85f77be52ff59391544bfe73390a +https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda#212fe5f1067445544c99dc1c847d032c +https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda#2a307a17309d358c9b42afdd3199ddcc +https://conda.anaconda.org/conda-forge/linux-64/charls-2.4.3-hecca717_0.conda#937ca49a245fcf2b88d51b6b52959426 https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda#418c6ca5929a611cbd69204907a83995 -https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda#bfd56492d8346d669010eccafe0ba058 +https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda#dbe3ec0f120af456b3477743ffd99b74 https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda#3bf7b9fd5a7136126e0234db4b87c8b6 https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda#5aeabe88534ea4169d4c49998f293d6c -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.4-h3f801dc_0.conda#01ba04e414e47f95c03d6ddd81fd37be -https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hd53d788_0.conda#c183787d2b228775dece45842abbbe53 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h02bd7ab_0.conda#b7a924e3e9ebc7938ffc7d94fe603ed3 +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda#86f7414544ae606282352fa1e116b41f +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.3.0-h4c17acf_1.conda#c2a0c1d0120520e979685034e0b79859 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e -https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-hd08acf3_7.conda#716f4c96e07207d74e635c915b8b3f8b -https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.20-h4ab18f5_0.conda#a587892d3c13b6621a6091be690dbca2 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda#3a9428b74c403c71048104d38437b48c +https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda#007796e5a595bbc7df4a5e1580d72e1a +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 -https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda#7fa07cb0fb1b625a089ccc01218ee5b1 +https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd -https://conda.anaconda.org/conda-forge/linux-64/svt-av1-3.1.2-hecca717_0.conda#9859766c658e78fec9afa4a54891d920 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda#035da2e4f5770f036ff704fa17aace24 +https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda#2a2170a3e5c9a354d09e4be718c43235 +https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda#996583ea9c796e5b915f7d7580b51ea6 https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 -https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_3.conda#03b04e4effefa41aee638f8ba30a6e78 -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 +https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda#6a0eb48e58684cca4d7acc8b7a0fd3c7 +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda#346722a0be40f6edc53f12640d301338 https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d -https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hf2c8021_0.conda#5304333319a6124a2737d9f128cbc4ed +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 https://conda.anaconda.org/conda-forge/linux-64/brunsli-0.1-hd1e3526_2.conda#5948f4fead433c6e5c46444dbfb01162 -https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-2.22.0-h4cfbee9_0.conda#bede98a38485d588b3ec7e4ba2e46532 -https://conda.anaconda.org/conda-forge/linux-64/charls-2.4.2-h59595ed_0.conda#4336bd67920dd504cd8c6761d6a99645 -https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-hd9e9e21_7.conda#54876317578ad4bf695aad97ff8398d9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda#3f43953b7d3fb3aaa1d0d0723d91e368 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda#0cb0612bc9cb30c62baf41f9d600611b -https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.1-hf08fa70_5.conda#82954a6f42e3fba59628741dca105c98 +https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-3.0.2-hc31b594_0.conda#53b70d577abebd6fbfe21849e27c309b +https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda#99936dc616b7ce97b0468759b8a7c64e +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_1.conda#6016ea5ee9e986bc683879408cc87529 +https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda#850f48943d6b4589800a303f0de6a816 https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 https://conda.anaconda.org/conda-forge/linux-64/libzopfli-1.0.3-h9c3ff4c_0.tar.bz2#c66fe2d123249af7651ebde8984c51c2 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-habeac84_100_cp314.conda#a443f87920815d41bfe611296e507995 https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda#db038ce880f100acc74dba10302b5630 -https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-h41a2e66_0.conda#4ddfd44e473c676cb8e80548ba4aa704 -https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-hb991d5c_7.conda#39586596e88259bae48f904fb1025b77 -https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda#cae723309a49399d2949362f4ab5c9e4 -https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda#679616eb5ad4e521c83da4650860aba7 -https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h298d278_14.conda#fe0c2ac970a0b10835f3432a3dfd4542 -https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h7db7018_7.conda#a68add92b710d3139b46f46a27d06c80 -https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-he663afc_7.conda#2700e7aad63bca8c26c2042a6a7214d6 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.3.0-h6395336_2.conda#c09c4ac973f7992ba0c6bb1aafd77bd4 -https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda#d4a250da4737ee127fb1fa6452a9002e -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 -https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda#e7733bc6785ec009e47a224a71917e84 -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 -https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda#71ae752a748962161b4740eaff510258 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda#febbab7d15033c913d53c7a2c102309d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e -https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h387f397_9.conda#8035e5b54c08429354d5d64027041cad -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda#4afc585cd97ba8a23809406cd8a9eda8 -https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h76bdaa0_7.conda#cd5d2db69849f2fc7b592daf86c3015a -https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h1e4d427_14.conda#5d81121caf70d8799d90dabbf98e5d3d -https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hc876b51_14.conda#1852de0052b0d6af4294b3ae25a4a450 -https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a -https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda#e512be7dc1f84966d50959e900ca121f -https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda#2e5bf4f1da39c0b32778561c3c4e5878 -https://conda.anaconda.org/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda#c4202a55b4486314fbb8c11bc43a29a0 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda#4d1fc190b99912ed557a8236e958c559 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda#d3c295b50f092ab525ffe3c2aa4b7413 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda#2ccd714aa2242315acaf0a67faea780b -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda#17dcc85db3c7886650b8908b183d6876 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda#2de7f99d6581a4a7adbff607b5c278ca -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda#5efa5fa6243a622445fdfd72aee15efa +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a -https://conda.anaconda.org/conda-forge/noarch/attrs-25.4.0-pyh71513ae_0.conda#c7944d55af26b6d2d7629e27e9a972c1 -https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h7c6b74e_0.conda#645bc783bc723d67a294a51bc860762d -https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda#abd85120de1187b0d1ec305c2173c71b +https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda#c6b0543676ecb1fb2d7643941fe375f2 +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.4.0-py314h680f03e_0.conda#b712198b257f378e9bd8cde277218296 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda#8910d2c46f7e7b519129f486e0fe927a https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_1.tar.bz2#576d629e47797577ab0f1b351297ef4a -https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda#96a02a5c1a65470a7e4eedb644c872fd -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda#a22d1fd9bf98827e280a02875d9a007a -https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh707e725_0.conda#9ba00b39e03a0afb2b1cc0767d4c6175 -https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhd8ed1ab_0.conda#fcac5929097ba1f2a0e5b6ecaa13b253 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda#929471569c93acefb30282a22060dcd5 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda#2266262ce8a425ecb6523d765f79b303 https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cpython-3.11.14-py311hd8ed1ab_2.conda#43ed151bed1a0eb7181d305fed7cf051 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.1-py311h0daaf2c_0.conda#1be85c7845e9ba143f3cef9fd5780dc3 +https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda#fd57230e9a97b97bf20dd63aeae6fe61 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda#f111d4cfaf1fe9496f386bc98ae94452 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda#af491aae930edc096b58466c51c4126c +https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.4-py314h1807b08_0.conda#866fd3d25b767bccb4adc8476f4035cd +https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda#ce96f2f470d39bd96ce03945af92e280 https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2#961b3a227b437d82ad7054484cfa71b2 -https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc +https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda#d6bd3cd217e62bbd7efe67ff224cd667 +https://conda.anaconda.org/conda-forge/noarch/doit-0.37.0-pyhcf101f3_0.conda#37b3d4c558f2bb2b5378c43f4d6f1fb5 https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda#8f5b0b297b59e1ac160ad4beec99dbee -https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda#94394acdc56dcb4d55dddf0393134966 -https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda#91dc0abe7274ac5019deaa6100643265 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_24.conda#91b0f19212d79a1a4dca034aac729e4f +https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda#d5f5c8cc2a64220838a096041b7a7fb4 +https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda#8b867d053ed89743eeac52c3a50f112d https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac -https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda#53abe63df7e10a6ba605dc5f9f961d36 -https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2#7de5386c8fea29e76b303f37dde4c352 +https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda#fb7130c190f9b4ec91219840a05ba3ac +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/noarch/json5-0.12.1-pyhd8ed1ab_0.conda#0fc93f473c31a2f85c0bde213e7c63ca -https://conda.anaconda.org/conda-forge/linux-64/jsonpointer-3.0.0-py311h38be061_2.conda#5dd29601defbcc14ac6953d9504a80a7 -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py311h724c32c_2.conda#4089f739463c798e10d8644bc34e24de +https://conda.anaconda.org/conda-forge/noarch/json5-0.14.0-pyhd8ed1ab_0.conda#1269891272187518a0a75c286f7d0bbf +https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda#89bf346df77603055d3c8fe5811691e6 +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py314h97ea11e_0.conda#7397e418cab519b8d789936cf2dde6f6 https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda#9b965c999135d43a3d0f7bd7d024e26a -https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.1-default_h7f8ec31_1002.conda#c01021ae525a76fe62720c7346212d74 -https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.6-hf7376ad_0.conda#8aa154f30e0bc616cbde9794710e0be2 -https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_1.conda#638350cf5da41f3651958876a2104992 -https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda#372a62464d47d9e966b630ffae3abe73 -https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.0-hca5e8e5_0.conda#aa65b4add9574bb1d23c76560c5efd4c -https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda#87e6096ec6d542d1c1f8b33245fe8300 -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_0.conda#0954f1a6a26df4a510b54f73b2a0345c +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.1-hcfa2d63_0.conda#f79415aee8862b3af85ea55dea37e46b +https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda#49c553b47ff679a6a1e9fc80b9c5a2d4 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda#9a17c4307d23318476d7fbf0fedc0cde https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda#592132998493b3ff25fd7479396e8351 -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/narwhals-2.12.0-pyhcf101f3_0.conda#02cab382663872083b7e8675f09d9c21 -https://conda.anaconda.org/conda-forge/noarch/networkx-3.5-pyhe01879c_0.conda#16bff3d37a4f99e3aa089c36c2b8d650 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda#a2c1eeadae7a309daed9d62c96012a2b +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/linux-64/openjph-0.27.2-h8d634f6_0.conda#ac7564cac998d4df2f030de2e532291d +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2#457c2c8c08e54905d6954e79cb5b5db9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py311h07c5bb8_0.conda#51f505a537b2d216a1b36b823df80995 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.12.1.2-pyhd8ed1ab_0.conda#dc702b2fae7ebe770aff3c83adb16b63 -https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.0-pyhcf101f3_0.conda#5c7a868f8241e64e1cf5fdf4962f23e2 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.23.1-pyhd8ed1ab_0.conda#a1e91db2d17fd258c64921cb38e6745a -https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py311haee01d2_0.conda#2092b7977bc8e05eb17a1048724593a4 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda#89c0b6d1793601a2a3a3f7d2d3d8b937 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda#a11ab1f31af799dd93c3a39881528884 +https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda#4f225a966cfee267a79c5cb6382bd121 https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda#7d9daffbb8d8e0af0f769dbbcd173a54 https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.21.2-pyhe01879c_0.conda#23029aae904a2ba587daba708208012f -https://conda.anaconda.org/conda-forge/noarch/python-json-logger-2.0.7-pyhd8ed1ab_0.conda#a61bf9ec79426938ff785eb69dbb1960 -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py311h3778330_0.conda#707c3d23f2476d3bfde8345b4e7d7853 -https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py311h2315fbb_0.conda#6c87a0f4566469af3585b11d89163fd7 +https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.2-pyhd8ed1ab_0.conda#f6ad7450fc21e00ecc23812baed6d2e4 +https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda#2035f68f96be30dc60a5dfd7452c7941 https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2#912a71cc01012ee38e6b90ddd561e36f -https://conda.anaconda.org/conda-forge/noarch/roman-numerals-py-3.1.0-pyhd8ed1ab_0.conda#5f0f24f8032c2c1bb33f59b75974f5fc -https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.29.0-py311h902ca64_0.conda#9c57ad209dc7af39ada3b571202daf8d -https://conda.anaconda.org/conda-forge/noarch/send2trash-1.8.3-pyh0d859eb_1.conda#938c8de6b9de091997145b3bf25cdbf9 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda#0dc48b4b570931adc8641e55c6c17fe4 +https://conda.anaconda.org/conda-forge/linux-64/rpds-py-0.30.0-py314h2e6c369_0.conda#c1c368b5437b0d1a68f372ccf01cb133 +https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda#28eb91468df04f655a57bcfbb35fc5c5 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda#03fe290994c5e4ec17293cfb6bdce520 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda#755cf22df8693aa0d1aec1c123fa5863 -https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda#18c019ccf43769d211f2cf78e9ad46c2 +https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.3-pyhd8ed1ab_0.conda#18de09b20462742fe093ba39185d9bac https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb -https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_2.conda#959484a66b4b76befcddc4fa97c95567 https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py311h49ec1c0_2.conda#8d7a63fc9653ed0bdc253a51d9a5c371 -https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.3-pyhd8ed1ab_1.conda#019a7385be9af33791c989871317e1ed +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py314h5bd0f2a_0.conda#dc1ff1e915ab35a06b6fa61efae73ab5 +https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.0-pyhcf101f3_0.conda#4bada6a6d908a27262af8ebddf4f7492 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda#f6d7aa696c67756a650e91e15e88223c -https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py311h49ec1c0_1.conda#5e6d4026784e83c0a51c86ec428e8cc8 +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py314h5bd0f2a_0.conda#494fdf358c152f9fdd0673c128c2f3dd https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda#e7cb0f5745e4c5035a460248334af7eb https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda#6639b6b0d8b5a284f027a2003669aa65 https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda#2841eb5bfc75ce15e9a0054b98dcd64d https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda#2f1ed718fcd829c184a6d4f0f2e07409 -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f -https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda#df5e78d904988eb55042c0c97446079f +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 +https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda#b56e0c8432b56decafae7e78c5f29ba5 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e +https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda#755b096086851e1193f3b10347415d7c +https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.1-pyhcf101f3_0.conda#e1c36c6121a7c9c76f2f148f1e83b983 https://conda.anaconda.org/conda-forge/noarch/accessible-pygments-0.0.5-pyhd8ed1ab_1.conda#74ac5069774cdbc53910ec4d631a3999 -https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda#0a01c169f0ab0f91b26e77a3301fbfe4 -https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_0.conda#b1a27250d70881943cca0dd6b4ba0956 +https://conda.anaconda.org/conda-forge/noarch/bleach-6.3.0-pyhcf101f3_1.conda#7c5ebdc286220e8021bf55e6384acd67 https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_1.tar.bz2#9b347a7ec10940d3f7941ff6c460b551 -https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda#09262e66b19567aff4f592fb53b28760 -https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py311h03d9500_1.conda#3912e4373de46adafd8f1e97e4bd166b -https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda#5da8c935dca9186673987f79cef0b2a5 +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 +https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda#cf45f4278afd6f4e6d03eda0f435d527 https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.60.1-py311h3778330_0.conda#91f834f85ac92978cfc3c1c178573e85 -https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda#d5596f445a1273ddc5ea68864c01b69f +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.62.1-pyh7db6752_0.conda#14cf1ac7a1e29553c6918f7860aab6d8 +https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda#2dd149aa693db92758af3e685ef30439 +https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h6b77fdb_24.conda#491f76c26b2d032b21ba0b79cc324c4f +https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-h8a413ad_24.conda#ea3921760f33250a1c12926fce1660eb https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 -https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda#63ccfdc3a3ce25b027b8767eb722fca8 -https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda#c85c76dc67d75619a92f51dfbce06992 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda#080594bf4493e6bae2607e65390c520a +https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda#0ba6225c279baf7ea9473a62ea0ec9ae +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda#b38fe4e78ee75def7e599843ef4c1ab0 https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda#fd312693df06da3578383232528c468d -https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.6-default_h99862b1_0.conda#0fcc9b4d3fc5e5010a7098318d9b7971 -https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.6-default_h746c552_0.conda#f5b64315835b284c7eb5332202b1e14b -https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.0.0-pyhd8ed1ab_0.conda#5b5203189eb668f042ac2b0826244964 -https://conda.anaconda.org/conda-forge/noarch/memory_profiler-0.61.0-pyhd8ed1ab_1.conda#71abbefb6f3b95e1668cd5e0af3affb9 -https://conda.anaconda.org/conda-forge/noarch/mistune-3.1.4-pyhcf101f3_0.conda#f5a4d548d1d3bdd517260409fc21e205 +https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.5-pyhd8ed1ab_0.conda#75932da6f03a6bef32b70a51e991f6eb +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a +https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_2.conda#27ac5ae872a21375d980bd4a6f99edf3 +https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.2-default_hafda6a7_1000.conda#0ed3aa3e3e6bc85050d38881673a692f +https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.5-hf7376ad_1.conda#6adc0202fa7fcf0a5fce8c31ef2ed866 +https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9 +https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda#87e6096ec6d542d1c1f8b33245fe8300 +https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda#6d03368f2b2b0a5fb6839df53b2eb5e0 +https://conda.anaconda.org/conda-forge/noarch/memory_profiler-0.61.0-pyhcf101f3_1.conda#e1bccffd88819e75729412799824e270 +https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda#b97e84d1553b4a1c765b87fff83453ad +https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda#680608784722880fbfe1745067570b00 https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda#e51f1e4089cad105b6cac64bd8166587 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 -https://conda.anaconda.org/conda-forge/noarch/plotly-6.5.0-pyhd8ed1ab_0.conda#6d4c79b604d50c1140c32164f7eca72a -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda#76c4757c0ec9d11f969e8eb44899307b +https://conda.anaconda.org/conda-forge/noarch/plotly-6.6.0-pyhd8ed1ab_0.conda#3e9427ee186846052e81fadde8ebe96a +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/noarch/python-gil-3.11.14-hd8ed1ab_2.conda#a4effc7e6eb335d0e1080a5554590425 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.4-h4df99d1_100.conda#e4e60721757979d01d3964122f674959 +https://conda.anaconda.org/conda-forge/noarch/python-json-logger-3.2.1-pyh332efcf_0.conda#1cd2f3e885162ee1366312bd1b1677fd https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda#870293df500ca7e18bedefa5838a22ab https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda#36de09a8d3e5d5e6f4ee63af49e59706 https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda#7234f99325263a5af6d4cd195035e8f2 -https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda#e3259be3341da4bc06c5b7a78c8bf1bd -https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh0d859eb_0.conda#efba281bbdae5f6b0a1d53c6d4a97c93 -https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.5.0-pyhcf101f3_0.conda#2caf483992d5d92b232451f843bdc8af +https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda#17b43cee5cc84969529d5d0b0309b2cb +https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda#f1acf5fdefa8300de697982bcb1761c9 https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda#4d1fc190b99912ed557a8236e958c559 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda#f2ba4192d38b6cef2bb2c25029071d90 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda#2ccd714aa2242315acaf0a67faea780b +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda#17dcc85db3c7886650b8908b183d6876 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda#e192019153591938acf7322b6459d36e +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f -https://conda.anaconda.org/conda-forge/noarch/anyio-4.11.0-pyhcf101f3_0.conda#814472b61da9792fae28156cb9ee54f5 -https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py311h49ec1c0_2.conda#6e36e9d2b535c3fbe2e093108df26695 +https://conda.anaconda.org/conda-forge/noarch/anyio-4.13.0-pyhcf101f3_0.conda#af2df4b9108808da3dc76710fe50eae2 +https://conda.anaconda.org/conda-forge/linux-64/argon2-cffi-bindings-25.1.0-py314h5bd0f2a_2.conda#3cca1b74b2752917b5b65b81f61f0553 https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda#85c4f19f377424eafc4ed7911b291642 -https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.2-pyha770c72_0.conda#749ebebabc2cae99b2e5b3edd04c6ca2 -https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-h5f6438b_0.conda#08a03378bc5293c6f97637323802f480 -https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda#fdcf2e31dd960ef7c5daa9f2c95eff0e -https://conda.anaconda.org/conda-forge/noarch/doit-0.36.0-pyhd8ed1ab_1.conda#18d4243b3d30352f9dea8e522f6ff4d1 +https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda#5267bef8efea4127aacd1f4e1f149b6e +https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.3.0-hbca2aae_1.conda#f11a319b9700b203aa14c295858782b6 +https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda#abd85120de1187b0d1ec305c2173c71b https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda#d3549fd50d450b6d9e7dddff25dd2110 -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda#b8690f53007e9b5ee2c2178dd4ac778c -https://conda.anaconda.org/conda-forge/noarch/importlib-resources-6.5.2-pyhd8ed1ab_0.conda#e376ea42e9ae40f3278b0f79c9bf9826 +https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda#94394acdc56dcb4d55dddf0393134966 +https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda#91dc0abe7274ac5019deaa6100643265 +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.0-h6083320_0.conda#e194f6a2f498f0c7b1e6498bd0b12645 +https://conda.anaconda.org/conda-forge/noarch/importlib-resources-7.1.0-pyhd8ed1ab_0.conda#e3bffa82b874f8b9a2631bddb3869529 https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda#439cd0f567d697b20a8f45cb70a1005a -https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.6.3-pyhd8ed1ab_1.conda#4ebae00eae9705b0c3d6d1018a81d047 -https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.3-pyhd8ed1ab_1.conda#2d983ff1b82a1ccb6f2e9d8784bdd6bd -https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.4-pyhd8ed1ab_2.conda#d10d9393680734a8febc4b362a4c94f2 -https://conda.anaconda.org/conda-forge/noarch/mdit-py-plugins-0.5.0-pyhd8ed1ab_0.conda#1997a083ef0b4c9331f9191564be275e -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.0-h0e700b2_462.conda#a2e8e73f7132ea5ea70fda6f3cf05578 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda#ca45bfd4871af957aaa5035593d5efd2 +https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda#7b8bace4943e0dc345fc45938826f2b8 +https://conda.anaconda.org/conda-forge/noarch/jupyterlite-core-0.7.6-pyhcf101f3_0.conda#9885a00885bacfbf539e079a8aef0148 +https://conda.anaconda.org/conda-forge/linux-64/libclang13-22.1.5-default_h746c552_0.conda#c3df118cdc65584a78028bf225111b1b +https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_2.conda#53e7cbb2beb03d69a478631e23e340e9 +https://conda.anaconda.org/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda#405ec206d230d9d37ad7c2636114cbf4 +https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda#31ad065eda3c2d88f8215b1289df9c89 +https://conda.anaconda.org/conda-forge/noarch/mdit-py-plugins-0.6.0-pyhd8ed1ab_0.conda#9a704e945e87078f464726c69071677a +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-h51de99f_1.conda#6383c1684badc0d94408b12850cf07f1 +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda#8ac12aff0860280ee0cff7fa2cf63f3b +https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda#5da8c935dca9186673987f79cef0b2a5 +https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda#d5596f445a1273ddc5ea68864c01b69f https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda#0b0154421989637d424ccf0f104be51a -https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.25.1-pyhe01879c_0.conda#341fd940c242cf33e832c0402face56f -https://conda.anaconda.org/conda-forge/noarch/jupyterlite-core-0.6.4-pyhe01879c_0.conda#b1f5663c5ccf466416fb822d11e1aff3 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-2_h5875eb1_mkl.conda#6a1a4ec47263069b2dae3cfba106320c -https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.0-ha770c72_462.conda#619188d87dc94ed199e790d906d74bc3 -https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.34.0-py310hffdcd12_0.conda#496b18392ef5af544d22d18d91a2a371 +https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda#ada41c863af263cc4c5fcbaff7c3e4dc +https://conda.anaconda.org/conda-forge/noarch/jupyterlite-pyodide-kernel-0.7.2-pyhcf101f3_0.conda#ffe2104d16bc6896d9a09c3c95f2b9b6 +https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_2.conda#b513eb83b3137eca1192c34bf4f013a7 +https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.1-h0e700b2_12.conda#1a4a54fad5e36b8282ec6208dcb9bfb7 +https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.40.0-py310hffdcd12_0.conda#8eacf9ff4d4e1ca1b52f8f3ba3e0c993 https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.3-h5c1c036_1.conda#762af6d08fdfa7a45346b1466740bacd +https://conda.anaconda.org/conda-forge/linux-64/pyzmq-27.1.0-py312hda471dd_2.conda#082985717303dab433c976986c674b35 +https://conda.anaconda.org/conda-forge/noarch/requests-2.33.1-pyhcf101f3_1.conda#9659f587a8ceacc21864260acd02fc67 https://conda.anaconda.org/conda-forge/noarch/towncrier-25.8.0-pyhd8ed1ab_0.conda#3e0e8e44292bdac62f7bcbf0450b5cc7 -https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda#436c165519e140cb08d246a4472a9d6a -https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.25.1-he01879c_0.conda#13e31c573c884962318a738405ca3487 -https://conda.anaconda.org/conda-forge/noarch/jupyterlite-pyodide-kernel-0.6.1-pyhe01879c_0.conda#b55913693e8934299585267ce95af06e -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-2_hfef963f_mkl.conda#62ffd188ee5c953c2d6ac54662c158a7 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-2_h5e43f62_mkl.conda#4f33d79eda3c82c95a54e8c2981adddb +https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda#fdcf2e31dd960ef7c5daa9f2c95eff0e +https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda#8368d58342d0825f0843dc6acdd0c483 +https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.8.0-pyhcf101f3_0.conda#8a3d6d0523f66cf004e563a50d9392b3 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h5875eb1_mkl.conda#d03e4571f7876dcd4e530f3d07faf333 +https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.1-ha770c72_12.conda#db484eb7d5c23ca2a3129ddf5943de76 https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda#bbe1963f1e47f594070ffe87cdf612ea -https://conda.anaconda.org/conda-forge/noarch/polars-1.34.0-pyh6a1acc5_0.conda#d398dbcb3312bbebc2b2f3dbb98b4262 -https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.3-py311he4c1a5a_1.conda#8c769099c0729ff85aac64f566bcd0d7 -https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda#db0c6b99149880c8ba515cf4abe93ee4 -https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.0-pyh29332c3_0.conda#f56000b36f09ab7533877e695e4e8cb0 -https://conda.anaconda.org/conda-forge/noarch/jupytext-1.18.1-pyh80e38bb_0.conda#3c85f79f1debe2d2c82ac08f1c1126e1 -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-2_hdba1596_mkl.conda#96dea51ff1435bd823020e25fd02da59 -https://conda.anaconda.org/conda-forge/noarch/nbclient-0.10.2-pyhd8ed1ab_0.conda#6bb0d77277061742744176ab555b723c -https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py311h2e04523_0.conda#01da92ddaf561cabebd06019ae521510 -https://conda.anaconda.org/conda-forge/noarch/pooch-1.8.2-pyhd8ed1ab_3.conda#d2bbbd293097e664ffb01fc4cdaf5729 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-2_hcf00494_mkl.conda#77b464e7c3b853268dec4c82b21dca5a -https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py311hdf67eae_3.conda#c4e2f4d5193e55a70bb67a2aa07006ae -https://conda.anaconda.org/conda-forge/linux-64/imagecodecs-2025.11.11-py311h99464e2_0.conda#ef3de0e69e6b286b5ff5539c07a5c7d4 +https://conda.anaconda.org/conda-forge/noarch/polars-1.40.0-pyh58ad624_0.conda#fd16be490f5403adfbf27dd4901bbe34 +https://conda.anaconda.org/conda-forge/noarch/pooch-1.9.0-pyhd8ed1ab_0.conda#dd4b6337bf8886855db6905b336db3c8 +https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.0-pl5321h16c4a6b_4.conda#c81127acb50fdc7760682495fc9ab088 +https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda#bf42ee94c750c0b2e7e998b79ac299ea +https://conda.anaconda.org/conda-forge/noarch/jupytext-1.19.2-pyh0398c0e_0.conda#866d6b93cd3efa827ac3223c2c3cccbc +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_hfef963f_mkl.conda#72cf77ee057f87d826f9b98cacd67a59 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h5e43f62_mkl.conda#8b13738802df008211c9ecd08775ca21 +https://conda.anaconda.org/conda-forge/noarch/nbclient-0.10.4-pyhd8ed1ab_0.conda#00f5b8dafa842e0c27c1cd7296aa4875 +https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.11.0-py314h3987850_2.conda#c77e1fe23b6cf0b6077e5f924ac420c9 +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-6_hdba1596_mkl.conda#5efff83ae645656f28c826aa192e7651 +https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda#2bce0d047658a91b99441390b9b27045 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.3-py314h2b28147_0.conda#36f5b7eb328bdc204954a2225cf908e2 +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-6_hcf00494_mkl.conda#b789b886f2b45c3a9c91935639717808 +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h97ea11e_4.conda#95bede9cdb7a30a4b611223d52a01aa4 +https://conda.anaconda.org/conda-forge/linux-64/imagecodecs-2026.3.6-py314h2730e07_3.conda#8664abf57c0ab721522b72879a101d6c https://conda.anaconda.org/conda-forge/noarch/imageio-2.37.0-pyhfb79c49_0.conda#b5577bc2212219566578fd5af9993af6 -https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.16.6-pyhcf101f3_1.conda#cfc86ccc3b1de35d36ccaae4c50391f5 -https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py311hed34c8f_1.conda#72e3452bf0ff08132e86de0272f2fbb0 +https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.18.2-pyhcf101f3_0.conda#5ee7945accf0f215ddd6055d25d7cd83 +https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.2-py314hb4ffadd_0.conda#41ee6fe2a848876bc9f524c5a500b85b https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.2-pyhcf101f3_0.conda#8678577a52161cc4e1c93fcc18e8a646 -https://conda.anaconda.org/conda-forge/linux-64/pywavelets-1.9.0-py311h0372a8f_2.conda#4e078a6bafb23473ea476450f45c9650 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py311h1e13796_1.conda#e1947291b713cb0afa949e1bcda1f935 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.302-mkl.conda#9c83adee9e1069446e6cc92b8ea19797 -https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.17.0-pyhcf101f3_0.conda#d79a87dcfa726bcea8e61275feed6f83 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py311h0f3be63_0.conda#21a0139015232dc0edbf6c2179b5ec24 -https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py311h1d5f577_1.conda#65b9997185d6db9b8be75ccb11664de5 -https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.14.5-py311h0372a8f_1.conda#9db66ee103839915d80e7573b522d084 -https://conda.anaconda.org/conda-forge/noarch/tifffile-2025.10.16-pyhd8ed1ab_0.conda#f5b9f02d19761f79c564900a2a399984 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py314hf07bd8e_0.conda#d0510124f87c75403090e220db1e9d41 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.306-mkl.conda#51424ae4b1ba5521ee838721d63d4390 https://conda.anaconda.org/conda-forge/noarch/jupyterlab_server-2.28.0-pyhcf101f3_0.conda#a63877cb23de826b1620d3adfccc4014 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py311h38be061_0.conda#08b5a4eac150c688c9f924bcb3317e02 -https://conda.anaconda.org/conda-forge/linux-64/scikit-image-0.25.2-py311hed34c8f_2.conda#515ec832e4a98828374fded73405e3f3 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py314h1194b4b_0.conda#11a821746ad11e642fcc615c3d66aa44 +https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py314h3a4f467_1.conda#478c6ef795065cd15cdbe1e214b30175 +https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.14.6-py314hc02f841_0.conda#224e6e308b3df5c0c99d8ca5244bb34c +https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.5.2-pyhd8ed1ab_0.conda#acb237de455d7fbac79afc8a33eb43c0 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.9-py314hdafbbf9_0.conda#2046de06d7f4149a29c5d0e2cc26d6dd +https://conda.anaconda.org/conda-forge/linux-64/scikit-image-0.26.0-np2py314hda1ea4c_0.conda#50d6faa367ca045c438d3bb25315b476 https://conda.anaconda.org/conda-forge/noarch/seaborn-base-0.13.2-pyhd8ed1ab_3.conda#fd96da444e81f9e6fcaac38590f3dd42 https://conda.anaconda.org/conda-forge/noarch/seaborn-0.13.2-hd8ed1ab_3.conda#62afb877ca2c2b4b6f9ecb37320085b6 -https://conda.anaconda.org/conda-forge/noarch/jupyterlite-sphinx-0.22.0-pyhd8ed1ab_0.conda#058a1b9b7deca7ab48659088543a8158 -https://conda.anaconda.org/conda-forge/noarch/numpydoc-1.8.0-pyhd8ed1ab_1.conda#5af206d64d18d6c8dfb3122b4d9e643b -https://conda.anaconda.org/conda-forge/noarch/pydata-sphinx-theme-0.16.1-pyhd8ed1ab_0.conda#837aaf71ddf3b27acae0e7e9015eebc6 +https://conda.anaconda.org/conda-forge/noarch/jupyterlite-sphinx-0.22.1-pyhcf101f3_0.conda#1f90643873d0cc2f7b0bf2752db71016 +https://conda.anaconda.org/conda-forge/noarch/numpydoc-1.10.0-pyhcf101f3_0.conda#3aa4b625f20f55cf68e92df5e5bf3c39 +https://conda.anaconda.org/conda-forge/noarch/pydata-sphinx-theme-0.17.1-pyhcf101f3_0.conda#620cee61c85cf6a407f80e8d502796ec https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.5.2-pyhd8ed1ab_1.conda#bf22cb9c439572760316ce0748af3713 -https://conda.anaconda.org/conda-forge/noarch/sphinx-design-0.6.1-pyhd8ed1ab_2.conda#3e6c15d914b03f83fc96344f917e0838 -https://conda.anaconda.org/conda-forge/noarch/sphinx-gallery-0.19.0-pyhd8ed1ab_0.conda#3cfa26d23bd7987d84051879f202a855 +https://conda.anaconda.org/conda-forge/noarch/sphinx-design-0.7.0-pyhd8ed1ab_0.conda#28eddfb8b9ecdd044a6f609f985398a7 +https://conda.anaconda.org/conda-forge/noarch/sphinx-gallery-0.21.0-pyhd8ed1ab_0.conda#9b783047bd5bef0998f129bef8fad477 https://conda.anaconda.org/conda-forge/noarch/sphinx-prompt-1.10.1-pyhd8ed1ab_0.conda#bfc047865de18ef2657bd8a95d7b8b49 https://conda.anaconda.org/conda-forge/noarch/sphinx-remove-toctrees-1.0.0.post1-pyhd8ed1ab_1.conda#b275c865b753413caaa8548b9d44c024 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 -https://conda.anaconda.org/conda-forge/noarch/sphinx-8.2.3-pyhd8ed1ab_0.conda#f7af826063ed569bb13f7207d6f949b0 +https://conda.anaconda.org/conda-forge/noarch/sphinx-9.1.0-pyhd8ed1ab_0.conda#aabfbc2813712b71ba8beb217a978498 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 https://conda.anaconda.org/conda-forge/noarch/sphinxext-opengraph-0.13.0-pyhd8ed1ab_0.conda#1a159db0a9774bd77c1ea293bcaf17b7 # pip libsass @ https://files.pythonhosted.org/packages/fd/5a/eb5b62641df0459a3291fc206cf5bd669c0feed7814dded8edef4ade8512/libsass-0.23.0-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl#sha256=4a218406d605f325d234e4678bd57126a66a88841cb95bee2caeafdc6f138306 diff --git a/build_tools/circle/doc_min_dependencies_environment.yml b/build_tools/circle/doc_min_dependencies_environment.yml index 9d23aedf93b1f..38fd8d5e394e1 100644 --- a/build_tools/circle/doc_min_dependencies_environment.yml +++ b/build_tools/circle/doc_min_dependencies_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy=1.10.0 # min - cython=3.1.2 # min - joblib + - narwhals - threadpoolctl - matplotlib=3.6.1 # min - pyamg=5.0.0 # min diff --git a/build_tools/circle/doc_min_dependencies_linux-64_conda.lock b/build_tools/circle/doc_min_dependencies_linux-64_conda.lock index f171bd9b1de94..44b2f4756d5fe 100644 --- a/build_tools/circle/doc_min_dependencies_linux-64_conda.lock +++ b/build_tools/circle/doc_min_dependencies_linux-64_conda.lock @@ -1,83 +1,89 @@ # Generated by conda-lock. # platform: linux-64 -# input_hash: e0e4e2867718dacb1dd2b73cc3d277f941cbc79163f0a0f5f7fa23098d0b45b5 +# input_hash: 9895cebc3b59336319819bf179c2dcdfcc3c51ee7a8841807f6c9dccaf2d76a0 @EXPLICIT https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 -https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_8.conda#ff007ab0f0fdc53d245972bba8a6d40c -https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.0-hf2ce2f3_462.conda#0ec3505e9b16acc124d1ec6e5ae8207c +https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda#86d9cba083cd041bfbf242a01a7a1999 +https://conda.anaconda.org/conda-forge/linux-64/onemkl-license-2025.3.1-hf2ce2f3_12.conda#95321ce2d03500a23a6e80034cbd4804 https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 -https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-h85bb3a7_107.conda#84915638a998fae4d495fa038683a73e +https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.3.0-hf649bbc_119.conda#7d517e32d656a8880d98c0e4fc8ddc2c https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda#f7b4d76975aac7e5d9e6ad13845f92fe -https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h85bb3a7_107.conda#eaf0f047b048c4d86a4b8c60c0e95f38 -https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.6-h4922eb0_0.conda#7a0b9ce502e0ed62195e02891dfcd704 -https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_8.conda#1bad93f0aa428d618875ef3a588a889e -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-6_kmp_llvm.conda#197811678264cb9da0d2ea0726a70661 -https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45-bootstrap_h59bd682_3.conda#5f1f949fc9c875458b5bc02a0c856f18 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.3.0-h9f08a49_119.conda#d1a866495b9654ccfef5392b8541dc58 +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.5-h4922eb0_1.conda#f66101d2eb5de2924c10a63bbfa2926e +https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.1-hf2ce2f3_12.conda#c6e7262ad8afd5fe1d64554cfa456060 +https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda#13dc3adbc692664cd3beabd216434749 +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda#887b70e1d607fba7957aa02f9ee0d939 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 -https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45-bootstrap_h8a22499_3.conda#e39cc547941ee90dd512bfbe3d2a02d7 -https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45-bootstrap_h8a22499_3.conda#c990e32bb7fce8b93d78b67f5eb26117 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda#76df83c2a9035c54df5d04ff81bcc02d -https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda#791365c5f65975051e4e017b5da3abf5 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda#dcdc58c15961dbf17a0621312b01f5cb +https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda#8165352fdce2d2025bf884dc0ee85700 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda#f9f81ea472684d75b9dd8d0b328cf655 https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h09219d5_0.conda#9b3117ec960b823815b02190b41c0484 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-hd0affe5_1.conda#499cd8e2d4358986dbe3b30e8fe1bf6a https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda#68e52064ed3897463c0e958ab5c8f91b -https://conda.anaconda.org/conda-forge/linux-64/libopus-1.5.2-hd0c01bc_0.conda#b64523fb87ac6f87f0790f324ad43046 +https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda#2446ac1fe030c2aa6141386c1f5a6aed https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e -https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.7.1-h8fae777_3.conda#2c42649888aac645608191ffdc80d13a +https://conda.anaconda.org/conda-forge/linux-64/rav1e-0.8.1-h1fbca29_0.conda#d83958768626b3c8471ce032e28afcd3 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda#9a809ce9f65460195777f2f2116bae02 +https://conda.anaconda.org/conda-forge/linux-64/binutils-2.45.1-default_h4852527_102.conda#212fe5f1067445544c99dc1c847d032c +https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda#2a307a17309d358c9b42afdd3199ddcc +https://conda.anaconda.org/conda-forge/linux-64/charls-2.4.3-hecca717_0.conda#937ca49a245fcf2b88d51b6b52959426 https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda#418c6ca5929a611cbd69204907a83995 -https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda#a59c05d22bdcbb4e984bf0c021a2a02f https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda#3bf7b9fd5a7136126e0234db4b87c8b6 https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda#5aeabe88534ea4169d4c49998f293d6c https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2#a8832b479f93521a9e7b5b743803be51 -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.4-h3f801dc_0.conda#01ba04e414e47f95c03d6ddd81fd37be -https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda#3b0d184bc9404516d418d4509e418bdc -https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hd53d788_0.conda#c183787d2b228775dece45842abbbe53 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h02bd7ab_0.conda#b7a924e3e9ebc7938ffc7d94fe603ed3 -https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda#09c264d40c67b82b49a3f3b89037bd2e +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda#86f7414544ae606282352fa1e116b41f +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda#a1cfcc585f0c42bf8d5546bb1dfb668d -https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda#2f4de899028319b27eb7a4023be5dfd2 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.3.0-h4c17acf_1.conda#c2a0c1d0120520e979685034e0b79859 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e -https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-hd08acf3_7.conda#716f4c96e07207d74e635c915b8b3f8b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e +https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda#47595b9d53054907a00d95e4d47af1d6 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda#3a9428b74c403c71048104d38437b48c +https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.3.0-h8f1669f_19.conda#007796e5a595bbc7df4a5e1580d72e1a +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda#8ee3cb7f64be0e8c4787f3a4dbe024e6 https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda#b4ecbefe517ed0157c37f8182768271c https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc @@ -87,192 +93,182 @@ https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b5 https://conda.anaconda.org/conda-forge/linux-64/nspr-4.38-h29cc59b_0.conda#e235d5566c9cc8970eb2798dd4ecf62f https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd -https://conda.anaconda.org/conda-forge/linux-64/svt-av1-3.1.2-hecca717_0.conda#9859766c658e78fec9afa4a54891d920 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d +https://conda.anaconda.org/conda-forge/linux-64/svt-av1-4.0.1-hecca717_0.conda#2a2170a3e5c9a354d09e4be718c43235 https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 -https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_3.conda#03b04e4effefa41aee638f8ba30a6e78 -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 +https://conda.anaconda.org/conda-forge/linux-64/zfp-1.0.1-h909a3a2_5.conda#6a0eb48e58684cca4d7acc8b7a0fd3c7 +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda#346722a0be40f6edc53f12640d301338 https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d -https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hf2c8021_0.conda#5304333319a6124a2737d9f128cbc4ed +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 https://conda.anaconda.org/conda-forge/linux-64/brunsli-0.1-hd1e3526_2.conda#5948f4fead433c6e5c46444dbfb01162 -https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-2.22.0-h4cfbee9_0.conda#bede98a38485d588b3ec7e4ba2e46532 -https://conda.anaconda.org/conda-forge/linux-64/charls-2.4.2-h59595ed_0.conda#4336bd67920dd504cd8c6761d6a99645 -https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-hd9e9e21_7.conda#54876317578ad4bf695aad97ff8398d9 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda#3f43953b7d3fb3aaa1d0d0723d91e368 -https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda#fd9cf4a11d07f0ef3e44fc061611b1ed -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda#3f7a43b3160ec0345c9535a9f0d7908e -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h6548e54_1.conda#f01292fb36b6d00d5c51e5d46b513bcf -https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.1-hf08fa70_5.conda#82954a6f42e3fba59628741dca105c98 -https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.10-hd0affe5_2.conda#b04e0a2163a72588a40cde1afd6f2d18 +https://conda.anaconda.org/conda-forge/linux-64/c-blosc2-3.0.2-hc31b594_0.conda#53b70d577abebd6fbfe21849e27c309b +https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.3.0-h235f0fe_19.conda#99936dc616b7ce97b0468759b8a7c64e +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_19.conda#35d07243abf828674d273aecd1dd537e +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_1.conda#6016ea5ee9e986bc683879408cc87529 +https://conda.anaconda.org/conda-forge/linux-64/libjxl-0.11.2-h174a0a3_1.conda#850f48943d6b4589800a303f0de6a816 +https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda#067590f061c9f6ea7e61e3b2112ed6b3 https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 https://conda.anaconda.org/conda-forge/linux-64/libzopfli-1.0.3-h9c3ff4c_0.tar.bz2#c66fe2d123249af7651ebde8984c51c2 +https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda#567fbeed956c200c1db5782a424e58ee +https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-hd63d673_0_cpython.conda#a5ebcefec0c12a333bcd6d7bf3bddc1f https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda#db038ce880f100acc74dba10302b5630 -https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-h41a2e66_0.conda#4ddfd44e473c676cb8e80548ba4aa704 -https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-hb991d5c_7.conda#39586596e88259bae48f904fb1025b77 -https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda#cae723309a49399d2949362f4ab5c9e4 -https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda#679616eb5ad4e521c83da4650860aba7 -https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h298d278_14.conda#fe0c2ac970a0b10835f3432a3dfd4542 -https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda#c42356557d7f2e37676e121515417e3b -https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h7db7018_7.conda#a68add92b710d3139b46f46a27d06c80 -https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.2-hf516916_1.conda#495c262933b7c5b8c09413d44fa5974b -https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-he663afc_7.conda#2700e7aad63bca8c26c2042a6a7214d6 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.3.0-h6395336_2.conda#c09c4ac973f7992ba0c6bb1aafd77bd4 -https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda#d4a250da4737ee127fb1fa6452a9002e -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 -https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda#e7733bc6785ec009e47a224a71917e84 -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 -https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda#71ae752a748962161b4740eaff510258 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda#febbab7d15033c913d53c7a2c102309d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda#4afc585cd97ba8a23809406cd8a9eda8 -https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h76bdaa0_7.conda#cd5d2db69849f2fc7b592daf86c3015a -https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h1e4d427_14.conda#5d81121caf70d8799d90dabbf98e5d3d -https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-hc876b51_14.conda#1852de0052b0d6af4294b3ae25a4a450 -https://conda.anaconda.org/conda-forge/linux-64/libflac-1.4.3-h59595ed_0.conda#ee48bf17cc83a00f59ca1494d5646869 -https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a -https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda#e512be7dc1f84966d50959e900ca121f -https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda#567fbeed956c200c1db5782a424e58ee -https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda#2e5bf4f1da39c0b32778561c3c4e5878 -https://conda.anaconda.org/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda#c4202a55b4486314fbb8c11bc43a29a0 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda#d3c295b50f092ab525ffe3c2aa4b7413 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda#5efa5fa6243a622445fdfd72aee15efa +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a https://conda.anaconda.org/conda-forge/noarch/alabaster-0.7.16-pyhd8ed1ab_0.conda#def531a3ac77b7fb8c21d17bb5d0badb -https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h7c6b74e_0.conda#645bc783bc723d67a294a51bc860762d -https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda#abd85120de1187b0d1ec305c2173c71b -https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda#96a02a5c1a65470a7e4eedb644c872fd -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda#a22d1fd9bf98827e280a02875d9a007a -https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh707e725_0.conda#9ba00b39e03a0afb2b1cc0767d4c6175 +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.4.0-py311h6b1f9c4_0.conda#aa8c3009fd8903bebdcb22fbcb4c0dea +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda#86daecb8e4ed1042d5dc6efbe0152590 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda#929471569c93acefb30282a22060dcd5 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda#2266262ce8a425ecb6523d765f79b303 https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 +https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.3.0-he8ccf15_19.conda#fd57230e9a97b97bf20dd63aeae6fe61 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda#af491aae930edc096b58466c51c4126c https://conda.anaconda.org/conda-forge/linux-64/cython-3.1.2-py311ha3e34f5_2.conda#f56da6e1e1f310f27cca558e58882f40 +https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda#ce96f2f470d39bd96ce03945af92e280 https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda#8f5b0b297b59e1ac160ad4beec99dbee -https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda#94394acdc56dcb4d55dddf0393134966 -https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda#91dc0abe7274ac5019deaa6100643265 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.3.0-h50e9bb6_24.conda#91b0f19212d79a1a4dca034aac729e4f +https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-14.3.0-h1a219da_19.conda#d5f5c8cc2a64220838a096041b7a7fb4 +https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.1-hcfc306f_1.conda#ff216b19c24f3a46e9d17ebcf2f96390 +https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-h2185e75_19.conda#8b867d053ed89743eeac52c3a50f112d https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac -https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda#53abe63df7e10a6ba605dc5f9f961d36 -https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2#7de5386c8fea29e76b303f37dde4c352 +https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda#fb7130c190f9b4ec91219840a05ba3ac +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py311h724c32c_2.conda#4089f739463c798e10d8644bc34e24de -https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.1-default_h7f8ec31_1002.conda#c01021ae525a76fe62720c7346212d74 -https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.6-hf7376ad_0.conda#8aa154f30e0bc616cbde9794710e0be2 -https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_1.conda#638350cf5da41f3651958876a2104992 -https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc60ed4a_1.conda#ef1910918dd895516a769ed36b5b3a4e -https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.0-hca5e8e5_0.conda#aa65b4add9574bb1d23c76560c5efd4c -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_0.conda#0954f1a6a26df4a510b54f73b2a0345c -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py311h724c32c_0.conda#3d82751e8d682068b58f049edc924ce4 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libavif16-1.4.1-hcfa2d63_0.conda#f79415aee8862b3af85ea55dea37e46b +https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda#49c553b47ff679a6a1e9fc80b9c5a2d4 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda#f9efdf9b0f3d0cc309d56af6edf2a6b0 +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c https://conda.anaconda.org/conda-forge/noarch/networkx-3.4-pyhd8ed1ab_0.conda#17878dfc0a15a6e9d2aaef351a4210dc -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py311h07c5bb8_0.conda#51f505a537b2d216a1b36b823df80995 -https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.0-pyhcf101f3_0.conda#5c7a868f8241e64e1cf5fdf4962f23e2 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/linux-64/openjph-0.27.2-h8d634f6_0.conda#ac7564cac998d4df2f030de2e532291d +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda#89c0b6d1793601a2a3a3f7d2d3d8b937 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda#fd5062942bfa1b0bd5e0d2a4397b099e -https://conda.anaconda.org/conda-forge/linux-64/psutil-7.1.3-py311haee01d2_0.conda#2092b7977bc8e05eb17a1048724593a4 -https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 +https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py311haee01d2_0.conda#2ed8f6fe8b51d8e19f7621941f7bb95f +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 +https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.2-pyhd8ed1ab_0.conda#f6ad7450fc21e00ecc23812baed6d2e4 https://conda.anaconda.org/conda-forge/noarch/pytz-2024.1-pyhd8ed1ab_0.conda#3eeeeb9e4827ace8c0c1419c85d590ad -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda#755cf22df8693aa0d1aec1c123fa5863 -https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8-pyhd8ed1ab_0.conda#18c019ccf43769d211f2cf78e9ad46c2 +https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.8.3-pyhd8ed1ab_0.conda#18de09b20462742fe093ba39185d9bac https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb -https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.2-pyhd8ed1ab_0.conda#5d99943f2ae3cc69e1ada12ce9d4d701 +https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda#043f0599dc8aa023369deacdb5ac24eb https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py311h49ec1c0_2.conda#8d7a63fc9653ed0bdc253a51d9a5c371 +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py311h49ec1c0_0.conda#73b44a114241e564deb5846e7394bf19 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py311h49ec1c0_1.conda#5e6d4026784e83c0a51c86ec428e8cc8 -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 -https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhd8ed1ab_0.conda#df5e78d904988eb55042c0c97446079f +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py311h49ec1c0_0.conda#2889f0c0b6a6d7a37bd64ec60f4cc210 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 +https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda#b56e0c8432b56decafae7e78c5f29ba5 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e +https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.1-pyhcf101f3_0.conda#e1c36c6121a7c9c76f2f148f1e83b983 https://conda.anaconda.org/conda-forge/noarch/accessible-pygments-0.0.5-pyhd8ed1ab_1.conda#74ac5069774cdbc53910ec4d631a3999 -https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda#0a01c169f0ab0f91b26e77a3301fbfe4 -https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda#09262e66b19567aff4f592fb53b28760 -https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py311h03d9500_1.conda#3912e4373de46adafd8f1e97e4bd166b -https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda#5da8c935dca9186673987f79cef0b2a5 +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.60.1-py311h3778330_0.conda#91f834f85ac92978cfc3c1c178573e85 -https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda#d5596f445a1273ddc5ea68864c01b69f -https://conda.anaconda.org/conda-forge/linux-64/glib-2.86.2-h5192d8d_1.conda#7071a9745767777b4be235f8c164ea75 +https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.62.1-py311h3778330_0.conda#dd214022a8f01bc2ebed383dfdc8deea +https://conda.anaconda.org/conda-forge/linux-64/gcc-14.3.0-h0dff253_19.conda#2dd149aa693db92758af3e685ef30439 +https://conda.anaconda.org/conda-forge/linux-64/gfortran_linux-64-14.3.0-h6b77fdb_24.conda#491f76c26b2d032b21ba0b79cc324c4f +https://conda.anaconda.org/conda-forge/linux-64/glib-2.88.1-h435ced3_1.conda#7d844a122c6cf1d8d2fb024f85757225 +https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-h8a413ad_24.conda#ea3921760f33250a1c12926fce1660eb https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 -https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda#63ccfdc3a3ce25b027b8767eb722fca8 -https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.5.2-pyhd8ed1ab_0.conda#c85c76dc67d75619a92f51dfbce06992 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.6-default_h99862b1_0.conda#0fcc9b4d3fc5e5010a7098318d9b7971 -https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.6-default_h746c552_0.conda#f5b64315835b284c7eb5332202b1e14b -https://conda.anaconda.org/conda-forge/noarch/memory_profiler-0.61.0-pyhd8ed1ab_1.conda#71abbefb6f3b95e1668cd5e0af3affb9 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.8.0-pyhcf101f3_0.conda#080594bf4493e6bae2607e65390c520a +https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda#0ba6225c279baf7ea9473a62ea0ec9ae +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.5-pyhd8ed1ab_0.conda#75932da6f03a6bef32b70a51e991f6eb +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a +https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.2-default_hafda6a7_1000.conda#0ed3aa3e3e6bc85050d38881673a692f +https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.5-hf7376ad_1.conda#6adc0202fa7fcf0a5fce8c31ef2ed866 +https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9 +https://conda.anaconda.org/conda-forge/noarch/memory_profiler-0.61.0-pyhcf101f3_1.conda#e1bccffd88819e75729412799824e270 +https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda#680608784722880fbfe1745067570b00 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py311hf88fc01_0.conda#b4e4b0fc807b68aa1706457f2e31279d https://conda.anaconda.org/conda-forge/noarch/plotly-5.18.0-pyhd8ed1ab_0.conda#9f6a8664f1fe752f79473eeb9bf33a60 https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda#b8ea447fdf62e3597cb8d2fae4eb1a90 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 https://conda.anaconda.org/conda-forge/linux-64/sip-6.10.0-py311h1ddb823_1.conda#8012258dbc1728a96a7a72a2b3daf2ad -https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda#e3259be3341da4bc06c5b7a78c8bf1bd https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 -https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.2-pyha770c72_0.conda#749ebebabc2cae99b2e5b3edd04c6ca2 -https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda#fdcf2e31dd960ef7c5daa9f2c95eff0e -https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.24.11-hc37bda9_0.conda#056d86cacf2b48c79c6a562a2486eb8c -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda#b8690f53007e9b5ee2c2178dd4ac778c -https://conda.anaconda.org/conda-forge/noarch/importlib-resources-6.5.2-pyhd8ed1ab_0.conda#e376ea42e9ae40f3278b0f79c9bf9826 -https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.4-pyhd8ed1ab_2.conda#d10d9393680734a8febc4b362a4c94f2 -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.0-h0e700b2_462.conda#a2e8e73f7132ea5ea70fda6f3cf05578 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda#f2ba4192d38b6cef2bb2c25029071d90 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda#5267bef8efea4127aacd1f4e1f149b6e +https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda#abd85120de1187b0d1ec305c2173c71b +https://conda.anaconda.org/conda-forge/linux-64/gfortran-14.3.0-he448592_7.conda#94394acdc56dcb4d55dddf0393134966 +https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.26.11-h29cf534_0.conda#1e0e854b77451ac918b4a68f28932b1d +https://conda.anaconda.org/conda-forge/linux-64/gxx-14.3.0-he448592_7.conda#91dc0abe7274ac5019deaa6100643265 +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.0-h6083320_0.conda#e194f6a2f498f0c7b1e6498bd0b12645 +https://conda.anaconda.org/conda-forge/noarch/importlib-resources-7.1.0-pyhd8ed1ab_0.conda#e3bffa82b874f8b9a2631bddb3869529 +https://conda.anaconda.org/conda-forge/noarch/lazy_loader-0.5-pyhd8ed1ab_0.conda#4c8327180586e7b1cd8b6815fc8827f1 +https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp22.1-22.1.5-default_h99862b1_0.conda#eb9e3f61562dcf3a5d313e45cf7b0dd6 +https://conda.anaconda.org/conda-forge/linux-64/libclang13-22.1.5-default_h746c552_0.conda#c3df118cdc65584a78028bf225111b1b +https://conda.anaconda.org/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda#405ec206d230d9d37ad7c2636114cbf4 +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh8b19718_0.conda#35870d32aed92041d31cbb15e822dca3 https://conda.anaconda.org/conda-forge/linux-64/pyqt5-sip-12.17.0-py311h1ddb823_2.conda#4f296d802e51e7a6889955c7f1bd10be -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda#ca45bfd4871af957aaa5035593d5efd2 -https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.24.11-h651a532_0.conda#d8d8894f8ced2c9be76dc9ad1ae531ce -https://conda.anaconda.org/conda-forge/noarch/lazy_loader-0.4-pyhd8ed1ab_2.conda#bb0230917e2473c77d615104dbe8a49d -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-2_h5875eb1_mkl.conda#6a1a4ec47263069b2dae3cfba106320c -https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.0-ha770c72_462.conda#619188d87dc94ed199e790d906d74bc3 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-h51de99f_1.conda#6383c1684badc0d94408b12850cf07f1 +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/linux-64/cxx-compiler-1.11.0-hfcd1e18_0.conda#5da8c935dca9186673987f79cef0b2a5 +https://conda.anaconda.org/conda-forge/linux-64/fortran-compiler-1.11.0-h9bea470_0.conda#d5596f445a1273ddc5ea68864c01b69f +https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.1-h0e700b2_12.conda#1a4a54fad5e36b8282ec6208dcb9bfb7 +https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda#d53ffc0edc8eabf4253508008493c5bc https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/noarch/requests-2.33.1-pyhcf101f3_1.conda#9659f587a8ceacc21864260acd02fc67 https://conda.anaconda.org/conda-forge/noarch/towncrier-24.8.0-pyhd8ed1ab_1.conda#820b6a1ddf590fba253f8204f7200d82 -https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda#436c165519e140cb08d246a4472a9d6a -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-2_hfef963f_mkl.conda#62ffd188ee5c953c2d6ac54662c158a7 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-2_h5e43f62_mkl.conda#4f33d79eda3c82c95a54e8c2981adddb -https://conda.anaconda.org/conda-forge/linux-64/qt-main-5.15.15-h3c3fd16_6.conda#5aab84b9d164509b5bbe3af660518606 -https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda#db0c6b99149880c8ba515cf4abe93ee4 -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-2_hdba1596_mkl.conda#96dea51ff1435bd823020e25fd02da59 -https://conda.anaconda.org/conda-forge/linux-64/numpy-1.24.1-py311h8e6699e_0.conda#bd7c9bf413aa9478ea5f68123e796ab1 +https://conda.anaconda.org/conda-forge/linux-64/compilers-1.11.0-ha770c72_0.conda#fdcf2e31dd960ef7c5daa9f2c95eff0e +https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.26.11-h6d08254_0.conda#971da16e7fc43161329213557688d315 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h5875eb1_mkl.conda#d03e4571f7876dcd4e530f3d07faf333 +https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.1-ha770c72_12.conda#db484eb7d5c23ca2a3129ddf5943de76 https://conda.anaconda.org/conda-forge/noarch/pooch-1.8.0-pyhd8ed1ab_0.conda#134b2b57b7865d2316a7cce1915a51ed +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_hfef963f_mkl.conda#72cf77ee057f87d826f9b98cacd67a59 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h5e43f62_mkl.conda#8b13738802df008211c9ecd08775ca21 +https://conda.anaconda.org/conda-forge/linux-64/qt-main-5.15.15-h0c412b5_8.conda#80e27e7982af989ebc2e0f0d57c75ea7 +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-6_hdba1596_mkl.conda#5efff83ae645656f28c826aa192e7651 +https://conda.anaconda.org/conda-forge/linux-64/numpy-1.24.1-py311h8e6699e_0.conda#bd7c9bf413aa9478ea5f68123e796ab1 https://conda.anaconda.org/conda-forge/linux-64/pyqt-5.15.11-py311h0580839_2.conda#59ae5d8d4bcb1371d61ec49dfb985c70 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-2_hcf00494_mkl.conda#77b464e7c3b853268dec4c82b21dca5a +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-6_hcf00494_mkl.conda#b789b886f2b45c3a9c91935639717808 https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.2-py311hd18a35c_0.conda#f8e440efa026c394461a45a46cea49fc -https://conda.anaconda.org/conda-forge/linux-64/imagecodecs-2025.11.11-py311h99464e2_0.conda#ef3de0e69e6b286b5ff5539c07a5c7d4 +https://conda.anaconda.org/conda-forge/linux-64/imagecodecs-2026.3.6-py311h5d55412_3.conda#b6784a1d00abcf066925b91b71f887fc https://conda.anaconda.org/conda-forge/noarch/imageio-2.37.0-pyhfb79c49_0.conda#b5577bc2212219566578fd5af9993af6 https://conda.anaconda.org/conda-forge/linux-64/pandas-2.2.3-py311h7db5c69_1.conda#643f8cb35133eb1be4919fb953f0a25f https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.2-pyhcf101f3_0.conda#8678577a52161cc4e1c93fcc18e8a646 https://conda.anaconda.org/conda-forge/linux-64/polars-0.20.30-py311h00856b1_0.conda#5113e0013db6b28be897218ddf9835f9 https://conda.anaconda.org/conda-forge/linux-64/pywavelets-1.8.0-py311h9f3472d_0.conda#17334e5c12abdf2db6b25bd4187cd3e4 https://conda.anaconda.org/conda-forge/linux-64/scipy-1.10.0-py311h8e6699e_2.conda#29e7558b75488b2d5c7d1458be2b3b11 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.302-mkl.conda#9c83adee9e1069446e6cc92b8ea19797 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.306-mkl.conda#51424ae4b1ba5521ee838721d63d4390 https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.6.1-py311he728205_1.tar.bz2#88af4d7dc89608bfb7665a9685578800 https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.0.0-py311hcb41070_0.conda#af2d6818c526791fb81686c554ab262b -https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.14.5-py311h0372a8f_1.conda#9db66ee103839915d80e7573b522d084 -https://conda.anaconda.org/conda-forge/noarch/tifffile-2025.10.16-pyhd8ed1ab_0.conda#f5b9f02d19761f79c564900a2a399984 +https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.14.6-py311h0372a8f_0.conda#dd92402db25b74b98489a4c144f14b62 +https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.3.3-pyhd8ed1ab_0.conda#cecacab21bc8f4ed17fac11bc8b08cf0 https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.6.1-py311h38be061_1.tar.bz2#37d18a25f4f7fcef45ba4fb31cbe30af https://conda.anaconda.org/conda-forge/linux-64/scikit-image-0.22.0-py311h320fe9a_2.conda#e94b7f09b52628b89e66cdbd8c3029dd https://conda.anaconda.org/conda-forge/noarch/seaborn-base-0.12.0-pyhd8ed1ab_0.tar.bz2#05ee2fb22c1eca4309c06d11aff049f3 diff --git a/build_tools/generate_authors_table.py b/build_tools/generate_authors_table.py index 6dcddda40af4d..500a6dddd4fdb 100644 --- a/build_tools/generate_authors_table.py +++ b/build_tools/generate_authors_table.py @@ -104,13 +104,11 @@ def get_contributors(): emeritus_contributor_experience_team = { "cmarmo", } - emeritus_comm_team = {"reshamas"} + emeritus_comm_team = {"reshamas", "laurburke"} # Up-to-now, we can subtract the team emeritus from the original emeritus emeritus -= emeritus_contributor_experience_team | emeritus_comm_team - comm_team -= {"reshamas"} # in the comm team but not on the web page - # get profiles from GitHub core_devs = [get_profile(login) for login in core_devs] emeritus = [get_profile(login) for login in emeritus] diff --git a/build_tools/get_comment.py b/build_tools/get_comment.py index 2c25ae9da8605..1725865e6dba5 100644 --- a/build_tools/get_comment.py +++ b/build_tools/get_comment.py @@ -1,11 +1,10 @@ # This script is used to generate a comment for a PR when linting issues are # detected. It is used by the `Comment on failed linting` GitHub Action. -# This script fails if there are not comments to be posted. import os import re -import requests +from github import Auth, Github, GithubException def get_versions(versions_file): @@ -67,15 +66,15 @@ def get_step_message(log, start, end, title, message, details): return res -def get_message(log_file, repo, pr_number, sha, run_id, details, versions): +def get_message(log_file, repo_str, pr_number, sha, run_id, details, versions): with open(log_file, "r") as f: log = f.read() sub_text = ( "\n\n<sub> _Generated for commit:" - f" [{sha[:7]}](https://github.com/{repo}/pull/{pr_number}/commits/{sha}). " + f" [{sha[:7]}](https://github.com/{repo_str}/pull/{pr_number}/commits/{sha}). " "Link to the linter CI: [here]" - f"(https://github.com/{repo}/actions/runs/{run_id})_ </sub>" + f"(https://github.com/{repo_str}/actions/runs/{run_id})_ </sub>" ) if "### Linting completed ###" not in log: @@ -189,12 +188,8 @@ def get_message(log_file, repo, pr_number, sha, run_id, details, versions): ) if not message: - # no issues detected, so this script "fails" - return ( - "## โœ”๏ธ Linting Passed\n" - "All linting checks passed. Your pull request is in excellent shape! โ˜€๏ธ" - + sub_text - ) + # no issues detected, the linting succeeded + return None if not details: # This happens if posting the log fails, which happens if the log is too @@ -216,7 +211,7 @@ def get_message(log_file, repo, pr_number, sha, run_id, details, versions): + "https://scikit-learn.org/dev/developers/development_setup.html#set-up-pre-commit)" + ".\n\n" + "You can see the details of the linting issues under the `lint` job [here]" - + f"(https://github.com/{repo}/actions/runs/{run_id})\n\n" + + f"(https://github.com/{repo_str}/actions/runs/{run_id})\n\n" + message + sub_text ) @@ -224,96 +219,50 @@ def get_message(log_file, repo, pr_number, sha, run_id, details, versions): return message -def get_headers(token): - """Get the headers for the GitHub API.""" - return { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - } - - -def find_lint_bot_comments(repo, token, pr_number): +def find_lint_bot_comments(issue): """Get the comment from the linting bot.""" - # repo is in the form of "org/repo" - # API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#list-issue-comments - response = requests.get( - f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", - headers=get_headers(token), - ) - response.raise_for_status() - all_comments = response.json() failed_comment = "โŒ Linting issues" - success_comment = "โœ”๏ธ Linting Passed" - - # Find all comments that match the linting bot, and return the first one. - # There should always be only one such comment, or none, if the PR is - # just created. - comments = [ - comment - for comment in all_comments - if comment["user"]["login"] == "github-actions[bot]" - and (failed_comment in comment["body"] or success_comment in comment["body"]) - ] - - if len(all_comments) > 25 and not comments: - # By default the API returns the first 30 comments. If we can't find the - # comment created by the bot in those, then we raise and we skip creating - # a comment in the first place. - raise RuntimeError("Comment not found in the first 30 comments.") - - return comments[0] if comments else None - - -def create_or_update_comment(comment, message, repo, pr_number, token): - """Create a new comment or update existing one.""" - # repo is in the form of "org/repo" + + for comment in issue.get_comments(): + if comment.user.login == "github-actions[bot]": + if failed_comment in comment.body: + return comment + + return None + + +def create_or_update_comment(comment, message, issue): + """Create a new comment or update the existing linting comment.""" + if comment is not None: - print("updating existing comment") - # API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#update-an-issue-comment - response = requests.patch( - f"https://api.github.com/repos/{repo}/issues/comments/{comment['id']}", - headers=get_headers(token), - json={"body": message}, - ) + print("Updating existing comment") + comment.edit(message) else: - print("creating new comment") - # API doc: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment - response = requests.post( - f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", - headers=get_headers(token), - json={"body": message}, - ) + print("Creating new comment") + issue.create_comment(message) - response.raise_for_status() +def update_linter_fails_label(linting_failed, issue): + """Add or remove the label indicating that the linting has failed.""" -def update_linter_fails_label(message, repo, pr_number, token): - """ "Add or remove the label indicating that the linting has failed.""" + label = "CI:Linter failure" + + if linting_failed: + issue.add_to_labels(label) - if "โŒ Linting issues" in message: - # API doc: https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue - response = requests.post( - f"https://api.github.com/repos/{repo}/issues/{pr_number}/labels", - headers=get_headers(token), - json={"labels": ["CI:Linter failure"]}, - ) - response.raise_for_status() else: - # API doc: https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#remove-a-label-from-an-issue - response = requests.delete( - f"https://api.github.com/repos/{repo}/issues/{pr_number}/labels/CI:Linter" - " failure", - headers=get_headers(token), - ) - # If the label was not set, trying to remove it returns a 404 error - if response.status_code != 404: - response.raise_for_status() + try: + issue.remove_from_labels(label) + except GithubException as exception: + # The exception is ignored if raised because the issue did not have the + # label already + if not exception.message == "Label does not exist": + raise if __name__ == "__main__": - repo = os.environ["GITHUB_REPOSITORY"] + repo_str = os.environ["GITHUB_REPOSITORY"] token = os.environ["GITHUB_TOKEN"] pr_number = os.environ["PR_NUMBER"] sha = os.environ["BRANCH_SHA"] @@ -323,58 +272,60 @@ def update_linter_fails_label(message, repo, pr_number, token): versions = get_versions(versions_file) - if not repo or not token or not pr_number or not log_file or not run_id: - raise ValueError( - "One of the following environment variables is not set: " - "GITHUB_REPOSITORY, GITHUB_TOKEN, PR_NUMBER, LOG_FILE, RUN_ID" - ) + for var, val in [ + ("GITHUB_REPOSITORY", repo_str), + ("GITHUB_TOKEN", token), + ("PR_NUMBER", pr_number), + ("LOG_FILE", log_file), + ("RUN_ID", run_id), + ]: + if not val: + raise ValueError(f"The following environment variable is not set: {var}") if not re.match(r"\d+$", pr_number): raise ValueError(f"PR_NUMBER should be a number, got {pr_number!r} instead") + pr_number = int(pr_number) + + gh = Github(auth=Auth.Token(token)) + repo = gh.get_repo(repo_str) + issue = repo.get_issue(number=pr_number) + + message = get_message( + log_file, + repo_str=repo_str, + pr_number=pr_number, + sha=sha, + run_id=run_id, + details=True, + versions=versions, + ) - try: - comment = find_lint_bot_comments(repo, token, pr_number) - except RuntimeError: - print("Comment not found in the first 30 comments. Skipping!") - exit(0) - - try: - message = get_message( - log_file, - repo=repo, - pr_number=pr_number, - sha=sha, - run_id=run_id, - details=True, - versions=versions, - ) - create_or_update_comment( - comment=comment, - message=message, - repo=repo, - pr_number=pr_number, - token=token, - ) - print(message) - except requests.HTTPError: - # The above fails if the message is too long. In that case, we - # try again without the details. - message = get_message( - log_file, - repo=repo, - pr_number=pr_number, - sha=sha, - run_id=run_id, - details=False, - versions=versions, - ) - create_or_update_comment( - comment=comment, - message=message, - repo=repo, - pr_number=pr_number, - token=token, - ) - print(message) + update_linter_fails_label( + linting_failed=message is not None, + issue=issue, + ) + + comment = find_lint_bot_comments(issue) - update_linter_fails_label(message, repo, pr_number, token) + if message is None: # linting succeeded + if comment is not None: + print("Deleting existing comment.") + comment.delete() + else: + try: + create_or_update_comment(comment, message, issue) + print(message) + except GithubException: + # The above fails if the message is too long. In that case, we + # try again without the details. + message = get_message( + log_file, + repo=repo, + pr_number=pr_number, + sha=sha, + run_id=run_id, + details=False, + versions=versions, + ) + create_or_update_comment(comment, message, issue) + print(message) diff --git a/build_tools/azure/combine_coverage_reports.sh b/build_tools/github/combine_coverage_reports.sh similarity index 100% rename from build_tools/azure/combine_coverage_reports.sh rename to build_tools/github/combine_coverage_reports.sh diff --git a/build_tools/github/create_gpu_environment.sh b/build_tools/github/create_gpu_environment.sh index 96a62d7678566..35a7a4c79f441 100755 --- a/build_tools/github/create_gpu_environment.sh +++ b/build_tools/github/create_gpu_environment.sh @@ -12,6 +12,13 @@ source "${HOME}/conda/etc/profile.d/conda.sh" source build_tools/shared.sh conda activate base +# Run these debug commands before installing our specific conda environment. +# We want to see what is available on the runner before we make changes. But +# we need to install miniforge before being able to look at the output of the +# conda commands. +conda info --json | python -c "import sys, json; print('Conda virtual packages versions:', json.load(sys.stdin).get('virtual_pkgs', []));" +nvidia-smi + CONDA_ENV_NAME=sklearn LOCK_FILE=build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock create_conda_environment_from_lock_file $CONDA_ENV_NAME $LOCK_FILE diff --git a/build_tools/github/debian_32bit_lock.txt b/build_tools/github/debian_32bit_lock.txt new file mode 100644 index 0000000000000..f8e81f7a7d8de --- /dev/null +++ b/build_tools/github/debian_32bit_lock.txt @@ -0,0 +1,48 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=build_tools/github/debian_32bit_lock.txt build_tools/github/debian_32bit_requirements.txt +# +coverage[toml]==7.14.0 + # via pytest-cov +cython==3.2.4 + # via -r build_tools/github/debian_32bit_requirements.txt +execnet==2.1.2 + # via pytest-xdist +iniconfig==2.3.0 + # via pytest +joblib==1.5.3 + # via -r build_tools/github/debian_32bit_requirements.txt +meson==1.11.1 + # via meson-python +meson-python==0.19.0 + # via -r build_tools/github/debian_32bit_requirements.txt +narwhals==2.21.0 + # via -r build_tools/github/debian_32bit_requirements.txt +ninja==1.13.0 + # via -r build_tools/github/debian_32bit_requirements.txt +packaging==26.2 + # via + # meson-python + # pyproject-metadata + # pytest +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pygments==2.20.0 + # via pytest +pyproject-metadata==0.11.0 + # via meson-python +pytest==9.0.3 + # via + # -r build_tools/github/debian_32bit_requirements.txt + # pytest-cov + # pytest-xdist +pytest-cov==6.3.0 + # via -r build_tools/github/debian_32bit_requirements.txt +pytest-xdist==3.8.0 + # via -r build_tools/github/debian_32bit_requirements.txt +threadpoolctl==3.6.0 + # via -r build_tools/github/debian_32bit_requirements.txt diff --git a/build_tools/azure/debian_32bit_requirements.txt b/build_tools/github/debian_32bit_requirements.txt similarity index 96% rename from build_tools/azure/debian_32bit_requirements.txt rename to build_tools/github/debian_32bit_requirements.txt index 04c8ed569a900..ff766724ab5ae 100644 --- a/build_tools/azure/debian_32bit_requirements.txt +++ b/build_tools/github/debian_32bit_requirements.txt @@ -3,6 +3,7 @@ # build_tools/update_environments_and_lock_files.py cython joblib +narwhals threadpoolctl pytest pytest-xdist diff --git a/build_tools/azure/install.sh b/build_tools/github/install.sh similarity index 94% rename from build_tools/azure/install.sh rename to build_tools/github/install.sh index 6a462aea3ae95..8523bd2bb4274 100755 --- a/build_tools/azure/install.sh +++ b/build_tools/github/install.sh @@ -72,11 +72,14 @@ python_environment_install_and_activate() { if [[ "$DISTRIB" == "conda-pip-scipy-dev" ]]; then echo "Installing development dependency wheels" dev_anaconda_url=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple - dev_packages="numpy scipy pandas Cython" + dev_packages="numpy scipy pandas" pip install --pre --upgrade --timeout=60 --extra-index $dev_anaconda_url $dev_packages --only-binary :all: check_packages_dev_version $dev_packages + echo "Installing Cython from latest sources" + # NO_CYTHON_COMPILE=true installs Cython as a pure Python package (faster install) + NO_CYTHON_COMPILE=true pip install https://github.com/cython/cython/archive/master.zip echo "Installing joblib from latest sources" pip install https://github.com/joblib/joblib/archive/master.zip echo "Installing pillow from latest sources" diff --git a/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock index d3a632653ce31..807a24ee28798 100644 --- a/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock +++ b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_conda.lock @@ -1,257 +1,323 @@ # Generated by conda-lock. # platform: linux-64 -# input_hash: 7e08eaf0616843772a915db5f428b96f6455948f620bb0ddddf349ff9b84b200 +# input_hash: b90518da6466f9a8f061a1db617a369a22dcc6660cffa561889a7caaa46b4532 @EXPLICIT -https://conda.anaconda.org/conda-forge/noarch/cuda-version-11.8-h70ddcb2_3.conda#670f0e1593b8c1d84f57ad5fe5256799 +https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda#b6d5d7f1c171cbd228ea06b556cfa859 https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 -https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_8.conda#ff007ab0f0fdc53d245972bba8a6d40c -https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.18.0-ha770c72_1.conda#4fb055f57404920a43b147031471e03b -https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2024.2.2-ha770c72_17.conda#c18fd07c02239a7eb744ea728db39630 +https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.26.0-ha770c72_0.conda#cb93c6e226a7bed5557601846555153d https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda#16c2a0e9c4a166e53632cfca4f68d020 -https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa +https://conda.anaconda.org/conda-forge/linux-64/onemkl-license-2025.3.1-hf2ce2f3_12.conda#95321ce2d03500a23a6e80034cbd4804 +https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda#f0599959a2447c1e544e216bddf393fa +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/noarch/cuda-cccl_linux-64-12.9.27-ha770c72_0.conda#87ff6381e33b76e5b9b179a2cdd005ec +https://conda.anaconda.org/conda-forge/noarch/cuda-crt-dev_linux-64-12.9.86-ha770c72_2.conda#79d280de61e18010df5997daea4743df +https://conda.anaconda.org/conda-forge/linux-64/cuda-crt-tools-12.9.86-ha770c72_2.conda#503a94e20d2690d534d676a764a1852c +https://conda.anaconda.org/conda-forge/noarch/cuda-cudart-static_linux-64-12.9.79-h3f2d84a_0.conda#b87bf315d81218dd63eb46cc1eaef775 +https://conda.anaconda.org/conda-forge/noarch/cuda-cudart_linux-64-12.9.79-h3f2d84a_0.conda#64508631775fbbf9eca83c84b1df0cae +https://conda.anaconda.org/conda-forge/noarch/cuda-nvvm-dev_linux-64-12.9.86-ha770c72_2.conda#7b386291414c7eea113d25ac28a33772 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 -https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.6-h4922eb0_0.conda#7a0b9ce502e0ed62195e02891dfcd704 -https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_8.conda#1bad93f0aa428d618875ef3a588a889e -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-6_kmp_llvm.conda#197811678264cb9da0d2ea0726a70661 +https://conda.anaconda.org/conda-forge/noarch/libnvptxcompiler-dev_linux-64-12.9.86-ha770c72_2.conda#a66a909acf08924aced622903832a937 +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.5-h4922eb0_1.conda#f66101d2eb5de2924c10a63bbfa2926e +https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.1-hf2ce2f3_12.conda#c6e7262ad8afd5fe1d64554cfa456060 +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda#887b70e1d607fba7957aa02f9ee0d939 +https://conda.anaconda.org/conda-forge/noarch/cuda-cudart-dev_linux-64-12.9.79-h3f2d84a_0.conda#86e40eb67d83f1a58bdafdd44e5a77c6 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 +https://conda.anaconda.org/conda-forge/linux-64/libnvptxcompiler-dev-12.9.86-ha770c72_2.conda#3fd926c321c6dbf386aa14bd8b125bfb https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda#76df83c2a9035c54df5d04ff81bcc02d -https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.0-hb9d3cd8_0.conda#f65c946f28f0518f41ced702f44c52b7 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.5-hb9d3cd8_0.conda#f7f0d6cc2dc986d42ac2689ec88192be +https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_2.conda#c2a01a08fc991620a74b32420e97868a +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda#dcdc58c15961dbf17a0621312b01f5cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda#e36ad70a7e0b48f091ed6902f04c23b8 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/noarch/cuda-nvcc-dev_linux-64-12.9.86-he91c749_2.conda#19d4e090217f0ea89d30bedb7461c048 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvvm-impl-12.9.86-h4bc722e_2.conda#82125dd3c0c4aa009faa00e2829b93d8 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvvm-tools-12.9.86-h4bc722e_2.conda#f9af26e4079adcd72688a8e8dbecb229 https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 -https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.1.0-hb03c661_4.conda#1d29d2e33fe59954af82ef54a8af3fe1 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-hd0affe5_1.conda#499cd8e2d4358986dbe3b30e8fe1bf6a https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc -https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda#c7e925f37e3b40d893459e625f6a53f1 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda#db63358239cbe1ff86242406d440e44a https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc -https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.10.0-h202a827_0.conda#0f98f3e95272d118f7931b6bef69bfe5 -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.3-hfe17d71_0.conda#1247168fe4a0b8912e3336bccdbf98a5 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118 https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f -https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.8.7-h043a21b_0.conda#4fdf835d66ea197e693125c64fbd4482 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.1-h3870646_2.conda#17ccde79d864e6183a83c5bbb8fff34d -https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.3-h3870646_2.conda#06008b5ab42117c89c982aa2a32a5b25 -https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.3-h3870646_2.conda#303d9e83e0518f1dcb66e90054635ca6 -https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.3.1-h5888daf_0.conda#bfd56492d8346d669010eccafe0ba058 +https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda#aa8d21be4b461ce612d8f5fb791decae +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.13-h2c9d079_1.conda#3c3d02681058c3d206b562b2e3bc337f +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h8b1a151_0.conda#f16f498641c9e05b645fe65902df661a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h8b1a151_4.conda#c7e3e08b7b1b285524ab9d74162ce40b +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h8b1a151_0.conda#f8e1bcc5c7d839c5882e94498791be08 +https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda#cb15315d19b58bd9cd424084e58ad081 +https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-static-12.9.79-h5888daf_0.conda#d3c4ac48f4967f09dd910d9c15d40c81 +https://conda.anaconda.org/conda-forge/linux-64/cuda-cupti-12.9.79-h676940d_1.conda#a2ddf359dcb9e6a3d0173b10f58f4db9 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvcc-tools-12.9.86-he02047a_2.conda#dc256c9864c2e8e9c817fbca1c84a4bc +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvdisasm-12.9.88-hffce074_1.conda#5e7845d208a5067cb1461a429ff887e0 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvrtc-12.9.86-hecca717_1.conda#53f0062e2243b26e43ddac0b5267c6a3 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvtx-12.9.79-hecca717_1.conda#b4a3411fa031c409f98cfbd4b2db9ad7 +https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda#dbe3ec0f120af456b3477743ffd99b74 +https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda#f7d7a4104082b39e3b3473fbd4a38229 https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda#d411fc29e338efb48c5fd4576d71d881 https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libabseil-20240722.0-cxx17_hbbce691_4.conda#488f260ccda0afaf08acb286db439c2f -https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.1.0-hb03c661_4.conda#5cb5a1c9a94a78f5b23684bcb845338d -https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.1.0-hb03c661_4.conda#2e55011fa483edb8bfe3fd92e860cd79 +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260107.1-cxx17_h7b12aa8_0.conda#6f7b4302263347698fd24565fbf11310 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f +https://conda.anaconda.org/conda-forge/linux-64/libcufft-11.4.1.4-hecca717_1.conda#75ae571353ec92c8f34d4cf6ec6ba264 +https://conda.anaconda.org/conda-forge/linux-64/libcurand-10.3.10.19-h676940d_1.conda#2a91559a9345bedf09af8b7903deb6e6 https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda#a1cfcc585f0c42bf8d5546bb1dfb668d -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/libnvjitlink-12.9.86-hecca717_2.conda#3461b0f2d5cbb7973d361f9e85241d98 https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-260.1-h6569c3e_0.conda#5020e400d8f01d9e6a39d559e65060f1 +https://conda.anaconda.org/conda-forge/linux-64/libudev1-260.1-h6569c3e_0.conda#0bcc534980c246af677ed6f118d8c2ef https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/conda-forge/linux-64/nccl-2.30.4.1-h4d09622_0.conda#5f6cad41cf88e7938996445f694d76c6 https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 -https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda#7fa07cb0fb1b625a089ccc01218ee5b1 +https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/s2n-1.5.14-h6c98b2b_0.conda#efab4ad81ba5731b2fefa0ab4359e884 +https://conda.anaconda.org/rapidsai/linux-64/rapids-logger-0.2.3-h98325ef_0.conda#81257f29bfcc1e58f0405d7bc9feb309 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.2-hc5a330e_1.conda#3f578c7d2b0bb52469340e4060d48d94 https://conda.anaconda.org/conda-forge/linux-64/sleef-3.9.0-ha0421bc_0.conda#e8a0b4f5e82ecacffaa5e805020473cb https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d -https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda#035da2e4f5770f036ff704fa17aace24 +https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda#996583ea9c796e5b915f7d7580b51ea6 https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 -https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda#c9f075ab2f33b3bbee9e62d4ad0a6cd8 -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.17.0-h3dad3f2_6.conda#3a127d28266cdc0da93384d1f59fe8df -https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.1.0-hb03c661_4.conda#ca4ed8015764937c81b830f7f5b68543 -https://conda.anaconda.org/conda-forge/linux-64/cudatoolkit-11.8.0-h4ba93d1_13.conda#eb43f5f1f16e2fad2eba22219c3e499b +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.26.3-h692f434_1.conda#14260392d0b491c537b5e26e9a506fff +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-dev-12.9.79-h5888daf_0.conda#ba38a7c3b4c14625de45784b773f0c71 +https://conda.anaconda.org/conda-forge/linux-64/cuda-cuobjdump-12.9.82-hffce074_1.conda#55a83761db33f82d92d7d7a4a61662e5 https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda#ff862eebdfeb2fd048ae9dc92510baca https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda#c94a5994ef49749880a8139cf9afcbe1 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda#3f43953b7d3fb3aaa1d0d0723d91e368 +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2#c965a5aa0d5c1c37ffc62dff36e28400 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h32235b2_0.conda#0cb0612bc9cb30c62baf41f9d600611b -https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda#b499ce4b026493a13774bcf0f4c33849 -https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-5.28.3-h6128344_1.conda#d8703f1ffe5a06356f06467f1d0b9464 -https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2024.07.02-hbbce691_2.conda#b2fede24428726dd867611664fb372e8 -https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.21.0-h0e7cc3e_0.conda#dcb95c0a98ba9ff737f7ae482aef7833 +https://conda.anaconda.org/conda-forge/linux-64/libcublas-12.9.1.4-h676940d_1.conda#af0df9bc982b5ed2c67e8f5062d1f8c1 +https://conda.anaconda.org/conda-forge/linux-64/libcurand-dev-10.3.10.19-h676940d_1.conda#fc716aaff5af15b80ccbd28b3e67672c +https://conda.anaconda.org/conda-forge/linux-64/libcusparse-12.5.10.65-hecca717_2.conda#890ebfaad48c887d3d82847ec9d6bc79 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_1.conda#6016ea5ee9e986bc683879408cc87529 +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.33.5-h2b00c02_0.conda#11ac478fa72cf12c214199b8a96523f4 +https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h0dc7533_1.conda#ced7f10b6cfb4389385556f47c0ad949 +https://conda.anaconda.org/rapidsai/linux-64/librmm-26.04.00-cuda12_260408_48b36cc6.conda#815e224bae37f1bf6491fe3145adbda2 +https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h7d032f7_2.conda#b6e326fbe1e3948da50ec29cee0380db https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 -https://conda.anaconda.org/conda-forge/linux-64/nccl-2.27.3.1-h03a54cd_0.conda#616e835be8126fab0bf4cec1f40cc4ea +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-habeac84_100_cp314.conda#a443f87920815d41bfe611296e507995 https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 +https://conda.anaconda.org/conda-forge/linux-64/rdma-core-62.0-h192683f_0.conda#46a9d3342a5945cf6067f9277989900c https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda#db038ce880f100acc74dba10302b5630 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.5.4-h04a3f94_2.conda#81096a80f03fc2f0fb2a230f5d028643 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.9.4-hb9b18c6_4.conda#773c99d0dbe2b3704af165f97ff399e5 -https://conda.anaconda.org/conda-forge/linux-64/brotli-1.1.0-hb03c661_4.conda#eaf3fbd2aa97c212336de38a51fe404e -https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda#cae723309a49399d2949362f4ab5c9e4 -https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda#679616eb5ad4e521c83da4650860aba7 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.1.4-h7d33bf5_0.conda#93fe78190bc6fe40d5e7a737c8065286 -https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda#d4a250da4737ee127fb1fa6452a9002e -https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_0.conda#01e149d4a53185622dc2e788281961f2 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.7.0-h9b893ba_0.conda#60076118b1579967748f0c9a2912de7c +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.13-h4bacb7b_0.conda#77f70a9ab785a146dbf66fba00131403 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda#f111d4cfaf1fe9496f386bc98ae94452 +https://conda.anaconda.org/conda-forge/linux-64/cuda-nvcc-impl-12.9.86-h85509e4_2.conda#67458d2685e7503933efa550f3ee40f3 +https://conda.anaconda.org/conda-forge/noarch/cuda-pathfinder-1.5.4-pyhc364b38_0.conda#42d4610b52102122741f9bf68f2866ed +https://conda.anaconda.org/conda-forge/linux-64/cuda-profiler-api-12.9.79-h7938cbb_1.conda#90d09865fb37d11d510444e34ebe6a09 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda#af491aae930edc096b58466c51c4126c +https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.4-py314h1807b08_0.conda#866fd3d25b767bccb4adc8476f4035cd +https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda#ce96f2f470d39bd96ce03945af92e280 +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.0-pyhd8ed1ab_0.conda#8fa8358d022a3a9bd101384a808044c6 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py314h97ea11e_0.conda#7397e418cab519b8d789936cf2dde6f6 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libcublas-dev-12.9.1.4-h676940d_1.conda#f90f4ff087ac29005c6989ea0fb2735a +https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.2.21-hf7e9902_0.conda#a178a1f3642521f104ecceeefa138d01 +https://conda.anaconda.org/conda-forge/linux-64/libcudss-0.7.1.4-h58dd1b1_1.conda#c5b8ea827c65e5811d61aa49cd0bae9a +https://conda.anaconda.org/conda-forge/linux-64/libcufile-1.14.1.1-hbc026e6_1.conda#cab1818eada3952ed09c8dcbb7c26af7 +https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda#49c553b47ff679a6a1e9fc80b9c5a2d4 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libcusolver-11.7.5.82-h676940d_2.conda#bb6e31a0daa64ede76fe8d3fff01c06f +https://conda.anaconda.org/conda-forge/linux-64/libcusparse-dev-12.5.10.65-hecca717_2.conda#db94469fbd554c107acc3afd0af5d8ec https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.9-h04c0eec_0.conda#35eeb0a2add53b1e50218ed230fa6a02 -https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.1-h90cbb55_3.conda#2eeb50cab6652538eee8fc0bc3340c81 +https://conda.anaconda.org/rapidsai/linux-64/libraft-headers-only-26.04.00-cuda12_260408_b01e3028.conda#4c79a5c4309aa770d00749683f941c70 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda#9a17c4307d23318476d7fbf0fedc0cde +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-he0a73b1_0.conda#85ce2ffa51ab21da5efa4a9edc5946aa +https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda#a2c1eeadae7a309daed9d62c96012a2b +https://conda.anaconda.org/conda-forge/noarch/nvidia-ml-py-13.595.45-pyhd8ed1ab_1.conda#dc8587ae654e96031728802016e8258c https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/orc-2.1.1-h2271f48_0.conda#67075ef2cb33079efee3abfe58127a3b -https://conda.anaconda.org/conda-forge/linux-64/re2-2024.07.02-h9925aae_2.conda#e84ddf12bde691e8ec894b00ea829ddf +https://conda.anaconda.org/conda-forge/linux-64/orc-2.3.0-h21090e2_0.conda#8027fce94fdfdf2e54f9d18cbae496df +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pybind11-global-3.0.1-pyhc7ab6ef_0.conda#fe10b422ce8b5af5dab3740e4084c3f9 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_1.conda#66a715bc01c77d43aca1f9fcb13dde3c +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py314h5bd0f2a_0.conda#dc1ff1e915ab35a06b6fa61efae73ab5 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/linux-64/ucx-1.20.0-hbe80e26_1.conda#ffdaec09a7c09710040eb9e613f8c531 +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py314h5bd0f2a_0.conda#494fdf358c152f9fdd0673c128c2f3dd https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 -https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda#71ae752a748962161b4740eaff510258 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda#febbab7d15033c913d53c7a2c102309d +https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda#b56e0c8432b56decafae7e78c5f29ba5 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e -https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.8.6-hd08a7f5_4.conda#f5a770ac1fd2cb34b21327fc513013a7 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.12.2-h108da3e_2.conda#90e07c8bac8da6378ee1882ef0a9374a -https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.14.0-h5cfcd09_0.conda#0a8838771cc2e985cd295e01ae83baf1 -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda#4afc585cd97ba8a23809406cd8a9eda8 -https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.10.1.4-h0fdc2d1_0.conda#a0c0b44d26a4710e6ea577fcddbe09d1 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.1-ha62d5e7_3.conda#55eaf7066da1299d217ab32baedc7fa8 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.15.2-hc1936db_2.conda#9120bc47b6f837f3cea90928c3e9a8fa +https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.2-h206d751_0.conda#5492abf806c45298ae642831c670bba0 +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.0-py314h67df5f8_0.conda#7f8715a1928f6f126323320a4c5ada3a +https://conda.anaconda.org/conda-forge/linux-64/cuda-bindings-12.9.6-py314h7ea930b_0.conda#a8841fd311da95db72916f58eff3f5a6 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.62.1-pyh7db6752_0.conda#14cf1ac7a1e29553c6918f7860aab6d8 +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/linux-64/libcusolver-dev-11.7.5.82-h676940d_2.conda#0fe12e558abf507458bcec839e29778d https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a -https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.67.1-h25350d4_2.conda#bfcedaf5f9b003029cc6abe9431f66bf -https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.1-default_h3d81e11_1000.conda#d821210ab60be56dd27b5525ed18366d -https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.0-hecd9e04_0.conda#9ad637a7ac380c442be142dfb0b1b955 -https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda#74e91c36d0eef3557915c68b6c2bef96 -https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h7a3aeb2_0.conda#31059dc620fa57d787e3899ed0421e6d -https://conda.anaconda.org/conda-forge/linux-64/mpc-1.3.1-h24ddda3_1.conda#aa14b9a5196a6d8dd364164b7ce56acf -https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda#2e5bf4f1da39c0b32778561c3c4e5878 +https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_2.conda#27ac5ae872a21375d980bd4a6f99edf3 +https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.78.1-h1d1128b_0.conda#b5fb6d6c83f63d83ef2721dca6ff7091 +https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.2-default_hafda6a7_1000.conda#0ed3aa3e3e6bc85050d38881673a692f +https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.5-hf7376ad_1.conda#6adc0202fa7fcf0a5fce8c31ef2ed866 +https://conda.anaconda.org/rapidsai/linux-64/libucxx-0.49.00-cuda12_260408_8d47a9ff.conda#b8ddfc6e13aa7b755392fe8cb6f9250a +https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9 +https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda#87e6096ec6d542d1c1f8b33245fe8300 +https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-he0a73b1_0.conda#770d00bf57b5599c4544d61b61d8c6c6 +https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda#680608784722880fbfe1745067570b00 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda#76c4757c0ec9d11f969e8eb44899307b https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda#a83f6a2fdc079e643237887a37460668 -https://conda.anaconda.org/conda-forge/linux-64/python-3.13.9-hc97d973_101_cp313.conda#4780fe896e961722d0623fa91d0d3378 +https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.1-pyh7a1b43c_0.conda#70ece62498c769280f791e836ac53fff +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.4-h4df99d1_100.conda#e4e60721757979d01d3964122f674959 +https://conda.anaconda.org/conda-forge/linux-64/triton-3.6.0-cuda129py314h2b49ec1_1.conda#090f5ddba9c3c2e167619c801c212fb6 +https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda#4d1fc190b99912ed557a8236e958c559 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda#d3c295b50f092ab525ffe3c2aa4b7413 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda#f2ba4192d38b6cef2bb2c25029071d90 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda#2ccd714aa2242315acaf0a67faea780b https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda#17dcc85db3c7886650b8908b183d6876 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda#2de7f99d6581a4a7adbff607b5c278ca -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda#5efa5fa6243a622445fdfd72aee15efa -https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.7.13-h822ba82_2.conda#9cf2c3c13468f2209ee814be2c88655f -https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.10.0-h113e628_0.conda#73f73f60854f325a55f1d31459f2ab73 -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.8.0-h736e048_1.conda#13de36be8de3ae3f05ba127631599213 -https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.9-py313hd8ed1ab_101.conda#367133808e89325690562099851529c8 -https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.10.1.4-haad7af6_0.conda#8382d957333e0d3280dcbf5691516dc1 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.1-py313hc80a56d_0.conda#1617960e1d8164f837ed5d0996603b88 -https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/linux-64/fastrlock-0.8.3-py313h5d5ffb9_2.conda#9bcbd351966dc56a24fc0c368da5ad99 -https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.0-pyhd8ed1ab_0.conda#66b8b26023b8efdf8fcb23bac4b6325d -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda#8f5b0b297b59e1ac160ad4beec99dbee -https://conda.anaconda.org/conda-forge/noarch/fsspec-2025.10.0-pyhd8ed1ab_0.conda#d18004c37182f83b9818b714825a7627 -https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.2.1-py313h86d8783_2.conda#d904f240d2d2500d4906361c67569217 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py313hc8edb43_2.conda#3e0e65595330e26515e31b7fc6d933c7 -https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.0-default_h99862b1_1.conda#d599b346638b9216c1e8f9146713df05 -https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.0-default_h746c552_1.conda#327c78a8ce710782425a89df851392f7 -https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.36.0-h2b5623c_0.conda#c96ca58ad3352a964bfcb85de6cd1496 -https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.18.0-hfcad708_1.conda#1f5a5d66e77a39dc5bd639ec953705cf -https://conda.anaconda.org/conda-forge/linux-64/libpq-17.7-h5c52fec_1.conda#a4769024afeab4b32ac8167c2f92c7ac -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_0.conda#c14389156310b8ed3520d84f854be1ee -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 -https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b -https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/networkx-3.5-pyhe01879c_0.conda#16bff3d37a4f99e3aa089c36c2b8d650 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py313h50355cd_0.conda#8a96eab78687362de3e102a15c4747a8 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda#bf47878473e5ab9fdb4115735230e191 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2025.2-pyhd8ed1ab_0.conda#88476ae6ebd24f39261e0854ac244f33 -https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e -https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 -https://conda.anaconda.org/conda-forge/linux-64/tbb-2021.13.0-h8d10470_4.conda#e6d46d70c68d0eb69b9a040ebe3acddf -https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py313h07c4f96_2.conda#7824f18e343d1f846dcde7b23c9bf31a -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f -https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.31.0-h55f77e1_4.conda#0627af705ed70681f5bede31e72348e5 -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.13.0-h3cf044e_1.conda#7eb66060455c7a47d9dcdbfa9f46579b -https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda#09262e66b19567aff4f592fb53b28760 -https://conda.anaconda.org/conda-forge/linux-64/coverage-7.12.0-py313h3dea7bd_0.conda#8ef99d298907bfd688a95cc714662ae7 -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.60.1-py313h3dea7bd_0.conda#904860fc0d57532d28e9c6c4501f19a9 -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhd8ed1ab_0.conda#446bd6c8cb26050d528881df495ce646 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.36.0-h0121fbd_0.conda#fc5efe1833a4d709953964037985bb72 -https://conda.anaconda.org/conda-forge/linux-64/mkl-2024.2.2-ha770c72_17.conda#e4ab075598123e783b788b995afbdad0 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 -https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.9-h4df99d1_101.conda#f41e3c1125e292e6bfcea8392a3de3d8 -https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_105.conda#8c09fac3785696e1c477156192d64b91 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda#e192019153591938acf7322b6459d36e +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f -https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.510-h37a5c72_3.conda#beb8577571033140c6897d257acc7724 -https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.12.0-ha633028_1.conda#7c1980f89dd41b097549782121a73490 -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda#b8690f53007e9b5ee2c2178dd4ac778c -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.9.0-37_h5875eb1_mkl.conda#888c2ae634bce09709dffd739ba9f1bc -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2024.2.2-ha770c72_17.conda#e67269e07e58be5672f06441316f05f2 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/linux-64/libarrow-19.0.1-hc7b3859_3_cpu.conda#9ed3ded6da29dec8417f2e1db68798f2 -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-37_hfef963f_mkl.conda#f66eb9a9396715013772b8a3ef7396be -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-37_h5e43f62_mkl.conda#0c4af651539e79160cd3f0783391e918 -https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.35.2-py310hffdcd12_0.conda#2b90c3aaf73a5b6028b068cf3c76e0b7 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.2-he6ee468_1.conda#50ae8372984b8b98e056ac8f6b70ab29 +https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.3-hed0cdb0_1.conda#68bfb556bdf56d56e9f38da696e752ca +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.12.0-ha7a2c86_1.conda#6400f73fe5ebe19fe7aca3616f1f1de7 +https://conda.anaconda.org/conda-forge/noarch/cuda-python-12.9.6-pyh698daf1_0.conda#8d3dbe5292af711edd6df92c68e55a89 +https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.3.0-py314h28848ee_1.conda#a99b82fda10aecd4ed853172bf4f6a28 +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.0-h6083320_0.conda#e194f6a2f498f0c7b1e6498bd0b12645 +https://conda.anaconda.org/conda-forge/linux-64/libclang13-22.1.5-default_h746c552_0.conda#c3df118cdc65584a78028bf225111b1b +https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_2.conda#53e7cbb2beb03d69a478631e23e340e9 +https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.26.0-h9692893_0.conda#c360be6f9e0947b64427603e91f9651f +https://conda.anaconda.org/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda#405ec206d230d9d37ad7c2636114cbf4 +https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda#31ad065eda3c2d88f8215b1289df9c89 +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/linux-64/optree-0.19.1-py314h9891dd4_0.conda#44ffc8b345a7844a847d4fdf469d64ea +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-h51de99f_1.conda#6383c1684badc0d94408b12850cf07f1 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f +https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.38.3-h745e52d_1.conda#6a65b3595a8933808c03ff065dfb7702 +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.16.0-hdd73cc9_1.conda#939d9ce324e51961c7c4c0046733dbb7 +https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_2.conda#b513eb83b3137eca1192c34bf4f013a7 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-3.3.0-h25dbb67_1.conda#b2baa4ce6a9d9472aaa602b88f8d40ac +https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.1-h0e700b2_12.conda#1a4a54fad5e36b8282ec6208dcb9bfb7 +https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.40.0-py310hffdcd12_0.conda#8eacf9ff4d4e1ca1b52f8f3ba3e0c993 https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.9.2-h5bd77bc_1.conda#f7bfe5b8e7641ce7d11ea10cfd9f33cc -https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-19.0.1-hcb10f89_3_cpu.conda#8f8dc214d89e06933f1bc1dcd2310b9c -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.9.0-37_hdba1596_mkl.conda#4e76080972d13c913f178c90726b21ce -https://conda.anaconda.org/conda-forge/linux-64/libmagma-2.9.0-h45b15fe_0.conda#703a1ab01e36111d8bb40bc7517e900b -https://conda.anaconda.org/conda-forge/linux-64/libparquet-19.0.1-h081d1f1_3_cpu.conda#1d04307cdb1d8aeb5f55b047d5d403ea -https://conda.anaconda.org/conda-forge/linux-64/numpy-2.3.5-py313hf6604e3_0.conda#15f43bcd12c90186e78801fafc53d89b -https://conda.anaconda.org/conda-forge/noarch/polars-1.35.2-pyh6a1acc5_0.conda#24e8f78d79881b3c035f89f4b83c565c -https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-19.0.1-py313he5f92c8_0_cpu.conda#7d8649531c807b24295c8f9a0a396a78 -https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.9.2-py313ha3f37dd_1.conda#e2ec46ec4c607b97623e7b691ad31c54 -https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.4.1-pyhe01879c_0.conda#648e253c455718227c61e26f4a4ce701 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.9.0-37_hcf00494_mkl.conda#3a3a2906daecd117aad30e4d68276394 -https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py313h7037e92_3.conda#6186382cb34a9953bf2a18fc763dc346 -https://conda.anaconda.org/conda-forge/linux-64/cupy-core-13.6.0-py313hc2a895b_2.conda#1b3207acc9af23dcfbccb4647df0838e -https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-19.0.1-hcb10f89_3_cpu.conda#a28f04b6e68a1c76de76783108ad729d -https://conda.anaconda.org/conda-forge/linux-64/libmagma_sparse-2.9.0-h45b15fe_0.conda#beac0a5bbe0af75db6b16d3d8fd24f7e -https://conda.anaconda.org/conda-forge/linux-64/pandas-2.3.3-py313h08cd8bf_1.conda#9e87d4bda0c2711161d765332fa38781 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py313h11c21cd_1.conda#26b089b9e5fcdcdca714b01f8008d808 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.137-mkl.conda#9deb2d32720cc73c9991dbd9e24b499e -https://conda.anaconda.org/conda-forge/linux-64/cupy-13.6.0-py313h66a2ee2_2.conda#9d83bdb568a47daf7fc38117db17fe4e -https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-19.0.1-h08228c5_3_cpu.conda#a58e4763af8293deaac77b63bc7804d8 -https://conda.anaconda.org/conda-forge/linux-64/libtorch-2.4.1-cuda118_mkl_hee7131c_306.conda#28b3b3da11973494ed0100aa50f47328 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py313h683a580_0.conda#ffe67570e1a9192d2f4c189b27f75f89 -https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py313hfaae9d9_1.conda#6d308eafec3de495f6b06ebe69c990ed -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.8-py313h78bf25f_0.conda#85bce686dd57910d533807562204e16b -https://conda.anaconda.org/conda-forge/linux-64/pyarrow-19.0.1-py313h78bf25f_0.conda#e8efe6998a383dd149787c83d3d6a92e -https://conda.anaconda.org/conda-forge/linux-64/pytorch-2.4.1-cuda118_mkl_py313_h909c4c2_306.conda#de6e45613bbdb51127e9ff483c31bf41 -https://conda.anaconda.org/conda-forge/linux-64/pytorch-gpu-2.4.1-cuda118_mkl_hf8a3b2d_306.conda#b1802a39f1ca7ebed5f8c35755bffec1 +https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_106.conda#32d866e43b25275f61566b9391ccb7b5 +https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.747-h41c0014_4.conda#169a79ea1127077d8dc36dc963ff55ac +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.14.0-h52c5a47_1.conda#6d10339800840562b7dad7775f5d2c16 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h5875eb1_mkl.conda#d03e4571f7876dcd4e530f3d07faf333 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-3.3.0-hdbdcf42_1.conda#da94b149c8eea6ceef10d9e408dcfeb3 +https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.1-ha770c72_12.conda#db484eb7d5c23ca2a3129ddf5943de76 +https://conda.anaconda.org/conda-forge/noarch/polars-1.40.0-pyh58ad624_0.conda#fd16be490f5403adfbf27dd4901bbe34 +https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.0-pl5321h16c4a6b_4.conda#c81127acb50fdc7760682495fc9ab088 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-24.0.0-h72d56d0_1_cuda.conda#55ecfbd2e0853dd720e13ca68bda5617 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_hfef963f_mkl.conda#72cf77ee057f87d826f9b98cacd67a59 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h5e43f62_mkl.conda#8b13738802df008211c9ecd08775ca21 +https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.11.0-py314h3987850_2.conda#c77e1fe23b6cf0b6077e5f924ac420c9 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-24.0.0-h53684a4_1_cuda.conda#1f3d6ccda609a3065054a983b09725af +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-6_hdba1596_mkl.conda#5efff83ae645656f28c826aa192e7651 +https://conda.anaconda.org/conda-forge/linux-64/libmagma-2.9.0-ha7672b3_6.conda#7c6ca8cec0c6a213db89a1d80f53d197 +https://conda.anaconda.org/conda-forge/linux-64/libparquet-24.0.0-h7376487_1_cuda.conda#5351c337776036ecb8e4bea758399bed +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.3-py314h2b28147_0.conda#36f5b7eb328bdc204954a2225cf908e2 +https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.5-pyhcf101f3_0.conda#e65c7d49168ef8014ad0563ea0d94ff1 +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-6_hcf00494_mkl.conda#b789b886f2b45c3a9c91935639717808 +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h97ea11e_4.conda#95bede9cdb7a30a4b611223d52a01aa4 +https://conda.anaconda.org/conda-forge/linux-64/cuda-core-0.7.0-cuda12_py314h6985919_0.conda#ec0ac10cf8ea10e2ca9437a1feebbfc6 +https://conda.anaconda.org/conda-forge/linux-64/cupy-core-14.0.1-py314hf9e62a7_0.conda#4fe7bd0212d2f7788765875755f67684 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-24.0.0-h635bf11_1_cuda.conda#42c3d4b56b5dd405f6389c7bc8200a9c +https://conda.anaconda.org/conda-forge/linux-64/libtorch-2.10.0-cuda129_mkl_hd6d2a1f_303.conda#5b8a8672aca66f3871aab4d0d1a8f796 +https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.2-py314hb4ffadd_0.conda#41ee6fe2a848876bc9f524c5a500b85b +https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-24.0.0-py314h57703d4_0_cuda.conda#66a1f66c0554402ab43f6ab69617d70b +https://conda.anaconda.org/rapidsai/linux-64/rmm-26.04.00-cuda12_cp311_abi3_260408_48b36cc6.conda#347f02e4a94831af80076fd6157776ac +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.16.3-py314hf07bd8e_2.conda#ee95e8bb52e35c3267a53d3ee1347cc4 +https://conda.anaconda.org/conda-forge/noarch/scipy-doctest-2.2.0-pyhcf101f3_0.conda#21ac538af5bad73af42729841772de89 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.306-mkl.conda#51424ae4b1ba5521ee838721d63d4390 +https://conda.anaconda.org/conda-forge/linux-64/cupy-14.0.1-py314h3d8d815_0.conda#5045e5051a4d781d41d63e4acc264944 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-24.0.0-h635bf11_1_cuda.conda#64b5b9d375485dc9b13f7506ead51ed3 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py314h1194b4b_0.conda#11a821746ad11e642fcc615c3d66aa44 +https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py314h3a4f467_1.conda#478c6ef795065cd15cdbe1e214b30175 +https://conda.anaconda.org/conda-forge/linux-64/pytorch-2.10.0-cuda129_mkl_py314_h624cae8_303.conda#fa04d9a4d7fd7a9bd49deabc4b1a8b4f +https://conda.anaconda.org/rapidsai/linux-64/ucxx-0.49.00-cuda12_cp311_abi3_260408_8d47a9ff.conda#4e44176d2cfc7f9941dc3089df22c9dc +https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-24.0.0-hb4dd7c2_1_cuda.conda#f36b071f6793519c17f07a3764e18ce9 +https://conda.anaconda.org/rapidsai/linux-64/libraft-headers-26.04.00-cuda12_260408_b01e3028.conda#413e57142e096269834e8a95b4905d09 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.9-py314hdafbbf9_0.conda#2046de06d7f4149a29c5d0e2cc26d6dd +https://conda.anaconda.org/conda-forge/linux-64/pytorch-gpu-2.10.0-cuda129_mkl_h0d04637_303.conda#1050dc8cf80cd0a9e63f361c12ee0e82 +https://conda.anaconda.org/rapidsai/linux-64/libcuvs-headers-26.04.00-cuda12_260408_60b19a0f.conda#c3216f4b246d2016b1f1a9d719dc94ad +https://conda.anaconda.org/rapidsai/linux-64/libraft-26.04.00-cuda12_260408_b01e3028.conda#04014b08138ccd053a8352d879f1cb1e +https://conda.anaconda.org/conda-forge/linux-64/pyarrow-24.0.0-py314hdafbbf9_0.conda#6629041b133a9d65d68c4f2269432378 +https://conda.anaconda.org/rapidsai/linux-64/libcuvs-26.04.00-cuda12_260408_60b19a0f.conda#2b9d59fc5a9ee033d42f68ef6cd451aa +https://conda.anaconda.org/rapidsai/linux-64/pylibraft-26.04.00-cuda12_cp311_abi3_260408_b01e3028.conda#45e40019a250657411b7b42182c33dfd +https://conda.anaconda.org/rapidsai/linux-64/cuvs-26.04.00-cuda12_cp311_abi3_260408_.conda#a1a9e67d1b2f9134c41cb4a4063c7497 diff --git a/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_environment.yml b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_environment.yml index 709c8e4a5fad0..b88106d21a2b2 100644 --- a/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_environment.yml +++ b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_environment.yml @@ -2,16 +2,16 @@ # following script to centralize the configuration for CI builds: # build_tools/update_environments_and_lock_files.py channels: + - rapidsai - conda-forge - - pytorch - - nvidia dependencies: - python - numpy - - blas[build=mkl] + - blas - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas @@ -29,4 +29,6 @@ dependencies: - polars - pyarrow - cupy + - cuvs - array-api-strict + - scipy-doctest diff --git a/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_virtual_package_spec.yml b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_virtual_package_spec.yml new file mode 100644 index 0000000000000..243e258ab43f8 --- /dev/null +++ b/build_tools/github/pylatest_conda_forge_cuda_array-api_linux-64_virtual_package_spec.yml @@ -0,0 +1,9 @@ +# The versions of the virtual packages here are taken from running +# build_tools/github/create_gpu_environment.sh which outputs +# the versions of the packages used in the CUDA CI runner. +# Do not set them to what you see on your local machine. +subdirs: + linux-64: + packages: + __cuda: "12.8" + __glibc: "2.39" diff --git a/build_tools/github/pylatest_conda_forge_mkl_linux-64_conda.lock b/build_tools/github/pylatest_conda_forge_mkl_linux-64_conda.lock new file mode 100644 index 0000000000000..865de8d575e9a --- /dev/null +++ b/build_tools/github/pylatest_conda_forge_mkl_linux-64_conda.lock @@ -0,0 +1,278 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: f255677497ab26f3ca2b58ac4398205e2ddb89e38d8a3d0c9a4eb812fdbe26ef +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb +https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 +https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-headers-1.26.0-ha770c72_0.conda#cb93c6e226a7bed5557601846555153d +https://conda.anaconda.org/conda-forge/linux-64/nlohmann_json-3.12.0-h54a6638_1.conda#16c2a0e9c4a166e53632cfca4f68d020 +https://conda.anaconda.org/conda-forge/linux-64/onemkl-license-2025.3.1-hf2ce2f3_12.conda#95321ce2d03500a23a6e80034cbd4804 +https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda#f0599959a2447c1e544e216bddf393fa +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 +https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.5-h4922eb0_1.conda#f66101d2eb5de2924c10a63bbfa2926e +https://conda.anaconda.org/conda-forge/linux-64/mkl-include-2025.3.1-hf2ce2f3_12.conda#c6e7262ad8afd5fe1d64554cfa456060 +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda#887b70e1d607fba7957aa02f9ee0d939 +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab +https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 +https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 +https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.2-h25fd6f3_2.conda#c2a01a08fc991620a74b32420e97868a +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda#dcdc58c15961dbf17a0621312b01f5cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.12.6-hb03c661_0.conda#e36ad70a7e0b48f091ed6902f04c23b8 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 +https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.11.3-hfe17d71_0.conda#1247168fe4a0b8912e3336bccdbf98a5 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c +https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118 +https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xorg-xorgproto-2025.1-hb03c661_0.conda#aa8d21be4b461ce612d8f5fb791decae +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.13-h2c9d079_1.conda#3c3d02681058c3d206b562b2e3bc337f +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h8b1a151_0.conda#f16f498641c9e05b645fe65902df661a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h8b1a151_4.conda#c7e3e08b7b1b285524ab9d74162ce40b +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h8b1a151_0.conda#f8e1bcc5c7d839c5882e94498791be08 +https://conda.anaconda.org/conda-forge/linux-64/double-conversion-3.4.0-hecca717_0.conda#dbe3ec0f120af456b3477743ffd99b74 +https://conda.anaconda.org/conda-forge/linux-64/fmt-12.1.0-hff5e90c_0.conda#f7d7a4104082b39e3b3473fbd4a38229 +https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda#d411fc29e338efb48c5fd4576d71d881 +https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260107.1-cxx17_h7b12aa8_0.conda#6f7b4302263347698fd24565fbf11310 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f +https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 +https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b +https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 +https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda#a1cfcc585f0c42bf8d5546bb1dfb668d +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 +https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc +https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 +https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 +https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.2-hc5a330e_1.conda#3f578c7d2b0bb52469340e4060d48d94 +https://conda.anaconda.org/conda-forge/linux-64/sleef-3.9.0-ha0421bc_0.conda#e8a0b4f5e82ecacffaa5e805020473cb +https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd +https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda#996583ea9c796e5b915f7d7580b51ea6 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.26.3-h692f434_1.conda#14260392d0b491c537b5e26e9a506fff +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda#ff862eebdfeb2fd048ae9dc92510baca +https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda#c94a5994ef49749880a8139cf9afcbe1 +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2#c965a5aa0d5c1c37ffc62dff36e28400 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_1.conda#6016ea5ee9e986bc683879408cc87529 +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.33.5-h2b00c02_0.conda#11ac478fa72cf12c214199b8a96523f4 +https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2025.11.05-h0dc7533_1.conda#ced7f10b6cfb4389385556f47c0ad949 +https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.22.0-h7d032f7_2.conda#b6e326fbe1e3948da50ec29cee0380db +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-habeac84_100_cp314.conda#a443f87920815d41bfe611296e507995 +https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.7.0-h9b893ba_0.conda#60076118b1579967748f0c9a2912de7c +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.10.13-h4bacb7b_0.conda#77f70a9ab785a146dbf66fba00131403 +https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.4.0-py314h680f03e_0.conda#b712198b257f378e9bd8cde277218296 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda#8910d2c46f7e7b519129f486e0fe927a +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda#929471569c93acefb30282a22060dcd5 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda#f111d4cfaf1fe9496f386bc98ae94452 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda#af491aae930edc096b58466c51c4126c +https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.4-py314h1807b08_0.conda#866fd3d25b767bccb4adc8476f4035cd +https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda#ce96f2f470d39bd96ce03945af92e280 +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.0-pyhd8ed1ab_0.conda#8fa8358d022a3a9bd101384a808044c6 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.5.0-py314h42812f9_0.conda#d92bd1c6aa279f0c2e9add8aa5a8c846 +https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e +https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac +https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda#fb7130c190f9b4ec91219840a05ba3ac +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py314h97ea11e_0.conda#7397e418cab519b8d789936cf2dde6f6 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda#49c553b47ff679a6a1e9fc80b9c5a2d4 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda#9a17c4307d23318476d7fbf0fedc0cde +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/linux-64/mpfr-4.2.2-he0a73b1_0.conda#85ce2ffa51ab21da5efa4a9edc5946aa +https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda#a2c1eeadae7a309daed9d62c96012a2b +https://conda.anaconda.org/conda-forge/linux-64/nodejs-24.14.1-h3d65ac4_0.conda#fa4e76aac348ef9c27e72c79b02833fc +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/linux-64/orc-2.3.0-h21090e2_0.conda#8027fce94fdfdf2e54f9d18cbae496df +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pybind11-global-3.0.1-pyhc7ab6ef_0.conda#fe10b422ce8b5af5dab3740e4084c3f9 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac +https://conda.anaconda.org/conda-forge/linux-64/re2-2025.11.05-h5301d42_1.conda#66a715bc01c77d43aca1f9fcb13dde3c +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda#23b4ba5619c4752976eb7ba1f5acb7e8 +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py314h5bd0f2a_0.conda#dc1ff1e915ab35a06b6fa61efae73ab5 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py314h5bd0f2a_0.conda#494fdf358c152f9fdd0673c128c2f3dd +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 +https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda#b56e0c8432b56decafae7e78c5f29ba5 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.1-ha62d5e7_3.conda#55eaf7066da1299d217ab32baedc7fa8 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.15.2-hc1936db_2.conda#9120bc47b6f837f3cea90928c3e9a8fa +https://conda.anaconda.org/conda-forge/linux-64/azure-core-cpp-1.16.2-h206d751_0.conda#5492abf806c45298ae642831c670bba0 +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.0-py314h67df5f8_0.conda#7f8715a1928f6f126323320a4c5ada3a +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.62.1-pyh7db6752_0.conda#14cf1ac7a1e29553c6918f7860aab6d8 +https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a +https://conda.anaconda.org/conda-forge/linux-64/libglx-devel-1.7.0-ha4b6fd6_2.conda#27ac5ae872a21375d980bd4a6f99edf3 +https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.78.1-h1d1128b_0.conda#b5fb6d6c83f63d83ef2721dca6ff7091 +https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.2-default_hafda6a7_1000.conda#0ed3aa3e3e6bc85050d38881673a692f +https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.5-hf7376ad_1.conda#6adc0202fa7fcf0a5fce8c31ef2ed866 +https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9 +https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.43-h711ed8c_1.conda#87e6096ec6d542d1c1f8b33245fe8300 +https://conda.anaconda.org/conda-forge/linux-64/mpc-1.4.0-he0a73b1_0.conda#770d00bf57b5599c4544d61b61d8c6c6 +https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda#680608784722880fbfe1745067570b00 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda#76c4757c0ec9d11f969e8eb44899307b +https://conda.anaconda.org/conda-forge/linux-64/playwright-1.59.1-h5585027_0.conda#3ec4a57c54725b9be3cad126ccbad2c0 +https://conda.anaconda.org/conda-forge/linux-64/prometheus-cpp-1.3.0-ha5d0236_0.conda#a83f6a2fdc079e643237887a37460668 +https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.1-pyh7a1b43c_0.conda#70ece62498c769280f791e836ac53fff +https://conda.anaconda.org/conda-forge/noarch/pyee-13.0.1-pyhd8ed1ab_0.conda#eadf0f76d9121a6297be754e9d7cc099 +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.4-h4df99d1_100.conda#e4e60721757979d01d3964122f674959 +https://conda.anaconda.org/conda-forge/noarch/python-slugify-8.0.4-pyhd8ed1ab_1.conda#a4059bc12930bddeb41aef71537ffaed +https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda#4d1fc190b99912ed557a8236e958c559 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda#f2ba4192d38b6cef2bb2c25029071d90 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda#2ccd714aa2242315acaf0a67faea780b +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda#17dcc85db3c7886650b8908b183d6876 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.5-hb03c661_0.conda#e192019153591938acf7322b6459d36e +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.2-he6ee468_1.conda#50ae8372984b8b98e056ac8f6b70ab29 +https://conda.anaconda.org/conda-forge/linux-64/azure-identity-cpp-1.13.3-hed0cdb0_1.conda#68bfb556bdf56d56e9f38da696e752ca +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-common-cpp-12.12.0-ha7a2c86_1.conda#6400f73fe5ebe19fe7aca3616f1f1de7 +https://conda.anaconda.org/conda-forge/linux-64/gmpy2-2.3.0-py314h28848ee_1.conda#a99b82fda10aecd4ed853172bf4f6a28 +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.0-h6083320_0.conda#e194f6a2f498f0c7b1e6498bd0b12645 +https://conda.anaconda.org/conda-forge/linux-64/libclang13-22.1.5-default_h746c552_0.conda#c3df118cdc65584a78028bf225111b1b +https://conda.anaconda.org/conda-forge/linux-64/libgl-devel-1.7.0-ha4b6fd6_2.conda#53e7cbb2beb03d69a478631e23e340e9 +https://conda.anaconda.org/conda-forge/linux-64/libopentelemetry-cpp-1.26.0-h9692893_0.conda#c360be6f9e0947b64427603e91f9651f +https://conda.anaconda.org/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda#405ec206d230d9d37ad7c2636114cbf4 +https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.341.0-h5279c79_0.conda#31ad065eda3c2d88f8215b1289df9c89 +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/linux-64/optree-0.19.1-py314h9891dd4_0.conda#44ffc8b345a7844a847d4fdf469d64ea +https://conda.anaconda.org/conda-forge/noarch/playwright-python-1.59.0-pyhcf101f3_0.conda#313ba7b8e2a4fed359c935202853932c +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/linux-64/tbb-2023.0.0-h51de99f_1.conda#6383c1684badc0d94408b12850cf07f1 +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda#7bbe9a0cc0df0ac5f5a8ad6d6a11af2f +https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.38.3-h745e52d_1.conda#6a65b3595a8933808c03ff065dfb7702 +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-blobs-cpp-12.16.0-hdd73cc9_1.conda#939d9ce324e51961c7c4c0046733dbb7 +https://conda.anaconda.org/conda-forge/linux-64/libegl-devel-1.7.0-ha4b6fd6_2.conda#b513eb83b3137eca1192c34bf4f013a7 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-3.3.0-h25dbb67_1.conda#b2baa4ce6a9d9472aaa602b88f8d40ac +https://conda.anaconda.org/conda-forge/linux-64/mkl-2025.3.1-h0e700b2_12.conda#1a4a54fad5e36b8282ec6208dcb9bfb7 +https://conda.anaconda.org/conda-forge/linux-64/polars-runtime-32-1.40.0-py310hffdcd12_0.conda#8eacf9ff4d4e1ca1b52f8f3ba3e0c993 +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d +https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/noarch/requests-2.33.1-pyhcf101f3_1.conda#9659f587a8ceacc21864260acd02fc67 +https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_106.conda#32d866e43b25275f61566b9391ccb7b5 +https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.747-h41c0014_4.conda#169a79ea1127077d8dc36dc963ff55ac +https://conda.anaconda.org/conda-forge/linux-64/azure-storage-files-datalake-cpp-12.14.0-h52c5a47_1.conda#6d10339800840562b7dad7775f5d2c16 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h5875eb1_mkl.conda#d03e4571f7876dcd4e530f3d07faf333 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-3.3.0-hdbdcf42_1.conda#da94b149c8eea6ceef10d9e408dcfeb3 +https://conda.anaconda.org/conda-forge/linux-64/mkl-devel-2025.3.1-ha770c72_12.conda#db484eb7d5c23ca2a3129ddf5943de76 +https://conda.anaconda.org/conda-forge/noarch/polars-1.40.0-pyh58ad624_0.conda#fd16be490f5403adfbf27dd4901bbe34 +https://conda.anaconda.org/conda-forge/noarch/pytest-base-url-2.1.0-pyhd8ed1ab_1.conda#057f32e4c376ce0c4c4a32a9f06bf34e +https://conda.anaconda.org/conda-forge/linux-64/qt6-main-6.11.0-pl5321h16c4a6b_4.conda#c81127acb50fdc7760682495fc9ab088 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-24.0.0-h0935d00_1_cpu.conda#aed984d45692d6211ebf013b62c9fa02 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_hfef963f_mkl.conda#72cf77ee057f87d826f9b98cacd67a59 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h5e43f62_mkl.conda#8b13738802df008211c9ecd08775ca21 +https://conda.anaconda.org/conda-forge/linux-64/pyside6-6.11.0-py314h3987850_2.conda#c77e1fe23b6cf0b6077e5f924ac420c9 +https://conda.anaconda.org/conda-forge/noarch/pytest-playwright-0.7.2-pyhd8ed1ab_1.conda#34d1d3c36ffccb8dc02c3f8da7ae1e5c +https://conda.anaconda.org/conda-forge/linux-64/libarrow-compute-24.0.0-h53684a4_1_cpu.conda#0aac1926c3b2f8c35570af6be677f8ad +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-6_hdba1596_mkl.conda#5efff83ae645656f28c826aa192e7651 +https://conda.anaconda.org/conda-forge/linux-64/libparquet-24.0.0-h7376487_1_cpu.conda#5e60f3c311d00d456f089177bb75ebaf +https://conda.anaconda.org/conda-forge/linux-64/libtorch-2.10.0-cpu_mkl_h7058990_103.conda#2df90510834746b1f52c5299bc99a81f +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.3-py314h2b28147_0.conda#36f5b7eb328bdc204954a2225cf908e2 +https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.5-pyhcf101f3_0.conda#e65c7d49168ef8014ad0563ea0d94ff1 +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-6_hcf00494_mkl.conda#b789b886f2b45c3a9c91935639717808 +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h97ea11e_4.conda#95bede9cdb7a30a4b611223d52a01aa4 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-acero-24.0.0-h635bf11_1_cpu.conda#fa76d2ed4b435617a0fe5b8e7b9ae9c1 +https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.2-py314hb4ffadd_0.conda#41ee6fe2a848876bc9f524c5a500b85b +https://conda.anaconda.org/conda-forge/linux-64/pyarrow-core-24.0.0-py314h969be7f_0_cpu.conda#b066370d80ec7fca3c1d4028dc09164f +https://conda.anaconda.org/conda-forge/linux-64/pytorch-2.10.0-cpu_mkl_py314_h6018c46_103.conda#a3c40f317db763f9631d078d0fb2759e +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py314hf07bd8e_0.conda#d0510124f87c75403090e220db1e9d41 +https://conda.anaconda.org/conda-forge/noarch/scipy-doctest-2.2.0-pyhcf101f3_0.conda#21ac538af5bad73af42729841772de89 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.306-mkl.conda#51424ae4b1ba5521ee838721d63d4390 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-dataset-24.0.0-h635bf11_1_cpu.conda#021214e64486a6ba4df95d64b703f1fb +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py314h1194b4b_0.conda#11a821746ad11e642fcc615c3d66aa44 +https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py314h3a4f467_1.conda#478c6ef795065cd15cdbe1e214b30175 +https://conda.anaconda.org/conda-forge/linux-64/pytorch-cpu-2.10.0-cpu_mkl_hd61e0f4_103.conda#54ad123774a53ce33aa7e99b6e53b4a6 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-substrait-24.0.0-hb4dd7c2_1_cpu.conda#e3e42803a838c2177759e6aef1363512 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.10.9-py314hdafbbf9_0.conda#2046de06d7f4149a29c5d0e2cc26d6dd +https://conda.anaconda.org/conda-forge/linux-64/pyarrow-24.0.0-py314hdafbbf9_0.conda#6629041b133a9d65d68c4f2269432378 diff --git a/build_tools/azure/pylatest_conda_forge_mkl_linux-64_environment.yml b/build_tools/github/pylatest_conda_forge_mkl_linux-64_environment.yml similarity index 97% rename from build_tools/azure/pylatest_conda_forge_mkl_linux-64_environment.yml rename to build_tools/github/pylatest_conda_forge_mkl_linux-64_environment.yml index 52d3909e69b9e..04eea5a2eda06 100644 --- a/build_tools/azure/pylatest_conda_forge_mkl_linux-64_environment.yml +++ b/build_tools/github/pylatest_conda_forge_mkl_linux-64_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas diff --git a/build_tools/azure/pylatest_conda_forge_mkl_no_openmp_environment.yml b/build_tools/github/pylatest_conda_forge_mkl_no_openmp_environment.yml similarity index 97% rename from build_tools/azure/pylatest_conda_forge_mkl_no_openmp_environment.yml rename to build_tools/github/pylatest_conda_forge_mkl_no_openmp_environment.yml index beffbfec1753b..0ed7bccb07df0 100644 --- a/build_tools/azure/pylatest_conda_forge_mkl_no_openmp_environment.yml +++ b/build_tools/github/pylatest_conda_forge_mkl_no_openmp_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas diff --git a/build_tools/github/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock b/build_tools/github/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock new file mode 100644 index 0000000000000..a95c1f15d7b8d --- /dev/null +++ b/build_tools/github/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock @@ -0,0 +1,107 @@ +# Generated by conda-lock. +# platform: osx-64 +# input_hash: f1398e3b2f729d0642186ecb7999520190456ce9bfab584f3b12019c5374a520 +@EXPLICIT +https://conda.anaconda.org/conda-forge/osx-64/mkl-include-2023.2.0-h694c41f_50502.conda#f394610725ab086080230c5d8fd96cd4 +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda#4173ac3b19ec0a4f400b4f782910368b +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/osx-64/icu-78.3-h25d91c4_0.conda#627eca44e62e2b665eeec57a984a7f00 +https://conda.anaconda.org/conda-forge/osx-64/libbrotlicommon-1.2.0-h8616949_1.conda#f157c098841474579569c85a60ece586 +https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.5-h19cb2f5_1.conda#56fa8b3e43d26c97da88aea4e958f616 +https://conda.anaconda.org/conda-forge/osx-64/libdeflate-1.25-h517ebb2_0.conda#31aa65919a729dc48180893f62c25221 +https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.0-hcc62823_0.conda#d2e01f78c1daaeb4d2aa870125ebcd7e +https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda#66a0dc7464927d0853b590b6f53ba3ea +https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda#210a85a1119f97ea7887188d176db135 +https://conda.anaconda.org/conda-forge/osx-64/libjpeg-turbo-3.1.4.1-ha1e9b39_0.conda#57cc1464d457d01ac78f5860b9ca1714 +https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda#becdfbfe7049fa248e52aa37a9df09e2 +https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda#ec88ba8a245855935b871a7324373105 +https://conda.anaconda.org/conda-forge/osx-64/libwebp-base-1.6.0-hb807250_0.conda#7bb6608cf1f83578587297a158a6630b +https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda#30439ff30578e504ee5e0b390afc8c65 +https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.5-h0d3cbff_1.conda#d801d0ce2eab00dbb0178b196d0ce754 +https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda#31b8740cf1b2588d4e61c81191004061 +https://conda.anaconda.org/conda-forge/osx-64/pthread-stubs-0.4-h00291cd_1002.conda#8bcf980d2c6b17094961198284b8e862 +https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda#47f1b8b4a76ebd0cd22bd7153e54a4dc +https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda#435446d9d7db8e094d2c989766cfb146 +https://conda.anaconda.org/conda-forge/osx-64/xxhash-0.8.3-h13e91ac_0.conda#3e1f33316570709dac5d04bc4ad1b6d0 +https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda#eaac87c21aff3ed21ad9656697bb8326 +https://conda.anaconda.org/conda-forge/osx-64/lerc-4.1.0-h35c7297_0.conda#d2fe7e177d1c97c985140bd54e2a5e33 +https://conda.anaconda.org/conda-forge/osx-64/libbrotlidec-1.2.0-h8616949_1.conda#63186ac7a8a24b3528b4b14f21c03f54 +https://conda.anaconda.org/conda-forge/osx-64/libbrotlienc-1.2.0-h8616949_1.conda#12a58fd3fc285ce20cf20edf21a0ff8f +https://conda.anaconda.org/conda-forge/osx-64/libhiredis-1.3.0-h240833e_1.conda#5a088b358e37ccb4f4e5c573ff37a9f9 +https://conda.anaconda.org/conda-forge/osx-64/libpng-1.6.58-he930e7c_0.conda#9744d43d5200f284260637304a069ddd +https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.1-h8f8c405_0.conda#9273c877f78b7486b0dfdd9268327a79 +https://conda.anaconda.org/conda-forge/osx-64/libxcb-1.17.0-hf1f96e2_0.conda#bbeca862892e2898bdb45792a61c4afc +https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h7a90416_0.conda#c74ae93cd7876e3a9c4b5569d5e29e34 +https://conda.anaconda.org/conda-forge/osx-64/ninja-1.13.2-hfc0b2d5_0.conda#afda563484aa0017278866707807a335 +https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.2-hc881268_0.conda#5cf0ece4375c73d7a5765e83565a69c7 +https://conda.anaconda.org/conda-forge/osx-64/qhull-2020.2-h3c5361c_5.conda#dd1ea9ff27c93db7c01a7b7656bd4ad4 +https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda#eefd65452dfe7cce476a519bece46704 +https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda#6e6efb7463f8cef69dbcb4c2205bf60e +https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda#b3ecb6480fd46194e3f7dd0ff4445dff +https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda#727109b184d680772e3122f40136d5ca +https://conda.anaconda.org/conda-forge/osx-64/brotli-bin-1.2.0-h8616949_1.conda#34803b20dfec7af32ba675c5ccdbedbf +https://conda.anaconda.org/conda-forge/osx-64/ccache-4.13.6-h894318c_0.conda#8ae9dfcda989b435223605126a97a963 +https://conda.anaconda.org/conda-forge/osx-64/libfreetype6-2.14.3-h58fbd8d_0.conda#27515b8ab8bf4abd8d3d90cf11212411 +https://conda.anaconda.org/conda-forge/osx-64/libgcc-15.2.0-h08519bb_19.conda#4bf33d5ca73f4b89d3495285a42414a4 +https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.7.1-ha0a348c_1.conda#9d4344f94de4ab1330cdc41c40152ea6 +https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h953d39d_0.conda#33f30d4878d1f047da82a669c33b307d +https://conda.anaconda.org/conda-forge/osx-64/python-3.14.4-h7c6738f_100_cp314.conda#d4e8506d0ac094be21451682eed9ce4d +https://conda.anaconda.org/conda-forge/osx-64/brotli-1.2.0-hf139dec_1.conda#149d8ee7d6541a02a6117d8814fd9413 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/osx-64/cython-3.2.4-py314hf0dd12f_0.conda#4dbcccd0d8e2bfe89246de1547d58c17 +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/osx-64/kiwisolver-1.5.0-py314hd6e1bd6_0.conda#25a8718587d3d0d9114b25dfa93b864c +https://conda.anaconda.org/conda-forge/osx-64/lcms2-2.19.1-h5ea7634_0.conda#3ae3b6db0dcada986f1e3b608e1cb0fc +https://conda.anaconda.org/conda-forge/osx-64/libfreetype-2.14.3-h694c41f_0.conda#63b822fcf984c891f0afab2eedfcfaf4 +https://conda.anaconda.org/conda-forge/osx-64/libgfortran5-15.2.0-hd16e46c_19.conda#1cddb3f7e54f5871297afc0fafa61c2c +https://conda.anaconda.org/conda-forge/osx-64/libhwloc-2.12.2-default_h273dbb7_1000.conda#56aaf4b7cc4c24e30cecc185bb08668d +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h52bb76a_0.conda#46e628da6e796c948fa8ec9d6d10bda3 +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.5-py314h217eccc_0.conda#9fdead77ed9fd152b131289c6984ed7c +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.1-py314h4f144dc_0.conda#773e3141f292d9698e706da094ada8c1 +https://conda.anaconda.org/conda-forge/osx-64/coverage-7.14.0-py314h77fa6c7_0.conda#8c42f6115a718f67602c792c0ab2cc14 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.62.1-pyh7db6752_0.conda#14cf1ac7a1e29553c6918f7860aab6d8 +https://conda.anaconda.org/conda-forge/osx-64/freetype-2.14.3-h694c41f_0.conda#6ab1403cc6cb284d56d0464f19251075 +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/osx-64/libgfortran-15.2.0-h7e5c614_19.conda#d362f41203d0a1d2d4940446f95374c9 +https://conda.anaconda.org/conda-forge/osx-64/pillow-12.2.0-py314hc904d5e_0.conda#fb32d458ddac23248e07a0830c6ffc7b +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/osx-64/tbb-2021.13.0-h06b67a2_5.conda#f3e5cd2b56a3c866214b1d2529a54730 +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/osx-64/mkl-2023.2.0-h694c41f_50502.conda#0bdfc939c8542e0bc6041cbd9a900219 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/osx-64/libblas-3.9.0-20_osx64_mkl.conda#160fdc97a51d66d51dc782fb67d35205 +https://conda.anaconda.org/conda-forge/osx-64/mkl-devel-2023.2.0-h694c41f_50502.conda#045f993e4434eaa02518d780fdca34ae +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d +https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/osx-64/libcblas-3.9.0-20_osx64_mkl.conda#51089a4865eb4aec2bc5c7468bd07f9f +https://conda.anaconda.org/conda-forge/osx-64/liblapack-3.9.0-20_osx64_mkl.conda#58f08e12ad487fac4a08f90ff0b87aec +https://conda.anaconda.org/conda-forge/osx-64/liblapacke-3.9.0-20_osx64_mkl.conda#124ae8e384268a8da66f1d64114a1eda +https://conda.anaconda.org/conda-forge/osx-64/numpy-2.4.3-py314h7b24d9b_0.conda#3d8057ab97e4c8fd1f781356e7be9b40 +https://conda.anaconda.org/conda-forge/osx-64/blas-devel-3.9.0-20_osx64_mkl.conda#cc3260179093918b801e373c6e888e02 +https://conda.anaconda.org/conda-forge/osx-64/contourpy-1.3.3-py314h22a2ed9_4.conda#511f02f632e1fb0555da3cb4261851d9 +https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.2-py314h99bb933_0.conda#84a0c511492546f0363360ad1e4e6510 +https://conda.anaconda.org/conda-forge/osx-64/scipy-1.17.1-py314h5727af0_0.conda#adbed17bd17ac00193e6dce1f1a37781 +https://conda.anaconda.org/conda-forge/osx-64/blas-2.120-mkl.conda#b041a7677a412f3d925d8208936cb1e2 +https://conda.anaconda.org/conda-forge/osx-64/matplotlib-base-3.10.9-py314h7c1ad30_0.conda#9ab3835bd11afa0a9571ade6a875b5ce +https://conda.anaconda.org/conda-forge/osx-64/pyamg-5.3.0-py314h81027db_1.conda#47390f4299f43bcdae539d454178596e +https://conda.anaconda.org/conda-forge/osx-64/matplotlib-3.10.9-py314hee6578b_0.conda#4cfadc239dd7d8ca653048e041f70cd0 diff --git a/build_tools/github/pylatest_conda_forge_osx-arm64_conda.lock b/build_tools/github/pylatest_conda_forge_osx-arm64_conda.lock new file mode 100644 index 0000000000000..c050c895a380f --- /dev/null +++ b/build_tools/github/pylatest_conda_forge_osx-arm64_conda.lock @@ -0,0 +1,164 @@ +# Generated by conda-lock. +# platform: osx-arm64 +# input_hash: 072330962b0e84b395b4cef158ee9e0efdbcfc144c0a87ce34d8ede33c046c59 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/libgfortran-devel_osx-arm64-14.3.0-hc965647_1.conda#c1b69e537b3031d0f5af780b432ce511 +https://conda.anaconda.org/conda-forge/noarch/nomkl-1.0-h5ca1d4c_0.tar.bz2#9a66894dfd07c4510beb6b3f9672ccc0 +https://conda.anaconda.org/conda-forge/noarch/pybind11-abi-11-hc364b38_1.conda#f0599959a2447c1e544e216bddf393fa +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda#5f0ebbfea12d8e5bddff157e271fdb2f +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda#620b85a3f45526a8bc4d23fd78fc22f0 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda#006e7ddd8a110771134fcc4e1e3a6ffa +https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.5-h55c6f16_1.conda#ff484b683fecf1e875dfc7aa01d19796 +https://conda.anaconda.org/conda-forge/noarch/libcxx-headers-19.1.7-h707e725_2.conda#de91b5ce46dc7968b6e311f9add055a2 +https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda#a6130c709305cd9828b4e1bd9ba0000c +https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.0-hf6b4638_0.conda#65466e82c09e888ca7560c11a97d5450 +https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda#43c04d9cb46ef176bb2a4c77e324d599 +https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda#4d5a7445f0b25b6a3ddbb56e790f5251 +https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.4.1-h84a0fba_0.conda#b8a7544c83a67258b0e8592ec6a5d322 +https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda#b1fd823b5ae54fbec272cea0811bd8a9 +https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda#57c4be259f5e0b99a5983799a228ae55 +https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.51.0-h6caf38d_1.conda#c0d87c3c8e075daf1daf6c31b53e8083 +https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda#e5e7d467f80da752be17796b87fe6385 +https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda#bc5a5721b6439f2f62a84f2548136082 +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.5-hc7d1edf_1.conda#8a4e2a54034b35bc6fa5bf9282913f45 +https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda#343d10ed5b44030a2f67193905aea159 +https://conda.anaconda.org/conda-forge/osx-arm64/pthread-stubs-0.4-hd74edd7_1002.conda#415816daf82e0b23a736a069a75e9da7 +https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda#78b548eed8227a689f93775d5d23ae09 +https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda#9d1299ace1924aa8f4e0bc8e71dd0cf7 +https://conda.anaconda.org/conda-forge/osx-arm64/xxhash-0.8.3-haa4e116_0.conda#54a24201d62fc17c73523e4b86f71ae8 +https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda#a44032f282e7d2acdeb1c240308052dd +https://conda.anaconda.org/conda-forge/osx-arm64/fmt-12.1.0-h403dcb5_0.conda#ae2f556fbb43e5a75cc80a47ac942a8e +https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda#eed7278dfbab727b56f2c0b64330814b +https://conda.anaconda.org/conda-forge/osx-arm64/isl-0.26-imath32_h347afa1_101.conda#e80e44a3f4862b1da870dc0557f8cf3b +https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.1.0-h1eee2c3_0.conda#095e5749868adab9cae42d4b460e5443 +https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260107.1-cxx17_h2062a1b_0.conda#bb65152e0d7c7178c0f1ee25692c9fd1 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda#079e88933963f3f149054eec2c487bc2 +https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda#b2b7c8288ca1a2d71ff97a8e6a1e8883 +https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-devel-19.1.7-h6dc3340_2.conda#9f7810b7c0a731dbc84d46d6005890ef +https://conda.anaconda.org/conda-forge/osx-arm64/libhiredis-1.3.0-h286801f_1.conda#58b2c5aee0ad58549bf92baead9baead +https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda#2259ae0949dbe20c0665850365109b27 +https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.1-h1b79a29_0.conda#6681822ea9d362953206352371b6a904 +https://conda.anaconda.org/conda-forge/osx-arm64/libxcb-1.17.0-hdb1d25a_0.conda#af523aae2eca6dfa1c8eec693f5b9a79 +https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda#6c8292c2ee808aeef2406083beaa6da7 +https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h49c215f_0.conda#175809cc57b2c67f27a0f238bd7f069d +https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.2-hd24854e_0.conda#25dcccd4f80f1638428613e0d7c9b4e1 +https://conda.anaconda.org/conda-forge/osx-arm64/qhull-2020.2-h420ef59_5.conda#6483b1f59526e05d7d894e466b5b6924 +https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda#f8381319127120ce51e081dce4865cf4 +https://conda.anaconda.org/conda-forge/osx-arm64/sleef-3.9.0-hb028509_0.conda#68f833178f171cfffdd18854c0e9b7f9 +https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_2.conda#555070ad1e18b72de36e9ee7ed3236b3 +https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda#a9d86bc62f39b94c4661716624eb21b0 +https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_2.conda#f1c0bce276210bed45a04949cfe8dc20 +https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda#d99c2a23a31b0172e90f456f580b695e +https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda#ab136e4c34e97f34fb621d2592a393d8 +https://conda.anaconda.org/conda-forge/osx-arm64/brotli-bin-1.2.0-hc919400_1.conda#377d015c103ad7f3371be1777f8b584c +https://conda.anaconda.org/conda-forge/osx-arm64/ccache-4.13.6-h414bf82_0.conda#1628795893a799313a719264fd7f2227 +https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_0.conda#e98ba7b5f09a5f450eca083d5a1c4649 +https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-15.2.0-hcbb3090_19.conda#644058123986582db33aebd4ae2ca184 +https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.33.5-h4a5acfd_0.conda#b839e3295b66434f20969c8b940f056a +https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda#c08557d00807785decafb932b5be7ef5 +https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda#e2a72ab2fa54ecb6abab2b26cde93500 +https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda#0c1fdc80534d8f25fd74722aba81f044 +https://conda.anaconda.org/conda-forge/osx-arm64/mpfr-4.2.2-h6bc93b0_0.conda#a47a14da2103c9c7a390f7c8bc8d7f9b +https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.4-h4c637c5_100_cp314.conda#e1bc5a3015a4bbeb304706dba5a32b7f +https://conda.anaconda.org/conda-forge/osx-arm64/brotli-1.2.0-h7d5ae5b_1.conda#48ece20aa479be6ac9a284772827d00c +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_100.conda#f111d4cfaf1fe9496f386bc98ae94452 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/osx-arm64/cython-3.2.4-py314hc6117b3_0.conda#1289de88f884ac89144949cb97ccabe7 +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.0-pyhd8ed1ab_0.conda#8fa8358d022a3a9bd101384a808044c6 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/osx-arm64/kiwisolver-1.5.0-py314hf8a3a22_0.conda#eb1465d8a644ef290d18fb86af6e9bc4 +https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_0.conda#e5ba982008c0ac1a1c0154617371bab5 +https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_0.conda#f73b109d49568d5d1dda43bb147ae37f +https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-15.2.0-hdae7583_19.conda#ba36d8c606a6a53fe0b8c12d47267b3d +https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda#d1d9b233830f6631800acc1e081a9444 +https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda#d33c0a15882b70255abdd54711b06a45 +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/osx-arm64/mpc-1.4.0-h169892a_0.conda#2845c3a1d0d8da1db92aba8323892475 +https://conda.anaconda.org/conda-forge/noarch/mpmath-1.3.0-pyhd8ed1ab_1.conda#3585aa87c43ab15b167b574cd73b057b +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/networkx-3.6.1-pyhcf101f3_0.conda#a2c1eeadae7a309daed9d62c96012a2b +https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda#4b5d3a91320976eec71678fad1e3569b +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pybind11-global-3.0.1-pyhc7ab6ef_0.conda#fe10b422ce8b5af5dab3740e4084c3f9 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda#ade77ad7513177297b1d75e351e136ce +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.5-py314h6c2aa35_0.conda#3f81f8b2fe2c26a82c0abf57ab2b9610 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-17.0.1-py314h6c2aa35_0.conda#4fffb3ba871bb05f34ffb705534dfef5 +https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.14.0-py314h6e9b3f0_0.conda#70cf43e2d03269a3dfb33c284ce05dff +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.62.1-pyh7db6752_0.conda#14cf1ac7a1e29553c6918f7860aab6d8 +https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.3-hce30654_0.conda#6dcc75ba2e04c555e881b72793d3282f +https://conda.anaconda.org/conda-forge/osx-arm64/gmpy2-2.3.0-py314hf9f5e1b_1.conda#036584b863246f278f4057327c36a94d +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_4.conda#eaf3d06e3a8a10dee7565e8d76ae618d +https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda#ddb70ebdcbf3a44bddc2657a51faf490 +https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-15.2.0-h07b0088_19.conda#1ea03f87cdb1078fbc0e2b2deb63752c +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda#8237b150fcd7baf65258eef9a0fc76ef +https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.2.0-py314hab283cf_0.conda#adf49537da0e0c34cf735e71fe579506 +https://conda.anaconda.org/conda-forge/noarch/pybind11-3.0.1-pyh7a1b43c_0.conda#70ece62498c769280f791e836ac53fff +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda#edd329d7d3a4ab45dcf905899a7a6115 +https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda#5a77d772c22448f6ab340fbfff55db48 +https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_4.conda#22eb76f8d98f4d3b8319d40bda9174de +https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.32-openmp_he657e61_0.conda#3a1111a4b6626abebe8b978bb5a323bf +https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda#3e3ac06efc5fdc1aa675ca30bf7d53df +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/osx-arm64/optree-0.19.1-py314h6cfcd04_0.conda#7f58db69263708a3bd66bb7e547bf0a7 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/sympy-1.14.0-pyh2585a3b_106.conda#32d866e43b25275f61566b9391ccb7b5 +https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_he8a363d_4.conda#76c651b923e048f3f3e0ecb22c966f70 +https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-6_h51639a9_openblas.conda#e551103471911260488a02155cef9c94 +https://conda.anaconda.org/conda-forge/osx-arm64/openblas-0.3.32-openmp_hea878ba_0.conda#314abb0d8622fa7d95915e53bb511922 +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d +https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_4.conda#caf7c8e48827c2ad0c402716159fe0a2 +https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_4.conda#0d059c5db9d880ff37b2da53bf06509e +https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-6_hb0561ab_openblas.conda#805c6d31c5621fd75e53dfcf21fb243a +https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-6_hd9741b5_openblas.conda#ee33d2d05a7c5ea1f67653b37eb74db1 +https://conda.anaconda.org/conda-forge/osx-arm64/liblapacke-3.11.0-6_h1b118fd_openblas.conda#0151c0418077e835952ceee67a0ea693 +https://conda.anaconda.org/conda-forge/osx-arm64/libtorch-2.10.0-cpu_generic_hf7cc835_3.conda#98f89ad42eaba858443d31336677aed2 +https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.4.3-py314h1569ea8_0.conda#0fab9cf4fc5163131387f36742b50c79 +https://conda.anaconda.org/conda-forge/noarch/array-api-strict-2.5-pyhcf101f3_0.conda#e65c7d49168ef8014ad0563ea0d94ff1 +https://conda.anaconda.org/conda-forge/osx-arm64/blas-devel-3.11.0-6_h11c0a38_openblas.conda#923a8c7dd5c8ae2d5a3aff4e1e579337 +https://conda.anaconda.org/conda-forge/osx-arm64/contourpy-1.3.3-py314hf8a3a22_4.conda#cddc851000ce131d757678c2f329eaad +https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.2-py314he609de1_0.conda#a28d1a3565d7c6d95479c2c6e52c1b16 +https://conda.anaconda.org/conda-forge/osx-arm64/pytorch-2.10.0-cpu_generic_py314_he36690f_3.conda#51da3d684a7d90b35adfd742fb551cc4 +https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.17.1-py314hfc1f868_0.conda#7806ce54b78b0b11517b465a3398e910 +https://conda.anaconda.org/conda-forge/osx-arm64/blas-2.306-openblas.conda#4cd635f3755993f4658959c2b3e1f2ef +https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-base-3.10.9-py314hc042b31_0.conda#3252e58ac5ade3ba2dacd5dacfa6e7b8 +https://conda.anaconda.org/conda-forge/osx-arm64/pyamg-5.3.0-py314h95ce61a_1.conda#44282cc9330eb206f808c4f5be281fe2 +https://conda.anaconda.org/conda-forge/osx-arm64/pytorch-cpu-2.10.0-cpu_generic_hcc7c195_3.conda#e7ebf31f2c197adaba9bbf84a40dffd9 +https://conda.anaconda.org/conda-forge/osx-arm64/matplotlib-3.10.9-py314he55896b_0.conda#553de53f80d4eeef68ff2b2ec225ed5f +https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda#148516e0c9edf4e9331a4d53ae806a9b +https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda#20056c993a8c9df01e04a0e165579ec1 +https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda#8d99c82e0f5fed6cc36fcf66a11e03f0 +https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_impl_osx-arm64-14.3.0-h6d03799_1.conda#1e9ec88ecc684d92644a45c6df2399d0 +https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda#39451684370ae65667fa5c11222e43f7 +https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda#2aec2e39be3b4999bda2a3e5bd4cd2e6 +https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_31.conda#6645630920c0980a33f055a49fbdb88e +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda#8b7425e84f940861653c919142435bde +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx-19.1.7-default_hc995acf_9.conda#9a1ac8e5124fcc201adb20a103d51cc6 +https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_osx-arm64-14.3.0-h3c33bd0_0.conda#8db8c0061c0f3701444b7b9cc9966511 +https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_osx-arm64-19.1.7-h75f8d18_31.conda#bd6926e81dc196064373b614af3bc9ff +https://conda.anaconda.org/conda-forge/osx-arm64/gfortran-14.3.0-h3ef1dbf_0.conda#e148e0bc9bbc90b6325a479a5501786d +https://conda.anaconda.org/conda-forge/osx-arm64/cxx-compiler-1.11.0-h88570a1_0.conda#043afed05ca5a0f2c18252ae4378bdee +https://conda.anaconda.org/conda-forge/osx-arm64/fortran-compiler-1.11.0-h81a4f41_0.conda#d221c62af175b83186f96d8b0880bff6 +https://conda.anaconda.org/conda-forge/osx-arm64/compilers-1.11.0-hce30654_0.conda#aac0d423ecfd95bde39582d0de9ca657 diff --git a/build_tools/azure/pylatest_conda_forge_osx-arm64_environment.yml b/build_tools/github/pylatest_conda_forge_osx-arm64_environment.yml similarity index 97% rename from build_tools/azure/pylatest_conda_forge_osx-arm64_environment.yml rename to build_tools/github/pylatest_conda_forge_osx-arm64_environment.yml index f5bb0206a9fa6..ceeab1a46d216 100644 --- a/build_tools/azure/pylatest_conda_forge_osx-arm64_environment.yml +++ b/build_tools/github/pylatest_conda_forge_osx-arm64_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas diff --git a/build_tools/azure/pylatest_free_threaded_environment.yml b/build_tools/github/pylatest_free_threaded_environment.yml similarity index 96% rename from build_tools/azure/pylatest_free_threaded_environment.yml rename to build_tools/github/pylatest_free_threaded_environment.yml index a6bd1d1f653ba..38660db5ea287 100644 --- a/build_tools/azure/pylatest_free_threaded_environment.yml +++ b/build_tools/github/pylatest_free_threaded_environment.yml @@ -10,6 +10,7 @@ dependencies: - numpy - scipy - joblib + - narwhals - threadpoolctl - pytest - pytest-run-parallel diff --git a/build_tools/github/pylatest_free_threaded_linux-64_conda.lock b/build_tools/github/pylatest_free_threaded_linux-64_conda.lock new file mode 100644 index 0000000000000..95bc2fb843154 --- /dev/null +++ b/build_tools/github/pylatest_free_threaded_linux-64_conda.lock @@ -0,0 +1,59 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: dfa4c0cdd5a3e5cc983ff3ccbf680082d433b6249fd8b1639d9dbccb230466b3 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314t.conda#3251796e09870c978e0f69fa05e38fb6 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.32-pthreads_h94d23a6_0.conda#89d61bc91d3f39fda0ca10fcd3c68594 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-hf9ea5aa_0_cp314t.conda#f9c864fd19f2e57a6624520c63262a16 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.4-py314hd8ed1ab_0.conda#de1699ede4f26f116d44653d95228453 +https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.4-py314h3f98dc2_0.conda#cc2fcbfdf0628b5ad05b319866187bbc +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h4a7cf45_openblas.conda#6d6d225559bfa6e2f3c90ee9c03d4e2e +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_h0358290_openblas.conda#36ae340a916635b97ac8a0655ace2a35 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h47877c9_openblas.conda#881d801569b201c2e753f03c84b85e15 +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-freethreading-3.14.4-h92d6c8b_0.conda#431c21b61666866b1b4cb3252974642c +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.3-py314hd4f4903_0.conda#ee2b2bb9e96a9cd64d68492842559adf +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/pytest-run-parallel-0.8.2-pyhd8ed1ab_0.conda#288250b7e539cddf52f39616deae278d +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py314h529d2a9_0.conda#c09dd94be0e88aca25c60fb53d5c8e45 diff --git a/build_tools/azure/pylatest_pip_openblas_pandas_environment.yml b/build_tools/github/pylatest_pip_openblas_pandas_environment.yml similarity index 95% rename from build_tools/azure/pylatest_pip_openblas_pandas_environment.yml rename to build_tools/github/pylatest_pip_openblas_pandas_environment.yml index 38f2eaa36f432..7351905714d16 100644 --- a/build_tools/azure/pylatest_pip_openblas_pandas_environment.yml +++ b/build_tools/github/pylatest_pip_openblas_pandas_environment.yml @@ -12,6 +12,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pandas @@ -24,7 +25,7 @@ dependencies: - pytest-cov<=6.3.0 - coverage - sphinx - - numpydoc<1.9.0 + - numpydoc - lightgbm - array-api-strict - scipy-doctest diff --git a/build_tools/github/pylatest_pip_openblas_pandas_linux-64_conda.lock b/build_tools/github/pylatest_pip_openblas_pandas_linux-64_conda.lock new file mode 100644 index 0000000000000..38a724e6c6d4c --- /dev/null +++ b/build_tools/github/pylatest_pip_openblas_pandas_linux-64_conda.lock @@ -0,0 +1,84 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: bcf549bd8b88cf3153cf032a6a1e47a965be224b7a3a61f9df80cee66c784ab2 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/python-3.13.13-h6add32d_100_cp313.conda#05051be49267378d2fcd12931e319ac3 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +# pip alabaster @ https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl#sha256=fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b +# pip babel @ https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl#sha256=e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +# pip certifi @ https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl#sha256=3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a +# pip charset-normalizer @ https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd +# pip coverage @ https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl#sha256=9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca +# pip cycler @ https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl#sha256=85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 +# pip cython @ https://files.pythonhosted.org/packages/7a/d2/16fa02f129ed2b627e88d9d9ebd5ade3eeb66392ae5ba85b259d2d52b047/cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8 +# pip docutils @ https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl#sha256=d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de +# pip execnet @ https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl#sha256=67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec +# pip fonttools @ https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1 +# pip idna @ https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl#sha256=e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69 +# pip imagesize @ https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl#sha256=5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 +# pip iniconfig @ https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl#sha256=f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +# pip joblib @ https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl#sha256=5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 +# pip kiwisolver @ https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3 +# pip markupsafe @ https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 +# pip meson @ https://files.pythonhosted.org/packages/5e/cd/f3a881ff5e601d6bbeff63b38ee2362e1167c47d9cde03eddf8d71a4ffb0/meson-1.11.1-py3-none-any.whl#sha256=9b3a023657e393dbc5335b95c561337d49b7a458f5541e47ec44f2cc566e0d80 +# pip narwhals @ https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl#sha256=1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be +# pip ninja @ https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa +# pip numpy @ https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 +# pip packaging @ https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl#sha256=5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e +# pip pillow @ https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 +# pip pluggy @ https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl#sha256=e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +# pip pygments @ https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl#sha256=81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +# pip pyparsing @ https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl#sha256=850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d +# pip roman-numerals @ https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl#sha256=647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 +# pip six @ https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl#sha256=4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +# pip snowballstemmer @ https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl#sha256=6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 +# pip sphinxcontrib-applehelp @ https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl#sha256=4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 +# pip sphinxcontrib-devhelp @ https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl#sha256=aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2 +# pip sphinxcontrib-htmlhelp @ https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl#sha256=166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 +# pip sphinxcontrib-jsmath @ https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl#sha256=2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 +# pip sphinxcontrib-qthelp @ https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl#sha256=b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb +# pip sphinxcontrib-serializinghtml @ https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl#sha256=6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 +# pip threadpoolctl @ https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl#sha256=43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb +# pip urllib3 @ https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl#sha256=9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +# pip array-api-strict @ https://files.pythonhosted.org/packages/22/a3/ed2786497cb3cb90f13ff3eb5f3cf92b447ae3f8451307c0e94eeb2bb445/array_api_strict-2.5-py3-none-any.whl#sha256=0438dd48df521c710ca33d13e8f7e41dd74a9c2427a5d8f549d6f2fe93f8d945 +# pip contourpy @ https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9 +# pip jinja2 @ https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl#sha256=85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +# pip pyproject-metadata @ https://files.pythonhosted.org/packages/1d/0b/da4851b1e2d9c40c9bd74c0abd94510a7d797da9ccde0a90e8953751ed4a/pyproject_metadata-0.11.0-py3-none-any.whl#sha256=85bbecca8694e2c00f63b492c96921d6c228454057c88e7c352b2077fcaa4096 +# pip pytest @ https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl#sha256=2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 +# pip python-dateutil @ https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl#sha256=a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +# pip requests @ https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl#sha256=4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a +# pip scipy @ https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464 +# pip lightgbm @ https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl#sha256=cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d +# pip matplotlib @ https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f +# pip meson-python @ https://files.pythonhosted.org/packages/16/7f/d1b0c65b267a1463d752b324f11d3470e30889daefc4b9ec83029bfa30b5/meson_python-0.19.0-py3-none-any.whl#sha256=67b5906c37404396d23c195e12c8825506074460d4a2e7083266b845d14f0298 +# pip pandas @ https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl#sha256=5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e +# pip pyamg @ https://files.pythonhosted.org/packages/63/f3/c13ae1422434baeefe4d4f306a1cc77f024fe96d2abab3c212cfa1bf3ff8/pyamg-5.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl#sha256=5cc223c66a7aca06fba898eb5e8ede6bb7974a9ddf7b8a98f56143c829e63631 +# pip pytest-cov @ https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl#sha256=440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 +# pip pytest-xdist @ https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl#sha256=202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 +# pip scipy-doctest @ https://files.pythonhosted.org/packages/71/65/91154fa8addb81ad204406e9cbf8dbe630e86d3cf8689957de17b6a12ca2/scipy_doctest-2.2.0-py3-none-any.whl#sha256=2a0c2d445825176442e223bea24f57c645563a7de3a879f4bd5270ff12523d91 +# pip sphinx @ https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl#sha256=c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978 +# pip numpydoc @ https://files.pythonhosted.org/packages/62/5e/3a6a3e90f35cea3853c45e5d5fb9b7192ce4384616f932cf7591298ab6e1/numpydoc-1.10.0-py3-none-any.whl#sha256=3149da9874af890bcc2a82ef7aae5484e5aa81cb2778f08e3c307ba6d963721b diff --git a/build_tools/azure/pylatest_pip_scipy_dev_environment.yml b/build_tools/github/pylatest_pip_scipy_dev_environment.yml similarity index 95% rename from build_tools/azure/pylatest_pip_scipy_dev_environment.yml rename to build_tools/github/pylatest_pip_scipy_dev_environment.yml index ff94ab7b1949d..c2b10397b2d99 100644 --- a/build_tools/azure/pylatest_pip_scipy_dev_environment.yml +++ b/build_tools/github/pylatest_pip_scipy_dev_environment.yml @@ -18,5 +18,5 @@ dependencies: - coverage - pooch - sphinx - - numpydoc<1.9.0 + - numpydoc - python-dateutil diff --git a/build_tools/github/pylatest_pip_scipy_dev_linux-64_conda.lock b/build_tools/github/pylatest_pip_scipy_dev_linux-64_conda.lock new file mode 100644 index 0000000000000..04a0723668f53 --- /dev/null +++ b/build_tools/github/pylatest_pip_scipy_dev_linux-64_conda.lock @@ -0,0 +1,69 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: 24ef416e2330a91ab0f9ebe316ec9431025e1b63eab146a1ce2e60f14fcf4caa +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.4-habeac84_100_cp314.conda#a443f87920815d41bfe611296e507995 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh145f28c_0.conda#2e7e59a063366f1fc4f45ac86bd9485f +# pip alabaster @ https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl#sha256=fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b +# pip babel @ https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl#sha256=e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +# pip certifi @ https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl#sha256=3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a +# pip charset-normalizer @ https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e +# pip coverage @ https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl#sha256=5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3 +# pip docutils @ https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl#sha256=d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de +# pip execnet @ https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl#sha256=67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec +# pip idna @ https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl#sha256=e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69 +# pip imagesize @ https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl#sha256=5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96 +# pip iniconfig @ https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl#sha256=f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +# pip markupsafe @ https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl#sha256=457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 +# pip meson @ https://files.pythonhosted.org/packages/5e/cd/f3a881ff5e601d6bbeff63b38ee2362e1167c47d9cde03eddf8d71a4ffb0/meson-1.11.1-py3-none-any.whl#sha256=9b3a023657e393dbc5335b95c561337d49b7a458f5541e47ec44f2cc566e0d80 +# pip ninja @ https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl#sha256=fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa +# pip packaging @ https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl#sha256=5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e +# pip platformdirs @ https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl#sha256=e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 +# pip pluggy @ https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl#sha256=e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +# pip pygments @ https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl#sha256=81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +# pip roman-numerals @ https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl#sha256=647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 +# pip six @ https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl#sha256=4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +# pip snowballstemmer @ https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl#sha256=6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064 +# pip sphinxcontrib-applehelp @ https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl#sha256=4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5 +# pip sphinxcontrib-devhelp @ https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl#sha256=aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2 +# pip sphinxcontrib-htmlhelp @ https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl#sha256=166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8 +# pip sphinxcontrib-jsmath @ https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl#sha256=2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178 +# pip sphinxcontrib-qthelp @ https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl#sha256=b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb +# pip sphinxcontrib-serializinghtml @ https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl#sha256=6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331 +# pip threadpoolctl @ https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl#sha256=43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb +# pip urllib3 @ https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl#sha256=9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +# pip jinja2 @ https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl#sha256=85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +# pip pyproject-metadata @ https://files.pythonhosted.org/packages/1d/0b/da4851b1e2d9c40c9bd74c0abd94510a7d797da9ccde0a90e8953751ed4a/pyproject_metadata-0.11.0-py3-none-any.whl#sha256=85bbecca8694e2c00f63b492c96921d6c228454057c88e7c352b2077fcaa4096 +# pip pytest @ https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl#sha256=2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 +# pip python-dateutil @ https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl#sha256=a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +# pip requests @ https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl#sha256=4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a +# pip meson-python @ https://files.pythonhosted.org/packages/16/7f/d1b0c65b267a1463d752b324f11d3470e30889daefc4b9ec83029bfa30b5/meson_python-0.19.0-py3-none-any.whl#sha256=67b5906c37404396d23c195e12c8825506074460d4a2e7083266b845d14f0298 +# pip pooch @ https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl#sha256=f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b +# pip pytest-cov @ https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl#sha256=440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749 +# pip pytest-xdist @ https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl#sha256=202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 +# pip sphinx @ https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl#sha256=c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978 +# pip numpydoc @ https://files.pythonhosted.org/packages/62/5e/3a6a3e90f35cea3853c45e5d5fb9b7192ce4384616f932cf7591298ab6e1/numpydoc-1.10.0-py3-none-any.whl#sha256=3149da9874af890bcc2a82ef7aae5484e5aa81cb2778f08e3c307ba6d963721b diff --git a/build_tools/github/pymin_conda_forge_arm_environment.yml b/build_tools/github/pymin_conda_forge_arm_environment.yml index 47fad214303ec..403c972499010 100644 --- a/build_tools/github/pymin_conda_forge_arm_environment.yml +++ b/build_tools/github/pymin_conda_forge_arm_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pytest diff --git a/build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock b/build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock index dda4f7d48cf80..247299b6caf53 100644 --- a/build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock +++ b/build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock @@ -1,167 +1,171 @@ # Generated by conda-lock. # platform: linux-aarch64 -# input_hash: b0db406e405d91cd349c3c7b460345d0d459ac3a897e3458a15f333e2c772865 +# input_hash: c0892bbe9f13a37407e7494f4250ece95a46da85c1b730d8f0ed1a7f3d2510a2 @EXPLICIT https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda#9e115653741810778c9a915a2f8439e7 -https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-he277a41_7.conda#34cef4753287c36441f907d5fdd78d42 +https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_19.conda#c5e8a379c4a2ec2aea4ba22758c001d9 +https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda#502006882cf5461adced436e410046d1 https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2#6168d71addc746e8f2b8d57dfd2edcea -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda#468fd3bb9e1f671d36c2cbc677e56f1d +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda#cf105bce884e4ef8c8ccdca9fe6695e7 https://conda.anaconda.org/conda-forge/linux-aarch64/libopengl-1.7.0-hd24410f_2.conda#cf9d12bfab305e48d095a4c79002c922 +https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda#c3655f82dcea2aa179b291e7099c1fcc https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab -https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-he277a41_7.conda#afa05d91f8d57dd30985827a09c21464 -https://conda.anaconda.org/conda-forge/linux-aarch64/alsa-lib-1.2.14-h86ecc28_0.conda#a696b24c1b473ecc4774bcb5a6ac6337 -https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda#2921ac0b541bf37c69e66bd6d9a43bca +https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda#a21644fc4a83da26452a718dc9468d5f +https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_19.conda#f35b3f52d0a2ec4ffe3c89ba135cdb9a +https://conda.anaconda.org/conda-forge/linux-aarch64/alsa-lib-1.2.15.3-he30d5cf_0.conda#4a98cbc4ade694520227402ff8880630 +https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda#840d8fc0d7b3209be93080bc20e07f2d https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda#e7df0aab10b9cbb73ab2a467ebfaf8c7 -https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlicommon-1.2.0-hd4db518_0.conda#ede431bf5eb917815cd62dc3bf2703a4 +https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlicommon-1.2.0-he30d5cf_1.conda#8ec1d03f3000108899d1799d9964f281 https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda#a9138815598fe6b91a1d6782ca657b0c -https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda#b414e36fbb7ca122030276c75fa9c34a -https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda#0c5ad486dcfb188885e3cf8ba209b97b -https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_7.conda#a5ce1f0a32f02c75c11580c5b2f9258a -https://conda.anaconda.org/conda-forge/linux-aarch64/libgfortran5-15.2.0-h87db57e_7.conda#dd7233e2874ea59e92f7d24d26bb341b +https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.8.0-hfae3067_0.conda#3bacd6171f0a3f8fddd06c3d5ae01955 +https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda#2f364feefb6a7c00423e80dcb12db62a +https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_19.conda#770cf892e5530f43e63cadc673e85653 +https://conda.anaconda.org/conda-forge/linux-aarch64/libgfortran5-15.2.0-h1b7bec0_19.conda#779dbb494de6d3d6477cab52eb34285a https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda#5a86bf847b9b926f3a4f203339748d78 -https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda#5109d7f837a3dfdf5c60f60e311b041f -https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda#7d362346a479256857ab338588190da0 +https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.4.1-he30d5cf_0.conda#a85ba48648f6868016f2741fd9170250 +https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.3-he30d5cf_0.conda#76298a9e6d71ee6e832a8d0d7373b261 https://conda.anaconda.org/conda-forge/linux-aarch64/libnsl-2.0.1-h86ecc28_1.conda#d5d58b2dc3e57073fe22303f5fed4db7 https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda#5044e160c5306968d956c2a0a2a440d6 -https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-h3f4de04_7.conda#6a2f0ee17851251a85fbebafbe707d2d -https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h3e4203c_0.conda#3a68e44fdf2a2811672520fdd62996bd +https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.58-h1abf092_0.conda#f51503ac45a4888bce71af9027a2ecc9 +https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.53.1-h022381a_0.conda#2ec1119217d8f0d086e9a62f3cb0e5ea +https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_19.conda#543fbc8d71f2a0baf04cf88ce96cb8bb +https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda#a0b5de740d01c390bdbb46d7503c9fab https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda#24e92d0942c799db387f5c9d7b81f1af -https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda#08aad7cbe9f5a6b460d0976076b6ae64 -https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda#182afabe009dc78d8b73100255ee6868 -https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.0-h8e36d6e_0.conda#7624c6e01aecba942e9115e0f5a2af9d +https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.6-hf8d1292_0.conda#b2a43456aa56fe80c2477a5094899eff +https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.2-h546c87b_0.conda#3b129669089e4d6a5c6871dbb4669b99 https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda#bb5a90c93e3bac3d5690acf76b4a6386 +https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda#7fc6affb9b01e567d2ef1d05b84aa6ed https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda#c8d8ec3e00cd0fd8a231789b91a7c5b7 https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-he30d5cf_1.conda#1c246e1105000c3660558459e2fd6d43 https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-he30d5cf_1.conda#bff06dcde4a707339d66d45d96ceb2e2 -https://conda.anaconda.org/conda-forge/linux-aarch64/double-conversion-3.3.1-h5ad3122_0.conda#399959d889e1a73fc99f12ce480e77e1 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-xorgproto-2025.1-he30d5cf_0.conda#999d230bcb0329c11d101118ace392d9 +https://conda.anaconda.org/conda-forge/linux-aarch64/xxhash-0.8.3-hd794028_0.conda#f2accdfbd632e2be9a63bed23cb08045 +https://conda.anaconda.org/conda-forge/linux-aarch64/double-conversion-3.4.0-hfae3067_0.conda#9fd794eaf983eabf975ead524540b4be https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda#4aa540e9541cc9d6581ab23ff2043f13 -https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda#60dceb7e876f4d74a9cbd42bbbc6b9cf -https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlidec-1.2.0-hb159aeb_0.conda#05d5e1d976c0b5cb0885a654a368ee8a -https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlienc-1.2.0-ha5a240b_0.conda#09ea194ce9f89f7664a8a6d8baa63d88 +https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.3-hcab7f73_0.conda#546da38c2fa9efacf203e2ad3f987c59 +https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.1.0-h52b7260_0.conda#d13423b06447113a90b5b1366d4da171 +https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlidec-1.2.0-he30d5cf_1.conda#47e5b71b77bb8b47b4ecf9659492977f +https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlienc-1.2.0-he30d5cf_1.conda#6553a5d017fe14859ea8a4e6ea5def8f https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda#2079727b538f6dd16f3fa579d4c3c53f https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda#fb640d776fc92b682a14e001980825b1 -https://conda.anaconda.org/conda-forge/linux-aarch64/libgfortran-15.2.0-he9431aa_7.conda#ffe6ad135bd85bb594a6da1d78768f7c +https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.3-hdae7a39_0.conda#b99ed99e42dafb27889483b3098cace7 +https://conda.anaconda.org/conda-forge/linux-aarch64/libgfortran-15.2.0-he9431aa_19.conda#c7a5b5decf969ead5ecada83654164cf +https://conda.anaconda.org/conda-forge/linux-aarch64/libhiredis-1.3.0-h5ad3122_1.conda#c11818b31f7c054ce220041b2459aacb https://conda.anaconda.org/conda-forge/linux-aarch64/libntlm-1.4-hf897c2e_1002.tar.bz2#835c7c4137821de5c309f4266a51ba89 -https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.51-h1abf092_0.conda#913b1a53ee5f71ce323a15593597be0b -https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.0-h022381a_0.conda#8920ce2226463a3815e2183c8b5008b8 -https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hf1166c9_7.conda#9e5deec886ad32f3c6791b3b75c78681 +https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hdbbeba8_19.conda#c82ed61c3ec470c5ec624580e6ba16e4 https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda#cd14ee5cca2464a425b1dbfc24d90db2 https://conda.anaconda.org/conda-forge/linux-aarch64/libxcrypt-4.4.36-h31becfc_1.conda#b4df5d7d4b63579d081fd3a4cf99740e https://conda.anaconda.org/conda-forge/linux-aarch64/ninja-1.13.2-hdc560ac_0.conda#8b5222a41b5d51fb1a5a2c514e770218 -https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.46-h15761aa_0.conda#5128cb5188b630a58387799ea1366e37 +https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.47-hf841c20_0.conda#1a30c42e32ca0ea216bd0bfe6f842f0b https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda#1587081d537bd4ae77d1c0635d465ba5 -https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8382b9d_2.conda#c0f08fc2737967edde1a272d4bf41ed9 -https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h561c983_103.conda#631db4799bc2bfe4daccf80bb3cbc433 -https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda#f6966cb1f000c230359ae98c29e37d87 +https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda#3d49cad61f829f4f0e0611547a9cda12 +https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.25.0-h4f8a99f_0.conda#0a7a9548726f98d5869fd4c43e110f0f https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda#2d1409c50882819cb1af2de82e2b7208 -https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-ng-2.2.5-h92288e7_0.conda#ffbcf78fd47999748154300e9f2a6f39 -https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-hbcf94c1_2.conda#5be90c5a3e4b43c53e38f50a85e11527 -https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-bin-1.2.0-hf3d421d_0.conda#c43264ebd8b93281d09d3a9ad145f753 -https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda#268203e8b983fddb6412b36f2024e75c -https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda#29c10432a2ca1472b53f299ffb2ffa37 -https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45-default_1234567_3.conda#cafa05c86759c42f9eb1e8398b41a1a3 -https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda#9c2f56b6e011c6d8010ff43b796aab2f -https://conda.anaconda.org/conda-forge/linux-aarch64/libgfortran-ng-15.2.0-he9431aa_7.conda#e810efad68f395154237c4dce83aa482 -https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.2-he84ff74_0.conda#d184d68eaa57125062786e10440ff461 -https://conda.anaconda.org/conda-forge/linux-aarch64/libopenblas-0.3.30-pthreads_h9d3fd7e_4.conda#11d7d57b7bdd01da745bbf2b67020b2e +https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-ng-2.3.3-ha7cb516_1.conda#f731af71c723065d91b4c01bb822641b +https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-bin-1.2.0-he30d5cf_1.conda#b31f6f3a888c3f8f4c5a9dafc2575187 +https://conda.anaconda.org/conda-forge/linux-aarch64/ccache-4.13.6-h185addb_0.conda#529eb8e276a92d5d30c924e94c1b8099 +https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.22.2-hfd895c2_0.conda#d9ca108bd680ea86a963104b6b3e95ca +https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.3-h8af1aa0_0.conda#a229e22d4d8814a07702b0919d8e6701 +https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.4-hf53f6bf_1.conda#4ac4372fc4d7f20630a91314cdac8afd +https://conda.anaconda.org/conda-forge/linux-aarch64/libopenblas-0.3.32-pthreads_h9d3fd7e_0.conda#5d2ce5cf40443d055ec6d33840192265 https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda#8c6fd84f9c87ac00636007c6131e457d +https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.3-h79dcc73_0.conda#68866231cfe8789e780347f2482df96d +https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.11.15-h91f4b29_0_cpython.conda#bb09184ea3313703da05516cd730e8f8 https://conda.anaconda.org/conda-forge/linux-aarch64/qhull-2020.2-h70be974_5.conda#bb138086d938e2b64f5f364945793ebf https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-0.4.1-hca56bd8_2.conda#159ffec8f7fab775669a538f0b29373a https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-keysyms-0.4.1-h5c728e9_0.conda#57ca8564599ddf8b633c4ea6afee6f3a https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-renderutil-0.3.10-h5c728e9_0.conda#7beeda4223c5484ef72d89fb66b7e8c1 https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-wm-0.4.2-h5c728e9_0.conda#f14dcda6894722e421da2b7dcffb0b78 -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda#3df132f0048b9639bc091ef22937c111 -https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-1.2.0-hec30622_0.conda#5005bf1c06def246408b73d65f0d3de9 -https://conda.anaconda.org/conda-forge/linux-aarch64/cyrus-sasl-2.1.28-h6c5dea3_0.conda#b6d06b46e791add99cc39fbbc34530d5 -https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-heda779d_0.conda#9203b74bb1f3fa0d6f308094b3b44c1e -https://conda.anaconda.org/conda-forge/linux-aarch64/lcms2-2.17-hc88f144_0.conda#b87b1abd2542cf65a00ad2e2461a3083 -https://conda.anaconda.org/conda-forge/linux-aarch64/libblas-3.11.0-2_haddc8a3_openblas.conda#1a4b8fba71eb980ac7fb0f2ab86f295d -https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h5cdc715_5.conda#ac0333d338076ef19170938bbaf97582 -https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda#1e61fb236ccd3d6ccaf9e91cb2d7e12d -https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda#1d4269e233636148696a67e2d30dad2a -https://conda.anaconda.org/conda-forge/linux-aarch64/libhiredis-1.0.2-h05efe27_0.tar.bz2#a87f068744fd20334cd41489eb163bee -https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.1-h8591a01_0.conda#e7177c6fbbf815da7b215b4cc3e70208 -https://conda.anaconda.org/conda-forge/linux-aarch64/openblas-0.3.30-pthreads_h3a8cbd8_4.conda#e3f245ed352bd66d181b73a78d886038 -https://conda.anaconda.org/conda-forge/linux-aarch64/openjpeg-2.5.4-h5da879a_0.conda#cea962410e327262346d48d01f05936c -https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.11.14-h91f4b29_2_cpython.conda#622ae39bb186be3eeeaa564a9c7e1eec -https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-image-0.4.0-h5c728e9_2.conda#b82e5c78dbbfa931980e8bfe83bce913 -https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda#9524f30d9dea7dd5d6ead43a8823b6c2 -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda#bd1e86dd8aa3afd78a4bfdb4ef918165 -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda#e8b4056544341daf1d415eaeae7a040c -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda#ae2c2dd0e2d38d249887727db2af960e -https://conda.anaconda.org/conda-forge/linux-aarch64/ccache-4.11.3-h4889ad1_0.conda#e0b9e519da2bf0fb8c48381daf87a194 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.13-h63a1b12_0.conda#22dd10425ef181e80e130db50675d615 +https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-1.2.0-hd651790_1.conda#5c933384d588a06cd8dac78ca2864aab https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 -https://conda.anaconda.org/conda-forge/linux-aarch64/cython-3.2.1-py311hdc11669_0.conda#4e9072696f84a95df4aa562e2732d332 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-aarch64/cyrus-sasl-2.1.28-h6598af7_1.conda#f4fbf4001970e3e58984281a12c99969 +https://conda.anaconda.org/conda-forge/linux-aarch64/cython-3.2.4-py311hdc11669_0.conda#931a90956062cc7219c6bce6c6ccfe7f +https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-h70963c4_1.conda#a4b6b82427d15f0489cef0df2d82f926 https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda#0c8f36ebd3678eed1685f0fc93fc2175 +https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.17.1-hba86a56_0.conda#0fed1ff55f4938a65907f3ecf62609db +https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.3-h8af1aa0_0.conda#f11edf8adf0d119148b97f745548390d https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-aarch64/kiwisolver-1.4.9-py311h229e7f7_2.conda#18358d47ebdc1f936003b7d407c9e16f -https://conda.anaconda.org/conda-forge/linux-aarch64/libcblas-3.11.0-2_hd72aa62_openblas.conda#a074a14e43abb50d4a38fff28a791259 -https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda#0d00176464ebb25af83d40736a2cd3bb -https://conda.anaconda.org/conda-forge/linux-aarch64/liblapack-3.11.0-2_h88aeb00_openblas.conda#c73b83da5563196bdfd021579c45d54c -https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.1-h788dabe_0.conda#a0e7779b7625b88e37df9bd73f0638dc -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 +https://conda.anaconda.org/conda-forge/linux-aarch64/kiwisolver-1.5.0-py311h229e7f7_0.conda#aeade47300d466d9d6ba01daaca31a86 +https://conda.anaconda.org/conda-forge/linux-aarch64/lcms2-2.19.1-h9d5b58d_0.conda#b1f8bee3c53a6d2c103fb4a1ae44f5c4 +https://conda.anaconda.org/conda-forge/linux-aarch64/libblas-3.11.0-6_haddc8a3_openblas.conda#652bb20bb4618cacd11e17ae070f47ce +https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h4f2b762_6.conda#67828c963b17db7dc989fe5d509ef04a +https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda#1d4269e233636148696a67e2d30dad2a +https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.3-h869d058_0.conda#2cffef27cb2eb9ed1e315a1e269d4335 +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/linux-aarch64/openldap-2.6.10-h30c48ee_0.conda#48f31a61be512ec1929f4b4a9cedf4bd -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-aarch64/pillow-12.0.0-py311h9a6517a_0.conda#2dcc43f9f47cb65f1ebcbdc96183f6d2 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/linux-aarch64/openblas-0.3.32-pthreads_h3a8cbd8_0.conda#62e1383bcaf8f5244d2598bbda509e3b +https://conda.anaconda.org/conda-forge/linux-aarch64/openjpeg-2.5.4-h5da879a_0.conda#cea962410e327262346d48d01f05936c +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-aarch64/tornado-6.5.2-py311hb9158a3_2.conda#6d68a78b162d9823e5abe63001c6df36 +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-aarch64/tornado-6.5.5-py311hb9158a3_0.conda#8776b78b9f2532ef4f0e2acc8f03f755 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/linux-aarch64/unicodedata2-17.0.0-py311h19352d5_1.conda#4a55814831e0ec9be84ccef6aed798c1 -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 +https://conda.anaconda.org/conda-forge/linux-aarch64/unicodedata2-17.0.1-py311h19352d5_0.conda#22df73a2e312d88d56f6986e0a287edb +https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-image-0.4.0-h5c728e9_2.conda#b82e5c78dbbfa931980e8bfe83bce913 +https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.47-he30d5cf_0.conda#4ac707a4279972357712af099cd1ae50 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.7-he30d5cf_0.conda#fb42b683034619915863d68dd9df03a3 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda#e8b4056544341daf1d415eaeae7a040c +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda#ae2c2dd0e2d38d249887727db2af960e +https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h0b6afd8_1.conda#043c13ed3a18396994be9b4fab6572ad +https://conda.anaconda.org/conda-forge/linux-aarch64/coverage-7.14.0-py311h2dad8b0_0.conda#600e7eeee7b96c872a8097d76822b532 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/linux-aarch64/fonttools-4.62.1-py311h164a683_0.conda#247d8664e397d43269288370ffe81ac3 +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/linux-aarch64/libcblas-3.11.0-6_hd72aa62_openblas.conda#939e300b110db241a96a1bed438c315b +https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda#0d00176464ebb25af83d40736a2cd3bb +https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-devel-1.7.0-hd24410f_2.conda#1f9ddbb175a63401662d1c6222cef6ff +https://conda.anaconda.org/conda-forge/linux-aarch64/liblapack-3.11.0-6_h88aeb00_openblas.conda#e23a27b52fb320687239e2c5ae4d7540 +https://conda.anaconda.org/conda-forge/linux-aarch64/libllvm22-22.1.5-hfd2ba90_1.conda#b033ae799252b9b2fa63a9b6502aba75 +https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.1-h3c6a4c8_0.conda#22c1ce28d481e490f3635c1b6a2bb23f +https://conda.anaconda.org/conda-forge/linux-aarch64/libxslt-1.1.43-h6700d25_1.conda#0f31501ccd51a40f0a91381080ae7368 +https://conda.anaconda.org/conda-forge/linux-aarch64/openldap-2.6.13-h2fb54aa_0.conda#67eea19865a3463f75ca0d3a1d096350 +https://conda.anaconda.org/conda-forge/linux-aarch64/pillow-12.2.0-py311h8e17b9e_0.conda#c2e33eff9ea2d1501b83d938d9d4334d +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c https://conda.anaconda.org/conda-forge/linux-aarch64/xcb-util-cursor-0.1.6-he30d5cf_0.conda#8b70063c86f7f9a0b045e78d2d9971f7 -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.6-h86ecc28_2.conda#86051eee0766c3542be24844a9c3cf36 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.7-he30d5cf_0.conda#9c639c1abdbfe6759c5beb2c1db4bc13 https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda#f2054759c2203d12d0007005e1f1296d https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda#d5773c4e4d64428d7ddaa01f6f845dc7 https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxi-1.8.2-h57736b2_0.conda#eeee3bdb31c6acde2b81ad1b8c287087 -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda#dd3e74283a082381aa3860312e3c721e -https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda#d745faa2d7c15092652e40a22bb261ed -https://conda.anaconda.org/conda-forge/linux-aarch64/coverage-7.12.0-py311h2dad8b0_0.conda#ddb3e5a915ecebd167f576268083c50b -https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda#112b71b6af28b47c624bcbeefeea685b -https://conda.anaconda.org/conda-forge/linux-aarch64/fonttools-4.60.1-py311h164a683_0.conda#e15201d7a1ed08ce5b85beca0d4a0131 -https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.2-pyhd8ed1ab_0.conda#4e717929cfa0d49cef92d911e31d0e90 -https://conda.anaconda.org/conda-forge/linux-aarch64/liblapacke-3.11.0-2_hb558247_openblas.conda#498aa2a8940c8c26c141dd4ce99e7843 -https://conda.anaconda.org/conda-forge/linux-aarch64/libllvm21-21.1.6-hfd2ba90_0.conda#54e87a913eeaa2b27f2e7b491860f612 -https://conda.anaconda.org/conda-forge/linux-aarch64/libpq-18.1-haf03d9f_1.conda#11a55df5dc2234fcd4135e73fb5737d7 -https://conda.anaconda.org/conda-forge/linux-aarch64/libvulkan-loader-1.4.328.1-h8b8848b_0.conda#e5a3ff3a266b68398bd28ed1d4363e65 -https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.0-h3c6a4c8_0.conda#a7c78be36bf59b4ba44ad2f2f8b92b37 -https://conda.anaconda.org/conda-forge/linux-aarch64/libxslt-1.1.43-h6700d25_1.conda#0f31501ccd51a40f0a91381080ae7368 -https://conda.anaconda.org/conda-forge/linux-aarch64/numpy-2.3.5-py311h669026d_0.conda#5ca3db64e7fe0c00685b97104def7953 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.5-he30d5cf_0.conda#1f64c613f0b8d67e9fb0e165d898fb6b +https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.7-he30d5cf_0.conda#b15ca02584678f38df6e114c32f93959 +https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-14.2.0-h1134a53_0.conda#1775defbef30aa990498e753a948cb18 +https://conda.anaconda.org/conda-forge/linux-aarch64/libclang13-22.1.5-default_h94a09a5_0.conda#40c3f0e6e00f8e296f20237902034865 +https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-devel-1.7.0-hd24410f_2.conda#5d8323dff6a93596fb6f985cf6e8521a +https://conda.anaconda.org/conda-forge/linux-aarch64/liblapacke-3.11.0-6_hb558247_openblas.conda#12da32239ec4cc63d4f94d83b8425947 +https://conda.anaconda.org/conda-forge/linux-aarch64/libpq-18.3-h7d4fc67_0.conda#7eb18b198b1d35da9352062c69c4ee64 +https://conda.anaconda.org/conda-forge/linux-aarch64/libvulkan-loader-1.4.341.0-h8b8848b_0.conda#06bb91a87fb97ea09398d2e121e00c39 +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/linux-aarch64/numpy-2.4.3-py311h669026d_0.conda#23c6d37dec83159283cfeee4fceebf84 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh8b19718_0.conda#35870d32aed92041d31cbb15e822dca3 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxtst-1.2.5-h57736b2_3.conda#c05698071b5c8e0da82a282085845860 -https://conda.anaconda.org/conda-forge/linux-aarch64/blas-devel-3.11.0-2_h9678261_openblas.conda#c6f09be2e4ba1626ed277430111cb494 -https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda#cd55953a67ec727db5dc32b167201aa6 -https://conda.anaconda.org/conda-forge/linux-aarch64/contourpy-1.3.3-py311hfca10b7_3.conda#47c305536dbf44cd3e629b6851605a50 -https://conda.anaconda.org/conda-forge/linux-aarch64/libclang-cpp21.1-21.1.6-default_he95a3c9_0.conda#6457ea18e8c2a534017aa7c7c88768eb -https://conda.anaconda.org/conda-forge/linux-aarch64/libclang13-21.1.6-default_h94a09a5_0.conda#9cf3f6e2f743eac1cd85b4e9e55ba8a5 -https://conda.anaconda.org/conda-forge/noarch/meson-python-0.18.0-pyh70fd9c4_0.conda#576c04b9d9f8e45285fb4d9452c26133 -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 -https://conda.anaconda.org/conda-forge/linux-aarch64/scipy-1.16.3-py311h33b5a33_1.conda#3d97f428e5e2f3d0f07f579d97e9fe70 -https://conda.anaconda.org/conda-forge/linux-aarch64/blas-2.302-openblas.conda#642f10de8032f498538c64494fcc3db8 -https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda#1437bf9690976948f90175a65407b65f -https://conda.anaconda.org/conda-forge/linux-aarch64/matplotlib-base-3.10.8-py311hb9c6b48_0.conda#4c9c9538c5a0a581b2dac04e2ea8c305 +https://conda.anaconda.org/conda-forge/linux-aarch64/blas-devel-3.11.0-6_h9678261_openblas.conda#64fe76410feeef76a105b2343edc4af7 +https://conda.anaconda.org/conda-forge/linux-aarch64/contourpy-1.3.3-py311h04741b4_4.conda#1eeea54b0c520a475db39f8c711de661 +https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-devel-1.7.0-hd24410f_2.conda#cd8877e3833ba1bfac2fbaa5ae72c226 https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 -https://conda.anaconda.org/conda-forge/linux-aarch64/qt6-main-6.9.3-h224e339_1.conda#ffcc8b87dd0a6315f231e690a7d7b6f2 -https://conda.anaconda.org/conda-forge/linux-aarch64/pyside6-6.9.3-py311hf1caecd_1.conda#73f404b29ee67faa8db72314a73ac714 -https://conda.anaconda.org/conda-forge/linux-aarch64/matplotlib-3.10.8-py311hfecb2dc_0.conda#3920b856b59a909812f1913b96adaad8 +https://conda.anaconda.org/conda-forge/linux-aarch64/scipy-1.17.1-py311h399493a_0.conda#be28b3d39c6942f89652b505e85ae3d6 +https://conda.anaconda.org/conda-forge/linux-aarch64/blas-2.306-openblas.conda#cc7cac46a53a5c76f20439cb298d10a2 +https://conda.anaconda.org/conda-forge/linux-aarch64/matplotlib-base-3.10.9-py311hb9c6b48_0.conda#a89049617b540aa442cdc8db9a2ae704 +https://conda.anaconda.org/conda-forge/linux-aarch64/qt6-main-6.11.0-pl5321h598db47_4.conda#e93276397240a46199cc7ddee1cfeb2d +https://conda.anaconda.org/conda-forge/linux-aarch64/pyside6-6.11.0-py311hb02cd75_2.conda#0f363faaee54d4d8164b762f1afc336f +https://conda.anaconda.org/conda-forge/linux-aarch64/matplotlib-3.10.9-py311hfecb2dc_0.conda#7b312514c56cac422f44b2b92c238ee4 diff --git a/build_tools/azure/pymin_conda_forge_openblas_environment.yml b/build_tools/github/pymin_conda_forge_openblas_environment.yml similarity index 97% rename from build_tools/azure/pymin_conda_forge_openblas_environment.yml rename to build_tools/github/pymin_conda_forge_openblas_environment.yml index c0b5590793bd8..8cba11ebb7120 100644 --- a/build_tools/azure/pymin_conda_forge_openblas_environment.yml +++ b/build_tools/github/pymin_conda_forge_openblas_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - matplotlib - pytest diff --git a/build_tools/azure/pymin_conda_forge_openblas_min_dependencies_environment.yml b/build_tools/github/pymin_conda_forge_openblas_min_dependencies_environment.yml similarity index 92% rename from build_tools/azure/pymin_conda_forge_openblas_min_dependencies_environment.yml rename to build_tools/github/pymin_conda_forge_openblas_min_dependencies_environment.yml index d8fa0b1a3842e..c74c283048082 100644 --- a/build_tools/azure/pymin_conda_forge_openblas_min_dependencies_environment.yml +++ b/build_tools/github/pymin_conda_forge_openblas_min_dependencies_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy=1.10.0 # min - cython=3.1.2 # min - joblib=1.3.0 # min + - narwhals=2.0.1 # min - threadpoolctl=3.2.0 # min - matplotlib=3.6.1 # min - pyamg=5.0.0 # min @@ -23,7 +24,7 @@ dependencies: - coverage - ccache - polars=0.20.30 # min - - pyarrow=12.0.0 # min + - pyarrow=13.0.0 # min - pip - pip: - pandas==1.5.0 # min diff --git a/build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock b/build_tools/github/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock similarity index 50% rename from build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock rename to build_tools/github/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock index 9f881ff559fc7..c157438e9ed8c 100644 --- a/build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock +++ b/build_tools/github/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock @@ -1,72 +1,81 @@ # Generated by conda-lock. # platform: linux-64 -# input_hash: 85d62da6957fb2aa8f14c534a934297a9946f5daea75996cc5f89c20f0a0038a +# input_hash: c0352e4c16c581d3f0312207c1fb8536322428093cf704d1ebdf12359d15f046 @EXPLICIT https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda#4222072737ccff51314b5ece9c7d6f5a -https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda#f0991f0f84902f6b6009b4d2350a83aa +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-bootstrap_ha15bf96_3.conda#3036ca5b895b7f5146c5a25486234a68 https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda#434ca7e50e40f4918ab701e3facd59a0 -https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-21.1.6-h4922eb0_0.conda#7a0b9ce502e0ed62195e02891dfcd704 -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-6_kmp_llvm.conda#197811678264cb9da0d2ea0726a70661 +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/llvm-openmp-22.1.5-h4922eb0_1.conda#f66101d2eb5de2924c10a63bbfa2926e +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-7_kmp_llvm.conda#887b70e1d607fba7957aa02f9ee0d939 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda#c151d5eb730e9b7480e6d48c0fc44048 https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_2.conda#7df50d44d4a14d6c31a2c54f2cd92157 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda#c0374badb3a5d4b1372db28d19462c53 -https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.14-hb9d3cd8_0.conda#76df83c2a9035c54df5d04ff81bcc02d -https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda#791365c5f65975051e4e017b5da3abf5 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda#51a19bba1b8ebfb60df25cde030b7ebc -https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.5-hb9d3cd8_0.conda#f7f0d6cc2dc986d42ac2689ec88192be +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.3-hb03c661_0.conda#dcdc58c15961dbf17a0621312b01f5cb +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda#f9f81ea472684d75b9dd8d0b328cf655 https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.1.0-hb03c661_4.conda#1d29d2e33fe59954af82ef54a8af3fe1 +https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-hd0affe5_1.conda#499cd8e2d4358986dbe3b30e8fe1bf6a https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 -https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda#8b09ae86839581147ef2e5c5e229d164 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda#35f29eec58405aaf55e01cb470d8c26a -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda#280ea6eee9e2ddefde25ff799c4f0363 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-hcd61629_7.conda#f116940d825ffc9104400f0d7f1a4551 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 -https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda#8397539e3a0bbd1695584fb4f927485a -https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda#1a580f7796c7bf6393fddb8bbbde58dc +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda#db63358239cbe1ff86242406d440e44a https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 https://conda.anaconda.org/conda-forge/linux-64/libntlm-1.8-hb9d3cd8_0.conda#7c7927b404672409d9917d49bff5f2d6 -https://conda.anaconda.org/conda-forge/linux-64/libnuma-2.0.18-hb9d3cd8_3.conda#20ab6b90150325f1af7ca96bffafde63 https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda#68e52064ed3897463c0e958ab5c8f91b -https://conda.anaconda.org/conda-forge/linux-64/libopus-1.5.2-hd0c01bc_0.conda#b64523fb87ac6f87f0790f324ad43046 +https://conda.anaconda.org/conda-forge/linux-64/libopus-1.6.1-h280c20c_0.conda#2446ac1fe030c2aa6141386c1f5a6aed https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda#70e3400cbbfa03e96dcde7fc13e38c7b -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda#5b767048b1b3ee9a954b06f4084f93dc +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 https://conda.anaconda.org/conda-forge/linux-64/libutf8proc-2.8.0-hf23e847_1.conda#b1aa0faa95017bca11369bd080487ec4 -https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda#80c07c68d2f6870250959dcc95b209d1 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda#edb0dca6bc32e4f4789199455a1dbeb8 -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7 -https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda#9ee58d5c534af06558933af3c845a780 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda#fb901ff28063514abb6046c9ec2c4a45 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda#9a809ce9f65460195777f2f2116bae02 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.8.23-hd590300_0.conda#cc4f06f7eedb1523f3b83fd0fb3942ff -https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda#a59c05d22bdcbb4e984bf0c021a2a02f +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.9.23-h4ab18f5_0.conda#94d61ae2b2b701008a9d52ce6bbead27 https://conda.anaconda.org/conda-forge/linux-64/gflags-2.2.2-h5888daf_1005.conda#d411fc29e338efb48c5fd4576d71d881 https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2#a8832b479f93521a9e7b5b743803be51 -https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda#9344155d33912347b37f0ae6c410a835 -https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda#3b0d184bc9404516d418d4509e418bdc -https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.0.9-h166bdaf_9.conda#61641e239f96eae2b8492dc7e755828c -https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda#09c264d40c67b82b49a3f3b89037bd2e +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.1.0-hb03c661_4.conda#5cb5a1c9a94a78f5b23684bcb845338d +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.1.0-hb03c661_4.conda#2e55011fa483edb8bfe3fd92e860cd79 https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda#9314bc5a1fe7d1044dc9dfd3ef400535 https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 https://conda.anaconda.org/conda-forge/linux-64/libevent-2.1.12-hf998b51_1.conda#a1cfcc585f0c42bf8d5546bb1dfb668d -https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda#2f4de899028319b27eb7a4023be5dfd2 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_7.conda#8621a450add4e231f676646880703f49 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.51-h421ea60_0.conda#d8b81203d08435eb999baa249427884e +https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda#47595b9d53054907a00d95e4d47af1d6 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-h4852527_7.conda#f627678cf829bd70bccf141a19c3ad3e +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda#8ee3cb7f64be0e8c4787f3a4dbe024e6 +https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda#2c2270f93d6f9073cbf72d821dfc7d72 https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h54a6638_2.conda#b4ecbefe517ed0157c37f8182768271c https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc @@ -75,168 +84,158 @@ https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b5 https://conda.anaconda.org/conda-forge/linux-64/nspr-4.38-h29cc59b_0.conda#e235d5566c9cc8970eb2798dd4ecf62f https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1 https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda#283b96675859b20a825f8fa30f311446 -https://conda.anaconda.org/conda-forge/linux-64/s2n-1.3.46-h06160fa_0.conda#413d96a0b655c8f8aacc36473a2dbb04 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda#86bc20552bf46075e3d92b67f089172d +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.4.17-he19d79f_0.conda#e25ac9bf10f8e6aa67727b1cdbe762ef +https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda#1c74ff8c35dcadf952a16f752ca5aa49 -https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda#c9f075ab2f33b3bbee9e62d4ad0a6cd8 -https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.2.5-hde8ca8f_0.conda#1920c3502e7f6688d650ab81cd3775fd -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda#6432cb5d4ac0046c3ac0a8a0f95842f9 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.6.0-h93469e0_0.conda#580a52a05f5be28ce00764149017c6d4 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.2.17-h862ab75_1.conda#0013fcee7acb3cfc801c5929824feb3c -https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.1.11-h862ab75_1.conda#6fbc9bd49434eb36d3a59c5020f4af95 -https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.1.16-h862ab75_1.conda#f883d61afbc95c50f7b3f62546da4235 -https://conda.anaconda.org/conda-forge/linux-64/glog-0.6.0-h6f12383_0.tar.bz2#b31f3565cb84435407594e548a2fb7b2 -https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda#8b189310083baabfb622af68fd9d3ae3 -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda#3f43953b7d3fb3aaa1d0d0723d91e368 -https://conda.anaconda.org/conda-forge/linux-64/libabseil-20230125.3-cxx17_h59595ed_0.conda#d1db1b8be7c3a8983dcbbbfe4f0765de -https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda#fd9cf4a11d07f0ef3e44fc061611b1ed -https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.0.9-h166bdaf_9.conda#081aa22f4581c08e4372b0b6c2f8478e -https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.0.9-h166bdaf_9.conda#1f0a03af852a9659ed2bf08f2f1704fd +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.7.1-h87b94db_1.conda#2d76d2cfdcfe2d5c3883d33d8be919e7 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.2.18-he027950_7.conda#11e5cb0b426772974f6416545baee0ce +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.1.16-he027950_3.conda#adbf0c44ca88a3cded175cd809a106b6 +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.1.18-he027950_7.conda#95611b325a9728ed68b8f7eef2dd3feb +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.1.0-hb03c661_4.conda#ca4ed8015764937c81b830f7f5b68543 +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/glog-0.7.1-hbabe93e_0.conda#ff862eebdfeb2fd048ae9dc92510baca +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libabseil-20240116.2-cxx17_he02047a_1.conda#c48fc56ec03229f294176923c3265c05 https://conda.anaconda.org/conda-forge/linux-64/libcrc32c-1.1.2-h9c3ff4c_0.tar.bz2#c965a5aa0d5c1c37ffc62dff36e28400 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda#8e7251989bca326a28f4a5ffbd74557a -https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda#3f7a43b3160ec0345c9535a9f0d7908e -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_7.conda#beeb74a6fe5ff118451cf0581bfe2642 -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.2-h6548e54_1.conda#f01292fb36b6d00d5c51e5d46b513bcf -https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda#b499ce4b026493a13774bcf0f4c33849 -https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-3.21.12-hfc55251_2.conda#e3a7d4ba09b8dc939b98fef55f539220 -https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.10-hd0affe5_2.conda#b04e0a2163a72588a40cde1afd6f2d18 -https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.18.1-h8fd135c_2.conda#bbf65f7688512872f063810623b755dc +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-15.2.0-h69a702a_19.conda#35d07243abf828674d273aecd1dd537e +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.88.1-h0d30a3d_1.conda#6016ea5ee9e986bc683879408cc87529 +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda#067590f061c9f6ea7e61e3b2112ed6b3 +https://conda.anaconda.org/conda-forge/linux-64/libthrift-0.19.0-hb90f79a_1.conda#8cdb7d41faa0260875ba92414c487e2d https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.9.4-hcb278e6_0.conda#318b08df404f9c9be5712aaa5a6f0bb0 -https://conda.anaconda.org/conda-forge/linux-64/rdma-core-28.9-h59595ed_1.conda#aeffb7c06b5f65e55e6c637408dc4100 -https://conda.anaconda.org/conda-forge/linux-64/re2-2023.03.02-h8c504da_0.conda#206f8fa808748f6e90599c3368a1114e -https://conda.anaconda.org/conda-forge/linux-64/snappy-1.1.10-hdb0a2a9_1.conda#78b8b85bdf1f42b8a2b3cb577d8742d1 +https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda#567fbeed956c200c1db5782a424e58ee +https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-hd63d673_0_cpython.conda#a5ebcefec0c12a333bcd6d7bf3bddc1f +https://conda.anaconda.org/conda-forge/linux-64/rdma-core-62.0-h192683f_0.conda#46a9d3342a5945cf6067f9277989900c https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda#fdc27cb255a7a2cc73b7919a968b48f0 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-keysyms-0.4.1-hb711507_0.conda#ad748ccca349aec3e91743e08b5e2b50 https://conda.anaconda.org/conda-forge/linux-64/xcb-util-renderutil-0.3.10-hb711507_0.conda#0e0cbe0564d03a99afd5fd7b362feecd https://conda.anaconda.org/conda-forge/linux-64/xcb-util-wm-0.4.2-hb711507_0.conda#608e0ef8256b81d04456e8d211eee3e8 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda#db038ce880f100acc74dba10302b5630 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.13.27-h3870b5a_0.conda#b868db6b48436bdbda71aa8576f4a44d -https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.0.9-h166bdaf_9.conda#d47dee1856d9cb955b8076eeff304a5b -https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hd9c7081_0.conda#cae723309a49399d2949362f4ab5c9e4 -https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda#679616eb5ad4e521c83da4650860aba7 -https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda#c42356557d7f2e37676e121515417e3b -https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.2-hf516916_1.conda#495c262933b7c5b8c09413d44fa5974b -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.17-h717163a_0.conda#000e85703f0fd9594c81710dd5066471 -https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda#d4a250da4737ee127fb1fa6452a9002e -https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_0.conda#01e149d4a53185622dc2e788281961f2 -https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda#f4084e4e6577797150f9b04a4560ceb0 -https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c -https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.54.3-hb20ce57_0.conda#7af7c59ab24db007dfd82e0a3a343f66 -https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.0.2-h2cc385e_0.tar.bz2#b34907d3a81a3cd8095ee83d174c074a -https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.25-pthreads_h413a1c8_0.conda#d172b34a443b95f86089e8229ddc9a17 -https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.0-hee844dc_0.conda#729a572a3ebb8c43933b30edcc628ceb -https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda#e7733bc6785ec009e47a224a71917e84 -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd -https://conda.anaconda.org/conda-forge/linux-64/orc-1.8.4-h2f23424_0.conda#4bb92585a250e67d49b46c073d29f9dd -https://conda.anaconda.org/conda-forge/linux-64/ucx-1.14.1-h64cca9d_5.conda#39aa3b356d10d7e5add0c540945a0944 -https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 -https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda#71ae752a748962161b4740eaff510258 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda#febbab7d15033c913d53c7a2c102309d -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e -https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.3.1-h1e03375_0.conda#3082be841420d6288bc1268a9be45b75 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.7.10-h9ab9c9b_2.conda#cf49873da2e59f876a2ad4794b05801b -https://conda.anaconda.org/conda-forge/linux-64/brotli-1.0.9-h166bdaf_9.conda#4601544b4982ba1861fa9b9c607b2c06 -https://conda.anaconda.org/conda-forge/linux-64/ccache-4.11.3-h80c52d3_0.conda#eb517c6a2b960c3ccb6f1db1005f063a -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda#4afc585cd97ba8a23809406cd8a9eda8 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.9.0-20_linux64_openblas.conda#2b7bb4f7562c8cf334fc2e20c2d28abc -https://conda.anaconda.org/conda-forge/linux-64/libflac-1.4.3-h59595ed_0.conda#ee48bf17cc83a00f59ca1494d5646869 -https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a -https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.12.0-hac9eb74_1.conda#0dee716254497604762957076ac76540 -https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda#e512be7dc1f84966d50959e900ca121f -https://conda.anaconda.org/conda-forge/linux-64/nss-3.118-h445c969_0.conda#567fbeed956c200c1db5782a424e58ee -https://conda.anaconda.org/conda-forge/linux-64/openblas-0.3.25-pthreads_h7a3da1a_0.conda#87661673941b5e702275fdf0fc095ad0 -https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.10-he970967_0.conda#2e5bf4f1da39c0b32778561c3c4e5878 -https://conda.anaconda.org/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda#c4202a55b4486314fbb8c11bc43a29a0 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda#d3c295b50f092ab525ffe3c2aa4b7413 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 -https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda#5efa5fa6243a622445fdfd72aee15efa -https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.7.0-h435f46f_0.conda#c7726f96aab024855ede05e0ca6e94a0 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.8.13-hd4f18eb_5.conda#860fb8c0efec64a4a678eb2ea066ff65 -https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.0.9-py311ha362b79_9.conda#ced5340f5dc6cff43a80deac8d0e398f -https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda#96a02a5c1a65470a7e4eedb644c872fd -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda#a22d1fd9bf98827e280a02875d9a007a +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.14.10-h826b7d6_1.conda#6961646dded770513a781de4cd5c1fe1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.1.0-hb03c661_4.conda#eaf3fbd2aa97c212336de38a51fe404e +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py311h1ddb823_4.conda#7138a06a7b0d11a23cfae323e6010a08 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda#929471569c93acefb30282a22060dcd5 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhd8ed1ab_1.conda#44600c4667a319d67dbe0681fc0bc833 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/linux-64/cyrus-sasl-2.1.28-hac629b4_1.conda#af491aae930edc096b58466c51c4126c https://conda.anaconda.org/conda-forge/linux-64/cython-3.1.2-py311ha3e34f5_2.conda#f56da6e1e1f310f27cca558e58882f40 +https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda#ce96f2f470d39bd96ce03945af92e280 https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda#8f5b0b297b59e1ac160ad4beec99dbee +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.17.1-h27c8c51_0.conda#867127763fbe935bab59815b6e0b7b5c +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.88.1-hcfc306f_1.conda#ff216b19c24f3a46e9d17ebcf2f96390 https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac -https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda#53abe63df7e10a6ba605dc5f9f961d36 +https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda#fb7130c190f9b4ec91219840a05ba3ac https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.4.9-py311h724c32c_2.conda#4089f739463c798e10d8644bc34e24de -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-20_linux64_openblas.conda#36d486d72ab64ffea932329a1d3729a3 -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-20_linux64_openblas.conda#6fabc51f5e647d09cc010c40061557e0 -https://conda.anaconda.org/conda-forge/linux-64/libllvm21-21.1.6-hf7376ad_0.conda#8aa154f30e0bc616cbde9794710e0be2 -https://conda.anaconda.org/conda-forge/linux-64/libpq-18.1-h5c52fec_1.conda#638350cf5da41f3651958876a2104992 -https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc60ed4a_1.conda#ef1910918dd895516a769ed36b5b3a4e -https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.0-hca5e8e5_0.conda#aa65b4add9574bb1d23c76560c5efd4c -https://conda.anaconda.org/conda-forge/noarch/meson-1.9.1-pyhcf101f3_0.conda#ef2b132f3e216b5bf6c2f3c36cfd4c89 +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py311h724c32c_0.conda#3d82751e8d682068b58f049edc924ce4 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-h7a8fb5f_6.conda#49c553b47ff679a6a1e9fc80b9c5a2d4 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda#c8013e438185f33b13814c5c488acd5c +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.25-pthreads_h413a1c8_0.conda#d172b34a443b95f86089e8229ddc9a17 +https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-4.25.3-hd5b35b9_1.conda#06def97690ef90781a91b786cb48a0a9 +https://conda.anaconda.org/conda-forge/linux-64/libre2-11-2023.09.01-h5a48ba9_2.conda#41c69fba59d495e8cf5ffda48a607e35 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 -https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda#58335b26c38bf4a20f399384c33cbcf9 -https://conda.anaconda.org/conda-forge/linux-64/pillow-12.0.0-py311h07c5bb8_0.conda#51f505a537b2d216a1b36b823df80995 -https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.0-pyhcf101f3_0.conda#5c7a868f8241e64e1cf5fdf4962f23e2 -https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhd8ed1ab_0.conda#7da7ccd349dbf6487a7778579d2bb971 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.0.1-pyhe01879c_0.conda#5f0dea40791cecf0f82882b9eea7f7c1 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.6-pyhcf101f3_0.conda#89c0b6d1793601a2a3a3f7d2d3d8b937 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e https://conda.anaconda.org/conda-forge/noarch/ply-3.11-pyhd8ed1ab_3.conda#fd5062942bfa1b0bd5e0d2a4397b099e https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef -https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066 -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.2.5-pyhcf101f3_0.conda#6c8979be6d7a17692793114fa26916e8 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac -https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda#4de79c071274a53dcaf2a8c749d1499e +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.2.0-pyha21a80b_0.conda#978d03388b62173b8e6f79162cf52b86 -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_2.conda#00d80af3a7bf27729484e786a68aafff -https://conda.anaconda.org/conda-forge/noarch/tomli-2.3.0-pyhcf101f3_0.conda#d2732eb636c264dc9aa4cbee404b1a53 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.2-py311h49ec1c0_2.conda#8d7a63fc9653ed0bdc253a51d9a5c371 +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.5-py311h49ec1c0_0.conda#73b44a114241e564deb5846e7394bf19 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d -https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.0-py311h49ec1c0_1.conda#5e6d4026784e83c0a51c86ec428e8cc8 -https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda#75cb7132eb58d97896e173ef12ac9986 -https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.3.12-he2a37c1_2.conda#44876aca9aa47da1e5e2d3f9906169ba -https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda#09262e66b19567aff4f592fb53b28760 +https://conda.anaconda.org/conda-forge/linux-64/ucx-1.16.0-h209287a_5.conda#1bd6b5d51b155a3c03b6aa2702d37f3f +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py311h49ec1c0_0.conda#2889f0c0b6a6d7a37bd64ec60f4cc210 +https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda#a0901183f08b6c7107aab109733a3c91 +https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.47-hb03c661_0.conda#b56e0c8432b56decafae7e78c5f29ba5 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda#96d57aba173e878a2089d5638016dc5e +https://conda.anaconda.org/conda-forge/linux-64/aws-c-event-stream-0.4.2-h7671281_15.conda#3b45b0da170f515de8be68155e14955a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.8.2-he17ee6b_6.conda#4e3d1bb2ade85619ac2163e695c2cc1b +https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-he90730b_1.conda#bb6c4808bfa69d6f7f6b07e5846ced37 https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py311h03d9500_1.conda#3912e4373de46adafd8f1e97e4bd166b -https://conda.anaconda.org/conda-forge/linux-64/coverage-7.12.0-py311h3778330_0.conda#4ef5919a315f5c2834fc8da49044156d +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.0-py311h3778330_0.conda#f566275adc487ec7b8dfaf9257967fcf https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab -https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.60.1-py311h3778330_0.conda#91f834f85ac92978cfc3c1c178573e85 -https://conda.anaconda.org/conda-forge/linux-64/glib-2.86.2-h5192d8d_1.conda#7071a9745767777b4be235f8c164ea75 +https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.62.1-py311h3778330_0.conda#dd214022a8f01bc2ebed383dfdc8deea +https://conda.anaconda.org/conda-forge/linux-64/glib-2.88.1-h435ced3_1.conda#7d844a122c6cf1d8d2fb024f85757225 https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 https://conda.anaconda.org/conda-forge/noarch/joblib-1.3.0-pyhd8ed1ab_1.conda#fb4caf6da228ccc487350eade569abae -https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp21.1-21.1.6-default_h99862b1_0.conda#0fcc9b4d3fc5e5010a7098318d9b7971 -https://conda.anaconda.org/conda-forge/linux-64/libclang13-21.1.6-default_h746c552_0.conda#f5b64315835b284c7eb5332202b1e14b -https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.9.0-20_linux64_openblas.conda#05c5862c7dc25e65ba6c471d96429dae -https://conda.anaconda.org/conda-forge/linux-64/numpy-1.24.1-py311h8e6699e_0.conda#bd7c9bf413aa9478ea5f68123e796ab1 -https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda#c55515ca43c6444d2572e0f0d93cb6b9 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.9.0-20_linux64_openblas.conda#2b7bb4f7562c8cf334fc2e20c2d28abc +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda#928b8be80851f5d8ffb016f9c81dae7a +https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.5-hf7376ad_1.conda#6adc0202fa7fcf0a5fce8c31ef2ed866 +https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9 +https://conda.anaconda.org/conda-forge/linux-64/openblas-0.3.25-pthreads_h7a3da1a_0.conda#87661673941b5e702275fdf0fc095ad0 +https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda#680608784722880fbfe1745067570b00 +https://conda.anaconda.org/conda-forge/linux-64/orc-2.0.1-h17fec99_1.conda#3bf65f0d8e7322a1cfe8b670fa35ec81 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py311hf88fc01_0.conda#b4e4b0fc807b68aa1706457f2e31279d https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda#b8ea447fdf62e3597cb8d2fae4eb1a90 -https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.10.0-pyhd8ed1ab_0.conda#d9998bf52ced268eb83749ad65a2e061 +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/linux-64/re2-2023.09.01-h7f4b329_2.conda#8f70e36268dea8eb666ef14c29bd3cda https://conda.anaconda.org/conda-forge/linux-64/sip-6.10.0-py311h1ddb823_1.conda#8012258dbc1728a96a7a72a2b3daf2ad -https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.20.2-h2a5cb19_18.conda#7313674073496cec938f73b71163bc31 -https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.9.0-20_linux64_openblas.conda#9932a1d4e9ecf2d35fb19475446e361e -https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.2-py311hd18a35c_0.conda#f8e440efa026c394461a45a46cea49fc -https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.24.11-hc37bda9_0.conda#056d86cacf2b48c79c6a562a2486eb8c -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda#b8690f53007e9b5ee2c2178dd4ac778c +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.7-hb03c661_0.conda#f2ba4192d38b6cef2bb2c25029071d90 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda#b5fcc7172d22516e1f965490e65e33a4 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.7.22-hbd3ac97_10.conda#7ca4abcc98c7521c02f4e8809bbe40df +https://conda.anaconda.org/conda-forge/linux-64/aws-c-mqtt-0.10.4-hcd6a914_8.conda#b81c45867558446640306507498b2c6b +https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.26.11-h29cf534_0.conda#1e0e854b77451ac918b4a68f28932b1d +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-14.2.0-h6083320_0.conda#e194f6a2f498f0c7b1e6498bd0b12645 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-20_linux64_openblas.conda#36d486d72ab64ffea932329a1d3729a3 +https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp22.1-22.1.5-default_h99862b1_0.conda#eb9e3f61562dcf3a5d313e45cf7b0dd6 +https://conda.anaconda.org/conda-forge/linux-64/libclang13-22.1.5-default_h746c552_0.conda#c3df118cdc65584a78028bf225111b1b +https://conda.anaconda.org/conda-forge/linux-64/libgrpc-1.62.2-h15f2491_0.conda#8dabe607748cb3d7002ad73cd06f1325 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-20_linux64_openblas.conda#6fabc51f5e647d09cc010c40061557e0 +https://conda.anaconda.org/conda-forge/linux-64/libpq-18.3-h9abb657_0.conda#405ec206d230d9d37ad7c2636114cbf4 https://conda.anaconda.org/conda-forge/noarch/meson-python-0.17.1-pyh70fd9c4_1.conda#7a02679229c6c2092571b4c025055440 -https://conda.anaconda.org/conda-forge/linux-64/polars-0.20.30-py311h00856b1_0.conda#5113e0013db6b28be897218ddf9835f9 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh8b19718_0.conda#35870d32aed92041d31cbb15e822dca3 https://conda.anaconda.org/conda-forge/linux-64/pyqt5-sip-12.17.0-py311h1ddb823_2.conda#4f296d802e51e7a6889955c7f1bd10be -https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.1-pyhcf101f3_0.conda#fa7f71faa234947d9c520f89b4bda1a2 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py311haee01d2_1.conda#ca45bfd4871af957aaa5035593d5efd2 -https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.10.57-h7b9373a_16.conda#54db1af780a69493a2e0675113a027f9 -https://conda.anaconda.org/conda-forge/linux-64/blas-2.120-openblas.conda#c8f6916a81a340650078171b1d852574 -https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.24.11-h651a532_0.conda#d8d8894f8ced2c9be76dc9ad1ae531ce -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.6.1-py311he728205_1.tar.bz2#88af4d7dc89608bfb7665a9685578800 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.6.0-h365ddd8_2.conda#22339cf124753bafda336167f80e7860 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-2.26.0-h26d7fe4_0.conda#7b9d4c93870fb2d644168071d4d76afb +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.9.0-20_linux64_openblas.conda#05c5862c7dc25e65ba6c471d96429dae +https://conda.anaconda.org/conda-forge/linux-64/numpy-1.24.1-py311h8e6699e_0.conda#bd7c9bf413aa9478ea5f68123e796ab1 +https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hda50119_1.conda#d53ffc0edc8eabf4253508008493c5bc https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 https://conda.anaconda.org/conda-forge/noarch/urllib3-2.5.0-pyhd8ed1ab_0.conda#436c165519e140cb08d246a4472a9d6a -https://conda.anaconda.org/conda-forge/linux-64/libarrow-12.0.0-hc410076_9_cpu.conda#3dcb50139596ef80908e2dd9a931d84c -https://conda.anaconda.org/conda-forge/linux-64/qt-main-5.15.15-h3c3fd16_6.conda#5aab84b9d164509b5bbe3af660518606 -https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda#db0c6b99149880c8ba515cf4abe93ee4 -https://conda.anaconda.org/conda-forge/noarch/pooch-1.8.2-pyhd8ed1ab_3.conda#d2bbbd293097e664ffb01fc4cdaf5729 -https://conda.anaconda.org/conda-forge/linux-64/pyarrow-12.0.0-py311h39c9aba_9_cpu.conda#c35fe329bcc51a1a3a254c990ba8f738 +https://conda.anaconda.org/conda-forge/linux-64/aws-crt-cpp-0.27.3-hda66527_2.conda#734875312c8196feecc91f89856da612 +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.9.0-20_linux64_openblas.conda#9932a1d4e9ecf2d35fb19475446e361e +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.2-py311hd18a35c_0.conda#f8e440efa026c394461a45a46cea49fc +https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.26.11-h6d08254_0.conda#971da16e7fc43161329213557688d315 +https://conda.anaconda.org/conda-forge/linux-64/libgoogle-cloud-storage-2.26.0-ha262f82_0.conda#89b53708fd67762b26c38c8ecc5d323d +https://conda.anaconda.org/conda-forge/linux-64/polars-0.20.30-py311h00856b1_0.conda#5113e0013db6b28be897218ddf9835f9 +https://conda.anaconda.org/conda-forge/noarch/requests-2.33.1-pyhcf101f3_1.conda#9659f587a8ceacc21864260acd02fc67 +https://conda.anaconda.org/conda-forge/linux-64/aws-sdk-cpp-1.11.329-h46c3b66_9.conda#c840f07ec58dc0b06041e7f36550a539 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.120-openblas.conda#c8f6916a81a340650078171b1d852574 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.6.1-py311he728205_1.tar.bz2#88af4d7dc89608bfb7665a9685578800 +https://conda.anaconda.org/conda-forge/noarch/pooch-1.9.0-pyhd8ed1ab_0.conda#dd4b6337bf8886855db6905b336db3c8 +https://conda.anaconda.org/conda-forge/linux-64/qt-main-5.15.15-h0c412b5_8.conda#80e27e7982af989ebc2e0f0d57c75ea7 +https://conda.anaconda.org/conda-forge/linux-64/libarrow-13.0.0-hbec76fc_49_cpu.conda#0e54818246f20cbd13ed6ba98a0a31bb https://conda.anaconda.org/conda-forge/linux-64/pyqt-5.15.11-py311h0580839_2.conda#59ae5d8d4bcb1371d61ec49dfb985c70 -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.6.1-py311h38be061_1.tar.bz2#37d18a25f4f7fcef45ba4fb31cbe30af https://conda.anaconda.org/conda-forge/linux-64/scipy-1.10.0-py311h8e6699e_2.conda#29e7558b75488b2d5c7d1458be2b3b11 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.6.1-py311h38be061_1.tar.bz2#37d18a25f4f7fcef45ba4fb31cbe30af https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.0.0-py311hcb41070_0.conda#af2d6818c526791fb81686c554ab262b -# pip pytz @ https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl#sha256=5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 +https://conda.anaconda.org/conda-forge/linux-64/pyarrow-13.0.0-py311h02bbc4d_49_cpu.conda#a1eeed75b982917baed517c2ed97af06 +# pip pytz @ https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl#sha256=04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126 # pip pandas @ https://files.pythonhosted.org/packages/fa/fe/c81ad3991f2c6aeacf01973f1d37b1dc76c0682f312f104741602a9557f1/pandas-1.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=e252a9e49b233ff96e2815c67c29702ac3a062098d80a170c506dff3470fd060 diff --git a/build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_environment.yml b/build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_environment.yml similarity index 94% rename from build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_environment.yml rename to build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_environment.yml index 761a4005adc29..1bcead72555ac 100644 --- a/build_tools/azure/pymin_conda_forge_openblas_ubuntu_2204_environment.yml +++ b/build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_environment.yml @@ -10,6 +10,7 @@ dependencies: - scipy - cython - joblib + - narwhals - threadpoolctl - pandas - pyamg @@ -20,5 +21,5 @@ dependencies: - ninja - meson-python - sphinx - - numpydoc<1.9.0 + - numpydoc - ccache diff --git a/build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock b/build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock new file mode 100644 index 0000000000000..b3cd980aa1325 --- /dev/null +++ b/build_tools/github/pymin_conda_forge_openblas_ubuntu_2204_linux-64_conda.lock @@ -0,0 +1,112 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: f06cde3a939d893ba718bc1020c1f52e5d8809bf0b8c641eb0a01d32482eaccd +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-hbd8a1cb_0.conda#e18ad67cf881dcadee8b8d9e2f8e5f73 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.0-hecca717_0.conda#a3b390520c563d78cc58974de95a03e5 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.1-h0c1763c_0.conda#7dc38adcbf71e6b38748e919e16e0dce +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda#38ffe67b78c9d4de527be8315e5ada2c +https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libhiredis-1.3.0-h5888daf_1.conda#aa342fcf3bc583660dbfdb2eae6be48e +https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 +https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc +https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_0.conda#b518e9e92493721281a60fa975bddc65 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/ccache-4.13.6-hedf47ba_0.conda#d66e791d7524770340296e9d34e7f324 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.32-pthreads_h94d23a6_0.conda#89d61bc91d3f39fda0ca10fcd3c68594 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/python-3.11.15-hd63d673_0_cpython.conda#a5ebcefec0c12a333bcd6d7bf3bddc1f +https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.4.0-py311h6b1f9c4_0.conda#aa8c3009fd8903bebdcb22fbcb4c0dea +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py311h66f275b_1.conda#86daecb8e4ed1042d5dc6efbe0152590 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.4.22-pyhd8ed1ab_0.conda#929471569c93acefb30282a22060dcd5 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/linux-64/cython-3.2.4-py311h0daaf2c_0.conda#e9173db94f5c77b3e854a9c76c0568a5 +https://conda.anaconda.org/conda-forge/noarch/docutils-0.22.4-pyhd8ed1ab_0.conda#d6bd3cd217e62bbd7efe67ff224cd667 +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e +https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac +https://conda.anaconda.org/conda-forge/noarch/idna-3.13-pyhcf101f3_0.conda#fb7130c190f9b4ec91219840a05ba3ac +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_0.conda#f92f984b558e6e6204014b16d212b271 +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-6_h4a7cf45_openblas.conda#6d6d225559bfa6e2f3c90ee9c03d4e2e +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py311h3778330_1.conda#f9efdf9b0f3d0cc309d56af6edf2a6b0 +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/linux-64/openblas-0.3.32-pthreads_h6ec200e_0.conda#2e9cf6ff9a29b98a4faf627f2eb2cdb7 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda#0dc48b4b570931adc8641e55c6c17fe4 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda#755cf22df8693aa0d1aec1c123fa5863 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-6_h0358290_openblas.conda#36ae340a916635b97ac8a0655ace2a35 +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-6_h47877c9_openblas.conda#881d801569b201c2e753f03c84b85e15 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py311hf88fc01_0.conda#b4e4b0fc807b68aa1706457f2e31279d +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/linux-64/liblapacke-3.11.0-6_h6ae95b6_openblas.conda#af6df8ece92110c951032683af64f1fa +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.3-py311h2e04523_0.conda#cfc8f864dea571677095ebae8e6f0c07 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh8b19718_0.conda#35870d32aed92041d31cbb15e822dca3 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/linux-64/blas-devel-3.11.0-6_h1ea3ea9_openblas.conda#064f82e2cd0146b28a0bda3ca9b6fb7e +https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.2-py311h8032f78_0.conda#138e5d98884407fcc8ccc6088574b1c7 +https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/noarch/requests-2.33.1-pyhcf101f3_1.conda#9659f587a8ceacc21864260acd02fc67 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py311hbe70eeb_0.conda#5ae6d73ab0bebbc892c2d46dc51e90a5 +https://conda.anaconda.org/conda-forge/linux-64/blas-2.306-openblas.conda#81122e5749efe4c34c07471ad866eab1 +https://conda.anaconda.org/conda-forge/linux-64/pyamg-5.3.0-py311h1d5f577_1.conda#65b9997185d6db9b8be75ccb11664de5 +https://conda.anaconda.org/conda-forge/noarch/numpydoc-1.10.0-pyhcf101f3_0.conda#3aa4b625f20f55cf68e92df5e5bf3c39 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 +https://conda.anaconda.org/conda-forge/noarch/sphinx-9.0.4-pyhd8ed1ab_0.conda#950eae33376107d143a529d48c363832 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 diff --git a/build_tools/github/pymin_conda_forge_openblas_win-64_conda.lock b/build_tools/github/pymin_conda_forge_openblas_win-64_conda.lock new file mode 100644 index 0000000000000..8f0ee4e66e505 --- /dev/null +++ b/build_tools/github/pymin_conda_forge_openblas_win-64_conda.lock @@ -0,0 +1,120 @@ +# Generated by conda-lock. +# platform: win-64 +# input_hash: feaae827995fbf38e3b1d4e04e52e22e9b2bc994c222cc772e9e30df1b0a0a73 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6 +https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb +https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7 +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-8_cp311.conda#8fcb6b0e2161850556231336dae58358 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda#71b24316859acd00bdb8b38f5e2ce328 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.4.22-h4c7d964_0.conda#56fb2c6c73efc627b40c77d14caecfba +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681 +https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda#8a86073cf3b343b87d03f41790d8b4e5 +https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_34.conda#242d9f25d2ae60c76b38a5e42858e51d +https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab +https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_19.conda#f1147651e3fdd585e2f442c0c2fc8f2d +https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_34.conda#37eb311485d2d8b2c419449582046a42 +https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda#1626967b574d1784b578b52eaeb071e7 +https://conda.anaconda.org/conda-forge/win-64/vc-14.3-h41ae7f8_34.conda#1e610f2416b6acdd231c5f573d754a0f +https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda#4cb8e6b48f67de0b018719cdf1136306 +https://conda.anaconda.org/conda-forge/win-64/double-conversion-3.4.0-hac47afa_0.conda#3d3caf4ccc6415023640af4b1b33060a +https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.14-hac47afa_2.conda#b785694dd3ec77a011ccf0c24725382b +https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h637d24d_0.conda#0097b24800cb696915c3dbd1f5335d3f +https://conda.anaconda.org/conda-forge/win-64/lerc-4.1.0-hd936e49_0.conda#54b231d595bc1ff9bff668dd443ee012 +https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hfd05255_1.conda#444b0a45bbd1cb24f82eedb56721b9c4 +https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda#e77030e67343e28b084fabd7db0ce43e +https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.0-hac47afa_0.conda#264e350e035092b5135a2147c238aec4 +https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda#720b39f5ec0610457b725eb3f396219a +https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_19.conda#cc5d690fc1c629038f13c68e88e65f44 +https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda#64571d1dd6cdcfa25d0664a5950fdaa2 +https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.1.4.1-hfd05255_0.conda#25a127bad5470852b30b239f030ec95b +https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda#8f83619ab1588b98dd99c90b0bfc5c6d +https://conda.anaconda.org/conda-forge/win-64/libopenblas-0.3.32-pthreads_h877e47f_0.conda#cb7bb86e848c806cf3f0a182fcdf77da +https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.1-hf5d6505_0.conda#7fea434a17c323256acc510a041b80d7 +https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.341.0-h477610d_0.conda#804880b2674119b84277d6c16b01677d +https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda#f9bbae5e2537e3b06e0f7310ba76c893 +https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda#dbabbd6234dea34040e631f87676292f +https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_0.conda#7ecb9f2f112c66f959d2bb7dbdb89b67 +https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.2-hf411b9b_0.conda#05c7d624cff49dbd8db1ad5ba537a8a3 +https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda#08c8fa3b419df480d985e304f7884d35 +https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda#854fbdff64b572b5c0b470f334d34c11 +https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda#0481bfd9814bf525bd4b3ee4b51494c4 +https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda#46a21c0a4e65f1a135251fc7c8663f83 +https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h0ea6238_0.conda#4432f52dc0c8eb6a7a6abc00a037d93c +https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-6_h0adab6e_openblas.conda#11b6a32e75b36340f4d86e19dad1b1dc +https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-hfd05255_1.conda#450e3ae947fc46b60f1d8f8f318b40d4 +https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-hfd05255_1.conda#ccd93cfa8e54fd9df4e83dbe55ff6e8c +https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda#2cf0cf76cc15d360dfa2f17fd6cf9772 +https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.58-h7351971_0.conda#52f1280563f3b48b5f75414cd2d15dd1 +https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda#9e8dd0d90ed830107b2c36801035b7db +https://conda.anaconda.org/conda-forge/win-64/openblas-0.3.32-pthreads_h4a7f399_0.conda#d7b743c101c58cf009ec7a887e49489f +https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-hd2b5f0e_0.conda#77eaf2336f3ae749e712f63e36b0f0a1 +https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda#3c8f2573569bb816483e5cf57efbbe29 +https://conda.anaconda.org/conda-forge/win-64/python-3.11.15-h0159041_0_cpython.conda#d09dbf470b41bca48cbe6a78ba1e009b +https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda#8436cab9a76015dfe7208d3c9f97c156 +https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda#a7c03e38aa9c0e84d41881b9236eacfb +https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda#053b84beec00b71ea8ff7a4f84b55207 +https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-hfd05255_1.conda#6abd7089eb3f0c790235fe469558d190 +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/win-64/cython-3.2.4-py311h9990397_0.conda#74e8c626533a6011c33fdf2a47fbf71c +https://conda.anaconda.org/conda-forge/noarch/execnet-2.1.2-pyhd8ed1ab_0.conda#a57b4be42619213a94f31d2c69c5dda7 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.5.0-py311h275cad7_0.conda#e50d15677f2673c114f18d60c88d9196 +https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-6_h2a8eebe_openblas.conda#56ab25cf3cbce9e3a809e3e89739b5a8 +https://conda.anaconda.org/conda-forge/win-64/libclang13-22.1.5-default_ha2db4b5_0.conda#74229a56cbbfda28f75bed42ac5cacc7 +https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.3-hdbac1cb_0.conda#f9975a0177ee6cdda10c86d1db1186b0 +https://conda.anaconda.org/conda-forge/win-64/libglib-2.88.1-h7ce1215_1.conda#574ba3f468b639cfaf65c0f2b04d8e9d +https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-6_hd232482_openblas.conda#06d2ad5bf21e9b86c46783833b2e3c42 +https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.1-h8f73337_1.conda#549845d5133100142452812feb9ba2e8 +https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda#a69bbf778a462da324489976c84cfc8c +https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda#95591ca5671d2213f5b2d5aa7818420d +https://conda.anaconda.org/conda-forge/noarch/meson-1.11.1-pyhcf101f3_0.conda#ced6358cc61d7e381e68fc128f7b63db +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/noarch/narwhals-2.21.0-pyhcf101f3_0.conda#d2ec42db1d2fcd69003c8b069fb4301c +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f +https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhcf101f3_3.conda#d0fc809fa4c4d85e959ce4ab6e1de800 +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.5-py311h3485c13_0.conda#b004afcc680af88cb877978e71d42667 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/win-64/unicodedata2-17.0.1-py311h3485c13_0.conda#e6badeb53d9bc5cccebe46a62c5a7336 +https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h2d644bc_1.conda#bc58fdbced45bb096364de0fba1637af +https://conda.anaconda.org/conda-forge/win-64/coverage-7.14.0-py311h3f79411_0.conda#219ec381b22d8fae397333ef23f4ac79 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77 +https://conda.anaconda.org/conda-forge/win-64/lcms2-2.19.1-hf2c6c5f_0.conda#29f2c366a0da954bafd69a0d549c0ab3 +https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.3-h57928b3_0.conda#d9f70dd06674e26b6d5a657ddd22b568 +https://conda.anaconda.org/conda-forge/win-64/liblapacke-3.11.0-6_hbb0e6ff_openblas.conda#7111f949f68a554498655d268b5fa7a5 +https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda#46034d9d983edc21e84c0b36f1b4ba61 +https://conda.anaconda.org/conda-forge/win-64/numpy-2.4.3-py311h65cb7f3_0.conda#e37a9cfab4d96b7945119a3095087648 +https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h0e57b4f_0.conda#e723ab7cc2794c954e1b22fde51c16e4 +https://conda.anaconda.org/conda-forge/noarch/pyproject-metadata-0.11.0-pyhd8ed1ab_0.conda#cd6dae6c673c8f12fe7267eac3503961 +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/win-64/blas-devel-3.11.0-6_ha590de0_openblas.conda#085478f89f9e91d9e2cf6e81b9f4665b +https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py311h275cad7_4.conda#9fb1f375c704c5287c97c60f6a88d137 +https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.17.1-hd47e2ca_0.conda#a0b1b87e871011ca3b783bbf410bc39f +https://conda.anaconda.org/conda-forge/win-64/fonttools-4.62.1-py311h3f79411_0.conda#2963b1774c916733613de34d7e3e0457 +https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.3-h57928b3_0.conda#507b36518b5a595edda64066c820a6ef +https://conda.anaconda.org/conda-forge/noarch/meson-python-0.19.0-pyh7e86bf3_2.conda#369afcc2d4965e7a6a075ab82e2a26b8 +https://conda.anaconda.org/conda-forge/win-64/pillow-12.2.0-py311h17b8079_0.conda#80382ea49ddde54350b5ca5135be2838 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.1-pyh8b19718_0.conda#35870d32aed92041d31cbb15e822dca3 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/win-64/scipy-1.17.1-py311h9c22a71_0.conda#24047b0ff1fa264427c456e4c7f68283 +https://conda.anaconda.org/conda-forge/win-64/blas-2.306-openblas.conda#8fe2bc7bfe72359526e4e94b018853a3 +https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h477c42c_1.conda#52ea1beba35b69852d210242dd20f97d +https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.10.9-py311h1675fdf_0.conda#0579f9b8de16d67c61c265cfbd283cf0 +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-6.3.0-pyhd8ed1ab_0.conda#50d191b852fccb4bf9ab7b59b030c99d +https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417 +https://conda.anaconda.org/conda-forge/win-64/harfbuzz-14.2.0-h5a1b470_0.conda#b8862b83b5c899f5b65bcba0298b8478 +https://conda.anaconda.org/conda-forge/win-64/qt6-main-6.11.0-pl5321hfcac499_4.conda#1aca2896ea9f0d1f0761a7b278f670a0 +https://conda.anaconda.org/conda-forge/win-64/pyside6-6.11.0-py311he824864_2.conda#30aa3757b558ffc51d662b73531dbb1f +https://conda.anaconda.org/conda-forge/win-64/matplotlib-3.10.9-py311h1ea47a8_0.conda#007ed4b4117621a19e545d62f47339c4 diff --git a/build_tools/azure/test_docs.sh b/build_tools/github/test_docs.sh similarity index 90% rename from build_tools/azure/test_docs.sh rename to build_tools/github/test_docs.sh index f41072bf23a8b..800acdcc8f1d4 100755 --- a/build_tools/azure/test_docs.sh +++ b/build_tools/github/test_docs.sh @@ -8,7 +8,7 @@ activate_environment scipy_doctest_installed=$(python -c 'import scipy_doctest' && echo "True" || echo "False") if [[ "$scipy_doctest_installed" == "True" ]]; then doc_rst_files=$(find $PWD/doc -name '*.rst' | sort) - # Changing dir, as we do in build_tools/azure/test_script.sh, avoids an + # Changing dir, as we do in build_tools/github/test_script.sh, avoids an # error when importing sklearn. Not sure why this happens ... I am going to # wild guess that it has something to do with the bespoke way we set up # conda with putting conda in the PATH and source activate, rather than diff --git a/build_tools/azure/test_pytest_soft_dependency.sh b/build_tools/github/test_pytest_soft_dependency.sh similarity index 100% rename from build_tools/azure/test_pytest_soft_dependency.sh rename to build_tools/github/test_pytest_soft_dependency.sh diff --git a/build_tools/azure/test_script.sh b/build_tools/github/test_script.sh similarity index 61% rename from build_tools/azure/test_script.sh rename to build_tools/github/test_script.sh index 5e48f6701ea87..f4300d4d0bf04 100755 --- a/build_tools/azure/test_script.sh +++ b/build_tools/github/test_script.sh @@ -7,31 +7,6 @@ source build_tools/shared.sh activate_environment -if [[ "$BUILD_REASON" == "Schedule" ]]; then - # Enable global random seed randomization to discover seed-sensitive tests - # only on nightly builds. - # https://scikit-learn.org/stable/computing/parallelism.html#environment-variables - export SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$(($RANDOM % 100)) - echo "To reproduce this test run, set the following environment variable:" - echo " SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$SKLEARN_TESTS_GLOBAL_RANDOM_SEED", - echo "See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed" - - # Enable global dtype fixture for all nightly builds to discover - # numerical-sensitive tests. - # https://scikit-learn.org/stable/computing/parallelism.html#environment-variables - export SKLEARN_RUN_FLOAT32_TESTS=1 -fi - -# In GitHub Action (especially in `.github/workflows/unit-tests.yml` which -# calls this script), the environment variable `COMMIT_MESSAGE` is already set -# to the latest commit message. -if [[ -z "${COMMIT_MESSAGE+x}" ]]; then - # If 'COMMIT_MESSAGE' is unset we are in Azure, and we retrieve the commit - # message via the get_commit_message.py script which uses Azure-specific - # variables, for example 'BUILD_SOURCEVERSIONMESSAGE'. - COMMIT_MESSAGE=$(python build_tools/azure/get_commit_message.py --only-show-message) -fi - if [[ "$COMMIT_MESSAGE" =~ \[float32\] ]]; then echo "float32 tests will be run due to commit message" export SKLEARN_RUN_FLOAT32_TESTS=1 diff --git a/build_tools/github/ubuntu_atlas_lock.txt b/build_tools/github/ubuntu_atlas_lock.txt new file mode 100644 index 0000000000000..05ee11c0938fd --- /dev/null +++ b/build_tools/github/ubuntu_atlas_lock.txt @@ -0,0 +1,41 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=build_tools/github/ubuntu_atlas_lock.txt build_tools/github/ubuntu_atlas_requirements.txt +# +cython==3.1.2 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +execnet==2.1.2 + # via pytest-xdist +iniconfig==2.3.0 + # via pytest +joblib==1.3.0 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +meson==1.11.1 + # via meson-python +meson-python==0.19.0 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +narwhals==2.0.1 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +ninja==1.13.0 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +packaging==26.2 + # via + # meson-python + # pyproject-metadata + # pytest +pluggy==1.6.0 + # via pytest +pygments==2.20.0 + # via pytest +pyproject-metadata==0.11.0 + # via meson-python +pytest==9.0.3 + # via + # -r build_tools/github/ubuntu_atlas_requirements.txt + # pytest-xdist +pytest-xdist==3.8.0 + # via -r build_tools/github/ubuntu_atlas_requirements.txt +threadpoolctl==3.2.0 + # via -r build_tools/github/ubuntu_atlas_requirements.txt diff --git a/build_tools/azure/ubuntu_atlas_requirements.txt b/build_tools/github/ubuntu_atlas_requirements.txt similarity index 92% rename from build_tools/azure/ubuntu_atlas_requirements.txt rename to build_tools/github/ubuntu_atlas_requirements.txt index 91569dfef2299..a1945c10595f7 100644 --- a/build_tools/azure/ubuntu_atlas_requirements.txt +++ b/build_tools/github/ubuntu_atlas_requirements.txt @@ -3,6 +3,7 @@ # build_tools/update_environments_and_lock_files.py cython==3.1.2 # min joblib==1.3.0 # min +narwhals==2.0.1 # min threadpoolctl==3.2.0 # min pytest pytest-xdist diff --git a/build_tools/shared.sh b/build_tools/shared.sh index 65e6d1946d33e..cc754738f53ff 100644 --- a/build_tools/shared.sh +++ b/build_tools/shared.sh @@ -65,6 +65,6 @@ create_conda_environment_from_lock_file() { conda create --quiet --name $ENV_NAME --file $LOCK_FILE else python -m pip install "$(get_dep conda-lock min)" - conda-lock install --log-level WARNING --name $ENV_NAME $LOCK_FILE + conda-lock install --name $ENV_NAME $LOCK_FILE fi } diff --git a/build_tools/update_environments_and_lock_files.py b/build_tools/update_environments_and_lock_files.py index e2e9e1e722b2d..1181c35ec9d5f 100644 --- a/build_tools/update_environments_and_lock_files.py +++ b/build_tools/update_environments_and_lock_files.py @@ -64,6 +64,7 @@ "scipy", "cython", "joblib", + "narwhals", "threadpoolctl", "matplotlib", "pandas", @@ -84,9 +85,6 @@ docstring_test_dependencies = ["sphinx", "numpydoc"] default_package_constraints = { - # TODO: remove once https://github.com/numpy/numpydoc/issues/638 is fixed - # and released. - "numpydoc": "<1.9.0", # TODO: remove once when we're using the new way to enable coverage in subprocess # introduced in 7.0.0, see https://github.com/pytest-dev/pytest-cov?tab=readme-ov-file#upgrading-from-pytest-cov-63 "pytest-cov": "<=6.3.0", @@ -104,7 +102,7 @@ def remove_from(alist, to_remove): "tag": "cuda", "folder": "build_tools/github", "platform": "linux-64", - "channels": ["conda-forge", "pytorch", "nvidia"], + "channels": ["rapidsai", "conda-forge"], "conda_dependencies": common_dependencies + [ "ccache", @@ -112,17 +110,18 @@ def remove_from(alist, to_remove): "polars", "pyarrow", "cupy", + # cuvs is needed for cupyx.scipy.spatial.distance.cdist and friends + "cuvs", "array-api-strict", + "scipy-doctest", ], - "package_constraints": { - "blas": "[build=mkl]", - }, + "virtual_package_spec": True, }, { "name": "pylatest_conda_forge_mkl_linux-64", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": common_dependencies @@ -144,7 +143,7 @@ def remove_from(alist, to_remove): "name": "pylatest_conda_forge_osx-arm64", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "osx-arm64", "channels": ["conda-forge"], "conda_dependencies": common_dependencies @@ -161,7 +160,7 @@ def remove_from(alist, to_remove): "name": "pylatest_conda_forge_mkl_no_openmp", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "osx-64", "channels": ["conda-forge"], "conda_dependencies": common_dependencies + ["ccache"], @@ -173,7 +172,7 @@ def remove_from(alist, to_remove): "name": "pymin_conda_forge_openblas_min_dependencies", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": remove_from(common_dependencies, ["pandas"]) @@ -189,6 +188,7 @@ def remove_from(alist, to_remove): "matplotlib": "min", "cython": "min", "joblib": "min", + "narwhals": "min", "threadpoolctl": "min", "meson-python": "min", "pandas": "min", @@ -201,7 +201,7 @@ def remove_from(alist, to_remove): "name": "pymin_conda_forge_openblas_ubuntu_2204", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": ( @@ -218,7 +218,7 @@ def remove_from(alist, to_remove): "name": "pylatest_pip_openblas_pandas", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": ["python", "ccache"], @@ -242,7 +242,7 @@ def remove_from(alist, to_remove): "name": "pylatest_pip_scipy_dev", "type": "conda", "tag": "scipy-dev", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": ["python", "ccache"], @@ -262,6 +262,7 @@ def remove_from(alist, to_remove): "pandas", "cython", "joblib", + "narwhals", "pillow", ], ) @@ -276,7 +277,7 @@ def remove_from(alist, to_remove): "name": "pylatest_free_threaded", "type": "conda", "tag": "free-threaded", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "linux-64", "channels": ["conda-forge"], "conda_dependencies": [ @@ -286,6 +287,7 @@ def remove_from(alist, to_remove): "numpy", "scipy", "joblib", + "narwhals", "threadpoolctl", "pytest", "pytest-run-parallel", @@ -297,7 +299,7 @@ def remove_from(alist, to_remove): "name": "pymin_conda_forge_openblas", "type": "conda", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "platform": "win-64", "channels": ["conda-forge"], "conda_dependencies": remove_from(common_dependencies, ["pandas", "pyamg"]) @@ -403,10 +405,7 @@ def remove_from(alist, to_remove): "sphinxcontrib-sass", ], "package_constraints": { - "python": "3.11", - # Pinned while https://github.com/pola-rs/polars/issues/25039 is - # not fixed. - "polars": "1.34.0", + "python": "3.14", }, }, { @@ -429,10 +428,11 @@ def remove_from(alist, to_remove): "name": "debian_32bit", "type": "pip", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "pip_dependencies": [ "cython", "joblib", + "narwhals", "threadpoolctl", "pytest", "pytest-xdist", @@ -448,10 +448,11 @@ def remove_from(alist, to_remove): "name": "ubuntu_atlas", "type": "pip", "tag": "main-ci", - "folder": "build_tools/azure", + "folder": "build_tools/github", "pip_dependencies": [ "cython", "joblib", + "narwhals", "threadpoolctl", "pytest", "pytest-xdist", @@ -460,6 +461,7 @@ def remove_from(alist, to_remove): ], "package_constraints": { "joblib": "min", + "narwhals": "min", "threadpoolctl": "min", "cython": "min", }, @@ -559,22 +561,26 @@ def write_all_conda_environments(build_metadata_list): write_conda_environment(build_metadata) -def conda_lock(environment_path, lock_file_path, platform): - execute_command( - [ - "conda-lock", - "lock", - "--mamba", - "--kind", - "explicit", - "--platform", - platform, - "--file", - str(environment_path), - "--filename-template", - str(lock_file_path), - ] - ) +def conda_lock( + environment_path, lock_file_path, platform, virtual_package_spec_path=None +): + cmd = [ + "conda-lock", + "lock", + "--mamba", + "--kind", + "explicit", + "--platform", + platform, + "--file", + str(environment_path), + "--filename-template", + str(lock_file_path), + ] + if virtual_package_spec_path is not None: + cmd.extend(["--virtual-package-spec", str(virtual_package_spec_path)]) + + execute_command(cmd) def create_conda_lock_file(build_metadata): @@ -587,7 +593,14 @@ def create_conda_lock_file(build_metadata): lock_file_basename = f"{lock_file_basename}_{platform}" lock_file_path = folder_path / f"{lock_file_basename}_conda.lock" - conda_lock(environment_path, lock_file_path, platform) + + virtual_package_spec_path = None + if build_metadata.get("virtual_package_spec"): + virtual_package_spec_path = ( + folder_path / f"{lock_file_basename}_virtual_package_spec.yml" + ) + + conda_lock(environment_path, lock_file_path, platform, virtual_package_spec_path) def write_all_conda_lock_files(build_metadata_list): @@ -653,6 +666,9 @@ def write_pip_lock_file(build_metadata): "-n", f"pip-tools-python{python_version}", f"python={python_version}", + # TODO remove the following line once pip-tools is compatible with pip 26.0, + # see https://github.com/jazzband/pip-tools/issues/2319 + "pip=25.3", "pip-tools", "-y", ] diff --git a/doc/about.rst b/doc/about.rst index fc5868b590b2b..278a8125cf185 100644 --- a/doc/about.rst +++ b/doc/about.rst @@ -159,9 +159,14 @@ Bibtex entry:: pages = {108--122}, } +.. _branding-and-logos: + Branding & Logos ================ +The scikit-learn brand is subject to the following `terms of use and guidelines +<https://blog.scikit-learn.org/assets/brand_guidelines/2025-02-scikit-learn-brand-guidelines.pdf>`_. + High quality PNG and SVG logos are available in the `doc/logos <https://github.com/scikit-learn/scikit-learn/tree/main/doc/logos>`_ source directory. The color palette is available in the @@ -170,345 +175,18 @@ source directory. The color palette is available in the .. image:: images/scikit-learn-logo-notext.png :align: center -Funding -======= - -Scikit-learn is a community driven project, however institutional and private -grants help to assure its sustainability. - -The project would like to thank the following funders. - -................................... - -.. div:: sk-text-image-grid-small - - .. div:: text-box - - `:probabl. <https://probabl.ai>`_ manages the whole sponsorship program - and employs the full-time core maintainers Adrin Jalali, Arturo Amor, - Franรงois Goupil, Guillaume Lemaitre, Jรฉrรฉmie du Boisberranger, Loรฏc Estรจve, - Olivier Grisel, and Stefanie Senger. - - .. div:: image-box - - .. image:: images/probabl.png - :target: https://probabl.ai - :width: 40% - -.......... - -Active Sponsors -=============== - -Founding sponsors ------------------ - -.. div:: sk-text-image-grid-small - - .. div:: text-box - - `Inria <https://www.inria.fr>`_ supports scikit-learn through their - sponsorship. - - .. div:: image-box - - .. image:: images/inria-logo.jpg - :target: https://www.inria.fr - -.......... - -Gold sponsors -------------- - -.. div:: sk-text-image-grid-small - - .. div:: text-box - - `Chanel <https://www.chanel.com>`_ supports scikit-learn through their - sponsorship. - - .. div:: image-box - - .. image:: images/chanel.png - :target: https://www.chanel.com - -.......... - -Silver sponsors ---------------- - -.. div:: sk-text-image-grid-small - - .. div:: text-box - - `BNP Paribas Group <https://group.bnpparibas/>`_ supports scikit-learn - through their sponsorship. - - .. div:: image-box - - .. image:: images/bnp-paribas.jpg - :target: https://group.bnpparibas/ - -.......... - -Bronze sponsors ---------------- - -.. div:: sk-text-image-grid-small - - .. div:: text-box - - `NVIDIA <https://nvidia.com>`_ supports scikit-learn through their sponsorship and employs full-time core maintainer Tim Head. - - .. div:: image-box - - .. image:: images/nvidia.png - :target: https://nvidia.com - -.......... - -Other contributions -------------------- - -.. |chanel| image:: images/chanel.png - :target: https://www.chanel.com - -.. |axa| image:: images/axa.png - :target: https://www.axa.fr/ - -.. |bnp| image:: images/bnp.png - :target: https://www.bnpparibascardif.com/ - -.. |bnpparibasgroup| image:: images/bnp-paribas.jpg - :target: https://group.bnpparibas/ - -.. |dataiku| image:: images/dataiku.png - :target: https://www.dataiku.com/ - -.. |nvidia| image:: images/nvidia.png - :target: https://www.nvidia.com - -.. |inria| image:: images/inria-logo.jpg - :target: https://www.inria.fr - -.. raw:: html - - <style> - table.image-subtable tr { - border-color: transparent; - } - - table.image-subtable td { - width: 50%; - vertical-align: middle; - text-align: center; - } - - table.image-subtable td img { - max-height: 40px !important; - max-width: 90% !important; - } - </style> - - -* `Microsoft <https://microsoft.com/>`_ funds Andreas Mรผller since 2020. - - -* `Quansight Labs <https://labs.quansight.org>`_ funds Lucy Liu since 2022. -* `The Chan-Zuckerberg Initiative <https://chanzuckerberg.com/>`_ and - `Wellcome Trust <https://wellcome.org/>`_ fund scikit-learn through the - `Essential Open Source Software for Science (EOSS) <https://chanzuckerberg.com/eoss/>`_ - cycle 6. +Institutional support +===================== - It supports Lucy Liu and diversity & inclusion initiatives that will - be announced in the future. - -* `Tidelift <https://tidelift.com/>`_ supports the project via their service - agreement. - -Past Sponsors -============= - -`Quansight Labs <https://labs.quansight.org>`_ funded Meekail Zain in 2022 and 2023, -and funded Thomas J. Fan from 2021 to 2023. - -`Columbia University <https://columbia.edu/>`_ funded Andreas Mรผller -(2016-2020). - -`The University of Sydney <https://sydney.edu.au/>`_ funded Joel Nothman -(2017-2021). - -Andreas Mรผller received a grant to improve scikit-learn from the -`Alfred P. Sloan Foundation <https://sloan.org>`_ . -This grant supported the position of Nicolas Hug and Thomas J. Fan. - -`INRIA <https://www.inria.fr>`_ has provided funding for Fabian Pedregosa -(2010-2012), Jaques Grobler (2012-2013) and Olivier Grisel (2013-2017) to -work on this project full-time. It also hosts coding sprints and other events. - -`Paris-Saclay Center for Data Science <http://www.datascience-paris-saclay.fr/>`_ -funded one year for a developer to work on the project full-time (2014-2015), 50% -of the time of Guillaume Lemaitre (2016-2017) and 50% of the time of Joris van den -Bossche (2017-2018). - -`NYU Moore-Sloan Data Science Environment <https://cds.nyu.edu/mooresloan/>`_ -funded Andreas Mueller (2014-2016) to work on this project. The Moore-Sloan -Data Science Environment also funds several students to work on the project -part-time. - -`Tรฉlรฉcom Paristech <https://www.telecom-paristech.fr/>`_ funded Manoj Kumar -(2014), Tom Duprรฉ la Tour (2015), Raghav RV (2015-2017), Thierry Guillemot -(2016-2017) and Albert Thomas (2017) to work on scikit-learn. - -`The Labex DigiCosme <https://digicosme.lri.fr>`_ funded Nicolas Goix -(2015-2016), Tom Duprรฉ la Tour (2015-2016 and 2017-2018), Mathurin Massias -(2018-2019) to work part time on scikit-learn during their PhDs. It also -funded a scikit-learn coding sprint in 2015. - -`The Chan-Zuckerberg Initiative <https://chanzuckerberg.com/>`_ funded Nicolas -Hug to work full-time on scikit-learn in 2020. - -The following students were sponsored by `Google -<https://opensource.google/>`_ to work on scikit-learn through -the `Google Summer of Code <https://en.wikipedia.org/wiki/Google_Summer_of_Code>`_ -program. - -- 2007 - David Cournapeau -- 2011 - `Vlad Niculae`_ -- 2012 - `Vlad Niculae`_, Immanuel Bayer -- 2013 - Kemal Eren, Nicolas Trรฉsegnie -- 2014 - Hamzeh Alsalhi, Issam Laradji, Maheshakya Wijewardena, Manoj Kumar -- 2015 - `Raghav RV <https://github.com/raghavrv>`_, Wei Xue -- 2016 - `Nelson Liu <http://nelsonliu.me>`_, `YenChen Lin <https://yenchenlin.me/>`_ - -.. _Vlad Niculae: https://vene.ro/ - -................... - -The `NeuroDebian <http://neuro.debian.net>`_ project providing `Debian -<https://www.debian.org/>`_ packaging and contributions is supported by -`Dr. James V. Haxby <http://haxbylab.dartmouth.edu/>`_ (`Dartmouth -College <https://pbs.dartmouth.edu/>`_). - -................... - -The following organizations funded the scikit-learn consortium at Inria in -the past: - -.. |msn| image:: images/microsoft.png - :target: https://www.microsoft.com/ - -.. |bcg| image:: images/bcg.png - :target: https://www.bcg.com/beyond-consulting/bcg-gamma/default.aspx - -.. |fujitsu| image:: images/fujitsu.png - :target: https://www.fujitsu.com/global/ - -.. |aphp| image:: images/logo_APHP_text.png - :target: https://aphp.fr/ - -.. |hf| image:: images/huggingface_logo-noborder.png - :target: https://huggingface.co - -.. raw:: html - - <style> - div.image-subgrid img { - max-height: 50px; - max-width: 90%; - } - </style> - -.. grid:: 2 2 4 4 - :class-row: image-subgrid - :gutter: 1 - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |msn| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |bcg| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |fujitsu| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |aphp| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |hf| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |dataiku| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |bnp| - - .. grid-item:: - :class: sd-text-center - :child-align: center - - |axa| +scikit-learn is a community driven project, however institutional and private +grants help to assure its sustainability. - -Donations in Kind ------------------ -The following organizations provide non-financial contributions to the -scikit-learn project. - -.. raw:: html - - <table cellspacing="0" cellpadding="8"> - <thead> - <tr> - <th>Company</th> - <th>Contribution</th> - </tr> - </thead> - <tbody> - <tr> - <td><a href="https://www.anaconda.com">Anaconda Inc</a></td> - <td>Storage for our staging and nightly builds</td> - </tr> - <tr> - <td><a href="https://circleci.com/">CircleCI</a></td> - <td>CPU time on their Continuous Integration servers</td> - </tr> - <tr> - <td><a href="https://www.github.com">GitHub</a></td> - <td>Teams account</td> - </tr> - <tr> - <td><a href="https://azure.microsoft.com/en-us/">Microsoft Azure</a></td> - <td>CPU time on their Continuous Integration servers</td> - </tr> - </tbody> - </table> +More details about institutional support are available in the :ref:`funding` +section. Coding Sprints --------------- +============== The scikit-learn project has a long history of `open source coding sprints <https://blog.scikit-learn.org/events/sprints-value/>`_ with over 50 sprint @@ -517,57 +195,9 @@ to costs which include venue, food, travel, developer time and more. See `scikit-learn sprints <https://blog.scikit-learn.org/sprints/>`_ for a full list of events. -Donating to the project -======================= - -If you have found scikit-learn to be useful in your work, research, or company, -please consider making a donation to the project commensurate with your resources. -There are several options for making donations: - -.. raw:: html - - <p class="text-center"> - <a class="btn sk-btn-orange mb-1" href="https://numfocus.org/donate-to-scikit-learn"> - Donate via NumFOCUS - </a> - <a class="btn sk-btn-orange mb-1" href="https://github.com/sponsors/scikit-learn"> - Donate via GitHub Sponsors - </a> - <a class="btn sk-btn-orange mb-1" href="https://causes.benevity.org/projects/433725"> - Donate via Benevity - </a> - </p> - -**Donation Options:** - -* **NumFOCUS**: Donate via the `NumFOCUS Donations Page - <https://numfocus.org/donate-to-scikit-learn>`_, scikit-learn's fiscal sponsor. - -* **GitHub Sponsors**: Support the project directly through `GitHub Sponsors - <https://github.com/sponsors/scikit-learn>`_. - -* **Benevity**: If your company uses scikit-learn, you can also support the - project through Benevity, a platform to manage employee donations. It is - widely used by hundreds of Fortune 1000 companies to streamline and scale - their social impact initiatives. If your company uses Benevity, you are - able to make a donation with a company match as high as 100%. Our project - ID is `433725 <https://causes.benevity.org/projects/433725>`_. - -All donations are managed by `NumFOCUS <https://numfocus.org/>`_, a 501(c)(3) -non-profit organization based in Austin, Texas, USA. The NumFOCUS board -consists of `SciPy community members <https://numfocus.org/board.html>`_. -Contributions are tax-deductible to the extent allowed by law. - -.. rubric:: Notes - -Contributions support the maintenance of the project, including development, -documentation, infrastructure and coding sprints. - scikit-learn Swag ----------------- Official scikit-learn swag is available for purchase at the `NumFOCUS online store <https://numfocus.myspreadshop.com/scikit-learn+logo?idea=6335cad48f3f5268f5f42559>`_. A portion of the proceeds from each sale goes to support the scikit-learn project. - - diff --git a/doc/api_reference.py b/doc/api_reference.py index d003b0bafd558..52167e270c7ce 100644 --- a/doc/api_reference.py +++ b/doc/api_reference.py @@ -41,8 +41,8 @@ def _get_submodule(module_name, submodule_name): components: short_summary (required) - The text to be printed on the index page; it has nothing to do the API reference - page of each module. + The text to be printed on the index page; it has nothing to do with + the API reference page of each module. description (required, `None` if not needed) The additional description for the module to be placed under the module docstring, before the sections start. @@ -603,7 +603,7 @@ def _get_submodule(module_name, submodule_name): "title": "Regressors with variable selection", "description": ( "The following estimators have built-in variable selection fitting " - "procedures, but any estimator using a L1 or elastic-net penalty " + "procedures, but any estimator using an L1 or elastic-net penalty " "also performs variable selection: typically " ":class:`~linear_model.SGDRegressor` or " ":class:`~sklearn.linear_model.SGDClassifier` with an appropriate " @@ -744,6 +744,7 @@ def _get_submodule(module_name, submodule_name): "jaccard_score", "log_loss", "matthews_corrcoef", + "metric_at_thresholds", "multilabel_confusion_matrix", "ndcg_score", "precision_recall_curve", diff --git a/doc/communication_team.rst b/doc/communication_team.rst index fb9666f0b42f7..cae5f9421c980 100644 --- a/doc/communication_team.rst +++ b/doc/communication_team.rst @@ -6,10 +6,6 @@ img.avatar {border-radius: 10px;} </style> <div> - <a href='https://github.com/laurburke'><img src='https://avatars.githubusercontent.com/u/35973528?v=4' class='avatar' /></a> <br /> - <p>Lauren Burke-McCarthy</p> - </div> - <div> <a href='https://github.com/francoisgoupil'><img src='https://avatars.githubusercontent.com/u/98105626?v=4' class='avatar' /></a> <br /> <p>Franรงois Goupil</p> </div> diff --git a/doc/communication_team_emeritus.rst b/doc/communication_team_emeritus.rst index d5ef7df59238e..858b605c73c5c 100644 --- a/doc/communication_team_emeritus.rst +++ b/doc/communication_team_emeritus.rst @@ -1 +1,2 @@ +- Lauren Burke-McCarthy - Reshama Shaikh diff --git a/doc/computing/computational_performance.rst b/doc/computing/computational_performance.rst index 6aa0865b54c35..d1df34551e157 100644 --- a/doc/computing/computational_performance.rst +++ b/doc/computing/computational_performance.rst @@ -178,7 +178,7 @@ non-zero coefficients. For the :mod:`sklearn.svm` family of algorithms with a non-linear kernel, the latency is tied to the number of support vectors (the fewer the faster). Latency and throughput should (asymptotically) grow linearly with the number -of support vectors in a SVC or SVR model. The kernel will also influence the +of support vectors in an SVC or SVR model. The kernel will also influence the latency as it is used to compute the projection of the input vector once per support vector. In the following graph the ``nu`` parameter of :class:`~svm.NuSVR` was used to influence the number of diff --git a/doc/computing/parallelism.rst b/doc/computing/parallelism.rst index bd24ace621c4e..de7dbfbde70d0 100644 --- a/doc/computing/parallelism.rst +++ b/doc/computing/parallelism.rst @@ -34,6 +34,15 @@ When the underlying implementation uses joblib, the number of workers (threads or processes) that are spawned in parallel can be controlled via the ``n_jobs`` parameter. +.. note:: + + **Startup Overhead** + + When using ``n_jobs > 1`` (or ``n_jobs=-1``), you may observe a delay + the first time a parallel function is called. This is expected behavior + caused by the overhead of starting the Python worker processes. + Subsequent calls will be faster as they reuse the existing pool of workers. + .. note:: Where (and how) parallelization happens in the estimators using joblib by diff --git a/doc/conf.py b/doc/conf.py index 0a06daa3e9df4..9b843cee9130d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -289,11 +289,11 @@ "secondary_sidebar_items": { "**": [ "page-toc", - "sourcelink", # Sphinx-Gallery-specific sidebar components # https://sphinx-gallery.github.io/stable/advanced.html#using-sphinx-gallery-sidebar-components "sg_download_links", "sg_launcher_links", + "funding_links", ], }, "show_version_warning_banner": True, @@ -338,6 +338,7 @@ "roadmap": [], "governance": [], "about": [], + "institutional_support": [], } # Additional templates that should be rendered to pages, maps page names to @@ -440,6 +441,7 @@ def add_js_css_files(app, pagename, templatename, context, doctree): "documentation": "index", "contents": "index", "preface": "index", + "dispatching": "data_interoperability", "modules/classes": "api/index", "tutorial/machine_learning_map/index": "machine_learning_map", "auto_examples/feature_selection/plot_permutation_test_for_classification": ( @@ -515,6 +517,9 @@ def add_js_css_files(app, pagename, templatename, context, doctree): "auto_examples/linear_model/plot_sgd_comparison": ( "auto_examples/linear_model/plot_sgd_loss_functions" ), + "auto_examples/miscellaneous/plot_partial_dependence_visualization_api": ( + "auto_examples/inspection/plot_partial_dependence_visualization_api" + ), } html_context["redirects"] = redirects for old_link in redirects: @@ -569,6 +574,7 @@ def add_js_css_files(app, pagename, templatename, context, doctree): "python": ("https://docs.python.org/{.major}".format(sys.version_info), None), "numpy": ("https://numpy.org/doc/stable", None), "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "narwhals": ("https://narwhals-dev.github.io/narwhals/", None), "matplotlib": ("https://matplotlib.org/", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), "joblib": ("https://joblib.readthedocs.io/en/latest/", None), @@ -910,7 +916,7 @@ def setup(app): r"^..?/", # ignore links to specific pdf pages because linkcheck does not handle them # ('utf-8' codec can't decode byte error) - r"http://www.utstat.toronto.edu/~rsalakhu/sta4273/notes/Lecture2.pdf#page=.*", + r"https://www.utstat.toronto.edu/~rsalakhu/sta4273/notes/Lecture2.pdf#page=.*", ( "https://www.fordfoundation.org/media/2976/roads-and-bridges" "-the-unseen-labor-behind-our-digital-infrastructure.pdf#page=.*" diff --git a/doc/data_interoperability.rst b/doc/data_interoperability.rst new file mode 100644 index 0000000000000..114352a99a316 --- /dev/null +++ b/doc/data_interoperability.rst @@ -0,0 +1,42 @@ +===================== +Data Interoperability +===================== + +.. currentmodule:: sklearn + +Scikit-learn handles four kinds of data for :term:`X` as used in `fit(X, y)`, `fit(X)`, +`fit_transform(X)` and `transform(X)` as well as :term:`Xt` as returned by +`transform(X)` and `fit_transform(X)`: + +- :term:`array-like` objects + + In `fit(X)` and `transform(X)`, array-like `X` is converted to a numpy ndarray by + calling `numpy.asarray` upon them. + The returned `Xt` of `transform` and `fit_transform` is also a numpy ndarray or it + is a sparse matrix or sparse array, see next bullet. +- :term:`sparse matrices <sparse matrix>` and sparse arrays + + Many estimators can deal with sparse `X`, some cannot and will raise an error. + For instance, :class:`linear_model.LogisticRegression` can be fit on sparse `X`, + :class:`isotonic.IsotonicRegression` can not. + + Some transformers return sparse `Xt` from `transform` and `fit_transform`. + Most often, it can be controlled by a `sparse_output` parameter as in + :class:`preprocessing.SplineTransformer`. + + To control whether it returns a sparse matrix or a sparse array, use + `sparse_interface` in :func:`config_context` or :func:`set_config`. + This also controls whether sparse attributes are sparse matrices or sparse arrays. +- tabular data: pandas and polars dataframes + + See :ref:`df_output_transform`. +- Array API compliant arrays + + Very importantly, this includes arrays on the GPU, see :ref:`array_api`. + + +.. toctree:: + :maxdepth: 2 + + modules/df_output_transform + modules/array_api diff --git a/doc/developers/contributing.rst b/doc/developers/contributing.rst index 1d582255f6c11..9ee2e9defaf27 100644 --- a/doc/developers/contributing.rst +++ b/doc/developers/contributing.rst @@ -24,20 +24,20 @@ Contributing .. currentmodule:: sklearn -This project is a community effort, and everyone is welcome to -contribute. It is hosted on https://github.com/scikit-learn/scikit-learn. +This project is a community effort, shaped by a large number of contributors from +across the world. For more information on the history and people behind scikit-learn +see :ref:`about`. It is hosted on https://github.com/scikit-learn/scikit-learn. The decision making process and governance structure of scikit-learn is laid out in :ref:`governance`. Scikit-learn is :ref:`selective <selectiveness>` when it comes to adding new algorithms and features. This means the best way to contribute and help the project is to start working on known issues. -See :ref:`new_contributors` to get started. +See :ref:`ways_to_contribute` to learn how to make meaningful contributions. .. topic:: **Our community, our values** - We are a community based on openness and friendly, didactic, - discussions. + We are a community based on openness and friendly, didactic discussions. We aspire to treat everybody equally, and value their contributions. We are particularly seeking people from underrepresented backgrounds in Open @@ -54,49 +54,33 @@ See :ref:`new_contributors` to get started. Communications on all channels should respect our `Code of Conduct <https://github.com/scikit-learn/scikit-learn/blob/main/CODE_OF_CONDUCT.md>`_. - - -In case you experience issues using this package, do not hesitate to submit a -ticket to the -`GitHub issue tracker -<https://github.com/scikit-learn/scikit-learn/issues>`_. You are also -welcome to post feature requests or pull requests. - .. _ways_to_contribute: Ways to contribute ================== -There are many ways to contribute to scikit-learn. Improving the -documentation is no less important than improving the code of the library -itself. If you find a typo in the documentation, or have made improvements, do -not hesitate to create a GitHub issue or preferably submit a GitHub pull request. - -There are many ways to help. In particular helping to -:ref:`improve, triage, and investigate issues <bug_triaging>` and -:ref:`reviewing other developers' pull requests <code_review>` are very -valuable contributions that move the project forward. - -Another way to contribute is to report issues you are facing, and give a "thumbs -up" on issues that others reported and that are relevant to you. It also helps -us if you spread the word: reference the project from your blog and articles, -link to it from your website, or simply star to say "I use it": - -.. raw:: html - - <p> - <object - data="https://img.shields.io/github/stars/scikit-learn/scikit-learn?style=for-the-badge&logo=github" - type="image/svg+xml"> - </object> - </p> - -In case a contribution/issue involves changes to the API principles -or changes to dependencies or supported versions, it must be backed by a -:ref:`slep`, where a SLEP must be submitted as a pull-request to -`enhancement proposals <https://scikit-learn-enhancement-proposals.readthedocs.io>`_ -using the `SLEP template <https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep_template.html>`_ -and follows the decision-making process outlined in :ref:`governance`. +There are many ways to contribute to scikit-learn. These include: + +* referencing scikit-learn from your blog and articles, linking to it from your website, + or simply + `staring it <https://docs.github.com/en/get-started/exploring-projects-on-github/saving-repositories-with-stars>`__ + to say "I use it"; this helps us promote the project +* :ref:`improving and investigating issues <bug_triaging>` +* :ref:`reviewing other developers' pull requests <code_review>` +* reporting difficulties when using this package by submitting an + `issue <https://github.com/scikit-learn/scikit-learn/issues>`__, and giving a + "thumbs up" on issues that others reported and that are relevant to you (see + :ref:`submitting_bug_feature` for details) +* improving the :ref:`contribute_documentation` +* making a code contribution + +There are many ways to contribute without writing code, and we value these +contributions just as highly as code contributions. If you are interested in making +a code contribution, please keep in mind that scikit-learn has evolved into a mature +and complex project since its inception in 2007. Contributing to the project code +generally requires advanced skills, and it may not be the best place to begin if you +are new to open source contribution. In this case we suggest you follow the suggestions +in :ref:`new_contributors`. .. dropdown:: Contributing to related projects @@ -125,16 +109,32 @@ New Contributors ---------------- We recommend new contributors start by reading this contributing guide, in -particular :ref:`ways_to_contribute`, :ref:`automated_contributions_policy` -and :ref:`pr_checklist`. For expected etiquette around which issues and stalled PRs +particular :ref:`ways_to_contribute`, :ref:`automated_contributions_policy`. + +Next, we advise new contributors gain foundational knowledge on +scikit-learn and open source by: + +* :ref:`improving and investigating issues <bug_triaging>` + + * confirming that a problem reported can be reproduced and providing a + :ref:`minimal reproducible code <minimal_reproducer>` (if missing), can help you + learn about different use cases and user needs + * investigating the root cause of an issue will aid you in familiarising yourself + with the scikit-learn codebase + +* :ref:`reviewing other developers' pull requests <code_review>` will help you + develop an understanding of the requirements and quality expected of contributions +* improving the :ref:`contribute_documentation` can help deepen your knowledge + of the statistical concepts behind models and functions, and scikit-learn API + +If you wish to make code contributions after building your foundational knowledge, we +recommend you start by looking for an issue that is of interest to you, in an area you +are already familiar with as a user or have background knowledge of. We recommend +starting with smaller pull requests and following our :ref:`pr_checklist`. +For expected etiquette around which issues and stalled PRs to work on, please read :ref:`stalled_pull_request`, :ref:`stalled_unclaimed_issues` and :ref:`issues_tagged_needs_triage`. -We understand that everyone has different interests and backgrounds, thus we recommend -you start by looking for an issue that is of interest to you, in an area you are -already familiar with as a user or have background knowledge of. We recommend starting -with smaller pull requests, to get used to the contribution process. - We rarely use the "good first issue" label because it is difficult to make assumptions about new contributors and these issues often prove more complex than originally anticipated. It is still useful to check if there are @@ -152,22 +152,28 @@ look. Automated Contributions Policy ============================== +Contributing to scikit-learn requires human judgment, contextual understanding, and +familiarity with scikit-learn's structure and goals. It is not suitable for +automatic processing by AI tools. + Please refrain from submitting issues or pull requests generated by fully-automated tools. Maintainers reserve the right, at their sole discretion, to close such submissions and to block any account responsible for them. -Ideally, contributions should follow from a human-to-human discussion in the -form of an issue. In particular, please do not paste AI generated text in the -description of issues, PRs or in comments as it makes it significantly harder for -reviewers to assess the relevance of your contribution and the potential value it -brings to future end-users of the library. Note that it's fine to use AI tools -to proofread or improve your draft text if you are not a native English speaker, -but reviewers are not interested in unknowingly interacting back and forth with -automated chatbots that fundamentally do not care about the value of our open -source project. +Review all code or documentation changes made by AI tools and +make sure you understand all changes and can explain them on request, before +submitting them under your name. Do not submit any AI-generated code that you haven't +personally reviewed, understood and tested, as this wastes maintainers' time. + +Please do not paste AI generated text in the description of issues, PRs or in comments +as this makes it harder for reviewers to assess your contribution. We are happy for it +to be used to improve grammar or if you are not a native English speaker. + +If you used AI tools, please state so in your PR description. + +PRs that appear to violate this policy will be closed without review. -Please self review all code or documentation changes made by AI tools before -submitting them under your name. +.. _submitting_bug_feature: Submitting a bug report or a feature request ============================================ @@ -195,6 +201,13 @@ following rules before submitting: - If you are submitting a bug report, we strongly encourage you to follow the guidelines in :ref:`filing_bugs`. +When a feature request involves changes to the API principles +or changes to dependencies or supported versions, it must be backed by a +:ref:`SLEP <slep>`, which must be submitted as a pull-request to +`enhancement proposals <https://scikit-learn-enhancement-proposals.readthedocs.io>`_ +using the `SLEP template <https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep_template.html>`_ +and follows the decision-making process outlined in :ref:`governance`. + .. _filing_bugs: How to make a good bug report @@ -228,6 +241,23 @@ feedback: <https://help.github.com/articles/creating-and-highlighting-code-blocks>`_ for more details. +- Please be explicit **how this issue impacts you as a scikit-learn user**. Giving + some details (a short paragraph) about how you use scikit-learn and why you need + this issue resolved will help the project maintainers invest time and effort + on issues that actually impact users. + +- Please tell us if you would be interested in opening a PR to resolve your issue + once triaged by a project maintainer. + +Note that the scikit-learn tracker receives `daily reports +<https://github.com/scikit-learn/scikit-learn/issues?q=label%3Aspam>`_ by +GitHub accounts that are mostly interested in increasing contribution +statistics and show little interest in the expected end-user impact of their +contributions. As project maintainers we want to be able to assess if our +efforts are likely to have a meaningful and positive impact to our end users. +Therefore, we ask you to avoid opening issues for things you don't actually +care about. + If you want to help curate issues, read about :ref:`bug_triaging`. Contributing code and documentation @@ -345,7 +375,7 @@ line .. topic:: Learning Git The `Git documentation <https://git-scm.com/doc>`_ and - http://try.github.io are excellent resources to get started with git, + https://try.github.io are excellent resources to get started with git, and understanding all of the commands shown here. .. _pr_checklist: @@ -374,7 +404,25 @@ complies with the following rules before marking a PR as "ready for review". The cases "Fix <ISSUE TITLE>" is enough. "Fix #<ISSUE NUMBER>" is never a good title. -2. **Make sure your code passes the tests**. The whole test suite can be run +2. **Pull requests are expected to resolve one or more issues**. + Please **do not open PRs for issues that are labeled as "Needs triage"** + (see :ref:`issues_tagged_needs_triage`) or with other kinds of "Needs ..." + labels. Please do not open PRs for issues for which: + + - the discussion has not settled down to an explicit resolution plan, + - the reporter has already expressed interest in opening a PR, + - there already exists cross-referenced and active PRs. + + If merging your pull request means that some other issues/PRs should be closed, + you should `use keywords to create link to them + <https://github.com/blog/1506-closing-issues-via-pull-requests/>`_ + (e.g., ``Fixes #1234``; multiple issues/PRs are allowed as long as each + one is preceded by a keyword). Upon merging, those issues/PRs will + automatically be closed by GitHub. If your pull request is simply + related to some other issues/PRs, or it only partially resolves the target + issue, create a link to them without using the keywords (e.g., ``Towards #1234``). + +3. **Make sure your code passes the tests**. The whole test suite can be run with `pytest`, but it is usually not recommended since it takes a long time. It is often enough to only run the test related to your changes: for example, if you changed something in @@ -397,12 +445,12 @@ complies with the following rules before marking a PR as "ready for review". The you don't need to run the whole test suite locally. For guidelines on how to use ``pytest`` efficiently, see the :ref:`pytest_tips`. -3. **Make sure your code is properly commented and documented**, and **make +4. **Make sure your code is properly commented and documented**, and **make sure the documentation renders properly**. To build the documentation, please refer to our :ref:`contribute_documentation` guidelines. The CI will also build the docs: please refer to :ref:`generated_doc_CI`. -4. **Tests are necessary for enhancements to be +5. **Tests are necessary for enhancements to be accepted**. Bug-fixes or new features should be provided with non-regression tests. These tests verify the correct behavior of the fix or feature. In this manner, further modifications on the code base are granted to be consistent with the @@ -410,27 +458,17 @@ complies with the following rules before marking a PR as "ready for review". The non-regression tests should fail for the code base in the ``main`` branch and pass for the PR code. -5. If your PR is likely to affect users, you need to add a changelog entry describing +6. If your PR is likely to affect users, you need to add a changelog entry describing your PR changes. See the `README <https://github.com/scikit-learn/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md>`_ for more details. -6. Follow the :ref:`coding-guidelines`. +7. Follow the :ref:`coding-guidelines`. -7. When applicable, use the validation tools and scripts in the :mod:`sklearn.utils` +8. When applicable, use the validation tools and scripts in the :mod:`sklearn.utils` module. A list of utility routines available for developers can be found in the :ref:`developers-utils` page. -8. Often pull requests resolve one or more other issues (or pull requests). - If merging your pull request means that some other issues/PRs should - be closed, you should `use keywords to create link to them - <https://github.com/blog/1506-closing-issues-via-pull-requests/>`_ - (e.g., ``Fixes #1234``; multiple issues/PRs are allowed as long as each - one is preceded by a keyword). Upon merging, those issues/PRs will - automatically be closed by GitHub. If your pull request is simply - related to some other issues/PRs, or it only partially resolves the target - issue, create a link to them without using the keywords (e.g., ``Towards #1234``). - 9. PRs should often substantiate the change, through benchmarks of performance and efficiency (see :ref:`monitoring_performances`) or through examples of usage. Examples also illustrate the features and intricacies of @@ -495,7 +533,7 @@ profiling and Cython optimizations. For two very well documented and more detailed guides on development workflow, please pay a visit to the `Scipy Development Workflow - <http://scipy.github.io/devdocs/dev/dev_quickstart.html>`_ - + <https://scipy.github.io/devdocs/dev/dev_quickstart.html>`_ - and the `Astropy Workflow for Developers <https://astropy.readthedocs.io/en/latest/development/workflow/development_workflow.html>`_ sections. @@ -503,11 +541,10 @@ profiling and Cython optimizations. Continuous Integration (CI) --------------------------- -* Azure pipelines are used for testing scikit-learn on Linux, Mac and Windows, - with different dependencies and settings. +* Github Actions are used for various tasks, including testing scikit-learn on + Linux, Mac and Windows, with different dependencies and settings, building + wheels and source distributions. * CircleCI is used to build the docs for viewing. -* Github Actions are used for various tasks, including building wheels and - source distributions. .. _commit_markers: @@ -522,11 +559,9 @@ Commit Message Marker Action Taken by CI ====================== =================== [ci skip] CI is skipped completely [cd build] CD is run (wheels and source distribution are built) -[lint skip] Azure pipeline skips linting [scipy-dev] Build & test with our dependencies (numpy, scipy, etc.) development builds [free-threaded] Build & test with CPython 3.14 free-threaded [pyodide] Build & test with Pyodide -[azure parallel] Run Azure CI jobs in parallel [float32] Run float32 tests by setting `SKLEARN_RUN_FLOAT32_TESTS=1`. See :ref:`environment_variable` for more details [all random seeds] Run tests using the `global_random_seed` fixture with all random seeds. See `this <https://github.com/scikit-learn/scikit-learn/issues/28959>`_ @@ -798,7 +833,7 @@ additions in the following areas: using the `.. rubric:: Note` directive. * Add one or two **snippets** of code in "Example" section to show how it can - be used. The code should be runable as is, i.e. it should include all + be used. The code should be runnable as is, i.e. it should include all required imports. Keep this section as brief as possible. diff --git a/doc/developers/cython.rst b/doc/developers/cython.rst index c1f371dd8a8da..1732525a495f2 100644 --- a/doc/developers/cython.rst +++ b/doc/developers/cython.rst @@ -66,7 +66,7 @@ Tips to ease development # This generates `source.c` as if you had recompiled scikit-learn entirely. cythonX --annotate source.pyx -* Using the ``--annotate`` option with this flag allows generating a HTML report of code annotation. +* Using the ``--annotate`` option with this flag allows generating an HTML report of code annotation. This report indicates interactions with the CPython interpreter on a line-by-line basis. Interactions with the CPython interpreter must be avoided as much as possible in the computationally intensive sections of the algorithms. @@ -74,7 +74,7 @@ Tips to ease development .. code-block:: - # This generates a HTML report (`source.html`) for `source.c`. + # This generates an HTML report (`source.html`) for `source.c`. cythonX --annotate source.pyx Tips for performance diff --git a/doc/developers/develop.rst b/doc/developers/develop.rst index 4b19fbabecd55..a8215a5a978b5 100644 --- a/doc/developers/develop.rst +++ b/doc/developers/develop.rst @@ -583,8 +583,9 @@ keyword arguments to its super class. Super classes' `__init_subclass__` should For transformers that return multiple arrays in `transform`, auto wrapping will only wrap the first array and not alter the other arrays. -See :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` -for an example on how to use the API. +Refer to the :ref:`user guide <df_output_transform>` for more details +and :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` for an +example on how to use the API. .. _developer_api_check_is_fitted: diff --git a/doc/developers/development_setup.rst b/doc/developers/development_setup.rst index 28f7eb70ad050..1f36a8795760c 100644 --- a/doc/developers/development_setup.rst +++ b/doc/developers/development_setup.rst @@ -128,8 +128,8 @@ the required packages. .. prompt:: conda create -n sklearn-dev -c conda-forge ^ - python numpy scipy cython meson-python ninja ^ - pytest pytest-cov ruff==0.11.2 mypy numpydoc ^ + python numpy scipy narwhals cython meson-python ninja ^ + pytest pytest-cov ruff==0.12.2 mypy numpydoc ^ joblib threadpoolctl pre-commit Activate the newly created conda environment: @@ -167,7 +167,7 @@ the required packages. .. prompt:: pip install wheel numpy scipy cython meson-python ninja ^ - pytest pytest-cov ruff==0.11.2 mypy numpydoc ^ + pytest pytest-cov ruff==0.12.2 mypy numpydoc ^ joblib threadpoolctl pre-commit @@ -199,7 +199,7 @@ the required packages. conda create -n sklearn-dev -c conda-forge python \ numpy scipy cython meson-python ninja \ - pytest pytest-cov ruff==0.11.2 mypy numpydoc \ + pytest pytest-cov ruff==0.12.2 mypy numpydoc \ joblib threadpoolctl compilers llvm-openmp pre-commit and activate the newly created conda environment: @@ -244,7 +244,7 @@ the required packages. .. prompt:: pip install wheel numpy scipy cython meson-python ninja \ - pytest pytest-cov ruff==0.11.2 mypy numpydoc \ + pytest pytest-cov ruff==0.12.2 mypy numpydoc \ joblib threadpoolctl pre-commit .. tab-item:: Linux @@ -267,7 +267,7 @@ the required packages. conda create -n sklearn-dev -c conda-forge python \ numpy scipy cython meson-python ninja \ - pytest pytest-cov ruff==0.11.2 mypy numpydoc \ + pytest pytest-cov ruff==0.12.2 mypy numpydoc \ joblib threadpoolctl compilers pre-commit and activate the newly created environment: @@ -327,7 +327,7 @@ the required packages. .. prompt:: pip install wheel numpy scipy cython meson-python ninja \ - pytest pytest-cov ruff==0.11.2 mypy numpydoc \ + pytest pytest-cov ruff==0.12.2 mypy numpydoc \ joblib threadpoolctl pre-commit diff --git a/doc/developers/maintainer.rst.template b/doc/developers/maintainer.rst.template index 5a6e28d5b63fd..0c1c41424bb67 100644 --- a/doc/developers/maintainer.rst.template +++ b/doc/developers/maintainer.rst.template @@ -401,7 +401,7 @@ Guideline for bumping minimum versions of our dependencies release (`X.Y.0`) that has wheels for our minimum Python version. In practice this means that our minimum supported version is around 3 years old, maybe a bit less. -- **pure Python dependencies** (joblib, threadpoolctl): at the time of the +- **pure Python dependencies** (joblib, narwhals, threadpoolctl): at the time of the scikit-learn release our minimum supported version is the most recent minor release (`X.Y.0`) that is at least 2 years old. - we may decide to be less conservative than this guideline in some edge cases. diff --git a/doc/developers/minimal_reproducer.rst b/doc/developers/minimal_reproducer.rst index 147efd8d71a06..11ea0d886c74e 100644 --- a/doc/developers/minimal_reproducer.rst +++ b/doc/developers/minimal_reproducer.rst @@ -34,13 +34,13 @@ In this section we will focus on the **Steps/Code to Reproduce** section of the `Issue template <https://github.com/scikit-learn/scikit-learn/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml>`_. We will start with a snippet of code that already provides a failing example but -that has room for readability improvement. We then craft a MCVE from it. +that has room for readability improvement. We then craft an MCVE from it. **Example** .. code-block:: python - # I am currently working in a ML project and when I tried to fit a + # I am currently working in an ML project and when I tried to fit a # GradientBoostingRegressor instance to my_data.csv I get a UserWarning: # "X has feature names, but DecisionTreeRegressor was fitted without # feature names". You can get a copy of my dataset from diff --git a/doc/developers/performance.rst b/doc/developers/performance.rst index ae2dc9cf7ce9e..89c410fbec6c3 100644 --- a/doc/developers/performance.rst +++ b/doc/developers/performance.rst @@ -311,7 +311,7 @@ standalone function in a ``.pyx`` file, add static type declarations and then use Cython to generate a C program suitable to be compiled as a Python extension module. -The `Cython's documentation <http://docs.cython.org/>`_ contains a tutorial and +The `Cython's documentation <https://docs.cython.org/>`_ contains a tutorial and reference guide for developing such a module. For more information about developing in Cython for scikit-learn, see :ref:`cython`. diff --git a/doc/developers/tips.rst b/doc/developers/tips.rst index e4f67a08a08c8..52c8ad682572b 100644 --- a/doc/developers/tips.rst +++ b/doc/developers/tips.rst @@ -339,14 +339,14 @@ tutorials and documentation on the `valgrind web site <https://valgrind.org>`_. .. _arm64_dev_env: -Building and testing for the ARM64 platform on a x86_64 machine -=============================================================== +Building and testing for the ARM64 platform on an x86_64 machine +================================================================ ARM-based machines are a popular target for mobile, edge or other low-energy deployments (including in the cloud, for instance on Scaleway or AWS Graviton). Here are instructions to setup a local dev environment to reproduce -ARM-specific bugs or test failures on a x86_64 host laptop or workstation. This +ARM-specific bugs or test failures on an x86_64 host laptop or workstation. This is based on QEMU user mode emulation using docker for convenience (see https://github.com/multiarch/qemu-user-static). diff --git a/doc/dispatching.rst b/doc/dispatching.rst deleted file mode 100644 index 101e493ee96b7..0000000000000 --- a/doc/dispatching.rst +++ /dev/null @@ -1,8 +0,0 @@ -=========== -Dispatching -=========== - -.. toctree:: - :maxdepth: 2 - - modules/array_api diff --git a/doc/faq.rst b/doc/faq.rst index bcf4b6145b2fb..7aa5558db528e 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -78,6 +78,9 @@ can be used via the `BSD 3-Clause License your work. Citations of scikit-learn are highly encouraged and appreciated. See :ref:`citing scikit-learn <citing-scikit-learn>`. +However, the scikit-learn logo is subject to some terms and conditions. +See :ref:`branding-and-logos`. + Implementation decisions ------------------------ @@ -193,8 +196,7 @@ Does scikit-learn work natively with various types of dataframes? Scikit-learn has limited support for :class:`pandas.DataFrame` and :class:`polars.DataFrame`. Scikit-learn estimators can accept both these dataframe types as input, and scikit-learn transformers can output dataframes using the `set_output` -API. For more details, refer to -:ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py`. +API. For more details, refer to :ref:`df_output_transform`. However, the internal computations in scikit-learn estimators rely on numerical operations that are more efficiently performed on homogeneous data structures such as @@ -300,6 +302,33 @@ reviewers are busy. We ask for your understanding and request that you not close your pull request or discontinue your work solely because of this reason. +For tips on how to make your pull request easier to review and more likely to be +reviewed quickly, see :ref:`improve_issue_pr`. + +.. _improve_issue_pr: + +How do I improve my issue or pull request? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To help your issue receive attention or improve the likelihood of your pull request +being reviewed, you can try: + +* follow our :ref:`contribution guidelines <contributing>`, in particular + :ref:`automated_contributions_policy`, :ref:`filing_bugs`, + :ref:`stalled_pull_request` and :ref:`stalled_unclaimed_issues`, +* complete all sections of the issue or pull request template provided by GitHub, + including a clear description of the issue or motivation and thought process behind + the pull request +* ensure the title clearly describes the issue or pull request and does not include + an issue number. + +For your pull requests specifically, the following will make it easier to review: + +* ensure your PR addresses an issue for which there is clear consensus on the solution + (see :ref:`issues_tagged_needs_triage`), +* ensure the PR satisfies all items in the :ref:`Pull request checklist <pr_checklist>`, +* ensure the changes are minimal and directly relevant to the described issue. + What does the "spam" label for issues or pull requests mean? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -313,19 +342,9 @@ is final. A common reason for this happening is when people open a PR for an issue that is still under discussion. Please wait for the discussion to converge before opening a PR. -If your issue or PR was labeled as spam and not closed the following steps -can increase the chances of the label being removed: - -- follow the :ref:`contribution guidelines <contributing>` and use the provided - issue and pull request templates -- improve the formatting and grammar of the text of the title and description of the issue/PR -- improve the diff to remove noise and unrelated changes -- improve the issue or pull request title to be more descriptive -- self review your code, especially if :ref:`you used AI tools to generate it <automated_contributions_policy>` -- refrain from opening PRs that paraphrase existing code or documentation - without actually improving the correctness, clarity or educational - value of the existing code or documentation. - +If your issue or PR was labeled as spam and not closed, see :ref:`improve_issue_pr` +for tips on improving your issue or pull request and increasing the likelihood +of the label being removed. .. _new_algorithms_inclusion_criteria: diff --git a/doc/glossary.rst b/doc/glossary.rst index 9ff1eb001c8e5..6dfffadd83656 100644 --- a/doc/glossary.rst +++ b/doc/glossary.rst @@ -63,6 +63,12 @@ General Concepts * a :class:`pandas.DataFrame` with all columns numeric * a numeric :class:`pandas.Series` + Other array API inputs, but see :ref:`array_api` for the preferred way of + using these: + + * a `PyTorch <https://pytorch.org/>`_ tensor on 'cpu' device + * a `JAX <https://docs.jax.dev/en/latest/index.html>`_ array + It excludes: * a :term:`sparse matrix` @@ -225,7 +231,7 @@ General Concepts cross validation A resampling method that iteratively partitions data into mutually exclusive 'train' and 'test' subsets so model performance can be - evaluated on unseen data. This conserves data as avoids the need to hold + evaluated on unseen data. This conserves data as it avoids the need to hold out a 'validation' dataset and accounts for variability as multiple rounds of cross validation are generally performed. See :ref:`User Guide <cross_validation>` for more details. @@ -512,14 +518,18 @@ General Concepts :term:`memory mapping`. See :ref:`parallelism` for more information. + label indicator format label indicator matrix multilabel indicator matrix multilabel indicator matrices - The format used to represent multilabel data, where each row of a 2d - array or sparse matrix corresponds to a sample, each column + This format can be used to represent binary or multilabel data. Each row of + a 2d array or sparse matrix corresponds to a sample, each column corresponds to a class, and each element is 1 if the sample is labeled with the class and 0 if not. + :ref:`LabelBinarizer <preprocessing_targets>` can be used to create a + multilabel indicator matrix from :term:`multiclass` labels. + leakage data leakage A problem in cross validation where generalization performance can be @@ -582,6 +592,26 @@ General Concepts import numpy as np + ovo + One-vs-one + one-vs-one + Method of decomposing a :term:`multiclass` problem into + `n_classes * (n_classes - 1) / 2` :term:`binary` problems, one for each + pairwise combination of classes. A metric is computed or a classifier is + fitted for each pair combination. + :class:`~sklearn.multiclass.OneVsOneClassifier` implements this + method for binary classifiers. + + ovr + One-vs-Rest + one-vs-rest + Method for decomposing a :term:`multiclass` problem into `n_classes` + :term:`binary` problems. For each class a metric is computed or classifier + fitted, with that class being treated as the positive class while all other + classes are negative. + :class:`~sklearn.multiclass.OneVsRestClassifier` implements this + method for binary classifiers. + online learning Where a model is iteratively updated by receiving each batch of ground truth :term:`targets` soon after making predictions on corresponding diff --git a/doc/governance.rst b/doc/governance.rst index cbe35c0ebe0a4..e0bc1a3503710 100644 --- a/doc/governance.rst +++ b/doc/governance.rst @@ -98,7 +98,7 @@ The following teams form the core contributors group: care. Being a maintainer allows contributors to more easily carry on with their project related activities by giving them direct access to the project's repository. Maintainers are expected to review code contributions, merge - approved pull requests, cast votes for and against merging a pull-request, + approved pull requests, cast votes for and against merging a pull request, and to be involved in deciding major changes to the API. Technical Committee @@ -156,11 +156,11 @@ are made according to the following rules: * **Code changes and major documentation changes** require +1 by two core contributors, no -1 by a core contributor (lazy - consensus), happens on the issue of pull-request page. + consensus), happens on the issue or pull request page. * **Changes to the API principles and changes to dependencies or supported versions** follow the decision-making process outlined above. In particular - changes to API principles are backed via a :ref:`slep`. Smaller decisions + changes to API principles are backed via :ref:`slep`. Smaller decisions like supported versions can happen on a GitHub issue or pull request. * **Changes to the governance model** follow the process outlined in `SLEP020 @@ -173,15 +173,15 @@ the decision making procedure outlined above. Governance Model Changes ------------------------ -Governance model changes occur through an enhancement proposal or a GitHub Pull -Request. An enhancement proposal will go through "**the decision-making process**" +Governance model changes occur through an enhancement proposal or a GitHub pull +request. An enhancement proposal will go through "**the decision-making process**" described in the previous section. Alternatively, an author may propose a change -directly to the governance model with a GitHub Pull Request. Logistically, an -author can open a Draft Pull Request for feedback and follow up with a new -revised Pull Request for voting. Once that author is happy with the state of the -Pull Request, they can call for a vote on the public mailing list. During the -one-month voting period, the Pull Request can not change. A Pull Request -Approval will count as a positive vote, and a "Request Changes" review will +directly to the governance model with a GitHub pull request. Logistically, an +author can open a draft pull request for feedback and follow up with a new +revised pull request for voting. Once that author is happy with the state of the +pull request, they can call for a vote on the public mailing list. During the +one-month voting period, the pull request can not change. A pull request +approval will count as a positive vote, and a "Request Changes" review will count as a negative vote. If two-thirds of the cast votes are positive, then the governance model change is accepted. @@ -192,7 +192,7 @@ Enhancement proposals (SLEPs) For all votes, a proposal must have been made public and discussed before the vote. Such proposal must be a consolidated document, in the form of a "Scikit-Learn Enhancement Proposal" (SLEP), rather than a long discussion on an -issue. A SLEP must be submitted as a pull-request to `enhancement proposals +issue. A SLEP must be submitted as a pull request to `enhancement proposals <https://scikit-learn-enhancement-proposals.readthedocs.io>`_ using the `SLEP template <https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep_template.html>`_. diff --git a/doc/images/bnp-paribas-small.png b/doc/images/bnp-paribas-small.png new file mode 100644 index 0000000000000..158d30819b14d Binary files /dev/null and b/doc/images/bnp-paribas-small.png differ diff --git a/doc/images/bnp-paribas.jpg b/doc/images/bnp-paribas.jpg deleted file mode 100644 index e9fea64acbce6..0000000000000 Binary files a/doc/images/bnp-paribas.jpg and /dev/null differ diff --git a/doc/images/bnp-paribas.png b/doc/images/bnp-paribas.png new file mode 100644 index 0000000000000..fa4f25327d689 Binary files /dev/null and b/doc/images/bnp-paribas.png differ diff --git a/doc/images/michelin-small.png b/doc/images/michelin-small.png new file mode 100644 index 0000000000000..3faabdc8b2cf3 Binary files /dev/null and b/doc/images/michelin-small.png differ diff --git a/doc/images/michelin.png b/doc/images/michelin.png new file mode 100644 index 0000000000000..20f9d8b77ab1d Binary files /dev/null and b/doc/images/michelin.png differ diff --git a/doc/images/nasa-small.png b/doc/images/nasa-small.png new file mode 100644 index 0000000000000..ef5ba6a592e92 Binary files /dev/null and b/doc/images/nasa-small.png differ diff --git a/doc/images/nasa.png b/doc/images/nasa.png new file mode 100644 index 0000000000000..1ca3d2c4529e0 Binary files /dev/null and b/doc/images/nasa.png differ diff --git a/doc/index.rst.template b/doc/index.rst.template index f1f1f49836515..fbfd73cf10d9d 100644 --- a/doc/index.rst.template +++ b/doc/index.rst.template @@ -21,4 +21,5 @@ related_projects roadmap Governance <governance> + institutional_support about diff --git a/doc/install.rst b/doc/install.rst index 7d03be12cf42c..e8832660d2343 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -295,7 +295,7 @@ It can be installed using ``dnf``: NetBSD ------ -scikit-learn is available via `pkgsrc-wip <http://pkgsrc-wip.sourceforge.net/>`_: +scikit-learn is available via `pkgsrc-wip <https://pkgsrc-wip.sourceforge.net/>`_: https://pkgsrc.se/math/py-scikit-learn diff --git a/doc/institutional_support.rst b/doc/institutional_support.rst new file mode 100644 index 0000000000000..ad84c4cc29ddc --- /dev/null +++ b/doc/institutional_support.rst @@ -0,0 +1,281 @@ +.. _funding: + +Institutional support +===================== + +Scikit-learn is a community driven project. However, a number of public +institutions and private entities have contributed and keep on contributing to +its success and sustainability. + +.. div:: sk-text-image-grid-small + + .. div:: image-box + + .. image:: images/inria-logo.jpg + :target: https://www.inria.fr + + .. div:: text-box + + Since the inception of scikit-learn and for a good decade, `Inria + <https://www.inria.fr>`_ has been its main supporting pillar, as the stable + employer of many core-maintainers and as the host for the scikit-learn + consortium. + +.. div:: sk-text-image-grid-small + + .. div:: image-box + + .. image:: images/probabl.png + :target: https://probabl.ai + + .. div:: text-box + + In 2023, Inria spun off `Probabl <https://probabl.ai>`_ as a mission driven + company to take scikit-learn beyond a research lab. All of the + scikit-learn core-maintainers employed by Inria have joined the spinoff as + co-founders, most as full-time employees. + + Today, Probabl employs the following core and non-core contributors: Adrin + Jalali, Antoine Baker, Arturo Amor, Franรงois Goupil, Guillaume Lemaitre, + Jรฉrรฉmie du Boisberranger, Loรฏc Estรจve, Olivier Grisel, Shruti Nath and + Stefanie Senger, as well as Gaรซl Varoquaux. + +The above financial commitments mean that Inria initially and now Probabl have +been and are the main source of financial support for scikit-learn, completed +by additional commitments detailed below. Cumulatively over a decade, these +represent several millions of euros or dollars worth of financial +participation. + + +Active financial participation (2026) +------------------------------------- + +In addition to the above financial commitments, the following organizations +financially support scikit-learn as follows: + +.. |probabl| image:: images/probabl.png + :target: https://probabl.ai + +.. |wellcome| image:: images/wellcome-trust-small.png + :target: https://wellcome.org + +.. |czi| image:: images/czi-small.png + :target: https://chanzuckerberg.com + +.. |nvidia| image:: images/nvidia-small.png + :target: https://www.nvidia.com + +.. |nasa| image:: images/nasa-small.png + :target: https://www.nasa.gov + +.. |chanel| image:: images/chanel-small.png + :target: https://www.chanel.com + +.. |bnpparibasgroup| image:: images/bnp-paribas-small.png + :target: https://group.bnpparibas/ + +.. |quansightlabs| image:: images/quansight-labs-small.png + :target: https://labs.quansight.org + +.. |michelin| image:: images/michelin-small.png + :target: https://www.michelin.com + +.. list-table:: + :widths: 33 33 34 + :header-rows: 1 + :class: sk-funding-participation-table + + * - 5 FTE or more + - 0.5 FTE or more + - less than 0.5 FTE + * - |probabl| + - * |czi| |wellcome| + * |nvidia| + * |nasa| |quansightlabs| + * |chanel| + - * |bnpparibasgroup| + * |michelin| + +FTE stands for Full-Time Equivalent. + +* `The Chan-Zuckerberg Initiative <https://chanzuckerberg.com/>`_ and `Wellcome + Trust <https://wellcome.org/>`_ support the work of Lucy Liu, Dea Maria Leon, + Anne Beyer and Francois Paugam through the `Essential Open Source Software + for Science (EOSS) <https://chanzuckerberg.com/eoss/>`_ cycle 6. + +* `NVIDIA <https://nvidia.com>`_ supports scikit-learn through their + sponsorship and employs full-time core maintainer Tim Head. + +* `NASA <https://www.nasa.gov>`_ supports work done by Quansight Labs and + Probabl team members via the NASA ROSES grant 80NSSC25K7215: "Ensuring a fast + and secure core for scientific Python". + +* `Quansight Labs <https://labs.quansight.org>`_ funds Lucy Liu since 2022. + +* `Chanel <https://www.chanel.com>`_ supports scikit-learn through a + multi-year sponsorship, initially through the Inria foundation (2023-2024), + now as a Gold Sponsor via Probabl (2024-2026). + +* `BNP Paribas Group <https://group.bnpparibas/>`_ supports scikit-learn + as a Silver Sponsor via Probabl (2025-2026). + +* `Michelin <https://www.michelin.com>`_ supports scikit-learn as a Bronze + Sponsor via Probabl (2025-2026). + +Past Sponsors +------------- + +`Microsoft <https://microsoft.com/>`_ funded Andreas Mรผller from 2020 to 2026. + +`APHP <https://aphp.fr/>`_, `AXA <https://www.axa.fr/>`_, `BCG +<https://www.bcg.com/>`_, `BNP-Paribas-Cardiff +<https://www.bnpparibascardif.com/>`_, `Dataiku <https://www.dataiku.com/>`_, +`Fujitsu <https://www.fujitsu.com/>`_, `Hugging Face +<https://huggingface.co/>`_, `Intel <https://www.intel.com/>`_, `Microsoft +<https://microsoft.com/>`_, `NVIDIA <https://nvidia.com>`_ supported the +project through their sponsorship via the Inria foundation between 2020 and +2024. + +`Tidelift <https://tidelift.com/>`_ financially supported the project via their +service agreement from 2023 to 2025. + +`Quansight Labs <https://labs.quansight.org>`_ and `NASA +<https://www.nasa.gov>`_ funded Meekail Zain in 2022 and 2023, and funded +Thomas J. Fan from 2021 to 2023 via the NASA ROSES grant 80NSSC22K0405: +"Reinforcing the Foundations of Scientific Python". + +`Columbia University <https://columbia.edu/>`_ funded Andreas Mรผller +(2016-2020). + +`The University of Sydney <https://sydney.edu.au/>`_ funded Joel Nothman +(2017-2021). + +Andreas Mรผller received a grant to improve scikit-learn from the +`Alfred P. Sloan Foundation <https://sloan.org>`_ . +This grant supported the position of Nicolas Hug and Thomas J. Fan. + +`INRIA <https://www.inria.fr>`_ has provided funding for Fabian Pedregosa +(2010-2012), Jaques Grobler (2012-2013) and Olivier Grisel (2013-2017) to +work on this project full-time. It also hosts coding sprints and other events. + +`Paris-Saclay Center for Data Science <http://www.datascience-paris-saclay.fr/>`_ +funded one year for a developer to work on the project full-time (2014-2015), 50% +of the time of Guillaume Lemaitre (2016-2017) and 50% of the time of Joris van den +Bossche (2017-2018). + +`NYU Moore-Sloan Data Science Environment <https://cds.nyu.edu/mooresloan/>`_ +funded Andreas Mueller (2014-2016) to work on this project. The Moore-Sloan +Data Science Environment also funds several students to work on the project +part-time. + +`Tรฉlรฉcom Paristech <https://www.telecom-paristech.fr/>`_ funded Manoj Kumar +(2014), Tom Duprรฉ la Tour (2015), Raghav RV (2015-2017), Thierry Guillemot +(2016-2017) and Albert Thomas (2017) to work on scikit-learn. + +`The Labex DigiCosme <https://digicosme.lri.fr>`_ funded Nicolas Goix +(2015-2016), Tom Duprรฉ la Tour (2015-2016 and 2017-2018), Mathurin Massias +(2018-2019) to work part time on scikit-learn during their PhDs. It also +funded a scikit-learn coding sprint in 2015. + +`The Chan-Zuckerberg Initiative <https://chanzuckerberg.com/>`_ funded Nicolas +Hug to work full-time on scikit-learn in 2020. + +The following students were sponsored by `Google +<https://opensource.google/>`_ to work on scikit-learn through +the `Google Summer of Code <https://en.wikipedia.org/wiki/Google_Summer_of_Code>`_ +program. + +- 2007 - David Cournapeau +- 2011 - `Vlad Niculae`_ +- 2012 - `Vlad Niculae`_, Immanuel Bayer +- 2013 - Kemal Eren, Nicolas Trรฉsegnie +- 2014 - Hamzeh Alsalhi, Issam Laradji, Maheshakya Wijewardena, Manoj Kumar +- 2015 - `Raghav RV <https://github.com/raghavrv>`_, Wei Xue +- 2016 - `Nelson Liu <https://nelsonliu.me>`_, `YenChen Lin <https://yenchenlin.me/>`_ + +.. _Vlad Niculae: https://vene.ro/ + +................... + +The `NeuroDebian <https://neuro.debian.net>`_ project providing `Debian +<https://www.debian.org/>`_ packaging and contributions is supported by +`Dr. James V. Haxby <http://haxbylab.dartmouth.edu/>`_ (`Dartmouth +College <https://pbs.dartmouth.edu/>`_). + + +Donating to the project +----------------------- + +If you have found scikit-learn to be useful in your work, research, or company, +please consider making a donation to the project commensurate with your resources. +There are several options for making donations: + +.. raw:: html + + <p class="text-center"> + <a class="btn sk-btn-orange mb-1" href="https://numfocus.org/donate-to-scikit-learn"> + Donate via NumFOCUS + </a> + <a class="btn sk-btn-orange mb-1" href="https://github.com/sponsors/scikit-learn"> + Donate via GitHub Sponsors + </a> + <a class="btn sk-btn-orange mb-1" href="https://causes.benevity.org/projects/433725"> + Donate via Benevity + </a> + </p> + +**Donation Options:** + +* **NumFOCUS**: Donate via the `NumFOCUS Donations Page + <https://numfocus.org/donate-to-scikit-learn>`_, scikit-learn's fiscal sponsor. + +* **GitHub Sponsors**: Support the project directly through `GitHub Sponsors + <https://github.com/sponsors/scikit-learn>`_. + +* **Benevity**: If your company uses scikit-learn, you can also support the + project through Benevity, a platform to manage employee donations. It is + widely used by hundreds of Fortune 1000 companies to streamline and scale + their social impact initiatives. If your company uses Benevity, you are + able to make a donation with a company match as high as 100%. Our project + ID is `433725 <https://causes.benevity.org/projects/433725>`_. + +All above donation options are managed by `NumFOCUS <https://numfocus.org/>`_, +a 501(c)(3) non-profit organization based in Austin, Texas, USA. The NumFOCUS +board consists of `SciPy community members <https://numfocus.org/board.html>`_. +Contributions are tax-deductible to the extent allowed by law. + +.. rubric:: Notes + +Contributions support the maintenance of the project, including development, +documentation, infrastructure and coding sprints. + + +Donations in kind +================= +The following organizations provide non-financial contributions to the +scikit-learn project. + +.. raw:: html + + <table cellspacing="0" cellpadding="8"> + <thead> + <tr> + <th>Company</th> + <th>Contribution</th> + </tr> + </thead> + <tbody> + <tr> + <td><a href="https://www.github.com">GitHub</a></td> + <td>CPU time on their Continuous Integration servers + Teams account and web hosting.</td> + </tr> + <tr> + <td><a href="https://circleci.com/">CircleCI</a></td> + <td>CPU time on their Continuous Integration servers</td> + </tr> + <tr> + <td><a href="https://www.anaconda.com">Anaconda Inc</a></td> + <td>Storage for our staging and nightly builds</td> + </tr> + </tbody> + </table> diff --git a/doc/jupyter-lite.json b/doc/jupyter-lite.json index 9ad29615decb6..63a4ad485b310 100644 --- a/doc/jupyter-lite.json +++ b/doc/jupyter-lite.json @@ -3,7 +3,7 @@ "jupyter-config-data": { "litePluginSettings": { "@jupyterlite/pyodide-kernel-extension:kernel": { - "pyodideUrl": "https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js" + "pyodideUrl": "https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js" } } } diff --git a/doc/maintainers_emeritus.rst b/doc/maintainers_emeritus.rst index 18edbfa90e3c6..04aef7fd0d7ac 100644 --- a/doc/maintainers_emeritus.rst +++ b/doc/maintainers_emeritus.rst @@ -40,4 +40,4 @@ - Nelle Varoquaux - David Warde-Farley - Ron Weiss -- Roman Yurchak \ No newline at end of file +- Roman Yurchak diff --git a/doc/make.bat b/doc/make.bat index 2a32bcb678f62..7d4b48ad1ed88 100644 --- a/doc/make.bat +++ b/doc/make.bat @@ -18,7 +18,7 @@ if "%1" == "help" ( echo. dirhtml to make HTML files named index.html in directories echo. pickle to make pickle files echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project + echo. htmlhelp to make HTML files and an HTML help project echo. qthelp to make HTML files and a qthelp project echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. changes to make an overview over all changed/added/deprecated items diff --git a/doc/metadata_routing.rst b/doc/metadata_routing.rst index 79e0dcc1bb362..20dd142ec1bbe 100644 --- a/doc/metadata_routing.rst +++ b/doc/metadata_routing.rst @@ -317,6 +317,7 @@ Meta-estimators and functions supporting metadata routing: - :class:`sklearn.multioutput.MultiOutputClassifier` - :class:`sklearn.multioutput.MultiOutputRegressor` - :class:`sklearn.multioutput.RegressorChain` +- :class:`sklearn.preprocessing.TargetEncoder` - :class:`sklearn.pipeline.FeatureUnion` - :class:`sklearn.pipeline.Pipeline` - :class:`sklearn.semi_supervised.SelfTrainingClassifier` diff --git a/doc/model_persistence.rst b/doc/model_persistence.rst index 21d6934a48730..af1b455660562 100644 --- a/doc/model_persistence.rst +++ b/doc/model_persistence.rst @@ -149,7 +149,7 @@ facilitate the conversion of the data models between different machine learning frameworks, and to improve their portability on different computing architectures. More details are available from the `ONNX tutorial <https://onnx.ai/get-started.html>`__. To convert scikit-learn model to `ONNX` -`sklearn-onnx <http://onnx.ai/sklearn-onnx/>`__ has been developed. However, +`sklearn-onnx <https://onnx.ai/sklearn-onnx/>`__ has been developed. However, not all scikit-learn models are supported, and it is limited to the core scikit-learn and does not support most third party estimators. One can write a custom converter for third party or custom estimators, but the documentation to @@ -159,7 +159,7 @@ do that is sparse and it might be challenging to do so. To convert the model to `ONNX` format, you need to give the converter some information about the input as well, about which you can read more `here - <http://onnx.ai/sklearn-onnx/index.html>`__:: + <https://onnx.ai/sklearn-onnx/index.html>`__:: from skl2onnx import to_onnx onx = to_onnx(clf, X[:1].astype(numpy.float32), target_opset=12) diff --git a/doc/modules/array_api.rst b/doc/modules/array_api.rst index b9b46f99f3cae..eb29e0c3fd457 100644 --- a/doc/modules/array_api.rst +++ b/doc/modules/array_api.rst @@ -12,17 +12,6 @@ Scikit-learn vendors pinned copies of `array-api-compat <https://github.com/data-apis/array-api-compat>`__ and `array-api-extra <https://github.com/data-apis/array-api-extra>`__. -Scikit-learn's support for the array API standard requires the environment variable -`SCIPY_ARRAY_API` to be set to `1` before importing `scipy` and `scikit-learn`: - -.. prompt:: bash $ - - export SCIPY_ARRAY_API=1 - -Please note that this environment variable is intended for temporary use. -For more details, refer to SciPy's `Array API documentation -<https://docs.scipy.org/doc/scipy/dev/api-dev/array_api.html#using-array-api-standard-support>`_. - Some scikit-learn estimators that primarily rely on NumPy (as opposed to using Cython) to implement the algorithmic logic of their `fit`, `predict` or `transform` methods can be configured to accept any Array API compatible input @@ -42,15 +31,43 @@ and how it facilitates interoperability between array libraries: - `Scikit-learn on GPUs with Array API <https://www.youtube.com/watch?v=c_s8tr1AizA>`_ by :user:`Thomas Fan <thomasjpfan>` at PyData NYC 2023. -Example usage -============= +Enabling array API support +========================== The configuration `array_api_dispatch=True` needs to be set to `True` to enable array API support. We recommend setting this configuration globally to ensure consistent behaviour and prevent accidental mixing of array namespaces. -Note that we set it with :func:`config_context` below to avoid having to call -:func:`set_config(array_api_dispatch=False)` at the end of every code snippet -that uses the array API. +Note that in the examples below, we use a context manager (:func:`config_context`) +to avoid having to reset it to `False` at the end of every code snippet, so as to +not affect the rest of the documentation. + +Scikit-learn's support for the array API standard requires the environment variable +`SCIPY_ARRAY_API` to be set to `1` before importing `scipy` and `scikit-learn`: + +.. prompt:: bash $ + + export SCIPY_ARRAY_API=1 + +Please note that this environment variable is intended for temporary use. +For more details, refer to SciPy's `Array API documentation +<https://docs.scipy.org/doc/scipy/dev/api-dev/array_api.html#using-array-api-standard-support>`_. + +The array API functionality assumes that the latest versions of scikit-learn's dependencies are +installed. Older versions might work, but we make no promises. While array API support is marked +as experimental, backwards compatibility is not guaranteed. In particular, when a newer version +of a dependency fixes a bug we will not introduce additional code to backport the fix or +maintain compatibility with older versions. + +Scikit-learn accepts :term:`array-like` inputs for all :mod:`metrics` +and some estimators. When `array_api_dispatch=False`, these inputs are converted +into NumPy arrays using :func:`numpy.asarray` (or :func:`numpy.array`). +While this will successfully convert some array API inputs (e.g., JAX array), +we generally recommend setting `array_api_dispatch=True` when using array API inputs. +This is because NumPy conversion can often fail, e.g., torch tensor allocated on GPU. + +Example usage +============= + The example code snippet below demonstrates how to use `CuPy <https://cupy.dev/>`_ to run :class:`~discriminant_analysis.LinearDiscriminantAnalysis` on a GPU:: @@ -72,16 +89,26 @@ The example code snippet below demonstrates how to use `CuPy >>> X_trans.device <CUDA Device 0> -After the model is trained, fitted attributes that are arrays will also be -from the same Array API namespace as the training data. For example, if CuPy's -Array API namespace was used for training, then fitted attributes will be on the -GPU. We provide an experimental `_estimator_with_converted_arrays` utility that -transfers an estimator attributes from Array API to a ndarray:: +After the model is trained, fitted attributes that are arrays will also be from +the same Array API namespace as the training data. For example, if CuPy's Array +API namespace was used for training, then fitted attributes will be on the GPU. +Passing data in a different namespace or in a different device within the same +namespace to ``transform`` or ``predict`` is an error:: - >>> from sklearn.utils._array_api import _estimator_with_converted_arrays - >>> cupy_to_ndarray = lambda array : array.get() - >>> lda_np = _estimator_with_converted_arrays(lda, cupy_to_ndarray) - >>> X_trans = lda_np.transform(X_np) + >>> with config_context(array_api_dispatch=True): + ... lda.transform(X_np) + Traceback (most recent call last): + ... + ValueError: Inputs passed to LinearDiscriminantAnalysis.transform() must use the same namespace and the same device as those passed to fit()... + +We provide ``move_estimator_to`` to transfer an estimator's array attributes +to a different namespace and device:: + + >>> from sklearn.utils._array_api import move_estimator_to, get_namespace_and_device + >>> import numpy as np + >>> lda_np = move_estimator_to(lda, np, device="cpu") + >>> with config_context(array_api_dispatch=True): + ... X_trans = lda_np.transform(X_np) >>> type(X_trans) <class 'numpy.ndarray'> @@ -114,14 +141,18 @@ Estimators - :class:`decomposition.PCA` (with `svd_solver="full"`, `svd_solver="covariance_eigh"`, or `svd_solver="randomized"` (`svd_solver="randomized"` only if `power_iteration_normalizer="QR"`)) +- :class:`kernel_approximation.Nystroem` +- :class:`linear_model.LogisticRegression` (with `solver="lbfgs"`) +- :class:`linear_model.PoissonRegressor` (with `solver="lbfgs"`) - :class:`linear_model.Ridge` (with `solver="svd"`) -- :class:`linear_model.RidgeCV` (with `solver="svd"`, see :ref:`device_support_for_float64`) +- :class:`linear_model.RidgeCV` (see :ref:`device_support_for_float64`) - :class:`linear_model.RidgeClassifier` (with `solver="svd"`) -- :class:`linear_model.RidgeClassifierCV` (with `solver="svd"`, see :ref:`device_support_for_float64`) +- :class:`linear_model.RidgeClassifierCV` (see :ref:`device_support_for_float64`) - :class:`discriminant_analysis.LinearDiscriminantAnalysis` (with `solver="svd"`) - :class:`naive_bayes.GaussianNB` - :class:`preprocessing.Binarizer` - :class:`preprocessing.KernelCenterer` +- :class:`preprocessing.LabelBinarizer` (with `sparse_output=False`) - :class:`preprocessing.LabelEncoder` - :class:`preprocessing.MaxAbsScaler` - :class:`preprocessing.MinMaxScaler` @@ -138,6 +169,7 @@ Meta-estimators that accept Array API inputs conditioned on the fact that the base estimator also does: - :class:`calibration.CalibratedClassifierCV` (with `method="temperature"`) +- :class:`pipeline.FeatureUnion` - :class:`model_selection.GridSearchCV` - :class:`model_selection.RandomizedSearchCV` - :class:`model_selection.HalvingGridSearchCV` @@ -147,13 +179,16 @@ Metrics ------- - :func:`sklearn.metrics.accuracy_score` +- :func:`sklearn.metrics.average_precision_score` - :func:`sklearn.metrics.balanced_accuracy_score` - :func:`sklearn.metrics.brier_score_loss` -- :func:`sklearn.metrics.cluster.calinski_harabasz_score` +- :func:`sklearn.metrics.calinski_harabasz_score` - :func:`sklearn.metrics.cohen_kappa_score` - :func:`sklearn.metrics.confusion_matrix` +- :func:`sklearn.metrics.d2_absolute_error_score` - :func:`sklearn.metrics.d2_brier_score` - :func:`sklearn.metrics.d2_log_loss_score` +- :func:`sklearn.metrics.d2_pinball_score` - :func:`sklearn.metrics.d2_tweedie_score` - :func:`sklearn.metrics.det_curve` - :func:`sklearn.metrics.explained_variance_score` @@ -177,13 +212,15 @@ Metrics - :func:`sklearn.metrics.pairwise.chi2_kernel` - :func:`sklearn.metrics.pairwise.cosine_similarity` - :func:`sklearn.metrics.pairwise.cosine_distances` -- :func:`sklearn.metrics.pairwise.pairwise_distances` (only supports "cosine", "euclidean", "manhattan" and "l2" metrics) +- :func:`sklearn.metrics.pairwise_distances` (only supports "cosine", "euclidean", "manhattan" and "l2" metrics) +- :func:`sklearn.metrics.pairwise_distances_argmin` - :func:`sklearn.metrics.pairwise.euclidean_distances` (see :ref:`device_support_for_float64`) - :func:`sklearn.metrics.pairwise.laplacian_kernel` - :func:`sklearn.metrics.pairwise.linear_kernel` - :func:`sklearn.metrics.pairwise.manhattan_distances` - :func:`sklearn.metrics.pairwise.paired_cosine_distances` - :func:`sklearn.metrics.pairwise.paired_euclidean_distances` +- :func:`sklearn.metrics.pairwise.paired_manhattan_distances` - :func:`sklearn.metrics.pairwise.pairwise_kernels` - :func:`sklearn.metrics.pairwise.polynomial_kernel` - :func:`sklearn.metrics.pairwise.rbf_kernel` (see :ref:`device_support_for_float64`) @@ -201,6 +238,7 @@ Metrics Tools ----- +- :func:`preprocessing.label_binarize` (with `sparse_output=False`) - :func:`model_selection.cross_val_predict` - :func:`model_selection.train_test_split` - :func:`utils.check_consistent_length` diff --git a/doc/modules/calibration.rst b/doc/modules/calibration.rst index 0df94bb7b82e0..e920261d03d3f 100644 --- a/doc/modules/calibration.rst +++ b/doc/modules/calibration.rst @@ -36,8 +36,8 @@ good a classifier is calibrated. decomposition of Murphy [1]_. As it is not clear which term dominates, the score is of limited use for assessing calibration alone (unless one computes each term of the decomposition). A lower Brier loss, for instance, does not necessarily - mean a better calibrated model, it could also mean a worse calibrated model with much - more discriminatory power, e.g. using many more features. + mean a better calibrated model, it could also mean a worse calibrated model with + much more discriminatory power, e.g. using many more features. .. _calibration_curve: @@ -317,7 +317,7 @@ parameters for each single class. .. [1] Allan H. Murphy (1973). :doi:`"A New Vector Partition of the Probability Score" <10.1175/1520-0450(1973)012%3C0595:ANVPOT%3E2.0.CO;2>` - Journal of Applied Meteorology and Climatology + Journal of Applied Meteorology and Climatology, 12(4), 595-600 .. [2] `On the combination of forecast probabilities for consecutive precipitation periods. diff --git a/doc/modules/classification_threshold.rst b/doc/modules/classification_threshold.rst index 94a5e0a30b716..48f4d04c08f28 100644 --- a/doc/modules/classification_threshold.rst +++ b/doc/modules/classification_threshold.rst @@ -1,6 +1,6 @@ .. currentmodule:: sklearn.model_selection -.. _TunedThresholdClassifierCV: +.. _threshold_tuning: ================================================== Tuning the decision threshold for class prediction @@ -28,7 +28,7 @@ cut-off rules: a positive class is predicted when the conditional probability :math:`P(y|X)` is greater than 0.5 (obtained with :term:`predict_proba`) or if the decision score is greater than 0 (obtained with :term:`decision_function`). -Here, we show an example that illustrates the relatonship between conditional +Here, we show an example that illustrates the relationship between conditional probability estimates :math:`P(y|X)` and class labels:: >>> from sklearn.datasets import make_classification @@ -63,7 +63,7 @@ Post-tuning the decision threshold One solution to address the problem stated in the introduction is to tune the decision threshold of the classifier once the model has been trained. The -:class:`~sklearn.model_selection.TunedThresholdClassifierCV` tunes this threshold using +:class:`TunedThresholdClassifierCV` tunes this threshold using an internal cross-validation. The optimum threshold is chosen to maximize a given metric. @@ -136,6 +136,17 @@ The option `cv="prefit"` should only be used when the provided classifier was al trained, and you just want to find the best decision threshold using a new validation set. +.. _metric_at_thresholds: + +Visualizing thresholds +---------------------- + +A useful visualization when tuning the decision threshold is a plot of metric values +across different thresholds. This is particularly valuable when there is more than +one metric of interest. The :func:`~sklearn.metrics.metric_at_thresholds` function +computes metric values at each unique score threshold, returning both the metric +array and corresponding threshold values for easy plotting. + .. _FixedThresholdClassifier: Manually setting the decision threshold diff --git a/doc/modules/clustering.rst b/doc/modules/clustering.rst index 3bc4991733d5f..4b2fa47c40ef3 100644 --- a/doc/modules/clustering.rst +++ b/doc/modules/clustering.rst @@ -237,7 +237,7 @@ clustering algorithms, see :func:`sklearn.cluster.kmeans_plusplus` for details and example usage. The algorithm supports sample weights, which can be given by a parameter -``sample_weight``. This allows to assign more weight to some samples when +``sample_weight``. This allows assigning more weight to some samples when computing cluster centers and values of inertia. For example, assigning a weight of 2 to a sample is equivalent to adding a duplicate of that sample to the dataset :math:`X`. @@ -847,7 +847,7 @@ clusters from Bisecting K-Means are well ordered and create quite a visible hier .. dropdown:: References * `"A Comparison of Document Clustering Techniques" - <http://www.philippe-fournier-viger.com/spmf/bisectingkmeans.pdf>`_ Michael + <https://www.philippe-fournier-viger.com/spmf/bisectingkmeans.pdf>`_ Michael Steinbach, George Karypis and Vipin Kumar, Department of Computer Science and Egineering, University of Minnesota (June 2000) * `"Performance Analysis of K-Means and Bisecting K-Means Algorithms in Weblog @@ -1584,7 +1584,7 @@ Bad (e.g. independent labelings) have non-positive scores:: * Strehl, Alexander, and Joydeep Ghosh (2002). "Cluster ensembles - a knowledge reuse framework for combining multiple partitions". Journal of Machine Learning Research 3: 583-617. `doi:10.1162/153244303321897735 - <http://strehl.com/download/strehl-jmlr02.pdf>`_. + <https://strehl.com/download/strehl-jmlr02.pdf>`_. * `Wikipedia entry for the (normalized) Mutual Information <https://en.wikipedia.org/wiki/Mutual_Information>`_ @@ -1769,7 +1769,7 @@ homogeneous but not complete:: Hirschberg, 2007 .. [B2011] `Identification and Characterization of Events in Social Media - <http://www.cs.columbia.edu/~hila/hila-thesis-distributed.pdf>`_, Hila + <https://www.cs.columbia.edu/~hila/hila-thesis-distributed.pdf>`_, Hila Becker, PhD Thesis. @@ -2120,7 +2120,7 @@ of classes. .. topic:: Advantages: - - Allows to examine the spread of each true cluster across predicted clusters + - Allows examining the spread of each true cluster across predicted clusters and vice versa. - The contingency table calculated is typically utilized in the calculation of diff --git a/doc/modules/cross_validation.rst b/doc/modules/cross_validation.rst index b1c9ccec8f641..24478cf7ecf5f 100644 --- a/doc/modules/cross_validation.rst +++ b/doc/modules/cross_validation.rst @@ -1022,5 +1022,5 @@ computation and thus speeds it up. .. dropdown:: References * Ojala and Garriga. `Permutation Tests for Studying Classifier Performance - <http://www.jmlr.org/papers/volume11/ojala10a/ojala10a.pdf>`_. + <https://www.jmlr.org/papers/volume11/ojala10a/ojala10a.pdf>`_. J. Mach. Learn. Res. 2010. diff --git a/doc/modules/decomposition.rst b/doc/modules/decomposition.rst index ebf4302d3ce5b..2b062154a544b 100644 --- a/doc/modules/decomposition.rst +++ b/doc/modules/decomposition.rst @@ -950,7 +950,7 @@ is not readily available from the start, or when the data does not fit into memo .. rubric:: References .. [1] `"Learning the parts of objects by non-negative matrix factorization" - <http://www.cs.columbia.edu/~blei/fogm/2020F/readings/LeeSeung1999.pdf>`_ + <https://www.cs.columbia.edu/~blei/fogm/2020F/readings/LeeSeung1999.pdf>`_ D. Lee, S. Seung, 1999 .. [2] `"Non-negative Matrix Factorization with Sparseness Constraints" @@ -959,7 +959,7 @@ is not readily available from the start, or when the data does not fit into memo .. [4] `"SVD based initialization: A head start for nonnegative matrix factorization" - <https://www.boutsidis.org/Boutsidis_PRE_08.pdf>`_ + <https://user.it.uu.se/~milga730/histo/before2011august/Boutsidis.pdf>`_ C. Boutsidis, E. Gallopoulos, 2008 .. [5] `"Fast local algorithms for large scale nonnegative matrix and tensor diff --git a/doc/modules/density.rst b/doc/modules/density.rst index b629857827c74..8c8c0b78c1990 100644 --- a/doc/modules/density.rst +++ b/doc/modules/density.rst @@ -3,7 +3,6 @@ ================== Density Estimation ================== -.. sectionauthor:: Jake Vanderplas <vanderplas@astro.washington.edu> Density estimation walks the line between unsupervised learning, feature engineering, and data modeling. Some of the most popular and useful @@ -42,7 +41,7 @@ the histogram. But what if, instead of stacking the blocks on a regular grid, we center each block on the point it represents, and sum the total height at each location? This idea leads to the lower-left visualization. It is perhaps not as clean as a histogram, but the fact that the data drive the block -locations mean that it is a much better representation of the underlying +locations means that it is a much better representation of the underlying data. This visualization is an example of a *kernel density estimation*, in this case diff --git a/doc/modules/df_output_transform.rst b/doc/modules/df_output_transform.rst new file mode 100644 index 0000000000000..a3d35adb93a19 --- /dev/null +++ b/doc/modules/df_output_transform.rst @@ -0,0 +1,140 @@ +.. _df_output_transform: + +=========================================================== +Pandas/Polars Output for Transformers with `set_output` API +=========================================================== + +.. currentmodule:: sklearn + +This part of the user guide explains how scikit-learn supports tabular data. + + +Propagation of Feature Names +============================ + +By default, scikit-learn :term:`transformers` (estimators with a :meth:`transform` +method) return numpy arrays (sometimes also sparse arrays). Because numpy arrays do +not provide names for the indices of axes/dimensions, prior to version 1.0 +the :class:`pipeline.Pipeline` did not know how to propagate feature names: + +- The single step estimators did not know how to handle incoming feature names. +- The pipeline did not know how to pass feature names from step to step. + +In practice, a lot of use cases start with tabular data like a `pandas dataframe +<https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe>`_ or a +`polars dataframe <https://docs.pola.rs/api/python/stable/reference/dataframe/index.html>`__ +which have column/feature names. + +A first step to support this important use case was made by the addition of the +:class:`compose.ColumnTransformer` in :ref:`version 0.20 <changes_0_20>`. +It acts as a gateway to apply different estimators on the different features. Most +notably it understands incoming feature names. + +It was then properly solved by `SLEP007: Feature names, their generation and the API +<https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep007/proposal.html>`__ +and fully implemented in :ref:`version 1.1 <release_notes_1_1>`, see +the :ref:`release highlights 1.0 <feature_names_in_release_highlights_1_0_0>` and +:ref:`release highlights 1.1 <get_feature_names_out_release_highlights_1_1_0>`. +When an estimator is passed a dataframe during :term:`fit`, the estimator will +set a `feature_names_in_` attribute containing the feature names. It understands pandas +dataframes as well as dataframes with the `Python dataframe interchange protocol +<https://data-apis.org/dataframe-protocol/latest/index.html>`__ `__dataframe__`. +Furthermore, fitted estimators have the method :meth:`get_feature_names_out`. The +`get_feature_names_out` of a transformer returnsโธบyou guessed itโธบthe feature names of +what `transform` returns. + + +Introducing the `set_output` API +================================ + +A further major step to support dataframes in a "dataframe in, dataframe out" fashion was +`SLEP018 <https://scikit-learn-enhancement-proposals.readthedocs.io/en/latest/slep018/proposal.html>`__, +implemented for pandas dataframes in :ref:`version 1.2 <release_notes_1_2>` and for +polars dataframes in :ref:`version 1.4 <release_notes_1_4>`. It introduced the +`set_output` API to configure transformers to output pandas or polars DataFrames. +The output of transformers can be configured per estimator by calling +the :meth:`set_output` method or globally, by setting `set_config(transform_output="pandas")`. +Set it to `"polars"` instead of `"pandas"` if you want the same thing to happen but with +polars DataFrames. + +The usage is basically as follows:: + + >>> import numpy as np + >>> import pandas as pd + >>> from sklearn.compose import ColumnTransformer + >>> from sklearn.pipeline import make_pipeline + >>> from sklearn.preprocessing import OneHotEncoder + >>> from sklearn.linear_model import LinearRegression + + >>> X = pd.DataFrame( + ... {"animals": ["cat", "cat", "dog", "dog"], "numeric": np.linspace(-1, 1, 4)} + ... ) + >>> y = np.array([-1.5, 0, 0.1, 1.0]) + >>> ct = ColumnTransformer( + ... [("categorical", OneHotEncoder(sparse_output=False), ["animals"])], + ... remainder="passthrough", + ... ) + >>> model = make_pipeline(ct, LinearRegression()).fit(X, y) + >>> model.feature_names_in_ + array(['animals', 'numeric'], dtype=object) + >>> model[0].get_feature_names_out() + array(['categorical__animals_cat', 'categorical__animals_dog', + 'remainder__numeric'], dtype=object) + >>> model[0].transform(X) + array([[ 1. , 0. , -1. ], + [ 1. , 0. , -0.33333333], + [ 0. , 1. , 0.33333333], + [ 0. , 1. , 1. ]]) + +Now the same, but with pandas set as output:: + + >>> from sklearn import set_config + >>> set_config(transform_output="pandas") + >>> model[0].transform(X) + c... + +.. raw:: html + + <table border="1" class="dataframe"> + <thead> + <tr style="text-align: right;"> + <th></th> + <th>categorical__animals_cat</th> + <th>categorical__animals_dog</th> + <th>remainder__numeric</th> + </tr> + </thead> + <tbody> + <tr> + <th>0</th> + <td>1.0</td> + <td>0.0</td> + <td>-1.000000</td> + </tr> + <tr> + <th>1</th> + <td>1.0</td> + <td>0.0</td> + <td>-0.333333</td> + </tr> + <tr> + <th>2</th> + <td>0.0</td> + <td>1.0</td> + <td>0.333333</td> + </tr> + <tr> + <th>3</th> + <td>0.0</td> + <td>1.0</td> + <td>1.000000</td> + </tr> + </tbody> + </table> + +To return to the default, simply run:: + + >>> set_config(transform_output="default") + +A more detailed example can be found in +:ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py`. diff --git a/doc/modules/ensemble.rst b/doc/modules/ensemble.rst index 028a4d380dfca..c75b12830d307 100644 --- a/doc/modules/ensemble.rst +++ b/doc/modules/ensemble.rst @@ -55,7 +55,7 @@ Histogram-Based Gradient Boosting Scikit-learn 0.21 introduced two new implementations of gradient boosted trees, namely :class:`HistGradientBoostingClassifier` and :class:`HistGradientBoostingRegressor`, inspired by -`LightGBM <https://github.com/Microsoft/LightGBM>`__ (See [LightGBM]_). +`LightGBM <https://github.com/lightgbm-org/LightGBM>`__ (See [LightGBM]_). These histogram-based estimators can be **orders of magnitude faster** than :class:`GradientBoostingClassifier` and @@ -460,7 +460,8 @@ is the number of samples at the node. :class:`HistGradientBoostingClassifier` and :class:`HistGradientBoostingRegressor`, in contrast, do not require sorting the -feature values and instead use a data-structure called a histogram, where the +feature values and instead use a data-structure called +`histogram <https://en.wikipedia.org/wiki/Histogram>`_ , where the samples are implicitly ordered. Building a histogram has a :math:`\mathcal{O}(n)` complexity, so the node splitting procedure has a :math:`\mathcal{O}(n_\text{features} \times n)` complexity, much smaller @@ -474,6 +475,11 @@ values, but it only happens once at the very beginning of the boosting process (not at each node, like in :class:`GradientBoostingClassifier` and :class:`GradientBoostingRegressor`). +A second major reason for being faster is the fact that +:class:`HistGradientBoostingClassifier` and :class:`HistGradientBoostingRegressor` both +use second order information about the loss, i.e. the Hessian. This is called Newton +boosting and avoids the line-search step of the ordinary gradient boosting. + Finally, many parts of the implementation of :class:`HistGradientBoostingClassifier` and :class:`HistGradientBoostingRegressor` are parallelized. diff --git a/doc/modules/feature_extraction.rst b/doc/modules/feature_extraction.rst index bbe3ed8ec1742..e3b4a9bfb75b6 100644 --- a/doc/modules/feature_extraction.rst +++ b/doc/modules/feature_extraction.rst @@ -622,7 +622,7 @@ Again please see the :ref:`reference documentation and comparison with :class:`HashingVectorizer`. * :ref:`sphx_glr_auto_examples_model_selection_plot_grid_search_text_feature_extraction.py`: - Tuning hyperparamters of :class:`TfidfVectorizer` as part of a pipeline. + Tuning hyperparameters of :class:`TfidfVectorizer` as part of a pipeline. Decoding text files diff --git a/doc/modules/feature_selection.rst b/doc/modules/feature_selection.rst index ffee801f34ccc..a245c2bf4339d 100644 --- a/doc/modules/feature_selection.rst +++ b/doc/modules/feature_selection.rst @@ -70,7 +70,7 @@ as objects that implement the ``transform`` method: selection with a configurable strategy. This allows to select the best univariate selection strategy with hyper-parameter search estimator. -For instance, we can use a F-test to retrieve the two +For instance, we can use an F-test to retrieve the two best features for a dataset as follows: >>> from sklearn.datasets import load_iris diff --git a/doc/modules/grid_search.rst b/doc/modules/grid_search.rst index edb915b193e37..9e71e62e5fbf0 100644 --- a/doc/modules/grid_search.rst +++ b/doc/modules/grid_search.rst @@ -536,7 +536,7 @@ additional information related to the successive halving process. .. [1] K. Jamieson, A. Talwalkar, `Non-stochastic Best Arm Identification and Hyperparameter - Optimization <http://proceedings.mlr.press/v51/jamieson16.html>`_, in + Optimization <https://proceedings.mlr.press/v51/jamieson16.html>`_, in proc. of Machine Learning Research, 2016. .. [2] L. Li, K. Jamieson, G. DeSalvo, A. Rostamizadeh, A. Talwalkar, diff --git a/doc/modules/impute.rst b/doc/modules/impute.rst index 59367b647dd58..9a9e05d830e26 100644 --- a/doc/modules/impute.rst +++ b/doc/modules/impute.rst @@ -56,11 +56,11 @@ that contain the missing values:: The :class:`SimpleImputer` class also supports sparse matrices:: >>> import scipy.sparse as sp - >>> X = sp.csc_matrix([[1, 2], [0, -1], [8, 4]]) + >>> X = sp.csc_array([[1, 2], [0, -1], [8, 4]]) >>> imp = SimpleImputer(missing_values=-1, strategy='mean') >>> imp.fit(X) SimpleImputer(missing_values=-1) - >>> X_test = sp.csc_matrix([[-1, 2], [6, -1], [7, 6]]) + >>> X_test = sp.csc_array([[-1, 2], [6, -1], [7, 6]]) >>> print(imp.transform(X_test).toarray()) [[3. 2.] [6. 3.] diff --git a/doc/modules/linear_model.rst b/doc/modules/linear_model.rst index 0d66074ff2e62..5f1db0f6c4e3b 100644 --- a/doc/modules/linear_model.rst +++ b/doc/modules/linear_model.rst @@ -8,8 +8,9 @@ Linear Models The following are a set of methods intended for regression in which the target value is expected to be a linear combination of the features. -In mathematical notation, if :math:`\hat{y}` is the predicted -value. +In mathematical notation, the predicted value :math:`\hat{y}` can be +written as: + .. math:: \hat{y}(w, x) = w_0 + w_1 x_1 + ... + w_p x_p @@ -212,11 +213,11 @@ Usage example:: >>> import numpy as np >>> from sklearn import linear_model >>> reg = linear_model.RidgeCV(alphas=np.logspace(-6, 6, 13)) - >>> reg.fit([[0, 0], [0, 0], [1, 1]], [0, .1, 1]) + >>> reg.fit([[0, 0], [0, 0.1], [1, 1]], [0, -0.1, 1]) RidgeCV(alphas=array([1.e-06, 1.e-05, 1.e-04, 1.e-03, 1.e-02, 1.e-01, 1.e+00, 1.e+01, 1.e+02, 1.e+03, 1.e+04, 1.e+05, 1.e+06])) >>> reg.alpha_ - np.float64(0.01) + np.float64(0.1) Specifying the value of the :term:`cv` attribute will trigger the use of cross-validation with :class:`~sklearn.model_selection.GridSearchCV`, for @@ -1448,7 +1449,7 @@ eta0=1.0)` can be used for PA-I or with ``learning_rate="pa2"`` for PA-II. .. dropdown:: References * `"Online Passive-Aggressive Algorithms" - <http://jmlr.csail.mit.edu/papers/volume7/crammer06a/crammer06a.pdf>`_ + <https://jmlr.csail.mit.edu/papers/volume7/crammer06a/crammer06a.pdf>`_ K. Crammer, O. Dekel, J. Keshat, S. Shalev-Shwartz, Y. Singer - JMLR 7 (2006) Robustness regression: outliers and modeling errors @@ -1655,7 +1656,7 @@ better than an ordinary least squares in high dimension. .. [#f1] Xin Dang, Hanxiang Peng, Xueqin Wang and Heping Zhang: `Theil-Sen Estimators in a Multiple Linear Regression Model. <http://home.olemiss.edu/~xdang/papers/MTSE.pdf>`_ - .. [#f2] T. Kรคrkkรคinen and S. ร„yrรคmรถ: `On Computation of Spatial Median for Robust Data Mining. <http://users.jyu.fi/~samiayr/pdf/ayramo_eurogen05.pdf>`_ + .. [#f2] T. Kรคrkkรคinen and S. ร„yrรคmรถ: `On Computation of Spatial Median for Robust Data Mining. <https://users.jyu.fi/~samiayr/pdf/ayramo_eurogen05.pdf>`_ Also see the `Wikipedia page <https://en.wikipedia.org/wiki/Theil%E2%80%93Sen_estimator>`_ diff --git a/doc/modules/manifold.rst b/doc/modules/manifold.rst index 10f2b9c14d181..e04ef6b9187f0 100644 --- a/doc/modules/manifold.rst +++ b/doc/modules/manifold.rst @@ -522,7 +522,7 @@ Formally, the loss function of classical MDS (strain) is given by where :math:`Z` is the :math:`n \times d` embedding matrix whose rows are :math:`z_i^T`, :math:`\|\cdot\|_F` denotes the Frobenius norm, and -:math:`B` is the Gram matrix with elements :math:`b_{ij}`, +:math:`B` is the Gram matrix with elements :math:`b_{ij}`, given by :math:`B = -\frac{1}{2}C\Delta C`. Here :math:`C\Delta C` is the double-centered matrix of squared dissimilarities, with :math:`\Delta` being the matrix of squared input dissimilarities diff --git a/doc/modules/model_evaluation.rst b/doc/modules/model_evaluation.rst index c86fae1b6688b..fa7829018248c 100644 --- a/doc/modules/model_evaluation.rst +++ b/doc/modules/model_evaluation.rst @@ -63,7 +63,7 @@ The most common decisions are done on binary classification tasks, where the res probability of rain a decision is made on how to act (whether to take mitigating measures like an umbrella or not). For classifiers, this is what :term:`predict` returns. -See also :ref:`TunedThresholdClassifierCV`. +See also :ref:`threshold_tuning`. There are many scoring functions which measure different aspects of such a decision, most of them are covered with or derived from the :func:`metrics.confusion_matrix`. @@ -466,12 +466,12 @@ Classification metrics .. currentmodule:: sklearn.metrics -The :mod:`sklearn.metrics` module implements several loss, score, and utility -functions to measure classification performance. -Some metrics might require probability estimates of the positive class, -confidence values, or binary decisions values. -Most implementations allow each sample to provide a weighted contribution -to the overall score, through the ``sample_weight`` parameter. +The :mod:`sklearn.metrics` module implements several loss, score, and utility functions +to measure classification performance. Some metrics might require probability estimates +of the positive class or non-thresholded decision values (as returned by +:term:`decision_function` on some classifiers). Most implementations allow each sample +to provide a weighted contribution to the overall score, through the ``sample_weight`` +parameter. Some of these are restricted to the binary classification case: @@ -1134,7 +1134,7 @@ Note the following behaviors when averaging: * If all labels are included, "micro"-averaging in a multiclass setting will produce precision, recall and :math:`F` that are all identical to accuracy. -* "weighted" averaging may produce a F-score that is not between precision and recall. +* "weighted" averaging may produce an F-score that is not between precision and recall. * "macro" averaging for F-measures is calculated as the arithmetic mean over per-label/class F-measures, not the harmonic mean over the arithmetic precision and recall means. Both calculations can be seen in the literature but are not equivalent, @@ -1302,7 +1302,7 @@ is defined by: - w_{i, y_i}, 0\right\} Here is a small example demonstrating the use of the :func:`hinge_loss` function -with a svm classifier in a binary class problem:: +with an svm classifier in a binary class problem:: >>> from sklearn import svm >>> from sklearn.metrics import hinge_loss @@ -1318,7 +1318,7 @@ with a svm classifier in a binary class problem:: 0.3 Here is an example demonstrating the use of the :func:`hinge_loss` function -with a svm classifier in a multiclass problem:: +with an svm classifier in a multiclass problem:: >>> X = np.array([[0], [1], [2], [3]]) >>> Y = np.array([0, 1, 2, 3]) @@ -1377,11 +1377,11 @@ method. >>> from sklearn.metrics import log_loss >>> y_true = [0, 0, 1, 1] - >>> y_pred = [[.9, .1], [.8, .2], [.3, .7], [.01, .99]] - >>> log_loss(y_true, y_pred) + >>> y_proba = [[.9, .1], [.8, .2], [.3, .7], [.01, .99]] + >>> log_loss(y_true, y_proba) 0.1738 -The first ``[.9, .1]`` in ``y_pred`` denotes 90% probability that the first +The first ``[.9, .1]`` in ``y_proba`` denotes 90% probability that the first sample has label 0. The log loss is non-negative. .. _matthews_corrcoef: @@ -1578,9 +1578,9 @@ Quoting Wikipedia : sensitivity, and FPR is one minus the specificity or true negative rate." This function requires the true binary value and the target scores, which can -either be probability estimates of the positive class, confidence values, or -binary decisions. Here is a small example of how to use the :func:`roc_curve` -function:: +either be probability estimates of the positive class or non-thresholded decision values +(as returned by :term:`decision_function` on some classifiers). Here is a small example +of how to use the :func:`roc_curve` function:: >>> import numpy as np >>> from sklearn.metrics import roc_curve @@ -1982,18 +1982,16 @@ two above definitions to follow. ... ) 0.146 -The Brier score can be used to assess how well a classifier is calibrated. -However, a lower Brier score loss does not always mean a better calibration. -This is because, by analogy with the bias-variance decomposition of the mean -squared error, the Brier score loss can be decomposed as the sum of calibration -loss and refinement loss [Bella2012]_. Calibration loss is defined as the mean -squared deviation from empirical probabilities derived from the slope of ROC -segments. Refinement loss can be defined as the expected optimal loss as -measured by the area under the optimal cost curve. Refinement loss can change -independently from calibration loss, thus a lower Brier score loss does not -necessarily mean a better calibrated model. "Only when refinement loss remains -the same does a lower Brier score loss always mean better calibration" -[Bella2012]_, [Flach2008]_. +.. note:: + As a strictly proper scoring rules for probabilistic predictions, + the Brier score assesses calibration (reliability) and + discriminative power (resolution) of a model, as well as the randomness of the data + (uncertainty) at the same time. This follows from the well-known Brier score + decomposition of Murphy [Murphy1973]_. As it is not clear which term dominates, + the score is of limited use for assessing calibration alone (unless one computes + each term of the decomposition). A lower Brier loss, for instance, does not + necessarily mean a better calibrated model, it could also mean a worse calibrated + model with much more discriminatory power, e.g. using many more features. .. rubric:: Examples @@ -2003,19 +2001,15 @@ the same does a lower Brier score loss always mean better calibration" .. rubric:: References -.. [Brier1950] G. Brier, `Verification of forecasts expressed in terms of probability - <ftp://ftp.library.noaa.gov/docs.lib/htdocs/rescue/mwr/078/mwr-078-01-0001.pdf>`_, - Monthly weather review 78.1 (1950) +.. [Brier1950] G. Brier (1950). + :doi:`"Verification of forecasts expressed in terms of probability" + <10.1175/1520-0493(1950)078%3C0001:VOFEIT%3E2.0.CO;2>`. + Monthly Weather Review 78(1), 1-3 -.. [Bella2012] Bella, Ferri, Hernรกndez-Orallo, and Ramรญrez-Quintana - `"Calibration of Machine Learning Models" - <http://dmip.webs.upv.es/papers/BFHRHandbook2010.pdf>`_ - in Khosrow-Pour, M. "Machine learning: concepts, methodologies, tools - and applications." Hershey, PA: Information Science Reference (2012). - -.. [Flach2008] Flach, Peter, and Edson Matsubara. `"On classification, ranking, - and probability estimation." <https://drops.dagstuhl.de/opus/volltexte/2008/1382/>`_ - Dagstuhl Seminar Proceedings. Schloss Dagstuhl-Leibniz-Zentrum fรผr Informatik (2008). +.. [Murphy1973] Allan H. Murphy (1973). + :doi:`"A New Vector Partition of the Probability Score" + <10.1175/1520-0450(1973)012%3C0595:ANVPOT%3E2.0.CO;2>` + Journal of Applied Meteorology and Climatology, 12(4), 595-600 .. _class_likelihood_ratios: @@ -2184,29 +2178,29 @@ of 0.0. >>> from sklearn.metrics import d2_log_loss_score >>> y_true = [1, 1, 2, 3] - >>> y_pred = [ + >>> y_proba = [ ... [0.5, 0.25, 0.25], ... [0.5, 0.25, 0.25], ... [0.5, 0.25, 0.25], ... [0.5, 0.25, 0.25], ... ] - >>> d2_log_loss_score(y_true, y_pred) + >>> d2_log_loss_score(y_true, y_proba) 0.0 >>> y_true = [1, 2, 3] - >>> y_pred = [ + >>> y_proba = [ ... [0.98, 0.01, 0.01], ... [0.01, 0.98, 0.01], ... [0.01, 0.01, 0.98], ... ] - >>> d2_log_loss_score(y_true, y_pred) + >>> d2_log_loss_score(y_true, y_proba) 0.981 >>> y_true = [1, 2, 3] - >>> y_pred = [ + >>> y_proba = [ ... [0.1, 0.6, 0.3], ... [0.1, 0.6, 0.3], ... [0.4, 0.5, 0.1], ... ] - >>> d2_log_loss_score(y_true, y_pred) + >>> d2_log_loss_score(y_true, y_proba) -0.552 @@ -2219,7 +2213,7 @@ of 0.0. \text{dev}(y, \hat{y}) = \text{brier_score_loss}(y, \hat{y}). - This is also referred to as the Brier Skill Score (BSS). + This is also referred to as the Brier Skill Score (BSS) and scaled Brier score. Here are some usage examples of the :func:`d2_brier_score` function:: @@ -2987,7 +2981,7 @@ quantile regressor via cross-validation: ... random_state=0, ... ) >>> cross_val_score(estimator, X, y, cv=5, scoring=mean_pinball_loss_95p) - array([13.6, 9.7, 23.3, 9.5, 10.4]) + array([14.3, 9.8, 23.9, 9.4, 10.8]) It is also possible to build scorer objects for hyper-parameter tuning. The sign of the loss must be switched to ensure that greater means better as @@ -3146,7 +3140,7 @@ expected value should be null and that their variance should be constant (homoschedasticity). If this is not the case, and in particular if the residuals plot show some -banana-shaped structure, this is a hint that the model is likely mis-specified +banana-shaped structure, this is a hint that the model is likely misspecified and that non-linear feature engineering or switching to a non-linear regression model might be useful. diff --git a/doc/modules/multiclass.rst b/doc/modules/multiclass.rst index f2e5182faab4b..94ef1a4c7b6a5 100644 --- a/doc/modules/multiclass.rst +++ b/doc/modules/multiclass.rst @@ -63,8 +63,8 @@ can provide additional strategies beyond what is built-in: - :class:`semi_supervised.LabelSpreading` - :class:`discriminant_analysis.LinearDiscriminantAnalysis` - :class:`svm.LinearSVC` (setting multi_class="crammer_singer") - - :class:`linear_model.LogisticRegression` (with most solvers) - - :class:`linear_model.LogisticRegressionCV` (with most solvers) + - :class:`linear_model.LogisticRegression` (all solvers but "liblinear") + - :class:`linear_model.LogisticRegressionCV` (all solvers but "liblinear") - :class:`neural_network.MLPClassifier` - :class:`neighbors.NearestCentroid` - :class:`discriminant_analysis.QuadraticDiscriminantAnalysis` @@ -86,8 +86,6 @@ can provide additional strategies beyond what is built-in: - :class:`ensemble.GradientBoostingClassifier` - :class:`gaussian_process.GaussianProcessClassifier` (setting multi_class = "one_vs_rest") - :class:`svm.LinearSVC` (setting multi_class="ovr") - - :class:`linear_model.LogisticRegression` (most solvers) - - :class:`linear_model.LogisticRegressionCV` (most solvers) - :class:`linear_model.SGDClassifier` - :class:`linear_model.Perceptron` @@ -169,9 +167,9 @@ Valid :term:`multiclass` representations for [1 0 0] [0 1 0]] >>> from scipy import sparse - >>> y_sparse = sparse.csr_matrix(y_dense) + >>> y_sparse = sparse.csr_array(y_dense) >>> print(y_sparse) - <Compressed Sparse Row sparse matrix of dtype 'int64' + <Compressed Sparse Row sparse array of dtype 'int64' with 4 stored elements and shape (4, 3)> Coords Values (0, 0) 1 @@ -379,9 +377,9 @@ refer to :ref:`preprocessing_targets`. An example of the same ``y`` in sparse matrix form: - >>> y_sparse = sparse.csr_matrix(y) + >>> y_sparse = sparse.csr_array(y) >>> print(y_sparse) - <Compressed Sparse Row sparse matrix of dtype 'int64' + <Compressed Sparse Row sparse array of dtype 'int64' with 4 stored elements and shape (3, 4)> Coords Values (0, 0) 1 diff --git a/doc/modules/naive_bayes.rst b/doc/modules/naive_bayes.rst index 0f291599d8008..423c6fc8ae126 100644 --- a/doc/modules/naive_bayes.rst +++ b/doc/modules/naive_bayes.rst @@ -12,7 +12,7 @@ based on applying Bayes' theorem with the "naive" assumption of conditional independence between every pair of features given the value of the class variable. Bayes' theorem states the following relationship, given class variable :math:`y` and dependent feature -vector :math:`x_1` through :math:`x_n`, : +vector :math:`x_1` through :math:`x_n`: .. math:: @@ -61,7 +61,7 @@ the references below.) Naive Bayes learners and classifiers can be extremely fast compared to more sophisticated methods. The decoupling of the class conditional feature distributions means that each -distribution can be independently estimated as a one dimensional distribution. +distribution can be independently estimated as a one-dimensional distribution. This in turn helps to alleviate problems stemming from the curse of dimensionality. diff --git a/doc/modules/neighbors.rst b/doc/modules/neighbors.rst index a9c0bb57d7dbc..1f095383499b2 100644 --- a/doc/modules/neighbors.rst +++ b/doc/modules/neighbors.rst @@ -4,8 +4,6 @@ Nearest Neighbors ================= -.. sectionauthor:: Jake Vanderplas <vanderplas@astro.washington.edu> - .. currentmodule:: sklearn.neighbors :mod:`sklearn.neighbors` provides functionality for unsupervised and @@ -638,8 +636,6 @@ implementation with special data types. The precomputed neighbors Neighborhood Components Analysis ================================ -.. sectionauthor:: William de Vazelhes <william.de-vazelhes@inria.fr> - Neighborhood Components Analysis (NCA, :class:`NeighborhoodComponentsAnalysis`) is a distance metric learning algorithm which aims to improve the accuracy of nearest neighbors classification compared to the standard Euclidean distance. diff --git a/doc/modules/outlier_detection.rst b/doc/modules/outlier_detection.rst index f68e3dc8d9f66..73fea5dd4cfd1 100644 --- a/doc/modules/outlier_detection.rst +++ b/doc/modules/outlier_detection.rst @@ -366,6 +366,10 @@ on new unseen data when LOF is applied for novelty detection, i.e. when the ``novelty`` parameter is set to ``True``, but the result of ``predict`` may differ from that of ``fit_predict``. See :ref:`novelty_with_lof`. +When the ``contamination`` parameter is set, the threshold (called ``offset_``) +is determined as the corresponding percentile of ``negative_outlier_factor_`` +scores on the training data. Samples with scores strictly below this threshold +are classified as outliers. This strategy is illustrated below. diff --git a/doc/modules/preprocessing.rst b/doc/modules/preprocessing.rst index 5d1bb9e1836bd..f47aeb91f46af 100644 --- a/doc/modules/preprocessing.rst +++ b/doc/modules/preprocessing.rst @@ -1064,7 +1064,7 @@ For instance, we can use the Pandas function :func:`pandas.cut`:: >>> X = np.array([0.2, 2, 15, 25, 97]) >>> transformer.fit_transform(X) ['infant', 'kid', 'teen', 'adult', 'senior citizen'] - Categories (5, object): ['infant' < 'kid' < 'teen' < 'adult' < 'senior citizen'] + Categories (5, str): ['infant' < 'kid' < 'teen' < 'adult' < 'senior citizen'] .. rubric:: Examples diff --git a/doc/modules/preprocessing_targets.rst b/doc/modules/preprocessing_targets.rst index f8035bc059af4..c0d3769b69263 100644 --- a/doc/modules/preprocessing_targets.rst +++ b/doc/modules/preprocessing_targets.rst @@ -41,6 +41,8 @@ that support the label indicator matrix format. For more information about multiclass classification, refer to :ref:`multiclass_classification`. +.. _multilabelbinarizer: + MultiLabelBinarizer ------------------- diff --git a/doc/modules/sgd.rst b/doc/modules/sgd.rst index 360ba2f11c994..8f6043521b82e 100644 --- a/doc/modules/sgd.rst +++ b/doc/modules/sgd.rst @@ -283,7 +283,7 @@ variant can be several orders of magnitude faster. This is similar to the optimization problems studied in section :ref:`sgd_mathematical_formulation` with :math:`y_i = 1, 1 \leq i \leq n` and - :math:`\alpha = \nu/2`, :math:`L` being the hinge loss function and :math:`R` + :math:`\alpha = \nu`, :math:`L` being the hinge loss function and :math:`R` being the :math:`L_2` norm. We just need to add the term :math:`b\nu` in the optimization loop. @@ -457,7 +457,7 @@ misclassification error (Zero-one loss) as shown in the Figure below. Popular choices for the regularization term :math:`R` (the `penalty` parameter) include: -- :math:`L_2` norm: :math:`R(w) := \frac{1}{2} \sum_{j=1}^{m} w_j^2 = ||w||_2^2`, +- :math:`L_2` norm: :math:`R(w) := \frac{1}{2} \sum_{j=1}^{m} w_j^2 = \frac{1}{2} ||w||_2^2`, - :math:`L_1` norm: :math:`R(w) := \sum_{j=1}^{m} |w_j|`, which leads to sparse solutions. - Elastic Net: :math:`R(w) := \frac{\rho}{2} \sum_{j=1}^{n} w_j^2 + diff --git a/doc/modules/svm.rst b/doc/modules/svm.rst index dc912a289ed46..3518962603ab1 100644 --- a/doc/modules/svm.rst +++ b/doc/modules/svm.rst @@ -813,4 +813,4 @@ used, please refer to their respective papers. .. [#8] Crammer and Singer `On the Algorithmic Implementation of Multiclass Kernel-based Vector Machines - <http://jmlr.csail.mit.edu/papers/volume2/crammer01a/crammer01a.pdf>`_, JMLR 2001. + <https://jmlr.csail.mit.edu/papers/volume2/crammer01a/crammer01a.pdf>`_, JMLR 2001. diff --git a/doc/modules/tree.rst b/doc/modules/tree.rst index 5ebc7b0e398e6..d0335cd2f6b89 100644 --- a/doc/modules/tree.rst +++ b/doc/modules/tree.rst @@ -310,7 +310,7 @@ the lower half of those faces. * M. Dumont et al, `Fast multi-class image annotation with random subwindows and multiple output randomized trees - <http://www.montefiore.ulg.ac.be/services/stochastic/pubs/2009/DMWG09/dumont-visapp09-shortpaper.pdf>`_, + <https://www.montefiore.ulg.ac.be/services/stochastic/pubs/2009/DMWG09/dumont-visapp09-shortpaper.pdf>`_, International Conference on Computer Vision Theory and Applications 2009 .. _tree_complexity: @@ -647,10 +647,6 @@ support for missing values for `splitter='random'`, where the splits are determined randomly. For more details on how the splitter differs on non-missing values, see the :ref:`Forest section <forest>`. -The criterion supported when there are missing values are -`'gini'`, `'entropy'`, or `'log_loss'`, for classification or -`'squared_error'`, `'friedman_mse'`, or `'poisson'` for regression. - First we will describe how :class:`DecisionTreeClassifier`, :class:`DecisionTreeRegressor` handle missing-values in the data. diff --git a/doc/related_projects.rst b/doc/related_projects.rst index a7a10aef7929e..9c2dd0a92a047 100644 --- a/doc/related_projects.rst +++ b/doc/related_projects.rst @@ -65,7 +65,7 @@ enhance the functionality of scikit-learn's estimators. organize, log and reproduce experiments - `Scikit-Learn Laboratory - <https://skll.readthedocs.io/en/latest/index.html>`_ A command-line + <https://skll.readthedocs.io/en/latest/index.html>`_ A command-line wrapper around scikit-learn that makes it easy to run machine learning experiments with multiple learners and large feature sets. @@ -77,11 +77,6 @@ enhance the functionality of scikit-learn's estimators. - `model-diagnostics <https://lorentzenchr.github.io/model-diagnostics/>`_ Tools for diagnostics and assessment of (machine learning) models (in Python). -- `sklearn-evaluation <https://github.com/ploomber/sklearn-evaluation>`_ - Machine learning model evaluation made easy: plots, tables, HTML reports, - experiment tracking and Jupyter notebook analysis. Visual analysis, model - selection, evaluation and diagnostics. - - `yellowbrick <https://github.com/DistrictDataLabs/yellowbrick>`_ A suite of custom matplotlib visualizers for scikit-learn estimators to support visual feature analysis, model selection, evaluation, and diagnostics. @@ -292,7 +287,7 @@ Other packages useful for data analysis and machine learning. Models are fully compatible with scikit-learn. Recommendation Engine packages -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------ - `implicit <https://github.com/benfred/implicit>`_, Library for implicit feedback datasets. @@ -304,7 +299,7 @@ Recommendation Engine packages datasets. Domain specific packages -~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------ - `scikit-network <https://scikit-network.readthedocs.io/>`_ Machine learning on graphs. diff --git a/doc/scss/custom.scss b/doc/scss/custom.scss index a59c903f839eb..2dd8608c18802 100644 --- a/doc/scss/custom.scss +++ b/doc/scss/custom.scss @@ -216,13 +216,18 @@ div.sk-authors-container { @mixin sk-text-image-grid($img-max-height) { display: flex; - align-items: center; + align-items: flex-start; flex-wrap: wrap; - div.text-box, - div.image-box { - width: 50%; + div.text-box { + width: 65%; + @media screen and (max-width: 500px) { + width: 100%; + } + } + div.image-box { + width: 35%; @media screen and (max-width: 500px) { width: 100%; } @@ -252,6 +257,52 @@ div.sk-text-image-grid-large { @include sk-text-image-grid(100px); } +/* Institutional support: active funding participation table (institutional_support.rst) */ + +.bd-article table.sk-funding-participation-table { + width: 100%; + border-collapse: collapse; + --bs-table-striped-bg: #fff; + --bs-table-hover-bg: #fff; + --bs-table-active-bg: #fff; + tbody, + tr, + td { + } + + th, + td { + padding: 0.75rem 0.5rem; + border: 1px solid var(--pst-color-border); + text-align: center; + } + + li { + list-style: none !important; + list-style-type: none !important; + margin: 0.5rem !important; + } + + img { + height: 40px !important; + padding: 5px; + } +} + +/* Funding sidebar secondary panel (doc/templates/funding_links.html) */ + +.sk-funding-sidepanel { + margin-top: 1.25rem; + + .sk-funding-logos img { + max-width: 80%; + padding: 5px; + height: auto !important; + width: auto; + display: inline-block; + } +} + /* Responsive three-column grid list */ .grid-list-three-columns { display: grid; diff --git a/doc/scss/index.scss b/doc/scss/index.scss index c3bb8e86b41c6..7888dbc40979f 100644 --- a/doc/scss/index.scss +++ b/doc/scss/index.scss @@ -150,23 +150,14 @@ div.sk-landing-more-info { } } -/* Footer */ +/* Funding section */ -div.sk-landing-footer { - a.sk-footer-funding-link { +div.sk-landing-funding { + a.sk-funding-link { text-decoration: none; - p.sk-footer-funding-text { - color: var(--pst-color-link); - - &:hover { - color: var(--pst-color-secondary); - } - } - - div.sk-footer-funding-logos > img { - max-height: 40px; - max-width: 85px; + div.sk-funding-logos > img { + height: 40px; margin: 0 8px 8px 8px; padding: 5px; border-radius: 3px; diff --git a/doc/sphinxext/allow_nan_estimators.py b/doc/sphinxext/allow_nan_estimators.py index 3b85ce6c87508..4835433e48636 100644 --- a/doc/sphinxext/allow_nan_estimators.py +++ b/doc/sphinxext/allow_nan_estimators.py @@ -19,12 +19,16 @@ def make_paragraph_for_estimator_type(estimator_type): lst = nodes.bullet_list() for name, est_class in all_estimators(type_filter=estimator_type): with suppress(SkipTest): - # Here we generate the text only for one instance. This directive - # should not be used for meta-estimators where tags depend on the - # sub-estimator. - est = next(_construct_instances(est_class)) - - if est.__sklearn_tags__().input_tags.allow_nan: + # Check all instances generated by _construct_instances, since + # some estimators support NaN only with specific + # hyper-parameters (e.g. SplineTransformer with + # handle_missing="zeros"). + allows_nan = any( + est.__sklearn_tags__().input_tags.allow_nan + for est in _construct_instances(est_class) + ) + + if allows_nan: module_name = ".".join(est_class.__module__.split(".")[:2]) class_title = f"{est_class.__name__}" class_url = f"./generated/{module_name}.{class_title}.html" diff --git a/doc/templates/funding_links.html b/doc/templates/funding_links.html new file mode 100644 index 0000000000000..b61c359c290e8 --- /dev/null +++ b/doc/templates/funding_links.html @@ -0,0 +1,16 @@ +<div class="sk-funding-sidepanel"> + <hr/> + <p>scikit-learn is <a href="{{ pathto('institutional_support') + }}#funding">financially supported</a> by Probabl and other + institutions.</p> + <div> + <div class="text-center"> + <a href="https://probabl.ai/lp/scikit-learn"> + <div class="sk-funding-logos"> + <img src="{{ pathto('_static/probabl.png', 1) }}" title="Probabl"> + </div> + <p>Enterprise-grade solutions and services</p> + </a> + </div> + </div> +</div> diff --git a/doc/templates/index.html b/doc/templates/index.html index 08abde9895ea0..55b7a80883fd6 100644 --- a/doc/templates/index.html +++ b/doc/templates/index.html @@ -199,6 +199,47 @@ <h4 class="sk-card-title card-title sk-vert-align" sk-align-name="title"> {% block footer %} +<div class="container-fluid sk-landing-funding py-3"> + <div class="container sk-landing-container bd-page-width"> + <div class="text-center"> + <p class="mt-2 sk-footer-funding-text"> + scikit-learn is made possible by the support of organizations and + individuals committed to open source machine learning. + </p> + </div> + <div class="row"> + <div class="col-md-4"> + <a class="sk-funding-link" href="https://probabl.ai/lp/scikit-learn"> + <div class="text-center"> + <div class="sk-funding-logos"> + <img src="_static/probabl.png" title="Probabl"> + </div> + <p>Probabl: Enterprise-grade solutions and services</p> + </div> + </a> + </div> + <div class="col-md-8"> + <a class="sk-funding-link" href="institutional_support.html#funding"> + <div class="text-center"> + <div class="sk-funding-logos"> + <img src="_static/inria-small.png" title="Inria"> + <img src="_static/czi-small.png" title="Chan Zuckerberg Initiative"> + <img src="_static/wellcome-trust-small.png" title="Wellcome Trust"> + <img src="_static/nvidia-small.png" title="NVIDIA"> + <img src="_static/nasa-small.png" title="NASA"> + <img src="_static/quansight-labs-small.png" title="Quansight Labs"> + <img src="_static/chanel-small.png" title="Chanel"> + <img src="_static/bnp-paribas-small.png" title="BNP Paribas Group"> + <img src="_static/michelin-small.png" title="Michelin"> + </div> + <p>Learn more about scikit-learn's financial support.</p> + </div> + </a> + </div> + </div> + </div> +</div> + <div class="container-fluid sk-landing-more-info py-3"> <div class="container sk-landing-container bd-page-width"> <div class="row"> @@ -206,15 +247,13 @@ <h4 class="sk-card-title card-title sk-vert-align" sk-align-name="title"> <div class="col-md-4"> <h4 class="sk-landing-call-header">News</h4> <ul class="sk-landing-call-list list-unstyled"> - <li><strong>On-going development:</strong> <a href="https://scikit-learn.org/dev/whats_new/v1.8.html#version-1-8-0">scikit-learn 1.8 (Changelog)</a>.</li> + <li><strong>On-going development:</strong> <a href="https://scikit-learn.org/dev/whats_new/v1.9.html#version-1-9-0">scikit-learn 1.9 (Changelog)</a>.</li> + <li><strong>December 2025.</strong> scikit-learn 1.8.0 is available for download (<a href="whats_new/v1.8.html#version-1-8-0">Changelog</a>).</li> <li><strong>September 2025.</strong> scikit-learn 1.7.2 is available for download (<a href="whats_new/v1.7.html#version-1-7-2">Changelog</a>).</li> <li><strong>July 2025.</strong> scikit-learn 1.7.1 is available for download (<a href="whats_new/v1.7.html#version-1-7-1">Changelog</a>).</li> <li><strong>June 2025.</strong> scikit-learn 1.7.0 is available for download (<a href="whats_new/v1.7.html#version-1-7-0">Changelog</a>).</li> <li><strong>January 2025.</strong> scikit-learn 1.6.1 is available for download (<a href="whats_new/v1.6.html#version-1-6-1">Changelog</a>).</li> <li><strong>December 2024.</strong> scikit-learn 1.6.0 is available for download (<a href="whats_new/v1.6.html#version-1-6-0">Changelog</a>).</li> - <li><strong>September 2024.</strong> scikit-learn 1.5.2 is available for download (<a href="whats_new/v1.5.html#version-1-5-2">Changelog</a>).</li> - <li><strong>July 2024.</strong> scikit-learn 1.5.1 is available for download (<a href="whats_new/v1.5.html#version-1-5-1">Changelog</a>).</li> - <li><strong>May 2024.</strong> scikit-learn 1.5.0 is available for download (<a href="whats_new/v1.5.html#version-1-5-0">Changelog</a>).</li> <li><strong>All releases:</strong> <a href="https://scikit-learn.org/dev/whats_new.html"><strong>What's new</strong> (Changelog)</a>.</li> </ul> </div> @@ -229,6 +268,7 @@ <h4 class="sk-landing-call-header">Community</h4> <li><strong>Blog:</strong> <a href="https://blog.scikit-learn.org">blog.scikit-learn.org</a></li> <li><strong>Logos & Branding:</strong> <a href="https://github.com/scikit-learn/scikit-learn/tree/main/doc/logos">logos and branding</a></li> <li><strong>Calendar:</strong> <a href="https://blog.scikit-learn.org/calendar/">calendar</a></li> + <li><strong>Ecosystem:</strong> <a href="https://scikit-learn-central.probabl.ai">scikit-learn central</a></li> <li><strong>LinkedIn:</strong> <a href="https://www.linkedin.com/company/scikit-learn">linkedin/scikit-learn</a></li> <li><strong>Bluesky:</strong> <a href="https://bsky.app/profile/scikit-learn.org">bluesky/scikit-learn.org</a></li> <li><strong>Mastodon:</strong> <a href="https://mastodon.social/@sklearn@fosstodon.org">@sklearn</a></li> @@ -283,29 +323,6 @@ <h4 class="sk-landing-call-header">Who uses scikit-learn?</h4> </div> </div> -<div class="container-fluid sk-landing-footer py-3"> - <div class="container sk-landing-container"> - <a class="sk-footer-funding-link" href="about.html#funding"> - <div class="text-center"> - <p class="mt-2 sk-footer-funding-text"> - scikit-learn development and maintenance are financially supported by - </p> - <div class="sk-footer-funding-logos"> - <img src="_static/probabl.png" title="Probabl"> - <img src="_static/inria-small.png" title="INRIA"> - <img src="_static/chanel-small.png" title="Chanel"> - <img src="_static/bnp-paribas.png" title="BNP Paribas Group"> - <img src="_static/microsoft-small.png" title="Microsoft"> - <img src="_static/nvidia-small.png" title="Nvidia"> - <img src="_static/quansight-labs-small.png" title="Quansight Labs"> - <img src="_static/czi-small.png" title="Chan Zuckerberg Initiative"> - <img src="_static/wellcome-trust-small.png" title="Wellcome Trust"> - </div> - </div> - </a> - </div> -</div> - {% endblock footer %} {%- block scripts_end %} diff --git a/doc/testimonials/testimonials.rst b/doc/testimonials/testimonials.rst index 3c8c15b2e25ee..dca5d71515718 100644 --- a/doc/testimonials/testimonials.rst +++ b/doc/testimonials/testimonials.rst @@ -390,8 +390,8 @@ Who is using scikit-learn? :target: https://www.phimeca.com/?lang=en -`HowAboutWe <http://www.howaboutwe.com/>`_ ------------------------------------------- +`HowAboutWe <https://www.howaboutwe.com/>`_ +------------------------------------------- .. div:: sk-text-image-grid-large @@ -413,7 +413,7 @@ Who is using scikit-learn? .. div:: image-box .. image:: images/howaboutwe.png - :target: http://www.howaboutwe.com/ + :target: https://www.howaboutwe.com/ `PeerIndex <https://www.brandwatch.com/peerindex-and-brandwatch>`_ @@ -598,8 +598,8 @@ Who is using scikit-learn? :target: https://www.solidodesign.com/ -`INFONEA <http://www.infonea.com/en/>`_ ---------------------------------------- +`INFONEA <https://www.infonea.com/en/>`_ +---------------------------------------- .. div:: sk-text-image-grid-large @@ -620,7 +620,7 @@ Who is using scikit-learn? .. div:: image-box .. image:: images/infonea.jpg - :target: http://www.infonea.com/en/ + :target: https://www.infonea.com/en/ `Dataiku <https://www.dataiku.com/>`_ diff --git a/doc/user_guide.rst b/doc/user_guide.rst index 0c1a6ee66ebf9..eaf0ef3cf04d8 100644 --- a/doc/user_guide.rst +++ b/doc/user_guide.rst @@ -19,6 +19,6 @@ User Guide computing.rst model_persistence.rst common_pitfalls.rst - dispatching.rst + data_interoperability.rst machine_learning_map.rst presentations.rst diff --git a/doc/visualizations.rst b/doc/visualizations.rst index e9d38f25e1e0d..a7a4d85fd5ac4 100644 --- a/doc/visualizations.rst +++ b/doc/visualizations.rst @@ -111,8 +111,8 @@ values of the curves. .. rubric:: Examples +* :ref:`sphx_glr_auto_examples_inspection_plot_partial_dependence_visualization_api.py` * :ref:`sphx_glr_auto_examples_miscellaneous_plot_roc_curve_visualization_api.py` -* :ref:`sphx_glr_auto_examples_miscellaneous_plot_partial_dependence_visualization_api.py` * :ref:`sphx_glr_auto_examples_miscellaneous_plot_display_object_visualization.py` * :ref:`sphx_glr_auto_examples_calibration_plot_compare_calibration.py` diff --git a/doc/whats_new.rst b/doc/whats_new.rst index 1e9d0316691e1..85331dba43e42 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -15,6 +15,7 @@ Changelogs and release notes for all scikit-learn releases are linked in this pa .. toctree:: :maxdepth: 2 + whats_new/v1.9.rst whats_new/v1.8.rst whats_new/v1.7.rst whats_new/v1.6.rst diff --git a/doc/whats_new/_contributors.rst b/doc/whats_new/_contributors.rst index c74a2964e57bc..da23c137b194a 100644 --- a/doc/whats_new/_contributors.rst +++ b/doc/whats_new/_contributors.rst @@ -22,11 +22,11 @@ .. _Olivier Grisel: https://bsky.app/profile/ogrisel.bsky.social -.. _Gael Varoquaux: http://gael-varoquaux.info +.. _Gael Varoquaux: https://gael-varoquaux.info -.. _Alexandre Gramfort: http://alexandre.gramfort.net +.. _Alexandre Gramfort: https://alexandre.gramfort.net -.. _Fabian Pedregosa: http://fa.bianp.net +.. _Fabian Pedregosa: https://fa.bianp.net .. _Mathieu Blondel: http://www.mblondel.org @@ -42,7 +42,7 @@ .. _Peter Prettenhofer: https://sites.google.com/site/peterprettenhofer/ -.. _Alexandre Passos: http://atpassos.me +.. _Alexandre Passos: https://atpassos.me .. _Nicolas Pinto: https://twitter.com/npinto @@ -54,7 +54,7 @@ .. _Jake Vanderplas: https://staff.washington.edu/jakevdp/ -.. _Gilles Louppe: http://www.montefiore.ulg.ac.be/~glouppe/ +.. _Gilles Louppe: https://www.montefiore.ulg.ac.be/~glouppe/ .. _INRIA: https://www.inria.fr/ @@ -90,13 +90,13 @@ .. _Kyle Kastner: https://kastnerkyle.github.io/ -.. _Daniel Nouri: http://danielnouri.org +.. _Daniel Nouri: https://danielnouri.org .. _Manoj Kumar: https://manojbits.wordpress.com -.. _Luis Pedro Coelho: http://luispedro.org +.. _Luis Pedro Coelho: https://luispedro.org -.. _Fares Hedyati: http://www.eecs.berkeley.edu/~fareshed +.. _Fares Hedyati: https://www.eecs.berkeley.edu/~fareshed .. _Antony Lee: https://www.ocf.berkeley.edu/~antonyl/ @@ -104,7 +104,7 @@ .. _Matteo Visconti di Oleggio Castello: http://www.mvdoc.me -.. _Trevor Stephens: http://trevorstephens.com/ +.. _Trevor Stephens: https://trevorstephens.com/ .. _Jan Hendrik Metzen: https://jmetzen.github.io/ @@ -156,7 +156,7 @@ .. _Vincent Pham: https://github.com/vincentpham1991 -.. _Denis Engemann: http://denis-engemann.de +.. _Denis Engemann: https://denis-engemann.de .. _Anish Shah: https://github.com/AnishShah diff --git a/doc/whats_new/upcoming_changes/README.md b/doc/whats_new/upcoming_changes/README.md index 86edb6bd00e74..0d6be128bc452 100644 --- a/doc/whats_new/upcoming_changes/README.md +++ b/doc/whats_new/upcoming_changes/README.md @@ -33,7 +33,7 @@ folder with the following content:: now supports missing values in the data matrix `X`. Missing-values are handled by randomly moving all of the samples to the left, or right child node as the tree is traversed. - By :user:`Adam Li <adam2392>` + By :user:`Adam Li <adam2392>`. ``` If you are unsure how to name the news fragment or which folder to use, don't diff --git a/doc/whats_new/upcoming_changes/array-api/27113.feature.rst b/doc/whats_new/upcoming_changes/array-api/27113.feature.rst deleted file mode 100644 index 5e044c82cd568..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/27113.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`sklearn.preprocessing.StandardScaler` now supports Array API compliant inputs. - By :user:`Alexander Fabisch <AlexanderFabisch>`, :user:`Edoardo Abati <EdAbati>`, - :user:`Olivier Grisel <ogrisel>` and :user:`Charles Hill <charlesjhill>`. diff --git a/doc/whats_new/upcoming_changes/array-api/27961.feature.rst b/doc/whats_new/upcoming_changes/array-api/27961.feature.rst deleted file mode 100644 index 3dbea99e0f749..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/27961.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`linear_model.RidgeCV`, :class:`linear_model.RidgeClassifier` and - :class:`linear_model.RidgeClassifierCV` now support array API compatible - inputs with `solver="svd"`. - By :user:`Jรฉrรดme Dockรจs <jeromedockes>`. diff --git a/doc/whats_new/upcoming_changes/array-api/29661.enhancement.rst b/doc/whats_new/upcoming_changes/array-api/29661.enhancement.rst new file mode 100644 index 0000000000000..f5e2921ca96ba --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/29661.enhancement.rst @@ -0,0 +1,2 @@ +- :class:`kernel_approximation.Nystroem` now supports array API compatible inputs. + By :user:`Emily Chen <EmilyXinyi>` \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/array-api/29822.feature.rst b/doc/whats_new/upcoming_changes/array-api/29822.feature.rst deleted file mode 100644 index 4cd3dc8d300cb..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/29822.feature.rst +++ /dev/null @@ -1,5 +0,0 @@ -- :func:`metrics.pairwise.pairwise_kernels` for any kernel except - "laplacian" and - :func:`metrics.pairwise_distances` for metrics "cosine", - "euclidean" and "l2" now support array API inputs. - By :user:`Emily Chen <EmilyXinyi>` and :user:`Lucy Liu <lucyleeow>` diff --git a/doc/whats_new/upcoming_changes/array-api/30562.feature.rst b/doc/whats_new/upcoming_changes/array-api/30562.feature.rst deleted file mode 100644 index 3c1a58d90bfe5..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/30562.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.confusion_matrix` now supports Array API compatible inputs. - By :user:`Stefanie Senger <StefanieSenger>` diff --git a/doc/whats_new/upcoming_changes/array-api/30777.feature.rst b/doc/whats_new/upcoming_changes/array-api/30777.feature.rst deleted file mode 100644 index aec9bb4da1e71..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/30777.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`sklearn.mixture.GaussianMixture` with - `init_params="random"` or `init_params="random_from_data"` and - `warm_start=False` now supports Array API compatible inputs. - By :user:`Stefanie Senger <StefanieSenger>` and :user:`Loรฏc Estรจve <lesteve>` diff --git a/doc/whats_new/upcoming_changes/array-api/30878.feature.rst b/doc/whats_new/upcoming_changes/array-api/30878.feature.rst deleted file mode 100644 index fabb4c80f5713..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/30878.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.roc_curve` now supports Array API compatible inputs. - By :user:`Thomas Li <lithomas1>` diff --git a/doc/whats_new/upcoming_changes/array-api/31580.feature.rst b/doc/whats_new/upcoming_changes/array-api/31580.feature.rst deleted file mode 100644 index 3d7aaa4372109..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/31580.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :class:`preprocessing.PolynomialFeatures` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/array-api/31671.feature.rst b/doc/whats_new/upcoming_changes/array-api/31671.feature.rst new file mode 100644 index 0000000000000..f9d6a6aecb0b0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/31671.feature.rst @@ -0,0 +1,3 @@ +- :func:`sklearn.metrics.d2_absolute_error_score` and + :func:`sklearn.metrics.d2_pinball_score` now support array API compatible inputs. + By :user:`Virgil Chan <virchan>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32246.feature.rst b/doc/whats_new/upcoming_changes/array-api/32246.feature.rst deleted file mode 100644 index aaf015fd3ff79..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32246.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`calibration.CalibratedClassifierCV` now supports array API compatible - inputs with `method="temperature"` and when the underlying `estimator` also - supports the array API. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/array-api/32249.feature.rst b/doc/whats_new/upcoming_changes/array-api/32249.feature.rst deleted file mode 100644 index f8102a540328f..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32249.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :func:`sklearn.metrics.precision_recall_curve` now supports array API compatible - inputs. - By :user:`Lucy Liu <lucyleeow>` diff --git a/doc/whats_new/upcoming_changes/array-api/32270.feature.rst b/doc/whats_new/upcoming_changes/array-api/32270.feature.rst deleted file mode 100644 index 1b2e4ce05090d..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32270.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.model_selection.cross_val_predict` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/array-api/32422.feature.rst b/doc/whats_new/upcoming_changes/array-api/32422.feature.rst deleted file mode 100644 index fa0cfe503d7f7..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32422.feature.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :func:`sklearn.metrics.brier_score_loss`, :func:`sklearn.metrics.log_loss`, - :func:`sklearn.metrics.d2_brier_score` and :func:`sklearn.metrics.d2_log_loss_score` - now support array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/array-api/32497.feature.rst b/doc/whats_new/upcoming_changes/array-api/32497.feature.rst deleted file mode 100644 index 1b02c72f043af..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32497.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :class:`naive_bayes.GaussianNB` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/array-api/32586.feature.rst b/doc/whats_new/upcoming_changes/array-api/32586.feature.rst deleted file mode 100644 index 8770a2422140b..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32586.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.det_curve` now supports Array API compliant inputs. - By :user:`Josef Affourtit <jaffourt>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32597.feature.rst b/doc/whats_new/upcoming_changes/array-api/32597.feature.rst deleted file mode 100644 index 2d22190b4a052..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32597.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.pairwise.manhattan_distances` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32600.feature.rst b/doc/whats_new/upcoming_changes/array-api/32600.feature.rst deleted file mode 100644 index f39aa06a6cb70..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32600.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.calinski_harabasz_score` now supports Array API compliant inputs. - By :user:`Josef Affourtit <jaffourt>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32604.feature.rst b/doc/whats_new/upcoming_changes/array-api/32604.feature.rst deleted file mode 100644 index 752ea5b9cb3b5..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32604.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.balanced_accuracy_score` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32613.feature.rst b/doc/whats_new/upcoming_changes/array-api/32613.feature.rst deleted file mode 100644 index 34c73b653f475..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32613.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.pairwise.laplacian_kernel` now supports array API compatible inputs. - By :user:`Zubair Shakoor <zubairshakoorarbisoft>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32619.feature.rst b/doc/whats_new/upcoming_changes/array-api/32619.feature.rst deleted file mode 100644 index ba3928cea8bce..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32619.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.cohen_kappa_score` now supports array API compatible inputs. - By :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32644.feature.rst b/doc/whats_new/upcoming_changes/array-api/32644.feature.rst new file mode 100644 index 0000000000000..1b125b81dbd29 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32644.feature.rst @@ -0,0 +1,3 @@ +- :class:`linear_model.LogisticRegression` now supports array API compatible inputs + with `solver="lbfgs"`. + By :user:`Omar Salman <OmarManzoor>` and :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32693.feature.rst b/doc/whats_new/upcoming_changes/array-api/32693.feature.rst deleted file mode 100644 index 466ae99f4e360..0000000000000 --- a/doc/whats_new/upcoming_changes/array-api/32693.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`sklearn.metrics.cluster.davies_bouldin_score` now supports Array API compliant inputs. - By :user:`Josef Affourtit <jaffourt>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32846.fix.rst b/doc/whats_new/upcoming_changes/array-api/32846.fix.rst new file mode 100644 index 0000000000000..c9df3929e14c6 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32846.fix.rst @@ -0,0 +1,3 @@ +- Fixed a bug that would cause Cython-based estimators to fail when fit on + NumPy inputs when setting `sklearn.set_config(array_api_dispatch=True)`. By + :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32909.feature.rst b/doc/whats_new/upcoming_changes/array-api/32909.feature.rst new file mode 100644 index 0000000000000..c3e550401d375 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32909.feature.rst @@ -0,0 +1,3 @@ +- :func:`sklearn.metrics.ranking.average_precision_score` now supports Array API + compliant inputs. + By :user:`Stefanie Senger <StefanieSenger>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32923.fix.rst b/doc/whats_new/upcoming_changes/array-api/32923.fix.rst new file mode 100644 index 0000000000000..ea18ff7aabaca --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32923.fix.rst @@ -0,0 +1,3 @@ +- Fixes how `pos_label` is inferred when `pos_label` is set to `None`, in + :func:`sklearn.metrics.brier_score_loss` and + :func:`sklearn.metrics.d2_brier_score`. By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32979.feature.rst b/doc/whats_new/upcoming_changes/array-api/32979.feature.rst new file mode 100644 index 0000000000000..9a719e514056a --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32979.feature.rst @@ -0,0 +1,2 @@ +- :func:`sklearn.metrics.pairwise.paired_manhattan_distances` now supports array API + compatible inputs. By :user:`Bharat Raghunathan <bharatr21>`. diff --git a/doc/whats_new/upcoming_changes/array-api/32985.feature.rst b/doc/whats_new/upcoming_changes/array-api/32985.feature.rst new file mode 100644 index 0000000000000..18846bce3def0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/32985.feature.rst @@ -0,0 +1,2 @@ +- :func:`sklearn.metrics.pairwise.pairwise_distances_argmin` now supports array API + compatible inputs. By :user:`Bharat Raghunathan <bharatr21>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33020.enhancement.rst b/doc/whats_new/upcoming_changes/array-api/33020.enhancement.rst new file mode 100644 index 0000000000000..ff677c1b30556 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33020.enhancement.rst @@ -0,0 +1,3 @@ +- :class:`linear_model.RidgeCV` now accepts array API compliant arrays + with `gcv_mode` set to `auto` or `eigen`. + By :user:`Antoine Baker <antoinebaker>` diff --git a/doc/whats_new/upcoming_changes/array-api/33076.feature.rst b/doc/whats_new/upcoming_changes/array-api/33076.feature.rst new file mode 100644 index 0000000000000..8053318f56075 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33076.feature.rst @@ -0,0 +1,8 @@ +- :class:`linear_model.LinearRegression`, :class:`linear_model.Ridge`, + :class:`linear_model.RidgeClassifier`, :class:`linear_model.LogisticRegression`, + and :class:`discriminant_analysis.LinearDiscriminantAnalysis` now raise a more + informative error message when arrays passed at fit and prediction time use + different array API namespaces or devices. A new + ``sklearn.utils._array_api.move_estimator_to`` utility is provided to move an + estimator's fitted array attributes to a different namespace and device. + By :user:`Jรฉrรดme Dockรจs <jeromedockes>` and :user:`Tim Head <betatim>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33263.feature.rst b/doc/whats_new/upcoming_changes/array-api/33263.feature.rst new file mode 100644 index 0000000000000..fcded68c3762b --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33263.feature.rst @@ -0,0 +1,2 @@ +- :class:`pipeline.FeatureUnion` now supports Array API compliant inputs when all + its transformers do. By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33348.feature.rst b/doc/whats_new/upcoming_changes/array-api/33348.feature.rst new file mode 100644 index 0000000000000..a390ccc1ddb8e --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33348.feature.rst @@ -0,0 +1,3 @@ +- :class:`linear_model.PoissonRegressor` now supports array API compatible inputs + with `solver="lbfgs"`. + By :user:`Christian Lorentzen <lorentzenchr>` and :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33437.fix.rst b/doc/whats_new/upcoming_changes/array-api/33437.fix.rst new file mode 100644 index 0000000000000..23d9f888f9dae --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33437.fix.rst @@ -0,0 +1,5 @@ +- :func:`linear_model.ridge_regression` now correctly passes a Python scalar as + ``fill_value`` to ``xp.full`` when broadcasting alpha for multi-target + regression, ensuring compliance with the array API specification. This fixes + compatibility issues with some array API backends. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33623.enhancement.rst b/doc/whats_new/upcoming_changes/array-api/33623.enhancement.rst new file mode 100644 index 0000000000000..1aac9ec9edaf1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33623.enhancement.rst @@ -0,0 +1,5 @@ +- Internal NumPy CPU conversions now always attempt a generic DLPack-based + transfer and only fallback to library-specific methods when necessary. This + should ease support for additional array API and DLPack compliant input types + without extending the ad hoc conversion helpers. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/array-api/33873.fix.rst b/doc/whats_new/upcoming_changes/array-api/33873.fix.rst new file mode 100644 index 0000000000000..be187769fafa5 --- /dev/null +++ b/doc/whats_new/upcoming_changes/array-api/33873.fix.rst @@ -0,0 +1,4 @@ +- :func:`metrics.pairwise_distances` no longer emits spurious cross-library + dtype comparison warnings when called with Array API inputs under + ``config_context(array_api_dispatch=True)``. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/changed-models/33272.enhancement.rst b/doc/whats_new/upcoming_changes/changed-models/33272.enhancement.rst new file mode 100644 index 0000000000000..2d1b9a82e109a --- /dev/null +++ b/doc/whats_new/upcoming_changes/changed-models/33272.enhancement.rst @@ -0,0 +1,5 @@ +- The :meth:`transform` method of :class:`preprocessing.PowerTransformer` with + `method="yeo-johnson"` now uses the numerical more stable function + `scipy.stats.yeojohnson` instead of an own implementation. The results may deviate in + numerical edge cases or within the precision of floating-point arithmetic. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/changed-models/33333.api b/doc/whats_new/upcoming_changes/changed-models/33333.api new file mode 100644 index 0000000000000..684030b2e51d6 --- /dev/null +++ b/doc/whats_new/upcoming_changes/changed-models/33333.api @@ -0,0 +1,8 @@ +- The default value of the `scoring` parameter in + :class:`linear_model.LogisticRegressionCV` will change in version 1.11 from `None`, + i.e. accuracy, to `"neg_log_loss"`. This is a much better default scoring function + as it aligns with the log loss that logistic regression is minimizing + (with regularization). + For the meantime, you can silence the warning for this change by explicitly passing + a value to `scoring`. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/custom-top-level/31127.enhancement.rst b/doc/whats_new/upcoming_changes/custom-top-level/31127.enhancement.rst new file mode 100644 index 0000000000000..d4f1f89a9522b --- /dev/null +++ b/doc/whats_new/upcoming_changes/custom-top-level/31127.enhancement.rst @@ -0,0 +1,9 @@ +- Scikit-learn accepted a new library dependency: + `narwhals <https://github.com/narwhals-dev/narwhals>`__. + This is a very lightweight dependency that simplifies the support of dataframe input + `X` and dataframe output as specified in the `set_output` API. Examples are pandas and + polars dataframes. Narwhals can also help to support more dataframe libraries. + Another reason for its adoption was that the dataframe interchange protocol + (`__dataframe__`) on which scikit-learn relied so far for non-pandas dataframes got + deprecated by polars and has run its course. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/custom-top-level/custom-top-level-32079.other.rst b/doc/whats_new/upcoming_changes/custom-top-level/custom-top-level-32079.other.rst deleted file mode 100644 index 0ac966843c075..0000000000000 --- a/doc/whats_new/upcoming_changes/custom-top-level/custom-top-level-32079.other.rst +++ /dev/null @@ -1,23 +0,0 @@ -Free-threaded CPython 3.14 support ----------------------------------- - -scikit-learn has support for free-threaded CPython, in particular -free-threaded wheels are available for all of our supported platforms on Python -3.14. - -Free-threaded (also known as nogil) CPython is a version of CPython that aims at -enabling efficient multi-threaded use cases by removing the Global Interpreter -Lock (GIL). - -If you want to try out free-threaded Python, the recommendation is to use -Python 3.14, that has fixed a number of issues compared to Python 3.13. Feel -free to try free-threaded on your use case and report any issues! - -For more details about free-threaded CPython see `py-free-threading doc <https://py-free-threading.github.io>`_, -in particular `how to install a free-threaded CPython <https://py-free-threading.github.io/installing_cpython/>`_ -and `Ecosystem compatibility tracking <https://py-free-threading.github.io/tracking/>`_. - -By :user:`Loรฏc Estรจve <lesteve>` and :user:`Olivier Grisel <ogrisel>` and many -other people in the wider Scientific Python and CPython ecosystem, for example -:user:`Nathan Goldbaum <ngoldbaum>`, :user:`Ralf Gommers <rgommers>`, -:user:`Edgar Andrรฉs Margffoy Tuay <andfoy>`. diff --git a/doc/whats_new/upcoming_changes/many-modules/31177.major-feature.rst b/doc/whats_new/upcoming_changes/many-modules/31177.major-feature.rst new file mode 100644 index 0000000000000..18e84470e1ef4 --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/31177.major-feature.rst @@ -0,0 +1,9 @@ +- Introduced a new config key: "sparse_interface" to control whether functions + return sparse objects using SciPy sparse matrix or SciPy sparse array. + Use `sklearn.set_config(sparse_interface="sparray")` to have sklearn + return sparse arrays. See more at `the SciPy Sparse Migration Guide. + <https://docs.scipy.org/doc/scipy/reference/sparse.migration_to_sparray.html>`_ + The scikit-learn config "sparse_interface" initially defaults + to sparse matrix ("spmatrix"). The plan is to have the default change to + sparse array ("sparray") in a few releases. + By :user:`Dan Schult <dschult>` diff --git a/doc/whats_new/upcoming_changes/many-modules/31775.efficiency.rst b/doc/whats_new/upcoming_changes/many-modules/31775.efficiency.rst deleted file mode 100644 index 5aa067aeeb7cf..0000000000000 --- a/doc/whats_new/upcoming_changes/many-modules/31775.efficiency.rst +++ /dev/null @@ -1,4 +0,0 @@ -- Improved CPU and memory usage in estimators and metric functions that rely on - weighted percentiles and better match NumPy and Scipy (un-weighted) implementations - of percentiles. - By :user:`Lucy Liu <lucyleeow>` diff --git a/doc/whats_new/upcoming_changes/many-modules/31937.enhancement.rst b/doc/whats_new/upcoming_changes/many-modules/31937.enhancement.rst new file mode 100644 index 0000000000000..d8fc54ff139bb --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/31937.enhancement.rst @@ -0,0 +1,9 @@ +- The HTML representation of all scikit-learn estimators inheriting from + :class:`base.BaseEstimator` now displays a new block showing the number + and names of the output features when using a :class:`compose.ColumnTransformer` + or a :class:`pipeline.FeatureUnion`. A copy-paste button is available + for the output features name. By :user:`Dea Marรญa Lรฉon <DeaMariaLeon>`, + :user:`Guillaume Lemaitre <glemaitre>`, + :user:`Jรฉrรฉmie du Boisberranger <jeremiedbb>`, + :user:`Olivier Grisel <ogrisel>`, + :user:`Antoine Baker <antoinebaker>`. diff --git a/doc/whats_new/upcoming_changes/many-modules/32212.fix.rst b/doc/whats_new/upcoming_changes/many-modules/32212.fix.rst new file mode 100644 index 0000000000000..fbfaa4560aae8 --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/32212.fix.rst @@ -0,0 +1,5 @@ +- Raise ValueError when `sample_weight` contains only zero values to prevent + meaningless input data during fitting. This change applies to all estimators that + support the parameter `sample_weight`. This change also affects metrics that validate + sample weights. + By :user:`Lucy Liu <lucyleeow>` and :user:`John Hendricks <j-hendricks>`. diff --git a/doc/whats_new/upcoming_changes/many-modules/32888.enhancement.rst b/doc/whats_new/upcoming_changes/many-modules/32888.enhancement.rst new file mode 100644 index 0000000000000..09247f7d02ee7 --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/32888.enhancement.rst @@ -0,0 +1,4 @@ +- :class:`pipeline.Pipeline`, :class:`pipeline.FeatureUnion` and + :class:`compose.ColumnTransformer` now raise a clearer + error message when an estimator class is passed instead of an instance. + By :user:`Anne Beyer <AnneBeyer>` diff --git a/doc/whats_new/upcoming_changes/many-modules/32942.fix.rst b/doc/whats_new/upcoming_changes/many-modules/32942.fix.rst new file mode 100644 index 0000000000000..d37df9a5f277a --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/32942.fix.rst @@ -0,0 +1,4 @@ +- Some parameter descriptions in the HTML representation of estimators + were not properly escaped, which could lead to malformed HTML if the + description contains characters like `<` or `>`. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/many-modules/33399.enhancement.rst b/doc/whats_new/upcoming_changes/many-modules/33399.enhancement.rst new file mode 100644 index 0000000000000..433fb535cbe83 --- /dev/null +++ b/doc/whats_new/upcoming_changes/many-modules/33399.enhancement.rst @@ -0,0 +1,10 @@ +- The HTML representation of all scikit-learn estimators + inheriting from :class:`base.BaseEstimator` now includes a table + displaying their fitted :term:`attributes`. These are all the public + estimator attributes that are computed during the call to :term:`fit` + with a name that ends with an underscore. + By :user:`Dea Marรญa Lรฉon <DeaMariaLeon>`, + :user:`Jรฉrรฉmie du Boisberranger <jeremiedbb>`, + :user:`Olivier Grisel <ogrisel>`, + :user:`Guillaume Lemaitre <glemaitre>`, + :user:`Antoine Baker <antoinebaker>`. diff --git a/doc/whats_new/upcoming_changes/metadata-routing/31898.fix.rst b/doc/whats_new/upcoming_changes/metadata-routing/31898.fix.rst deleted file mode 100644 index bb4b71974ca60..0000000000000 --- a/doc/whats_new/upcoming_changes/metadata-routing/31898.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fixed an issue where passing `sample_weight` to a :class:`Pipeline` inside a - :class:`GridSearchCV` would raise an error with metadata routing enabled. - By `Adrin Jalali`_. diff --git a/doc/whats_new/upcoming_changes/metadata-routing/33089.enhancement.rst b/doc/whats_new/upcoming_changes/metadata-routing/33089.enhancement.rst new file mode 100644 index 0000000000000..c7588da78f75b --- /dev/null +++ b/doc/whats_new/upcoming_changes/metadata-routing/33089.enhancement.rst @@ -0,0 +1,5 @@ +- :class:`~preprocessing.TargetEncoder` now routes `groups` to the :term:`CV splitter` + internally used for :term:`cross fitting` in its + :meth:`~preprocessing.TargetEncoder.fit_transform`. + By :user:`Samruddhi Baviskar <samruddhibaviskar11>` and + :user:`Stefanie Senger <StefanieSenger>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.base/31928.feature.rst b/doc/whats_new/upcoming_changes/sklearn.base/31928.feature.rst deleted file mode 100644 index 65b94b580f3de..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.base/31928.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Refactored :meth:`dir` in :class:`BaseEstimator` to recognize condition check in :meth:`available_if`. - By :user:`John Hendricks <j-hendricks>` and :user:`Miguel Parece <MiguelParece>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.base/32341.fix.rst b/doc/whats_new/upcoming_changes/sklearn.base/32341.fix.rst deleted file mode 100644 index d5437f8273d37..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.base/32341.fix.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Fixed the handling of pandas missing values in HTML display of all estimators. - By :user: `Dea Marรญa Lรฉon <deamarialeon>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.calibration/31068.feature.rst b/doc/whats_new/upcoming_changes/sklearn.calibration/31068.feature.rst deleted file mode 100644 index 4201db9ad0e59..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.calibration/31068.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Added temperature scaling method in :class:`calibration.CalibratedClassifierCV`. - By :user:`Virgil Chan <virchan>` and :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.cluster/24681.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.cluster/24681.enhancement.rst new file mode 100644 index 0000000000000..5ef1b655f6655 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.cluster/24681.enhancement.rst @@ -0,0 +1,4 @@ +- :class:`cluster.AgglomerativeClustering` and + :class:`cluster.FeatureAgglomeration` now accept `metric="l2"` together with + `linkage="ward"`. `metric="l2"` is equivalent to `metric="euclidean"`. + :pr:`24681` by :user:`Guillaume Lemaitre <glemaitre>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.cluster/30751.fix.rst b/doc/whats_new/upcoming_changes/sklearn.cluster/30751.fix.rst new file mode 100644 index 0000000000000..f1ebdbd79d46f --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.cluster/30751.fix.rst @@ -0,0 +1,6 @@ +- :class:`cluster.MiniBatchKMeans` now correctly handles sample weights + during fitting. When sample weights are not None, mini-batch + indices are created by sub-sampling with replacement using the + normalized sample weights as probabilities. + By :user:`Shruti Nath <snath-xoc>`, :user:`Olivier Grisel <ogrisel>`, + and :user:`Jeremie du Boisberranger <jeremiedbb>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.cluster/31973.fix.rst b/doc/whats_new/upcoming_changes/sklearn.cluster/31973.fix.rst deleted file mode 100644 index f04abbb889f7d..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.cluster/31973.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- The default value of the `copy` parameter in :class:`cluster.HDBSCAN` - will change from `False` to `True` in 1.10 to avoid data modification - and maintain consistency with other estimators. - By :user:`Sarthak Puri <sarthakpurii>`. \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.cluster/31991.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.cluster/31991.efficiency.rst deleted file mode 100644 index 955b8b9ef4c14..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.cluster/31991.efficiency.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :func:`cluster.kmeans_plusplus` now uses `np.cumsum` directly without extra - numerical stability checks and without casting to `np.float64`. - By :user:`Tiziano Zito <otizonaizit>` diff --git a/doc/whats_new/upcoming_changes/sklearn.cluster/33148.fix.rst b/doc/whats_new/upcoming_changes/sklearn.cluster/33148.fix.rst new file mode 100644 index 0000000000000..82d5c21738d63 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.cluster/33148.fix.rst @@ -0,0 +1,3 @@ +- Fixed a bug in :class:`cluster.BisectingKMeans` when using a custom callable `init` + with `n_clusters > 2`. + By :user:`Mohammad Ahmadullah Khan <MAUK9086>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.compose/32188.fix.rst b/doc/whats_new/upcoming_changes/sklearn.compose/32188.fix.rst deleted file mode 100644 index 1bd73934a426c..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.compose/32188.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- The :class:`compose.ColumnTransformer` now correctly fits on data provided as a - `polars.DataFrame` when any transformer has a sparse output. - By :user:`Phillipp Gnan <ph-ll-pp>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.compose/32713.fix.rst b/doc/whats_new/upcoming_changes/sklearn.compose/32713.fix.rst new file mode 100644 index 0000000000000..6eb85870877b1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.compose/32713.fix.rst @@ -0,0 +1,4 @@ +- The dotted line for :class:`compose.ColumnTransformer` in its HTML display + now includes only its elements. The behaviour when a remainder is used, + has also been corrected. + By :user:`Dea Marรญa Lรฉon <deamarialeon>` diff --git a/doc/whats_new/upcoming_changes/sklearn.compose/33665.fix.rst b/doc/whats_new/upcoming_changes/sklearn.compose/33665.fix.rst new file mode 100644 index 0000000000000..aa29d57288bba --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.compose/33665.fix.rst @@ -0,0 +1,4 @@ +- Fixes the regression that a `KeyError` was thrown when using + :func:`compose.ColumnTransformer.fit_transform` with metadata routing and + `remainder="passthrough"`. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.covariance/31987.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.covariance/31987.efficiency.rst deleted file mode 100644 index a05849fd84ad8..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.covariance/31987.efficiency.rst +++ /dev/null @@ -1,6 +0,0 @@ -- :class:`sklearn.covariance.GraphicalLasso`, - :class:`sklearn.covariance.GraphicalLassoCV` and - :func:`sklearn.covariance.graphical_lasso` with `mode="cd"` profit from the - fit time performance improvement of :class:`sklearn.linear_model.Lasso` by means of - gap safe screening rules. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.covariance/31987.fix.rst b/doc/whats_new/upcoming_changes/sklearn.covariance/31987.fix.rst deleted file mode 100644 index 1728c7f9ead6e..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.covariance/31987.fix.rst +++ /dev/null @@ -1,6 +0,0 @@ -- Fixed uncontrollable randomness in :class:`sklearn.covariance.GraphicalLasso`, - :class:`sklearn.covariance.GraphicalLassoCV` and - :func:`sklearn.covariance.graphical_lasso`. For `mode="cd"`, they now use cyclic - coordinate descent. Before, it was random coordinate descent with uncontrollable - random number seeding. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.covariance/32117.fix.rst b/doc/whats_new/upcoming_changes/sklearn.covariance/32117.fix.rst deleted file mode 100644 index fb8145e22e5ed..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.covariance/32117.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- Added correction to :class:`covariance.MinCovDet` to adjust for - consistency at the normal distribution. This reduces the bias present - when applying this method to data that is normally distributed. - By :user:`Daniel Herrera-Esposito <dherrera1911>` diff --git a/doc/whats_new/upcoming_changes/sklearn.datasets/33118.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.datasets/33118.efficiency.rst new file mode 100644 index 0000000000000..8518bcb840196 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.datasets/33118.efficiency.rst @@ -0,0 +1,3 @@ +- Re-enabled compressed caching for :func:`datasets.fetch_kddcup99`, reducing + on-disk cache size without changing the public API. + By :user:`Unique Shrestha <un1u3>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.datasets/33868.fix.rst b/doc/whats_new/upcoming_changes/sklearn.datasets/33868.fix.rst new file mode 100644 index 0000000000000..0c5f423a0e7e0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.datasets/33868.fix.rst @@ -0,0 +1,5 @@ +- Fixed :func:`datasets.fetch_openml` to issue OpenML API calls to + ``https://www.openml.org/api/v1/`` instead of + ``https://api.openml.org/api/v1/``, which no longer resolves or redirects + correctly. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.decomposition/29310.fix.rst b/doc/whats_new/upcoming_changes/sklearn.decomposition/29310.fix.rst deleted file mode 100644 index a6ff94cdac6ab..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.decomposition/29310.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Add input checks to the `inverse_transform` method of :class:`decomposition.PCA` - and :class:`decomposition.IncrementalPCA`. - :pr:`29310` by :user:`Ian Faust <icfaust>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.decomposition/31987.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.decomposition/31987.efficiency.rst deleted file mode 100644 index 8edfdfcb74d31..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.decomposition/31987.efficiency.rst +++ /dev/null @@ -1,11 +0,0 @@ -- :class:`sklearn.decomposition.DictionaryLearning` and - :class:`sklearn.decomposition.MiniBatchDictionaryLearning` with `fit_algorithm="cd"`, - :class:`sklearn.decomposition.SparseCoder` with `transform_algorithm="lasso_cd"`, - :class:`sklearn.decomposition.MiniBatchSparsePCA`, - :class:`sklearn.decomposition.SparsePCA`, - :func:`sklearn.decomposition.dict_learning` and - :func:`sklearn.decomposition.dict_learning_online` with `method="cd"`, - :func:`sklearn.decomposition.sparse_encode` with `algorithm="lasso_cd"` - all profit from the fit time performance improvement of - :class:`sklearn.linear_model.Lasso` by means of gap safe screening rules. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.decomposition/32077.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.decomposition/32077.enhancement.rst deleted file mode 100644 index aacff8ae1b76c..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.decomposition/32077.enhancement.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`decomposition.SparseCoder` now follows the transformer API of scikit-learn. - In addition, the :meth:`fit` method now validates the input and parameters. - By :user:`Franรงois Paugam <FrancoisPgm>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.decomposition/33269.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.decomposition/33269.efficiency.rst new file mode 100644 index 0000000000000..59affd41b45f7 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.decomposition/33269.efficiency.rst @@ -0,0 +1,3 @@ +- :class:`~sklearn.decomposition.FastICA` with `algorithm='deflation'` and + `fun='logcosh'` is now an order of magnitude faster. + By :user:`Mohammad Ahmadullah Khan <MAUK9086>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.decomposition/33492.fix.rst b/doc/whats_new/upcoming_changes/sklearn.decomposition/33492.fix.rst new file mode 100644 index 0000000000000..c368f41bfd073 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.decomposition/33492.fix.rst @@ -0,0 +1,3 @@ +- Fixed a typo (from `"OR"` to `"QR"`) in the list of allowed values for + `power_iteration_normalizer` in :class:`decomposition.TruncatedSVD`. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.discriminant_analysis/32108.feature.rst b/doc/whats_new/upcoming_changes/sklearn.discriminant_analysis/32108.feature.rst deleted file mode 100644 index 1379a834c63a4..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.discriminant_analysis/32108.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -- Added `solver`, `covariance_estimator` and `shrinkage` in - :class:`discriminant_analysis.QuadraticDiscriminantAnalysis`. - The resulting class is more similar to - :class:`discriminant_analysis.LinearDiscriminantAnalysis` - and allows for more flexibility in the estimation of the covariance matrices. - By :user:`Daniel Herrera-Esposito <dherrera1911>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.ensemble/29641.fix.rst b/doc/whats_new/upcoming_changes/sklearn.ensemble/29641.fix.rst new file mode 100644 index 0000000000000..987fcf033a3fe --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.ensemble/29641.fix.rst @@ -0,0 +1,11 @@ +- Fixed the way :class:`ensemble.HistGradientBoostingClassifier` and + `ensemble.HistGradientBoostingRegressor` compute their bin edges to properly and + consistently handle :term:`sample_weight`. When `sample_weights=None` is + passed to `fit` and the number of distinct feature values is less than the + specified `max_bins`, the edges are still set to midpoints between consecutive + feature values. Otherwise, the bin edges are set to weight-aware quantiles + computed using the averaged inverted CDF method. If `n_samples` is larger than + the `subsample` parameter, the weights are instead used to subsample the data + (with replacement) and the bin edges are set using unweighted quantiles of the + subsampled data. By + :user:`Shruti Nath <snath-xoc>` and :user:`Olivier Grisel <ogrisel>` diff --git a/doc/whats_new/upcoming_changes/sklearn.ensemble/31414.fix.rst b/doc/whats_new/upcoming_changes/sklearn.ensemble/31414.fix.rst deleted file mode 100644 index 17c2f765d4b7c..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.ensemble/31414.fix.rst +++ /dev/null @@ -1,7 +0,0 @@ -- :class:`ensemble.BaggingClassifier`, :class:`ensemble.BaggingRegressor` - and :class:`ensemble.IsolationForest` now use `sample_weight` to draw - the samples instead of forwarding them multiplied by a uniformly sampled - mask to the underlying estimators. Furthermore, `max_samples` is now - interpreted as a fraction of `sample_weight.sum()` instead of `X.shape[0]` - when passed as a float. - By :user:`Antoine Baker <antoinebaker>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.ensemble/31529.fix.rst b/doc/whats_new/upcoming_changes/sklearn.ensemble/31529.fix.rst new file mode 100644 index 0000000000000..adac2129baf0a --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.ensemble/31529.fix.rst @@ -0,0 +1,10 @@ +- :class:`ensemble.RandomForestClassifier`, :class:`ensemble.RandomForestRegressor`, + :class:`ensemble.ExtraTreesClassifier` and :class:`ensemble.ExtraTreesRegressor` + now use `sample_weight` to draw the samples instead of forwarding them + multiplied by a uniformly sampled mask to the underlying estimators. + Furthermore, when `max_samples` is a float, it is now interpreted as a + fraction of `sample_weight.sum()` instead of `X.shape[0]`. As sampling is done + with replacement, a float `max_samples` greater than `1.0` is now allowed, as + well as an integer `max_samples` greater then `X.shape[0]`. The default + `max_samples=None` draws `X.shape[0]` samples, irrespective of `sample_weight`. + By :user:`Antoine Baker <antoinebaker>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.api.rst b/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.api.rst new file mode 100644 index 0000000000000..69bac5a1ae540 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.api.rst @@ -0,0 +1,6 @@ +- The `criterion` parameter is now deprecated for classes + :class:`ensemble.GradientBoostingRegressor` + and :class:`ensemble.GradientBoostingClassifier`, as both options + (`"friedman_mse"` and `"squared_error"`) were producing the same results, + up to floating-point rounding discrepancies and a bug in `"friedman_mse"`. + By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.fix.rst b/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.fix.rst new file mode 100644 index 0000000000000..f80975de936b7 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.ensemble/32708.fix.rst @@ -0,0 +1,7 @@ +- Both :class:`ensemble.GradientBoostingRegressor` and + :class:`ensemble.GradientBoostingClassifier` with the default + `"friedman_mse"` criterion were computing impurity values with an incorrect scaling, + leading to unexpected trees in some cases. The implementation now uses + `"squared_error"`, which is exactly equivalent to `"friedman_mse"` up to + floating-point error discrepancies but computes correct impurity values. + By :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.feature_extraction/33643.fix.rst b/doc/whats_new/upcoming_changes/sklearn.feature_extraction/33643.fix.rst new file mode 100644 index 0000000000000..e04b8446a8354 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.feature_extraction/33643.fix.rst @@ -0,0 +1,4 @@ +- :func:`feature_extraction.image.reconstruct_from_patches_2d` now produces + correct results when a patch dimension equals the corresponding image + dimension. + By :user:`Eden Rochman <EdenRochmanSharabi>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.feature_selection/29532.fix.rst b/doc/whats_new/upcoming_changes/sklearn.feature_selection/29532.fix.rst new file mode 100644 index 0000000000000..5b631123a7885 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.feature_selection/29532.fix.rst @@ -0,0 +1,4 @@ +- :class:`feature_selection.RFE` now uses stable sorting when ranking feature + importances. This ensures that the feature selection is deterministic and consistent + across runs when feature importances are tied. + By :user:`blitchj <blitchj>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.feature_selection/31939.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.feature_selection/31939.enhancement.rst deleted file mode 100644 index 8c038c35389ed..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.feature_selection/31939.enhancement.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`feature_selection.SelectFromModel` now does not force `max_features` to be - less than or equal to the number of input features. - By :user:`Thibault <ThibaultDECO>` diff --git a/doc/whats_new/upcoming_changes/sklearn.feature_selection/33786.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.feature_selection/33786.enhancement.rst new file mode 100644 index 0000000000000..aa61e2076bacc --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.feature_selection/33786.enhancement.rst @@ -0,0 +1,5 @@ +- :class:`feature_selection.SelectFromModel` and :class:`feature_selection.RFE` + now support estimators whose feature importance is a sparse matrix or array, notably + by passing a user-defined callable to the parameter `importance_getter`. + By :user:`andymucyo-ops <andymucyo-ops>` and + :user:`isaacambrogetti <isaacambrogetti>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.gaussian_process/31431.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.gaussian_process/31431.efficiency.rst deleted file mode 100644 index 798f2ebb6bd2f..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.gaussian_process/31431.efficiency.rst +++ /dev/null @@ -1,3 +0,0 @@ -- make :class:`GaussianProcessRegressor.predict` faster when `return_cov` and - `return_std` are both `False`. - By :user:`Rafael Ayllรณn Gavilรกn <RafaAyGar>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.gaussian_process/32964.fix.rst b/doc/whats_new/upcoming_changes/sklearn.gaussian_process/32964.fix.rst new file mode 100644 index 0000000000000..73f915b8dde93 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.gaussian_process/32964.fix.rst @@ -0,0 +1,5 @@ +- The hyperparameters of the default kernel of :class:`~sklearn.gaussian_process.GaussianProcessRegressor`, + namely `ConstantKernel() * RBF()`, + are now optimized when `optimizer` is not `None`. + Thus, `gpr = GaussianProcessRegressor().fit(X, y)` uses optimized kernel hyperparameters. + By :user:`Matthias De Lozzo <mdelozzo>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.gaussian_process/33067.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.gaussian_process/33067.efficiency.rst new file mode 100644 index 0000000000000..4ea6331076753 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.gaussian_process/33067.efficiency.rst @@ -0,0 +1,3 @@ +- Constructor signature of Gaussian process kernels is now cached, + improving performance on small and medium datasets. + By :user:`Stanislav Terliakov <sterliakov>` \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33015.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33015.fix.rst new file mode 100644 index 0000000000000..393f15198d4e1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33015.fix.rst @@ -0,0 +1,3 @@ +- In :class:`inspection.DecisionBoundaryDisplay`, `multiclass_colors` is now also used + for multiclass plotting when `response_method="predict"`. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33202.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33202.fix.rst new file mode 100644 index 0000000000000..998fd48b78f22 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33202.fix.rst @@ -0,0 +1,4 @@ +- In :class:`inspection.DecisionBoundaryDisplay`, `n_classes` is now inferred more + robustly from the estimator. If it fails for custom estimators, a comprehensive error + message is shown. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33300.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33300.fix.rst new file mode 100644 index 0000000000000..2a96cd9af085e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33300.fix.rst @@ -0,0 +1,5 @@ +- :class:`inspection.DecisionBoundaryDisplay` now displays all class boundaries when + using ``plot_method="contour"`` with all response_methods, and displays all classes + in distinct colors when using ``plot_method="contourf"`` with + ``response_method="predict"``. + By :user:`Anne Beyer <AnneBeyer>` and :user:`Levente Csibi <leweex95>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33419.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33419.fix.rst new file mode 100644 index 0000000000000..e0da40b4044b5 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33419.fix.rst @@ -0,0 +1,4 @@ +- In :class:`inspection.DecisionBoundaryDisplay`, a `ValueError` is now raised if the + colormap passed to `multiclass_colors` contains fewer colors than there are classes in + multiclass problems. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33471.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33471.fix.rst new file mode 100644 index 0000000000000..585a4ee4e4197 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33471.fix.rst @@ -0,0 +1,5 @@ +- For multiclass data, :class:`inspection.DecisionBoundaryDisplay` with + ``plot_method="contour"`` now also displays class-specific contours for + ``response_method="predict_proba"`` and ``response_method="decision_function"``. + Multiclass class boundary contour lines are now displayed in black by default for all + response methods to avoid confusion. By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33651.fix.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33651.fix.rst new file mode 100644 index 0000000000000..45da8a577aa8b --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33651.fix.rst @@ -0,0 +1,3 @@ +- In :class:`inspection.DecisionBoundaryDisplay`, `multiclass_colors_` now always stores + the colors for multiclass problems as a numpy array. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.inspection/33709.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.inspection/33709.enhancement.rst new file mode 100644 index 0000000000000..3de50488d0db0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.inspection/33709.enhancement.rst @@ -0,0 +1,4 @@ +- In :class:`inspection.DecisionBoundaryDisplay`, `multiclass_colors` now defaults to + the more accessible [Petroff color sequence](https://arxiv.org/abs/2107.02270) for + multiclass problems with up to 10 classes. + By :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/29097.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/29097.api.rst deleted file mode 100644 index 8cb6265a607a5..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/29097.api.rst +++ /dev/null @@ -1,7 +0,0 @@ -- :class:`linear_model.PassiveAggressiveClassifier` and - :class:`linear_model.PassiveAggressiveRegressor` are deprecated and will be removed - in 1.10. Equivalent estimators are available with :class:`linear_model.SGDClassifier` - and :class:`SGDRegressor`, both of which expose the options `learning_rate="pa1"` and - `"pa2"`. The parameter `eta0` can be used to specify the aggressiveness parameter of - the Passive-Aggressive-Algorithms, called C in the reference paper. - By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31932` and diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31474.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31474.api.rst deleted file mode 100644 index 845b9b502b9f1..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31474.api.rst +++ /dev/null @@ -1,6 +0,0 @@ -- :class:`linear_model.SGDClassifier`, :class:`linear_model.SGDRegressor`, and - :class:`linear_model.SGDOneClassSVM` now deprecate negative values for the - `power_t` parameter. Using a negative value will raise a warning in version 1.8 - and will raise an error in version 1.10. A value in the range [0.0, inf) must be used - instead. - By :user:`Ritvi Alagusankar <ritvi-alagusankar>` \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31665.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31665.efficiency.rst deleted file mode 100644 index 24a8d53f80b23..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31665.efficiency.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`linear_model.ElasticNet` and :class:`linear_model.Lasso` with - `precompute=False` use less memory for dense `X` and are a bit faster. - Previously, they used twice the memory of `X` even for Fortran-contiguous `X`. - By :user:`Christian Lorentzen <lorentzenchr>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31848.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31848.efficiency.rst deleted file mode 100644 index b76b7cacc8328..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31848.efficiency.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`linear_model.ElasticNet` and :class:`linear_model.Lasso` avoid - double input checking and are therefore a bit faster. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31856.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31856.fix.rst deleted file mode 100644 index 8d9138d2b449a..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31856.fix.rst +++ /dev/null @@ -1,6 +0,0 @@ -- Fix the convergence criteria for SGD models, to avoid premature convergence when - `tol != None`. This primarily impacts :class:`SGDOneClassSVM` but also affects - :class:`SGDClassifier` and :class:`SGDRegressor`. Before this fix, only the loss - function without penalty was used as the convergence check, whereas now, the full - objective with regularization is used. - By :user:`Guillaume Lemaitre <glemaitre>` and :user:`kostayScr <kostayScr>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31880.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31880.efficiency.rst deleted file mode 100644 index 195eb42d907eb..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31880.efficiency.rst +++ /dev/null @@ -1,9 +0,0 @@ -- :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, - :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, - :class:`linear_model.MultiTaskElasticNet`, - :class:`linear_model.MultiTaskElasticNetCV`, - :class:`linear_model.MultiTaskLasso` and :class:`linear_model.MultiTaskLassoCV` - are faster to fit by avoiding a BLAS level 1 (axpy) call in the innermost loop. - Same for functions :func:`linear_model.enet_path` and - :func:`linear_model.lasso_path`. - By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31956` and diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31888.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31888.api.rst deleted file mode 100644 index a1ac21999bb09..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31888.api.rst +++ /dev/null @@ -1,4 +0,0 @@ -- Raising error in :class:`sklearn.linear_model.LogisticRegression` when - liblinear solver is used and input X values are larger than 1e30, - the liblinear solver freezes otherwise. - By :user:`Shruti Nath <snath-xoc>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31906.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31906.enhancement.rst deleted file mode 100644 index 8417c3dd2ac29..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31906.enhancement.rst +++ /dev/null @@ -1,9 +0,0 @@ -- :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, - :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, - :class:`MultiTaskElasticNet`, :class:`MultiTaskElasticNetCV`, - :class:`MultiTaskLasso`, :class:`MultiTaskLassoCV`, as well as - :func:`linear_model.enet_path` and :func:`linear_model.lasso_path` - now use `dual gap <= tol` instead of `dual gap < tol` as stopping criterion. - The resulting coefficients might differ to previous versions of scikit-learn in - rare cases. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31933.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31933.fix.rst deleted file mode 100644 index b4995b3908c35..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31933.fix.rst +++ /dev/null @@ -1,8 +0,0 @@ -- The allowed parameter range for the initial learning rate `eta0` in - :class:`linear_model.SGDClassifier`, :class:`linear_model.SGDOneClassSVM`, - :class:`linear_model.SGDRegressor` and :class:`linear_model.Perceptron` - changed from non-negative numbers to strictly positive numbers. - As a consequence, the default `eta0` of :class:`linear_model.SGDClassifier` - and :class:`linear_model.SGDOneClassSVM` changed from 0 to 0.01. But note that - `eta0` is not used by the default learning rate "optimal" of those two estimators. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/31946.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/31946.efficiency.rst deleted file mode 100644 index 0a4fc0bccf2a6..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/31946.efficiency.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`linear_model.ElasticNetCV`, :class:`linear_model.LassoCV`, - :class:`linear_model.MultiTaskElasticNetCV` and :class:`linear_model.MultiTaskLassoCV` - avoid an additional copy of `X` with default `copy_X=True`. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32014.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32014.efficiency.rst deleted file mode 100644 index 6aab24b0854c5..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/32014.efficiency.rst +++ /dev/null @@ -1,13 +0,0 @@ -- :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, - :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, - :class:`linear_model.MultiTaskElasticNetCV`, :class:`linear_model.MultiTaskLassoCV` - as well as - :func:`linear_model.lasso_path` and :func:`linear_model.enet_path` now implement - gap safe screening rules in the coordinate descent solver for dense and sparse `X`. - The speedup of fitting time is particularly pronounced (10-times is possible) when - computing regularization paths like the \*CV-variants of the above estimators do. - There is now an additional check of the stopping criterion before entering the main - loop of descent steps. As the stopping criterion requires the computation of the dual - gap, the screening happens whenever the dual gap is computed. - By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31882`, :pr:`31986`, - :pr:`31987` and diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32114.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32114.api.rst deleted file mode 100644 index 7b6768464cf81..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/32114.api.rst +++ /dev/null @@ -1,16 +0,0 @@ -- :class:`linear_model.LogisticRegressionCV` got a new parameter - `use_legacy_attributes` to control the types and shapes of the fitted attributes - `C_`, `l1_ratio_`, `coefs_paths_`, `scores_` and `n_iter_`. - The current default value `True` keeps the legacy behaviour. If `False` then: - - - ``C_`` is a float. - - ``l1_ratio_`` is a float. - - ``coefs_paths_`` is an ndarray of shape - (n_folds, n_l1_ratios, n_cs, n_classes, n_features). - For binary problems (n_classes=2), the 2nd last dimension is 1. - - ``scores_`` is an ndarray of shape (n_folds, n_l1_ratios, n_cs). - - ``n_iter_`` is an ndarray of shape (n_folds, n_l1_ratios, n_cs). - - In version 1.10, the default will change to `False` and `use_legacy_attributes` will - be deprecated. In 1.12 `use_legacy_attributes` will be removed. - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32644.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32644.efficiency.rst new file mode 100644 index 0000000000000..74220bbd7faa2 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/32644.efficiency.rst @@ -0,0 +1,7 @@ +- :class:`linear_model.LogisticRegression` with `solver="lbfgs"` now estimates + the gradient of the loss at `float32` precision when fitted with `float32` + data (`X`) to improve training speed and memory efficiency. Previously, the input + data would be implicitly cast to `float64`. If you relied on the previous + behavior for numerical reasons, you can explicitly cast your data to + `float64` before fitting to reproduce it. + By :user:`Omar Salman <OmarManzoor>` and :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32659.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32659.api.rst deleted file mode 100644 index 00b3cd23a7de3..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/32659.api.rst +++ /dev/null @@ -1,27 +0,0 @@ -- Parameter `penalty` of :class:`linear_model.LogisticRegression` and - :class:`linear_model.LogisticRegressionCV` is deprecated and will be removed in - version 1.10. The equivalent behaviour can be obtained as follows: - - - for :class:`linear_model.LogisticRegression` - - - use `l1_ratio=0` instead of `penalty="l2"` - - use `l1_ratio=1` instead of `penalty="l1"` - - use `0<l1_ratio<1` instead of `penalty="elasticnet"` - - use `C=np.inf` instead of `penalty=None` - - - for :class:`linear_model.LogisticRegressionCV` - - - use `l1_ratios=(0,)` instead of `penalty="l2"` - - use `l1_ratios=(1,)` instead of `penalty="l1"` - - the equivalent of `penalty=None` is to have `np.inf` as an element of the `Cs` parameter - - For :class:`linear_model.LogisticRegression`, the default value of `l1_ratio` - has changed from `None` to `0.0`. Setting `l1_ratio=None` is deprecated and - will raise an error in version 1.10 - - For :class:`linear_model.LogisticRegressionCV`, the default value of `l1_ratios` - has changed from `None` to `"warn"`. It will be changed to `(0,)` in version - 1.10. Setting `l1_ratios=None` is deprecated and will raise an error in - version 1.10. - - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32742.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32742.api.rst deleted file mode 100644 index 0fd15ccf7371e..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/32742.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -- The `n_jobs` parameter of :class:`linear_model.LogisticRegression` is deprecated and - will be removed in 1.10. It has no effect since 1.8. - By :user:`Loรฏc Estรจve <lesteve>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32747.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32747.fix.rst deleted file mode 100644 index 38e560d6f6f75..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.linear_model/32747.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`linear_model.LogisticRegressionCV` is able to handle CV splits where - some class labels are missing in some folds. Before, it raised an error whenever a - class label were missing in a fold. - By :user:`Christian Lorentzen <lorentzenchr> diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32768.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32768.fix.rst new file mode 100644 index 0000000000000..67f1bee7687d8 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/32768.fix.rst @@ -0,0 +1,5 @@ +- :class:`linear_model.LassoCV` and :class:`linear_model.ElasticNetCV` now + take the `positive` parameter into account to compute the maximum `alpha` parameter, + where all coefficients are zero. This impacts the search grid for the + internally tuned `alpha` hyper-parameter stored in the attribute `alphas_`. + By :user:`Junteng Li <JasonLiJT>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32778.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32778.fix.rst new file mode 100644 index 0000000000000..5dedb5f37e6e2 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/32778.fix.rst @@ -0,0 +1,5 @@ +- Correct the formulation of `alpha` within :class:`linear_model.SGDOneClassSVM`. + The corrected value is `alpha = nu` instead of `alpha = nu / 2`. + Note: This might result in changed values for the fitted attributes like + `coef_` and `offset_` as well as the predictions made using this class. + By :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32828.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32828.enhancement.rst new file mode 100644 index 0000000000000..d16333467b187 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/32828.enhancement.rst @@ -0,0 +1,4 @@ +- :class:`linear_model.LogisticRegressionCV` now correctly handles the case when the + `scoring` parameter is set (to something not `None`) and when the CV splits result in + folds where some class labels are missing. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/32845.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/32845.enhancement.rst new file mode 100644 index 0000000000000..332a2b11ed160 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/32845.enhancement.rst @@ -0,0 +1,7 @@ +- :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV` and + :func:`linear_model.enet_path` + now are able to fit Ridge regression, i.e. setting `l1_ratio=0`. + Before this PR, the stopping criterion was a formulation of the dual gap that breaks + down for `l1_ratio=0`. Now, an alternative dual gap formulation is used for this + setting. This reduces the noise of raised warnings. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33014.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33014.fix.rst new file mode 100644 index 0000000000000..83150ff46d8a0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33014.fix.rst @@ -0,0 +1,6 @@ +- :func:`linear_model.enet_path` now correctly handles the ``precompute`` + parameter when ``check_input=False``. Previously, the value of + ``precompute`` was not properly treated which could lead to a ValueError. + This also affects :class:`linear_model.ElasticNetCV`, :class:`linear_model.LassoCV`, + :class:`linear_model.MultiTaskElasticNetCV` and :class:`linear_model.MultiTaskLassoCV`. + By :user:`Albert Dorador <adc-trust-ai>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33020.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33020.fix.rst new file mode 100644 index 0000000000000..5a4c715edbc12 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33020.fix.rst @@ -0,0 +1,10 @@ +- The leave-one out errors and model parameters estimated in + :class:`linear_model.RidgeCV` and :class:`linear_model.RidgeClassifierCV` when + `cv=None` are now numerically stable in the small `alpha` regime. The default + `auto` option is now equivalent to `eigen` and picks the cheaper option: + eigendecomposition of the covariance matrix when `n_features <= n_samples`, + respectively of the Gram matrix when `n_samples > n_features`. When + `store_cv_results=True` and `X` is an integer array, the `cv_results_` + attribute was wrongly coerced to the integer dtype of `X`, it now always has a + float dtype. + By :user:`Antoine Baker <antoinebaker>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33041.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33041.efficiency.rst new file mode 100644 index 0000000000000..332ebcf68417e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33041.efficiency.rst @@ -0,0 +1,7 @@ +- The :class:`linear_model.LinearRegression`, :class:`linear_model.Ridge`, + :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, + :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV` and + :class:`linear_model.BayesianRidge` classes now no longer make an unnecessary copy of + dense `X, y` input during preprocessing when `copy_X=False` and `sample_weight` + is provided. + By :user:`Junteng Li <JasonLiJT>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33161.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33161.enhancement.rst new file mode 100644 index 0000000000000..4d15cdaf269b2 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33161.enhancement.rst @@ -0,0 +1,9 @@ +- |Efficiency| :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, + :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, + :class:`linear_model.MultiTaskElasticNet`, :class:`linear_model.MultiTaskElasticNetCV` + :class:`linear_model.MultiTaskLasso`, :class:`linear_model.MultiTaskLassoCV` + as well as + :func:`linear_model.lasso_path` and :func:`linear_model.enet_path` are now faster when + fit with strong L1 penalty and many features. During gap safe screening of features, + the update of the residual is now only performed if the coefficient is not zero. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33168.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33168.fix.rst new file mode 100644 index 0000000000000..d918df1e36ae4 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33168.fix.rst @@ -0,0 +1,4 @@ +- Fixed a bug in :class:`linear_model.SGDClassifier` for multiclass settings where + large negative values of :method:`decision_function` could lead to NaN values. In + this case, this fix assigns equal probability for each class. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33333.api b/doc/whats_new/upcoming_changes/sklearn.linear_model/33333.api new file mode 100644 index 0000000000000..684030b2e51d6 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33333.api @@ -0,0 +1,8 @@ +- The default value of the `scoring` parameter in + :class:`linear_model.LogisticRegressionCV` will change in version 1.11 from `None`, + i.e. accuracy, to `"neg_log_loss"`. This is a much better default scoring function + as it aligns with the log loss that logistic regression is minimizing + (with regularization). + For the meantime, you can silence the warning for this change by explicitly passing + a value to `scoring`. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33440.feature.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33440.feature.rst new file mode 100644 index 0000000000000..c39e018db60b6 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33440.feature.rst @@ -0,0 +1,5 @@ +- :class:`linear_model.MultiTaskElasticNet`, + :class:`linear_model.MultiTaskElasticNetCV`, + :class:`linear_model.MultiTaskLasso`, and :class:`linear_model.MultiTaskLassoCV` now + support fitting on sparse `X` as well as fitting with `sample_weight`. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33441.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33441.fix.rst new file mode 100644 index 0000000000000..e581cb03edef4 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33441.fix.rst @@ -0,0 +1,4 @@ +- Fix unsigned integer overflow in :class:`linear_model.RidgeClassifier` + when fitting with unsigned integer inputs. Internal label binarisation now + avoids wrapping -1 for unsigned integer target dtypes. + By :user:`Virgil Chan <virchan>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33565.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33565.fix.rst new file mode 100644 index 0000000000000..73d3b0b61bbf5 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33565.fix.rst @@ -0,0 +1,6 @@ +- The `tol` parameter in :class:`linear_model.LinearRegression` is now set as + the `cond` parameter of the :func:`scipy.linalg.lstsq` solver when fitting on + dense data. Some tests involving `LinearRegression` were brittle with the + default `cond` values from `scipy` or `numpy`. Here at least the user has + control over the `cond` value and can change it if necessary. + By :user:`Antoine Baker <antoinebaker>` diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33855.api.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33855.api.rst new file mode 100644 index 0000000000000..3ad6d0374fc3e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33855.api.rst @@ -0,0 +1,9 @@ +- The parameter `n_alphas` has been deprecated for + :func:`linear_model.lasso_path` and :func:`linear_model.enet_path`. + This deprecation follows the same deprecation that has happened for + :class:`linear_model.ElasticNetCV` and :class:`linear_model.LassoCV`. + The parameter `alphas` now supports both integers and array-likes, removing the need + for `n_alphas`. From now on, only `alphas` should be set, either to and integer to + indicate the number of automatically generated alphas or to an array-like of values + for the regularization path. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33902.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33902.fix.rst new file mode 100644 index 0000000000000..991afe2bb4c9e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33902.fix.rst @@ -0,0 +1,5 @@ +- :class:`linear_model.LogisticRegressionCV` no longer raises a ``TypeError`` + when `refit=False` and `use_legacy_attributes=False` are set together with a + non-elasticnet penalty like `l1_ratios=[0.0]`. Previously, `None` was stored in `l1_ratio_` instead + of `0.0`, which caused `float()` to fail during post-processing. + By :user:`Mohamad Fazeli <Fazel94>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.linear_model/33918.fix.rst b/doc/whats_new/upcoming_changes/sklearn.linear_model/33918.fix.rst new file mode 100644 index 0000000000000..985b44181980f --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.linear_model/33918.fix.rst @@ -0,0 +1,3 @@ +- :class:`linear_model.BayesianRidge` and :class:`linear_model.ARDRegression` now + center test features during :meth:`predict` to correctly compute predictive variance. + By :user:`Danilo Silva (danilo-silva-ufsc)`. diff --git a/doc/whats_new/upcoming_changes/sklearn.manifold/31322.major-feature.rst b/doc/whats_new/upcoming_changes/sklearn.manifold/31322.major-feature.rst deleted file mode 100644 index 0d1610d69747f..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.manifold/31322.major-feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`manifold.ClassicalMDS` was implemented to perform classical MDS - (eigendecomposition of the double-centered distance matrix). - By :user:`Dmitry Kobak <dkobak>` and :user:`Meekail Zain <Micky774>` diff --git a/doc/whats_new/upcoming_changes/sklearn.manifold/32229.feature.rst b/doc/whats_new/upcoming_changes/sklearn.manifold/32229.feature.rst deleted file mode 100644 index b1af155f5a1c3..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.manifold/32229.feature.rst +++ /dev/null @@ -1,6 +0,0 @@ -- :class:`manifold.MDS` now supports arbitrary distance metrics - (via `metric` and `metric_params` parameters) and - initialization via classical MDS (via `init` parameter). - The `dissimilarity` parameter was deprecated. The old `metric` parameter - was renamed into `metric_mds`. - By :user:`Dmitry Kobak <dkobak>` diff --git a/doc/whats_new/upcoming_changes/sklearn.manifold/32433.feature.rst b/doc/whats_new/upcoming_changes/sklearn.manifold/32433.feature.rst deleted file mode 100644 index 6a65dd1ad56d9..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.manifold/32433.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :class:`manifold.TSNE` now supports PCA initialization with sparse input matrices. - By :user:`Arturo Amor <ArturoAmorQ>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.manifold/33262.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.manifold/33262.efficiency.rst new file mode 100644 index 0000000000000..a1e405482e738 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.manifold/33262.efficiency.rst @@ -0,0 +1,4 @@ +- The way ARPACK eigensolver is called in :class:`manifold.SpectralEmbedding` + and :class:`cluster.SpectralClustering` was improved, resulting in faster + runtimes. + By :user:`Dmitry Kobak <dkobak>` diff --git a/doc/whats_new/upcoming_changes/sklearn.manifold/33318.fix.rst b/doc/whats_new/upcoming_changes/sklearn.manifold/33318.fix.rst new file mode 100644 index 0000000000000..a851f13c59fa3 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.manifold/33318.fix.rst @@ -0,0 +1,3 @@ +- :meth:`manifold.MDS.transform` returns the correct number of components when + using `init="classical_mds"`. + By :user:`Ben Pedigo <bdpedigo>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/28971.feature.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/28971.feature.rst deleted file mode 100644 index 9a2379bc31114..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/28971.feature.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`metrics.d2_brier_score` has been added which calculates the D^2 for the Brier score. - By :user:`Omar Salman <OmarManzoor>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/30134.feature.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/30134.feature.rst deleted file mode 100644 index 09f0c99501395..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/30134.feature.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Add :func:`metrics.confusion_matrix_at_thresholds` function that returns the number of - true negatives, false positives, false negatives and true positives per threshold. - By :user:`Success Moses <SuccessMoses>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/30508.feature.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/30508.feature.rst new file mode 100644 index 0000000000000..eecf73a1e9c2a --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/30508.feature.rst @@ -0,0 +1,4 @@ +- Add class method `from_cv_results` to :class:`metrics.PrecisionRecallDisplay`, + which allows easy plotting of multiple precision-recall curves from + :func:`model_selection.cross_validate` results. + By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/30787.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/30787.fix.rst deleted file mode 100644 index 13edbdfc7874d..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/30787.fix.rst +++ /dev/null @@ -1,6 +0,0 @@ -- :func:`metrics.median_absolute_error` now uses `_averaged_weighted_percentile` - instead of `_weighted_percentile` to calculate median when `sample_weight` is not - `None`. This is equivalent to using the "averaged_inverted_cdf" instead of - the "inverted_cdf" quantile method, which gives results equivalent to `numpy.median` - if equal weights used. - By :user:`Lucy Liu <lucyleeow>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31172.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31172.enhancement.rst new file mode 100644 index 0000000000000..426a467226bc9 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/31172.enhancement.rst @@ -0,0 +1,4 @@ +- :func:`~metrics.cohen_kappa_score` now has a `replace_undefined_by` param, that can be + set to define the function's return value when the metric is undefined (division by + zero). + By :user:`Stefanie Senger <StefanieSenger>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31294.api.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31294.api.rst deleted file mode 100644 index d5afd1d46e6e0..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/31294.api.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`metrics.cluster.entropy` is deprecated and will be removed in v1.10. - By :user:`Lucy Liu <lucyleeow>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31406.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31406.enhancement.rst deleted file mode 100644 index 4736c67c80132..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/31406.enhancement.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :func:`metrics.median_absolute_error` now supports Array API compatible inputs. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31671.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31671.fix.rst new file mode 100644 index 0000000000000..9bfcd7827bedd --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/31671.fix.rst @@ -0,0 +1,8 @@ +- :func:`metrics.d2_pinball_score` and :func:`metrics.d2_absolute_error_score` now + always use the `"averaged_inverted_cdf"` quantile method, both with and + without sample weights. Previously, the `"linear"` quantile method was used only + for the unweighted case leading the surprising discrepancies when comparing the + results with unit weights. Note that all quantile interpolation methods are + asymptotically equivalent in the large sample limit, but this fix can cause score + value changes on small evaluation sets (without weights). + By :user:`Virgil Chan <virchan>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31701.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31701.fix.rst deleted file mode 100644 index 646cdb544f496..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/31701.fix.rst +++ /dev/null @@ -1,21 +0,0 @@ -- Additional `sample_weight` checking has been added to - :func:`metrics.accuracy_score`, - :func:`metrics.balanced_accuracy_score`, - :func:`metrics.brier_score_loss`, - :func:`metrics.class_likelihood_ratios`, - :func:`metrics.classification_report`, - :func:`metrics.cohen_kappa_score`, - :func:`metrics.confusion_matrix`, - :func:`metrics.f1_score`, - :func:`metrics.fbeta_score`, - :func:`metrics.hamming_loss`, - :func:`metrics.jaccard_score`, - :func:`metrics.matthews_corrcoef`, - :func:`metrics.multilabel_confusion_matrix`, - :func:`metrics.precision_recall_fscore_support`, - :func:`metrics.precision_score`, - :func:`metrics.recall_score` and - :func:`metrics.zero_one_loss`. - `sample_weight` can only be 1D, consistent to `y_true` and `y_pred` in length,and - all values must be finite and not complex. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31764.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31764.fix.rst deleted file mode 100644 index 8dab2fc772563..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/31764.fix.rst +++ /dev/null @@ -1,5 +0,0 @@ -- `y_pred` is deprecated in favour of `y_score` in - :func:`metrics.DetCurveDisplay.from_predictions` and - :func:`metrics.PrecisionRecallDisplay.from_predictions`. `y_pred` will be removed in - v1.10. - By :user:`Luis <luiser1401>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/31891.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/31891.fix.rst deleted file mode 100644 index f1f280859a1e5..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/31891.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- `repr` on a scorer which has been created with a `partial` `score_func` now correctly - works and uses the `repr` of the given `partial` object. - By `Adrin Jalali`_. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32047.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32047.enhancement.rst deleted file mode 100644 index 7fcad9a062ce7..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32047.enhancement.rst +++ /dev/null @@ -1,9 +0,0 @@ -- Improved the error message for sparse inputs for the following metrics: - :func:`metrics.accuracy_score`, - :func:`metrics.multilabel_confusion_matrix`, :func:`metrics.jaccard_score`, - :func:`metrics.zero_one_loss`, :func:`metrics.f1_score`, - :func:`metrics.fbeta_score`, :func:`metrics.precision_recall_fscore_support`, - :func:`metrics.class_likelihood_ratios`, :func:`metrics.precision_score`, - :func:`metrics.recall_score`, :func:`metrics.classification_report`, - :func:`metrics.hamming_loss`. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32310.api.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32310.api.rst deleted file mode 100644 index ae7fc385b3bcc..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32310.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -- The `estimator_name` parameter is deprecated in favour of `name` in - :class:`metrics.PrecisionRecallDisplay` and will be removed in 1.10. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32313.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32313.fix.rst deleted file mode 100644 index b8f0fc21660da..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32313.fix.rst +++ /dev/null @@ -1,5 +0,0 @@ -- kwargs specified in the `curve_kwargs` parameter of - :meth:`metrics.RocCurveDisplay.from_cv_results` now only overwrite their corresponding - default value before being passed to Matplotlib's `plot`. Previously, passing any - `curve_kwargs` would overwrite all default kwargs. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32356.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32356.efficiency.rst deleted file mode 100644 index 03b3e41f67911..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32356.efficiency.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Avoid redundant input validation in :func:`metrics.d2_log_loss_score` - leading to a 1.2x speedup in large scale benchmarks. - By :user:`Olivier Grisel <ogrisel>` and :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32356.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32356.fix.rst deleted file mode 100644 index ac611096234b6..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32356.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- Registered named scorer objects for :func:`metrics.d2_brier_score` and - :func:`metrics.d2_log_loss_score` and updated their input validation to be - consistent with related metric functions. - By :user:`Olivier Grisel <ogrisel>` and :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32372.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32372.fix.rst deleted file mode 100644 index 5fa8d2204b312..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32372.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :meth:`metrics.RocCurveDisplay.from_cv_results` will now infer `pos_label` as - `estimator.classes_[-1]`, using the estimator from `cv_results`, when - `pos_label=None`. Previously, an error was raised when `pos_label=None`. - By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32549.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32549.fix.rst deleted file mode 100644 index 070e3d1e7fefe..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.metrics/32549.fix.rst +++ /dev/null @@ -1,7 +0,0 @@ -- All classification metrics now raise a `ValueError` when required input arrays - (`y_pred`, `y_true`, `y1`, `y2`, `pred_decision`, or `y_proba`) are empty. - Previously, `accuracy_score`, `class_likelihood_ratios`, `classification_report`, - `confusion_matrix`, `hamming_loss`, `jaccard_score`, `matthews_corrcoef`, - `multilabel_confusion_matrix`, and `precision_recall_fscore_support` did not raise - this error consistently. - By :user:`Stefanie Senger <StefanieSenger>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/32732.major-feature.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/32732.major-feature.rst new file mode 100644 index 0000000000000..c5deeff54dd50 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/32732.major-feature.rst @@ -0,0 +1,3 @@ +- :func:`metrics.metric_at_thresholds` has been added to compute + a metric's values across all possible thresholds. + By :user:`Carlo Lemos <vitaliset>` and :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33086.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33086.fix.rst new file mode 100644 index 0000000000000..5126f17c961a3 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33086.fix.rst @@ -0,0 +1,10 @@ +- :func:`metrics.accuracy_score`, :func:`metrics.hamming_loss` + :func:`metrics.zero_one_loss`, :func:`metrics.matthews_corrcoef` and + :func:`metrics.confusion_matrix` (when `labels` is not `None`) now + raise an error when `y_true` is string and `y_pred` is numeric, for + all array-like inputs. Previously, lists and numpy arrays not of `object` dtype + did not raise an error for this mixed input case. + The above metrics will also raise an error for :term:`label indicator matrix` inputs + of inconsistent size, except for :func:`metrics.confusion_matrix` which does not + accept label indicator matrix inputs. + By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33252.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33252.fix.rst new file mode 100644 index 0000000000000..b29d09c8c77f4 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33252.fix.rst @@ -0,0 +1,5 @@ +- Fixed :func:`metrics.pairwise_distances_argmin` and + :func:`metrics.pairwise_distances_argmin_min` to avoid a quadratic-time path + when many distances are identical, which could lead to severe slowdowns or + even a stack overflow (segmentation fault) on large inputs. + By :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33357.api.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33357.api.rst new file mode 100644 index 0000000000000..362f6cd1cd710 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33357.api.rst @@ -0,0 +1,4 @@ +- Passing the `pos_label` and `sample_weight` parameters of + :func:`metrics.confusion_matrix_at_thresholds` as positional arguments is deprecated + and will be removed in v1.11. + By :user:`Jรฉrรฉmie du Boisberranger <jeremiedbb>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33405.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33405.fix.rst new file mode 100644 index 0000000000000..4de356a5fc557 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33405.fix.rst @@ -0,0 +1,4 @@ +- :meth:`metrics.PrecisionRecallDisplay.from_estimator` and + :meth:`metrics.PrecisionRecallDisplay.from_predictions` now + correctly plot chance level line when `y_true` is a pytorch tensor. + By :user:`Lucas Oliveira <lucolivi>`. \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33740.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33740.fix.rst new file mode 100644 index 0000000000000..14ac406feffee --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33740.fix.rst @@ -0,0 +1,4 @@ +- `y_pred` was deprecated in favor of `y_proba` for :func:`metrics.log_loss` + and :func:`metrics.d2_log_loss_score` as predicted probabilities are expected, + not predicted labels. + By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.metrics/33876.fix.rst b/doc/whats_new/upcoming_changes/sklearn.metrics/33876.fix.rst new file mode 100644 index 0000000000000..55a452d12134e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.metrics/33876.fix.rst @@ -0,0 +1,3 @@ +- :func:`metrics.pairwise.pairwise_distances` no longer raises an error for the + euclidean metric when called with `Y_norm_squared` and `n_jobs > 1`. + By :user:`Kunle Li <unw9527>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.model_selection/28464.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.model_selection/28464.enhancement.rst new file mode 100644 index 0000000000000..8ac8c36fa831a --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.model_selection/28464.enhancement.rst @@ -0,0 +1,4 @@ +- :class:`~sklearn.model_selection.GroupKFold` now uses `stable` sorting when doing + the group distribution. This ensures that the splits are consistent across + runs. + By :user:`marikabergengren <marikabergengren>` and `Adrin Jalali`_ diff --git a/doc/whats_new/upcoming_changes/sklearn.model_selection/32265.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.model_selection/32265.enhancement.rst deleted file mode 100644 index b9c87bfec19d9..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.model_selection/32265.enhancement.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`model_selection.StratifiedShuffleSplit` will now specify which classes - have too few members when raising a ``ValueError`` if any class has less than 2 members. - This is useful to identify which classes are causing the error. - By :user:`Marc Bresson <MarcBresson>` diff --git a/doc/whats_new/upcoming_changes/sklearn.model_selection/32540.fix.rst b/doc/whats_new/upcoming_changes/sklearn.model_selection/32540.fix.rst deleted file mode 100644 index ec15ecccee161..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.model_selection/32540.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fix shuffle behaviour in :class:`model_selection.StratifiedGroupKFold`. Now - stratification among folds is also preserved when `shuffle=True`. - By :user:`Pau Folch <pfolch>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.model_selection/33176.fix.rst b/doc/whats_new/upcoming_changes/sklearn.model_selection/33176.fix.rst new file mode 100644 index 0000000000000..60a181946a4a0 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.model_selection/33176.fix.rst @@ -0,0 +1,3 @@ +- :class:`model_selection.StratifiedGroupKFold` now raises a `ValueError` when + `n_splits` is greater than the number of unique groups, preventing degenerate folds. + By :user:`Chani Fainendler <gitCHANI2005>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.model_selection/33473.fix.rst b/doc/whats_new/upcoming_changes/sklearn.model_selection/33473.fix.rst new file mode 100644 index 0000000000000..007b9e614d6a9 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.model_selection/33473.fix.rst @@ -0,0 +1,7 @@ +- Fixed incorrect :class:`ValueError` when using + ``scoring="average_precision"`` or similar in model selection utilities such + as `model_selection.GridSearchCV` or `model_selection.cross_validate` with + multiclass classifiers. The ``pos_label`` parameter is only relevant for + binary classification and was incorrectly being validated for scorers used on + multiclass problems. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.multiclass/15504.fix.rst b/doc/whats_new/upcoming_changes/sklearn.multiclass/15504.fix.rst deleted file mode 100644 index 177a7309ae3f3..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.multiclass/15504.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fix tie-breaking behavior in :class:`multiclass.OneVsRestClassifier` to match - `np.argmax` tie-breaking behavior. - By :user:`Lakshmi Krishnan <lakrish>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.naive_bayes/32497.fix.rst b/doc/whats_new/upcoming_changes/sklearn.naive_bayes/32497.fix.rst deleted file mode 100644 index 855dd8c238f4a..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.naive_bayes/32497.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`naive_bayes.GaussianNB` preserves the dtype of the fitted attributes - according to the dtype of `X`. - By :user:`Omar Salman <OmarManzoor>` diff --git a/doc/whats_new/upcoming_changes/sklearn.neighbors/33252.fix.rst b/doc/whats_new/upcoming_changes/sklearn.neighbors/33252.fix.rst new file mode 100644 index 0000000000000..641d17cc8bdc4 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.neighbors/33252.fix.rst @@ -0,0 +1,6 @@ +- Fixed a quadratic-time path in the internal ``simultaneous_sort`` used by + :class:`neighbors.BallTree` and :class:`neighbors.KDTree` queries when many + distances are identical, which could lead to severe slowdowns or even a stack + overflow (segmentation fault) on large inputs. Neighbor searches with tied + distances no longer degrade badly in runtime. + By :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.neural_network/33774.fix.rst b/doc/whats_new/upcoming_changes/sklearn.neural_network/33774.fix.rst new file mode 100644 index 0000000000000..7bb5d4a537a8b --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.neural_network/33774.fix.rst @@ -0,0 +1,5 @@ +- :class:`neural_network.MLPClassifier` with ``early_stopping=True`` no longer + raises a `TypeError` when ``y`` contains non-numeric class labels (e.g. + strings): validation scoring now checks finiteness only for floating + predictions. + By :user:`Guillaume Lemaitre <glemaitre>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.pipeline/32853.fix.rst b/doc/whats_new/upcoming_changes/sklearn.pipeline/32853.fix.rst new file mode 100644 index 0000000000000..558d2afd2838e --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.pipeline/32853.fix.rst @@ -0,0 +1 @@ +- Fixed :class:`pipeline.FeatureUnion` to properly handle column renaming when using Polars output, preventing duplicate column names. By :user:`Levente Csibi <leweex95>`. :pr:`32853` \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.pipeline/33362.fix.rst b/doc/whats_new/upcoming_changes/sklearn.pipeline/33362.fix.rst new file mode 100644 index 0000000000000..aed94f805bc99 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.pipeline/33362.fix.rst @@ -0,0 +1,4 @@ +- :class:`pipeline.Pipeline` now raises an `AttributeError` when accessing attributes + that are not available on an empty pipeline. It's therefore possible to call `dir` + on an empty pipeline. + By :user:`Jรฉrรฉmie du Boisberranger <jeremiedbb>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.preprocessing/28043.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.preprocessing/28043.enhancement.rst deleted file mode 100644 index 8195352292539..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.preprocessing/28043.enhancement.rst +++ /dev/null @@ -1,2 +0,0 @@ -- :class:`preprocessing.SplineTransformer` can now handle missing values with the - parameter `handle_missing`. By :user:`Stefanie Senger <StefanieSenger>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.preprocessing/29307.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.preprocessing/29307.enhancement.rst deleted file mode 100644 index aa9b02400a0c0..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.preprocessing/29307.enhancement.rst +++ /dev/null @@ -1,4 +0,0 @@ -- The :class:`preprocessing.PowerTransformer` now returns a warning - when NaN values are encountered in the inverse transform, `inverse_transform`, typically - caused by extremely skewed data. - By :user:`Roberto Mourao <maf-rnmourao>` \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.preprocessing/31790.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.preprocessing/31790.enhancement.rst deleted file mode 100644 index caabc96b626fd..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.preprocessing/31790.enhancement.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :class:`preprocessing.MaxAbsScaler` can now clip out-of-range values in held-out data - with the parameter `clip`. - By :user:`Hleb Levitski <glevv>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.preprocessing/33268.fix.rst b/doc/whats_new/upcoming_changes/sklearn.preprocessing/33268.fix.rst new file mode 100644 index 0000000000000..1c697b32e18bc --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.preprocessing/33268.fix.rst @@ -0,0 +1,5 @@ +- :class:`~sklearn.preprocessing.PowerTransformer` and + :class:`~sklearn.preprocessing.QuantileTransformer` now don't raise a warning in + :meth:`inverse_transform` related to feature names if :meth:`fit` is called using data with + feature names. + By :user:`Thibault <ThibaultDECO>` and :user:`Mohammad Ahmadullah Khan <MAUK9086>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.preprocessing/33453.api.rst b/doc/whats_new/upcoming_changes/sklearn.preprocessing/33453.api.rst new file mode 100644 index 0000000000000..f8bb059d00416 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.preprocessing/33453.api.rst @@ -0,0 +1,5 @@ +- The `shuffle` and the `random_state` parameters are deprecated on + :class:`~preprocessing.TargetEncoder` and will be removed in version 1.11. Pass a + cross-validation generator as `cv` argument to specify the shuffling behaviour + instead. + By :user:`Stefanie Senger <StefanieSenger>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.semi_supervised/31924.fix.rst b/doc/whats_new/upcoming_changes/sklearn.semi_supervised/31924.fix.rst deleted file mode 100644 index fe21593d99680..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.semi_supervised/31924.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- User written kernel results are now normalized in - :class:`semi_supervised.LabelPropagation` - so all row sums equal 1 even if kernel gives asymmetric or non-uniform row sums. - By :user:`Dan Schult <dschult>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.svm/32050.api.rst b/doc/whats_new/upcoming_changes/sklearn.svm/32050.api.rst new file mode 100644 index 0000000000000..d00a2e603f1ae --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.svm/32050.api.rst @@ -0,0 +1,5 @@ +- The `probability` parameter of :class:`sklearn.svm.SVC` and :class:`sklearn.svm.NuSVC` + is deprecated due to not being thread-safe and will be removed in 1.11. Use + :class:`sklearn.calibration.CalibratedClassifierCV` with the respective estimator and + `ensemble=False` instead. + By :user:`Shruti Nath <snath-xoc>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.svm/32212.fix.rst b/doc/whats_new/upcoming_changes/sklearn.svm/32212.fix.rst new file mode 100644 index 0000000000000..40cf076951315 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.svm/32212.fix.rst @@ -0,0 +1,2 @@ +- Raise more informative error when fitting :class:`NuSVR` with all zero sample weights. + By :user:`Lucy Liu <lucyleeow>` and :user:`John Hendricks <j-hendricks>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.svm/33388.api.rst b/doc/whats_new/upcoming_changes/sklearn.svm/33388.api.rst new file mode 100644 index 0000000000000..ff03d806028e1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.svm/33388.api.rst @@ -0,0 +1,4 @@ +- The `probA_` and `probB_` attributes of :class:`sklearn.svm.SVC` and + :class:`sklearn.svm.NuSVC` are deprecated due to deprecation of the + `probability` parameter and will be removed in 1.11. + By :user:`Shruti Nath <snath-xoc>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/27630.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.tree/27630.enhancement.rst new file mode 100644 index 0000000000000..56f5904b8e87c --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/27630.enhancement.rst @@ -0,0 +1,9 @@ +- :class:`tree.DecisionTreeClassifier`, :class:`tree.DecisionTreeRegressor`, + :class:`tree.ExtraTreeClassifier`, :class:`tree.ExtraTreeRegressor`, + :class:`ensemble.RandomForestClassifier`, + :class:`ensemble.RandomForestRegressor`, :class:`ensemble.ExtraTreesClassifier`, + and :class:`ensemble.ExtraTreesRegressor` now support combining + `monotonic_cst` with missing values in dense training data. This builds on + the improvements to missing-value support for dense training data in + :pr:`32119`. + By :user:`Samuel O. Ronsin <samronsin>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/30041.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/30041.fix.rst deleted file mode 100644 index 98c90e31f36eb..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/30041.fix.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Make :func:`tree.export_text` thread-safe. - By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/31036.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/31036.fix.rst deleted file mode 100644 index 32e26e180595d..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/31036.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :func:`~sklearn.tree.export_graphviz` now raises a `ValueError` if given feature - names are not all strings. - By :user:`Guilherme Peixoto <guilhermecsnpeixoto>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32100.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32100.efficiency.rst deleted file mode 100644 index 0df37311f22ce..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32100.efficiency.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :class:`tree.DecisionTreeRegressor` with `criterion="absolute_error"` - now runs much faster: O(n log n) complexity against previous O(n^2) - allowing to scale to millions of data points, even hundred of millions. - By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32100.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32100.fix.rst deleted file mode 100644 index 7d337131c25e6..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32100.fix.rst +++ /dev/null @@ -1,6 +0,0 @@ -- :class:`tree.DecisionTreeRegressor` with `criterion="absolute_error"` - would sometimes make sub-optimal splits - (i.e. splits that don't minimize the absolute error). - Now it's fixed. Hence retraining trees might gives slightly different - results. - By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32119.feature.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32119.feature.rst new file mode 100644 index 0000000000000..73a7251fce13b --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/32119.feature.rst @@ -0,0 +1,4 @@ +- In :class:`tree.DecisionTreeRegressor` and :class:`ensemble.RandomForestRegressor`, + `criterion="absolute_error"` โ€” and, consequently, all criterion options โ€” + now support missing values for dense training data `X`. + By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32119.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32119.fix.rst new file mode 100644 index 0000000000000..d512560566fb1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/32119.fix.rst @@ -0,0 +1,6 @@ +- Fix calculation of node impurity in :class:`tree.DecisionTreeRegressor`, + :class:`ensemble.RandomForestRegressor`, :class:`ExtraTreeRegressor` and + :class:`ExtraTreesRegressor` when missing values are present for the Poisson + criterion. The Poisson criterion was returning invalid impurities (including + negative values) when missing values were present. + By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32193.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32193.fix.rst new file mode 100644 index 0000000000000..6c4b3d4421e21 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/32193.fix.rst @@ -0,0 +1,9 @@ +- Fixed feature-wise NaN detection in trees. + Features could be seen as NaN-free for some edge-case patterns, which led to + not considering splits with NaNs assigned to the left node for those features. + This affects: + - :class:`tree.DecisionTreeRegressor` + - :class:`tree.ExtraTreeRegressor` + - :class:`ensemble.RandomForestRegressor` + - :class:`ensemble.ExtraTreesRegressor` + By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32259.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32259.fix.rst deleted file mode 100644 index f25f0f2eec483..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32259.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fixed a regression in :ref:`decision trees <tree>` where almost constant features were - not handled properly. - By :user:`Sercan Turkmen <sercant>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32274.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32274.fix.rst deleted file mode 100644 index 84c1123cf26c8..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32274.fix.rst +++ /dev/null @@ -1,6 +0,0 @@ -- Fixed splitting logic during training in :class:`tree.DecisionTree*` - (and consequently in :class:`ensemble.RandomForest*`) - for nodes containing near-constant feature values and missing values. - Beforehand, trees were cut short if a constant feature was found, - even if there was more splitting that could be done on the basis of missing values. - By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32280.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32280.fix.rst deleted file mode 100644 index 5ff0a9b453e77..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32280.fix.rst +++ /dev/null @@ -1,4 +0,0 @@ -- Fix handling of missing values in method :func:`decision_path` of trees - (:class:`tree.DecisionTreeClassifier`, :class:`tree.DecisionTreeRegressor`, - :class:`tree.ExtraTreeClassifier` and :class:`tree.ExtraTreeRegressor`) - By :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32351.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32351.fix.rst deleted file mode 100644 index 0c422d7a9e14c..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.tree/32351.fix.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fix decision tree splitting with missing values present in some features. In some cases the last - non-missing sample would not be partitioned correctly. - By :user:`Tim Head <betatim>` and :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/32708.api.rst b/doc/whats_new/upcoming_changes/sklearn.tree/32708.api.rst new file mode 100644 index 0000000000000..fd18524f24b36 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/32708.api.rst @@ -0,0 +1,9 @@ +- `criterion="friedman_mse"` is now deprecated. This criterion was intended for + gradient boosting but was incorrectly implemented in scikit-learn's trees and + was actually behaving identically to `criterion="squared_error"`. Use + `criterion="squared_error"` instead. This affects: + - :class:`tree.DecisionTreeRegressor` + - :class:`tree.ExtraTreeRegressor` + - :class:`ensemble.RandomForestRegressor` + - :class:`ensemble.ExtraTreesRegressor` + By :user:`Arthur Lacote <cakedev0>` diff --git a/doc/whats_new/upcoming_changes/sklearn.tree/33845.fix.rst b/doc/whats_new/upcoming_changes/sklearn.tree/33845.fix.rst new file mode 100644 index 0000000000000..934a7edf042ec --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.tree/33845.fix.rst @@ -0,0 +1,4 @@ +- Fixed color conversion in tree export so RGB values with zero channels are + correctly converted to two-digit hexadecimal components (for example, + ``(0, 255, 0)`` now yields ``#00ff00``). + By :user:`Simon-Martin Schrรถder <moi90>`. \ No newline at end of file diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/31564.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/31564.enhancement.rst deleted file mode 100644 index 6b9ef89fdd01f..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/31564.enhancement.rst +++ /dev/null @@ -1,5 +0,0 @@ -- The parameter table in the HTML representation of all scikit-learn estimators and - more generally of estimators inheriting from :class:`base.BaseEstimator` - now displays the parameter description as a tooltip and has a link to the online - documentation for each parameter. - By :user:`Dea Marรญa Lรฉon <DeaMariaLeon>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/31873.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/31873.enhancement.rst deleted file mode 100644 index 6e82ce3713f5a..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/31873.enhancement.rst +++ /dev/null @@ -1,4 +0,0 @@ -- ``sklearn.utils._check_sample_weight`` now raises a clearer error message when the - provided weights are neither a scalar nor a 1-D array-like of the same size as the - input data. - By :user:`Kapil Parekh <kapslock123>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/31951.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/31951.enhancement.rst deleted file mode 100644 index 556c406bff7b8..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/31951.enhancement.rst +++ /dev/null @@ -1,4 +0,0 @@ -- :func:`sklearn.utils.estimator_checks.parametrize_with_checks` now lets you configure - strict mode for xfailing checks. Tests that unexpectedly pass will lead to a test - failure. The default behaviour is unchanged. - By :user:`Tim Head <betatim>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/31952.efficiency.rst b/doc/whats_new/upcoming_changes/sklearn.utils/31952.efficiency.rst deleted file mode 100644 index f334bfd81c8dd..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/31952.efficiency.rst +++ /dev/null @@ -1,5 +0,0 @@ -- The function :func:`sklearn.utils.extmath.safe_sparse_dot` was improved by a dedicated - Cython routine for the case of `a @ b` with sparse 2-dimensional `a` and `b` and when - a dense output is required, i.e., `dense_output=True`. This improves several - algorithms in scikit-learn when dealing with sparse arrays (or matrices). - By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/31969.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/31969.enhancement.rst deleted file mode 100644 index 079b9c589bc91..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/31969.enhancement.rst +++ /dev/null @@ -1,3 +0,0 @@ -- Fixed the alignment of the "?" and "i" symbols and improved the color style of the - HTML representation of estimators. - By :user:`Guillaume Lemaitre <glemaitre>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/32258.api.rst b/doc/whats_new/upcoming_changes/sklearn.utils/32258.api.rst deleted file mode 100644 index a8ab5744ddf87..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/32258.api.rst +++ /dev/null @@ -1,3 +0,0 @@ -- :func:`utils.extmath.stable_cumsum` is deprecated and will be removed - in v1.10. Use `np.cumulative_sum` with the desired dtype directly instead. - By :user:`Tiziano Zito <opossumnano>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/32330.fix.rst b/doc/whats_new/upcoming_changes/sklearn.utils/32330.fix.rst deleted file mode 100644 index c2243ad2f7c3b..0000000000000 --- a/doc/whats_new/upcoming_changes/sklearn.utils/32330.fix.rst +++ /dev/null @@ -1,2 +0,0 @@ -- Changes the way color are chosen when displaying an estimator as an HTML representation. Colors are not adapted anymore to the user's theme, but chosen based on theme declared color scheme (light or dark) for VSCode and JupyterLab. If theme does not declare a color scheme, scheme is chosen according to default text color of the page, if it fails fallbacks to a media query. - By :user:`Matt J. <rouk1>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/32565.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/32565.enhancement.rst new file mode 100644 index 0000000000000..06993be1ff366 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/32565.enhancement.rst @@ -0,0 +1,3 @@ +- ``sklearn.utils._tags.get_tags`` now provides a clearer error message when a class + is passed instead of an estimator instance. + By :user:`Achyuthan S <Achyuthan-S>` and :user:`Anne Beyer <AnneBeyer>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/32887.fix.rst b/doc/whats_new/upcoming_changes/sklearn.utils/32887.fix.rst new file mode 100644 index 0000000000000..765e4f62b9a58 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/32887.fix.rst @@ -0,0 +1,6 @@ +- The parameter table in the HTML representation of all scikit-learn + estimators inheritiging from :class:`base.BaseEstimator`, displays + each parameter documentation as a tooltip. The last tooltip of a + parameter in the last table of any HTML representation was partially hidden. + This issue has been fixed. + By :user:`Dea Marรญa Lรฉon <DeaMariaLeon>` diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/33086.api.rst b/doc/whats_new/upcoming_changes/sklearn.utils/33086.api.rst new file mode 100644 index 0000000000000..24d6a4fd10c3a --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/33086.api.rst @@ -0,0 +1,3 @@ +- :func:`utils.multiclass.unique_labels` now accepts `ys_types` parameter, + which allows avoiding duplicate calls to `type_of_target`. + By :user:`Lucy Liu <lucyleeow>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/33126.enhancement.rst b/doc/whats_new/upcoming_changes/sklearn.utils/33126.enhancement.rst new file mode 100644 index 0000000000000..b3bd130028a91 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/33126.enhancement.rst @@ -0,0 +1,3 @@ +- ``sklearn.utils._response._get_response_values`` now provides a clearer error message + when estimator does not implement the given ``response_method``. + By :user:`Quentin Barthรฉlemy <qbarthelemy>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/33127.fix.rst b/doc/whats_new/upcoming_changes/sklearn.utils/33127.fix.rst new file mode 100644 index 0000000000000..93beb06bfb8c1 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/33127.fix.rst @@ -0,0 +1,8 @@ +- Fixed ``_weighted_percentile`` with ``average=True`` so zero-weight samples + just before the end of the array are handled correctly. This + can change results when using ``sample_weight`` with + :class:`preprocessing.KBinsDiscretizer` (``strategy="quantile"``, + ``quantile_method="averaged_inverted_cdf"``) and in + :func:`metrics.median_absolute_error`, :func:`metrics.d2_pinball_score`, and + :func:`metrics.d2_absolute_error_score`. + By :user:`Arthur Lacote <cakedev0>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/33491.fix.rst b/doc/whats_new/upcoming_changes/sklearn.utils/33491.fix.rst new file mode 100644 index 0000000000000..d40f0fc8ac63d --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/33491.fix.rst @@ -0,0 +1,6 @@ +- :func:`utils.validation.check_array` now correctly rejects pandas + ``StringDtype`` columns when ``dtype="numeric"`` is requested. In pandas 3, + string columns use ``StringDtype`` instead of ``object`` dtype, which caused + ``check_array`` to silently accept string data instead of raising a + ``ValueError``. + By :user:`Olivier Grisel <ogrisel>`. diff --git a/doc/whats_new/upcoming_changes/sklearn.utils/33789.fix.rst b/doc/whats_new/upcoming_changes/sklearn.utils/33789.fix.rst new file mode 100644 index 0000000000000..9c3643f21cda5 --- /dev/null +++ b/doc/whats_new/upcoming_changes/sklearn.utils/33789.fix.rst @@ -0,0 +1,4 @@ +- The code path for polars dataframes in :func:`validate_data` was made independent of + the dataframe interchange protocol `__dataframe__`. This change was necessary to + adapt to the recent deprecation of the interchange protocol in polars version 1.40. + By :user:`Christian Lorentzen <lorentzenchr>`. diff --git a/doc/whats_new/v0.16.rst b/doc/whats_new/v0.16.rst index b5656d3bff64c..4296b0cd8b9fd 100644 --- a/doc/whats_new/v0.16.rst +++ b/doc/whats_new/v0.16.rst @@ -414,7 +414,7 @@ Bug fixes - Fixed handling of ties in :class:`isotonic.IsotonicRegression`. We now use the weighted average of targets (secondary method). By - `Andreas Mรผller`_ and `Michael Bommarito <http://bommaritollc.com/>`_. + `Andreas Mรผller`_ and `Michael Bommarito <https://bommaritollc.com/>`_. API changes summary ------------------- diff --git a/doc/whats_new/v0.23.rst b/doc/whats_new/v0.23.rst index 379fa7adfe7aa..8983bbc9db52e 100644 --- a/doc/whats_new/v0.23.rst +++ b/doc/whats_new/v0.23.rst @@ -708,7 +708,7 @@ Changelog generates 31bits/63bits random numbers on all platforms. In addition, the crude "modulo" postprocessor used to get a random number in a bounded interval was replaced by the tweaked Lemire method as suggested by `this blog - post <http://www.pcg-random.org/posts/bounded-rands.html>`_. + post <https://www.pcg-random.org/posts/bounded-rands.html>`_. Any model using the `svm.libsvm` or the `svm.liblinear` solver, including :class:`svm.LinearSVC`, :class:`svm.LinearSVR`, :class:`svm.NuSVC`, :class:`svm.NuSVR`, :class:`svm.OneClassSVM`, diff --git a/doc/whats_new/v1.8.rst b/doc/whats_new/v1.8.rst index 603373824d395..a0f0ccba40a93 100644 --- a/doc/whats_new/v1.8.rst +++ b/doc/whats_new/v1.8.rst @@ -8,27 +8,687 @@ Version 1.8 =========== -.. - -- UNCOMMENT WHEN 1.8.0 IS RELEASED -- - For a short description of the main highlights of the release, please refer to - :ref:`sphx_glr_auto_examples_release_highlights_plot_release_highlights_1_7_0.py`. - - -.. - DELETE WHEN 1.8.0 IS RELEASED - Since October 2024, DO NOT add your changelog entry in this file. -.. - Instead, create a file named `<PR_NUMBER>.<TYPE>.rst` in the relevant sub-folder in - `doc/whats_new/upcoming_changes/`. For full details, see: - https://github.com/scikit-learn/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md +For a short description of the main highlights of the release, please refer to +:ref:`sphx_glr_auto_examples_release_highlights_plot_release_highlights_1_8_0.py`. .. include:: changelog_legend.inc .. towncrier release notes start +.. _changes_1_8_0: + +Version 1.8.0 +============= + +**December 2025** + +Changes impacting many modules +------------------------------ + +- |Efficiency| Improved CPU and memory usage in estimators and metric functions that rely on + weighted percentiles and better match NumPy and Scipy (un-weighted) implementations + of percentiles. + By :user:`Lucy Liu <lucyleeow>` :pr:`31775` + +Support for Array API +--------------------- + +Additional estimators and functions have been updated to include support for all +`Array API <https://data-apis.org/array-api/latest/>`_ compliant inputs. + +See :ref:`array_api` for more details. + +- |Feature| :class:`sklearn.preprocessing.StandardScaler` now supports Array API compliant inputs. + By :user:`Alexander Fabisch <AlexanderFabisch>`, :user:`Edoardo Abati <EdAbati>`, + :user:`Olivier Grisel <ogrisel>` and :user:`Charles Hill <charlesjhill>`. :pr:`27113` + +- |Feature| :class:`linear_model.RidgeCV`, :class:`linear_model.RidgeClassifier` and + :class:`linear_model.RidgeClassifierCV` now support array API compatible + inputs with `solver="svd"`. + By :user:`Jรฉrรดme Dockรจs <jeromedockes>`. :pr:`27961` + +- |Feature| :func:`metrics.pairwise.pairwise_kernels` for any kernel except + "laplacian" and + :func:`metrics.pairwise_distances` for metrics "cosine", + "euclidean" and "l2" now support array API inputs. + By :user:`Emily Chen <EmilyXinyi>` and :user:`Lucy Liu <lucyleeow>` :pr:`29822` + +- |Feature| :func:`sklearn.metrics.confusion_matrix` now supports Array API compatible inputs. + By :user:`Stefanie Senger <StefanieSenger>` :pr:`30562` + +- |Feature| :class:`sklearn.mixture.GaussianMixture` with + `init_params="random"` or `init_params="random_from_data"` and + `warm_start=False` now supports Array API compatible inputs. + By :user:`Stefanie Senger <StefanieSenger>` and :user:`Loรฏc Estรจve <lesteve>` :pr:`30777` + +- |Feature| :func:`sklearn.metrics.roc_curve` now supports Array API compatible inputs. + By :user:`Thomas Li <lithomas1>` :pr:`30878` + +- |Feature| :class:`preprocessing.PolynomialFeatures` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>` :pr:`31580` + +- |Feature| :class:`calibration.CalibratedClassifierCV` now supports array API compatible + inputs with `method="temperature"` and when the underlying `estimator` also + supports the array API. + By :user:`Omar Salman <OmarManzoor>` :pr:`32246` + +- |Feature| :func:`sklearn.metrics.precision_recall_curve` now supports array API compatible + inputs. + By :user:`Lucy Liu <lucyleeow>` :pr:`32249` + +- |Feature| :func:`sklearn.model_selection.cross_val_predict` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>` :pr:`32270` + +- |Feature| :func:`sklearn.metrics.brier_score_loss`, :func:`sklearn.metrics.log_loss`, + :func:`sklearn.metrics.d2_brier_score` and :func:`sklearn.metrics.d2_log_loss_score` + now support array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>` :pr:`32422` + +- |Feature| :class:`naive_bayes.GaussianNB` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>` :pr:`32497` + +- |Feature| :class:`preprocessing.LabelBinarizer` and :func:`preprocessing.label_binarize` now + support numeric array API compatible inputs with `sparse_output=False`. + By :user:`Virgil Chan <virchan>`. :pr:`32582` + +- |Feature| :func:`sklearn.metrics.det_curve` now supports Array API compliant inputs. + By :user:`Josef Affourtit <jaffourt>`. :pr:`32586` + +- |Feature| :func:`sklearn.metrics.pairwise.manhattan_distances` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>`. :pr:`32597` + +- |Feature| :func:`sklearn.metrics.calinski_harabasz_score` now supports Array API compliant inputs. + By :user:`Josef Affourtit <jaffourt>`. :pr:`32600` + +- |Feature| :func:`sklearn.metrics.balanced_accuracy_score` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>`. :pr:`32604` + +- |Feature| :func:`sklearn.metrics.pairwise.laplacian_kernel` now supports array API compatible inputs. + By :user:`Zubair Shakoor <zubairshakoorarbisoft>`. :pr:`32613` + +- |Feature| :func:`sklearn.metrics.cohen_kappa_score` now supports array API compatible inputs. + By :user:`Omar Salman <OmarManzoor>`. :pr:`32619` + +- |Feature| :func:`sklearn.metrics.cluster.davies_bouldin_score` now supports Array API compliant inputs. + By :user:`Josef Affourtit <jaffourt>`. :pr:`32693` + +- |Fix| Estimators with array API support no longer reject dataframe inputs when array API support is enabled. + By :user:`Tim Head <betatim>` :pr:`32838` + +Metadata routing +---------------- + +Refer to the :ref:`Metadata Routing User Guide <metadata_routing>` for +more details. + +- |Fix| Fixed an issue where passing `sample_weight` to a :class:`Pipeline` inside a + :class:`GridSearchCV` would raise an error with metadata routing enabled. + By `Adrin Jalali`_. :pr:`31898` + +Free-threaded CPython 3.14 support +---------------------------------- + +scikit-learn has support for free-threaded CPython, in particular +free-threaded wheels are available for all of our supported platforms on Python +3.14. + +Free-threaded (also known as nogil) CPython is a version of CPython that aims at +enabling efficient multi-threaded use cases by removing the Global Interpreter +Lock (GIL). + +If you want to try out free-threaded Python, the recommendation is to use +Python 3.14, that has fixed a number of issues compared to Python 3.13. Feel +free to try free-threaded on your use case and report any issues! + +For more details about free-threaded CPython see `py-free-threading doc <https://py-free-threading.github.io>`_, +in particular `how to install a free-threaded CPython <https://py-free-threading.github.io/installing_cpython/>`_ +and `Ecosystem compatibility tracking <https://py-free-threading.github.io/tracking/>`_. + +By :user:`Loรฏc Estรจve <lesteve>` and :user:`Olivier Grisel <ogrisel>` and many +other people in the wider Scientific Python and CPython ecosystem, for example +:user:`Nathan Goldbaum <ngoldbaum>`, :user:`Ralf Gommers <rgommers>`, +:user:`Edgar Andrรฉs Margffoy Tuay <andfoy>`. :pr:`32079` + +:mod:`sklearn.base` +------------------- + +- |Feature| Refactored :meth:`dir` in :class:`BaseEstimator` to recognize condition check in :meth:`available_if`. + By :user:`John Hendricks <j-hendricks>` and :user:`Miguel Parece <MiguelParece>`. :pr:`31928` + +- |Fix| Fixed the handling of pandas missing values in HTML display of all estimators. + By :user:`Dea Marรญa Lรฉon <deamarialeon>`. :pr:`32341` + +:mod:`sklearn.calibration` +-------------------------- + +- |Feature| Added temperature scaling method in :class:`calibration.CalibratedClassifierCV`. + By :user:`Virgil Chan <virchan>` and :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31068` + +:mod:`sklearn.cluster` +---------------------- + +- |Efficiency| :func:`cluster.kmeans_plusplus` now uses `np.cumsum` directly without extra + numerical stability checks and without casting to `np.float64`. + By :user:`Tiziano Zito <otizonaizit>` :pr:`31991` + +- |Fix| The default value of the `copy` parameter in :class:`cluster.HDBSCAN` + will change from `False` to `True` in 1.10 to avoid data modification + and maintain consistency with other estimators. + By :user:`Sarthak Puri <sarthakpurii>`. :pr:`31973` + +:mod:`sklearn.compose` +---------------------- + +- |Fix| The :class:`compose.ColumnTransformer` now correctly fits on data provided as a + `polars.DataFrame` when any transformer has a sparse output. + By :user:`Phillipp Gnan <ph-ll-pp>`. :pr:`32188` + +:mod:`sklearn.covariance` +------------------------- + +- |Efficiency| :class:`sklearn.covariance.GraphicalLasso`, + :class:`sklearn.covariance.GraphicalLassoCV` and + :func:`sklearn.covariance.graphical_lasso` with `mode="cd"` profit from the + fit time performance improvement of :class:`sklearn.linear_model.Lasso` by means of + gap safe screening rules. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31987` + +- |Fix| Fixed uncontrollable randomness in :class:`sklearn.covariance.GraphicalLasso`, + :class:`sklearn.covariance.GraphicalLassoCV` and + :func:`sklearn.covariance.graphical_lasso`. For `mode="cd"`, they now use cyclic + coordinate descent. Before, it was random coordinate descent with uncontrollable + random number seeding. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31987` + +- |Fix| Added correction to :class:`covariance.MinCovDet` to adjust for + consistency at the normal distribution. This reduces the bias present + when applying this method to data that is normally distributed. + By :user:`Daniel Herrera-Esposito <dherrera1911>` :pr:`32117` + +:mod:`sklearn.decomposition` +---------------------------- + +- |Efficiency| :class:`sklearn.decomposition.DictionaryLearning` and + :class:`sklearn.decomposition.MiniBatchDictionaryLearning` with `fit_algorithm="cd"`, + :class:`sklearn.decomposition.SparseCoder` with `transform_algorithm="lasso_cd"`, + :class:`sklearn.decomposition.MiniBatchSparsePCA`, + :class:`sklearn.decomposition.SparsePCA`, + :func:`sklearn.decomposition.dict_learning` and + :func:`sklearn.decomposition.dict_learning_online` with `method="cd"`, + :func:`sklearn.decomposition.sparse_encode` with `algorithm="lasso_cd"` + all profit from the fit time performance improvement of + :class:`sklearn.linear_model.Lasso` by means of gap safe screening rules. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31987` + +- |Enhancement| :class:`decomposition.SparseCoder` now follows the transformer API of scikit-learn. + In addition, the :meth:`fit` method now validates the input and parameters. + By :user:`Franรงois Paugam <FrancoisPgm>`. :pr:`32077` + +- |Fix| Add input checks to the `inverse_transform` method of :class:`decomposition.PCA` + and :class:`decomposition.IncrementalPCA`. + :pr:`29310` by :user:`Ian Faust <icfaust>`. :pr:`29310` + +:mod:`sklearn.discriminant_analysis` +------------------------------------ + +- |Feature| Added `solver`, `covariance_estimator` and `shrinkage` in + :class:`discriminant_analysis.QuadraticDiscriminantAnalysis`. + The resulting class is more similar to + :class:`discriminant_analysis.LinearDiscriminantAnalysis` + and allows for more flexibility in the estimation of the covariance matrices. + By :user:`Daniel Herrera-Esposito <dherrera1911>`. :pr:`32108` + +:mod:`sklearn.ensemble` +----------------------- + +- |Fix| :class:`ensemble.BaggingClassifier`, :class:`ensemble.BaggingRegressor` and + :class:`ensemble.IsolationForest` now use `sample_weight` to draw the samples + instead of forwarding them multiplied by a uniformly sampled mask to the + underlying estimators. Furthermore, when `max_samples` is a float, it is now + interpreted as a fraction of `sample_weight.sum()` instead of `X.shape[0]`. + The new default `max_samples=None` draws `X.shape[0]` samples, irrespective + of `sample_weight`. + By :user:`Antoine Baker <antoinebaker>`. :pr:`31414` and :pr:`32825` + +:mod:`sklearn.feature_selection` +-------------------------------- + +- |Enhancement| :class:`feature_selection.SelectFromModel` now does not force `max_features` to be + less than or equal to the number of input features. + By :user:`Thibault <ThibaultDECO>` :pr:`31939` + +:mod:`sklearn.gaussian_process` +------------------------------- + +- |Efficiency| make :class:`GaussianProcessRegressor.predict` faster when `return_cov` and + `return_std` are both `False`. + By :user:`Rafael Ayllรณn Gavilรกn <RafaAyGar>`. :pr:`31431` + +:mod:`sklearn.linear_model` +--------------------------- + +- |Efficiency| :class:`linear_model.ElasticNet` and :class:`linear_model.Lasso` with + `precompute=False` use less memory for dense `X` and are a bit faster. + Previously, they used twice the memory of `X` even for Fortran-contiguous `X`. + By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31665` + +- |Efficiency| :class:`linear_model.ElasticNet` and :class:`linear_model.Lasso` avoid + double input checking and are therefore a bit faster. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31848` + +- |Efficiency| :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, + :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, + :class:`linear_model.MultiTaskElasticNet`, + :class:`linear_model.MultiTaskElasticNetCV`, + :class:`linear_model.MultiTaskLasso` and :class:`linear_model.MultiTaskLassoCV` + are faster to fit by avoiding a BLAS level 1 (axpy) call in the innermost loop. + Same for functions :func:`linear_model.enet_path` and + :func:`linear_model.lasso_path`. + By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31956` and :pr:`31880` + +- |Efficiency| :class:`linear_model.ElasticNetCV`, :class:`linear_model.LassoCV`, + :class:`linear_model.MultiTaskElasticNetCV` and :class:`linear_model.MultiTaskLassoCV` + avoid an additional copy of `X` with default `copy_X=True`. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31946` + +- |Efficiency| :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, + :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, + :class:`linear_model.MultiTaskElasticNet`, :class:`linear_model.MultiTaskElasticNetCV` + :class:`linear_model.MultiTaskLasso`, :class:`linear_model.MultiTaskLassoCV` + as well as + :func:`linear_model.lasso_path` and :func:`linear_model.enet_path` now implement + gap safe screening rules in the coordinate descent solver for dense and sparse `X`. + The speedup of fitting time is particularly pronounced (10-times is possible) when + computing regularization paths like the \*CV-variants of the above estimators do. + There is now an additional check of the stopping criterion before entering the main + loop of descent steps. As the stopping criterion requires the computation of the dual + gap, the screening happens whenever the dual gap is computed. + By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31882`, :pr:`31986`, + :pr:`31987` and :pr:`32014` + +- |Enhancement| :class:`linear_model.ElasticNet`, :class:`linear_model.ElasticNetCV`, + :class:`linear_model.Lasso`, :class:`linear_model.LassoCV`, + :class:`MultiTaskElasticNet`, :class:`MultiTaskElasticNetCV`, + :class:`MultiTaskLasso`, :class:`MultiTaskLassoCV`, as well as + :func:`linear_model.enet_path` and :func:`linear_model.lasso_path` + now use `dual gap <= tol` instead of `dual gap < tol` as stopping criterion. + The resulting coefficients might differ to previous versions of scikit-learn in + rare cases. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31906` + +- |Fix| Fix the convergence criteria for SGD models, to avoid premature convergence when + `tol != None`. This primarily impacts :class:`SGDOneClassSVM` but also affects + :class:`SGDClassifier` and :class:`SGDRegressor`. Before this fix, only the loss + function without penalty was used as the convergence check, whereas now, the full + objective with regularization is used. + By :user:`Guillaume Lemaitre <glemaitre>` and :user:`kostayScr <kostayScr>` :pr:`31856` + +- |Fix| The allowed parameter range for the initial learning rate `eta0` in + :class:`linear_model.SGDClassifier`, :class:`linear_model.SGDOneClassSVM`, + :class:`linear_model.SGDRegressor` and :class:`linear_model.Perceptron` + changed from non-negative numbers to strictly positive numbers. + As a consequence, the default `eta0` of :class:`linear_model.SGDClassifier` + and :class:`linear_model.SGDOneClassSVM` changed from 0 to 0.01. But note that + `eta0` is not used by the default learning rate "optimal" of those two estimators. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31933` + +- |Enhancement| :class:`linear_model.LogisticRegressionCV` is able to handle CV splits where + some class labels are missing in some folds. Before, it raised an error whenever a + class label were missing in a fold. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`32747` + +- |API| :class:`linear_model.PassiveAggressiveClassifier` and + :class:`linear_model.PassiveAggressiveRegressor` are deprecated and will be removed + in 1.10. Equivalent estimators are available with :class:`linear_model.SGDClassifier` + and :class:`SGDRegressor`, both of which expose the options `learning_rate="pa1"` and + `"pa2"`. The parameter `eta0` can be used to specify the aggressiveness parameter of + the Passive-Aggressive-Algorithms, called C in the reference paper. + By :user:`Christian Lorentzen <lorentzenchr>` :pr:`31932` and :pr:`29097` + +- |API| :class:`linear_model.SGDClassifier`, :class:`linear_model.SGDRegressor`, and + :class:`linear_model.SGDOneClassSVM` now deprecate negative values for the + `power_t` parameter. Using a negative value will raise a warning in version 1.8 + and will raise an error in version 1.10. A value in the range [0.0, inf) must be used + instead. + By :user:`Ritvi Alagusankar <ritvi-alagusankar>` :pr:`31474` + +- |API| Raising error in :class:`sklearn.linear_model.LogisticRegression` when + liblinear solver is used and input X values are larger than 1e30, + the liblinear solver freezes otherwise. + By :user:`Shruti Nath <snath-xoc>`. :pr:`31888` + +- |API| :class:`linear_model.LogisticRegressionCV` got a new parameter + `use_legacy_attributes` to control the types and shapes of the fitted attributes + `C_`, `l1_ratio_`, `coefs_paths_`, `scores_` and `n_iter_`. + The current default value `True` keeps the legacy behaviour. If `False` then: + + - ``C_`` is a float. + - ``l1_ratio_`` is a float. + - ``coefs_paths_`` is an ndarray of shape + (n_folds, n_l1_ratios, n_cs, n_classes, n_features). + For binary problems (n_classes=2), the 2nd last dimension is 1. + - ``scores_`` is an ndarray of shape (n_folds, n_l1_ratios, n_cs). + - ``n_iter_`` is an ndarray of shape (n_folds, n_l1_ratios, n_cs). + + In version 1.10, the default will change to `False` and `use_legacy_attributes` will + be deprecated. In 1.12 `use_legacy_attributes` will be removed. + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`32114` + +- |API| Parameter `penalty` of :class:`linear_model.LogisticRegression` and + :class:`linear_model.LogisticRegressionCV` is deprecated and will be removed in + version 1.10. The equivalent behaviour can be obtained as follows: + + - for :class:`linear_model.LogisticRegression` + + - use `l1_ratio=0` instead of `penalty="l2"` + - use `l1_ratio=1` instead of `penalty="l1"` + - use `0<l1_ratio<1` instead of `penalty="elasticnet"` + - use `C=np.inf` instead of `penalty=None` + + - for :class:`linear_model.LogisticRegressionCV` + + - use `l1_ratios=(0,)` instead of `penalty="l2"` + - use `l1_ratios=(1,)` instead of `penalty="l1"` + - the equivalent of `penalty=None` is to have `np.inf` as an element of the `Cs` parameter + + For :class:`linear_model.LogisticRegression`, the default value of `l1_ratio` + has changed from `None` to `0.0`. Setting `l1_ratio=None` is deprecated and + will raise an error in version 1.10 + + For :class:`linear_model.LogisticRegressionCV`, the default value of `l1_ratios` + has changed from `None` to `"warn"`. It will be changed to `(0,)` in version + 1.10. Setting `l1_ratios=None` is deprecated and will raise an error in + version 1.10. + + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`32659` + +- |API| The `n_jobs` parameter of :class:`linear_model.LogisticRegression` is deprecated and + will be removed in 1.10. It has no effect since 1.8. + By :user:`Loรฏc Estรจve <lesteve>`. :pr:`32742` + +:mod:`sklearn.manifold` +----------------------- + +- |MajorFeature| :class:`manifold.ClassicalMDS` was implemented to perform classical MDS + (eigendecomposition of the double-centered distance matrix). + By :user:`Dmitry Kobak <dkobak>` and :user:`Meekail Zain <Micky774>` :pr:`31322` + +- |Feature| :class:`manifold.MDS` now supports arbitrary distance metrics + (via `metric` and `metric_params` parameters) and + initialization via classical MDS (via `init` parameter). + The `dissimilarity` parameter was deprecated. The old `metric` parameter + was renamed into `metric_mds`. + By :user:`Dmitry Kobak <dkobak>` :pr:`32229` + +- |Feature| :class:`manifold.TSNE` now supports PCA initialization with sparse input matrices. + By :user:`Arturo Amor <ArturoAmorQ>`. :pr:`32433` + +:mod:`sklearn.metrics` +---------------------- + +- |Feature| :func:`metrics.d2_brier_score` has been added which calculates the D^2 for the Brier score. + By :user:`Omar Salman <OmarManzoor>`. :pr:`28971` + +- |Feature| Add :func:`metrics.confusion_matrix_at_thresholds` function that returns the number of + true negatives, false positives, false negatives and true positives per threshold. + By :user:`Success Moses <SuccessMoses>`. :pr:`30134` + +- |Efficiency| Avoid redundant input validation in :func:`metrics.d2_log_loss_score` + leading to a 1.2x speedup in large scale benchmarks. + By :user:`Olivier Grisel <ogrisel>` and :user:`Omar Salman <OmarManzoor>` :pr:`32356` + +- |Enhancement| :func:`metrics.median_absolute_error` now supports Array API compatible inputs. + By :user:`Lucy Liu <lucyleeow>`. :pr:`31406` + +- |Enhancement| Improved the error message for sparse inputs for the following metrics: + :func:`metrics.accuracy_score`, + :func:`metrics.multilabel_confusion_matrix`, :func:`metrics.jaccard_score`, + :func:`metrics.zero_one_loss`, :func:`metrics.f1_score`, + :func:`metrics.fbeta_score`, :func:`metrics.precision_recall_fscore_support`, + :func:`metrics.class_likelihood_ratios`, :func:`metrics.precision_score`, + :func:`metrics.recall_score`, :func:`metrics.classification_report`, + :func:`metrics.hamming_loss`. + By :user:`Lucy Liu <lucyleeow>`. :pr:`32047` + +- |Fix| :func:`metrics.median_absolute_error` now uses `_averaged_weighted_percentile` + instead of `_weighted_percentile` to calculate median when `sample_weight` is not + `None`. This is equivalent to using the "averaged_inverted_cdf" instead of + the "inverted_cdf" quantile method, which gives results equivalent to `numpy.median` + if equal weights used. + By :user:`Lucy Liu <lucyleeow>` :pr:`30787` + +- |Fix| Additional `sample_weight` checking has been added to + :func:`metrics.accuracy_score`, + :func:`metrics.balanced_accuracy_score`, + :func:`metrics.brier_score_loss`, + :func:`metrics.class_likelihood_ratios`, + :func:`metrics.classification_report`, + :func:`metrics.cohen_kappa_score`, + :func:`metrics.confusion_matrix`, + :func:`metrics.f1_score`, + :func:`metrics.fbeta_score`, + :func:`metrics.hamming_loss`, + :func:`metrics.jaccard_score`, + :func:`metrics.matthews_corrcoef`, + :func:`metrics.multilabel_confusion_matrix`, + :func:`metrics.precision_recall_fscore_support`, + :func:`metrics.precision_score`, + :func:`metrics.recall_score` and + :func:`metrics.zero_one_loss`. + `sample_weight` can only be 1D, consistent to `y_true` and `y_pred` in length,and + all values must be finite and not complex. + By :user:`Lucy Liu <lucyleeow>`. :pr:`31701` + +- |Fix| `y_pred` is deprecated in favour of `y_score` in + :func:`metrics.DetCurveDisplay.from_predictions` and + :func:`metrics.PrecisionRecallDisplay.from_predictions`. `y_pred` will be removed in + v1.10. + By :user:`Luis <luiser1401>` :pr:`31764` + +- |Fix| `repr` on a scorer which has been created with a `partial` `score_func` now correctly + works and uses the `repr` of the given `partial` object. + By `Adrin Jalali`_. :pr:`31891` + +- |Fix| kwargs specified in the `curve_kwargs` parameter of + :meth:`metrics.RocCurveDisplay.from_cv_results` now only overwrite their corresponding + default value before being passed to Matplotlib's `plot`. Previously, passing any + `curve_kwargs` would overwrite all default kwargs. + By :user:`Lucy Liu <lucyleeow>`. :pr:`32313` + +- |Fix| Registered named scorer objects for :func:`metrics.d2_brier_score` and + :func:`metrics.d2_log_loss_score` and updated their input validation to be + consistent with related metric functions. + By :user:`Olivier Grisel <ogrisel>` and :user:`Omar Salman <OmarManzoor>` :pr:`32356` + +- |Fix| :meth:`metrics.RocCurveDisplay.from_cv_results` will now infer `pos_label` as + `estimator.classes_[-1]`, using the estimator from `cv_results`, when + `pos_label=None`. Previously, an error was raised when `pos_label=None`. + By :user:`Lucy Liu <lucyleeow>`. :pr:`32372` + +- |Fix| All classification metrics now raise a `ValueError` when required input arrays + (`y_pred`, `y_true`, `y1`, `y2`, `pred_decision`, or `y_proba`) are empty. + Previously, `accuracy_score`, `class_likelihood_ratios`, `classification_report`, + `confusion_matrix`, `hamming_loss`, `jaccard_score`, `matthews_corrcoef`, + `multilabel_confusion_matrix`, and `precision_recall_fscore_support` did not raise + this error consistently. + By :user:`Stefanie Senger <StefanieSenger>`. :pr:`32549` + +- |API| :func:`metrics.cluster.entropy` is deprecated and will be removed in v1.10. + By :user:`Lucy Liu <lucyleeow>` :pr:`31294` + +- |API| The `estimator_name` parameter is deprecated in favour of `name` in + :class:`metrics.PrecisionRecallDisplay` and will be removed in 1.10. + By :user:`Lucy Liu <lucyleeow>`. :pr:`32310` + +:mod:`sklearn.model_selection` +------------------------------ + +- |Enhancement| :class:`model_selection.StratifiedShuffleSplit` will now specify which classes + have too few members when raising a ``ValueError`` if any class has less than 2 members. + This is useful to identify which classes are causing the error. + By :user:`Marc Bresson <MarcBresson>` :pr:`32265` + +- |Fix| Fix shuffle behaviour in :class:`model_selection.StratifiedGroupKFold`. Now + stratification among folds is also preserved when `shuffle=True`. + By :user:`Pau Folch <pfolch>`. :pr:`32540` + +:mod:`sklearn.multiclass` +------------------------- + +- |Fix| Fix tie-breaking behavior in :class:`multiclass.OneVsRestClassifier` to match + `np.argmax` tie-breaking behavior. + By :user:`Lakshmi Krishnan <lakrish>`. :pr:`15504` + +:mod:`sklearn.naive_bayes` +-------------------------- + +- |Fix| :class:`naive_bayes.GaussianNB` preserves the dtype of the fitted attributes + according to the dtype of `X`. + By :user:`Omar Salman <OmarManzoor>` :pr:`32497` + +:mod:`sklearn.preprocessing` +---------------------------- + +- |Enhancement| :class:`preprocessing.SplineTransformer` can now handle missing values with the + parameter `handle_missing`. By :user:`Stefanie Senger <StefanieSenger>`. :pr:`28043` + +- |Enhancement| The :class:`preprocessing.PowerTransformer` now returns a warning + when NaN values are encountered in the inverse transform, `inverse_transform`, typically + caused by extremely skewed data. + By :user:`Roberto Mourao <maf-rnmourao>` :pr:`29307` + +- |Enhancement| :class:`preprocessing.MaxAbsScaler` can now clip out-of-range values in held-out data + with the parameter `clip`. + By :user:`Hleb Levitski <glevv>`. :pr:`31790` + +- |Fix| Fixed a bug in :class:`preprocessing.OneHotEncoder` where `handle_unknown='warn'` incorrectly behaved like `'ignore'` instead of `'infrequent_if_exist'`. + By :user:`Nithurshen <nithurshen>` :pr:`32592` + +:mod:`sklearn.semi_supervised` +------------------------------ + +- |Fix| User written kernel results are now normalized in + :class:`semi_supervised.LabelPropagation` + so all row sums equal 1 even if kernel gives asymmetric or non-uniform row sums. + By :user:`Dan Schult <dschult>`. :pr:`31924` + +:mod:`sklearn.tree` +------------------- + +- |Efficiency| :class:`tree.DecisionTreeRegressor` with `criterion="absolute_error"` + now runs much faster: O(n log n) complexity against previous O(n^2) + allowing to scale to millions of data points, even hundred of millions. + By :user:`Arthur Lacote <cakedev0>` :pr:`32100` + +- |Fix| Make :func:`tree.export_text` thread-safe. + By :user:`Olivier Grisel <ogrisel>`. :pr:`30041` + +- |Fix| :func:`~sklearn.tree.export_graphviz` now raises a `ValueError` if given feature + names are not all strings. + By :user:`Guilherme Peixoto <guilhermecsnpeixoto>` :pr:`31036` + +- |Fix| :class:`tree.DecisionTreeRegressor` with `criterion="absolute_error"` + would sometimes make sub-optimal splits + (i.e. splits that don't minimize the absolute error). + Now it's fixed. Hence retraining trees might gives slightly different + results. + By :user:`Arthur Lacote <cakedev0>` :pr:`32100` + +- |Fix| Fixed a regression in :ref:`decision trees <tree>` where almost constant features were + not handled properly. + By :user:`Sercan Turkmen <sercant>`. :pr:`32259` + +- |Fix| Fixed splitting logic during training in :class:`tree.DecisionTree*` + (and consequently in :class:`ensemble.RandomForest*`) + for nodes containing near-constant feature values and missing values. + Beforehand, trees were cut short if a constant feature was found, + even if there was more splitting that could be done on the basis of missing values. + By :user:`Arthur Lacote <cakedev0>` :pr:`32274` + +- |Fix| Fix handling of missing values in method :func:`decision_path` of trees + (:class:`tree.DecisionTreeClassifier`, :class:`tree.DecisionTreeRegressor`, + :class:`tree.ExtraTreeClassifier` and :class:`tree.ExtraTreeRegressor`) + By :user:`Arthur Lacote <cakedev0>`. :pr:`32280` + +- |Fix| Fix decision tree splitting with missing values present in some features. In some cases the last + non-missing sample would not be partitioned correctly. + By :user:`Tim Head <betatim>` and :user:`Arthur Lacote <cakedev0>`. :pr:`32351` + +:mod:`sklearn.utils` +-------------------- + +- |Efficiency| The function :func:`sklearn.utils.extmath.safe_sparse_dot` was improved by a dedicated + Cython routine for the case of `a @ b` with sparse 2-dimensional `a` and `b` and when + a dense output is required, i.e., `dense_output=True`. This improves several + algorithms in scikit-learn when dealing with sparse arrays (or matrices). + By :user:`Christian Lorentzen <lorentzenchr>`. :pr:`31952` + +- |Enhancement| The parameter table in the HTML representation of all scikit-learn estimators and + more generally of estimators inheriting from :class:`base.BaseEstimator` + now displays the parameter description as a tooltip and has a link to the online + documentation for each parameter. + By :user:`Dea Marรญa Lรฉon <DeaMariaLeon>`. :pr:`31564` + +- |Enhancement| ``sklearn.utils._check_sample_weight`` now raises a clearer error message when the + provided weights are neither a scalar nor a 1-D array-like of the same size as the + input data. + By :user:`Kapil Parekh <kapslock123>`. :pr:`31873` + +- |Enhancement| :func:`sklearn.utils.estimator_checks.parametrize_with_checks` now lets you configure + strict mode for xfailing checks. Tests that unexpectedly pass will lead to a test + failure. The default behaviour is unchanged. + By :user:`Tim Head <betatim>`. :pr:`31951` + +- |Enhancement| Fixed the alignment of the "?" and "i" symbols and improved the color style of the + HTML representation of estimators. + By :user:`Guillaume Lemaitre <glemaitre>`. :pr:`31969` + +- |Fix| Changes the way color are chosen when displaying an estimator as an HTML representation. Colors are not adapted anymore to the user's theme, but chosen based on theme declared color scheme (light or dark) for VSCode and JupyterLab. If theme does not declare a color scheme, scheme is chosen according to default text color of the page, if it fails fallbacks to a media query. + By :user:`Matt J. <rouk1>`. :pr:`32330` + +- |API| :func:`utils.extmath.stable_cumsum` is deprecated and will be removed + in v1.10. Use `np.cumulative_sum` with the desired dtype directly instead. + By :user:`Tiziano Zito <opossumnano>`. :pr:`32258` + .. rubric:: Code and documentation contributors Thanks to everyone who has contributed to the maintenance and improvement of the project since version 1.7, including: -TODO: update at the time of the release. +$id, 4hm3d, Acciaro Gennaro Daniele, achyuthan.s, Adam J. Stewart, Adriano +Leรฃo, Adrien Linares, Adrin Jalali, Aitsaid Azzedine Idir, Alexander Fabisch, +Alexandre Abraham, Andrรฉs H. Zapke, Anne Beyer, Anthony Gitter, AnthonyPrudent, +antoinebaker, Arpan Mukherjee, Arthur, Arthur Lacote, Arturo Amor, +ayoub.agouzoul, Ayrat, Ayush, Ayush Tanwar, Basile Jezequel, Bhavya Patwa, +BRYANT MUSI BABILA, Casey Heath, Chems Ben, Christian Lorentzen, Christian +Veenhuis, Christine P. Chai, cstec, C. Titus Brown, Daniel Herrera-Esposito, +Dan Schult, dbXD320, Dea Marรญa Lรฉon, Deepyaman Datta, dependabot[bot], Dhyey +Findoriya, Dimitri Papadopoulos Orfanos, Dipak Dhangar, Dmitry Kobak, +elenafillo, Elham Babaei, EmilyXinyi, Emily (Xinyi) Chen, Eugen-Bleck, Evgeni +Burovski, fabarca, Fabrizio Damicelli, Faizan-Ul Huda, Franรงois Goupil, +Franรงois Paugam, Gaetan, GaetandeCast, Gesa Loof, Gonรงalo Guiomar, Gordon Grey, +Gowtham Kumar K., Guilherme Peixoto, Guillaume Lemaitre, hakan รงanakรงฤฑ, Harshil +Sanghvi, Henri Bonamy, Hleb Levitski, HulusiOzy, hvtruong, Ian Faust, Imad +Saddik, Jรฉrรฉmie du Boisberranger, Jรฉrรดme Dockรจs, John Hendricks, Joris Van den +Bossche, Josef Affourtit, Josh, jshn9515, Junaid, KALLA GANASEKHAR, Kapil +Parekh, Kenneth Enevoldsen, Kian Eliasi, kostayScr, Krishnan Vignesh, kryggird, +Kyle S, Lakshmi Krishnan, Leomax, Loic Esteve, Luca Bittarello, Lucas Colley, +Lucy Liu, Luigi Giugliano, Luis, Mahdi Abid, Mahi Dhiman, Maitrey Talware, +Mamduh Zabidi, Manikandan Gobalakrishnan, Marc Bresson, Marco Edward Gorelli, +Marek Pokropiล„ski, Maren Westermann, Marie Sacksick, Marija Vlajic, Matt J., +Mayank Raj, Michael Burkhart, Michael ล imรกฤek, Miguel Fernandes, Miro Hronฤok, +Mohamed DHIFALLAH, Muhammad Waseem, MUHAMMED SINAN D, Natalia Mokeeva, Nicholas +Farr, Nicolas Bolle, Nicolas Hug, nithish-74, Nithurshen, Nitin Pratap Singh, +NotAceNinja, Olivier Grisel, omahs, Omar Salman, Patrick Walsh, Peter Holzer, +pfolch, ph-ll-pp, Prashant Bansal, Quan H. Nguyen, Radovenchyk, Rafael Ayllรณn +Gavilรกn, Raghvender, Ranjodh Singh, Ravichandranayakar, Remi Gau, Reshama +Shaikh, Richard Harris, RishiP2006, Ritvi Alagusankar, Roberto Mourao, Robert +Pollak, Roshangoli, roychan, R Sagar Shresti, Sarthak Puri, saskra, +scikit-learn-bot, Scott Huberty, Sercan Turkmen, Sergio P, Shashank S, Shaurya +Bisht, Shivam, Shruti Nath, SIKAI ZHANG, sisird864, SiyuJin-1, S. M. Mohiuddin +Khan Shiam, Somdutta Banerjee, sotagg, Sota Goto, Spencer Bradkin, Stefan, +Stefanie Senger, Steffen Rehberg, Steven Hur, Success Moses, Sylvain Combettes, +ThibaultDECO, Thomas J. Fan, Thomas Li, Thomas S., Tim Head, Tingwei Zhu, +Tiziano Zito, TJ Norred, Username46786, Utsab Dahal, Vasanth K, Veghit, +VirenPassi, Virgil Chan, Vivaan Nanavati, Xiao Yuan, xuzhang0327, Yaroslav +Halchenko, Yaswanth Kumar, Zijun yi, zodchi94, Zubair Shakoor diff --git a/doc/whats_new/v1.9.rst b/doc/whats_new/v1.9.rst new file mode 100644 index 0000000000000..0b7a15ba62292 --- /dev/null +++ b/doc/whats_new/v1.9.rst @@ -0,0 +1,34 @@ +.. include:: _contributors.rst + +.. currentmodule:: sklearn + +.. _release_notes_1_9: + +=========== +Version 1.9 +=========== + +.. + -- UNCOMMENT WHEN 1.9.0 IS RELEASED -- + For a short description of the main highlights of the release, please refer to + :ref:`sphx_glr_auto_examples_release_highlights_plot_release_highlights_1_9_0.py`. + + +.. + DELETE WHEN 1.9.0 IS RELEASED + Since October 2024, DO NOT add your changelog entry in this file. +.. + Instead, create a file named `<PR_NUMBER>.<TYPE>.rst` in the relevant sub-folder in + `doc/whats_new/upcoming_changes/`. For full details, see: + https://github.com/scikit-learn/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md + +.. include:: changelog_legend.inc + +.. towncrier release notes start + +.. rubric:: Code and documentation contributors + +Thanks to everyone who has contributed to the maintenance and improvement of +the project since version 1.8, including: + +TODO: update at the time of the release. diff --git a/examples/applications/plot_cyclical_feature_engineering.py b/examples/applications/plot_cyclical_feature_engineering.py index c684cb072b743..dbbc39479571e 100644 --- a/examples/applications/plot_cyclical_feature_engineering.py +++ b/examples/applications/plot_cyclical_feature_engineering.py @@ -210,7 +210,7 @@ def evaluate(model, X, y, cv, model_prop=None, model_step=None): y, cv=cv, scoring=["neg_mean_absolute_error", "neg_root_mean_squared_error"], - return_estimator=model_prop is not None, + return_estimator=True, ) if model_prop is not None: if model_step is not None: @@ -226,6 +226,8 @@ def evaluate(model, X, y, cv, model_prop=None, model_step=None): f"Mean Absolute Error: {mae.mean():.3f} +/- {mae.std():.3f}\n" f"Root Mean Squared Error: {rmse.mean():.3f} +/- {rmse.std():.3f}" ) + # To display the fitted estimator diagrams in the notebook. + return cv_results["estimator"][0] evaluate(gbrt, X, y, cv=ts_cv, model_prop="n_iter_") @@ -261,6 +263,7 @@ def evaluate(model, X, y, cv, model_prop=None, model_step=None): ("categorical", one_hot_encoder, categorical_columns), ], remainder=MinMaxScaler(), + verbose_feature_names_out=False, ), RidgeCV(alphas=alphas), ) @@ -308,6 +311,7 @@ def evaluate(model, X, y, cv, model_prop=None, model_step=None): ("one_hot_time", one_hot_encoder, ["hour", "weekday", "month"]), ], remainder=MinMaxScaler(), + verbose_feature_names_out=False, ), RidgeCV(alphas=alphas), ) @@ -348,11 +352,15 @@ def evaluate(model, X, y, cv, model_prop=None, model_step=None): def sin_transformer(period): - return FunctionTransformer(lambda x: np.sin(x / period * 2 * np.pi)) + return FunctionTransformer( + lambda x: np.sin(x / period * 2 * np.pi), feature_names_out="one-to-one" + ) def cos_transformer(period): - return FunctionTransformer(lambda x: np.cos(x / period * 2 * np.pi)) + return FunctionTransformer( + lambda x: np.cos(x / period * 2 * np.pi), feature_names_out="one-to-one" + ) # %% @@ -399,6 +407,7 @@ def cos_transformer(period): ("hour_cos", cos_transformer(24), ["hour"]), ], remainder=MinMaxScaler(), + verbose_feature_names_out=True, ) cyclic_cossin_linear_pipeline = make_pipeline( cyclic_cossin_transformer, @@ -472,6 +481,7 @@ def periodic_spline_transformer(period, n_splines=None, degree=3): ("cyclic_hour", periodic_spline_transformer(24, n_splines=12), ["hour"]), ], remainder=MinMaxScaler(), + verbose_feature_names_out=False, ) cyclic_spline_linear_pipeline = make_pipeline( cyclic_spline_transformer, @@ -615,8 +625,15 @@ def periodic_spline_transformer(period, n_splines=None, degree=3): ColumnTransformer( [ ("cyclic_hour", periodic_spline_transformer(24, n_splines=8), ["hour"]), - ("workingday", FunctionTransformer(lambda x: x == "True"), ["workingday"]), - ] + ( + "workingday", + FunctionTransformer( + lambda x: x == "True", feature_names_out="one-to-one" + ), + ["workingday"], + ), + ], + verbose_feature_names_out=False, ), PolynomialFeatures(degree=2, interaction_only=True, include_bias=False), ) @@ -631,8 +648,9 @@ def periodic_spline_transformer(period, n_splines=None, degree=3): [ ("marginal", cyclic_spline_transformer), ("interactions", hour_workday_interaction), - ] - ), + ], + verbose_feature_names_out=True, + ).set_output(transform="pandas"), RidgeCV(alphas=alphas), ) evaluate(cyclic_spline_interactions_pipeline, X, y, cv=ts_cv) @@ -683,10 +701,11 @@ def periodic_spline_transformer(period, n_splines=None, degree=3): ("one_hot_time", one_hot_encoder, ["hour", "weekday", "month"]), ], remainder="passthrough", + verbose_feature_names_out=False, ), Nystroem(kernel="poly", degree=2, n_components=300, random_state=0), RidgeCV(alphas=alphas), -) +).set_output(transform="pandas") evaluate(one_hot_poly_pipeline, X, y, cv=ts_cv) diff --git a/examples/applications/plot_face_recognition.py b/examples/applications/plot_face_recognition.py index add219aed1610..e14c2686514ef 100644 --- a/examples/applications/plot_face_recognition.py +++ b/examples/applications/plot_face_recognition.py @@ -83,7 +83,7 @@ # %% -# Train a SVM classification model +# Train an SVM classification model print("Fitting the classifier to the training set") t0 = time() diff --git a/examples/applications/plot_out_of_core_classification.py b/examples/applications/plot_out_of_core_classification.py index 52ebd0862150d..2b0df1eed6640 100644 --- a/examples/applications/plot_out_of_core_classification.py +++ b/examples/applications/plot_out_of_core_classification.py @@ -52,7 +52,7 @@ def _not_in_sphinx(): class ReutersParser(HTMLParser): - """Utility class to parse a SGML file and yield documents one at a time.""" + """Utility class to parse an SGML file and yield documents one at a time.""" def __init__(self, encoding="latin-1"): HTMLParser.__init__(self) diff --git a/examples/applications/plot_tomography_l1_reconstruction.py b/examples/applications/plot_tomography_l1_reconstruction.py index 02d4594b90518..7be4947ea8a18 100644 --- a/examples/applications/plot_tomography_l1_reconstruction.py +++ b/examples/applications/plot_tomography_l1_reconstruction.py @@ -89,7 +89,9 @@ def build_projection_operator(l_x, n_dir): weights += list(w[mask]) camera_inds += list(inds[mask] + i * l_x) data_inds += list(data_unravel_indices[mask]) - proj_operator = sparse.coo_matrix((weights, (camera_inds, data_inds))) + camera_inds = np.array(camera_inds, dtype=np.int32) # lasso needs int32 inds + data_inds = np.array(data_inds, dtype=np.int32) + proj_operator = sparse.coo_array((weights, (camera_inds, data_inds))) return proj_operator diff --git a/examples/applications/wikipedia_principal_eigenvector.py b/examples/applications/wikipedia_principal_eigenvector.py index 2ccd028b9a00d..b59cf8eb6c058 100644 --- a/examples/applications/wikipedia_principal_eigenvector.py +++ b/examples/applications/wikipedia_principal_eigenvector.py @@ -146,7 +146,7 @@ def get_adjacency_matrix(redirects_filename, page_links_filename, limit=None): break print("Computing the adjacency matrix") - X = sparse.lil_matrix((len(index_map), len(index_map)), dtype=np.float32) + X = sparse.lil_array((len(index_map), len(index_map)), dtype=np.float32) for i, j in links: X[i, j] = 1.0 del links diff --git a/examples/calibration/plot_compare_calibration.py b/examples/calibration/plot_compare_calibration.py index b5a2794fc9e7e..bdccb5ef9eed3 100644 --- a/examples/calibration/plot_compare_calibration.py +++ b/examples/calibration/plot_compare_calibration.py @@ -186,7 +186,7 @@ def predict_proba(self, X): # sufficient to guarantee a well-calibrated model by itself: even with a very # large training set, logistic regression could still be poorly calibrated, if # it was too strongly regularized or if the choice and preprocessing of input -# features made this model mis-specified (e.g. if the true decision boundary of +# features made this model misspecified (e.g. if the true decision boundary of # the dataset is a highly non-linear function of the input features). # # In this example the training set was intentionally kept very small. In this diff --git a/examples/classification/plot_classification_probability.py b/examples/classification/plot_classification_probability.py index 050afc2377669..413b02fdff88a 100644 --- a/examples/classification/plot_classification_probability.py +++ b/examples/classification/plot_classification_probability.py @@ -8,7 +8,7 @@ probabilities of various classifiers in a 2D feature space, mostly for didactic purposes. -The first three columns shows the predicted probability for varying values of +The first three columns show the predicted probability for varying values of the two features. Round markers represent the test data that was predicted to belong to that class. @@ -20,6 +20,7 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause +# %% import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np @@ -63,14 +64,14 @@ # the classifier in regions where it is not certain of its prediction. classifiers = { - "Logistic regression\n(C=0.01)": LogisticRegression(C=0.1), - "Logistic regression\n(C=1)": LogisticRegression(C=100), + "Logistic regression\n(C=0.1)": LogisticRegression(C=0.1), + "Logistic regression\n(C=100)": LogisticRegression(C=100), "Gaussian Process": GaussianProcessClassifier(kernel=1.0 * RBF([1.0, 1.0])), "Logistic regression\n(RBF features)": make_pipeline( Nystroem(kernel="rbf", gamma=5e-1, n_components=50, random_state=1), LogisticRegression(C=10), ), - "Gradient Boosting": HistGradientBoostingClassifier(), + "Gradient Boosting": HistGradientBoostingClassifier(random_state=42), "Logistic regression\n(binned features)": make_pipeline( KBinsDiscretizer(n_bins=5, quantile_method="averaged_inverted_cdf"), PolynomialFeatures(interaction_only=True), @@ -136,7 +137,7 @@ cmap="Blues", levels=levels, ) - axes[classifier_idx, label].set_title(f"Class {label}") + axes[classifier_idx, label].set_title(f"Class {iris.target_names[label]}") # plot data predicted to belong to given class mask_y_pred = y_pred == label axes[classifier_idx, label].scatter( @@ -157,7 +158,8 @@ ) for label in y_unique: mask_label = y_test == label - axes[classifier_idx, 3].scatter( + max_col = len(y_unique) + axes[classifier_idx, max_col].scatter( X_test[mask_label, 0], X_test[mask_label, 1], c=max_class_disp.multiclass_colors_[[label], :], @@ -197,7 +199,6 @@ # ----------------------- pd.DataFrame(evaluation_results).round(2) - # %% # Analysis # -------- diff --git a/examples/cluster/plot_face_compress.py b/examples/cluster/plot_face_compress.py index 7a078d24fe16d..2c7541e645cde 100644 --- a/examples/cluster/plot_face_compress.py +++ b/examples/cluster/plot_face_compress.py @@ -28,7 +28,7 @@ # %% # Thus the image is a 2D array of 768 pixels in height and 1024 pixels in width. Each -# value is a 8-bit unsigned integer, which means that the image is encoded using 8 +# value is an 8-bit unsigned integer, which means that the image is encoded using 8 # bits per pixel. The total memory usage of the image is 786 kilobytes (1 byte equals # 8 bits). # diff --git a/examples/cluster/plot_inductive_clustering.py b/examples/cluster/plot_inductive_clustering.py index 29846b15cdb60..c4d168afecd58 100644 --- a/examples/cluster/plot_inductive_clustering.py +++ b/examples/cluster/plot_inductive_clustering.py @@ -25,7 +25,7 @@ import matplotlib.pyplot as plt -from sklearn.base import BaseEstimator, clone +from sklearn.base import BaseEstimator, ClusterMixin, clone from sklearn.cluster import AgglomerativeClustering from sklearn.datasets import make_blobs from sklearn.ensemble import RandomForestClassifier @@ -50,7 +50,7 @@ def _classifier_has(attr): ) -class InductiveClusterer(BaseEstimator): +class InductiveClusterer(ClusterMixin, BaseEstimator): def __init__(self, clusterer, classifier): self.clusterer = clusterer self.classifier = classifier @@ -60,6 +60,7 @@ def fit(self, X, y=None): self.classifier_ = clone(self.classifier) y = self.clusterer_.fit_predict(X) self.classifier_.fit(X, y) + self.labels_ = y return self @available_if(_classifier_has("predict")) @@ -122,7 +123,12 @@ def plot_scatter(X, color, alpha=0.5): # Plotting decision regions DecisionBoundaryDisplay.from_estimator( - inductive_learner, X, response_method="predict", alpha=0.4, ax=ax + inductive_learner, + X, + response_method="predict", + multiclass_colors="viridis", + alpha=0.4, + ax=ax, ) plt.title("Classify unknown instances") diff --git a/examples/compose/plot_column_transformer.py b/examples/compose/plot_column_transformer.py index 8f779d085614a..f61b3b04b0195 100644 --- a/examples/compose/plot_column_transformer.py +++ b/examples/compose/plot_column_transformer.py @@ -171,7 +171,7 @@ def text_stats(posts): }, ), ), - # Use a SVC classifier on the combined features + # Use an SVC classifier on the combined features ("svc", LinearSVC(dual=False)), ], verbose=True, diff --git a/examples/compose/plot_column_transformer_mixed_types.py b/examples/compose/plot_column_transformer_mixed_types.py index 91768e261f271..a27c5dc56e3d4 100644 --- a/examples/compose/plot_column_transformer_mixed_types.py +++ b/examples/compose/plot_column_transformer_mixed_types.py @@ -78,7 +78,7 @@ categorical_features = ["embarked", "sex", "pclass"] categorical_transformer = Pipeline( steps=[ - ("encoder", OneHotEncoder(handle_unknown="ignore")), + ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)), ("selector", SelectPercentile(chi2, percentile=50)), ] ) @@ -94,7 +94,7 @@ # Now we have a full prediction pipeline. clf = Pipeline( steps=[("preprocessor", preprocessor), ("classifier", LogisticRegression())] -) +).set_output(transform="pandas") X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0) @@ -199,6 +199,7 @@ print("Best params:") print(search_cv.best_params_) +search_cv # %% # The internal cross-validation scores obtained by those parameters is: diff --git a/examples/covariance/plot_covariance_estimation.py b/examples/covariance/plot_covariance_estimation.py index f8bee76ea7ae7..18c7737f31b34 100644 --- a/examples/covariance/plot_covariance_estimation.py +++ b/examples/covariance/plot_covariance_estimation.py @@ -71,7 +71,7 @@ # according to a grid of potential shrinkage parameters. # # * A close formula proposed by Ledoit and Wolf to compute -# the asymptotically optimal regularization parameter (minimizing a MSE +# the asymptotically optimal regularization parameter (minimizing an MSE # criterion), yielding the :class:`~sklearn.covariance.LedoitWolf` # covariance estimate. # diff --git a/examples/covariance/plot_lw_vs_oas.py b/examples/covariance/plot_lw_vs_oas.py index 6ec995c5c3b01..1611404a64ce0 100644 --- a/examples/covariance/plot_lw_vs_oas.py +++ b/examples/covariance/plot_lw_vs_oas.py @@ -5,7 +5,7 @@ The usual covariance maximum likelihood estimate can be regularized using shrinkage. Ledoit and Wolf proposed a close formula to compute -the asymptotically optimal shrinkage parameter (minimizing a MSE +the asymptotically optimal shrinkage parameter (minimizing an MSE criterion), yielding the Ledoit-Wolf covariance estimate. Chen et al. [1]_ proposed an improvement of the Ledoit-Wolf shrinkage diff --git a/examples/covariance/plot_mahalanobis_distances.py b/examples/covariance/plot_mahalanobis_distances.py index a1507c3ef162e..1298524734243 100644 --- a/examples/covariance/plot_mahalanobis_distances.py +++ b/examples/covariance/plot_mahalanobis_distances.py @@ -109,9 +109,9 @@ from sklearn.covariance import EmpiricalCovariance, MinCovDet -# fit a MCD robust estimator to data +# fit an MCD robust estimator to data robust_cov = MinCovDet().fit(X) -# fit a MLE estimator to data +# fit an MLE estimator to data emp_cov = EmpiricalCovariance().fit(X) print( "Estimated covariance matrix:\nMCD (Robust):\n{}\nMLE:\n{}".format( diff --git a/examples/ensemble/plot_gradient_boosting_categorical.py b/examples/ensemble/plot_gradient_boosting_categorical.py index 5e6957b0945b4..c67aa716ea8f7 100644 --- a/examples/ensemble/plot_gradient_boosting_categorical.py +++ b/examples/ensemble/plot_gradient_boosting_categorical.py @@ -160,11 +160,14 @@ # held-out part. This way, each sample is encoded using statistics from data it # was not part of, preventing information leakage from the target. +from sklearn.model_selection import KFold from sklearn.preprocessing import TargetEncoder target_encoder = make_column_transformer( ( - TargetEncoder(target_type="continuous", random_state=42), + TargetEncoder( + target_type="continuous", cv=KFold(shuffle=True, random_state=42) + ), make_column_selector(dtype_include="category"), ), remainder="passthrough", diff --git a/examples/ensemble/plot_gradient_boosting_quantile.py b/examples/ensemble/plot_gradient_boosting_quantile.py index dbe3a99b045dd..3086482d614ac 100644 --- a/examples/ensemble/plot_gradient_boosting_quantile.py +++ b/examples/ensemble/plot_gradient_boosting_quantile.py @@ -52,13 +52,13 @@ def f(x): # Fitting non-linear quantile and least squares regressors # -------------------------------------------------------- # -# Fit gradient boosting models trained with the quantile loss and -# alpha=0.05, 0.5, 0.95. +# Fit gradient boosting models trained with the quantile loss and `alpha=0.05`, +# `alpha=0.5`, `alpha=0.95`. # -# The models obtained for alpha=0.05 and alpha=0.95 produce a 90% confidence +# The models obtained for `alpha=0.05` and `alpha=0.95` produce a 90% coverage # interval (95% - 5% = 90%). # -# The model trained with alpha=0.5 produces a regression of the median: on +# The model trained with `alpha=0.5` produces a regression of the median: on # average, there should be the same number of target observations above and # below the predicted values. from sklearn.ensemble import GradientBoostingRegressor @@ -90,7 +90,7 @@ def f(x): # %% # Create an evenly spaced evaluation set of input values spanning the [0, 10] # range. -xx = np.atleast_2d(np.linspace(0, 10, 1000)).T +x_plot = np.atleast_2d(np.linspace(0, 10, 1000)).T # %% # Plot the true conditional mean function f, the predictions of the conditional @@ -98,18 +98,18 @@ def f(x): # 90% interval (from 5th to 95th conditional percentiles). import matplotlib.pyplot as plt -y_pred = all_models["mse"].predict(xx) -y_lower = all_models["q 0.05"].predict(xx) -y_upper = all_models["q 0.95"].predict(xx) -y_med = all_models["q 0.50"].predict(xx) +y_pred = all_models["mse"].predict(x_plot) +y_lower = all_models["q 0.05"].predict(x_plot) +y_upper = all_models["q 0.95"].predict(x_plot) +y_med = all_models["q 0.50"].predict(x_plot) fig = plt.figure(figsize=(10, 10)) -plt.plot(xx, f(xx), "black", linewidth=3, label=r"$f(x) = x\,\sin(x)$") +plt.plot(x_plot, f(x_plot), "black", linewidth=3, label=r"$f(x) = x\,\sin(x)$") plt.plot(X_test, y_test, "b.", markersize=10, label="Test observations") -plt.plot(xx, y_med, "tab:orange", linewidth=3, label="Predicted median") -plt.plot(xx, y_pred, "tab:green", linewidth=3, label="Predicted mean") +plt.plot(x_plot, y_med, "tab:orange", linewidth=3, label="Predicted median") +plt.plot(x_plot, y_pred, "tab:green", linewidth=3, label="Predicted mean") plt.fill_between( - xx.ravel(), y_lower, y_upper, alpha=0.4, label="Predicted 90% interval" + x_plot.ravel(), y_lower, y_upper, alpha=0.4, label="Predicted 90% interval" ) plt.xlabel("$x$") plt.ylabel("$f(x)$") @@ -193,39 +193,6 @@ def highlight_min(x): # (underestimation for this asymmetric noise) but is also naturally robust to # outliers and overfits less. # -# .. _calibration-section: -# -# Calibration of the confidence interval -# -------------------------------------- -# -# We can also evaluate the ability of the two extreme quantile estimators at -# producing a well-calibrated conditional 90%-confidence interval. -# -# To do this we can compute the fraction of observations that fall between the -# predictions: -def coverage_fraction(y, y_low, y_high): - return np.mean(np.logical_and(y >= y_low, y <= y_high)) - - -coverage_fraction( - y_train, - all_models["q 0.05"].predict(X_train), - all_models["q 0.95"].predict(X_train), -) - -# %% -# On the training set the calibration is very close to the expected coverage -# value for a 90% confidence interval. -coverage_fraction( - y_test, all_models["q 0.05"].predict(X_test), all_models["q 0.95"].predict(X_test) -) - - -# %% -# On the test set, the estimated confidence interval is slightly too narrow. -# Note, however, that we would need to wrap those metrics in a cross-validation -# loop to assess their variability under data resampling. -# # Tuning the hyper-parameters of the quantile regressors # ------------------------------------------------------ # @@ -238,7 +205,7 @@ def coverage_fraction(y, y_low, y_high): # # To confirm this hypothesis, we tune the hyper-parameters of a new regressor # of the 5th percentile by selecting the best model parameters by -# cross-validation on the pinball loss with alpha=0.05: +# cross-validation on the pinball loss with `alpha=0.05`: # %% from pprint import pprint @@ -253,13 +220,12 @@ def coverage_fraction(y, y_low, y_high): min_samples_leaf=[1, 5, 10, 20], min_samples_split=[5, 10, 20, 30, 50], ) -alpha = 0.05 neg_mean_pinball_loss_05p_scorer = make_scorer( mean_pinball_loss, - alpha=alpha, + alpha=0.05, greater_is_better=False, # maximize the negative loss ) -gbr = GradientBoostingRegressor(loss="quantile", alpha=alpha, random_state=0) +gbr = GradientBoostingRegressor(loss="quantile", alpha=0.05, random_state=0) search_05p = HalvingRandomSearchCV( gbr, param_grid, @@ -279,18 +245,17 @@ def coverage_fraction(y, y_low, y_high): # # Let's now tune the hyper-parameters for the 95th percentile regressor. We # need to redefine the `scoring` metric used to select the best model, along -# with adjusting the alpha parameter of the inner gradient boosting estimator +# with adjusting the `alpha` parameter of the inner gradient boosting estimator # itself: from sklearn.base import clone -alpha = 0.95 neg_mean_pinball_loss_95p_scorer = make_scorer( mean_pinball_loss, - alpha=alpha, + alpha=0.95, greater_is_better=False, # maximize the negative loss ) search_95p = clone(search_05p).set_params( - estimator__alpha=alpha, + estimator__alpha=0.95, scoring=neg_mean_pinball_loss_95p_scorer, ) search_95p.fit(X_train, y_train) @@ -301,18 +266,22 @@ def coverage_fraction(y, y_low, y_high): # identified by the search procedure are roughly in the same range as the hand-tuned # hyper-parameters for the median regressor and the hyper-parameters # identified by the search procedure for the 5th percentile regressor. However, -# the hyper-parameter searches did lead to an improved 90% confidence interval +# the hyper-parameter searches did lead to an improved 90% coverage interval # that is comprised by the predictions of those two tuned quantile regressors. # Note that the prediction of the upper 95th percentile has a much coarser shape # than the prediction of the lower 5th percentile because of the outliers: -y_lower = search_05p.predict(xx) -y_upper = search_95p.predict(xx) +y_lower_plot = search_05p.predict(x_plot) +y_upper_plot = search_95p.predict(x_plot) fig = plt.figure(figsize=(10, 10)) -plt.plot(xx, f(xx), "black", linewidth=3, label=r"$f(x) = x\,\sin(x)$") +plt.plot(x_plot, f(x_plot), "black", linewidth=3, label=r"$f(x) = x\,\sin(x)$") plt.plot(X_test, y_test, "b.", markersize=10, label="Test observations") plt.fill_between( - xx.ravel(), y_lower, y_upper, alpha=0.4, label="Predicted 90% interval" + x_plot.ravel(), + y_lower_plot, + y_upper_plot, + alpha=0.4, + label="Predicted 90% interval", ) plt.xlabel("$x$") plt.ylabel("$f(x)$") @@ -325,14 +294,82 @@ def coverage_fraction(y, y_low, y_high): # The plot looks qualitatively better than for the untuned models, especially # for the shape of the of lower quantile. # -# We now quantitatively evaluate the joint-calibration of the pair of -# estimators: +# .. _calibration-section: +# +# Calibration of the confidence interval +# -------------------------------------- +# +# We can also evaluate the ability of the two extreme quantile estimators at +# producing well-calibrated predictions of the 90%-coverage interval +# (conditional on `X`), meaning that on average 90% of the observations should +# lie within this interval. +# +# To do this we can compute the coverage fraction, i.e. the proportion of +# observations that fall within the prediction intervals: + + +def coverage_fraction(y, y_low, y_high): + return np.mean(np.logical_and(y >= y_low, y <= y_high)) + + coverage_fraction(y_train, search_05p.predict(X_train), search_95p.predict(X_train)) + # %% +# On the training set the calibration is very close to the expected coverage +# value of 90%. coverage_fraction(y_test, search_05p.predict(X_test), search_95p.predict(X_test)) + # %% -# The calibration of the tuned pair is sadly not better on the test set: the -# width of the estimated confidence interval is still too narrow. -# -# Again, we would need to wrap this study in a cross-validation loop to -# better assess the variability of those estimates. +# On the test set, the estimated interval is too narrow to cover 90% of the test +# points, but it may still hit the right coverage within reasonable statistical +# uncertainty. We can use :func:`scipy.stats.bootstrap` to measure the +# variability of the coverage fraction at prediction time, without retraining +# the models. We use a 95% confidence level for the estimated (bootstrapped) +# interval of coverage; this is not to be confused with the 90% coverage +# stemming from our 5% and 95% quantile predictions: + +from scipy.stats import bootstrap + +train_coverage_bs = bootstrap( + ( + y_train, + search_05p.predict(X_train), + search_95p.predict(X_train), + ), + coverage_fraction, + paired=True, + confidence_level=0.95, + n_resamples=1000, +) +ci = train_coverage_bs.confidence_interval +print( + f"Training-set coverage lies between {ci.low:.1%} and {ci.high:.1%}, " + f"based on a 95% bootstrap confidence interval." +) + +# %% +# Notice that the interval contains the target value of 90% coverage. + +# %% +test_coverage_bs = bootstrap( + ( + y_test, + search_05p.predict(X_test), + search_95p.predict(X_test), + ), + coverage_fraction, + paired=True, + confidence_level=0.95, + n_resamples=1000, +) +ci = test_coverage_bs.confidence_interval +print( + f"Test-set coverage lies between {ci.low:.1%} and {ci.high:.1%}, " + f"based on a 95% bootstrap confidence interval." +) + + +# %% +# The quantile estimates from the tuned models are sadly not well-calibrated on +# the test set: the width of the estimated confidence interval is too narrow +# even when taking it's variations into account. diff --git a/examples/ensemble/plot_hgbt_regression.py b/examples/ensemble/plot_hgbt_regression.py index dce97a6e0b700..e23c739e395cf 100644 --- a/examples/ensemble/plot_hgbt_regression.py +++ b/examples/ensemble/plot_hgbt_regression.py @@ -309,7 +309,7 @@ def generate_missing_values(X, missing_fraction): _ = ax.legend(loc="lower right") # %% -# We observe a tendence to over-estimate the energy transfer. This could be be +# We observe a tendency to over-estimate the energy transfer. This could be be # quantitatively confirmed by computing empirical coverage numbers as done in # the :ref:`calibration of confidence intervals section <calibration-section>`. # Keep in mind that those predicted percentiles are just estimations from a @@ -326,7 +326,7 @@ def generate_missing_values(X, missing_fraction): # # Given specific domain knowledge that requires the relationship between a # feature and the target to be monotonically increasing or decreasing, one can -# enforce such behaviour in the predictions of a HGBT model using monotonic +# enforce such behaviour in the predictions of an HGBT model using monotonic # constraints. This makes the model more interpretable and can reduce its # variance (and potentially mitigate overfitting) at the risk of increasing # bias. Monotonic constraints can also be used to enforce specific regulatory diff --git a/examples/ensemble/plot_stack_predictors.py b/examples/ensemble/plot_stack_predictors.py index 78d1aab5dcc09..7922e2a794682 100644 --- a/examples/ensemble/plot_stack_predictors.py +++ b/examples/ensemble/plot_stack_predictors.py @@ -5,14 +5,18 @@ .. currentmodule:: sklearn -Stacking refers to a method to blend estimators. In this strategy, some -estimators are individually fitted on some training data while a final -estimator is trained using the stacked predictions of these base estimators. +Stacking is an :ref:`ensemble method <ensemble>`. In this strategy, the +out-of-fold predictions from several base estimators are used to train a +meta-model that combines their outputs at inference time. Unlike +:class:`~sklearn.ensemble.VotingRegressor`, which averages predictions with +fixed (optionally user-specified) weights, +:class:`~sklearn.ensemble.StackingRegressor` learns the combination through its +`final_estimator`. In this example, we illustrate the use case in which different regressors are -stacked together and a final linear penalized regressor is used to output the +stacked together and a final regularized linear regressor is used to output the prediction. We compare the performance of each individual regressor with the -stacking strategy. Stacking slightly improves the overall performance. +stacking strategy. Here, stacking slightly improves the overall performance. """ @@ -20,175 +24,73 @@ # SPDX-License-Identifier: BSD-3-Clause # %% -# Download the dataset -# #################### +# Generate data +# ############# # -# We will use the `Ames Housing`_ dataset which was first compiled by Dean De Cock -# and became better known after it was used in Kaggle challenge. It is a set -# of 1460 residential homes in Ames, Iowa, each described by 80 features. We -# will use it to predict the final logarithmic price of the houses. In this -# example we will use only 20 most interesting features chosen using -# GradientBoostingRegressor() and limit number of entries (here we won't go -# into the details on how to select the most interesting features). -# -# The Ames housing dataset is not shipped with scikit-learn and therefore we -# will fetch it from `OpenML`_. -# -# .. _`Ames Housing`: http://jse.amstat.org/v19n3/decock.pdf -# .. _`OpenML`: https://www.openml.org/d/42165 +# We use synthetic data generated from a sinusoid plus a linear trend with +# heteroscedastic Gaussian noise. A sudden drop is introduced, as it cannot be +# described by a linear model, but a tree-based model can naturally deal with +# it. import numpy as np +import pandas as pd -from sklearn.datasets import fetch_openml -from sklearn.utils import shuffle - - -def load_ames_housing(): - df = fetch_openml(name="house_prices", as_frame=True) - X = df.data - y = df.target - - features = [ - "YrSold", - "HeatingQC", - "Street", - "YearRemodAdd", - "Heating", - "MasVnrType", - "BsmtUnfSF", - "Foundation", - "MasVnrArea", - "MSSubClass", - "ExterQual", - "Condition2", - "GarageCars", - "GarageType", - "OverallQual", - "TotalBsmtSF", - "BsmtFinSF1", - "HouseStyle", - "MiscFeature", - "MoSold", - ] - - X = X.loc[:, features] - X, y = shuffle(X, y, random_state=0) - - X = X.iloc[:600] - y = y.iloc[:600] - return X, np.log(y) - - -X, y = load_ames_housing() - -# %% -# Make pipeline to preprocess the data -# #################################### -# -# Before we can use Ames dataset we still need to do some preprocessing. -# First, we will select the categorical and numerical columns of the dataset to -# construct the first step of the pipeline. - -from sklearn.compose import make_column_selector - -cat_selector = make_column_selector(dtype_include=[object, "string"]) -num_selector = make_column_selector(dtype_include=np.number) -cat_selector(X) +rng = np.random.RandomState(42) +X = rng.uniform(-3, 3, size=500) +trend = 2.4 * X +seasonal = 3.1 * np.sin(3.2 * X) +drop = 10.0 * (X > 2).astype(float) +sigma = 0.75 + 0.75 * X**2 +y = trend + seasonal - drop + rng.normal(loc=0.0, scale=np.sqrt(sigma)) -# %% -num_selector(X) - -# %% -# Then, we will need to design preprocessing pipelines which depends on the -# ending regressor. If the ending regressor is a linear model, one needs to -# one-hot encode the categories. If the ending regressor is a tree-based model -# an ordinal encoder will be sufficient. Besides, numerical values need to be -# standardized for a linear model while the raw numerical data can be treated -# as is by a tree-based model. However, both models need an imputer to -# handle missing values. -# -# We will first design the pipeline required for the tree-based models. - -from sklearn.compose import make_column_transformer -from sklearn.impute import SimpleImputer -from sklearn.pipeline import make_pipeline -from sklearn.preprocessing import OrdinalEncoder - -cat_tree_processor = OrdinalEncoder( - handle_unknown="use_encoded_value", - unknown_value=-1, - encoded_missing_value=-2, -) -num_tree_processor = SimpleImputer(strategy="mean", add_indicator=True) - -tree_preprocessor = make_column_transformer( - (num_tree_processor, num_selector), (cat_tree_processor, cat_selector) -) -tree_preprocessor - -# %% -# Then, we will now define the preprocessor used when the ending regressor -# is a linear model. - -from sklearn.preprocessing import OneHotEncoder, StandardScaler - -cat_linear_processor = OneHotEncoder(handle_unknown="ignore") -num_linear_processor = make_pipeline( - StandardScaler(), SimpleImputer(strategy="mean", add_indicator=True) -) - -linear_preprocessor = make_column_transformer( - (num_linear_processor, num_selector), (cat_linear_processor, cat_selector) -) -linear_preprocessor +df = pd.DataFrame({"X": X, "y": y}) +_ = df.plot.scatter(x="X", y="y") # %% # Stack of predictors on a single data set # ######################################## # -# It is sometimes tedious to find the model which will best perform on a given -# dataset. Stacking provide an alternative by combining the outputs of several -# learners, without the need to choose a model specifically. The performance of -# stacking is usually close to the best model and sometimes it can outperform -# the prediction performance of each individual model. +# It is sometimes not evident which model is more suited for a given task, as +# different model families can achieve similar performance while exhibiting +# different strengths and weaknesses. Stacking combines their outputs to exploit +# these complementary behaviors and can correct systematic errors that no single +# model can fix on its own. With appropriate regularization in the +# `final_estimator`, the :class:`~sklearn.ensemble.StackingRegressor` often +# matches the strongest base model, and can outperform it when base learners' +# errors are only partially correlated, allowing the combination to reduce +# individual bias/variance. # -# Here, we combine 3 learners (linear and non-linear) and use a ridge regressor -# to combine their outputs together. +# Here, we combine 3 learners (linear and non-linear) and use the default +# :class:`~sklearn.linear_model.RidgeCV` regressor to combine their outputs +# together. # # .. note:: -# Although we will make new pipelines with the processors which we wrote in -# the previous section for the 3 learners, the final estimator -# :class:`~sklearn.linear_model.RidgeCV()` does not need preprocessing of -# the data as it will be fed with the already preprocessed output from the 3 -# learners. - -from sklearn.linear_model import LassoCV - -lasso_pipeline = make_pipeline(linear_preprocessor, LassoCV()) -lasso_pipeline - -# %% -from sklearn.ensemble import RandomForestRegressor - -rf_pipeline = make_pipeline(tree_preprocessor, RandomForestRegressor(random_state=42)) -rf_pipeline +# Although some base learners include preprocessing (such as the +# :class:`~sklearn.preprocessing.StandardScaler`), the `final_estimator` does +# not need additional preprocessing when using the default +# `passthrough=False`, as it receives only the base learners' predictions. If +# `passthrough=True`, `final_estimator` should be a pipeline with proper +# preprocessing. + +from sklearn.ensemble import HistGradientBoostingRegressor, StackingRegressor +from sklearn.linear_model import RidgeCV +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import PolynomialFeatures, SplineTransformer, StandardScaler -# %% -from sklearn.ensemble import HistGradientBoostingRegressor +linear_ridge = make_pipeline(StandardScaler(), RidgeCV()) -gbdt_pipeline = make_pipeline( - tree_preprocessor, HistGradientBoostingRegressor(random_state=0) +spline_ridge = make_pipeline( + SplineTransformer(n_knots=6, degree=3), + PolynomialFeatures(interaction_only=True), + RidgeCV(), ) -gbdt_pipeline -# %% -from sklearn.ensemble import StackingRegressor -from sklearn.linear_model import RidgeCV +hgbt = HistGradientBoostingRegressor(random_state=0) estimators = [ - ("Random Forest", rf_pipeline), - ("Lasso", lasso_pipeline), - ("Gradient Boosting", gbdt_pipeline), + ("Linear Ridge", linear_ridge), + ("Spline Ridge", spline_ridge), + ("HGBT", hgbt), ] stacking_regressor = StackingRegressor(estimators=estimators, final_estimator=RidgeCV()) @@ -198,14 +100,54 @@ def load_ames_housing(): # Measure and plot the results # ############################ # -# Now we can use Ames Housing dataset to make the predictions. We check the -# performance of each individual predictor as well as of the stack of the -# regressors. +# We can directly plot the predictions. Indeed, the sudden drop is correctly +# described by the :class:`~sklearn.ensemble.HistGradientBoostingRegressor` +# model (HGBT), but the spline model is smoother and less overfitting. The stacked +# regressor then turns to be a smoother version of the HGBT. +import matplotlib.pyplot as plt -import time +X = X.reshape(-1, 1) +linear_ridge.fit(X, y) +spline_ridge.fit(X, y) +hgbt.fit(X, y) +stacking_regressor.fit(X, y) + +x_plot = np.linspace(X.min() - 0.1, X.max() + 0.1, 500).reshape(-1, 1) +preds = { + "Linear Ridge": linear_ridge.predict(x_plot), + "Spline Ridge": spline_ridge.predict(x_plot), + "HGBT": hgbt.predict(x_plot), + "Stacking (Ridge final estimator)": stacking_regressor.predict(x_plot), +} + +fig, axes = plt.subplots(2, 2, figsize=(10, 8), sharex=True, sharey=True) +axes = axes.ravel() +for ax, (name, y_pred) in zip(axes, preds.items()): + ax.scatter( + X[:, 0], + y, + s=6, + alpha=0.35, + linewidths=0, + label="observed (sample)", + ) -import matplotlib.pyplot as plt + ax.plot(x_plot.ravel(), y_pred, linewidth=2, alpha=0.9, label=name) + ax.set_title(name) + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.legend(loc="lower right") + +plt.suptitle("Base Models Predictions versus Stacked Predictions", y=1) +plt.tight_layout() +plt.show() + +# %% +# We can plot the prediction errors as well and evaluate the performance of the +# individual predictors and the stack of the regressors. + +import time from sklearn.metrics import PredictionErrorDisplay from sklearn.model_selection import cross_val_predict, cross_validate @@ -216,18 +158,17 @@ def load_ames_housing(): for ax, (name, est) in zip( axs, estimators + [("Stacking Regressor", stacking_regressor)] ): - scorers = {"R2": "r2", "MAE": "neg_mean_absolute_error"} + scorers = {r"$R^2$": "r2", "MAE": "neg_mean_absolute_error"} start_time = time.time() - scores = cross_validate( - est, X, y, scoring=list(scorers.values()), n_jobs=-1, verbose=0 - ) + scores = cross_validate(est, X, y, scoring=list(scorers.values()), n_jobs=-1) elapsed_time = time.time() - start_time - y_pred = cross_val_predict(est, X, y, n_jobs=-1, verbose=0) + y_pred = cross_val_predict(est, X, y, n_jobs=-1) scores = { key: ( - f"{np.abs(np.mean(scores[f'test_{value}'])):.2f} +- " + f"{np.abs(np.mean(scores[f'test_{value}'])):.2f}" + r" $\pm$ " f"{np.std(scores[f'test_{value}']):.2f}" ) for key, value in scorers.items() @@ -247,12 +188,99 @@ def load_ames_housing(): ax.plot([], [], " ", label=f"{name}: {score}") ax.legend(loc="upper left") -plt.suptitle("Single predictors versus stacked predictors") +plt.suptitle("Prediction Errors of Base versus Stacked Predictors", y=1) plt.tight_layout() plt.subplots_adjust(top=0.9) plt.show() # %% -# The stacked regressor will combine the strengths of the different regressors. -# However, we also see that training the stacked regressor is much more -# computationally expensive. +# Even if the scores overlap considerably after cross-validation, the predictions +# from the stacked regressor are slightly better. +# +# Once fitted, we can inspect the coefficients (or meta-weights) of the trained +# `final_estimator_` (as long as it is a linear model). They reveal how much the +# individual estimators contribute to the the stacked regressor: + +stacking_regressor.fit(X, y) +stacking_regressor.final_estimator_.coef_ + +# %% +# We see that in this case, the HGBT model dominates, with the spline +# ridge also contributing meaningfully. The plain linear model does not add +# useful signal once those two are included; with +# :class:`~sklearn.linear_model.RidgeCV` as the `final_estimator`, it is not +# dropped, but receives a small negative weight to correct its residual bias. +# +# If we use :class:`~sklearn.linear_model.LassoCV` as the +# `final_estimator`, that small, unhelpful contribution is set exactly to zero, +# yielding a simpler blend of the spline ridge and HGBT models. + +from sklearn.linear_model import LassoCV + +stacking_regressor = StackingRegressor(estimators=estimators, final_estimator=LassoCV()) +stacking_regressor.fit(X, y) +stacking_regressor.final_estimator_.coef_ + +# %% +# How to mimic SuperLearner with scikit-learn +# ########################################### +# +# The `SuperLearner` [Polley2010]_ is a stacking strategy implemented as `an R +# package <https://cran.r-project.org/web/packages/SuperLearner/index.html>`_, but +# not available off-the-shelf in Python. It is closely related to the +# :class:`~sklearn.ensemble.StackingRegressor`, as both train the meta-model on +# out-of-fold predictions from the base estimators. +# +# The key difference is that `SuperLearner` estimates a convex set of +# meta-weights (non-negative and summing to 1) and omits an intercept; by +# contrast, :class:`~sklearn.ensemble.StackingRegressor` uses an unconstrained +# meta-learner with an intercept by default (and can optionally include raw +# features via passthrough). +# +# Without an intercept, the meta-weights are directly interpretable as +# fractional contributions to the final prediction. + +from sklearn.linear_model import LinearRegression + +linear_reg = LinearRegression(fit_intercept=False, positive=True) +super_learner_like = StackingRegressor( + estimators=estimators, final_estimator=linear_reg +) +super_learner_like.fit(X, y) +super_learner_like.final_estimator_.coef_ + +# %% +# The sum of meta-weights in the stacked regressor is close to 1.0, but not +# exactly one: + +super_learner_like.final_estimator_.coef_.sum() + +# %% +# Beyond interpretability, the normalization to 1.0 constraint in the `SuperLearner` +# presents the following advantages: +# +# - Consensus-preserving: if all base models output the same value at a point, +# the ensemble returns that same value (no artificial amplification or +# attenuation). +# - Translation-equivariant: adding a constant to every base prediction shifts +# the ensemble by the same constant. +# - Removes one degree of freedom: avoiding redundancy with a constant term and +# modestly stabilizing weights under collinearity. +# +# The cleanest way to enforce the coefficient normalization with scikit-learn is +# by defining a custom estimator, but doing so is beyond the scope of this +# tutorial. +# +# Conclusions +# ########### +# +# The stacked regressor combines the strengths of the different regressors. +# However, notice that training the stacked regressor is much more +# computationally expensive than selecting the best performing model. +# +# .. rubric:: References +# +# .. [Polley2010] Polley, E. C. and van der Laan, M. J., `Super Learner In +# Prediction +# <https://biostats.bepress.com/cgi/viewcontent.cgi?article=1269&context=ucbbiostat>`_, +# 2010. diff --git a/examples/gaussian_process/plot_gpr_on_structured_data.py b/examples/gaussian_process/plot_gpr_on_structured_data.py index f3a8de5d018ef..dfe39964100f1 100644 --- a/examples/gaussian_process/plot_gpr_on_structured_data.py +++ b/examples/gaussian_process/plot_gpr_on_structured_data.py @@ -5,7 +5,7 @@ This example illustrates the use of Gaussian processes for regression and classification tasks on data that are not in fixed-length feature vector form. -This is achieved through the use of kernel functions that operates directly +This is achieved through the use of kernel functions that operate directly on discrete structures such as variable-length sequences, trees, and graphs. Specifically, here the input variables are some gene sequences stored as @@ -54,7 +54,7 @@ class SequenceKernel(GenericKernelMixin, Kernel): A minimal (but valid) convolutional kernel for sequences of variable lengths.""" - def __init__(self, baseline_similarity=0.5, baseline_similarity_bounds=(1e-5, 1)): + def __init__(self, baseline_similarity=0.5, baseline_similarity_bounds="fixed"): self.baseline_similarity = baseline_similarity self.baseline_similarity_bounds = baseline_similarity_bounds @@ -102,6 +102,12 @@ def clone_with_theta(self, theta): return cloned +# %% +# .. note:: +# Here, we freeze the value of ``baseline_similarity`` by setting +# `baseline_similarity_bounds="fixed"` as LBFGS would otherwise fail to +# optimize the value of this kernel parameter for some unknown reason. + kernel = SequenceKernel() # %% diff --git a/examples/miscellaneous/plot_partial_dependence_visualization_api.py b/examples/inspection/plot_partial_dependence_visualization_api.py similarity index 100% rename from examples/miscellaneous/plot_partial_dependence_visualization_api.py rename to examples/inspection/plot_partial_dependence_visualization_api.py diff --git a/examples/linear_model/plot_lasso_and_elasticnet.py b/examples/linear_model/plot_lasso_and_elasticnet.py index 235a65fe731ea..cdfded2c2ae1a 100644 --- a/examples/linear_model/plot_lasso_and_elasticnet.py +++ b/examples/linear_model/plot_lasso_and_elasticnet.py @@ -153,7 +153,7 @@ # # :class:`~sklearn.linear_model.ElasticNet` is a middle ground between # :class:`~sklearn.linear_model.Lasso` and :class:`~sklearn.linear_model.Ridge`, -# as it combines a L1 and a L2-penalty. The amount of regularization is +# as it combines an L1 and an L2-penalty. The amount of regularization is # controlled by the two hyperparameters `l1_ratio` and `alpha`. For `l1_ratio = # 0` the penalty is pure L2 and the model is equivalent to a # :class:`~sklearn.linear_model.Ridge`. Similarly, `l1_ratio = 1` is a pure L1 diff --git a/examples/linear_model/plot_lasso_dense_vs_sparse_data.py b/examples/linear_model/plot_lasso_dense_vs_sparse_data.py index 920994da1ffb5..e0763448bd59e 100644 --- a/examples/linear_model/plot_lasso_dense_vs_sparse_data.py +++ b/examples/linear_model/plot_lasso_dense_vs_sparse_data.py @@ -32,7 +32,7 @@ X, y = make_regression(n_samples=200, n_features=5000, random_state=0) # create a copy of X in sparse format -X_sp = sparse.coo_matrix(X) +X_sp = sparse.coo_array(X) alpha = 1 sparse_lasso = Lasso(alpha=alpha, fit_intercept=False, max_iter=1000) @@ -64,7 +64,7 @@ # make Xs sparse by replacing the values lower than 2.5 with 0s Xs[Xs < 2.5] = 0.0 # create a copy of Xs in sparse format -Xs_sp = sparse.coo_matrix(Xs) +Xs_sp = sparse.coo_array(Xs) Xs_sp = Xs_sp.tocsc() # compute the proportion of non-zero coefficient in the data matrix diff --git a/examples/linear_model/plot_logistic_multinomial.py b/examples/linear_model/plot_logistic_multinomial.py index c12229c81c7f1..1ea433fb6c107 100644 --- a/examples/linear_model/plot_logistic_multinomial.py +++ b/examples/linear_model/plot_logistic_multinomial.py @@ -18,10 +18,10 @@ # Dataset Generation # ------------------ # -# We generate a synthetic dataset using :func:`~sklearn.datasets.make_blobs` function. -# The dataset consists of 1,000 samples from three different classes, +# We generate a synthetic dataset using the :func:`~sklearn.datasets.make_blobs` +# function. The dataset consists of 1,000 samples from three different classes, # centered around [-5, 0], [0, 1.5], and [5, -1]. After generation, we apply a linear -# transformation to introduce some correlation between features and make the problem +# transformation to introduce some correlation between the features to make the problem # more challenging. This results in a 2D dataset with three overlapping classes, # suitable for demonstrating the differences between multinomial and one-vs-rest # logistic regression. @@ -30,6 +30,8 @@ from sklearn.datasets import make_blobs +cmap = "coolwarm" + centers = [[-5, 0], [0, 1.5], [5, -1]] X, y = make_blobs(n_samples=1_000, centers=centers, random_state=40) transformation = [[0.4, 0.2], [-0.4, 1.2]] @@ -37,7 +39,7 @@ fig, ax = plt.subplots(figsize=(6, 4)) -scatter = ax.scatter(X[:, 0], X[:, 1], c=y, edgecolor="black") +scatter = ax.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap, edgecolor="black") ax.set(title="Synthetic Dataset", xlabel="Feature 1", ylabel="Feature 2") _ = ax.legend(*scatter.legend_elements(), title="Classes") @@ -86,8 +88,9 @@ ax=ax, response_method="predict", alpha=0.8, + multiclass_colors=cmap, ) - scatter = ax.scatter(X[:, 0], X[:, 1], c=y, edgecolor="k") + scatter = ax.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap, edgecolor="k") legend = ax.legend(*scatter.legend_elements(), title="Classes") ax.add_artist(legend) ax.set_title(title) @@ -108,8 +111,8 @@ # -------------------------- # # We also visualize the hyperplanes that correspond to the line when the probability -# estimate for a class is of 0.5. -def plot_hyperplanes(classifier, X, ax): +# estimate for a class is 0.5. +def plot_hyperplanes(classifier, X, ax, colors): xmin, xmax = X[:, 0].min(), X[:, 0].max() ymin, ymax = X[:, 1].min(), X[:, 1].max() ax.set(xlim=(xmin, xmax), ylim=(ymin, ymax)) @@ -121,12 +124,12 @@ def plot_hyperplanes(classifier, X, ax): coef = classifier.coef_ intercept = classifier.intercept_ - for i in range(coef.shape[0]): + for i, color in zip(range(coef.shape[0]), colors): w = coef[i] a = -w[0] / w[1] xx = np.linspace(xmin, xmax) yy = a * xx - (intercept[i]) / w[1] - ax.plot(xx, yy, "--", linewidth=3, label=f"Class {i}") + ax.plot(xx, yy, "--", color=color, linewidth=4, label=f"Class {i}") return ax.get_legend_handles_labels() @@ -142,8 +145,10 @@ def plot_hyperplanes(classifier, X, ax): ), (logistic_regression_ovr, "One-vs-Rest Logistic Regression Hyperplanes", ax2), ]: - hyperplane_handles, hyperplane_labels = plot_hyperplanes(model, X, ax) - scatter = ax.scatter(X[:, 0], X[:, 1], c=y, edgecolor="k") + hyperplane_handles, hyperplane_labels = plot_hyperplanes( + model, X, ax, colors=["blue", "dimgray", "red"] + ) + scatter = ax.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap, edgecolor="k") scatter_handles, scatter_labels = scatter.legend_elements() all_handles = hyperplane_handles + scatter_handles diff --git a/examples/linear_model/plot_sgd_iris.py b/examples/linear_model/plot_sgd_iris.py index e8aaf3a2e13a2..3e8b51e056a11 100644 --- a/examples/linear_model/plot_sgd_iris.py +++ b/examples/linear_model/plot_sgd_iris.py @@ -26,7 +26,7 @@ # avoid this ugly slicing by using a two-dim dataset X = iris.data[:, :2] y = iris.target -colors = "bry" +colors = "byr" # shuffle idx = np.arange(X.shape[0]) @@ -45,7 +45,6 @@ DecisionBoundaryDisplay.from_estimator( clf, X, - cmap=plt.cm.Paired, ax=ax, response_method="predict", xlabel=iris.feature_names[0], diff --git a/examples/miscellaneous/plot_display_object_visualization.py b/examples/miscellaneous/plot_display_object_visualization.py index ec54d909d1c37..4d1c03b83528a 100644 --- a/examples/miscellaneous/plot_display_object_visualization.py +++ b/examples/miscellaneous/plot_display_object_visualization.py @@ -79,7 +79,7 @@ # Combining the display objects into a single plot # ################################################ # The display objects store the computed values that were passed as arguments. -# This allows for the visualizations to be easliy combined using matplotlib's +# This allows for the visualizations to be easily combined using matplotlib's # API. In the following example, we place the displays next to each other in a # row. diff --git a/examples/miscellaneous/plot_metadata_routing.py b/examples/miscellaneous/plot_metadata_routing.py index 63dddac1f9c2f..f27d8fb2ec527 100644 --- a/examples/miscellaneous/plot_metadata_routing.py +++ b/examples/miscellaneous/plot_metadata_routing.py @@ -1,22 +1,22 @@ """ -================ -Metadata Routing -================ +===================================================== +Developing Estimators Compliant with Metadata Routing +===================================================== .. currentmodule:: sklearn This document shows how you can use the :ref:`metadata routing mechanism -<metadata_routing>` in scikit-learn to route metadata to the estimators, -scorers, and CV splitters consuming them. +<metadata_routing>` in scikit-learn to build estimators that route metadata +to other estimators, scorers, and CV splitters, that can consume :term:`metadata`. To better understand the following document, we need to introduce two concepts: -routers and consumers. A router is an object which forwards some given data and -metadata to other objects. In most cases, a router is a :term:`meta-estimator`, -i.e. an estimator which takes another estimator as a parameter. A function such -as :func:`sklearn.model_selection.cross_validate` which takes an estimator as a -parameter and forwards data and metadata, is also a router. +:term:`routers <router>` and :term:`consumers <consumer>`. A :term:`router` is an object +which forwards some given data and metadata to other objects. In most cases, a router is +a :term:`meta-estimator`, i.e. an estimator which takes another estimator as a +parameter. A function such as :func:`sklearn.model_selection.cross_validate` which takes +an estimator as a parameter and forwards data and metadata, is also a router. -A consumer, on the other hand, is an object which accepts and uses some given +A :term:`consumer`, on the other hand, is an object which accepts and uses some given metadata. For instance, an estimator taking into account ``sample_weight`` in its :term:`fit` method is a consumer of ``sample_weight``. @@ -51,7 +51,6 @@ from sklearn.utils.metadata_routing import ( MetadataRouter, MethodMapping, - get_routing_for_object, process_routing, ) from sklearn.utils.validation import check_is_fitted @@ -92,7 +91,7 @@ def print_routing(obj): # ------------------- # Here we demonstrate how an estimator can expose the required API to support # metadata routing as a consumer. Imagine a simple classifier accepting -# ``sample_weight`` as a metadata on its ``fit`` and ``groups`` in its +# ``sample_weight`` as a metadata in its ``fit`` and ``groups`` in its # ``predict`` method: @@ -146,10 +145,21 @@ def predict(self, X, groups=None): # metadata and the set values are ignored, since a consumer does not # validate or route given metadata. A simple usage of the above estimator # would work as expected. - -est = ExampleClassifier() -est.fit(X, y, sample_weight=my_weights) -est.predict(X[:3, :], groups=my_groups) +# +# .. code-block:: python +# +# est = ExampleClassifier() +# est.fit(X, y, sample_weight=my_weights) +# est.predict(X[:3, :], groups=my_groups) +# +# Out: +# +# .. code-block:: python-console +# +# Received sample_weight of length = 100 in ExampleClassifier. +# Received groups of length = 100 in ExampleClassifier. +# +# array([1., 1., 1.]) # %% # Routing Meta-Estimator @@ -157,6 +167,13 @@ def predict(self, X, groups=None): # Now, we show how to design a meta-estimator to be a router. As a simplified # example, here is a meta-estimator, which doesn't do much other than routing # the metadata. +# +# To make the meta-estimator a router, you only need to: +# +# - define its `get_metadata_routing` method, which returns a `MetadataRouter` +# instance in charge of configuring the metadata routing. +# - use `process_routing` inside its methods (`fit`, `predict`, ...) to properly +# route the metadata from the meta-estimator to its sub-estimator. class MetaClassifier(MetaEstimatorMixin, ClassifierMixin, BaseEstimator): @@ -166,7 +183,7 @@ def __init__(self, estimator): def get_metadata_routing(self): # This method defines the routing for this meta-estimator. # In order to do so, a `MetadataRouter` instance is created, and the - # routing is added to it. More explanations follow below. + # routing is added to it. router = MetadataRouter(owner=self).add( estimator=self.estimator, method_mapping=MethodMapping() @@ -177,56 +194,36 @@ def get_metadata_routing(self): return router def fit(self, X, y, **fit_params): - # `get_routing_for_object` returns a copy of the `MetadataRouter` - # constructed by the above `get_metadata_routing` method, that is - # internally called. - request_router = get_routing_for_object(self) - # Meta-estimators are responsible for validating the given metadata. - # `method` refers to the parent's method, i.e. `fit` in this example. - request_router.validate_metadata(params=fit_params, method="fit") - # `MetadataRouter.route_params` maps the given metadata to the metadata - # required by the underlying estimator based on the routing information - # defined by the MetadataRouter. The output of type `Bunch` has a key - # for each consuming object and those hold keys for their consuming - # methods, which then contain key for the metadata which should be - # routed to them. - routed_params = request_router.route_params(params=fit_params, caller="fit") - + # Get information on all the metadata that should be routed from here to + # consuming methods. + routed_params = process_routing(self, "fit", **fit_params) # A sub-estimator is fitted and its classes are attributed to the - # meta-estimator. + # meta-estimator. Since we call the sub-estimator's fit method, we pass the + # the metadata stored in `routed_params.estimator.fit`. self.estimator_ = clone(self.estimator).fit(X, y, **routed_params.estimator.fit) self.classes_ = self.estimator_.classes_ return self def predict(self, X, **predict_params): check_is_fitted(self) - # As in `fit`, we get a copy of the object's MetadataRouter, - request_router = get_routing_for_object(self) - # then we validate the given metadata, - request_router.validate_metadata(params=predict_params, method="predict") - # and then prepare the input to the underlying `predict` method. - routed_params = request_router.route_params( - params=predict_params, caller="predict" - ) + # As in `fit`, we get information on all the metadata that should be routed and + # pass the metadata that is stored in `routed_params.estimator.predict` to the + # sub-estimator's predict method. + routed_params = process_routing(self, "predict", **predict_params) return self.estimator_.predict(X, **routed_params.estimator.predict) # %% # Let's break down different parts of the above code. # -# First, the :meth:`~utils.metadata_routing.get_routing_for_object` takes our -# meta-estimator (``self``) and returns a -# :class:`~utils.metadata_routing.MetadataRouter` or, a -# :class:`~utils.metadata_routing.MetadataRequest` if the object is a consumer, -# based on the output of the estimator's ``get_metadata_routing`` method. -# -# Then in each method, we use the ``route_params`` method to construct a -# dictionary of the form ``{"object_name": {"method_name": {"metadata": +# In each method, we use the ``process_routing`` function to construct a +# :class:`~utils.Bunch` of the form ``{"object_name": {"method_name": {"metadata": # value}}}`` to pass to the underlying estimator's method. The ``object_name`` -# (``estimator`` in the above ``routed_params.estimator.fit`` example) is the -# same as the one added in the ``get_metadata_routing``. ``validate_metadata`` -# makes sure all given metadata are requested to avoid silent bugs. -# +# (``estimator`` in ``routed_params.estimator.fit``) is the same as the `estimator` +# added in the ``get_metadata_routing``. ``process_routing`` also validates the input +# metadata: it makes sure all given metadata are requested to avoid silent bugs. + +# %% # Next, we illustrate the different behaviors and notably the type of errors # raised. @@ -378,24 +375,14 @@ def fit(self, X, y, sample_weight, **fit_params): # We add `sample_weight` to the `fit_params` dictionary. if sample_weight is not None: fit_params["sample_weight"] = sample_weight - - request_router = get_routing_for_object(self) - request_router.validate_metadata(params=fit_params, method="fit") - routed_params = request_router.route_params(params=fit_params, caller="fit") + routed_params = process_routing(self, "fit", **fit_params) self.estimator_ = clone(self.estimator).fit(X, y, **routed_params.estimator.fit) self.classes_ = self.estimator_.classes_ return self def predict(self, X, **predict_params): check_is_fitted(self) - # As in `fit`, we get a copy of the object's MetadataRouter, - request_router = get_routing_for_object(self) - # we validate the given metadata, - request_router.validate_metadata(params=predict_params, method="predict") - # and then prepare the input to the underlying ``predict`` method. - routed_params = request_router.route_params( - params=predict_params, caller="predict" - ) + routed_params = process_routing(self, "predict", **predict_params) return self.estimator_.predict(X, **routed_params.estimator.predict) diff --git a/examples/miscellaneous/plot_roc_curve_visualization_api.py b/examples/miscellaneous/plot_roc_curve_visualization_api.py index 1aacbd9de3631..2a9b14fdeabcf 100644 --- a/examples/miscellaneous/plot_roc_curve_visualization_api.py +++ b/examples/miscellaneous/plot_roc_curve_visualization_api.py @@ -13,8 +13,8 @@ # SPDX-License-Identifier: BSD-3-Clause # %% -# Load Data and Train a SVC -# ------------------------- +# Load Data and Train an SVC +# -------------------------- # First, we load the wine dataset and convert it to a binary classification # problem. Then, we train a support vector classifier on a training dataset. import matplotlib.pyplot as plt diff --git a/examples/model_selection/plot_confusion_matrix.py b/examples/model_selection/plot_confusion_matrix.py index 71ee654c5f5fb..bf675e7fa3035 100644 --- a/examples/model_selection/plot_confusion_matrix.py +++ b/examples/model_selection/plot_confusion_matrix.py @@ -30,7 +30,8 @@ import matplotlib.pyplot as plt import numpy as np -from sklearn import datasets, svm +from sklearn import datasets +from sklearn.linear_model import LogisticRegression from sklearn.metrics import ConfusionMatrixDisplay from sklearn.model_selection import train_test_split @@ -45,7 +46,7 @@ # Run classifier, using a model that is too regularized (C too low) to see # the impact on the results -classifier = svm.SVC(kernel="linear", C=0.01).fit(X_train, y_train) +classifier = LogisticRegression(C=0.01).fit(X_train, y_train) np.set_printoptions(precision=2) @@ -74,12 +75,12 @@ # Binary Classification # ===================== # -# For binary problems, :func:`sklearn.metrics.confusion_matrix` has the `ravel` method -# we can use get counts of true negatives, false positives, false negatives and -# true positives. +# For binary classification, use :func:`sklearn.metrics.confusion_matrix` with +# the `ravel` method to get counts of true negatives, false positives, false +# negatives, and true positives. # -# To obtain true negatives, false positives, false negatives and true -# positives counts at different thresholds, one can use +# To obtain counts of true negatives, false positives, false negatives, and true +# positives at different thresholds, one can use # :func:`sklearn.metrics.confusion_matrix_at_thresholds`. # This is fundamental for binary classification # metrics like :func:`~sklearn.metrics.roc_auc_score` and @@ -101,20 +102,20 @@ X, y, test_size=0.3, random_state=42 ) -classifier = svm.SVC(kernel="linear", C=0.01, probability=True) +classifier = LogisticRegression(C=0.01) classifier.fit(X_train, y_train) y_score = classifier.predict_proba(X_test)[:, 1] -tns, fps, fns, tps, threshold = confusion_matrix_at_thresholds(y_test, y_score) +tns, fps, fns, tps, thresholds = confusion_matrix_at_thresholds(y_test, y_score) # Plot TNs, FPs, FNs and TPs vs Thresholds plt.figure(figsize=(10, 6)) -plt.plot(threshold, tns, label="True Negatives (TNs)") -plt.plot(threshold, fps, label="False Positives (FPs)") -plt.plot(threshold, fns, label="False Negatives (FNs)") -plt.plot(threshold, tps, label="True Positives (TPs)") +plt.plot(thresholds, tns, label="True Negatives (TNs)") +plt.plot(thresholds, fps, label="False Positives (FPs)") +plt.plot(thresholds, fns, label="False Negatives (FNs)") +plt.plot(thresholds, tps, label="True Positives (TPs)") plt.xlabel("Thresholds") plt.ylabel("Count") plt.title("TNs, FPs, FNs and TPs vs Thresholds") diff --git a/examples/model_selection/plot_cost_sensitive_learning.py b/examples/model_selection/plot_cost_sensitive_learning.py index 8b5209e85e8a0..affef34d92044 100644 --- a/examples/model_selection/plot_cost_sensitive_learning.py +++ b/examples/model_selection/plot_cost_sensitive_learning.py @@ -304,10 +304,9 @@ def plot_roc_pr_curves(vanilla_model, tuned_model, *, title): X_test, y_test, pos_label=pos_label, - linestyle=linestyle, - color=color, ax=axs[0], name=name, + curve_kwargs={"linestyle": linestyle, "color": color}, ) axs[0].plot( scoring["recall"](est, X_test, y_test), @@ -322,7 +321,7 @@ def plot_roc_pr_curves(vanilla_model, tuned_model, *, title): X_test, y_test, pos_label=pos_label, - curve_kwargs=dict(linestyle=linestyle, color=color), + curve_kwargs={"linestyle": linestyle, "color": color}, ax=axs[1], name=name, plot_chance_level=idx == 1, diff --git a/examples/model_selection/plot_learning_curve.py b/examples/model_selection/plot_learning_curve.py index d8060c67cbe15..876c70c0d901e 100644 --- a/examples/model_selection/plot_learning_curve.py +++ b/examples/model_selection/plot_learning_curve.py @@ -24,8 +24,8 @@ # process. The effect is depicted by checking the statistical performance of # the model in terms of training score and testing score. # -# Here, we compute the learning curve of a naive Bayes classifier and a SVM -# classifier with a RBF kernel using the digits dataset. +# Here, we compute the learning curve of a naive Bayes classifier and an SVM +# classifier with an RBF kernel using the digits dataset. from sklearn.datasets import load_digits from sklearn.naive_bayes import GaussianNB from sklearn.svm import SVC diff --git a/examples/model_selection/plot_precision_recall.py b/examples/model_selection/plot_precision_recall.py index c7ff06d3f8fcb..e3be77d50569e 100644 --- a/examples/model_selection/plot_precision_recall.py +++ b/examples/model_selection/plot_precision_recall.py @@ -136,9 +136,18 @@ # ............................... # # To plot the precision-recall curve, you should use -# :class:`~sklearn.metrics.PrecisionRecallDisplay`. Indeed, there is two -# methods available depending if you already computed the predictions of the -# classifier or not. +# :class:`~sklearn.metrics.PrecisionRecallDisplay`. There are three +# methods available: +# +# * for plotting a single curve: +# +# * :func:`~sklearn.metrics.PrecisionRecallDisplay.from_estimator` for when you +# have not computed the predictions +# * :func:`~sklearn.metrics.PrecisionRecallDisplay.from_predictions` for when +# you already have the predictions +# +# * for plotting multiple curves using cross-validation results: +# :func:`~sklearn.metrics.PrecisionRecallDisplay.from_cv_results` # # Let's first plot the precision-recall curve without the classifier # predictions. We use @@ -162,6 +171,20 @@ ) _ = display.ax_.set_title("2-class Precision-Recall curve") +# %% +# The :func:`~sklearn.metrics.PrecisionRecallDisplay.from_cv_results` takes the +# cross-validation results from :func:`~sklearn.model_selection.cross_validate` +# and plots a precision-recall curve for each fold. + +from sklearn.model_selection import cross_validate + +classifier = make_pipeline(StandardScaler(), LinearSVC(random_state=random_state)) +cv_results = cross_validate( + classifier, X_train, y_train, return_estimator=True, return_indices=True +) +display = PrecisionRecallDisplay.from_cv_results(cv_results, X_train, y_train) +_ = display.ax_.set_title("Cross-validation Precision-Recall curves") + # %% # In multi-label settings # ----------------------- @@ -256,7 +279,9 @@ precision=precision["micro"], average_precision=average_precision["micro"], ) -display.plot(ax=ax, name="Micro-average precision-recall", color="gold") +display.plot( + ax=ax, name="Micro-average precision-recall", curve_kwargs={"color": "gold"} +) for i, color in zip(range(n_classes), colors): display = PrecisionRecallDisplay( @@ -265,7 +290,10 @@ average_precision=average_precision[i], ) display.plot( - ax=ax, name=f"Precision-recall for class {i}", color=color, despine=True + ax=ax, + name=f"Precision-recall for class {i}", + curve_kwargs={"color": color}, + despine=True, ) # add the legend for the iso-f1 curves diff --git a/examples/model_selection/plot_roc_crossval.py b/examples/model_selection/plot_roc_crossval.py index 3c5c3fc9119b7..b927346b34148 100644 --- a/examples/model_selection/plot_roc_crossval.py +++ b/examples/model_selection/plot_roc_crossval.py @@ -63,20 +63,20 @@ # ------------------------------- # # Here we run :func:`~sklearn.model_selection.cross_validate` on a -# :class:`~sklearn.svm.SVC` classifier, then use the computed cross-validation results -# to plot the ROC curves fold-wise. Notice that the baseline to define the chance -# level (dashed ROC curve) is a classifier that would always predict the most -# frequent class. +# :class:`~sklearn.linear_model.LogisticRegression` classifier, then use the computed +# cross-validation results to plot the ROC curves fold-wise. Notice that the baseline +# to define the chance level (dashed ROC curve) is a classifier that would always +# predict the most frequent class. import matplotlib.pyplot as plt -from sklearn import svm +from sklearn.linear_model import LogisticRegression from sklearn.metrics import RocCurveDisplay, auc from sklearn.model_selection import StratifiedKFold, cross_validate n_splits = 6 cv = StratifiedKFold(n_splits=n_splits) -classifier = svm.SVC(kernel="linear", probability=True, random_state=random_state) +classifier = LogisticRegression(random_state=random_state).fit(X, y) cv_results = cross_validate( classifier, X, y, cv=cv, return_estimator=True, return_indices=True ) diff --git a/examples/neighbors/approximate_nearest_neighbors.py b/examples/neighbors/approximate_nearest_neighbors.py index eaacaf25f03d6..fa54f563c0936 100644 --- a/examples/neighbors/approximate_nearest_neighbors.py +++ b/examples/neighbors/approximate_nearest_neighbors.py @@ -39,7 +39,7 @@ # `nmslib`, as well as a loading function. import joblib import numpy as np -from scipy.sparse import csr_matrix +from scipy.sparse import csr_array from sklearn.base import BaseEstimator, TransformerMixin from sklearn.datasets import fetch_openml @@ -93,7 +93,7 @@ def transform(self, X): indices, distances = np.vstack(indices), np.vstack(distances) indptr = np.arange(0, n_samples_transform * n_neighbors + 1, n_neighbors) - kneighbors_graph = csr_matrix( + kneighbors_graph = csr_array( (distances.ravel(), indices.ravel(), indptr), shape=(n_samples_transform, self.n_samples_fit_), ) diff --git a/examples/neighbors/plot_classification.py b/examples/neighbors/plot_classification.py index 1754869943ac7..82ee3f481fa99 100644 --- a/examples/neighbors/plot_classification.py +++ b/examples/neighbors/plot_classification.py @@ -53,6 +53,7 @@ # Now, we fit two classifiers with different values of the parameter # `weights`. We plot the decision boundary of each classifier as well as the original # dataset to observe the difference. +import matplotlib as mpl import matplotlib.pyplot as plt from sklearn.inspection import DecisionBoundaryDisplay @@ -72,11 +73,14 @@ alpha=0.5, ax=ax, ) - scatter = disp.ax_.scatter(X.iloc[:, 0], X.iloc[:, 1], c=y, edgecolors="k") + cmap = mpl.colors.ListedColormap(disp.multiclass_colors_) + scatter = disp.ax_.scatter( + X.iloc[:, 0], X.iloc[:, 1], c=y, cmap=cmap, edgecolors="k" + ) disp.ax_.legend( scatter.legend_elements()[0], iris.target_names, - loc="lower left", + loc="lower right", title="Classes", ) _ = disp.ax_.set_title( @@ -90,7 +94,7 @@ # ---------- # # We observe that the parameter `weights` has an impact on the decision boundary. When -# `weights="unifom"` all nearest neighbors will have the same impact on the decision. +# `weights="uniform"` all nearest neighbors will have the same impact on the decision. # Whereas when `weights="distance"` the weight given to each neighbor is proportional # to the inverse of the distance from that neighbor to the query point. # diff --git a/examples/neighbors/plot_nca_classification.py b/examples/neighbors/plot_nca_classification.py index b8d69b82fec42..b8f60d2600628 100644 --- a/examples/neighbors/plot_nca_classification.py +++ b/examples/neighbors/plot_nca_classification.py @@ -4,7 +4,7 @@ ============================================================================= An example comparing nearest neighbors classification with and without -Neighborhood Components Analysis. +:ref:`nca`. It will plot the class decision boundaries given by a Nearest Neighbors classifier when using the Euclidean distance on the original features, versus @@ -41,11 +41,6 @@ X, y, stratify=y, test_size=0.7, random_state=42 ) -h = 0.05 # step size in the mesh - -# Create color maps -cmap_light = ListedColormap(["#FFAAAA", "#AAFFAA", "#AAAAFF"]) -cmap_bold = ListedColormap(["#FF0000", "#00FF00", "#0000FF"]) names = ["KNN", "NCA, KNN"] @@ -70,11 +65,10 @@ score = clf.score(X_test, y_test) _, ax = plt.subplots() - DecisionBoundaryDisplay.from_estimator( + disp = DecisionBoundaryDisplay.from_estimator( clf, X, - cmap=cmap_light, - alpha=0.8, + alpha=0.5, ax=ax, response_method="predict", plot_method="pcolormesh", @@ -82,12 +76,13 @@ ) # Plot also the training and testing points - plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap_bold, edgecolor="k", s=20) - plt.title("{} (k = {})".format(name, n_neighbors)) + cmap = ListedColormap(disp.multiclass_colors_) + plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap, edgecolor="k", s=20) + plt.title(f"{name} (k = {n_neighbors})") plt.text( 0.9, 0.1, - "{:.2f}".format(score), + f"{score:.2f}", size=15, ha="center", va="center", diff --git a/examples/neighbors/plot_nearest_centroid.py b/examples/neighbors/plot_nearest_centroid.py index 1718e213f9252..71d45a1b03460 100644 --- a/examples/neighbors/plot_nearest_centroid.py +++ b/examples/neighbors/plot_nearest_centroid.py @@ -3,14 +3,14 @@ Nearest Centroid Classification =============================== -Sample usage of Nearest Centroid classification. +Sample usage of the :ref:`nearest_centroid_classifier` with different shrink thresholds. It will plot the decision boundaries for each class. """ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause - +# %% import matplotlib.pyplot as plt import numpy as np from matplotlib.colors import ListedColormap @@ -26,25 +26,26 @@ X = iris.data[:, :2] y = iris.target -# Create color maps -cmap_light = ListedColormap(["orange", "cyan", "cornflowerblue"]) -cmap_bold = ListedColormap(["darkorange", "c", "darkblue"]) - for shrinkage in [None, 0.2]: # we create an instance of Nearest Centroid Classifier and fit the data. clf = NearestCentroid(shrink_threshold=shrinkage) clf.fit(X, y) y_pred = clf.predict(X) - print(shrinkage, np.mean(y == y_pred)) + acc = np.mean(y == y_pred) _, ax = plt.subplots() - DecisionBoundaryDisplay.from_estimator( - clf, X, cmap=cmap_light, ax=ax, response_method="predict" + disp = DecisionBoundaryDisplay.from_estimator( + clf, X, ax=ax, response_method="predict", alpha=0.5 ) # Plot also the training points - plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap_bold, edgecolor="k", s=20) - plt.title("3-Class classification (shrink_threshold=%r)" % shrinkage) + cmap = ListedColormap(disp.multiclass_colors_) + plt.scatter(X[:, 0], X[:, 1], c=y, cmap=cmap, edgecolor="k", s=20) + plt.title( + f"3-Class classification (shrink_threshold={shrinkage})\nAccuracy: {acc:.2f}" + ) plt.axis("tight") plt.show() + +# %% diff --git a/examples/preprocessing/plot_scaling_importance.py b/examples/preprocessing/plot_scaling_importance.py index c0f133ee38175..f2f6c46f5d91e 100644 --- a/examples/preprocessing/plot_scaling_importance.py +++ b/examples/preprocessing/plot_scaling_importance.py @@ -65,6 +65,7 @@ # of features. import matplotlib.pyplot as plt +from matplotlib.colors import ListedColormap from sklearn.inspection import DecisionBoundaryDisplay from sklearn.neighbors import KNeighborsClassifier @@ -83,7 +84,10 @@ def fit_and_plot_model(X_plot, y, clf, ax): alpha=0.5, ax=ax, ) - disp.ax_.scatter(X_plot["proline"], X_plot["hue"], c=y, s=20, edgecolor="k") + cmap = ListedColormap(disp.multiclass_colors_) + disp.ax_.scatter( + X_plot["proline"], X_plot["hue"], c=y, cmap=cmap, s=20, edgecolor="k" + ) disp.ax_.set_xlim((X_plot["proline"].min(), X_plot["proline"].max())) disp.ax_.set_ylim((X_plot["hue"].min(), X_plot["hue"].max())) return disp.ax_ @@ -207,14 +211,25 @@ def fit_and_plot_model(X_plot, y, clf, ax): Cs = np.logspace(-5, 5, 20) unscaled_clf = make_pipeline( - pca, LogisticRegressionCV(Cs=Cs, use_legacy_attributes=False, l1_ratios=(0,)) + pca, + LogisticRegressionCV( + Cs=Cs, + use_legacy_attributes=False, + l1_ratios=(0,), # TODO(1.10): remove because it is default now + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ), ) unscaled_clf.fit(X_train, y_train) scaled_clf = make_pipeline( scaler, pca, - LogisticRegressionCV(Cs=Cs, use_legacy_attributes=False, l1_ratios=(0,)), + LogisticRegressionCV( + Cs=Cs, + use_legacy_attributes=False, + l1_ratios=(0,), # TODO(1.10): remove because it is default now + scoring="neg_log_loss", # TODO(1.11): remove because it is default now, + ), ) scaled_clf.fit(X_train, y_train) diff --git a/examples/preprocessing/plot_target_encoder_cross_val.py b/examples/preprocessing/plot_target_encoder_cross_val.py index d44ee2c6ba021..06635abd0d2e4 100644 --- a/examples/preprocessing/plot_target_encoder_cross_val.py +++ b/examples/preprocessing/plot_target_encoder_cross_val.py @@ -111,10 +111,13 @@ # Next, we create a pipeline with the target encoder and ridge model. The pipeline # uses :meth:`TargetEncoder.fit_transform` which uses :term:`cross fitting`. We # see that the model fits the data well and generalizes to the test set: +from sklearn.model_selection import KFold from sklearn.pipeline import make_pipeline from sklearn.preprocessing import TargetEncoder -model_with_cf = make_pipeline(TargetEncoder(random_state=0), ridge) +model_with_cf = make_pipeline( + TargetEncoder(cv=KFold(shuffle=True, random_state=0)), ridge +) model_with_cf.fit(X_train, y_train) print("Model with CF on train set: ", model_with_cf.score(X_train, y_train)) print("Model with CF on test set: ", model_with_cf.score(X_test, y_test)) diff --git a/examples/release_highlights/plot_release_highlights_0_22_0.py b/examples/release_highlights/plot_release_highlights_0_22_0.py index 8d5648188f0fe..8a920b585edc6 100644 --- a/examples/release_highlights/plot_release_highlights_0_22_0.py +++ b/examples/release_highlights/plot_release_highlights_0_22_0.py @@ -288,9 +288,9 @@ def test_sklearn_compatible_estimator(estimator, check): from sklearn.datasets import make_classification +from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score -from sklearn.svm import SVC X, y = make_classification(n_classes=4, n_informative=16) -clf = SVC(decision_function_shape="ovo", probability=True).fit(X, y) -print(roc_auc_score(y, clf.predict_proba(X), multi_class="ovo")) +clf = LogisticRegression().fit(X, y) +print(roc_auc_score(y, clf.predict_proba(X), multi_class="ovr")) diff --git a/examples/release_highlights/plot_release_highlights_0_24_0.py b/examples/release_highlights/plot_release_highlights_0_24_0.py index d09250ba6ff64..1f64ac42cd478 100644 --- a/examples/release_highlights/plot_release_highlights_0_24_0.py +++ b/examples/release_highlights/plot_release_highlights_0_24_0.py @@ -121,15 +121,15 @@ import numpy as np from sklearn import datasets +from sklearn.linear_model import LogisticRegression from sklearn.semi_supervised import SelfTrainingClassifier -from sklearn.svm import SVC rng = np.random.RandomState(42) iris = datasets.load_iris() random_unlabeled_points = rng.rand(iris.target.shape[0]) < 0.3 iris.target[random_unlabeled_points] = -1 -svc = SVC(probability=True, gamma="auto") -self_training_model = SelfTrainingClassifier(svc) +clf = LogisticRegression() +self_training_model = SelfTrainingClassifier(clf) self_training_model.fit(iris.data, iris.target) ############################################################################## diff --git a/examples/release_highlights/plot_release_highlights_1_0_0.py b/examples/release_highlights/plot_release_highlights_1_0_0.py index 03213076b326e..c9b0da81cd8f8 100644 --- a/examples/release_highlights/plot_release_highlights_1_0_0.py +++ b/examples/release_highlights/plot_release_highlights_1_0_0.py @@ -136,6 +136,8 @@ # :scale: 50% ############################################################################## +# .. _feature_names_in_release_highlights_1_0_0: +# # Feature Names Support # -------------------------------------------------------------------------- # When an estimator is passed a `pandas' dataframe diff --git a/examples/release_highlights/plot_release_highlights_1_1_0.py b/examples/release_highlights/plot_release_highlights_1_1_0.py index fdb11f887f3db..2b5377466f9fb 100644 --- a/examples/release_highlights/plot_release_highlights_1_1_0.py +++ b/examples/release_highlights/plot_release_highlights_1_1_0.py @@ -59,6 +59,8 @@ # :ref:`sphx_glr_auto_examples_ensemble_plot_hgbt_regression.py` # %% +# .. _get_feature_names_out_release_highlights_1_1_0: +# # `get_feature_names_out` Available in all Transformers # ----------------------------------------------------- # :term:`get_feature_names_out` is now available in all transformers, thereby diff --git a/examples/release_highlights/plot_release_highlights_1_3_0.py b/examples/release_highlights/plot_release_highlights_1_3_0.py index fe352c2eb1746..f05abe874c4c3 100644 --- a/examples/release_highlights/plot_release_highlights_1_3_0.py +++ b/examples/release_highlights/plot_release_highlights_1_3_0.py @@ -73,12 +73,13 @@ # More details in the :ref:`User Guide <target_encoder>`. import numpy as np +from sklearn.model_selection import KFold from sklearn.preprocessing import TargetEncoder X = np.array([["cat"] * 30 + ["dog"] * 20 + ["snake"] * 38], dtype=object).T y = [90.3] * 30 + [20.4] * 20 + [21.2] * 38 -enc = TargetEncoder(random_state=0) +enc = TargetEncoder(cv=KFold(shuffle=True, random_state=0)) X_trans = enc.fit_transform(X, y) enc.encodings_ diff --git a/examples/release_highlights/plot_release_highlights_1_8_0.py b/examples/release_highlights/plot_release_highlights_1_8_0.py new file mode 100644 index 0000000000000..a1d3da07849a6 --- /dev/null +++ b/examples/release_highlights/plot_release_highlights_1_8_0.py @@ -0,0 +1,288 @@ +# ruff: noqa: CPY001 +""" +======================================= +Release Highlights for scikit-learn 1.8 +======================================= + +.. currentmodule:: sklearn + +We are pleased to announce the release of scikit-learn 1.8! Many bug fixes +and improvements were added, as well as some key new features. Below we +detail the highlights of this release. **For an exhaustive list of +all the changes**, please refer to the :ref:`release notes <release_notes_1_8>`. + +To install the latest version (with pip):: + + pip install --upgrade scikit-learn + +or with conda:: + + conda install -c conda-forge scikit-learn + +""" + +# %% +# Array API support (enables GPU computations) +# -------------------------------------------- +# The progressive adoption of the Python array API standard in +# scikit-learn means that PyTorch and CuPy input arrays +# are used directly. This means that in scikit-learn estimators +# and functions non-CPU devices, such as GPUs, can be used +# to perform the computation. As a result performance is improved +# and integration with these libraries is easier. +# +# In scikit-learn 1.8, several estimators and functions have been updated to +# support array API compatible inputs, for example PyTorch tensors and CuPy +# arrays. +# +# Array API support was added to the following estimators: +# :class:`preprocessing.StandardScaler`, +# :class:`preprocessing.PolynomialFeatures`, :class:`linear_model.RidgeCV`, +# :class:`linear_model.RidgeClassifierCV`, :class:`mixture.GaussianMixture` and +# :class:`calibration.CalibratedClassifierCV`. +# +# Array API support was also added to several metrics in :mod:`sklearn.metrics` +# module, see :ref:`array_api_supported` for more details. +# +# Please refer to the :ref:`array API support<array_api>` page for instructions +# to use scikit-learn with array API compatible libraries such as PyTorch or CuPy. +# Note: Array API support is experimental and must be explicitly enabled both +# in SciPy and scikit-learn. +# +# Here is an excerpt of using a feature engineering preprocessor on the CPU, +# followed by :class:`calibration.CalibratedClassifierCV` +# and :class:`linear_model.RidgeCV` together on a GPU with the help of PyTorch: +# +# .. code-block:: python +# +# ridge_pipeline_gpu = make_pipeline( +# # Ensure that all features (including categorical features) are preprocessed +# # on the CPU and mapped to a numerical representation. +# feature_preprocessor, +# # Move the results to the GPU and perform computations there +# FunctionTransformer( +# lambda x: torch.tensor(x.to_numpy().astype(np.float32), device="cuda")) +# , +# CalibratedClassifierCV( +# RidgeClassifierCV(alphas=alphas), method="temperature" +# ), +# ) +# with sklearn.config_context(array_api_dispatch=True): +# cv_results = cross_validate(ridge_pipeline_gpu, features, target) +# +# +# See the `full notebook on Google Colab +# <https://colab.research.google.com/drive/1ztH8gUPv31hSjEeR_8pw20qShTwViGRx?usp=sharing>`_ +# for more details. On this particular example, using the Colab GPU vs using a +# single CPU core leads to a 10x speedup which is quite typical for such workloads. + +# %% +# Free-threaded CPython 3.14 support +# ---------------------------------- +# +# scikit-learn has support for free-threaded CPython, in particular +# free-threaded wheels are available for all of our supported platforms on Python +# 3.14. +# +# We would be very interested by user feedback. Here are a few things you can +# try: +# +# - install free-threaded CPython 3.14, run your favourite +# scikit-learn script and check that nothing breaks unexpectedly. +# Note that CPython 3.14 (rather than 3.13) is strongly advised because a +# number of free-threaded bugs have been fixed since CPython 3.13. +# - if you use some estimators with a `n_jobs` parameter, try changing the +# default backend to threading with `joblib.parallel_config` as in the +# snippet below. This could potentially speed-up your code because the +# default joblib backend is process-based and incurs more overhead than +# threads. +# +# .. code-block:: python +# +# grid_search = GridSearchCV(clf, param_grid=param_grid, n_jobs=4) +# with joblib.parallel_config(backend="threading"): +# grid_search.fit(X, y) +# +# - don't hesitate to report any issue or unexpected performance behaviour by +# opening a `GitHub issue <https://github.com/scikit-learn/scikit-learn/issues/new/choose>`_! +# +# Free-threaded (also known as nogil) CPython is a version of CPython that aims +# to enable efficient multi-threaded use cases by removing the Global +# Interpreter Lock (GIL). +# +# For more details about free-threaded CPython see `py-free-threading doc +# <https://py-free-threading.github.io>`_, in particular `how to install a +# free-threaded CPython <https://py-free-threading.github.io/installing-cpython/>`_ +# and `Ecosystem compatibility tracking <https://py-free-threading.github.io/tracking/>`_. +# +# In scikit-learn, one hope with free-threaded Python is to more efficiently +# leverage multi-core CPUs by using thread workers instead of subprocess +# workers for parallel computation when passing `n_jobs>1` in functions or +# estimators. Efficiency gains are expected by removing the need for +# inter-process communication. Be aware that switching the default joblib +# backend and testing that everything works well with free-threaded Python is an +# ongoing long-term effort. + +# %% +# Temperature scaling in `CalibratedClassifierCV` +# ----------------------------------------------- +# Probability calibration of classifiers with temperature scaling is available in +# :class:`calibration.CalibratedClassifierCV` by setting `method="temperature"`. +# This method is particularly well suited for multiclass problems because it provides +# (better) calibrated probabilities with a single free parameter. This is in +# contrast to all the other available calibrations methods +# which use a "One-vs-Rest" scheme that adds more parameters for each class. + +from sklearn.calibration import CalibratedClassifierCV +from sklearn.datasets import make_classification +from sklearn.naive_bayes import GaussianNB + +X, y = make_classification(n_classes=3, n_informative=8, random_state=42) +clf = GaussianNB().fit(X, y) +sig = CalibratedClassifierCV(clf, method="sigmoid", ensemble=False).fit(X, y) +ts = CalibratedClassifierCV(clf, method="temperature", ensemble=False).fit(X, y) + +# %% +# The following example shows that temperature scaling can produce better calibrated +# probabilities than sigmoid calibration in multi-class classification problem +# with 3 classes. + +import matplotlib.pyplot as plt + +from sklearn.calibration import CalibrationDisplay + +fig, axes = plt.subplots( + figsize=(8, 4.5), + ncols=3, + sharey=True, +) +for i, c in enumerate(ts.classes_): + CalibrationDisplay.from_predictions( + y == c, clf.predict_proba(X)[:, i], name="Uncalibrated", ax=axes[i], marker="s" + ) + CalibrationDisplay.from_predictions( + y == c, + ts.predict_proba(X)[:, i], + name="Temperature scaling", + ax=axes[i], + marker="o", + ) + CalibrationDisplay.from_predictions( + y == c, sig.predict_proba(X)[:, i], name="Sigmoid", ax=axes[i], marker="v" + ) + axes[i].set_title(f"Class {c}") + axes[i].set_xlabel(None) + axes[i].set_ylabel(None) + axes[i].get_legend().remove() +fig.suptitle("Reliability Diagrams per Class") +fig.supxlabel("Mean Predicted Probability") +fig.supylabel("Fraction of Class") +fig.legend(*axes[0].get_legend_handles_labels(), loc=(0.72, 0.5)) +plt.subplots_adjust(right=0.7) +_ = fig.show() + +# %% +# Efficiency improvements in linear models +# ---------------------------------------- +# The fit time has been massively reduced for squared error based estimators +# with L1 penalty: `ElasticNet`, `Lasso`, `MultiTaskElasticNet`, +# `MultiTaskLasso` and their CV variants. The fit time improvement is mainly +# achieved by **gap safe screening rules**. They enable the coordinate descent +# solver to set feature coefficients to zero early on and not look at them +# again. The stronger the L1 penalty the earlier features can be excluded from +# further updates. + +from time import time + +from sklearn.datasets import make_regression +from sklearn.linear_model import ElasticNetCV + +X, y = make_regression(n_features=10_000, random_state=0) +model = ElasticNetCV() +tic = time() +model.fit(X, y) +toc = time() +print(f"Fitting ElasticNetCV took {toc - tic:.3} seconds.") + +# %% +# HTML representation of estimators +# --------------------------------- +# Hyperparameters in the dropdown table of the HTML representation now include +# links to the online documentation. Docstring descriptions are also shown as +# tooltips on hover. + +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler + +clf = make_pipeline(StandardScaler(), LogisticRegression(random_state=0, C=10)) + +# %% +# Expand the estimator diagram below by clicking on "LogisticRegression" and then on +# "Parameters". + +clf + + +# %% +# DecisionTreeRegressor with `criterion="absolute_error"` +# ------------------------------------------------------- +# :class:`tree.DecisionTreeRegressor` with `criterion="absolute_error"` +# now runs much faster. It has now `O(n * log(n))` complexity compared to +# `O(n**2)` previously, which allows to scale to millions of data points. +# +# As an illustration, on a dataset with 100_000 samples and 1 feature, doing a +# single split takes of the order of 100 ms, compared to ~20 seconds before. + +import time + +from sklearn.datasets import make_regression +from sklearn.tree import DecisionTreeRegressor + +X, y = make_regression(n_samples=100_000, n_features=1) +tree = DecisionTreeRegressor(criterion="absolute_error", max_depth=1) + +tic = time.time() +tree.fit(X, y) +elapsed = time.time() - tic +print(f"Fit took {elapsed:.2f} seconds") + +# %% +# ClassicalMDS +# ------------ +# Classical MDS, also known as "Principal Coordinates Analysis" (PCoA) +# or "Torgerson's scaling" is now available within the `sklearn.manifold` +# module. Classical MDS is close to PCA and instead of approximating +# distances, it approximates pairwise scalar products, which has an exact +# analytic solution in terms of eigendecomposition. +# +# Let's illustrate this new addition by using it on an S-curve dataset to +# get a low-dimensional representation of the data. + +import matplotlib.pyplot as plt +from matplotlib import ticker + +from sklearn import datasets, manifold + +n_samples = 1500 +S_points, S_color = datasets.make_s_curve(n_samples, random_state=0) +md_classical = manifold.ClassicalMDS(n_components=2) +S_scaling = md_classical.fit_transform(S_points) + +fig = plt.figure(figsize=(8, 4)) +ax1 = fig.add_subplot(1, 2, 1, projection="3d") +x, y, z = S_points.T +ax1.scatter(x, y, z, c=S_color, s=50, alpha=0.8) +ax1.set_title("Original S-curve samples", size=16) +ax1.view_init(azim=-60, elev=9) +for axis in (ax1.xaxis, ax1.yaxis, ax1.zaxis): + axis.set_major_locator(ticker.MultipleLocator(1)) + +ax2 = fig.add_subplot(1, 2, 2) +x2, y2 = S_scaling.T +ax2.scatter(x2, y2, c=S_color, s=50, alpha=0.8) +ax2.set_title("Classical MDS", size=16) +for axis in (ax2.xaxis, ax2.yaxis): + axis.set_major_formatter(ticker.NullFormatter()) + +plt.show() diff --git a/examples/semi_supervised/plot_self_training_varying_threshold.py b/examples/semi_supervised/plot_self_training_varying_threshold.py index bbdaeb634f570..bd64f4aaca5a5 100644 --- a/examples/semi_supervised/plot_self_training_varying_threshold.py +++ b/examples/semi_supervised/plot_self_training_varying_threshold.py @@ -36,6 +36,7 @@ import numpy as np from sklearn import datasets +from sklearn.calibration import CalibratedClassifierCV from sklearn.metrics import accuracy_score from sklearn.model_selection import StratifiedKFold from sklearn.semi_supervised import SelfTrainingClassifier @@ -50,7 +51,7 @@ y[50:] = -1 total_samples = y.shape[0] -base_classifier = SVC(probability=True, gamma=0.001, random_state=42) +base_classifier = CalibratedClassifierCV(SVC(gamma=0.001, random_state=42)) x_values = np.arange(0.4, 1.05, 0.05) x_values = np.append(x_values, 0.99999) diff --git a/examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py b/examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py index 333b80ee88812..58dda26b9a167 100644 --- a/examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py +++ b/examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py @@ -33,6 +33,7 @@ import matplotlib.pyplot as plt import numpy as np +from sklearn.calibration import CalibratedClassifierCV from sklearn.datasets import load_iris from sklearn.inspection import DecisionBoundaryDisplay from sklearn.semi_supervised import LabelSpreading, SelfTrainingClassifier @@ -53,7 +54,7 @@ ls30 = (LabelSpreading().fit(X, y_30), y_30, "LabelSpreading with 30% labeled data") ls100 = (LabelSpreading().fit(X, y), y, "LabelSpreading with 100% labeled data") -base_classifier = SVC(gamma=0.5, probability=True, random_state=42) +base_classifier = CalibratedClassifierCV(SVC(gamma=0.5, random_state=42)) st10 = ( SelfTrainingClassifier(base_classifier).fit(X, y_10), y_10, @@ -70,34 +71,34 @@ "SVC with rbf kernel\n(equivalent to Self-training with 100% labeled data)", ) -tab10 = plt.get_cmap("tab10") -color_map = {cls: tab10(cls) for cls in np.unique(y)} -color_map[-1] = (1, 1, 1) classifiers = (ls10, st10, ls30, st30, ls100, rbf_svc) fig, axes = plt.subplots(nrows=3, ncols=2, sharex="col", sharey="row", figsize=(10, 12)) axes = axes.ravel() - -handles = [ - mpatches.Patch(facecolor=tab10(i), edgecolor="black", label=iris.target_names[i]) - for i in np.unique(y) -] -handles.append(mpatches.Patch(facecolor="white", edgecolor="black", label="Unlabeled")) - for ax, (clf, y_train, title) in zip(axes, classifiers): - DecisionBoundaryDisplay.from_estimator( + disp = DecisionBoundaryDisplay.from_estimator( clf, X, response_method="predict_proba", plot_method="contourf", ax=ax, ) - colors = [color_map[label] for label in y_train] + colors = [ + (1, 1, 1, 1) if label == -1 else disp.multiclass_colors_[label] + for label in y_train + ] ax.scatter(X[:, 0], X[:, 1], c=colors, edgecolor="black") ax.set_title(title) fig.suptitle( "Semi-supervised decision boundaries with varying fractions of labeled data", y=1 ) +handles = [ + mpatches.Patch( + facecolor=color, edgecolor="black", label=iris.target_names[class_idx] + ) + for class_idx, color in enumerate(disp.multiclass_colors_) +] +handles.append(mpatches.Patch(facecolor="white", edgecolor="black", label="Unlabeled")) fig.legend( handles=handles, loc="lower center", ncol=len(handles), bbox_to_anchor=(0.5, 0.0) ) diff --git a/examples/svm/plot_custom_kernel.py b/examples/svm/plot_custom_kernel.py index d3816849f73b8..857d18e560837 100644 --- a/examples/svm/plot_custom_kernel.py +++ b/examples/svm/plot_custom_kernel.py @@ -3,8 +3,8 @@ SVM with custom kernel ====================== -Simple usage of Support Vector Machines to classify a sample. It will -plot the decision surface and the support vectors. +Simple usage of :ref:`svm` classifier with a custom kernel. It will +plot the decision surface and highlight the support vectors. """ @@ -17,10 +17,10 @@ from sklearn import datasets, svm from sklearn.inspection import DecisionBoundaryDisplay -# import some data to play with +# Import some data to play with. iris = datasets.load_iris() -X = iris.data[:, :2] # we only take the first two features. We could -# avoid this ugly slicing by using a two-dim dataset +X = iris.data[:, :2] # We only take the first two features. We could +# avoid this ugly slicing by using a two-dim dataset. Y = iris.target @@ -36,9 +36,7 @@ def my_kernel(X, Y): return np.dot(np.dot(X, M), Y.T) -h = 0.02 # step size in the mesh - -# we create an instance of SVM and fit out data. +# We create an instance of SVC with that kernel and fit it on the data. clf = svm.SVC(kernel=my_kernel) clf.fit(X, Y) @@ -46,15 +44,23 @@ def my_kernel(X, Y): DecisionBoundaryDisplay.from_estimator( clf, X, - cmap=plt.cm.Paired, + multiclass_colors="Paired", ax=ax, response_method="predict", plot_method="pcolormesh", shading="auto", + alpha=0.5, ) -# Plot also the training points -plt.scatter(X[:, 0], X[:, 1], c=Y, cmap=plt.cm.Paired, edgecolors="k") +# Plot the training points +plt.scatter(X[:, 0], X[:, 1], c=Y, cmap=plt.cm.Paired) +# Highlight the support vectors +plt.scatter( + X[clf.support_, 0], + X[clf.support_, 1], + facecolor="none", + edgecolors="k", +) plt.title("3-Class classification using Support Vector Machine with custom kernel") plt.axis("tight") plt.show() diff --git a/examples/svm/plot_iris_svc.py b/examples/svm/plot_iris_svc.py index 77259f9d1ea2c..cb16b69b85f91 100644 --- a/examples/svm/plot_iris_svc.py +++ b/examples/svm/plot_iris_svc.py @@ -9,8 +9,8 @@ - Sepal length - Sepal width -This example shows how to plot the decision surface for four SVM classifiers -with different kernels. +This example shows how to plot the decision surface and the support vectors for +four SVM classifiers with different kernels. The linear models ``LinearSVC()`` and ``SVC(kernel='linear')`` yield slightly different decision boundaries. This can be a consequence of the following @@ -27,7 +27,7 @@ flexible non-linear decision boundaries with shapes that depend on the kind of kernel and its parameters. -.. NOTE:: while plotting the decision function of classifiers for toy 2D +.. NOTE:: While plotting the decision function of classifiers for toy 2D datasets can help get an intuitive understanding of their respective expressive power, be aware that those intuitions don't always generalize to more realistic high-dimensional problems. @@ -38,18 +38,19 @@ # SPDX-License-Identifier: BSD-3-Clause import matplotlib.pyplot as plt +import numpy as np from sklearn import datasets, svm from sklearn.inspection import DecisionBoundaryDisplay -# import some data to play with +# Import some data to play with. iris = datasets.load_iris() -# Take the first two features. We could avoid this by using a two-dim dataset +# Take the first two features. We could avoid this by using a two-dim dataset. X = iris.data[:, :2] y = iris.target -# we create an instance of SVM and fit out data. We do not scale our -# data since we want to plot the support vectors +# We create an instance of SVM and fit out data. We do not scale our +# data since we want to plot the support vectors. C = 1.0 # SVM regularization parameter models = ( svm.SVC(kernel="linear", C=C), @@ -59,7 +60,7 @@ ) models = (clf.fit(X, y) for clf in models) -# title for the plots +# Title for the plots titles = ( "SVC with linear kernel", "LinearSVC (linear kernel)", @@ -71,20 +72,34 @@ fig, sub = plt.subplots(2, 2) plt.subplots_adjust(wspace=0.4, hspace=0.4) -X0, X1 = X[:, 0], X[:, 1] - for clf, title, ax in zip(models, titles, sub.flatten()): disp = DecisionBoundaryDisplay.from_estimator( clf, X, response_method="predict", - cmap=plt.cm.coolwarm, + multiclass_colors="coolwarm", alpha=0.8, ax=ax, xlabel=iris.feature_names[0], ylabel=iris.feature_names[1], ) - ax.scatter(X0, X1, c=y, cmap=plt.cm.coolwarm, s=20, edgecolors="k") + + # Plot the support vectors. + # For LinearSVC we compute the support vectors from the decision function, see + # https://scikit-learn.org/dev/auto_examples/svm/plot_linearsvc_support_vectors.html + if hasattr(clf, "support_"): + support_vector_indices = clf.support_ + else: + decision_function = clf.decision_function(X) + support_vector_indices = (np.abs(decision_function) <= 1 + 1e-15).nonzero()[0] + ax.scatter( + X[support_vector_indices, 0], + X[support_vector_indices, 1], + c=y[support_vector_indices], + cmap=plt.cm.coolwarm, + edgecolors="k", + ) + ax.set_xticks(()) ax.set_yticks(()) ax.set_title(title) diff --git a/examples/svm/plot_separating_hyperplane_unbalanced.py b/examples/svm/plot_separating_hyperplane_unbalanced.py index d0814e1af065f..d92735fc91a82 100644 --- a/examples/svm/plot_separating_hyperplane_unbalanced.py +++ b/examples/svm/plot_separating_hyperplane_unbalanced.py @@ -17,7 +17,7 @@ This example will also work by replacing ``SVC(kernel="linear")`` with ``SGDClassifier(loss="hinge")``. Setting the ``loss`` parameter of the :class:`SGDClassifier` equal to ``hinge`` will yield behaviour - such as that of a SVC with a linear kernel. + such as that of an SVC with a linear kernel. For example try instead of the ``SVC``:: diff --git a/examples/svm/plot_svm_scale_c.py b/examples/svm/plot_svm_scale_c.py index 09cde25983ba1..30aa913a95511 100644 --- a/examples/svm/plot_svm_scale_c.py +++ b/examples/svm/plot_svm_scale_c.py @@ -28,7 +28,7 @@ between the main problem and the smaller problems within the folds of the cross validation. -Since the loss function dependens on the amount of samples, the latter +Since the loss function depends on the amount of samples, the latter influences the selected value of `C`. The question that arises is "How do we optimally adjust C to account for the different amount of training samples?" """ @@ -138,7 +138,7 @@ # # Using the default scale results in a somewhat stable optimal value of `C`, # whereas the transition out of the underfitting region depends on the number of -# training samples. The reparametrization leads to even more stable results. +# training samples. The reparameterization leads to even more stable results. # # See e.g. theorem 3 of :arxiv:`On the prediction performance of the Lasso # <1402.1700>` or :arxiv:`Simultaneous analysis of Lasso and Dantzig selector @@ -198,7 +198,7 @@ plt.show() # %% -# For the L2 penalty case, the reparametrization seems to have a smaller impact +# For the L2 penalty case, the reparameterization seems to have a smaller impact # on the stability of the optimal value for the regularization. The transition # out of the overfitting region occurs in a more spread range and the accuracy # does not seem to be degraded up to chance level. diff --git a/examples/tree/plot_cost_complexity_pruning.py b/examples/tree/plot_cost_complexity_pruning.py index bdd1a2b0c358f..57c81685687bd 100644 --- a/examples/tree/plot_cost_complexity_pruning.py +++ b/examples/tree/plot_cost_complexity_pruning.py @@ -6,7 +6,7 @@ .. currentmodule:: sklearn.tree The :class:`DecisionTreeClassifier` provides parameters such as -``min_samples_leaf`` and ``max_depth`` to prevent a tree from overfiting. Cost +``min_samples_leaf`` and ``max_depth`` to prevent a tree from overfitting. Cost complexity pruning provides another option to control the size of a tree. In :class:`DecisionTreeClassifier`, this pruning technique is parameterized by the cost complexity parameter, ``ccp_alpha``. Greater values of ``ccp_alpha`` diff --git a/examples/tree/plot_iris_dtc.py b/examples/tree/plot_iris_dtc.py index 349f4a893511e..c21871383151b 100644 --- a/examples/tree/plot_iris_dtc.py +++ b/examples/tree/plot_iris_dtc.py @@ -28,18 +28,11 @@ # %% # Display the decision functions of trees trained on all pairs of features. import matplotlib.pyplot as plt -import numpy as np +from matplotlib.colors import ListedColormap -from sklearn.datasets import load_iris from sklearn.inspection import DecisionBoundaryDisplay from sklearn.tree import DecisionTreeClassifier -# Parameters -n_classes = 3 -plot_colors = "ryb" -plot_step = 0.02 - - for pairidx, pair in enumerate([[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]]): # We only take the two corresponding features X = iris.data[:, pair] @@ -51,30 +44,33 @@ # Plot the decision boundary ax = plt.subplot(2, 3, pairidx + 1) plt.tight_layout(h_pad=0.5, w_pad=0.5, pad=2.5) - DecisionBoundaryDisplay.from_estimator( + disp = DecisionBoundaryDisplay.from_estimator( clf, X, - cmap=plt.cm.RdYlBu, response_method="predict", ax=ax, xlabel=iris.feature_names[pair[0]], ylabel=iris.feature_names[pair[1]], + alpha=0.5, ) # Plot the training points - for i, color in zip(range(n_classes), plot_colors): - idx = np.asarray(y == i).nonzero() - plt.scatter( - X[idx, 0], - X[idx, 1], - c=color, - label=iris.target_names[i], - edgecolor="black", - s=15, - ) + scatter = disp.ax_.scatter( + X[:, 0], + X[:, 1], + c=y, + cmap=ListedColormap(disp.multiclass_colors_), + edgecolor="black", + s=15, + ) plt.suptitle("Decision surface of decision trees trained on pairs of features") -plt.legend(loc="lower right", borderpad=0, handletextpad=0) +plt.figlegend( + scatter.legend_elements()[0], + iris.target_names, + loc="lower center", + ncols=len(iris.target_names), +) _ = plt.axis("tight") # %% diff --git a/maint_tools/bump-dependencies-versions.py b/maint_tools/bump-dependencies-versions.py index 1e732e83f6dba..8f1042ec6779b 100644 --- a/maint_tools/bump-dependencies-versions.py +++ b/maint_tools/bump-dependencies-versions.py @@ -158,6 +158,7 @@ def show_versions_update(scikit_learn_release_date="today"): pure_python_or_example_dependencies = [ "joblib", + "narwhals", "threadpoolctl", "scikit-image", "seaborn", diff --git a/maint_tools/update_tracking_issue.py b/maint_tools/update_tracking_issue.py index b40e8222fefae..8de186bed2f68 100644 --- a/maint_tools/update_tracking_issue.py +++ b/maint_tools/update_tracking_issue.py @@ -13,6 +13,7 @@ import argparse import sys +import warnings from datetime import datetime, timezone from pathlib import Path @@ -28,12 +29,21 @@ parser.add_argument("ci_name", help="Name of CI run instance") parser.add_argument("issue_repo", help="Repo to track issues") parser.add_argument("link_to_ci_run", help="URL to link to") +parser.add_argument( + "--job-name", + help=( + "Name of the job. If provided the job ID will be added to the log URL so that" + " it points to log of the job and not the whole workflow." + ), + default=None, +) parser.add_argument("--junit-file", help="JUnit file to determine if tests passed") parser.add_argument( "--tests-passed", help=( "If --tests-passed is true, then the original issue is closed if the issue " - "exists. If tests-passed is false, then the an issue is updated or created." + "exists, unless --auto-close is set to false. If tests-passed is false, then " + "the issue is updated or created." ), ) parser.add_argument( @@ -62,11 +72,29 @@ title_query = f"CI failed on {args.ci_name}" title = f"โš ๏ธ {title_query} (last failure: {date_str}) โš ๏ธ" +url = args.link_to_ci_run + +if args.job_name is not None: + run_id = int(args.link_to_ci_run.split("/")[-1]) + workflow_run = issue_repo.get_workflow_run(run_id) + jobs = workflow_run.jobs() + + for job in jobs: + if job.name == args.job_name: + url = f"{url}/job/{job.id}" + break + else: + warnings.warn( + f"Job '{args.job_name}' not found, the URL in the issue will link to the" + " whole workflow's log rather than the job's one." + ) + def get_issue(): login = gh.get_user().login issues = gh.search_issues( f"repo:{args.issue_repo} {title_query} in:title state:open author:{login}" + " is:issue" ) first_page = issues.get_page(0) # Return issue if it exist @@ -75,7 +103,7 @@ def get_issue(): def create_or_update_issue(body=""): # Interact with GitHub API to create issue - link = f"[{args.ci_name}]({args.link_to_ci_run})" + link = f"[{args.ci_name}]({url})" issue = get_issue() max_body_length = 60_000 @@ -106,9 +134,7 @@ def close_issue_if_opened(): issue = get_issue() if issue is not None: header_str = "## CI is no longer failing!" - comment_str = ( - f"{header_str} โœ…\n\n[Successful run]({args.link_to_ci_run}) on {date_str}" - ) + comment_str = f"{header_str} โœ…\n\n[Successful run]({url}) on {date_str}" print(f"Commented on issue #{issue.number}") # New comment if "## CI is no longer failing!" comment does not exist diff --git a/maint_tools/vendor_array_api_compat.sh b/maint_tools/vendor_array_api_compat.sh index 51056ce477cbb..96282b52733a8 100755 --- a/maint_tools/vendor_array_api_compat.sh +++ b/maint_tools/vendor_array_api_compat.sh @@ -6,7 +6,7 @@ set -o nounset set -o errexit URL="https://github.com/data-apis/array-api-compat.git" -VERSION="1.12" +VERSION="1.13" ROOT_DIR=sklearn/externals/array_api_compat diff --git a/meson.build b/meson.build index f843a1ff8f45c..99dea014d8800 100644 --- a/meson.build +++ b/meson.build @@ -3,7 +3,7 @@ project( 'c', 'cpp', 'cython', version: run_command('sklearn/_build_utils/version.py', check: true).stdout().strip(), license: 'BSD-3', - meson_version: '>= 1.1.0', + meson_version: '>= 1.9.0', default_options: [ 'c_std=c11', 'cpp_std=c++14', diff --git a/pyproject.toml b/pyproject.toml index 4e0e4417c2d7f..d775d55516115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "numpy>=1.24.1", "scipy>=1.10.0", "joblib>=1.3.0", + "narwhals>=2.0.1", "threadpoolctl>=3.2.0", ] requires-python = ">=3.11" @@ -44,7 +45,7 @@ tracker = "https://github.com/scikit-learn/scikit-learn/issues" [project.optional-dependencies] build = ["numpy>=1.24.1", "scipy>=1.10.0", "cython>=3.1.2", "meson-python>=0.17.1"] -install = ["numpy>=1.24.1", "scipy>=1.10.0", "joblib>=1.3.0", "threadpoolctl>=3.2.0"] +install = ["numpy>=1.24.1", "scipy>=1.10.0", "joblib>=1.3.0", "narwhals>=2.0.1", "threadpoolctl>=3.2.0"] benchmark = ["matplotlib>=3.6.1", "pandas>=1.5.0", "memory_profiler>=0.57.0"] docs = [ "matplotlib>=3.6.1", @@ -81,11 +82,11 @@ tests = [ "pandas>=1.5.0", "pytest>=7.1.2", "pytest-cov>=2.9.0", - "ruff>=0.11.7", + "ruff>=0.12.2", "mypy>=1.15", "pyamg>=5.0.0", "polars>=0.20.30", - "pyarrow>=12.0.0", + "pyarrow>=13.0.0", "numpydoc>=1.2.0", "pooch>=1.8.0", ] @@ -115,6 +116,12 @@ thread_unsafe_fixtures = [ "tmp_path", # does not isolate temporary directories across threads "pyplot", # some tests might mutate some shared state of pyplot. ] +# 10 min timeout per test: in case of timeout, dump the tracebacks of all +# threads and terminate the whole test session if a test hangs for more than 10 +# min (likely due to a deadlock). +# The second option requires pytest 9.0+ to be active. +faulthandler_timeout = 600 +faulthandler_exit_on_timeout = true [tool.ruff] @@ -261,7 +268,7 @@ exclude= ''' [tool.check-sdist] # These settings should match .gitattributes sdist-only = [] -git-only = [".*", "asv_benchmarks", "azure-pipelines.yml", "benchmarks", "build_tools", "maint_tools"] +git-only = [".*", "asv_benchmarks", "benchmarks", "build_tools", "maint_tools"] default-ignore = false [tool.spin] @@ -285,12 +292,12 @@ package = "sklearn" # name of your package whatsnew_pattern = 'doc/whatsnew/upcoming_changes/[^/]+/\d+\.[^.]+\.rst' [tool.codespell] -skip = ["./.git", "*.svg", "./.mypy_cache", "./sklearn/feature_extraction/_stop_words.py", "./sklearn/feature_extraction/tests/test_text.py", "./doc/_build", "./doc/auto_examples", "./doc/modules/generated"] +skip = ["./.git", "*.svg", "./.mypy_cache", "*sklearn/feature_extraction/_stop_words.py", "*sklearn/feature_extraction/tests/test_text.py", "./doc/_build", "./doc/auto_examples", "./doc/modules/generated"] ignore-words = "build_tools/codespell_ignore_words.txt" [tool.towncrier] package = "sklearn" - filename = "doc/whats_new/v1.8.rst" + filename = "doc/whats_new/v1.9.rst" single_file = true directory = "doc/whats_new/upcoming_changes" issue_format = ":pr:`{issue}`" diff --git a/sklearn/__check_build/__init__.py b/sklearn/__check_build/__init__.py index 0a4162d0dffc6..f272b008f85b9 100644 --- a/sklearn/__check_build/__init__.py +++ b/sklearn/__check_build/__init__.py @@ -42,7 +42,7 @@ def raise_build_error(e): If you have installed scikit-learn from source, please do not forget to build the package before using it. For detailed instructions, see: -https://scikit-learn.org/dev/developers/advanced_installation.html#building-from-source +https://scikit-learn.org/dev/developers/development_setup.html#install-editable-version-of-scikit-learn %s""" % (e, local_dir, "".join(dir_content).strip(), msg) ) diff --git a/sklearn/__init__.py b/sklearn/__init__.py index 2bb31200ed1a5..2c14dd82c0acc 100644 --- a/sklearn/__init__.py +++ b/sklearn/__init__.py @@ -42,14 +42,14 @@ # Dev branch marker is: 'X.Y.dev' or 'X.Y.devN' where N is an integer. # 'X.Y.dev0' is the canonical version of 'X.Y.dev' # -__version__ = "1.8.dev0" +__version__ = "1.9.dev0" # On OSX, we can get a runtime error due to multiple OpenMP libraries loaded # simultaneously. This can happen for instance when calling BLAS inside a # prange. Setting the following environment variable allows multiple OpenMP # libraries to be loaded. It should not degrade performances since we manually -# take care of potential over-subcription performance issues, in sections of +# take care of potential over-subscription performance issues, in sections of # the code where nested OpenMP loops can happen, by dynamically reconfiguring # the inner OpenMP runtime to temporarily disable it while under the scope of # the outer OpenMP parallel section. diff --git a/sklearn/_config.py b/sklearn/_config.py index 217386c81c80e..f460323592210 100644 --- a/sklearn/_config.py +++ b/sklearn/_config.py @@ -20,6 +20,7 @@ "transform_output": "default", "enable_metadata_routing": False, "skip_parameter_validation": False, + "sparse_interface": "spmatrix", } _threadlocal = threading.local() @@ -71,6 +72,7 @@ def set_config( transform_output=None, enable_metadata_routing=None, skip_parameter_validation=None, + sparse_interface=None, ): """Set global scikit-learn configuration. @@ -153,8 +155,9 @@ def set_config( transform_output : str, default=None Configure output of `transform` and `fit_transform`. - See :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` - for an example on how to use the API. + Refer to the :ref:`user guide <df_output_transform>` for more details + and :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` for an + example on how to use the API. - `"default"`: Default output format of a transformer - `"pandas"`: DataFrame output @@ -193,6 +196,16 @@ def set_config( .. versionadded:: 1.3 + sparse_interface : str, default="spmatrix" + + The sparse interface used for every sparse object that scikit-learn produces, + e.g., function returns, estimator attributes, estimator properties, etc. + + - `"sparray"`: Return sparse as SciPy sparse array + - `"spmatrix"`: Return sparse as SciPy sparse matrix + + .. versionadded:: 1.9 + See Also -------- config_context : Context manager for global scikit-learn configuration. @@ -228,6 +241,8 @@ def set_config( local_config["enable_metadata_routing"] = enable_metadata_routing if skip_parameter_validation is not None: local_config["skip_parameter_validation"] = skip_parameter_validation + if sparse_interface is not None: + local_config["sparse_interface"] = sparse_interface @contextmanager @@ -243,6 +258,7 @@ def config_context( transform_output=None, enable_metadata_routing=None, skip_parameter_validation=None, + sparse_interface=None, ): """Context manager to temporarily change the global scikit-learn configuration. @@ -320,8 +336,9 @@ def config_context( transform_output : str, default=None Configure output of `transform` and `fit_transform`. - See :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` - for an example on how to use the API. + Refer to the :ref:`user guide <df_output_transform>` for more details + and :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` for an + example on how to use the API. - `"default"`: Default output format of a transformer - `"pandas"`: DataFrame output @@ -360,6 +377,16 @@ def config_context( .. versionadded:: 1.3 + sparse_interface : str, default="spmatrix" + + The sparse interface used for every sparse object that scikit-learn produces, + e.g., function returns, estimator attributes, estimator properties, etc. + + - `"sparray"`: Return sparse as SciPy sparse array + - `"spmatrix"`: Return sparse as SciPy sparse matrix + + .. versionadded:: 1.8 + Yields ------ None. @@ -399,6 +426,7 @@ def config_context( transform_output=transform_output, enable_metadata_routing=enable_metadata_routing, skip_parameter_validation=skip_parameter_validation, + sparse_interface=sparse_interface, ) try: diff --git a/sklearn/_loss/__init__.py b/sklearn/_loss/__init__.py index e0269a93a49ca..a55958a4d1ced 100644 --- a/sklearn/_loss/__init__.py +++ b/sklearn/_loss/__init__.py @@ -9,8 +9,10 @@ from sklearn._loss.loss import ( AbsoluteError, HalfBinomialLoss, + HalfBinomialLossArrayAPI, HalfGammaLoss, HalfMultinomialLoss, + HalfMultinomialLossArrayAPI, HalfPoissonLoss, HalfSquaredError, HalfTweedieLoss, @@ -22,8 +24,10 @@ __all__ = [ "AbsoluteError", "HalfBinomialLoss", + "HalfBinomialLossArrayAPI", "HalfGammaLoss", "HalfMultinomialLoss", + "HalfMultinomialLossArrayAPI", "HalfPoissonLoss", "HalfSquaredError", "HalfTweedieLoss", diff --git a/sklearn/_loss/_loss.pyx.tp b/sklearn/_loss/_loss.pyx.tp index 44d5acd530a7f..0b594f47d9f30 100644 --- a/sklearn/_loss/_loss.pyx.tp +++ b/sklearn/_loss/_loss.pyx.tp @@ -1185,7 +1185,7 @@ cdef class CyHalfMultinomialLoss(): Raw prediction values (in link space). sample_weight : double Sample weight. - gradient_out : array of shape (n_classs,) + gradient_out : array of shape (n_classes,) A location into which the gradient is stored. Returns diff --git a/sklearn/_loss/link.py b/sklearn/_loss/link.py index 03677c8da6139..6a42e5508fad1 100644 --- a/sklearn/_loss/link.py +++ b/sklearn/_loss/link.py @@ -7,11 +7,11 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from math import ulp -import numpy as np -from scipy.special import expit, logit from scipy.stats import gmean +from sklearn.utils._array_api import _expit, _logit, get_namespace from sklearn.utils.extmath import softmax @@ -41,49 +41,50 @@ def includes(self, x): ------- result : bool """ + xp, _ = get_namespace(x) if self.low_inclusive: - low = np.greater_equal(x, self.low) + low = xp.greater_equal(x, self.low) else: - low = np.greater(x, self.low) + low = xp.greater(x, self.low) - if not np.all(low): + if not xp.all(low): return False if self.high_inclusive: - high = np.less_equal(x, self.high) + high = xp.less_equal(x, self.high) else: - high = np.less(x, self.high) + high = xp.less(x, self.high) # Note: np.all returns numpy.bool_ - return bool(np.all(high)) + return bool(xp.all(high)) -def _inclusive_low_high(interval, dtype=np.float64): +def _inclusive_low_high(interval): """Generate values low and high to be within the interval range. This is used in tests only. Returns ------- - low, high : tuple + low, high : tuple of floats The returned values low and high lie within the interval. """ - eps = 10 * np.finfo(dtype).eps - if interval.low == -np.inf: + eps = 10 * ulp(1) + if interval.low == -float("inf"): low = -1e10 elif interval.low < 0: low = interval.low * (1 - eps) + eps else: low = interval.low * (1 + eps) + eps - if interval.high == np.inf: + if interval.high == float("inf"): high = 1e10 elif interval.high < 0: high = interval.high * (1 + eps) - eps else: high = interval.high * (1 - eps) - eps - return low, high + return float(low), float(high) class BaseLink(ABC): @@ -105,11 +106,11 @@ class BaseLink(ABC): # Usually, raw_prediction may be any real number and y_pred is an open # interval. - # interval_raw_prediction = Interval(-np.inf, np.inf, False, False) - interval_y_pred = Interval(-np.inf, np.inf, False, False) + # interval_raw_prediction = Interval(-float("inf"), float("inf"), False, False) + interval_y_pred = Interval(-float("inf"), float("inf"), False, False) @abstractmethod - def link(self, y_pred, out=None): + def link(self, y_pred): """Compute the link function g(y_pred). The link function maps (predicted) target values to raw predictions, @@ -119,19 +120,15 @@ def link(self, y_pred, out=None): ---------- y_pred : array Predicted target values. - out : array - A location into which the result is stored. If provided, it must - have a shape that the inputs broadcast to. If not provided or None, - a freshly-allocated array is returned. Returns ------- - out : array + array Output array, element-wise link function. """ @abstractmethod - def inverse(self, raw_prediction, out=None): + def inverse(self, raw_prediction): """Compute the inverse link function h(raw_prediction). The inverse link function maps raw predictions to predicted target @@ -141,14 +138,10 @@ def inverse(self, raw_prediction, out=None): ---------- raw_prediction : array Raw prediction values (in link space). - out : array - A location into which the result is stored. If provided, it must - have a shape that the inputs broadcast to. If not provided or None, - a freshly-allocated array is returned. Returns ------- - out : array + array Output array, element-wise inverse link function. """ @@ -156,12 +149,8 @@ def inverse(self, raw_prediction, out=None): class IdentityLink(BaseLink): """The identity link function g(x)=x.""" - def link(self, y_pred, out=None): - if out is not None: - np.copyto(out, y_pred) - return out - else: - return y_pred + def link(self, y_pred): + return y_pred # TODO: Should we copy? inverse = link @@ -169,13 +158,15 @@ def link(self, y_pred, out=None): class LogLink(BaseLink): """The log link function g(x)=log(x).""" - interval_y_pred = Interval(0, np.inf, False, False) + interval_y_pred = Interval(0, float("inf"), False, False) - def link(self, y_pred, out=None): - return np.log(y_pred, out=out) + def link(self, y_pred): + xp, _ = get_namespace(y_pred) + return xp.log(y_pred) - def inverse(self, raw_prediction, out=None): - return np.exp(raw_prediction, out=out) + def inverse(self, raw_prediction): + xp, _ = get_namespace(raw_prediction) + return xp.exp(raw_prediction) class LogitLink(BaseLink): @@ -183,11 +174,11 @@ class LogitLink(BaseLink): interval_y_pred = Interval(0, 1, False, False) - def link(self, y_pred, out=None): - return logit(y_pred, out=out) + def link(self, y_pred): + return _logit(y_pred) - def inverse(self, raw_prediction, out=None): - return expit(raw_prediction, out=out) + def inverse(self, raw_prediction): + return _expit(raw_prediction) class HalfLogitLink(BaseLink): @@ -198,13 +189,11 @@ class HalfLogitLink(BaseLink): interval_y_pred = Interval(0, 1, False, False) - def link(self, y_pred, out=None): - out = logit(y_pred, out=out) - out *= 0.5 - return out + def link(self, y_pred): + return 0.5 * _logit(y_pred) - def inverse(self, raw_prediction, out=None): - return expit(2 * raw_prediction, out) + def inverse(self, raw_prediction): + return _expit(2 * raw_prediction) class MultinomialLogit(BaseLink): @@ -257,20 +246,17 @@ class MultinomialLogit(BaseLink): interval_y_pred = Interval(0, 1, False, False) def symmetrize_raw_prediction(self, raw_prediction): - return raw_prediction - np.mean(raw_prediction, axis=1)[:, np.newaxis] + xp, _ = get_namespace(raw_prediction) + return raw_prediction - xp.mean(raw_prediction, axis=1)[:, None] - def link(self, y_pred, out=None): + def link(self, y_pred): + xp, _ = get_namespace(y_pred) # geometric mean as reference category gm = gmean(y_pred, axis=1) - return np.log(y_pred / gm[:, np.newaxis], out=out) + return xp.log(y_pred / gm[:, None]) - def inverse(self, raw_prediction, out=None): - if out is None: - return softmax(raw_prediction, copy=True) - else: - np.copyto(out, raw_prediction) - softmax(out, copy=False) - return out + def inverse(self, raw_prediction): + return softmax(raw_prediction) _LINKS = { diff --git a/sklearn/_loss/loss.py b/sklearn/_loss/loss.py index 9cbaa5284d3a2..c2b53fa3d3cd4 100644 --- a/sklearn/_loss/loss.py +++ b/sklearn/_loss/loss.py @@ -45,7 +45,14 @@ LogLink, MultinomialLogit, ) +from sklearn.externals.array_api_extra import one_hot from sklearn.utils import check_scalar +from sklearn.utils._array_api import ( + _average, + _logsumexp, + _ravel, +) +from sklearn.utils.extmath import softmax from sklearn.utils.stats import _weighted_percentile @@ -89,15 +96,29 @@ class BaseLoss: Parameters ---------- + closs: CyLossFunction + For example, a CyLossFunction; hence the name "c"loss. + link : BaseLink sample_weight : {None, ndarray} If sample_weight is None, the hessian might be constant. n_classes : {None, int} The number of classes for classification, else None. + xp : module, default=None + Array namespace module. + device : device, default=None + A device object (see the "Device Support" section of the array API spec). Attributes ---------- closs: CyLossFunction + For example, a CyLossFunction; hence the name "c"loss. link : BaseLink + n_classes : {None, int} + The number of classes for classification, else None. + xp : module or None + Array namespace module. Ignored by the Cython implementation. + device : device or None + A device object. Ignored by the Cython implementation. interval_y_true : Interval Valid interval for y_true interval_y_pred : Interval @@ -105,9 +126,6 @@ class BaseLoss: differentiable : bool Indicates whether or not loss function is differentiable in raw_prediction everywhere. - need_update_leaves_values : bool - Indicates whether decision trees in gradient boosting need to uptade - leave values after having been fit to the (negative) gradients. approx_hessian : bool Indicates whether the hessian is approximated or exact. If, approximated, it should be larger or equal to the exact one. @@ -118,7 +136,7 @@ class BaseLoss: """ # For gradient boosted decision trees: - # This variable indicates whether the loss requires the leaves values to + # If differentiable = False for a loss, the leaves values are required to # be updated once the tree has been trained. The trees are trained to # predict a Newton-Raphson step (see grower._finalize_leaf()). But for # some losses (e.g. least absolute deviation) we need to adjust the tree @@ -127,15 +145,16 @@ class BaseLoss: # Gradient Boosting Machine by Friedman # (https://statweb.stanford.edu/~jhf/ftp/trebst.pdf) for the theory. differentiable = True - need_update_leaves_values = False is_multiclass = False - def __init__(self, closs, link, n_classes=None): + def __init__(self, closs, link, n_classes=None, xp=None, device=None): self.closs = closs self.link = link + self.n_classes = n_classes + self.xp = xp # simplifies array API versions + self.device = device # simplifies array API versions self.approx_hessian = False self.constant_hessian = False - self.n_classes = n_classes self.interval_y_true = Interval(-np.inf, np.inf, False, False) self.interval_y_pred = self.link.interval_y_pred @@ -526,10 +545,6 @@ def init_gradient_and_hessian(self, n_samples, dtype=np.float64, order="F"): return gradient, hessian -# Note: Naturally, we would inherit in the following order -# class HalfSquaredError(IdentityLink, CyHalfSquaredError, BaseLoss) -# But because of https://github.com/cython/cython/issues/4350 we -# set BaseLoss as the last one. This, of course, changes the MRO. class HalfSquaredError(BaseLoss): """Half squared error with identity link, for regression. @@ -548,8 +563,10 @@ class HalfSquaredError(BaseLoss): half the Normal distribution deviance. """ - def __init__(self, sample_weight=None): - super().__init__(closs=CyHalfSquaredError(), link=IdentityLink()) + def __init__(self, sample_weight=None, xp=None, device=None): + super().__init__( + closs=CyHalfSquaredError(), link=IdentityLink(), xp=xp, device=device + ) self.constant_hessian = sample_weight is None @@ -572,10 +589,11 @@ class AbsoluteError(BaseLoss): """ differentiable = False - need_update_leaves_values = True - def __init__(self, sample_weight=None): - super().__init__(closs=CyAbsoluteError(), link=IdentityLink()) + def __init__(self, sample_weight=None, xp=None, device=None): + super().__init__( + closs=CyAbsoluteError(), link=IdentityLink(), xp=xp, device=device + ) self.approx_hessian = True self.constant_hessian = sample_weight is None @@ -622,9 +640,8 @@ class PinballLoss(BaseLoss): """ differentiable = False - need_update_leaves_values = True - def __init__(self, sample_weight=None, quantile=0.5): + def __init__(self, sample_weight=None, quantile=0.5, xp=None, device=None): check_scalar( quantile, "quantile", @@ -636,6 +653,8 @@ def __init__(self, sample_weight=None, quantile=0.5): super().__init__( closs=CyPinballLoss(quantile=float(quantile)), link=IdentityLink(), + xp=xp, + device=device, ) self.approx_hessian = True self.constant_hessian = sample_weight is None @@ -689,9 +708,10 @@ class HuberLoss(BaseLoss): """ differentiable = False - need_update_leaves_values = True - def __init__(self, sample_weight=None, quantile=0.9, delta=0.5): + def __init__( + self, sample_weight=None, quantile=0.9, delta=0.5, xp=None, device=None + ): check_scalar( quantile, "quantile", @@ -704,6 +724,8 @@ def __init__(self, sample_weight=None, quantile=0.9, delta=0.5): super().__init__( closs=CyHuberLoss(delta=float(delta)), link=IdentityLink(), + xp=xp, + device=device, ) self.approx_hessian = True self.constant_hessian = False @@ -748,8 +770,10 @@ class HalfPoissonLoss(BaseLoss): We also skip the constant term `y_true_i * log(y_true_i) - y_true_i`. """ - def __init__(self, sample_weight=None): - super().__init__(closs=CyHalfPoissonLoss(), link=LogLink()) + def __init__(self, sample_weight=None, xp=None, device=None): + super().__init__( + closs=CyHalfPoissonLoss(), link=LogLink(), xp=xp, device=device + ) self.interval_y_true = Interval(0, np.inf, True, False) def constant_to_optimal_zero(self, y_true, sample_weight=None): @@ -779,8 +803,8 @@ class HalfGammaLoss(BaseLoss): We also skip the constant term `-log(y_true_i) - 1`. """ - def __init__(self, sample_weight=None): - super().__init__(closs=CyHalfGammaLoss(), link=LogLink()) + def __init__(self, sample_weight=None, xp=None, device=None): + super().__init__(closs=CyHalfGammaLoss(), link=LogLink(), xp=xp, device=device) self.interval_y_true = Interval(0, np.inf, False, False) def constant_to_optimal_zero(self, y_true, sample_weight=None): @@ -821,10 +845,12 @@ class HalfTweedieLoss(BaseLoss): the expectation. """ - def __init__(self, sample_weight=None, power=1.5): + def __init__(self, sample_weight=None, power=1.5, xp=None, device=None): super().__init__( closs=CyHalfTweedieLoss(power=float(power)), link=LogLink(), + xp=xp, + device=device, ) if self.closs.power <= 0: self.interval_y_true = Interval(-np.inf, np.inf, False, False) @@ -882,10 +908,12 @@ class HalfTweedieLossIdentity(BaseLoss): the expectation. """ - def __init__(self, sample_weight=None, power=1.5): + def __init__(self, sample_weight=None, power=1.5, xp=None, device=None): super().__init__( closs=CyHalfTweedieLossIdentity(power=float(power)), link=IdentityLink(), + xp=xp, + device=device, ) if self.closs.power <= 0: self.interval_y_true = Interval(-np.inf, np.inf, False, False) @@ -932,11 +960,13 @@ class HalfBinomialLoss(BaseLoss): loss(x_i) = - y_true_i * log(y_pred_i) - (1 - y_true_i) * log(1 - y_pred_i) """ - def __init__(self, sample_weight=None): + def __init__(self, sample_weight=None, xp=None, device=None): super().__init__( closs=CyHalfBinomialLoss(), link=LogitLink(), n_classes=2, + xp=xp, + device=device, ) self.interval_y_true = Interval(0, 1, True, True) @@ -1004,6 +1034,12 @@ class HalfMultinomialLoss(BaseLoss): n_classes : {None, int} The number of classes for classification, else None. + xp : module or None + Array namespace module. Ignored by the Cython implementation. + + device : device or None + A device object. Ignored by the Cython implementation. + References ---------- .. [1] :arxiv:`Simon, Noah, J. Friedman and T. Hastie. @@ -1014,14 +1050,22 @@ class HalfMultinomialLoss(BaseLoss): is_multiclass = True - def __init__(self, sample_weight=None, n_classes=3): + def __init__(self, sample_weight=None, n_classes=3, xp=None, device=None): super().__init__( closs=CyHalfMultinomialLoss(), link=MultinomialLogit(), n_classes=n_classes, + xp=xp, + device=device, ) self.interval_y_true = Interval(0, np.inf, True, False) self.interval_y_pred = Interval(0, 1, False, False) + # These instance variables are specifically used for the array API + # methods to store certain intermediate values in order to avoid + # having to recompute them repeatedly. + self.class_indexing_offsets = None + self.y_true_int = None + self.y_true_one_hot = None def in_y_true_range(self, y): """Return True if y is in the valid range of y_true. @@ -1165,11 +1209,13 @@ class ExponentialLoss(BaseLoss): + (1 - y_true_i) * sqrt(y_pred_i / (1 - y_pred_i)) """ - def __init__(self, sample_weight=None): + def __init__(self, sample_weight=None, xp=None, device=None): super().__init__( closs=CyExponentialLoss(), link=HalfLogitLink(), n_classes=2, + xp=xp, + device=device, ) self.interval_y_true = Interval(0, 1, True, True) @@ -1214,3 +1260,455 @@ def predict_proba(self, raw_prediction): "multinomial_loss": HalfMultinomialLoss, "exponential_loss": ExponentialLoss, } + + +class ArrayAPILossMixin: + """Mixin for loss classes that are compatible with the array API. + + Currently this mixin redefines methods: + - __call__(...) + - loss(...) + - loss_gradient(...) + - gradient(...) + + such that they work according to the array API specification. + It uses the attributes self.xp and self.device from BaseLoss and it assumes that + methods self._compute_loss and self._compute_gradient are implemented. + """ + + def __call__( + self, + y_true, + raw_prediction, + sample_weight=None, + n_threads=1, + ): + """Compute the weighted average loss for the array API losses. + + Parameters + ---------- + y_true : C-contiguous array of shape (n_samples,) + Observed, true target values. + raw_prediction : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Raw prediction values (in link space). + sample_weight : None or C-contiguous array of shape (n_samples,) + Sample weights. + n_threads : int, default=1 + Ignored by the array API implementation. + + Returns + ------- + loss : float + Mean or averaged loss function. + """ + loss_xp = self.loss( + y_true=y_true, raw_prediction=raw_prediction, sample_weight=None + ) + return float(_average(loss_xp, weights=sample_weight, xp=self.xp)) + + def loss( + self, + y_true, + raw_prediction, + sample_weight=None, + loss_out=None, + n_threads=1, + ): + """Compute the pointwise loss value for each input. + + Parameters + ---------- + y_true : C-contiguous array of shape (n_samples,) + Observed, true target values. + raw_prediction : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Raw prediction values (in link space). + sample_weight : None or C-contiguous array of shape (n_samples,) + Sample weights. + loss_out : None or C-contiguous array of shape (n_samples,) + Ignored by the array API implementation. + n_threads : int, default=1 + Ignored by the array API implementation. + + Returns + ------- + loss : array of shape (n_samples,) + Element-wise loss function. + """ + return self._compute_loss( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + ) + + def loss_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + loss_out=None, + gradient_out=None, + n_threads=1, + ): + """Compute loss and gradient w.r.t. raw_prediction for each input. + + Parameters + ---------- + y_true : C-contiguous array of shape (n_samples,) + Observed, true target values. + raw_prediction : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Raw prediction values (in link space). + sample_weight : None or C-contiguous array of shape (n_samples,) + Sample weights. + loss_out : None or C-contiguous array of shape (n_samples,) + Ignored by the array API implementation. + gradient_out : None or C-contiguous array of shape (n_samples,) or array \ + of shape (n_samples, n_classes) + Ignored by the array API implementation. + n_threads : int, default=1 + Ignored by the array API implementation. + + Returns + ------- + loss : array of shape (n_samples,) + Element-wise loss function. + + gradient : array of shape (n_samples,) or (n_samples, n_classes) + Element-wise gradients. + """ + loss = self._compute_loss( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + ) + gradient = self._compute_gradient( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + ) + return loss, gradient + + def gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + gradient_out=None, + n_threads=1, + ): + """Compute gradient of loss w.r.t raw_prediction for each input. + + Parameters + ---------- + y_true : C-contiguous array of shape (n_samples,) + Observed, true target values. + raw_prediction : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Raw prediction values (in link space). + sample_weight : None or C-contiguous array of shape (n_samples,) + Sample weights. + gradient_out : None or C-contiguous array of shape (n_samples,) or array \ + of shape (n_samples, n_classes) + Ignored by the array API implementation. + n_threads : int, default=1 + Ignored by the array API implementation. + + Returns + ------- + gradient : array of shape (n_samples,) or (n_samples, n_classes) + Element-wise gradients. + """ + return self._compute_gradient( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + ) + + +def _log1pexp(raw_prediction, raw_prediction_exp, xp): + """Numerically stable version of log(1 + exp(x)) that is compatible with + the array API. + + Parameters + ---------- + raw_prediction : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Raw prediction values (in link space). + raw_prediction_exp : C-contiguous array of shape (n_samples,) or array of \ + shape (n_samples, n_classes) + Exponential of the raw prediction values. + xp : module, default=None + Array namespace module. + + Returns + ------- + log1pexp : float + Numerically stable value for log(1 + exp(raw_prediction)). + """ + + # The "magic constants" used here are different for float64 and float32 + # dtypes. For float64, we simply use the values that are present in the + # Cython loss module and the details can be found there. For float32, + # we use the `scipy.optimize.brentq` with `xtol=1e-7`to deduce the valid + # cutoff for each of the different cases that are handled. The trick is + # to define for each special case a function that subtracts + # `np.log1p(np.exp(x, dtype=np.float32))` from the special case under + # consideration. Additionally the resulting values that are very close to + # zero are set to -1. + # Consider as an example the case `x + exp(-x)`: + # + # def x_plus_exp_negx(x): + # x = np.float32(x) + # val = ( + # x + np.exp(-x, dtype=np.float32)) + # - np.log1p(np.exp(x, dtype=np.float32) + # ) + # if np.isclose(val, 0, atol=1e-16): + # val = -1 + # return val + # + # + # x_cutoff = brentq(x_plus_exp_negx, 1, 20, xtol=1e-7) + # + # The bounds used in the `brentq` function for each case respectively are + # acquired through the referenced paper: + # https://cran.r-project.org/web/packages/Rmpfr/vignettes/log1mexp-note.pdf + # Compared to the reference, we have the additional case distinction x <= -2 + # in the float64 case. Since we don't have the reference bounds for this, + # we estimate the value as approximately x <= -1 for float32. + constants = ( + [-37, -2, 18, 33.3] + if raw_prediction.dtype == xp.float64 + else [-17, -1, 9, 14.6] + ) + return xp.where( + raw_prediction <= constants[0], + raw_prediction_exp, + xp.where( + raw_prediction <= constants[1], + xp.log1p(raw_prediction_exp), + xp.where( + raw_prediction <= constants[2], + xp.log(1.0 + raw_prediction_exp), + xp.where( + raw_prediction <= constants[3], + raw_prediction + 1 / raw_prediction_exp, + raw_prediction, + ), + ), + ), + ) + + +class HalfBinomialLossArrayAPI(ArrayAPILossMixin, HalfBinomialLoss): + """A version of the HalfBinomialLoss that is compatible with the array API.""" + + def loss_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + loss_out=None, + gradient_out=None, + n_threads=1, + ): + raw_prediction_exp = self.xp.exp(raw_prediction) + loss = self._compute_loss( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + raw_prediction_exp=raw_prediction_exp, + ) + gradient = self._compute_gradient( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + raw_prediction_exp=raw_prediction_exp, + ) + return loss, gradient + + def _compute_loss( + self, + y_true, + raw_prediction, + sample_weight=None, + raw_prediction_exp=None, + ): + if raw_prediction_exp is None: + raw_prediction_exp = self.xp.exp(raw_prediction) + log1pexp = _log1pexp( + raw_prediction=raw_prediction, + raw_prediction_exp=raw_prediction_exp, + xp=self.xp, + ) + loss = log1pexp - y_true * raw_prediction + if sample_weight is not None: + loss *= sample_weight + return loss + + def _compute_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + raw_prediction_exp=None, + ): + xp = self.xp + if raw_prediction_exp is None: + raw_prediction_exp = xp.exp(raw_prediction) + neg_raw_prediction_exp = 1 / raw_prediction_exp + grad = xp.where( + raw_prediction > (-37 if raw_prediction.dtype == xp.float64 else -17), + ((1 - y_true) - y_true * neg_raw_prediction_exp) + / (1 + neg_raw_prediction_exp), + raw_prediction_exp - y_true, + ) + if sample_weight is not None: + grad *= sample_weight + return grad + + +class HalfMultinomialLossArrayAPI(ArrayAPILossMixin, HalfMultinomialLoss): + """A version of the HalfMultinomialLoss that is compatible with the array API. + + Parameters + ---------- + sample_weight : {None, ndarray} + If sample_weight is None, the hessian might be constant. + + n_classes : {None, int} + The number of classes for classification, else None. + + xp : module or None + Array namespace module. + + device : device or None + A device object. + """ + + def __init__(self, sample_weight=None, n_classes=3, xp=None, device=None): + super().__init__(n_classes=n_classes, xp=xp, device=device) + # These instance variables are specifically to store certain + # intermediate values in order to avoid having to recompute + # them repeatedly. + + # Used when computing the multinomial loss. + self.class_indexing_offsets = None + self.y_true_int = None + + # Used when computing the gradient. + self.y_true_one_hot = None + + def _compute_loss( + self, + y_true, + raw_prediction, + sample_weight=None, + ): + xp = self.xp + device = self.device + log_sum_exp = _logsumexp(raw_prediction, axis=1, xp=xp) + if self.y_true_int is None: + self.y_true_int = xp.asarray(y_true, dtype=xp.int64, device=device) + + if self.class_indexing_offsets is None: + self.class_indexing_offsets = ( + xp.arange(y_true.shape[0], device=device) * self.n_classes + ) + true_label_probs = xp.take( + _ravel(raw_prediction), self.y_true_int + self.class_indexing_offsets + ) + loss = log_sum_exp - true_label_probs + if sample_weight is not None: + loss *= sample_weight + return loss + + def _compute_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + ): + xp = self.xp + device_ = self.device + if self.y_true_one_hot is None: + if self.y_true_int is None: + self.y_true_int = xp.asarray(y_true, dtype=xp.int64, device=device_) + + self.y_true_one_hot = one_hot( + self.y_true_int, + num_classes=self.n_classes, + dtype=raw_prediction.dtype, + ) + grad = softmax(raw_prediction) + # TODO: once incremental assignment for multiple integer array + # indices is part of a released version of the array API + # spec and array-api-strict has been updated accordingly, + # we can further avoid allocating a big (n_samples, n_classes) + # array for the one-hot encoded y_true and instead use one of the + # following (the latter should allow for JAX support): + # grad[xp.arange(y_true.shape[0]), y_true_int] -= 1 + # xpx.at(grad)[xp.arange(y_true.shape[0]), y_true_int].add(-1) + # See: https://github.com/data-apis/array-api/issues/864 + grad -= self.y_true_one_hot + if sample_weight is not None: + grad *= sample_weight[:, None] + return grad + + +class HalfPoissonLossArrayAPI(ArrayAPILossMixin, HalfPoissonLoss): + """A version of the HalfPoissonLoss that is compatible with the array API.""" + + def loss_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + loss_out=None, + gradient_out=None, + n_threads=1, + ): + raw_prediction_exp = self.xp.exp(raw_prediction) + loss = self._compute_loss( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + raw_prediction_exp=raw_prediction_exp, + ) + gradient = self._compute_gradient( + y_true=y_true, + raw_prediction=raw_prediction, + sample_weight=sample_weight, + raw_prediction_exp=raw_prediction_exp, + ) + return loss, gradient + + def _compute_loss( + self, + y_true, + raw_prediction, + sample_weight=None, + raw_prediction_exp=None, + ): + if raw_prediction_exp is None: + raw_prediction_exp = self.xp.exp(raw_prediction) + loss = raw_prediction_exp - y_true * raw_prediction + if sample_weight is not None: + loss *= sample_weight + return loss + + def _compute_gradient( + self, + y_true, + raw_prediction, + sample_weight=None, + raw_prediction_exp=None, + ): + if raw_prediction_exp is None: + raw_prediction_exp = self.xp.exp(raw_prediction) + grad = raw_prediction_exp - y_true + if sample_weight is not None: + grad *= sample_weight + return grad diff --git a/sklearn/_loss/meson.build b/sklearn/_loss/meson.build index a4b3425a21cd2..a5fefd793ca2e 100644 --- a/sklearn/_loss/meson.build +++ b/sklearn/_loss/meson.build @@ -1,6 +1,10 @@ # .pyx is generated, so this is needed to make Cython compilation work +# We add sklearn_root_cython_tree and __init__.py so Cython can detect the +# package hierarchy and set the correct __module__ on extension types. _loss_cython_tree = [ - fs.copyfile('_loss.pxd') + sklearn_root_cython_tree, + fs.copyfile('__init__.py'), + fs.copyfile('_loss.pxd'), ] _loss_pyx = custom_target( @@ -8,7 +12,7 @@ _loss_pyx = custom_target( output: '_loss.pyx', input: '_loss.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: _loss_cython_tree, diff --git a/sklearn/_loss/tests/test_link.py b/sklearn/_loss/tests/test_link.py index e5a665f8d48ac..1cff37702e859 100644 --- a/sklearn/_loss/tests/test_link.py +++ b/sklearn/_loss/tests/test_link.py @@ -1,7 +1,8 @@ import numpy as np import pytest -from numpy.testing import assert_allclose, assert_array_equal +from numpy.testing import assert_allclose +from sklearn import config_context from sklearn._loss.link import ( _LINKS, HalfLogitLink, @@ -9,6 +10,12 @@ MultinomialLogit, _inclusive_low_high, ) +from sklearn.utils._array_api import ( + _atol_for_type, + move_to, + yield_namespace_device_dtype_combinations, +) +from sklearn.utils._testing import _array_api_for_tests LINK_FUNCTIONS = list(_LINKS.values()) @@ -28,10 +35,10 @@ def test_interval_raises(): Interval(0, 1, False, True), Interval(0, 1, True, False), Interval(0, 1, True, True), - Interval(-np.inf, np.inf, False, False), - Interval(-np.inf, np.inf, False, True), - Interval(-np.inf, np.inf, True, False), - Interval(-np.inf, np.inf, True, True), + Interval(-float("inf"), float("inf"), False, False), + Interval(-float("inf"), float("inf"), False, True), + Interval(-float("inf"), float("inf"), True, False), + Interval(-float("inf"), float("inf"), True, True), Interval(-10, -1, False, False), Interval(-10, -1, False, True), Interval(-10, -1, True, False), @@ -39,10 +46,10 @@ def test_interval_raises(): ], ) def test_is_in_range(interval): - # make sure low and high are always within the interval, used for linspace + """Test that low and high are always within the interval used for linspace.""" low, high = _inclusive_low_high(interval) - x = np.linspace(low, high, num=10) + assert interval.includes(x) # x contains lower bound @@ -59,7 +66,7 @@ def test_is_in_range(interval): @pytest.mark.parametrize("link", LINK_FUNCTIONS) def test_link_inverse_identity(link, global_random_seed): - # Test that link of inverse gives identity. + """Test that link of inverse gives identity.""" rng = np.random.RandomState(global_random_seed) link = link() n_samples, n_classes = 100, None @@ -81,31 +88,52 @@ def test_link_inverse_identity(link, global_random_seed): assert_allclose(link.inverse(link.link(y_pred)), y_pred) +@pytest.mark.parametrize( + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) @pytest.mark.parametrize("link", LINK_FUNCTIONS) -def test_link_out_argument(link): - # Test that out argument gets assigned the result. - rng = np.random.RandomState(42) +def test_link_inverse_array_api( + namespace, device_name, dtype_name, link, global_random_seed +): + """Test that link and inverse link give same result for array API inputs.""" + rng = np.random.RandomState(global_random_seed) link = link() n_samples, n_classes = 100, None + # The values for `raw_prediction` are limited from -20 to 20 because in the + # class `LogitLink` the term `expit(x)` comes very close to 1 for large + # positive x and therefore loses precision. if link.is_multiclass: n_classes = 10 - raw_prediction = rng.normal(loc=0, scale=10, size=(n_samples, n_classes)) + raw_prediction = rng.uniform(low=-20, high=20, size=(n_samples, n_classes)) if isinstance(link, MultinomialLogit): raw_prediction = link.symmetrize_raw_prediction(raw_prediction) - else: - # So far, the valid interval of raw_prediction is (-inf, inf) and - # we do not need to distinguish. + elif isinstance(link, HalfLogitLink): raw_prediction = rng.uniform(low=-10, high=10, size=(n_samples)) + else: + raw_prediction = rng.uniform(low=-20, high=20, size=(n_samples)) - y_pred = link.inverse(raw_prediction, out=None) - out = np.empty_like(raw_prediction) - y_pred_2 = link.inverse(raw_prediction, out=out) - assert_allclose(y_pred, out) - assert_array_equal(out, y_pred_2) - assert np.shares_memory(out, y_pred_2) - - out = np.empty_like(y_pred) - raw_prediction_2 = link.link(y_pred, out=out) - assert_allclose(raw_prediction, out) - assert_array_equal(out, raw_prediction_2) - assert np.shares_memory(out, raw_prediction_2) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + if dtype_name != "float64": + raw_prediction *= 0.5 # avoid overflow + rtol = 1e-3 if n_classes else 1e-4 + else: + rtol = 1e-8 + atol = _atol_for_type(dtype_name) + + with config_context(array_api_dispatch=True): + raw_prediction_xp = xp.asarray(raw_prediction.astype(dtype_name), device=device) + assert_allclose( + move_to(link.inverse(raw_prediction_xp), xp=np, device="cpu"), + link.inverse(raw_prediction), + rtol=rtol, + ) + + y_pred = link.inverse(raw_prediction) + y_pred_xp = xp.asarray(y_pred.astype(dtype_name), device=device) + assert_allclose( + move_to(link.link(y_pred_xp), xp=np, device="cpu"), + link.link(y_pred), + rtol=rtol, + atol=atol, + ) diff --git a/sklearn/_loss/tests/test_loss.py b/sklearn/_loss/tests/test_loss.py index 4fea325729023..de4065dcc9a01 100644 --- a/sklearn/_loss/tests/test_loss.py +++ b/sklearn/_loss/tests/test_loss.py @@ -1,3 +1,4 @@ +import inspect import pickle import numpy as np @@ -12,23 +13,41 @@ ) from scipy.special import logsumexp +from sklearn import config_context +from sklearn._loss import _loss as _loss_module from sklearn._loss.link import IdentityLink, _inclusive_low_high from sklearn._loss.loss import ( _LOSSES, AbsoluteError, BaseLoss, HalfBinomialLoss, + HalfBinomialLossArrayAPI, HalfGammaLoss, HalfMultinomialLoss, + HalfMultinomialLossArrayAPI, HalfPoissonLoss, + HalfPoissonLossArrayAPI, HalfSquaredError, HalfTweedieLoss, HalfTweedieLossIdentity, HuberLoss, PinballLoss, + _log1pexp, ) from sklearn.utils import assert_all_finite -from sklearn.utils._testing import create_memmap_backed_data, skip_if_32bit +from sklearn.utils._array_api import ( + _atol_for_type, + move_to, + yield_namespace_device_dtype_combinations, +) +from sklearn.utils._array_api import ( + device as array_api_device, +) +from sklearn.utils._testing import ( + _array_api_for_tests, + create_memmap_backed_data, + skip_if_32bit, +) ALL_LOSSES = list(_LOSSES.values()) @@ -1356,3 +1375,157 @@ def test_tweedie_log_identity_consistency(p): assert_allclose( hessian_log, y_pred * gradient_identity + y_pred**2 * hessian_identity ) + + +@pytest.mark.parametrize( + "array_api_loss_class, loss_class", + [ + (HalfBinomialLossArrayAPI, HalfBinomialLoss), + (HalfMultinomialLossArrayAPI, HalfMultinomialLoss), + (HalfPoissonLossArrayAPI, HalfPoissonLoss), + ], + ids=["HalfBinomialLoss", "HalfMultinomialLoss", "HalfPoissonLoss"], +) +@pytest.mark.parametrize( + "method_name", ["__call__", "gradient", "loss", "loss_gradient"] +) +@pytest.mark.parametrize("use_sample_weight", [False, True]) +@pytest.mark.parametrize( + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_loss_array_api( + array_api_loss_class, + loss_class, + method_name, + use_sample_weight, + namespace, + device_name, + dtype_name, +): + def _assert_array_api_result( + result_xp, result_np, raw_prediction_xp, xp, rtol, atol + ): + assert_allclose( + move_to(result_xp, xp=np, device="cpu"), result_np, rtol=rtol, atol=atol + ) + assert result_xp.dtype == raw_prediction_xp.dtype + assert array_api_device(result_xp) == array_api_device(raw_prediction_xp) + + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + atol = _atol_for_type(dtype_name) + rtol = 1e-6 if dtype_name == "float32" else 1e-11 + random_seed = 42 + n_samples = 100 + array_api_loss_instance = array_api_loss_class(xp=xp, device=device) + loss_instance = loss_class() + y_true, raw_prediction = random_y_true_raw_prediction( + loss=loss_instance, + n_samples=n_samples, + y_bound=(-100, 100), + raw_bound=(-50, 50), + seed=random_seed, + ) + y_true = y_true.astype(dtype_name) + raw_prediction = raw_prediction.astype(dtype_name) + y_true_xp = xp.asarray(y_true, device=device) + raw_prediction_xp = xp.asarray(raw_prediction, device=device) + if use_sample_weight: + rng = np.random.RandomState(random_seed) + sample_weight_np = ( + rng.uniform(-1, 5, size=n_samples).clip(0, None).astype(dtype_name) + ) + sample_weight_xp = xp.asarray(sample_weight_np, device=device) + else: + sample_weight_np = None + sample_weight_xp = None + + method = getattr(loss_instance, method_name) + array_api_method = getattr(array_api_loss_instance, method_name) + result_np = method( + y_true=y_true, raw_prediction=raw_prediction, sample_weight=sample_weight_np + ) + with config_context(array_api_dispatch=True): + result_xp = array_api_method( + y_true=y_true_xp, + raw_prediction=raw_prediction_xp, + sample_weight=sample_weight_xp, + ) + if ( + method_name == "__call__" + ): # The `__call__` method just returns a float scalar + assert np.isclose(result_xp, result_np) + else: + if isinstance(result_xp, tuple): + for res_xp, res_np in zip(result_xp, result_np): + _assert_array_api_result( + result_xp=res_xp, + result_np=res_np, + raw_prediction_xp=raw_prediction_xp, + xp=xp, + rtol=rtol, + atol=atol, + ) + else: + _assert_array_api_result( + result_xp=result_xp, + result_np=result_np, + raw_prediction_xp=raw_prediction_xp, + xp=xp, + rtol=rtol, + atol=atol, + ) + + +@pytest.mark.parametrize( + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_log1pexp(namespace, device_name, dtype_name): + mpmath = pytest.importorskip("mpmath") + mpmath.mp.prec = 100 # Significantly more precise reference than float64. + values_to_test = np.linspace(-40, 40, 300) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + for value in values_to_test: + if dtype_name == "float32": + x = xp.asarray(value, dtype=xp.float32, device=device) + else: + x = xp.asarray(value, dtype=xp.float64, device=device) + + result_xp = float( + _log1pexp( + raw_prediction=x, + raw_prediction_exp=xp.exp(x), + xp=xp, + ) + ) + result_mpmath = float(mpmath.log(1 + mpmath.exp(value))) + assert result_mpmath > 0 + # Check that the relative error is within float32 or float64 precision. + assert result_xp == pytest.approx( + result_mpmath, + rel=1e-5 if dtype_name == "float32" else 1e-12, + abs=0, + ) + + +def test_cy_loss_classes_module(): + """Check that Cython extension types in _loss have the correct __module__. + + When _loss_cython_tree in meson.build is missing __init__.py files, Cython + can not detect the package hierarchy and set __module__ = '_loss' instead + of 'sklearn._loss._loss' on all Cy* extension types, e.g. + `CyHalfMultinomialLoss`. This breaks downstream tools like skops that rely + on __module__ for serialization. + """ + cy_classes = [ + obj + for name, obj in inspect.getmembers(_loss_module, inspect.isclass) + if name.startswith("Cy") + ] + assert len(cy_classes) > 0, "No Cy* classes found in sklearn._loss._loss" + for cls in cy_classes: + assert cls.__module__ == "sklearn._loss._loss", ( + f"{cls.__name__}.__module__ == {cls.__module__!r}, " + f"expected 'sklearn._loss._loss'" + ) diff --git a/sklearn/_min_dependencies.py b/sklearn/_min_dependencies.py index 82475f039e32b..08687d7d7a742 100644 --- a/sklearn/_min_dependencies.py +++ b/sklearn/_min_dependencies.py @@ -10,6 +10,7 @@ NUMPY_MIN_VERSION = "1.24.1" SCIPY_MIN_VERSION = "1.10.0" JOBLIB_MIN_VERSION = "1.3.0" +NARWHALS_MIN_VERSION = "2.0.1" THREADPOOLCTL_MIN_VERSION = "3.2.0" PYTEST_MIN_VERSION = "7.1.2" CYTHON_MIN_VERSION = "3.1.2" @@ -22,6 +23,7 @@ "numpy": (NUMPY_MIN_VERSION, "build, install"), "scipy": (SCIPY_MIN_VERSION, "build, install"), "joblib": (JOBLIB_MIN_VERSION, "install"), + "narwhals": (NARWHALS_MIN_VERSION, "install"), "threadpoolctl": (THREADPOOLCTL_MIN_VERSION, "install"), "cython": (CYTHON_MIN_VERSION, "build"), "meson-python": ("0.17.1", "build"), @@ -32,11 +34,11 @@ "memory_profiler": ("0.57.0", "benchmark, docs"), "pytest": (PYTEST_MIN_VERSION, "tests"), "pytest-cov": ("2.9.0", "tests"), - "ruff": ("0.11.7", "tests"), + "ruff": ("0.12.2", "tests"), "mypy": ("1.15", "tests"), "pyamg": ("5.0.0", "tests"), "polars": ("0.20.30", "docs, tests"), - "pyarrow": ("12.0.0", "tests"), + "pyarrow": ("13.0.0", "tests"), "sphinx": ("7.3.7", "docs"), "sphinx-copybutton": ("0.5.2", "docs"), "sphinx-gallery": ("0.17.1", "docs"), diff --git a/sklearn/base.py b/sklearn/base.py index b897e5c8f3ea8..7adf1401b7172 100644 --- a/sklearn/base.py +++ b/sklearn/base.py @@ -6,6 +6,7 @@ import copy import functools import inspect +import numbers import platform import re import warnings @@ -21,6 +22,7 @@ from sklearn.utils._param_validation import validate_parameter_constraints from sklearn.utils._repr_html.base import ReprHTMLMixin, _HTMLDocumentationLinkMixin from sklearn.utils._repr_html.estimator import estimator_html_repr +from sklearn.utils._repr_html.fitted_attributes import AttrsDict from sklearn.utils._repr_html.params import ParamsDict from sklearn.utils._set_output import _SetOutputMixin from sklearn.utils._tags import ( @@ -209,9 +211,8 @@ def __dir__(self): @classmethod def _get_param_names(cls): """Get parameter names for the estimator""" - # fetch the constructor or the original constructor before - # deprecation wrapping if any - init = getattr(cls.__init__, "deprecated_original", cls.__init__) + # fetch the constructor + init = cls.__init__ if init is object.__init__: # No explicit constructor to introspect return [] @@ -285,8 +286,7 @@ def _get_params_html(self, deep=True, doc_link=""): """ out = self.get_params(deep=deep) - init_func = getattr(self.__init__, "deprecated_original", self.__init__) - init_default_params = inspect.signature(init_func).parameters + init_default_params = inspect.signature(self.__init__).parameters init_default_params = { name: param.default for name, param in init_default_params.items() } @@ -318,19 +318,68 @@ def is_non_default(param_name, param_value): return False - # reorder the parameters from `self.get_params` using the `__init__` - # signature - remaining_params = [name for name in out if name not in init_default_params] - ordered_out = {name: out[name] for name in init_default_params if name in out} - ordered_out.update({name: out[name] for name in remaining_params}) - - non_default_ls = tuple( - [name for name, value in ordered_out.items() if is_non_default(name, value)] + # Sort parameters so non-default parameters are shown first + unordered_params = { + name: out[name] for name in init_default_params if name in out + } + unordered_params.update( + { + name: value + for name, value in out.items() + if name not in init_default_params + } ) + non_default_params, default_params = [], [] + for name, value in unordered_params.items(): + if is_non_default(name, value): + non_default_params.append(name) + else: + default_params.append(name) + + params = {name: out[name] for name in non_default_params + default_params} + return ParamsDict( - params=ordered_out, - non_default=non_default_ls, + params=params, + non_default=tuple(non_default_params), + estimator_class=self.__class__, + doc_link=doc_link, + ) + + def _get_fitted_attr_html(self, doc_link=""): + """Get fitted attributes of the estimator.""" + + fitted_attr = {} + for name, value in inspect.getmembers(self): + # We display up to 100 fitted attributes + if len(fitted_attr) > 100: + fitted_attr["..."] = { + "type_name": "...", + "value": "", + } + break + if name.startswith("_") or not name.endswith("_"): + continue + if ( + hasattr(value, "shape") + and hasattr(value, "dtype") + and not isinstance(value, numbers.Number) + ): + # array-like attribute with shape and dtype + fitted_attr[name] = { + "type_name": type(value).__name__, + "shape": value.shape, + "dtype": value.dtype, + "value": value, + } + else: + fitted_attr[name] = { + "type_name": type(value).__name__, + "value": value, + } + + return AttrsDict( + fitted_attrs=fitted_attr, estimator_class=self.__class__, doc_link=doc_link, ) @@ -1170,7 +1219,7 @@ def __sklearn_tags__(self): class _UnstableArchMixin: - """Mark estimators that are non-determinstic on 32bit or PowerPC""" + """Mark estimators that are non-deterministic on 32bit or PowerPC""" def __sklearn_tags__(self): tags = super().__sklearn_tags__() diff --git a/sklearn/calibration.py b/sklearn/calibration.py index d6c206f8870b2..f1b142720329c 100644 --- a/sklearn/calibration.py +++ b/sklearn/calibration.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause import warnings -from functools import partial from inspect import signature from math import log from numbers import Integral, Real @@ -13,7 +12,11 @@ from scipy.optimize import minimize, minimize_scalar from scipy.special import expit -from sklearn._loss import HalfBinomialLoss, HalfMultinomialLoss +from sklearn._loss import ( + HalfBinomialLoss, + HalfMultinomialLoss, + HalfMultinomialLossArrayAPI, +) from sklearn.base import ( BaseEstimator, ClassifierMixin, @@ -30,8 +33,6 @@ from sklearn.svm import LinearSVC from sklearn.utils import Bunch, _safe_indexing, column_or_1d, get_tags, indexable from sklearn.utils._array_api import ( - _convert_to_numpy, - _half_multinomial_loss, _is_numpy_namespace, get_namespace, get_namespace_and_device, @@ -142,9 +143,9 @@ class CalibratedClassifierCV(ClassifierMixin, MetaEstimatorMixin, BaseEstimator) Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if ``y`` is binary or multiclass, :class:`~sklearn.model_selection.StratifiedKFold` is used. If ``y`` is @@ -554,7 +555,7 @@ def predict(self, X): check_is_fitted(self) class_indices = xp.argmax(self.predict_proba(X), axis=1) if isinstance(self.classes_[0], str): - class_indices = _convert_to_numpy(class_indices, xp=xp) + class_indices = move_to(class_indices, xp=np, device="cpu") return self.classes_[class_indices] @@ -1112,10 +1113,14 @@ def fit(self, X, y, sample_weight=None): if sample_weight is not None: sample_weight = _check_sample_weight(sample_weight, labels, dtype=dtype_) - if _is_numpy_namespace(xp): - multinomial_loss = HalfMultinomialLoss(n_classes=logits.shape[1]) - else: - multinomial_loss = partial(_half_multinomial_loss, xp=xp) + is_numpy_namespace = _is_numpy_namespace(xp) + multinomial_loss = ( + HalfMultinomialLoss(n_classes=logits.shape[1]) + if is_numpy_namespace + else HalfMultinomialLossArrayAPI( + n_classes=logits.shape[1], xp=xp, device=xp_device + ) + ) def log_loss(log_beta=0.0): """Compute the log loss as a parameter of the inverse temperature @@ -1147,7 +1152,11 @@ def log_loss(log_beta=0.0): # This can cause dtype mismatch errors downstream (e.g., buffer dtype). log_beta = xp.asarray(log_beta, dtype=dtype_, device=xp_device) raw_prediction = xp.exp(log_beta) * logits - return multinomial_loss(labels, raw_prediction, sample_weight) + return multinomial_loss( + labels, + raw_prediction, + sample_weight, + ) xatol = 64 * xp.finfo(dtype_).eps log_beta_minimizer = minimize_scalar( @@ -1320,9 +1329,9 @@ def calibration_curve( binids = np.searchsorted(bins[1:-1], y_prob) - bin_sums = np.bincount(binids, weights=y_prob, minlength=len(bins)) - bin_true = np.bincount(binids, weights=y_true, minlength=len(bins)) - bin_total = np.bincount(binids, minlength=len(bins)) + bin_sums = np.bincount(binids, weights=y_prob, minlength=n_bins) + bin_true = np.bincount(binids, weights=y_true, minlength=n_bins) + bin_total = np.bincount(binids, minlength=n_bins) nonzero = bin_total != 0 prob_true = bin_true[nonzero] / bin_total[nonzero] diff --git a/sklearn/cluster/_agglomerative.py b/sklearn/cluster/_agglomerative.py index 776cb8ea2a712..2655ca6c6a4dc 100644 --- a/sklearn/cluster/_agglomerative.py +++ b/sklearn/cluster/_agglomerative.py @@ -38,6 +38,7 @@ StrOptions, validate_params, ) +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.graph import _fix_connected_components from sklearn.utils.validation import check_memory, validate_data @@ -92,7 +93,7 @@ def _fix_connectivity(X, connectivity, affinity): # Convert connectivity matrix to LIL if not sparse.issparse(connectivity): - connectivity = sparse.lil_matrix(connectivity) + connectivity = sparse.lil_array(connectivity) # `connectivity` is a sparse matrix at this point if connectivity.format != "lil": @@ -118,7 +119,7 @@ def _fix_connectivity(X, connectivity, affinity): mode="connectivity", ) - return connectivity, n_connected_components + return _align_api_if_sparse(connectivity), n_connected_components def _single_linkage_tree( @@ -795,7 +796,7 @@ class AgglomerativeClustering(ClusterMixin, BaseEstimator): metric : str or callable, default="euclidean" Metric used to compute the linkage. Can be "euclidean", "l1", "l2", "manhattan", "cosine", or "precomputed". If linkage is "ward", only - "euclidean" is accepted. If "precomputed", a distance matrix is needed + "euclidean" and "l2" are accepted. If "precomputed", a distance matrix is needed as input for the fit method. If connectivity is None, linkage is "single" and affinity is not "precomputed" any valid pairwise distance metric can be assigned. @@ -1020,10 +1021,10 @@ def _fit(self, X): "compute_full_tree must be True if distance_threshold is set." ) - if self.linkage == "ward" and self.metric != "euclidean": + if self.linkage == "ward" and self.metric not in ("euclidean", "l2"): raise ValueError( f"{self.metric} was provided as metric. Ward can only " - "work with euclidean distances." + "work with euclidean distances (i.e. 'euclidean' and 'l2')." ) tree_builder = _TREE_BUILDERS[self.linkage] @@ -1141,7 +1142,7 @@ class FeatureAgglomeration( metric : str or callable, default="euclidean" Metric used to compute the linkage. Can be "euclidean", "l1", "l2", "manhattan", "cosine", or "precomputed". If linkage is "ward", only - "euclidean" is accepted. If "precomputed", a distance matrix is needed + "euclidean" and "l2" are accepted. If "precomputed", a distance matrix is needed as input for the fit method. .. versionadded:: 1.2 diff --git a/sklearn/cluster/_bicluster.py b/sklearn/cluster/_bicluster.py index 83ad3fef2519a..38af90e513e8a 100644 --- a/sklearn/cluster/_bicluster.py +++ b/sklearn/cluster/_bicluster.py @@ -8,7 +8,7 @@ import numpy as np from scipy.linalg import norm -from scipy.sparse import dia_matrix, issparse +from scipy.sparse import dia_array, issparse from scipy.sparse.linalg import eigsh, svds from sklearn.base import BaseEstimator, BiclusterMixin, _fit_context @@ -34,8 +34,8 @@ def _scale_normalize(X): col_diag = np.where(np.isnan(col_diag), 0, col_diag) if issparse(X): n_rows, n_cols = X.shape - r = dia_matrix((row_diag, [0]), shape=(n_rows, n_rows)) - c = dia_matrix((col_diag, [0]), shape=(n_cols, n_cols)) + r = dia_array((row_diag, [0]), shape=(n_rows, n_rows)) + c = dia_array((col_diag, [0]), shape=(n_cols, n_cols)) an = r @ X @ c else: an = row_diag[:, np.newaxis] * X * col_diag diff --git a/sklearn/cluster/_bisect_k_means.py b/sklearn/cluster/_bisect_k_means.py index 3443d6d2511c4..9cbc2d31aa240 100644 --- a/sklearn/cluster/_bisect_k_means.py +++ b/sklearn/cluster/_bisect_k_means.py @@ -94,7 +94,7 @@ class BisectingKMeans(_BaseKMeans): centroids to generate. init : {'k-means++', 'random'} or callable, default='random' - Method for initialization: + Method for initialization for each bisection. 'k-means++' : selects initial cluster centers for k-mean clustering in a smart way to speed up convergence. See section @@ -104,7 +104,9 @@ class BisectingKMeans(_BaseKMeans): for the initial centroids. If a callable is passed, it should take arguments X, n_clusters and a - random state and return an initialization. + random state and return an initialization. Note that the bisecting algorithm + always performs a 2-way split, so the callable will always be called with + `n_clusters=2` and should return 2 centroids. n_init : int, default=1 Number of time the inner k-means algorithm will be run with different diff --git a/sklearn/cluster/_hdbscan/meson.build b/sklearn/cluster/_hdbscan/meson.build index 8d880b39a4db5..64be26f2c28b3 100644 --- a/sklearn/cluster/_hdbscan/meson.build +++ b/sklearn/cluster/_hdbscan/meson.build @@ -1,3 +1,10 @@ +# We add sklearn_root_cython_tree and __init__.py so Cython can detect the +# package hierarchy and set the correct __module__ on extension types. +cluster_hdbscan_cython_tree = [ + sklearn_root_cython_tree, + fs.copyfile('__init__.py'), +] + cluster_hdbscan_extension_metadata = { '_linkage': {'sources': [cython_gen.process('_linkage.pyx'), metrics_cython_tree]}, '_reachability': {'sources': [cython_gen.process('_reachability.pyx')]}, @@ -7,7 +14,7 @@ cluster_hdbscan_extension_metadata = { foreach ext_name, ext_dict : cluster_hdbscan_extension_metadata py.extension_module( ext_name, - ext_dict.get('sources'), + [ext_dict.get('sources'), cluster_hdbscan_cython_tree], dependencies: [np_dep], subdir: 'sklearn/cluster/_hdbscan', install: true diff --git a/sklearn/cluster/_hierarchical_fast.pyx b/sklearn/cluster/_hierarchical_fast.pyx index f20b1359f46e2..8d7c363daef37 100644 --- a/sklearn/cluster/_hierarchical_fast.pyx +++ b/sklearn/cluster/_hierarchical_fast.pyx @@ -351,7 +351,7 @@ cdef class UnionFind(object): def _single_linkage_label(const float64_t[:, :] L): """ - Convert an linkage array or MST to a tree by labelling clusters at merges. + Convert a linkage array or MST to a tree by labelling clusters at merges. This is done by using a Union find structure to keep track of merges efficiently. This is the private version of the function that assumes that ``L`` has been properly validated. See ``single_linkage_label`` for the @@ -399,7 +399,7 @@ def _single_linkage_label(const float64_t[:, :] L): @cython.wraparound(True) def single_linkage_label(L): """ - Convert an linkage array or MST to a tree by labelling clusters at merges. + Convert a linkage array or MST to a tree by labelling clusters at merges. This is done by using a Union find structure to keep track of merges efficiently. diff --git a/sklearn/cluster/_kmeans.py b/sklearn/cluster/_kmeans.py index 002df2ca56414..8a907c6e4f8bc 100644 --- a/sklearn/cluster/_kmeans.py +++ b/sklearn/cluster/_kmeans.py @@ -933,12 +933,15 @@ def _check_mkl_vcomp(self, X, n_samples): if has_vcomp and has_mkl: self._warn_mkl_vcomp(n_active_threads) - def _validate_center_shape(self, X, centers): - """Check if centers is compatible with X and n_clusters.""" - if centers.shape[0] != self.n_clusters: + def _validate_center_shape(self, X, centers, n_centroids=None): + """Check if the shape of the centers is correct.""" + if n_centroids is None: + n_centroids = self.n_clusters + + if centers.shape[0] != n_centroids: raise ValueError( f"The shape of the initial centers {centers.shape} does not " - f"match the number of clusters {self.n_clusters}." + f"match the number of clusters {n_centroids}." ) if centers.shape[1] != X.shape[1]: raise ValueError( @@ -1037,7 +1040,7 @@ def _init_centroids( elif callable(init): centers = init(X, n_clusters, random_state=random_state) centers = check_array(centers, dtype=X.dtype, copy=False, order="C") - self._validate_center_shape(X, centers) + self._validate_center_shape(X, centers, n_centroids=n_clusters) if sp.issparse(centers): centers = centers.toarray() @@ -1876,8 +1879,8 @@ class MiniBatchKMeans(_BaseKMeans): ... max_iter=10, ... n_init="auto").fit(X) >>> kmeans.cluster_centers_ - array([[3.55102041, 2.48979592], - [1.06896552, 1. ]]) + array([[3.20967742, 3.56451613], + [1.32758621, 0.77586207]]) >>> kmeans.predict([[0, 0], [4, 4]]) array([1, 0], dtype=int32) @@ -2153,18 +2156,32 @@ def fit(self, X, y=None, sample_weight=None): # Initialize number of samples seen since last reassignment self._n_since_last_reassign = 0 + sum_of_weights = np.sum(sample_weight) + n_steps = (self.max_iter * n_samples) // self._batch_size + normalized_sample_weight = sample_weight / sum_of_weights + unit_sample_weight = np.ones_like(sample_weight, shape=(self._batch_size,)) with _get_threadpool_controller().limit(limits=1, user_api="blas"): # Perform the iterative optimization until convergence for i in range(n_steps): # Sample a minibatch from the full dataset - minibatch_indices = random_state.randint(0, n_samples, self._batch_size) - + minibatch_indices = random_state.choice( + n_samples, + self._batch_size, + p=normalized_sample_weight, + replace=True, + ) # Perform the actual update step on the minibatch data + # Note: since the sampling of the minibatch is sample_weight aware, + # we pass fixed unit weights to the `_mini_batch_step` call to avoid + # accounting for the weights twice. Also note that `_mini_batch_step` + # can be called with non-unit weights when the caller constructs + # the batches explicitly by calling the public `partial_fit` method + # instead. batch_inertia = _mini_batch_step( X=X[minibatch_indices], - sample_weight=sample_weight[minibatch_indices], + sample_weight=unit_sample_weight, centers=centers, centers_new=centers_new, weight_sums=self._counts, @@ -2202,7 +2219,7 @@ def fit(self, X, y=None, sample_weight=None): n_threads=self._n_threads, ) else: - self.inertia_ = self._ewa_inertia * n_samples + self.inertia_ = self._ewa_inertia * sum_of_weights return self diff --git a/sklearn/cluster/_spectral.py b/sklearn/cluster/_spectral.py index 43fdc39c4dccd..ac1d10c8b715e 100644 --- a/sklearn/cluster/_spectral.py +++ b/sklearn/cluster/_spectral.py @@ -8,7 +8,7 @@ import numpy as np from scipy.linalg import LinAlgError, qr, svd -from scipy.sparse import csc_matrix +from scipy.sparse import csc_array from sklearn.base import BaseEstimator, ClusterMixin, _fit_context from sklearn.cluster._kmeans import k_means @@ -160,7 +160,7 @@ def discretize( t_discrete = np.dot(vectors, rotation) labels = t_discrete.argmax(axis=1) - vectors_discrete = csc_matrix( + vectors_discrete = csc_array( (np.ones(len(labels)), (np.arange(0, n_samples), labels)), shape=(n_samples, n_components), ) @@ -404,7 +404,7 @@ class SpectralClustering(ClusterMixin, BaseEstimator): Parameters ---------- n_clusters : int, default=8 - The dimension of the projection subspace. + Number of clusters to extract. eigen_solver : {'arpack', 'lobpcg', 'amg'}, default=None The eigenvalue decomposition strategy to use. AMG requires pyamg diff --git a/sklearn/cluster/tests/test_bisect_k_means.py b/sklearn/cluster/tests/test_bisect_k_means.py index 799ddbc086ce0..98be77d5438c8 100644 --- a/sklearn/cluster/tests/test_bisect_k_means.py +++ b/sklearn/cluster/tests/test_bisect_k_means.py @@ -156,3 +156,18 @@ def test_one_feature(): # https://github.com/scikit-learn/scikit-learn/issues/27236 X = np.random.normal(size=(128, 1)) BisectingKMeans(bisecting_strategy="biggest_inertia", random_state=0).fit(X) + + +def test_bisecting_kmeans_custom_init_validation(): + """Test that BisectingKMeans validates center shape correctly with callable init. + + Regression test for issue #33146 + """ + rng = np.random.RandomState(42) + X = rng.rand(100, 2) + + def my_init(X, n_clusters, random_state): + return X[:n_clusters] + + bisect = BisectingKMeans(n_clusters=3, init=my_init, n_init=1, random_state=rng) + bisect.fit(X) diff --git a/sklearn/cluster/tests/test_hierarchical.py b/sklearn/cluster/tests/test_hierarchical.py index 222d4f6cd9264..66488c6338f07 100644 --- a/sklearn/cluster/tests/test_hierarchical.py +++ b/sklearn/cluster/tests/test_hierarchical.py @@ -177,6 +177,7 @@ def test_agglomerative_clustering_distances( assert not hasattr(clustering, "distances_") +@pytest.mark.no_check_spmatrix # pickle breaks check_spmatrix @pytest.mark.parametrize("lil_container", LIL_CONTAINERS) def test_agglomerative_clustering(global_random_seed, lil_container): # Check that we obtain the correct number of clusters with @@ -226,17 +227,6 @@ def test_agglomerative_clustering(global_random_seed, lil_container): with pytest.raises(ValueError): clustering.fit(X) - # Test that using ward with another metric than euclidean raises an - # exception - clustering = AgglomerativeClustering( - n_clusters=10, - connectivity=connectivity.toarray(), - metric="manhattan", - linkage="ward", - ) - with pytest.raises(ValueError): - clustering.fit(X) - # Test using another metric than euclidean works with linkage complete for metric in PAIRED_DISTANCES.keys(): # Compare our (structured) implementation to scipy @@ -887,3 +877,42 @@ def test_precomputed_connectivity_metric_with_2_connected_components(): assert_array_equal(clusterer.labels_, clusterer_precomputed.labels_) assert_array_equal(clusterer.children_, clusterer_precomputed.children_) + + +@pytest.mark.parametrize("Clustering", [AgglomerativeClustering, FeatureAgglomeration]) +def test_agglomeration_ward_constrained_metric(Clustering): + """Check that we raise an error when 'euclidean' or 'l2' are not passed with + ward linkage.""" + rng = np.random.RandomState(0) + mask = np.ones([10, 10], dtype=bool) + n_samples = 100 + X = rng.randn(n_samples, 50) + connectivity = grid_to_graph(*mask.shape) + + clustering = Clustering( + n_clusters=10, + connectivity=connectivity.toarray(), + metric="manhattan", + linkage="ward", + ) + with pytest.raises(ValueError): + clustering.fit(X) + + +@pytest.mark.parametrize("Clustering", [AgglomerativeClustering, FeatureAgglomeration]) +@pytest.mark.parametrize("metric", ["euclidean", "l2"]) +def test_agglomeration_ward_euclidean(Clustering, metric): + """Check that we can pass 'euclidean' and 'l2' as metric with Ward linkage.""" + rng = np.random.RandomState(0) + mask = np.ones([10, 10], dtype=bool) + n_samples = 100 + X = rng.randn(n_samples, 100) + connectivity = grid_to_graph(*mask.shape) + + clustering = Clustering( + n_clusters=10, + connectivity=connectivity.toarray(), + metric=metric, + linkage="ward", + ) + clustering.fit(X) diff --git a/sklearn/cluster/tests/test_k_means.py b/sklearn/cluster/tests/test_k_means.py index da1a2a0f13765..bea412b5cdf55 100644 --- a/sklearn/cluster/tests/test_k_means.py +++ b/sklearn/cluster/tests/test_k_means.py @@ -6,7 +6,6 @@ import numpy as np import pytest -from scipy import sparse as sp from threadpoolctl import threadpool_info from sklearn.base import clone @@ -32,7 +31,7 @@ create_memmap_backed_data, ) from sklearn.utils.extmath import row_norms -from sklearn.utils.fixes import CSR_CONTAINERS +from sklearn.utils.fixes import CSR_CONTAINERS, _sparse_random_array from sklearn.utils.parallel import _get_threadpool_controller # non centered, sparse centers to check the @@ -1043,8 +1042,8 @@ def test_euclidean_distance(dtype, squared, global_random_seed): # Check that the _euclidean_(dense/sparse)_dense helpers produce correct # results rng = np.random.RandomState(global_random_seed) - a_sparse = sp.random( - 1, 100, density=0.5, format="csr", random_state=rng, dtype=dtype + a_sparse = _sparse_random_array( + (1, 100), density=0.5, format="csr", rng=rng, dtype=dtype ) a_dense = a_sparse.toarray().reshape(-1) b = rng.randn(100).astype(dtype, copy=False) @@ -1068,8 +1067,8 @@ def test_euclidean_distance(dtype, squared, global_random_seed): def test_inertia(dtype, global_random_seed): # Check that the _inertia_(dense/sparse) helpers produce correct results. rng = np.random.RandomState(global_random_seed) - X_sparse = sp.random( - 100, 10, density=0.5, format="csr", random_state=rng, dtype=dtype + X_sparse = _sparse_random_array( + (100, 10), density=0.5, format="csr", rng=rng, dtype=dtype ) X_dense = X_sparse.toarray() sample_weight = rng.randn(100).astype(dtype, copy=False) diff --git a/sklearn/cluster/tests/test_spectral.py b/sklearn/cluster/tests/test_spectral.py index 71b11c9fe151c..436c12e5653a7 100644 --- a/sklearn/cluster/tests/test_spectral.py +++ b/sklearn/cluster/tests/test_spectral.py @@ -36,6 +36,7 @@ ) +@pytest.mark.no_check_spmatrix # pickle breaks check_spmatrix @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) @pytest.mark.parametrize("eigen_solver", ("arpack", "lobpcg")) @pytest.mark.parametrize("assign_labels", ("kmeans", "discretize", "cluster_qr")) @@ -104,13 +105,13 @@ def test_spectral_clustering_sparse(assign_labels, coo_container, global_random_ def test_precomputed_nearest_neighbors_filtering(global_random_seed): # Test precomputed graph filtering when containing too many neighbors X, y = make_blobs( - n_samples=250, + n_samples=300, random_state=global_random_seed, - centers=[[1, 1, 1], [-1, -1, -1]], + centers=[[5, 5, 5], [-5, -5, -5]], cluster_std=0.01, ) - n_neighbors = 2 + n_neighbors = 10 results = [] for additional_neighbors in [0, 10]: nn = NearestNeighbors(n_neighbors=n_neighbors + additional_neighbors).fit(X) @@ -311,7 +312,7 @@ def test_verbose(assign_labels, capsys): def test_spectral_clustering_np_matrix_raises(): """Check that spectral_clustering raises an informative error when passed - a np.matrix. See #10993""" + an np.matrix. See #10993""" X = np.matrix([[0.0, 2.0], [2.0, 0.0]]) msg = r"np\.matrix is not supported. Please convert to a numpy array" diff --git a/sklearn/compose/_column_transformer.py b/sklearn/compose/_column_transformer.py index 4e052399d36f5..971d06895ff83 100644 --- a/sklearn/compose/_column_transformer.py +++ b/sklearn/compose/_column_transformer.py @@ -7,12 +7,12 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -import warnings from collections import Counter from functools import partial from itertools import chain from numbers import Integral, Real +import narwhals.stable.v2 as nw import numpy as np from scipy import sparse @@ -20,6 +20,7 @@ from sklearn.pipeline import _fit_transform_one, _name_estimators, _transform_one from sklearn.preprocessing import FunctionTransformer from sklearn.utils import Bunch +from sklearn.utils._dataframe import is_pandas_df from sklearn.utils._indexing import ( _determine_key_type, _get_column_indices, @@ -47,7 +48,6 @@ _check_feature_names_in, _check_n_features, _get_feature_names, - _is_pandas_df, _num_samples, check_array, check_is_fitted, @@ -165,23 +165,6 @@ class ColumnTransformer(TransformerMixin, _BaseComposition): .. versionchanged:: 1.6 `verbose_feature_names_out` can be a callable or a string to be formatted. - force_int_remainder_cols : bool, default=False - This parameter has no effect. - - .. note:: - If you do not access the list of columns for the remainder columns - in the `transformers_` fitted attribute, you do not need to set - this parameter. - - .. versionadded:: 1.5 - - .. versionchanged:: 1.7 - The default value for `force_int_remainder_cols` will change from - `True` to `False` in version 1.7. - - .. deprecated:: 1.7 - `force_int_remainder_cols` is deprecated and will be removed in 1.9. - Attributes ---------- transformers_ : list @@ -300,7 +283,6 @@ class ColumnTransformer(TransformerMixin, _BaseComposition): "transformer_weights": [dict, None], "verbose": ["verbose"], "verbose_feature_names_out": ["boolean", str, callable], - "force_int_remainder_cols": ["boolean", Hidden(StrOptions({"deprecated"}))], } def __init__( @@ -313,7 +295,6 @@ def __init__( transformer_weights=None, verbose=False, verbose_feature_names_out=True, - force_int_remainder_cols="deprecated", ): self.transformers = transformers self.remainder = remainder @@ -322,7 +303,6 @@ def __init__( self.transformer_weights = transformer_weights self.verbose = verbose self.verbose_feature_names_out = verbose_feature_names_out - self.force_int_remainder_cols = force_int_remainder_cols @property def _transformers(self): @@ -513,6 +493,7 @@ def _validate_transformers(self): self._validate_names(names) # validate estimators + self._check_estimators_are_instances(transformers) for t in transformers: if t in ("drop", "passthrough"): continue @@ -596,12 +577,17 @@ def _get_feature_name_out_for_transformer(self, name, trans, feature_names_in): column_indices = self._transformer_to_input_indices[name] names = feature_names_in[column_indices] # An actual transformer - if not hasattr(trans, "get_feature_names_out"): + if hasattr(trans, "get_feature_names_out"): + return trans.get_feature_names_out(names) + elif hasattr(self, "_transformers_feature_names_out"): + # Fallback to feature names returned by transformers that output + # dataframes but don't implement get_feature_names_out. + return self._transformers_feature_names_out[self.output_indices_[name]] + else: raise AttributeError( f"Transformer {name} (type {type(trans).__name__}) does " "not provide get_feature_names_out." ) - return trans.get_feature_names_out(names) def get_feature_names_out(self, input_features=None): """Get output feature names for transformation. @@ -761,10 +747,12 @@ def _validate_output(self, result): ) ] for Xs, name in zip(result, names): - if not getattr(Xs, "ndim", 0) == 2 and not hasattr(Xs, "__dataframe__"): + if not ( + getattr(Xs, "ndim", 0) == 2 or nw.dependencies.is_into_dataframe(Xs) + ): raise ValueError( - "The output of the '{0}' transformer should be 2D (numpy array, " - "scipy sparse array, dataframe).".format(name) + f"The output of the '{name}' transformer should be 2D (numpy " + "array, scipy sparse array, dataframe)." ) if _get_output_config("transform", self)["dense"] == "pandas": return @@ -773,7 +761,7 @@ def _validate_output(self, result): except ImportError: return for Xs, name in zip(result, names): - if not _is_pandas_df(Xs): + if not is_pandas_df(Xs): continue for col_name, dtype in Xs.dtypes.to_dict().items(): if getattr(dtype, "na_value", None) is not pd.NA: @@ -974,14 +962,6 @@ def fit_transform(self, X, y=None, **params): """ _raise_for_params(params, self, "fit_transform") - if self.force_int_remainder_cols != "deprecated": - warnings.warn( - "The parameter `force_int_remainder_cols` is deprecated and will be " - "removed in 1.9. It has no effect. Leave it to its default value to " - "avoid this warning.", - FutureWarning, - ) - validate_data(self, X=X, skip_check_array=True) X = _check_X(X) # set n_features_in_ attribute @@ -1064,7 +1044,7 @@ def transform(self, X, **params): # were not present in fit time, and the order of the columns doesn't # matter. fit_dataframe_and_transform_dataframe = hasattr(self, "feature_names_in_") and ( - _is_pandas_df(X) or hasattr(X, "__dataframe__") + nw.dependencies.is_into_dataframe(X) ) n_samples = _num_samples(X) @@ -1145,68 +1125,21 @@ def _hstack(self, Xs, *, n_samples): Xs = [f.toarray() if sparse.issparse(f) else f for f in Xs] adapter = _get_container_adapter("transform", self) if adapter and all(adapter.is_supported_container(X) for X in Xs): - # rename before stacking as it avoids to error on temporary duplicated - # columns - transformer_names = [ - t[0] - for t in self._iter( - fitted=True, - column_as_labels=False, - skip_drop=True, - skip_empty_columns=True, + # Store feature names out of transformers in case they don't implement + # get_feature_names_out + self._transformers_feature_names_out = np.hstack( + [_get_feature_names(X) for X in Xs] + ) + + # Rename all columns to avoid duplicated column names. + # The names are not important here as final column names will be + # generated by the set_output wrapper using `get_feature_names_out`. + Xs = [ + adapter.rename_columns( + X, [f"tmp_col_name_{i}_{j}" for j in range(X.shape[1])] ) + for i, X in enumerate(Xs) ] - feature_names_outs = [X.columns for X in Xs if X.shape[1] != 0] - if self.verbose_feature_names_out: - # `_add_prefix_for_feature_names_out` takes care about raising - # an error if there are duplicated columns. - feature_names_outs = self._add_prefix_for_feature_names_out( - list(zip(transformer_names, feature_names_outs)) - ) - else: - # check for duplicated columns and raise if any - feature_names_outs = list(chain.from_iterable(feature_names_outs)) - feature_names_count = Counter(feature_names_outs) - if any(count > 1 for count in feature_names_count.values()): - duplicated_feature_names = sorted( - name - for name, count in feature_names_count.items() - if count > 1 - ) - err_msg = ( - "Duplicated feature names found before concatenating the" - " outputs of the transformers:" - f" {duplicated_feature_names}.\n" - ) - for transformer_name, X in zip(transformer_names, Xs): - if X.shape[1] == 0: - continue - dup_cols_in_transformer = sorted( - set(X.columns).intersection(duplicated_feature_names) - ) - if len(dup_cols_in_transformer): - err_msg += ( - f"Transformer {transformer_name} has conflicting " - f"columns names: {dup_cols_in_transformer}.\n" - ) - raise ValueError( - err_msg - + "Either make sure that the transformers named above " - "do not generate columns with conflicting names or set " - "verbose_feature_names_out=True to automatically " - "prefix to the output feature names with the name " - "of the transformer to prevent any conflicting " - "names." - ) - - names_idx = 0 - for X in Xs: - if X.shape[1] == 0: - continue - names_out = feature_names_outs[names_idx : names_idx + X.shape[1]] - adapter.rename_columns(X, names_out) - names_idx += X.shape[1] - output = adapter.hstack(Xs) output_samples = output.shape[0] if output_samples != n_samples: @@ -1223,23 +1156,43 @@ def _hstack(self, Xs, *, n_samples): return np.hstack(Xs) def _sk_visual_block_(self): - if isinstance(self.remainder, str) and self.remainder == "drop": - transformers = self.transformers - elif hasattr(self, "_remainder"): - remainder_columns = self._remainder[2] - if ( - hasattr(self, "feature_names_in_") - and remainder_columns - and not all(isinstance(col, str) for col in remainder_columns) - ): - remainder_columns = self.feature_names_in_[remainder_columns].tolist() - transformers = chain( - self.transformers, [("remainder", self.remainder, remainder_columns)] + # We can find remainder and its column only when it's fitted + if hasattr(self, "transformers_"): + transformers = ( + self.transformers_[:-1] + if self.transformers_ and self.transformers_[-1][0] == "remainder" + else self.transformers_ ) - else: - transformers = chain(self.transformers, [("remainder", self.remainder, "")]) + # Add remainder back to fitted transformers if remainder is not drop + # and if there are remainder columns to display + remainder_columns = self._remainder[2] + if self.remainder != "drop" and remainder_columns: + has_numeric_columns = not all( + isinstance(col, str) for col in remainder_columns + ) + # Convert indices to column names when feature names are available + if hasattr(self, "feature_names_in_") and has_numeric_columns: + remainder_columns = self.feature_names_in_[ + remainder_columns + ].tolist() + # get the fitted remainder function so we can access its methods to + # build the display in utils._repr_html.estimator.py + remainder_transformer = self.transformers_[-1][1] + + transformers = chain( + transformers, + [("remainder", remainder_transformer, remainder_columns)], + ) + else: # not fitted + if self.remainder != "drop": + transformers = chain( + self.transformers, [("remainder", self.remainder, [])] + ) + else: + transformers = self.transformers names, transformers, name_details = zip(*transformers) + return _VisualBlock( "parallel", transformers, names=names, name_details=name_details ) @@ -1294,7 +1247,10 @@ def get_metadata_routing(self): # might happen if no columns are selected for that transformer. We # request all metadata requested by all transformers. transformers = self.transformers - if self.remainder not in ("drop", "passthrough"): + if self.remainder != "drop": + # Note: remainder="passthrough" will be converted into a FunctionTransformer + # internally, so it needs to be added to the router as well here, even if it + # doesn't consume any metadata, to avoid a `KeyError` later. transformers = chain(transformers, [("remainder", self.remainder, None)]) for name, step, _ in transformers: method_mapping = MethodMapping() @@ -1336,7 +1292,7 @@ def _check_X(X): """Use check_array only when necessary, e.g. on lists and other non-array-likes.""" if ( (hasattr(X, "__array__") and hasattr(X, "shape")) - or hasattr(X, "__dataframe__") + or nw.dependencies.is_into_dataframe(X) or sparse.issparse(X) ): return X @@ -1385,7 +1341,6 @@ def make_column_transformer( n_jobs=None, verbose=False, verbose_feature_names_out=True, - force_int_remainder_cols="deprecated", ): """Construct a ColumnTransformer from the given transformers. @@ -1458,23 +1413,6 @@ def make_column_transformer( .. versionadded:: 1.0 - force_int_remainder_cols : bool, default=True - This parameter has no effect. - - .. note:: - If you do not access the list of columns for the remainder columns - in the :attr:`ColumnTransformer.transformers_` fitted attribute, - you do not need to set this parameter. - - .. versionadded:: 1.5 - - .. versionchanged:: 1.7 - The default value for `force_int_remainder_cols` will change from - `True` to `False` in version 1.7. - - .. deprecated:: 1.7 - `force_int_remainder_cols` is deprecated and will be removed in version 1.9. - Returns ------- ct : ColumnTransformer @@ -1508,7 +1446,6 @@ def make_column_transformer( sparse_threshold=sparse_threshold, verbose=verbose, verbose_feature_names_out=verbose_feature_names_out, - force_int_remainder_cols=force_int_remainder_cols, ) diff --git a/sklearn/compose/_target.py b/sklearn/compose/_target.py index 38ba0dce1adeb..2303c363cf534 100644 --- a/sklearn/compose/_target.py +++ b/sklearn/compose/_target.py @@ -17,6 +17,7 @@ process_routing, ) from sklearn.utils._param_validation import HasMethods +from sklearn.utils._repr_html.estimator import _VisualBlock from sklearn.utils._tags import get_tags from sklearn.utils.validation import check_is_fitted @@ -395,3 +396,15 @@ def _get_regressor(self, get_clone=False): return LinearRegression() return clone(self.regressor) if get_clone else self.regressor + + def _sk_visual_block_(self): + regressor = ( + self.regressor_ if hasattr(self, "regressor_") else self._get_regressor() + ) + return _VisualBlock( + "serial", + [regressor], + names=[f"regressor: {regressor.__class__.__name__}"], + name_details=[str(regressor)], + dash_wrapped=True, + ) diff --git a/sklearn/compose/tests/test_column_transformer.py b/sklearn/compose/tests/test_column_transformer.py index a4c9ba38f460b..95bcb452a0964 100644 --- a/sklearn/compose/tests/test_column_transformer.py +++ b/sklearn/compose/tests/test_column_transformer.py @@ -40,7 +40,7 @@ assert_almost_equal, assert_array_equal, ) -from sklearn.utils.fixes import CSR_CONTAINERS, parse_version +from sklearn.utils.fixes import CSR_CONTAINERS, _sparse_eye_array, parse_version class Trans(TransformerMixin, BaseEstimator): @@ -74,7 +74,7 @@ def fit(self, X, y=None): def transform(self, X, y=None): n_samples = len(X) - return self.csr_container(sparse.eye(n_samples, n_samples)) + return self.csr_container(_sparse_eye_array(n_samples)) class TransNo2D(BaseEstimator): @@ -93,6 +93,26 @@ def transform(self, X, y=None): raise ValueError("specific message") +@pytest.mark.parametrize( + "transformers, class_name", + [ + ([("trans1", Trans, [0]), ("trans2", Trans(), [1])], "Trans"), + ([("trans1", Trans(), [0]), ("trans2", Trans, [1])], "Trans"), + ([("drop", "drop", [0]), ("trans2", Trans, [1])], "Trans"), + ([("trans1", Trans, [0]), ("passthrough", "passthrough", [1])], "Trans"), + ], +) +def test_column_transformer_raises_class_not_instance_error(transformers, class_name): + # non-regression tests for https://github.com/scikit-learn/scikit-learn/issues/32719 + ct = ColumnTransformer(transformers) + msg = re.escape( + f"Expected an estimator instance ({class_name}()), " + f"got estimator class instead ({class_name})." + ) + with pytest.raises(TypeError, match=msg): + ct.fit([[1]]) + + def test_column_transformer(): X_array = np.array([[0, 1, 2], [2, 4, 6]]).T @@ -176,16 +196,13 @@ def test_column_transformer_tuple_transformers_parameter(): ) -@pytest.mark.parametrize("constructor_name", ["dataframe", "polars"]) +@pytest.mark.parametrize("constructor_name", ["pandas", "polars"]) def test_column_transformer_dataframe(constructor_name): - if constructor_name == "dataframe": - dataframe_lib = pytest.importorskip("pandas") - else: - dataframe_lib = pytest.importorskip(constructor_name) + df_lib = pytest.importorskip(constructor_name) X_array = np.array([[0, 1, 2], [2, 4, 6]]).T X_df = _convert_container( - X_array, constructor_name, columns_name=["first", "second"] + X_array, constructor_name, column_names=["first", "second"] ) X_res_first = np.array([0, 1, 2]).reshape(-1, 1) @@ -209,16 +226,15 @@ def test_column_transformer_dataframe(constructor_name): # boolean mask (np.array([True, False]), X_res_first), ([True, False], X_res_first), + # scalar + (0, X_res_first), + ("first", X_res_first), ] - if constructor_name == "dataframe": - # Scalars are only supported for pandas dataframes. + if constructor_name == "pandas": cases.extend( [ - # scalar - (0, X_res_first), - ("first", X_res_first), ( - dataframe_lib.Series([True, False], index=["first", "second"]), + df_lib.Series([True, False], index=["first", "second"]), X_res_first, ), ] @@ -295,38 +311,36 @@ def fit(self, X, y=None): def transform(self, X, y=None): assert isinstance(X, self.expected_type_transform) - if isinstance(X, dataframe_lib.Series): - X = X.to_frame() + if len(X.shape) < 2: + X = _convert_container(X, constructor_name) return X ct = ColumnTransformer( [ ( "trans", - TransAssert(expected_type_transform=dataframe_lib.DataFrame), + TransAssert(expected_type_transform=df_lib.DataFrame), ["first", "second"], ) ] ) ct.fit_transform(X_df) - if constructor_name == "dataframe": - # DataFrame protocol does not have 1d columns, so we only test on Pandas - # dataframes. - ct = ColumnTransformer( - [ - ( - "trans", - TransAssert(expected_type_transform=dataframe_lib.Series), - "first", - ) - ], - remainder="drop", - ) - ct.fit_transform(X_df) + ct = ColumnTransformer( + [ + ( + "trans", + TransAssert(expected_type_transform=df_lib.Series), + "first", + ) + ], + remainder="drop", + ) + ct.fit_transform(X_df) - # Only test on pandas because the dataframe protocol requires string column - # names + if constructor_name == "pandas": + # Only pandas (but not polars, nor pyarrow) allows for column names that are + # not strings. # integer column spec + integer column names -> still use positional X_df2 = X_df.copy() X_df2.columns = [1, 0] @@ -470,7 +484,7 @@ def test_column_transformer_output_indices_df(): @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_column_transformer_sparse_array(csr_container): - X_sparse = csr_container(sparse.eye(3, 2)) + X_sparse = csr_container(_sparse_eye_array(3, 2)) # no distinction between 1D and 2D X_res_first = X_sparse[:, [0]] @@ -516,7 +530,7 @@ def test_column_transformer_list(): @pytest.mark.parametrize("constructor_name", ["array", "pandas", "polars"]) def test_column_transformer_sparse_stacking(csr_container, constructor_name): X = np.array([[0, 1, 2], [2, 4, 6]]).T - X = _convert_container(X, constructor_name, columns_name=["first", "second"]) + X = _convert_container(X, constructor_name, column_names=["first", "second"]) col_trans = ColumnTransformer( [("trans1", Trans(), [0]), ("trans2", SparseMatrixTrans(csr_container), 1)], @@ -794,7 +808,6 @@ def test_column_transformer_get_set_params(): "transformer_weights": None, "verbose_feature_names_out": True, "verbose": False, - "force_int_remainder_cols": "deprecated", } assert ct.get_params() == exp @@ -816,7 +829,6 @@ def test_column_transformer_get_set_params(): "transformer_weights": None, "verbose_feature_names_out": True, "verbose": False, - "force_int_remainder_cols": "deprecated", } assert ct.get_params() == exp @@ -976,23 +988,6 @@ def test_column_transformer_remainder_dtypes(cols1, cols2, expected_remainder_co assert ct.transformers_[-1][-1] == expected_remainder_cols -# TODO(1.9): remove this test -@pytest.mark.parametrize("force_int_remainder_cols", [True, False]) -def test_force_int_remainder_cols_deprecation(force_int_remainder_cols): - """Check that ColumnTransformer raises a FutureWarning when - force_int_remainder_cols is set. - """ - X = np.ones((1, 3)) - ct = ColumnTransformer( - [("T1", Trans(), [0]), ("T2", Trans(), [1])], - remainder="passthrough", - force_int_remainder_cols=force_int_remainder_cols, - ) - - with pytest.warns(FutureWarning, match="`force_int_remainder_cols` is deprecated"): - ct.fit(X) - - @pytest.mark.parametrize( "key, expected_cols", [ @@ -1176,7 +1171,6 @@ def test_column_transformer_get_set_params_with_remainder(): "transformer_weights": None, "verbose_feature_names_out": True, "verbose": False, - "force_int_remainder_cols": "deprecated", } assert ct.get_params() == exp @@ -1197,7 +1191,6 @@ def test_column_transformer_get_set_params_with_remainder(): "transformer_weights": None, "verbose_feature_names_out": True, "verbose": False, - "force_int_remainder_cols": "deprecated", } assert ct.get_params() == exp @@ -1538,7 +1531,7 @@ def test_sk_visual_block_remainder(remainder): ) visual_block = ct._sk_visual_block_() assert visual_block.names == ("ohe", "remainder") - assert visual_block.name_details == (["col1", "col2"], "") + assert visual_block.name_details == (["col1", "col2"], []) assert visual_block.estimators == (ohe, remainder) @@ -1573,7 +1566,15 @@ def test_sk_visual_block_remainder_fitted_pandas(remainder): visual_block = ct._sk_visual_block_() assert visual_block.names == ("ohe", "remainder") assert visual_block.name_details == (["col1", "col2"], ["col3", "col4"]) - assert visual_block.estimators == (ohe, remainder) + assert isinstance(visual_block.estimators[0], OneHotEncoder) + if remainder == "passthrough": + # comparing visual_block.estimators[1] to FunctionTransformer because + # _column_transformer.py::sk_visual_block needs to send the remainder + # as a transformer (not as a string) to estimator.py in order to + # display output names. + assert isinstance(visual_block.estimators[1], FunctionTransformer) + else: + assert isinstance(visual_block.estimators[1], StandardScaler) @pytest.mark.parametrize("remainder", ["passthrough", StandardScaler()]) @@ -1588,7 +1589,69 @@ def test_sk_visual_block_remainder_fitted_numpy(remainder): visual_block = ct._sk_visual_block_() assert visual_block.names == ("scale", "remainder") assert visual_block.name_details == ([0, 2], [1]) - assert visual_block.estimators == (scaler, remainder) + assert isinstance(visual_block.estimators[0], StandardScaler) + if remainder == "passthrough": + # comparing visual_block.estimators[1] to FunctionTransformer because + # _column_transformer.py::sk_visual_block needs to send the remainder + # as a transformer (not as a string) to estimator.py in order to + # display output names. + assert isinstance(visual_block.estimators[1], FunctionTransformer) + else: + assert isinstance(visual_block.estimators[1], StandardScaler) + + +def test_sk_visual_block_remainder_col_names_pandas(): + """Check that the visual block `name_details` matches the `feature_names_in_` + Non-regression test - when remainder_columns logic is removed it should fail + https://github.com/scikit-learn/scikit-learn/pull/31442#discussion_r2841235711 + """ + pd = pytest.importorskip("pandas") + ohe = OneHotEncoder() + ct = ColumnTransformer( + transformers=[("ohe", ohe, ["col1"])], + remainder="passthrough", + ) + df = pd.DataFrame( + { + "col1": ["a", "b", "c"], + "col2": ["z", "z", "z"], + } + ) + # It is not possible to guess the remainder columns when not fitted. + visual_block = ct._sk_visual_block_() + assert visual_block.name_details == (["col1"], []) + + ct.fit(df) + # Once fitted, the remainder columns are the columns seen during fit not + # specified for specific transformers. + visual_block = ct._sk_visual_block_() + assert visual_block.name_details == (["col1"], ["col2"]) + + +def test_sk_visual_block_full_transform(): + """Check that visual_block doesn't return remainder when it has no columns + Non-regression test - https://github.com/scikit-learn/scikit-learn/issues/33513 + """ + ct = ColumnTransformer([("norm1", Normalizer(), [0, 1])], remainder="passthrough") + X = np.array([[0, 4], [3, 3]]) + ct.fit(X) + visual_block = ct._sk_visual_block_() + assert visual_block.names == ("norm1",) + assert visual_block.name_details == ([0, 1],) + assert isinstance(visual_block.estimators[0], Normalizer) + assert len(visual_block.estimators) == 1 + + +def test_sk_visual_block_int_remainder_cols_pandas(): + """Check that remainder still uses available string column names in visual block + even when transformer columns are specified by integer index. + """ + pd = pytest.importorskip("pandas") + X = pd.DataFrame({"a": [1, 2], "b": [3, 4], "c": [5, 6]}) + ct = ColumnTransformer([("scaler", StandardScaler(), [0])], remainder="passthrough") + ct.fit(X) + visual_block = ct._sk_visual_block_() + assert visual_block.name_details == ([0], ["b", "c"]) @pytest.mark.parametrize("explicit_colname", ["first", "second", 0, 1]) @@ -1629,7 +1692,7 @@ def test_column_transformer_reordered_column_names_remainder( tf.transform(X_array) -def test_feature_name_validation_missing_columns_drop_passthough(): +def test_feature_name_validation_missing_columns_drop_passthrough(): """Test the interaction between {'drop', 'passthrough'} and missing column names.""" pd = pytest.importorskip("pandas") @@ -1669,7 +1732,7 @@ def test_feature_names_in_(): Column transformer deliberately does not check for column name consistency. It only checks that the non-dropped names seen in `fit` are seen in `transform`. This behavior is already tested in - `test_feature_name_validation_missing_columns_drop_passthough`""" + `test_feature_name_validation_missing_columns_drop_passthrough`""" pd = pytest.importorskip("pandas") @@ -2428,7 +2491,7 @@ def test_remainder_set_output(): def test_transform_pd_na(): - """Check behavior when a tranformer's output contains pandas.NA + """Check behavior when a transformer's output contains pandas.NA It should raise an error unless the output config is set to 'pandas'. """ @@ -2532,6 +2595,23 @@ def test_column_transformer_remainder_passthrough_naming_consistency(transform_o assert preprocessor.get_feature_names_out().tolist() == expected_column_names +# DfOutTransformer that does not define get_feature_names_out +class DfOutTransformer(BaseEstimator): + def __init__(self, offset=1.0): + self.offset = offset + + def fit(self, X, y=None): + return self + + def transform(self, X, y=None): + return X - self.offset + + def set_output(self, transform=None): + # This transformer will always output a DataFrame regardless of the + # configuration. + return self + + @pytest.mark.parametrize("dataframe_lib", ["pandas", "polars"]) def test_column_transformer_column_renaming(dataframe_lib): """Check that we properly rename columns when using `ColumnTransformer` and @@ -2549,15 +2629,17 @@ def test_column_transformer_column_renaming(dataframe_lib): ("A", "passthrough", ["x1", "x2", "x3"]), ("B", FunctionTransformer(), ["x1", "x2"]), ("C", StandardScaler(), ["x1", "x3"]), + ("D", DfOutTransformer(), ["x2", "x3"]), # special case of a transformer returning 0-columns, e.g feature selector ( - "D", + "E", FunctionTransformer(lambda x: _safe_indexing(x, [], axis=1)), ["x1", "x2", "x3"], ), ], - verbose_feature_names_out=True, ).set_output(transform=dataframe_lib) + + # by default, verbose_feature_names_out is True df_trans = transformer.fit_transform(df) assert list(df_trans.columns) == [ "A__x1", @@ -2567,39 +2649,12 @@ def test_column_transformer_column_renaming(dataframe_lib): "B__x2", "C__x1", "C__x3", + "D__x2", + "D__x3", ] - -@pytest.mark.parametrize("dataframe_lib", ["pandas", "polars"]) -def test_column_transformer_error_with_duplicated_columns(dataframe_lib): - """Check that we raise an error when using `ColumnTransformer` and - the columns names are duplicated between transformers.""" - lib = pytest.importorskip(dataframe_lib) - - df = lib.DataFrame({"x1": [1, 2, 3], "x2": [10, 20, 30], "x3": [100, 200, 300]}) - - transformer = ColumnTransformer( - transformers=[ - ("A", "passthrough", ["x1", "x2", "x3"]), - ("B", FunctionTransformer(), ["x1", "x2"]), - ("C", StandardScaler(), ["x1", "x3"]), - # special case of a transformer returning 0-columns, e.g feature selector - ( - "D", - FunctionTransformer(lambda x: _safe_indexing(x, [], axis=1)), - ["x1", "x2", "x3"], - ), - ], - verbose_feature_names_out=False, - ).set_output(transform=dataframe_lib) - err_msg = re.escape( - "Duplicated feature names found before concatenating the outputs of the " - "transformers: ['x1', 'x2', 'x3'].\n" - "Transformer A has conflicting columns names: ['x1', 'x2', 'x3'].\n" - "Transformer B has conflicting columns names: ['x1', 'x2'].\n" - "Transformer C has conflicting columns names: ['x1', 'x3'].\n" - ) - with pytest.raises(ValueError, match=err_msg): + transformer.set_params(verbose_feature_names_out=False) + with pytest.raises(ValueError, match=r"Output feature names:.*are not unique"): transformer.fit_transform(df) @@ -2805,5 +2860,40 @@ def test_unused_transformer_request_present(): assert router.consumes("fit", ["metadata"]) == set(["metadata"]) +@config_context(enable_metadata_routing=True) +@pytest.mark.parametrize( + "remainder", + [ + "drop", + "passthrough", # consumes no metadata + Trans(), # consumes no metadata + ConsumingTransformer(), # consumes metadata + ], +) +def test_metadata_routing_with_remainder_no_error(remainder): + # Make sure that metadata routing works with all possible remainder types. + # Non-regression test for https://github.com/scikit-learn/scikit-learn/issues/33614 + + X = np.array([[1, 2], [3, 4]]) + y = [0, 1] + sample_weight = [1, 1] + + # This can only be set here because metadata routing has to be enabled first. + if isinstance(remainder, ConsumingTransformer): + remainder.set_fit_request(sample_weight=True).set_transform_request( + sample_weight=True + ) + + transformer = ( + ConsumingTransformer() + .set_fit_request(sample_weight=True) + .set_transform_request(sample_weight=True) + ) + ct = ColumnTransformer([("trans", transformer, [0])], remainder=remainder) + + # Check that no error is raised + ct.fit_transform(X, y=y, sample_weight=sample_weight) + + # End of Metadata Routing Tests # ============================= diff --git a/sklearn/conftest.py b/sklearn/conftest.py index 5699392ba2505..a4490844547e1 100644 --- a/sklearn/conftest.py +++ b/sklearn/conftest.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: BSD-3-Clause import builtins -import faulthandler import platform import sys from contextlib import suppress @@ -272,6 +271,111 @@ def pyplot(): pyplot.close("all") +def munge_scipy_to_check_spmatrix_usage(): + import scipy + + def flag_this_call(*args, **kwds): + raise ValueError("Old spmatrix function called. Use e.g. block or random.") + + scipy.sparse._construct.bmat = flag_this_call + scipy.sparse._construct.rand = flag_this_call + scipy.sparse._construct.rand = flag_this_call + + class _strict_mul_mixin: + def __mul__(self, other): + if not scipy.sparse._sputils.isscalarlike(other): + raise ValueError("Operator * used here! Change to @?") + return super().__mul__(other) + + def __rmul__(self, other): + if not scipy.sparse._sputils.isscalarlike(other): + raise ValueError("Operator * used here! Change to @?") + return super().__rmul__(other) + + def __imul__(self, other): + if not scipy.sparse._sputils.isscalarlike(other): + raise ValueError("Operator * used here! Change to @?") + return super().__imul__(other) + + def __pow__(self, *args, **kwargs): + raise ValueError("spmatrix ** used here! Use sparse.linalg.matrix_power?") + + @property + def A(self): + raise TypeError("spmatrix A property is not allowed! Use .toarray()") + + @property + def H(self): + raise TypeError("spmatrix H property is not allowed! Use .conjugate().T") + + def asfptype(self): + raise TypeError("spmatrix asfptype is not allowed! rewrite needed") + + def get_shape(self): + raise TypeError("spmatrix get_shape is not allowed! Use .shape") + + def getformat(self): + raise TypeError("spmatrix getformat is not allowed! Use .shape") + + def getmaxprint(self): + raise TypeError("spmatrix getmaxprint is not allowed! Use .shape") + + def getnnz(self): + raise TypeError("spmatrix getnnz is not allowed! Use .shape") + + def getH(self): + raise TypeError("spmatrix getH is not allowed! Use .shape") + + def getrow(self): + raise TypeError("spmatrix getrow is not allowed! Use .shape") + + def getcol(self): + raise TypeError("spmatrix getcol is not allowed! Use .shape") + + class _strict_coo_matrix(_strict_mul_mixin, scipy.sparse.coo_matrix): + pass + + class _strict_bsr_matrix(_strict_mul_mixin, scipy.sparse.bsr_matrix): + pass + + class _strict_csr_matrix(_strict_mul_mixin, scipy.sparse.csr_matrix): + pass + + class _strict_csc_matrix(_strict_mul_mixin, scipy.sparse.csc_matrix): + pass + + class _strict_dok_matrix(_strict_mul_mixin, scipy.sparse.dok_matrix): + pass + + class _strict_lil_matrix(_strict_mul_mixin, scipy.sparse.lil_matrix): + pass + + class _strict_dia_matrix(_strict_mul_mixin, scipy.sparse.dia_matrix): + pass + + scipy.sparse.coo_matrix = scipy.sparse._coo.coo_matrix = _strict_coo_matrix + scipy.sparse.bsr_matrix = scipy.sparse._bsr.bsr_matrix = _strict_bsr_matrix + scipy.sparse.csr_matrix = scipy.sparse._csr.csr_matrix = _strict_csr_matrix + scipy.sparse.csc_matrix = scipy.sparse._csc.csc_matrix = _strict_csc_matrix + scipy.sparse.dok_matrix = scipy.sparse._dok.dok_matrix = _strict_dok_matrix + scipy.sparse.lil_matrix = scipy.sparse._lil.lil_matrix = _strict_lil_matrix + scipy.sparse.dia_matrix = scipy.sparse._dia.dia_matrix = _strict_dia_matrix + + scipy.sparse._construct.bsr_matrix = _strict_bsr_matrix + scipy.sparse._construct.coo_matrix = _strict_coo_matrix + scipy.sparse._construct.csc_matrix = _strict_csc_matrix + scipy.sparse._construct.csr_matrix = _strict_csr_matrix + scipy.sparse._construct.dia_matrix = _strict_dia_matrix + + scipy.sparse._matrix.bsr_matrix = _strict_bsr_matrix + scipy.sparse._matrix.coo_matrix = _strict_coo_matrix + scipy.sparse._matrix.csc_matrix = _strict_csc_matrix + scipy.sparse._matrix.csr_matrix = _strict_csr_matrix + scipy.sparse._matrix.dia_matrix = _strict_dia_matrix + scipy.sparse._matrix.dok_matrix = _strict_dok_matrix + scipy.sparse._matrix.lil_matrix = _strict_lil_matrix + + def pytest_generate_tests(metafunc): """Parametrization of global_random_seed fixture @@ -320,6 +424,17 @@ def pytest_generate_tests(metafunc): def pytest_addoption(parser, pluginmanager): if not PARALLEL_RUN_AVAILABLE: parser.addini("thread_unsafe_fixtures", "list of stuff") + parser.addoption( + "--check_spmatrix", + action="store_true", + default=False, + help="raise for spmatrix usage that breaks sparray", + ) + + +def pytest_runtest_setup(item): + if "no_check_spmatrix" in item.keywords and item.config.option.check_spmatrix: + pytest.skip("skip due to check_spmatrix scipy patch breaking this test") def pytest_configure(config): @@ -346,10 +461,13 @@ def pytest_configure(config): for line in get_pytest_filterwarning_lines(): config.addinivalue_line("filterwarnings", line) - faulthandler_timeout = int(environ.get("SKLEARN_FAULTHANDLER_TIMEOUT", "0")) - if faulthandler_timeout > 0: - faulthandler.enable() - faulthandler.dump_traceback_later(faulthandler_timeout, exit=True) + if config.option.check_spmatrix: + # Note: this patches scipy.sparse to raise upon outdated spmatrix usage + # If you run into this with new PR code to sklearn, make sure it: + # - converts spmatrix input to sparray + # - uses the sparray interface for manipulating the sparse object + # - uses align_api_if_sparse(X) just before returning a sparse object + munge_scipy_to_check_spmatrix_usage() if not PARALLEL_RUN_AVAILABLE: config.addinivalue_line( diff --git a/sklearn/covariance/_graph_lasso.py b/sklearn/covariance/_graph_lasso.py index dce753fea71f4..aa114cb4ba195 100644 --- a/sklearn/covariance/_graph_lasso.py +++ b/sklearn/covariance/_graph_lasso.py @@ -747,9 +747,9 @@ class GraphicalLassoCV(BaseGraphicalLasso): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs :class:`~sklearn.model_selection.KFold` is used. diff --git a/sklearn/covariance/tests/test_robust_covariance.py b/sklearn/covariance/tests/test_robust_covariance.py index 4a7590ef2c18c..c2b56048e90b7 100644 --- a/sklearn/covariance/tests/test_robust_covariance.py +++ b/sklearn/covariance/tests/test_robust_covariance.py @@ -19,7 +19,7 @@ def test_mcd(global_random_seed): # Tests the FastMCD algorithm implementation # Small data set # test without outliers (random independent normal data) - launch_mcd_on_dataset(100, 5, 0, 0.02, 0.1, 75, global_random_seed) + launch_mcd_on_dataset(100, 5, 0, 0.02, 0.1, 74, global_random_seed) # test with a contaminated data set (medium contamination) launch_mcd_on_dataset(100, 5, 20, 0.3, 0.3, 65, global_random_seed) # test with a contaminated data set (strong contamination) @@ -182,11 +182,17 @@ def test_mincovdet_bias_on_normal(n_samples, n_features, global_random_seed): https://github.com/scikit-learn/scikit-learn/issues/23162 """ threshold = 0.985 # threshold for variance underesitmation - x = np.random.randn(n_features, n_samples) + rng = np.random.default_rng(global_random_seed) + x = rng.normal(size=(n_features, n_samples)) # Assume centered data, to reduce test complexity var_emp = empirical_covariance(x.T, assume_centered=True).diagonal() cov_mcd = ( - MinCovDet(support_fraction=1.0, store_precision=False, assume_centered=True) + MinCovDet( + support_fraction=1.0, + store_precision=False, + assume_centered=True, + random_state=global_random_seed, + ) .fit(x.T) .covariance_ ) diff --git a/sklearn/cross_decomposition/_pls.py b/sklearn/cross_decomposition/_pls.py index 756af41e97290..bb720c9ab503b 100644 --- a/sklearn/cross_decomposition/_pls.py +++ b/sklearn/cross_decomposition/_pls.py @@ -903,7 +903,7 @@ def __init__( class PLSSVD(ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator): """Partial Least Square SVD. - This transformer simply performs a SVD on the cross-covariance matrix + This transformer simply performs an SVD on the cross-covariance matrix `X'y`. It is able to project both the training data `X` and the targets `y`. The training data `X` is projected on the left singular vectors, while the targets are projected on the right singular vectors. diff --git a/sklearn/cross_decomposition/tests/test_pls.py b/sklearn/cross_decomposition/tests/test_pls.py index f2b91a2712ef5..375a7826cbab0 100644 --- a/sklearn/cross_decomposition/tests/test_pls.py +++ b/sklearn/cross_decomposition/tests/test_pls.py @@ -354,7 +354,7 @@ def test_convergence_fail(): @pytest.mark.parametrize("Est", (PLSSVD, PLSRegression, PLSCanonical)) -def test_attibutes_shapes(Est): +def test_attributes_shapes(Est): # Make sure attributes are of the correct shape depending on n_components d = load_linnerud() X = d.data diff --git a/sklearn/datasets/_arff_parser.py b/sklearn/datasets/_arff_parser.py index 311dc6d8db993..d884d68f1f26d 100644 --- a/sklearn/datasets/_arff_parser.py +++ b/sklearn/datasets/_arff_parser.py @@ -16,6 +16,7 @@ from sklearn.externals._arff import ArffSparseDataType from sklearn.utils._chunking import chunk_generator, get_chunk_n_rows from sklearn.utils._optional_dependencies import check_pandas_support +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.fixes import pd_fillna @@ -184,21 +185,21 @@ def _io_to_generator(gzip_file): pd = check_pandas_support("fetch_openml with as_frame=True") columns_info = OrderedDict(arff_container["attributes"]) - columns_names = list(columns_info.keys()) + column_names = list(columns_info.keys()) # calculate chunksize first_row = next(arff_container["data"]) - first_df = pd.DataFrame([first_row], columns=columns_names, copy=False) + first_df = pd.DataFrame([first_row], columns=column_names, copy=False) row_bytes = first_df.memory_usage(deep=True).sum() chunksize = get_chunk_n_rows(row_bytes) # read arff data with chunks - columns_to_keep = [col for col in columns_names if col in columns_to_select] + columns_to_keep = [col for col in column_names if col in columns_to_select] dfs = [first_df[columns_to_keep]] for data in chunk_generator(arff_container["data"], chunksize): dfs.append( - pd.DataFrame(data, columns=columns_names, copy=False)[columns_to_keep] + pd.DataFrame(data, columns=column_names, copy=False)[columns_to_keep] ) # dfs[0] contains only one row, which may not have enough data to infer to # column's dtype. Here we use `dfs[1]` to configure the dtype in dfs[0] @@ -262,12 +263,12 @@ def _io_to_generator(gzip_file): arff_data_X = _split_sparse_columns(arff_data, feature_indices_to_select) num_obs = max(arff_data[1]) + 1 X_shape = (num_obs, len(feature_indices_to_select)) - X = sp.sparse.coo_matrix( + X = sp.sparse.coo_array( (arff_data_X[0], (arff_data_X[1], arff_data_X[2])), shape=X_shape, dtype=np.float64, ) - X = X.tocsr() + X = _align_api_if_sparse(X.tocsr()) y = _sparse_data_to_array(arff_data, target_indices_to_select) else: # This should never happen diff --git a/sklearn/datasets/_base.py b/sklearn/datasets/_base.py index 39a84d9a45ff8..de8b954e2dc90 100644 --- a/sklearn/datasets/_base.py +++ b/sklearn/datasets/_base.py @@ -911,7 +911,7 @@ def load_breast_cancer(*, return_X_y=False, as_frame=False): def load_digits(*, n_class=10, return_X_y=False, as_frame=False): """Load and return the digits dataset (classification). - Each datapoint is a 8x8 image of a digit. + Each datapoint is an 8x8 image of a digit. ================= ============== Classes 10 diff --git a/sklearn/datasets/_california_housing.py b/sklearn/datasets/_california_housing.py index ed2fbde9583c4..0cfbfe3f6adb8 100644 --- a/sklearn/datasets/_california_housing.py +++ b/sklearn/datasets/_california_housing.py @@ -43,7 +43,7 @@ from sklearn.utils._param_validation import Interval, validate_params # The original data can be found at: -# https://www.dcc.fc.up.pt/~ltorgo/Regression/cal_housing.tgz +# https://lib.stat.cmu.edu/datasets/houses.zip ARCHIVE = RemoteFileMetadata( filename="cal_housing.tgz", url="https://ndownloader.figshare.com/files/5976036", diff --git a/sklearn/datasets/_kddcup99.py b/sklearn/datasets/_kddcup99.py index 7a8571a3686df..0cc70fc0a2f4c 100644 --- a/sklearn/datasets/_kddcup99.py +++ b/sklearn/datasets/_kddcup99.py @@ -402,12 +402,8 @@ def _fetch_brute_kddcup99( X = Xy[:, :-1] y = Xy[:, -1] - # XXX bug when compress!=0: - # (error: 'Incorrect data length while decompressing[...] the file - # could be corrupted.') - - joblib.dump(X, samples_path, compress=0) - joblib.dump(y, targets_path, compress=0) + joblib.dump(X, samples_path, compress=3) + joblib.dump(y, targets_path, compress=3) else: raise OSError("Data not found and `download_if_missing` is False") diff --git a/sklearn/datasets/_openml.py b/sklearn/datasets/_openml.py index 7ca17cf1ad0a9..b707797c99859 100644 --- a/sklearn/datasets/_openml.py +++ b/sklearn/datasets/_openml.py @@ -33,10 +33,10 @@ __all__ = ["fetch_openml"] -_SEARCH_NAME = "https://api.openml.org/api/v1/json/data/list/data_name/{}/limit/2" -_DATA_INFO = "https://api.openml.org/api/v1/json/data/{}" -_DATA_FEATURES = "https://api.openml.org/api/v1/json/data/features/{}" -_DATA_QUALITIES = "https://api.openml.org/api/v1/json/data/qualities/{}" +_SEARCH_NAME = "https://www.openml.org/api/v1/json/data/list/data_name/{}/limit/2" +_DATA_INFO = "https://www.openml.org/api/v1/json/data/{}" +_DATA_FEATURES = "https://www.openml.org/api/v1/json/data/features/{}" +_DATA_QUALITIES = "https://www.openml.org/api/v1/json/data/qualities/{}" OpenmlQualitiesType = List[Dict[str, str]] OpenmlFeaturesType = List[Dict[str, str]] diff --git a/sklearn/datasets/_rcv1.py b/sklearn/datasets/_rcv1.py index c5be518a1d711..cc173f97791e8 100644 --- a/sklearn/datasets/_rcv1.py +++ b/sklearn/datasets/_rcv1.py @@ -29,6 +29,7 @@ from sklearn.utils import Bunch from sklearn.utils import shuffle as shuffle_ from sklearn.utils._param_validation import Interval, StrOptions, validate_params +from sklearn.utils._sparse import _align_api_if_sparse # The original vectorized data can be found at: # http://www.ai.mit.edu/projects/jmlr/papers/volume5/lewis04a/a13-vector-files/lyrl2004_vectors_test_pt0.dat.gz @@ -285,7 +286,7 @@ def fetch_rcv1( # reorder categories in lexicographic order order = np.argsort(categories) categories = categories[order] - y = sp.csr_matrix(y[:, order]) + y = _align_api_if_sparse(sp.csr_array(y[:, order])) joblib.dump(y, sample_topics_path, compress=9) joblib.dump(categories, topics_path, compress=9) @@ -314,6 +315,7 @@ def fetch_rcv1( fdescr = load_descr("rcv1.rst") + X = _align_api_if_sparse(X) if return_X_y: return X, y diff --git a/sklearn/datasets/_samples_generator.py b/sklearn/datasets/_samples_generator.py index 96eb154439ebb..98e7826bea2d5 100644 --- a/sklearn/datasets/_samples_generator.py +++ b/sklearn/datasets/_samples_generator.py @@ -18,6 +18,12 @@ from sklearn.utils import Bunch, check_array, check_random_state from sklearn.utils import shuffle as util_shuffle from sklearn.utils._param_validation import Interval, StrOptions, validate_params +from sklearn.utils._sparse import _align_api_if_sparse +from sklearn.utils.fixes import ( + _sparse_diags_array, + _sparse_eye_array, + _sparse_random_array, +) from sklearn.utils.random import sample_without_replacement @@ -549,10 +555,12 @@ def sample_example(): X_indptr.append(len(X_indices)) Y.append(y) X_data = np.ones(len(X_indices), dtype=np.float64) - X = sp.csr_matrix((X_data, X_indices, X_indptr), shape=(n_samples, n_features)) + X = sp.csr_array((X_data, X_indices, X_indptr), shape=(n_samples, n_features)) X.sum_duplicates() if not sparse: X = X.toarray() + else: + X = _align_api_if_sparse(X) # return_indicator can be True due to backward compatibility if return_indicator in (True, "sparse", "dense"): @@ -1817,13 +1825,12 @@ def make_sparse_spd_matrix( """ random_state = check_random_state(random_state) - chol = -sp.eye(n_dim) - aux = sp.random( - m=n_dim, - n=n_dim, + chol = -_sparse_eye_array(n_dim) + aux = _sparse_random_array( + shape=(n_dim, n_dim), density=1 - alpha, - data_rvs=lambda x: random_state.uniform( - low=smallest_coef, high=largest_coef, size=x + data_sampler=lambda size: random_state.uniform( + low=smallest_coef, high=largest_coef, size=size ), random_state=random_state, ) @@ -1839,13 +1846,13 @@ def make_sparse_spd_matrix( if norm_diag: # Form the diagonal vector into a row matrix - d = sp.diags(1.0 / np.sqrt(prec.diagonal())) + d = _sparse_diags_array(1.0 / np.sqrt(prec.diagonal())) prec = d @ prec @ d if sparse_format is None: return prec.toarray() else: - return prec.asformat(sparse_format) + return _align_api_if_sparse(prec.asformat(sparse_format)) @validate_params( diff --git a/sklearn/datasets/_svmlight_format_io.py b/sklearn/datasets/_svmlight_format_io.py index 13e5d650dc2cc..5c26e711a054a 100644 --- a/sklearn/datasets/_svmlight_format_io.py +++ b/sklearn/datasets/_svmlight_format_io.py @@ -32,6 +32,7 @@ StrOptions, validate_params, ) +from sklearn.utils._sparse import _align_api_if_sparse @validate_params( @@ -409,9 +410,9 @@ def get_data(): result = [] for data, indices, indptr, y, query_values in r: shape = (indptr.shape[0] - 1, n_features) - X = sp.csr_matrix((data, indices, indptr), shape) + X = sp.csr_array((data, indices, indptr), shape) X.sort_indices() - result += X, y + result += _align_api_if_sparse(X), y if query_id: result.append(query_values) diff --git a/sklearn/datasets/descr/california_housing.rst b/sklearn/datasets/descr/california_housing.rst index 47a25b9ba272a..599b0b69723ec 100644 --- a/sklearn/datasets/descr/california_housing.rst +++ b/sklearn/datasets/descr/california_housing.rst @@ -21,8 +21,8 @@ California Housing dataset :Missing Attribute Values: None -This dataset was obtained from the StatLib repository. -https://www.dcc.fc.up.pt/~ltorgo/Regression/cal_housing.html +This dataset was obtained from the StatLib: +https://lib.stat.cmu.edu/datasets/houses.zip The target variable is the median house value for California districts, expressed in hundreds of thousands of dollars ($100,000). diff --git a/sklearn/datasets/descr/wine_data.rst b/sklearn/datasets/descr/wine_data.rst index 64efe49900ebf..8d5c3126df21e 100644 --- a/sklearn/datasets/descr/wine_data.rst +++ b/sklearn/datasets/descr/wine_data.rst @@ -63,7 +63,7 @@ wine. Original Owners: Forina, M. et al, PARVUS - -An Extendible Package for Data Exploration, Classification and Correlation. +An Extendable Package of Programs for Data Exploration, Classification and Correlation. Institute of Pharmaceutical and Food Analysis and Technologies, Via Brigata Salerno, 16147 Genoa, Italy. diff --git a/sklearn/datasets/tests/test_lfw.py b/sklearn/datasets/tests/test_lfw.py index cc86fe8637232..7ea741679d11b 100644 --- a/sklearn/datasets/tests/test_lfw.py +++ b/sklearn/datasets/tests/test_lfw.py @@ -105,7 +105,7 @@ def test_load_fake_lfw_people(mock_data_home): data_home=mock_data_home, min_faces_per_person=3, download_if_missing=False ) - # The data is croped around the center as a rectangular bounding box + # The data is cropped around the center as a rectangular bounding box # around the face. Colors are converted to gray levels: assert lfw_people.images.shape == (10, 62, 47) assert lfw_people.data.shape == (10, 2914) @@ -177,7 +177,7 @@ def test_load_fake_lfw_pairs(mock_data_home): data_home=mock_data_home, download_if_missing=False ) - # The data is croped around the center as a rectangular bounding box + # The data is cropped around the center as a rectangular bounding box # around the face. Colors are converted to gray levels: assert lfw_pairs_train.pairs.shape == (10, 2, 62, 47) diff --git a/sklearn/datasets/tests/test_openml.py b/sklearn/datasets/tests/test_openml.py index 3c29a526a008b..916c42d7cab7e 100644 --- a/sklearn/datasets/tests/test_openml.py +++ b/sklearn/datasets/tests/test_openml.py @@ -72,10 +72,10 @@ def _monkey_patch_webbased_functions(context, data_id, gzip_response): # monkey patches the urlopen function. Important note: Do NOT use this # in combination with a regular cache directory, as the files that are # stored as cache should not be mixed up with real openml datasets - url_prefix_data_description = "https://api.openml.org/api/v1/json/data/" - url_prefix_data_features = "https://api.openml.org/api/v1/json/data/features/" + url_prefix_data_description = "https://www.openml.org/api/v1/json/data/" + url_prefix_data_features = "https://www.openml.org/api/v1/json/data/features/" url_prefix_download_data = "https://www.openml.org/data/v1/download" - url_prefix_data_list = "https://api.openml.org/api/v1/json/data/list/" + url_prefix_data_list = "https://www.openml.org/api/v1/json/data/list/" path_suffix = ".gz" read_fn = gzip.open @@ -163,7 +163,7 @@ def _mock_urlopen_data_list(url, has_gzip_header): data_file_name = _file_name(url, ".json") data_file_path = resources.files(data_module) / data_file_name - # load the file itself, to simulate a http error + # load the file itself, to simulate an http error with data_file_path.open("rb") as f: decompressed_f = read_fn(f, "rb") decoded_s = decompressed_f.read().decode("utf-8") @@ -1042,7 +1042,7 @@ def test_fetch_openml_sparse_arff_error(monkeypatch, params, err_msg): @pytest.mark.parametrize( "data_id, data_type", [ - (61, "dataframe"), # iris dataset version 1 + (61, "pandas"), # iris dataset version 1 (292, "sparse"), # Australian dataset version 1 ], ) @@ -1052,7 +1052,7 @@ def test_fetch_openml_auto_mode(monkeypatch, data_id, data_type): _monkey_patch_webbased_functions(monkeypatch, data_id, True) data = fetch_openml(data_id=data_id, as_frame="auto", cache=False) - klass = pd.DataFrame if data_type == "dataframe" else scipy.sparse.csr_matrix + klass = pd.DataFrame if data_type == "pandas" else scipy.sparse.csr_matrix assert isinstance(data.data, klass) diff --git a/sklearn/datasets/tests/test_samples_generator.py b/sklearn/datasets/tests/test_samples_generator.py index 81e8183c6722e..2abb218dc7467 100644 --- a/sklearn/datasets/tests/test_samples_generator.py +++ b/sklearn/datasets/tests/test_samples_generator.py @@ -322,19 +322,22 @@ def test_make_multilabel_classification_return_indicator(): assert_almost_equal(p_w_c.sum(axis=0), [1] * 3) -def test_make_multilabel_classification_return_indicator_sparse(): - for allow_unlabeled, min_length in zip((True, False), (0, 1)): - X, Y = make_multilabel_classification( - n_samples=25, - n_features=20, - n_classes=3, - random_state=0, - return_indicator="sparse", - allow_unlabeled=allow_unlabeled, - ) - assert X.shape == (25, 20), "X shape mismatch" - assert Y.shape == (25, 3), "Y shape mismatch" - assert sp.issparse(Y) +@pytest.mark.parametrize("allow_unlabeled", [True, False]) +@pytest.mark.parametrize("sparse_feature", [True, False]) +def test_make_multilabel_classification_return_sparse(allow_unlabeled, sparse_feature): + X, Y = make_multilabel_classification( + n_samples=25, + n_features=20, + n_classes=3, + random_state=0, + sparse=sparse_feature, + return_indicator="sparse", + allow_unlabeled=allow_unlabeled, + ) + assert X.shape == (25, 20), "X shape mismatch" + assert Y.shape == (25, 3), "Y shape mismatch" + assert sp.issparse(Y) + assert sp.issparse(X) if sparse_feature else not sp.issparse(X) def test_make_hastie_10_2(): diff --git a/sklearn/decomposition/_dict_learning.py b/sklearn/decomposition/_dict_learning.py index d4550e4ce8982..2a32ad92de83e 100644 --- a/sklearn/decomposition/_dict_learning.py +++ b/sklearn/decomposition/_dict_learning.py @@ -1360,7 +1360,7 @@ def fit(self, X, y=None): if X.shape[1] != self.dictionary.shape[1]: raise ValueError( "Dictionary and X have different numbers of features:" - f"dictionary.shape: {self.dictionary.shape} X.shape{X.shape}" + f"dictionary.shape: {self.dictionary.shape} X.shape: {X.shape}" ) return self diff --git a/sklearn/decomposition/_fastica.py b/sklearn/decomposition/_fastica.py index ea72a3790631f..59bffc7621d54 100644 --- a/sklearn/decomposition/_fastica.py +++ b/sklearn/decomposition/_fastica.py @@ -140,16 +140,19 @@ def _ica_par(X, tol, g, fun_args, max_iter, w_init): return W, ii + 1 -# Some standard non-linear functions. -# XXX: these should be optimized, as they can be a bottleneck. def _logcosh(x, fun_args=None): - alpha = fun_args.get("alpha", 1.0) # comment it out? + alpha = fun_args.get("alpha", 1.0) x *= alpha gx = np.tanh(x, x) # apply the tanh inplace + + if x.ndim == 1: + return gx, alpha * (1 - gx**2) + + # When the input is 2D, compute in a loop to avoid extra allocation + # of array of shape x.shape g_x = np.empty(x.shape[0], dtype=x.dtype) - # XXX compute in chunks to avoid extra allocation - for i, gx_i in enumerate(gx): # please don't vectorize. + for i, gx_i in enumerate(gx): g_x[i] = (alpha * (1 - gx_i**2)).mean() return gx, g_x diff --git a/sklearn/decomposition/_incremental_pca.py b/sklearn/decomposition/_incremental_pca.py index 3988b7fc97573..a9342cda2d869 100644 --- a/sklearn/decomposition/_incremental_pca.py +++ b/sklearn/decomposition/_incremental_pca.py @@ -177,7 +177,7 @@ class IncrementalPCA(_BasePCA): >>> transformer.partial_fit(X[:100, :]) IncrementalPCA(batch_size=200, n_components=7) >>> # or let the fit function itself divide the data into batches - >>> X_sparse = sparse.csr_matrix(X) + >>> X_sparse = sparse.csr_array(X) >>> X_transformed = transformer.fit_transform(X_sparse) >>> X_transformed.shape (1797, 7) diff --git a/sklearn/decomposition/_nmf.py b/sklearn/decomposition/_nmf.py index 25efec3d564ad..99c3f8bc05bd3 100644 --- a/sklearn/decomposition/_nmf.py +++ b/sklearn/decomposition/_nmf.py @@ -25,6 +25,7 @@ from sklearn.exceptions import ConvergenceWarning from sklearn.utils import check_array, check_random_state, gen_batches from sklearn.utils._param_validation import Interval, StrOptions, validate_params +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import _randomized_svd, safe_sparse_dot, squared_norm from sklearn.utils.validation import check_is_fitted, check_non_negative, validate_data @@ -196,8 +197,8 @@ def _special_sparse_dot(W, H, X): axis=1 ) - WH = sp.coo_matrix((dot_vals, (ii, jj)), shape=X.shape) - return WH.tocsr() + WH = sp.coo_array((dot_vals, (ii, jj)), shape=X.shape) + return _align_api_if_sparse(WH.tocsr()) else: return np.dot(W, H) @@ -479,9 +480,11 @@ def _fit_coordinate_descent( Cichocki, Andrzej, and P. H. A. N. Anh-Huy. IEICE transactions on fundamentals of electronics, communications and computer sciences 92.3: 708-721, 2009. """ - # so W and Ht are both in C order in memory - Ht = check_array(H.T, order="C") - X = check_array(X, accept_sparse="csr") + # ensure that W and Ht are both in C order in memory and that X is csr + W = np.ascontiguousarray(W) + Ht = np.ascontiguousarray(H.T) + if sp.issparse(X) and X.format == "csc": + X = X.tocsr() rng = check_random_state(random_state) diff --git a/sklearn/decomposition/_truncated_svd.py b/sklearn/decomposition/_truncated_svd.py index afef1eaa7164f..c6decd460a898 100644 --- a/sklearn/decomposition/_truncated_svd.py +++ b/sklearn/decomposition/_truncated_svd.py @@ -141,12 +141,12 @@ class to data once, then keep the instance around to do transformations. Examples -------- >>> from sklearn.decomposition import TruncatedSVD - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> import numpy as np >>> np.random.seed(0) >>> X_dense = np.random.rand(100, 100) >>> X_dense[:, 2 * np.arange(50)] = 0 - >>> X = csr_matrix(X_dense) + >>> X = csr_array(X_dense) >>> svd = TruncatedSVD(n_components=5, n_iter=7, random_state=42) >>> svd.fit(X) TruncatedSVD(n_components=5, n_iter=7, random_state=42) @@ -163,7 +163,7 @@ class to data once, then keep the instance around to do transformations. "algorithm": [StrOptions({"arpack", "randomized"})], "n_iter": [Interval(Integral, 0, None, closed="left")], "n_oversamples": [Interval(Integral, 1, None, closed="left")], - "power_iteration_normalizer": [StrOptions({"auto", "OR", "LU", "none"})], + "power_iteration_normalizer": [StrOptions({"auto", "QR", "LU", "none"})], "random_state": ["random_state"], "tol": [Interval(Real, 0, None, closed="left")], } diff --git a/sklearn/decomposition/tests/test_dict_learning.py b/sklearn/decomposition/tests/test_dict_learning.py index 80bcd92480ae7..4b53c942d23fa 100644 --- a/sklearn/decomposition/tests/test_dict_learning.py +++ b/sklearn/decomposition/tests/test_dict_learning.py @@ -978,6 +978,7 @@ def test_dict_learning_online_numerical_consistency(method): ) def test_get_feature_names_out(estimator): """Check feature names for dict learning estimators.""" + estimator = clone(estimator) estimator.fit(X) n_components = X.shape[1] diff --git a/sklearn/decomposition/tests/test_kernel_pca.py b/sklearn/decomposition/tests/test_kernel_pca.py index 6d77a6379a2b7..47c6890df776e 100644 --- a/sklearn/decomposition/tests/test_kernel_pca.py +++ b/sklearn/decomposition/tests/test_kernel_pca.py @@ -355,7 +355,7 @@ def test_nested_circles(): train_score = Perceptron(max_iter=5).fit(X, y).score(X, y) assert train_score < 0.8 - # Project the circles data into the first 2 components of a RBF Kernel + # Project the circles data into the first 2 components of an RBF Kernel # PCA model. # Note that the gamma value is data dependent. If this test breaks # and the gamma value has to be updated, the Kernel PCA example will diff --git a/sklearn/decomposition/tests/test_nmf.py b/sklearn/decomposition/tests/test_nmf.py index 17be798b3f392..f287b19c184eb 100644 --- a/sklearn/decomposition/tests/test_nmf.py +++ b/sklearn/decomposition/tests/test_nmf.py @@ -1008,3 +1008,18 @@ def test_nmf_custom_init_shape_error(): with pytest.raises(ValueError, match="Array with wrong second dimension passed"): nmf.fit(X, H=H, W=rng.random_sample((6, 3))) + + +@pytest.mark.parametrize("init", (None, "nndsvd", "nndsvda", "nndsvdar", "random")) +@pytest.mark.parametrize("shape", ((30, 10), (10, 30)), ids=("tall", "wide")) +@pytest.mark.parametrize("solver", ("cd", "mu")) +def test_nmf_smoke(init, shape, solver): + """Smoke test NMF with all inits, solvers on tall/wide arrays.""" + rng = np.random.RandomState(0) + X = np.abs(rng.random_sample(shape)) + + nmf = NMF(n_components=5, init=init, random_state=0, solver=solver) + W = nmf.fit_transform(X) + + assert W.shape == (shape[0], 5) + assert nmf.components_.shape == (5, shape[1]) diff --git a/sklearn/decomposition/tests/test_pca.py b/sklearn/decomposition/tests/test_pca.py index 588ca9fa6c677..0aec4837cc490 100644 --- a/sklearn/decomposition/tests/test_pca.py +++ b/sklearn/decomposition/tests/test_pca.py @@ -4,7 +4,6 @@ import numpy as np import pytest -import scipy as sp from numpy.testing import assert_array_equal from sklearn import config_context, datasets @@ -14,8 +13,7 @@ from sklearn.decomposition._pca import _assess_dimension, _infer_dimension from sklearn.utils._array_api import ( _atol_for_type, - _convert_to_numpy, - _get_namespace_device_dtype_ids, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._array_api import device as array_device @@ -24,7 +22,7 @@ from sklearn.utils.estimator_checks import ( check_array_api_input_and_values, ) -from sklearn.utils.fixes import CSC_CONTAINERS, CSR_CONTAINERS +from sklearn.utils.fixes import CSC_CONTAINERS, CSR_CONTAINERS, _sparse_random_array iris = datasets.load_iris() PCA_SOLVERS = ["full", "covariance_eigh", "arpack", "randomized", "auto"] @@ -87,17 +85,12 @@ def test_pca_sparse( atol = 1e-12 transform_atol = 1e-10 - random_state = np.random.default_rng(global_random_seed) + rng = np.random.default_rng(global_random_seed) X = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - density=density, - ) + _sparse_random_array((SPARSE_M, SPARSE_N), rng=rng, density=density) ) # Scale the data + vary the column means - scale_vector = random_state.random(X.shape[1]) * scale + scale_vector = rng.random(X.shape[1]) * scale X = X.multiply(scale_vector) pca = PCA( @@ -120,12 +113,7 @@ def test_pca_sparse( # Test transform X2 = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - density=density, - ) + _sparse_random_array((SPARSE_M, SPARSE_N), rng=rng, density=density) ) X2d = X2.toarray() @@ -135,23 +123,10 @@ def test_pca_sparse( @pytest.mark.parametrize("sparse_container", CSR_CONTAINERS + CSC_CONTAINERS) def test_pca_sparse_fit_transform(global_random_seed, sparse_container): - random_state = np.random.default_rng(global_random_seed) - X = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - density=0.01, - ) - ) - X2 = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - density=0.01, - ) - ) + rng = np.random.default_rng(global_random_seed) + shp = (SPARSE_M, SPARSE_N) + X = sparse_container(_sparse_random_array(shp, rng=rng, density=0.01)) + X2 = sparse_container(_sparse_random_array(shp, rng=rng, density=0.01)) pca_fit = PCA(n_components=10, svd_solver="arpack", random_state=global_random_seed) pca_fit_transform = PCA( @@ -170,14 +145,8 @@ def test_pca_sparse_fit_transform(global_random_seed, sparse_container): @pytest.mark.parametrize("svd_solver", ["randomized", "full"]) @pytest.mark.parametrize("sparse_container", CSR_CONTAINERS + CSC_CONTAINERS) def test_sparse_pca_solver_error(global_random_seed, svd_solver, sparse_container): - random_state = np.random.RandomState(global_random_seed) - X = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - ) - ) + rng = np.random.RandomState(global_random_seed) + X = sparse_container(_sparse_random_array((SPARSE_M, SPARSE_N), rng=rng)) pca = PCA(n_components=30, svd_solver=svd_solver) error_msg_pattern = ( 'PCA only support sparse inputs with the "arpack" and "covariance_eigh"' @@ -188,18 +157,12 @@ def test_sparse_pca_solver_error(global_random_seed, svd_solver, sparse_containe @pytest.mark.parametrize("sparse_container", CSR_CONTAINERS + CSC_CONTAINERS) -def test_sparse_pca_auto_arpack_singluar_values_consistency( +def test_sparse_pca_auto_arpack_singular_values_consistency( global_random_seed, sparse_container ): """Check that "auto" and "arpack" solvers are equivalent for sparse inputs.""" - random_state = np.random.RandomState(global_random_seed) - X = sparse_container( - sp.sparse.random( - SPARSE_M, - SPARSE_N, - random_state=random_state, - ) - ) + rng = np.random.RandomState(global_random_seed) + X = sparse_container(_sparse_random_array((SPARSE_M, SPARSE_N), rng=rng)) pca_arpack = PCA(n_components=10, svd_solver="arpack").fit(X) pca_auto = PCA(n_components=10, svd_solver="auto").fit(X) assert_allclose(pca_arpack.singular_values_, pca_auto.singular_values_, rtol=5e-3) @@ -914,7 +877,7 @@ def test_mle_simple_case(): assert pca_skl.n_components_ == n_dim - 1 -def test_assess_dimesion_rank_one(): +def test_assess_dimension_rank_one(): # Make sure assess_dimension works properly on a matrix of rank 1 n_samples, n_features = 9, 6 X = np.ones((n_samples, n_features)) # rank 1 matrix @@ -972,8 +935,10 @@ def test_variance_correctness(copy): np.testing.assert_allclose(pca_var, true_var) -def check_array_api_get_precision(name, estimator, array_namespace, device, dtype_name): - xp = _array_api_for_tests(array_namespace, device) +def check_array_api_get_precision( + name, estimator, array_namespace, device_name, dtype_name +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) iris_np = iris.data.astype(dtype_name) iris_xp = xp.asarray(iris_np, device=device) @@ -989,7 +954,7 @@ def check_array_api_get_precision(name, estimator, array_namespace, device, dtyp assert precision_xp.dtype == iris_xp.dtype assert_allclose( - _convert_to_numpy(precision_xp, xp=xp), + move_to(precision_xp, xp=np, device="cpu"), precision_np, rtol=rtol, atol=_atol_for_type(dtype_name), @@ -999,7 +964,7 @@ def check_array_api_get_precision(name, estimator, array_namespace, device, dtyp assert covariance_xp.dtype == iris_xp.dtype assert_allclose( - _convert_to_numpy(covariance_xp, xp=xp), + move_to(covariance_xp, xp=np, device="cpu"), covariance_np, rtol=rtol, atol=_atol_for_type(dtype_name), @@ -1007,9 +972,8 @@ def check_array_api_get_precision(name, estimator, array_namespace, device, dtyp @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "check", @@ -1034,17 +998,22 @@ def check_array_api_get_precision(name, estimator, array_namespace, device, dtyp ids=_get_check_estimator_ids, ) def test_pca_array_api_compliance( - estimator, check, array_namespace, device, dtype_name + estimator, check, array_namespace, device_name, dtype_name ): name = estimator.__class__.__name__ estimator = clone(estimator) - check(name, estimator, array_namespace, device=device, dtype_name=dtype_name) + check( + name, + estimator, + array_namespace, + device_name=device_name, + dtype_name=dtype_name, + ) @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "check", @@ -1064,14 +1033,20 @@ def test_pca_array_api_compliance( ids=_get_check_estimator_ids, ) def test_pca_mle_array_api_compliance( - estimator, check, array_namespace, device, dtype_name + estimator, check, array_namespace, device_name, dtype_name ): name = estimator.__class__.__name__ - check(name, estimator, array_namespace, device=device, dtype_name=dtype_name) + check( + name, + estimator, + array_namespace, + device_name=device_name, + dtype_name=dtype_name, + ) # Simpler variant of the generic check_array_api_input checker tailored for # the specific case of PCA with mle-trimmed components. - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X, y = make_classification(random_state=42) X = X.astype(dtype_name, copy=False) @@ -1092,11 +1067,11 @@ def test_pca_mle_array_api_compliance( est_xp.fit(X_xp, y_xp) components_xp = est_xp.components_ assert array_device(components_xp) == array_device(X_xp) - components_xp_np = _convert_to_numpy(components_xp, xp=xp) + components_xp_np = move_to(components_xp, xp=np, device="cpu") explained_variance_xp = est_xp.explained_variance_ assert array_device(explained_variance_xp) == array_device(X_xp) - explained_variance_xp_np = _convert_to_numpy(explained_variance_xp, xp=xp) + explained_variance_xp_np = move_to(explained_variance_xp, xp=np, device="cpu") assert components_xp_np.dtype == components_np.dtype assert components_xp_np.shape[1] == components_np.shape[1] diff --git a/sklearn/decomposition/tests/test_truncated_svd.py b/sklearn/decomposition/tests/test_truncated_svd.py index 07b35c873ee3e..ef982abde2992 100644 --- a/sklearn/decomposition/tests/test_truncated_svd.py +++ b/sklearn/decomposition/tests/test_truncated_svd.py @@ -2,11 +2,11 @@ import numpy as np import pytest -import scipy.sparse as sp from sklearn.decomposition import PCA, TruncatedSVD from sklearn.utils import check_random_state from sklearn.utils._testing import assert_allclose, assert_array_less +from sklearn.utils.fixes import _sparse_random_array SVD_SOLVERS = ["arpack", "randomized"] @@ -15,7 +15,7 @@ def X_sparse(): # Make an X that looks somewhat like a small tf-idf matrix. rng = check_random_state(42) - X = sp.random(60, 55, density=0.2, format="csr", random_state=rng) + X = _sparse_random_array((60, 55), density=0.2, format="csr", rng=rng) X.data[:] = 1 + np.log(X.data) return X @@ -198,14 +198,27 @@ def test_truncated_svd_eq_pca(X_sparse): @pytest.mark.parametrize( - "algorithm, tol", [("randomized", 0.0), ("arpack", 1e-6), ("arpack", 0.0)] + "algorithm, tol, normalizer", + [ + ("randomized", 0.0, "auto"), + ("randomized", 0.0, "QR"), + ("randomized", 0.0, "LU"), + ("randomized", 0.0, "none"), + ("arpack", 1e-6, "auto"), + ("arpack", 0.0, "auto"), + ], ) @pytest.mark.parametrize("kind", ("dense", "sparse")) -def test_fit_transform(X_sparse, algorithm, tol, kind): +def test_fit_transform(X_sparse, algorithm, tol, kind, normalizer): # fit_transform(X) should equal fit(X).transform(X) X = X_sparse if kind == "sparse" else X_sparse.toarray() svd = TruncatedSVD( - n_components=5, n_iter=7, random_state=42, algorithm=algorithm, tol=tol + n_components=5, + n_iter=7, + random_state=42, + algorithm=algorithm, + power_iteration_normalizer=normalizer, + tol=tol, ) X_transformed_1 = svd.fit_transform(X) X_transformed_2 = svd.fit(X).transform(X) diff --git a/sklearn/discriminant_analysis.py b/sklearn/discriminant_analysis.py index e6396462cef5d..0fe50e33a5689 100644 --- a/sklearn/discriminant_analysis.py +++ b/sklearn/discriminant_analysis.py @@ -20,7 +20,13 @@ from sklearn.covariance import empirical_covariance, ledoit_wolf, shrunk_covariance from sklearn.linear_model._base import LinearClassifierMixin from sklearn.preprocessing import StandardScaler -from sklearn.utils._array_api import _expit, device, get_namespace, size +from sklearn.utils._array_api import ( + _expit, + check_same_namespace, + device, + get_namespace, + size, +) from sklearn.utils._param_validation import HasMethods, Interval, StrOptions from sklearn.utils.extmath import softmax from sklearn.utils.multiclass import check_classification_targets, unique_labels @@ -749,6 +755,7 @@ def transform(self, X): "transform not implemented for 'lsqr' solver (use 'svd' or 'eigen')." ) check_is_fitted(self) + check_same_namespace(X, self, attribute="coef_", method="transform") X = validate_data(self, X, reset=False) if self.solver == "svd": @@ -772,10 +779,11 @@ def predict_proba(self, X): Estimated probabilities. """ check_is_fitted(self) + check_same_namespace(X, self, attribute="coef_", method="predict_proba") xp, _ = get_namespace(X) decision = self.decision_function(X) if size(self.classes_) == 2: - proba = _expit(decision, xp) + proba = _expit(decision, xp=xp) return xp.stack([1 - proba, proba], axis=1) else: return softmax(decision) @@ -793,6 +801,7 @@ def predict_log_proba(self, X): C : ndarray of shape (n_samples, n_classes) Estimated log probabilities. """ + check_same_namespace(X, self, attribute="coef_", method="predict_log_proba") xp, _ = get_namespace(X) prediction = self.predict_proba(X) diff --git a/sklearn/ensemble/_bagging.py b/sklearn/ensemble/_bagging.py index 067bdb9e7db0e..e7d470fcf4fa3 100644 --- a/sklearn/ensemble/_bagging.py +++ b/sklearn/ensemble/_bagging.py @@ -14,6 +14,7 @@ from sklearn.base import ClassifierMixin, RegressorMixin, _fit_context from sklearn.ensemble._base import BaseEnsemble, _partition_estimators +from sklearn.ensemble._bootstrap import _get_n_samples_bootstrap from sklearn.metrics import accuracy_score, r2_score from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from sklearn.utils import Bunch, _safe_indexing, check_random_state, column_or_1d @@ -273,6 +274,7 @@ class BaseBagging(BaseEnsemble, metaclass=ABCMeta): "estimator": [HasMethods(["fit", "predict"]), None], "n_estimators": [Interval(Integral, 1, None, closed="left")], "max_samples": [ + None, Interval(Integral, 1, None, closed="left"), Interval(RealNotInt, 0, 1, closed="right"), ], @@ -295,7 +297,7 @@ def __init__( estimator=None, n_estimators=10, *, - max_samples=1.0, + max_samples=None, max_features=1.0, bootstrap=True, bootstrap_features=False, @@ -340,7 +342,9 @@ def fit(self, X, y, sample_weight=None, **fit_params): Sample weights. If None, then samples are equally weighted. Used as probabilities to sample the training set. Note that the expected frequency semantics for the `sample_weight` parameter are only - fulfilled when sampling with replacement `bootstrap=True`. + fulfilled when sampling with replacement `bootstrap=True` and using + a float or integer `max_samples` (instead of the default + `max_samples=None`). **fit_params : dict Parameters to pass to the underlying estimators. @@ -462,20 +466,7 @@ def _fit( if max_samples is None: max_samples = self.max_samples - if not isinstance(max_samples, numbers.Integral): - if sample_weight is None: - max_samples = max(int(max_samples * X.shape[0]), 1) - else: - sw_sum = np.sum(sample_weight) - if sw_sum <= 1: - raise ValueError( - f"The total sum of sample weights is {sw_sum}, which prevents " - "resampling with a fractional value for max_samples=" - f"{max_samples}. Either pass max_samples as an integer or " - "use a larger sample_weight." - ) - max_samples = max(int(max_samples * sw_sum), 1) - + max_samples = _get_n_samples_bootstrap(X.shape[0], max_samples, sample_weight) if not self.bootstrap and max_samples > X.shape[0]: raise ValueError( f"Effective max_samples={max_samples} must be <= n_samples=" @@ -728,13 +719,14 @@ class BaggingClassifier(ClassifierMixin, BaseBagging): n_estimators : int, default=10 The number of base estimators in the ensemble. - max_samples : int or float, default=1.0 + max_samples : int or float, default=None The number of samples to draw from X to train each base estimator (with replacement by default, see `bootstrap` for more details). + - If None, then draw `X.shape[0]` samples irrespective of `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max_samples * X.shape[0]` unweighted samples - or `max_samples * sample_weight.sum()` weighted samples. + - If float, then draw `max_samples * X.shape[0]` unweighted samples or + `max_samples * sample_weight.sum()` weighted samples. max_features : int or float, default=1.0 The number of features to draw from X to train each base estimator ( @@ -867,7 +859,7 @@ def __init__( estimator=None, n_estimators=10, *, - max_samples=1.0, + max_samples=None, max_features=1.0, bootstrap=True, bootstrap_features=False, @@ -1239,12 +1231,14 @@ class BaggingRegressor(RegressorMixin, BaseBagging): n_estimators : int, default=10 The number of base estimators in the ensemble. - max_samples : int or float, default=1.0 + max_samples : int or float, default=None The number of samples to draw from X to train each base estimator (with replacement by default, see `bootstrap` for more details). + - If None, then draw `X.shape[0]` samples irrespective of `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max_samples * X.shape[0]` samples. + - If float, then draw `max_samples * X.shape[0]` unweighted samples or + `max_samples * sample_weight.sum()` weighted samples. max_features : int or float, default=1.0 The number of features to draw from X to train each base estimator ( @@ -1368,7 +1362,7 @@ def __init__( estimator=None, n_estimators=10, *, - max_samples=1.0, + max_samples=None, max_features=1.0, bootstrap=True, bootstrap_features=False, diff --git a/sklearn/ensemble/_bootstrap.py b/sklearn/ensemble/_bootstrap.py new file mode 100644 index 0000000000000..53d3cd51a675a --- /dev/null +++ b/sklearn/ensemble/_bootstrap.py @@ -0,0 +1,69 @@ +"""Utility function to get the number of bootstrap samples.""" + +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +from numbers import Integral +from warnings import warn + + +def _get_n_samples_bootstrap(n_samples, max_samples, sample_weight): + """ + Get the number of samples in a bootstrap sample. + + Notes + ----- + The frequency semantics of :term:`sample_weight` is guaranteed when + `max_samples` is a float or integer, but not when `max_samples` is None. The + returned `n_samples_bootstrap` will be the same between a weighted dataset + with integer `sample_weights` and a dataset with as many rows repeated when + `max_samples` is a float or integer. They will differ when `max_samples` is + None (the weighted and repeated datasets do not have the same number of rows). + + Parameters + ---------- + n_samples : int + Number of samples in the dataset. + + max_samples : None, int or float + The maximum number of samples to draw. + + - If None, then draw `n_samples` samples. + - If int, then draw `max_samples` samples. + - If float, then draw `max_samples * n_samples` unweighted samples or + `max_samples * sample_weight.sum()` weighted samples. + + sample_weight : array of shape (n_samples,) or None + Sample weights. + + Returns + ------- + n_samples_bootstrap : int + The total number of samples to draw for the bootstrap sample. + """ + if max_samples is None: + return n_samples + elif isinstance(max_samples, Integral): + return max_samples + + if sample_weight is None: + weighted_n_samples = n_samples + weighted_n_samples_msg = f"the number of samples is {weighted_n_samples} " + else: + weighted_n_samples = sample_weight.sum() + weighted_n_samples_msg = ( + f"the total sum of sample weights is {weighted_n_samples} " + ) + + # max_samples Real fractional value relative to weighted_n_samples + n_samples_bootstrap = max(int(max_samples * weighted_n_samples), 1) + # Warn when number of bootstrap samples is suspiciously small. + # This heuristic for "suspiciously small" might be adapted if found + # unsuitable in practice. + if n_samples_bootstrap < max(10, n_samples ** (1 / 3)): + warn( + f"Using the fractional value {max_samples=} when {weighted_n_samples_msg}" + f"results in a low number ({n_samples_bootstrap}) of bootstrap samples. " + "We recommend passing `max_samples` as an integer instead." + ) + return n_samples_bootstrap diff --git a/sklearn/ensemble/_forest.py b/sklearn/ensemble/_forest.py index 54ecdec5e977e..28b9d0cbdf63f 100644 --- a/sklearn/ensemble/_forest.py +++ b/sklearn/ensemble/_forest.py @@ -37,8 +37,8 @@ class calls the ``fit`` method of each sub-estimator on random samples import threading from abc import ABCMeta, abstractmethod -from numbers import Integral, Real -from warnings import catch_warnings, simplefilter, warn +from numbers import Integral +from warnings import warn import numpy as np from scipy.sparse import hstack as sparse_hstack @@ -53,6 +53,7 @@ class calls the ``fit`` method of each sub-estimator on random samples is_classifier, ) from sklearn.ensemble._base import BaseEnsemble, _partition_estimators +from sklearn.ensemble._bootstrap import _get_n_samples_bootstrap from sklearn.exceptions import DataConversionWarning from sklearn.metrics import accuracy_score, r2_score from sklearn.preprocessing import OneHotEncoder @@ -63,8 +64,11 @@ class calls the ``fit`` method of each sub-estimator on random samples ExtraTreeClassifier, ExtraTreeRegressor, ) -from sklearn.tree._tree import DOUBLE, DTYPE -from sklearn.utils import check_random_state, compute_sample_weight +from sklearn.utils import ( + check_random_state, + compute_class_weight, + compute_sample_weight, +) from sklearn.utils._param_validation import Interval, RealNotInt, StrOptions from sklearn.utils._tags import get_tags from sklearn.utils.multiclass import check_classification_targets, type_of_target @@ -88,56 +92,34 @@ class calls the ``fit`` method of each sub-estimator on random samples MAX_INT = np.iinfo(np.int32).max -def _get_n_samples_bootstrap(n_samples, max_samples): - """ - Get the number of samples in a bootstrap sample. - - Parameters - ---------- - n_samples : int - Number of samples in the dataset. - max_samples : int or float - The maximum number of samples to draw from the total available: - - if float, this indicates a fraction of the total and should be - the interval `(0.0, 1.0]`; - - if int, this indicates the exact number of samples; - - if None, this indicates the total number of samples. - - Returns - ------- - n_samples_bootstrap : int - The total number of samples to draw for the bootstrap sample. - """ - if max_samples is None: - return n_samples - - if isinstance(max_samples, Integral): - if max_samples > n_samples: - msg = "`max_samples` must be <= n_samples={} but got value {}" - raise ValueError(msg.format(n_samples, max_samples)) - return max_samples - - if isinstance(max_samples, Real): - return max(round(n_samples * max_samples), 1) - - -def _generate_sample_indices(random_state, n_samples, n_samples_bootstrap): +def _generate_sample_indices( + random_state, n_samples, n_samples_bootstrap, sample_weight +): """ Private function used to _parallel_build_trees function.""" random_instance = check_random_state(random_state) - sample_indices = random_instance.randint( - 0, n_samples, n_samples_bootstrap, dtype=np.int32 - ) - + if sample_weight is None: + sample_indices = random_instance.randint(0, n_samples, n_samples_bootstrap) + else: + normalized_sample_weight = sample_weight / np.sum(sample_weight) + sample_indices = random_instance.choice( + n_samples, + n_samples_bootstrap, + replace=True, + p=normalized_sample_weight, + ) + sample_indices = sample_indices.astype(np.int32) return sample_indices -def _generate_unsampled_indices(random_state, n_samples, n_samples_bootstrap): +def _generate_unsampled_indices( + random_state, n_samples, n_samples_bootstrap, sample_weight +): """ Private function used to forest._set_oob_score function.""" sample_indices = _generate_sample_indices( - random_state, n_samples, n_samples_bootstrap + random_state, n_samples, n_samples_bootstrap, sample_weight ) sample_counts = np.bincount(sample_indices, minlength=n_samples) unsampled_mask = sample_counts == 0 @@ -167,28 +149,21 @@ def _parallel_build_trees( if bootstrap: n_samples = X.shape[0] - if sample_weight is None: - curr_sample_weight = np.ones((n_samples,), dtype=np.float64) - else: - curr_sample_weight = sample_weight.copy() - indices = _generate_sample_indices( - tree.random_state, n_samples, n_samples_bootstrap + tree.random_state, n_samples, n_samples_bootstrap, sample_weight ) - sample_counts = np.bincount(indices, minlength=n_samples) - curr_sample_weight *= sample_counts - - if class_weight == "subsample": - with catch_warnings(): - simplefilter("ignore", DeprecationWarning) - curr_sample_weight *= compute_sample_weight("auto", y, indices=indices) - elif class_weight == "balanced_subsample": - curr_sample_weight *= compute_sample_weight("balanced", y, indices=indices) + # Simulate row-wise sampling by passing counts as sample_weight in trees. + sample_weight_tree = np.bincount(indices, minlength=n_samples) + if class_weight == "balanced_subsample": + expanded_class_weight = compute_sample_weight( + "balanced", y, indices=indices + ) + sample_weight_tree = sample_weight_tree * expanded_class_weight tree._fit( X, y, - sample_weight=curr_sample_weight, + sample_weight=sample_weight_tree, check_input=False, missing_values_in_feature_mask=missing_values_in_feature_mask, ) @@ -222,7 +197,7 @@ class BaseForest(MultiOutputMixin, BaseEnsemble, metaclass=ABCMeta): "warm_start": ["boolean"], "max_samples": [ None, - Interval(RealNotInt, 0.0, 1.0, closed="right"), + Interval(RealNotInt, 0.0, None, closed="neither"), Interval(Integral, 1, None, closed="left"), ], } @@ -362,7 +337,7 @@ def fit(self, X, y, sample_weight=None): y, multi_output=True, accept_sparse="csc", - dtype=DTYPE, + dtype=np.float32, ensure_all_finite=False, ) # _compute_missing_values_in_feature_mask checks if X has missing values and @@ -415,16 +390,23 @@ def fit(self, X, y, sample_weight=None): self._n_samples, self.n_outputs_ = y.shape - y, expanded_class_weight = self._validate_y_class_weight(y) + y, expanded_class_weight = self._validate_y_class_weight(y, sample_weight) - if getattr(y, "dtype", None) != DOUBLE or not y.flags.contiguous: - y = np.ascontiguousarray(y, dtype=DOUBLE) + if getattr(y, "dtype", None) != np.float64 or not y.flags.contiguous: + y = np.ascontiguousarray(y, dtype=np.float64) - if expanded_class_weight is not None: - if sample_weight is not None: - sample_weight = sample_weight * expanded_class_weight - else: - sample_weight = expanded_class_weight + # Combined _sample_weight = sample_weight * expanded_class_weight + # (when provided) used in _parallel_build_trees to draw indices + # (bootstrap=True) or passed to the trees (bootstrap=False). + if sample_weight is None: + _sample_weight = expanded_class_weight + elif expanded_class_weight is None: + _sample_weight = sample_weight + else: + _sample_weight = sample_weight * expanded_class_weight + + # Storing _sample_weight (needed by _get_estimators_indices). + self._sample_weight = _sample_weight if not self.bootstrap and self.max_samples is not None: raise ValueError( @@ -434,7 +416,7 @@ def fit(self, X, y, sample_weight=None): ) elif self.bootstrap: n_samples_bootstrap = _get_n_samples_bootstrap( - n_samples=X.shape[0], max_samples=self.max_samples + X.shape[0], self.max_samples, _sample_weight ) else: n_samples_bootstrap = None @@ -493,7 +475,7 @@ def fit(self, X, y, sample_weight=None): self.bootstrap, X, y, - sample_weight, + _sample_weight, i, len(trees), verbose=self.verbose, @@ -578,7 +560,7 @@ def _compute_oob_predictions(self, X, y): n_samples = y.shape[0] n_outputs = self.n_outputs_ if is_classifier(self) and hasattr(self, "n_classes_"): - # n_classes_ is a ndarray at this stage + # n_classes_ is an ndarray at this stage # all the supported type of target will have the same number of # classes in all outputs oob_pred_shape = (n_samples, self.n_classes_[0], n_outputs) @@ -590,16 +572,12 @@ def _compute_oob_predictions(self, X, y): oob_pred = np.zeros(shape=oob_pred_shape, dtype=np.float64) n_oob_pred = np.zeros((n_samples, n_outputs), dtype=np.int64) - - n_samples_bootstrap = _get_n_samples_bootstrap( - n_samples, - self.max_samples, - ) for estimator in self.estimators_: unsampled_indices = _generate_unsampled_indices( estimator.random_state, n_samples, - n_samples_bootstrap, + self._n_samples_bootstrap, + self._sample_weight, ) y_pred = self._get_oob_predictions(estimator, X[unsampled_indices, :]) @@ -621,7 +599,7 @@ def _compute_oob_predictions(self, X, y): return oob_pred - def _validate_y_class_weight(self, y): + def _validate_y_class_weight(self, y, sample_weight): # Default implementation return y, None @@ -637,7 +615,7 @@ def _validate_X_predict(self, X): X = validate_data( self, X, - dtype=DTYPE, + dtype=np.float32, accept_sparse="csr", reset=False, ensure_all_finite=ensure_all_finite, @@ -694,7 +672,10 @@ def _get_estimators_indices(self): # Operations accessing random_state must be performed identically # to those in `_parallel_build_trees()` yield _generate_sample_indices( - seed, self._n_samples, self._n_samples_bootstrap + seed, + self._n_samples, + self._n_samples_bootstrap, + self._sample_weight, ) @property @@ -826,15 +807,10 @@ def _set_oob_score_and_attributes(self, X, y, scoring_function=None): y, np.argmax(self.oob_decision_function_, axis=1) ) - def _validate_y_class_weight(self, y): + def _validate_y_class_weight(self, y, sample_weight): check_classification_targets(y) - y = np.copy(y) - expanded_class_weight = None - - if self.class_weight is not None: - y_original = np.copy(y) - + y_original = np.copy(y) self.classes_ = [] self.n_classes_ = [] @@ -847,36 +823,60 @@ def _validate_y_class_weight(self, y): self.n_classes_.append(classes_k.shape[0]) y = y_store_unique_indices - if self.class_weight is not None: - valid_presets = ("balanced", "balanced_subsample") - if isinstance(self.class_weight, str): - if self.class_weight not in valid_presets: - raise ValueError( - "Valid presets for class_weight include " - '"balanced" and "balanced_subsample".' - 'Given "%s".' % self.class_weight - ) - if self.warm_start: - warn( - 'class_weight presets "balanced" or ' - '"balanced_subsample" are ' - "not recommended for warm_start if the fitted data " - "differs from the full dataset. In order to use " - '"balanced" weights, use compute_class_weight ' - '("balanced", classes, y). In place of y you can use ' - "a large enough sample of the full training set " - "target to properly estimate the class frequency " - "distributions. Pass the resulting weights as the " - "class_weight parameter." - ) - - if self.class_weight != "balanced_subsample" or not self.bootstrap: - if self.class_weight == "balanced_subsample": - class_weight = "balanced" - else: - class_weight = self.class_weight - expanded_class_weight = compute_sample_weight(class_weight, y_original) + if self.class_weight is None: + return y, None + + # User defined class_weight (dict or list) + if isinstance(self.class_weight, (dict, list)): + expanded_class_weight = compute_sample_weight(self.class_weight, y_original) + return y, expanded_class_weight + + # Checking class_weight options + valid_presets = ("balanced", "balanced_subsample") + if self.class_weight not in valid_presets: + raise ValueError( + "Valid presets for class_weight include " + '"balanced" and "balanced_subsample".' + 'Given "%s".' % self.class_weight + ) + if self.warm_start: + warn( + 'class_weight presets "balanced" or ' + '"balanced_subsample" are ' + "not recommended for warm_start if the fitted data " + "differs from the full dataset. In order to use " + '"balanced" weights, use compute_class_weight ' + '("balanced", classes, y). In place of y you can use ' + "a large enough sample of the full training set " + "target to properly estimate the class frequency " + "distributions. Pass the resulting weights as the " + "class_weight parameter." + ) + + # "balanced_subsample" option with subsampling (bootstrap=True) + if self.class_weight == "balanced_subsample" and self.bootstrap: + # class_weight will be computed on the bootstrap sample + return y, None + + # Computing class_weight (dict or list) for the "balanced" option. + # The "balanced_subsample" option without subsampling (bootstrap=False) + # is equivalent to the "balanced" option. + class_weight = [] + for k in range(self.n_outputs_): + class_weight_k_vect = compute_class_weight( + "balanced", + classes=self.classes_[k], + y=y_original[:, k], + sample_weight=sample_weight, + ) + class_weight_k = { + key: val for (key, val) in zip(self.classes_[k], class_weight_k_vect) + } + class_weight.append(class_weight_k) + if self.n_outputs_ == 1: + class_weight = class_weight[0] + expanded_class_weight = compute_sample_weight(class_weight, y_original) return y, expanded_class_weight def predict(self, X): @@ -1136,7 +1136,7 @@ def _compute_partial_dependence_recursion(self, grid, target_features): Parameters ---------- - grid : ndarray of shape (n_samples, n_target_features), dtype=DTYPE + grid : ndarray of shape (n_samples, n_target_features), dtype=np.float32 The grid points on which the partial dependence should be evaluated. target_features : ndarray of shape (n_target_features), dtype=np.intp @@ -1148,7 +1148,7 @@ def _compute_partial_dependence_recursion(self, grid, target_features): averaged_predictions : ndarray of shape (n_samples,) The value of the partial dependence function on each grid point. """ - grid = np.asarray(grid, dtype=DTYPE, order="C") + grid = np.asarray(grid, dtype=np.float32, order="C") target_features = np.asarray(target_features, dtype=np.intp, order="C") averaged_predictions = np.zeros( shape=grid.shape[0], dtype=np.float64, order="C" @@ -1364,13 +1364,18 @@ class RandomForestClassifier(ForestClassifier): If bootstrap is True, the number of samples to draw from X to train each base estimator. - - If None (default), then draw `X.shape[0]` samples. + - If None (default), then draw `X.shape[0]` samples irrespective of + `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max(round(n_samples * max_samples), 1)` samples. Thus, - `max_samples` should be in the interval `(0.0, 1.0]`. + - If float, then draw `max_samples * X.shape[0]` unweighted samples + or `max_samples * sample_weight.sum()` weighted samples. .. versionadded:: 0.22 + .. versionchanged:: 1.9 + Float `max_samples` is relative to `sample_weight.sum()` instead of + `X.shape[0]` for weighted samples. + monotonic_cst : array-like of int of shape (n_features), default=None Indicates the monotonicity constraint to enforce on each feature. - 1: monotonic increase @@ -1381,8 +1386,7 @@ class RandomForestClassifier(ForestClassifier): Monotonicity constraints are not supported for: - multiclass classifications (i.e. when `n_classes > 2`), - - multioutput classifications (i.e. when `n_outputs_ > 1`), - - classifications trained on data with missing values. + - multioutput classifications (i.e. when `n_outputs_ > 1`). The constraints hold over the probability of the positive class. @@ -1603,18 +1607,14 @@ class RandomForestRegressor(ForestRegressor): The default value of ``n_estimators`` changed from 10 to 100 in 0.22. - criterion : {"squared_error", "absolute_error", "friedman_mse", "poisson"}, \ - default="squared_error" + criterion : {"squared_error", "absolute_error", "poisson"}, default="squared_error" The function to measure the quality of a split. Supported criteria are "squared_error" for the mean squared error, which is equal to variance reduction as feature selection criterion and minimizes the L2 - loss using the mean of each terminal node, "friedman_mse", which uses - mean squared error with Friedman's improvement score for potential - splits, "absolute_error" for the mean absolute error, which minimizes - the L1 loss using the median of each terminal node, and "poisson" which - uses reduction in Poisson deviance to find splits. - Training using "absolute_error" is significantly slower - than when using "squared_error". + loss using the mean of each terminal node, "absolute_error" for the mean + absolute error, which minimizes the L1 loss using the median of each terminal + node, and "poisson" which uses reduction in Poisson deviance to find splits, + also using the mean of each terminal node. .. versionadded:: 0.18 Mean Absolute Error (MAE) criterion. @@ -1622,6 +1622,9 @@ class RandomForestRegressor(ForestRegressor): .. versionadded:: 1.0 Poisson criterion. + .. versionchanged:: 1.9 + Criterion `"friedman_mse"` was deprecated. + max_depth : int, default=None The maximum depth of the tree. If None, then nodes are expanded until all leaves are pure or until all leaves contain less than @@ -1753,13 +1756,18 @@ class RandomForestRegressor(ForestRegressor): If bootstrap is True, the number of samples to draw from X to train each base estimator. - - If None (default), then draw `X.shape[0]` samples. + - If None (default), then draw `X.shape[0]` samples irrespective of + `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max(round(n_samples * max_samples), 1)` samples. Thus, - `max_samples` should be in the interval `(0.0, 1.0]`. + - If float, then draw `max_samples * X.shape[0]` unweighted samples + or `max_samples * sample_weight.sum()` weighted samples. .. versionadded:: 0.22 + .. versionchanged:: 1.9 + Float `max_samples` is relative to `sample_weight.sum()` instead of + `X.shape[0]` for weighted samples. + monotonic_cst : array-like of int of shape (n_features), default=None Indicates the monotonicity constraint to enforce on each feature. - 1: monotonically increasing @@ -1769,8 +1777,7 @@ class RandomForestRegressor(ForestRegressor): If monotonic_cst is None, no constraints are applied. Monotonicity constraints are not supported for: - - multioutput regressions (i.e. when `n_outputs_ > 1`), - - regressions trained on data with missing values. + - multioutput regressions (i.e. when `n_outputs_ > 1`). Read more in the :ref:`User Guide <monotonic_cst_gbdt>`. @@ -1929,6 +1936,16 @@ def __init__( max_samples=max_samples, ) + if isinstance(criterion, str) and criterion == "friedman_mse": + # TODO(1.11): remove support of "friedman_mse" criterion. + criterion = "squared_error" + warn( + 'Value `"friedman_mse"` for `criterion` is deprecated and will be ' + 'removed in 1.11. It maps to `"squared_error"` as both ' + 'were always equivalent. Use `criterion="squared_error"` ' + "to remove this warning.", + FutureWarning, + ) self.criterion = criterion self.max_depth = max_depth self.min_samples_split = min_samples_split @@ -2132,13 +2149,18 @@ class ExtraTreesClassifier(ForestClassifier): If bootstrap is True, the number of samples to draw from X to train each base estimator. - - If None (default), then draw `X.shape[0]` samples. + - If None (default), then draw `X.shape[0]` samples irrespective of + `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max_samples * X.shape[0]` samples. Thus, - `max_samples` should be in the interval `(0.0, 1.0]`. + - If float, then draw `max_samples * X.shape[0]` unweighted samples + or `max_samples * sample_weight.sum()` weighted samples. .. versionadded:: 0.22 + .. versionchanged:: 1.9 + Float `max_samples` is relative to `sample_weight.sum()` instead of + `X.shape[0]` for weighted samples. + monotonic_cst : array-like of int of shape (n_features), default=None Indicates the monotonicity constraint to enforce on each feature. - 1: monotonically increasing @@ -2149,8 +2171,7 @@ class ExtraTreesClassifier(ForestClassifier): Monotonicity constraints are not supported for: - multiclass classifications (i.e. when `n_classes > 2`), - - multioutput classifications (i.e. when `n_outputs_ > 1`), - - classifications trained on data with missing values. + - multioutput classifications (i.e. when `n_outputs_ > 1`). The constraints hold over the probability of the positive class. @@ -2353,22 +2374,21 @@ class ExtraTreesRegressor(ForestRegressor): The default value of ``n_estimators`` changed from 10 to 100 in 0.22. - criterion : {"squared_error", "absolute_error", "friedman_mse", "poisson"}, \ - default="squared_error" + criterion : {"squared_error", "absolute_error", "poisson"}, default="squared_error" The function to measure the quality of a split. Supported criteria are "squared_error" for the mean squared error, which is equal to variance reduction as feature selection criterion and minimizes the L2 - loss using the mean of each terminal node, "friedman_mse", which uses - mean squared error with Friedman's improvement score for potential - splits, "absolute_error" for the mean absolute error, which minimizes - the L1 loss using the median of each terminal node, and "poisson" which - uses reduction in Poisson deviance to find splits. - Training using "absolute_error" is significantly slower - than when using "squared_error". + loss using the mean of each terminal node, "absolute_error" for the mean + absolute error, which minimizes the L1 loss using the median of each terminal + node, and "poisson" which uses reduction in Poisson deviance to find splits, + also using the mean of each terminal node. .. versionadded:: 0.18 Mean Absolute Error (MAE) criterion. + .. versionchanged:: 1.9 + Criterion `"friedman_mse"` was deprecated. + max_depth : int, default=None The maximum depth of the tree. If None, then nodes are expanded until all leaves are pure or until all leaves contain less than @@ -2504,13 +2524,18 @@ class ExtraTreesRegressor(ForestRegressor): If bootstrap is True, the number of samples to draw from X to train each base estimator. - - If None (default), then draw `X.shape[0]` samples. + - If None (default), then draw `X.shape[0]` samples irrespective of + `sample_weight`. - If int, then draw `max_samples` samples. - - If float, then draw `max_samples * X.shape[0]` samples. Thus, - `max_samples` should be in the interval `(0.0, 1.0]`. + - If float, then draw `max_samples * X.shape[0]` unweighted samples + or `max_samples * sample_weight.sum()` weighted samples. .. versionadded:: 0.22 + .. versionchanged:: 1.9 + Float `max_samples` is relative to `sample_weight.sum()` instead of + `X.shape[0]` for weighted samples. + monotonic_cst : array-like of int of shape (n_features), default=None Indicates the monotonicity constraint to enforce on each feature. - 1: monotonically increasing @@ -2520,8 +2545,7 @@ class ExtraTreesRegressor(ForestRegressor): If monotonic_cst is None, no constraints are applied. Monotonicity constraints are not supported for: - - multioutput regressions (i.e. when `n_outputs_ > 1`), - - regressions trained on data with missing values. + - multioutput regressions (i.e. when `n_outputs_ > 1`). Read more in the :ref:`User Guide <monotonic_cst_gbdt>`. @@ -2664,6 +2688,16 @@ def __init__( max_samples=max_samples, ) + if isinstance(criterion, str) and criterion == "friedman_mse": + # TODO(1.11): remove support of "friedman_mse" criterion. + criterion = "squared_error" + warn( + 'Value `"friedman_mse"` for `criterion` is deprecated and will be ' + 'removed in 1.11. It maps to `"squared_error"` as both ' + 'were always equivalent. Use `criterion="squared_error"` ' + "to remove this warning.", + FutureWarning, + ) self.criterion = criterion self.max_depth = max_depth self.min_samples_split = min_samples_split diff --git a/sklearn/ensemble/_gb.py b/sklearn/ensemble/_gb.py index e64763123f270..9ac0dad723f32 100644 --- a/sklearn/ensemble/_gb.py +++ b/sklearn/ensemble/_gb.py @@ -26,7 +26,7 @@ from time import time import numpy as np -from scipy.sparse import csc_matrix, csr_matrix, issparse +from scipy.sparse import csc_array, csr_array, issparse from sklearn._loss.loss import ( _LOSSES, @@ -50,9 +50,9 @@ from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from sklearn.tree import DecisionTreeRegressor -from sklearn.tree._tree import DOUBLE, DTYPE, TREE_LEAF +from sklearn.tree._tree import TREE_LEAF from sklearn.utils import check_array, check_random_state, column_or_1d -from sklearn.utils._param_validation import HasMethods, Interval, StrOptions +from sklearn.utils._param_validation import HasMethods, Hidden, Interval, StrOptions from sklearn.utils.multiclass import check_classification_targets from sklearn.utils.stats import _weighted_percentile from sklearn.utils.validation import ( @@ -274,7 +274,7 @@ def compute_update(y_, indices, neg_gradient, raw_prediction, k): def set_huber_delta(loss, y_true, raw_prediction, sample_weight=None): """Calculate and set self.closs.delta based on self.quantile.""" abserr = np.abs(y_true - raw_prediction.squeeze()) - # sample_weight is always a ndarray, never None. + # sample_weight is always an ndarray, never None. delta = _weighted_percentile(abserr, sample_weight, 100 * loss.quantile) loss.closs.delta = float(delta) @@ -365,7 +365,10 @@ class BaseGradientBoosting(BaseEnsemble, metaclass=ABCMeta): **DecisionTreeRegressor._parameter_constraints, "learning_rate": [Interval(Real, 0.0, None, closed="left")], "n_estimators": [Interval(Integral, 1, None, closed="left")], - "criterion": [StrOptions({"friedman_mse", "squared_error"})], + "criterion": [ + StrOptions({"squared_error"}), + Hidden(StrOptions({"deprecated", "friedman_mse"})), + ], "subsample": [Interval(Real, 0.0, 1.0, closed="right")], "verbose": ["verbose"], "warm_start": ["boolean"], @@ -383,7 +386,6 @@ def __init__( loss, learning_rate, n_estimators, - criterion, min_samples_split, min_samples_leaf, min_weight_fraction_leaf, @@ -401,6 +403,7 @@ def __init__( validation_fraction=0.1, n_iter_no_change=None, tol=1e-4, + criterion="deprecated", ): self.n_estimators = n_estimators self.learning_rate = learning_rate @@ -476,7 +479,7 @@ def _fit_stage( # induce regression tree on the negative gradient tree = DecisionTreeRegressor( - criterion=self.criterion, + criterion="squared_error", splitter="best", max_depth=self.max_depth, min_samples_split=self.min_samples_split, @@ -628,7 +631,7 @@ def fit(self, X, y, sample_weight=None, monitor=None): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. y : array-like of shape (n_samples,) Target values (strings or integers in classification, real numbers @@ -659,6 +662,14 @@ def fit(self, X, y, sample_weight=None, monitor=None): if not self.warm_start: self._clear_state() + if self.criterion != "deprecated": + warnings.warn( + "The parameter `criterion` is deprecated and will be " + "removed in 1.11. It has no effect. Leave it to its default value to " + "avoid this warning.", + FutureWarning, + ) + # Check input # Since check_array converts both X and y to the same dtype, but the # trees use different types for X and y, checking them separately. @@ -668,7 +679,7 @@ def fit(self, X, y, sample_weight=None, monitor=None): X, y, accept_sparse=["csr", "csc", "coo"], - dtype=DTYPE, + dtype=np.float32, multi_output=True, ) sample_weight_is_none = sample_weight is None @@ -783,7 +794,7 @@ def fit(self, X, y, sample_weight=None, monitor=None): # matrices. Finite values have already been checked in _validate_data. X_train = check_array( X_train, - dtype=DTYPE, + dtype=np.float32, order="C", accept_sparse="csr", ensure_all_finite=False, @@ -846,8 +857,8 @@ def _fit_stages( verbose_reporter = VerboseReporter(verbose=self.verbose) verbose_reporter.init(self, begin_at_stage) - X_csc = csc_matrix(X) if issparse(X) else None - X_csr = csr_matrix(X) if issparse(X) else None + X_csc = csc_array(X) if issparse(X) else None + X_csr = csr_array(X) if issparse(X) else None if self.n_iter_no_change is not None: loss_history = np.full(self.n_iter_no_change, np.inf) @@ -985,7 +996,7 @@ def _staged_raw_predict(self, X, check_input=True): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. check_input : bool, default=True If False, the input arrays X will not be checked. @@ -1000,7 +1011,7 @@ def _staged_raw_predict(self, X, check_input=True): """ if check_input: X = validate_data( - self, X, dtype=DTYPE, order="C", accept_sparse="csr", reset=False + self, X, dtype=np.float32, order="C", accept_sparse="csr", reset=False ) raw_predictions = self._raw_predict_init(X) for i in range(self.estimators_.shape[0]): @@ -1013,7 +1024,7 @@ def feature_importances_(self): The higher, the more important the feature. The importance of a feature is computed as the (normalized) - total reduction of the criterion brought by that feature. It is also + total reduction of the MSE brought by that feature. It is also known as the Gini importance. Warning: impurity-based feature importances can be misleading for @@ -1073,7 +1084,7 @@ def _compute_partial_dependence_recursion(self, grid, target_features): "Got init=%s." % self.init, UserWarning, ) - grid = np.asarray(grid, dtype=DTYPE, order="C") + grid = np.asarray(grid, dtype=np.float32, order="C") n_estimators, n_trees_per_stage = self.estimators_.shape averaged_predictions = np.zeros( (n_trees_per_stage, grid.shape[0]), dtype=np.float64, order="C" @@ -1100,7 +1111,7 @@ def apply(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, its dtype will be converted to ``dtype=np.float32``. If a sparse matrix is provided, it will - be converted to a sparse ``csr_matrix``. + be converted to a sparse ``csr_array``. Returns ------- @@ -1179,14 +1190,13 @@ class GradientBoostingClassifier(ClassifierMixin, BaseGradientBoosting): Values must be in the range `(0.0, 1.0]`. criterion : {'friedman_mse', 'squared_error'}, default='friedman_mse' - The function to measure the quality of a split. Supported criteria are - 'friedman_mse' for the mean squared error with improvement score by - Friedman, 'squared_error' for mean squared error. The default value of - 'friedman_mse' is generally the best as it can provide a better - approximation in some cases. + This parameter has no effect. .. versionadded:: 0.18 + .. deprecated:: 1.9 + `criterion` is deprecated and will be removed in 1.11. + min_samples_split : int or float, default=2 The minimum number of samples required to split an internal node: @@ -1354,7 +1364,7 @@ class GradientBoostingClassifier(ClassifierMixin, BaseGradientBoosting): The impurity-based feature importances. The higher, the more important the feature. The importance of a feature is computed as the (normalized) - total reduction of the criterion brought by that feature. It is also + total reduction of the MSE brought by that feature. It is also known as the Gini importance. Warning: impurity-based feature importances can be misleading for @@ -1432,7 +1442,7 @@ class GradientBoostingClassifier(ClassifierMixin, BaseGradientBoosting): ----- The features are always randomly permuted at each split. Therefore, the best found split may vary, even with the same training data and - ``max_features=n_features``, if the improvement of the criterion is + ``max_features=n_features``, if the improvement of the MSE is identical for several splits enumerated during the search of the best split. To obtain a deterministic behaviour during fitting, ``random_state`` has to be fixed. @@ -1478,7 +1488,7 @@ def __init__( learning_rate=0.1, n_estimators=100, subsample=1.0, - criterion="friedman_mse", + criterion="deprecated", min_samples_split=2, min_samples_leaf=1, min_weight_fraction_leaf=0.0, @@ -1574,7 +1584,7 @@ def decision_function(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Returns ------- @@ -1586,7 +1596,7 @@ def decision_function(self, X): array of shape (n_samples,). """ X = validate_data( - self, X, dtype=DTYPE, order="C", accept_sparse="csr", reset=False + self, X, dtype=np.float32, order="C", accept_sparse="csr", reset=False ) raw_predictions = self._raw_predict(X) if raw_predictions.shape[1] == 1: @@ -1604,7 +1614,7 @@ def staged_decision_function(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Yields ------ @@ -1625,7 +1635,7 @@ def predict(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Returns ------- @@ -1650,7 +1660,7 @@ def staged_predict(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Yields ------ @@ -1674,7 +1684,7 @@ def predict_proba(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Returns ------- @@ -1698,7 +1708,7 @@ def predict_log_proba(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Returns ------- @@ -1725,7 +1735,7 @@ def staged_predict_proba(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Yields ------ @@ -1791,14 +1801,13 @@ class GradientBoostingRegressor(RegressorMixin, BaseGradientBoosting): Values must be in the range `(0.0, 1.0]`. criterion : {'friedman_mse', 'squared_error'}, default='friedman_mse' - The function to measure the quality of a split. Supported criteria are - "friedman_mse" for the mean squared error with improvement score by - Friedman, "squared_error" for mean squared error. The default value of - "friedman_mse" is generally the best as it can provide a better - approximation in some cases. + This parameter has no effect. .. versionadded:: 0.18 + .. deprecated:: 1.9 + `criterion` is deprecated and will be removed in 1.11. + min_samples_split : int or float, default=2 The minimum number of samples required to split an internal node: @@ -1970,7 +1979,7 @@ class GradientBoostingRegressor(RegressorMixin, BaseGradientBoosting): The impurity-based feature importances. The higher, the more important the feature. The importance of a feature is computed as the (normalized) - total reduction of the criterion brought by that feature. It is also + total reduction of the MSE brought by that feature. It is also known as the Gini importance. Warning: impurity-based feature importances can be misleading for @@ -2033,7 +2042,7 @@ class GradientBoostingRegressor(RegressorMixin, BaseGradientBoosting): ----- The features are always randomly permuted at each split. Therefore, the best found split may vary, even with the same training data and - ``max_features=n_features``, if the improvement of the criterion is + ``max_features=n_features``, if the improvement of the MSE is identical for several splits enumerated during the search of the best split. To obtain a deterministic behaviour during fitting, ``random_state`` has to be fixed. @@ -2084,7 +2093,7 @@ def __init__( learning_rate=0.1, n_estimators=100, subsample=1.0, - criterion="friedman_mse", + criterion="deprecated", min_samples_split=2, min_samples_leaf=1, min_weight_fraction_leaf=0.0, @@ -2129,7 +2138,7 @@ def __init__( def _encode_y(self, y=None, sample_weight=None): # Just convert y to the expected dtype self.n_trees_per_iteration_ = 1 - y = y.astype(DOUBLE, copy=False) + y = y.astype(np.float64, copy=False) return y def _get_loss(self, sample_weight): @@ -2146,7 +2155,7 @@ def predict(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Returns ------- @@ -2154,7 +2163,7 @@ def predict(self, X): The predicted values. """ X = validate_data( - self, X, dtype=DTYPE, order="C", accept_sparse="csr", reset=False + self, X, dtype=np.float32, order="C", accept_sparse="csr", reset=False ) # In regression we can directly return the raw value from the trees. return self._raw_predict(X).ravel() @@ -2170,7 +2179,7 @@ def staged_predict(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, it will be converted to ``dtype=np.float32`` and if a sparse matrix is provided - to a sparse ``csr_matrix``. + to a sparse ``csr_array``. Yields ------ @@ -2190,7 +2199,7 @@ def apply(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The input samples. Internally, its dtype will be converted to ``dtype=np.float32``. If a sparse matrix is provided, it will - be converted to a sparse ``csr_matrix``. + be converted to a sparse ``csr_array``. Returns ------- diff --git a/sklearn/ensemble/_gradient_boosting.pyx b/sklearn/ensemble/_gradient_boosting.pyx index 6224dee324a57..a9dcac9c7c2e7 100644 --- a/sklearn/ensemble/_gradient_boosting.pyx +++ b/sklearn/ensemble/_gradient_boosting.pyx @@ -19,7 +19,7 @@ from sklearn.tree._utils cimport safe_realloc from numpy import zeros as np_zeros -# constant to mark tree leafs +# constant to mark tree leaves cdef intp_t TREE_LEAF = -1 cdef void _predict_regression_tree_inplace_fast_dense( diff --git a/sklearn/ensemble/_hist_gradient_boosting/_bitset.pxd b/sklearn/ensemble/_hist_gradient_boosting/_bitset.pxd deleted file mode 100644 index 83dda474bab7f..0000000000000 --- a/sklearn/ensemble/_hist_gradient_boosting/_bitset.pxd +++ /dev/null @@ -1,20 +0,0 @@ -from sklearn.ensemble._hist_gradient_boosting.common cimport X_BINNED_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_INNER_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport X_DTYPE_C -from sklearn.utils._typedefs cimport uint8_t - - -cdef void init_bitset(BITSET_DTYPE_C bitset) noexcept nogil - -cdef void set_bitset(BITSET_DTYPE_C bitset, X_BINNED_DTYPE_C val) noexcept nogil - -cdef uint8_t in_bitset(BITSET_DTYPE_C bitset, X_BINNED_DTYPE_C val) noexcept nogil - -cpdef uint8_t in_bitset_memoryview(const BITSET_INNER_DTYPE_C[:] bitset, - X_BINNED_DTYPE_C val) noexcept nogil - -cdef uint8_t in_bitset_2d_memoryview( - const BITSET_INNER_DTYPE_C[:, :] bitset, - X_BINNED_DTYPE_C val, - unsigned int row) noexcept nogil diff --git a/sklearn/ensemble/_hist_gradient_boosting/_bitset.pyx b/sklearn/ensemble/_hist_gradient_boosting/_bitset.pyx deleted file mode 100644 index e80ce0e16985d..0000000000000 --- a/sklearn/ensemble/_hist_gradient_boosting/_bitset.pyx +++ /dev/null @@ -1,65 +0,0 @@ -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_INNER_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport X_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport X_BINNED_DTYPE_C -from sklearn.utils._typedefs cimport uint8_t - - -# A bitset is a data structure used to represent sets of integers in [0, n]. We -# use them to represent sets of features indices (e.g. features that go to the -# left child, or features that are categorical). For familiarity with bitsets -# and bitwise operations: -# https://en.wikipedia.org/wiki/Bit_array -# https://en.wikipedia.org/wiki/Bitwise_operation - - -cdef inline void init_bitset(BITSET_DTYPE_C bitset) noexcept nogil: # OUT - cdef: - unsigned int i - - for i in range(8): - bitset[i] = 0 - - -cdef inline void set_bitset(BITSET_DTYPE_C bitset, # OUT - X_BINNED_DTYPE_C val) noexcept nogil: - bitset[val // 32] |= (1 << (val % 32)) - - -cdef inline uint8_t in_bitset(BITSET_DTYPE_C bitset, - X_BINNED_DTYPE_C val) noexcept nogil: - return (bitset[val // 32] >> (val % 32)) & 1 - - -cpdef inline uint8_t in_bitset_memoryview(const BITSET_INNER_DTYPE_C[:] bitset, - X_BINNED_DTYPE_C val) noexcept nogil: - return (bitset[val // 32] >> (val % 32)) & 1 - - -cdef inline uint8_t in_bitset_2d_memoryview(const BITSET_INNER_DTYPE_C[:, :] bitset, - X_BINNED_DTYPE_C val, - unsigned int row) noexcept nogil: - # Same as above but works on 2d memory views to avoid the creation of 1d - # memory views. See https://github.com/scikit-learn/scikit-learn/issues/17299 - return (bitset[row, val // 32] >> (val % 32)) & 1 - - -cpdef inline void set_bitset_memoryview(BITSET_INNER_DTYPE_C[:] bitset, # OUT - X_BINNED_DTYPE_C val): - bitset[val // 32] |= (1 << (val % 32)) - - -def set_raw_bitset_from_binned_bitset(BITSET_INNER_DTYPE_C[:] raw_bitset, # OUT - BITSET_INNER_DTYPE_C[:] binned_bitset, - X_DTYPE_C[:] categories): - """Set the raw_bitset from the values of the binned bitset - - categories is a mapping from binned category value to raw category value. - """ - cdef: - int binned_cat_value - X_DTYPE_C raw_cat_value - - for binned_cat_value, raw_cat_value in enumerate(categories): - if in_bitset_memoryview(binned_bitset, binned_cat_value): - set_bitset_memoryview(raw_bitset, <X_BINNED_DTYPE_C>raw_cat_value) diff --git a/sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx b/sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx index 37f8055fcdf8c..e8dde001ec98e 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx +++ b/sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx @@ -5,14 +5,13 @@ from cython.parallel import prange from libc.math cimport isnan import numpy as np +from sklearn.utils._bitset cimport BITSET_INNER_DTYPE_C, in_bitset_2d_memoryview from sklearn.utils._typedefs cimport intp_t, uint8_t from sklearn.ensemble._hist_gradient_boosting.common cimport X_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common cimport Y_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common import Y_DTYPE from sklearn.ensemble._hist_gradient_boosting.common cimport X_BINNED_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_INNER_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common cimport node_struct -from sklearn.ensemble._hist_gradient_boosting._bitset cimport in_bitset_2d_memoryview def _predict_from_raw_data( # raw data = non-binned data diff --git a/sklearn/ensemble/_hist_gradient_boosting/binning.py b/sklearn/ensemble/_hist_gradient_boosting/binning.py index b0745b58ae8dd..444249eda041c 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/binning.py +++ b/sklearn/ensemble/_hist_gradient_boosting/binning.py @@ -10,10 +10,10 @@ # SPDX-License-Identifier: BSD-3-Clause import numpy as np +from numpy.lib.stride_tricks import sliding_window_view from sklearn.base import BaseEstimator, TransformerMixin from sklearn.ensemble._hist_gradient_boosting._binning import _map_to_bins -from sklearn.ensemble._hist_gradient_boosting._bitset import set_bitset_memoryview from sklearn.ensemble._hist_gradient_boosting.common import ( ALMOST_INF, X_BINNED_DTYPE, @@ -21,12 +21,14 @@ X_DTYPE, ) from sklearn.utils import check_array, check_random_state +from sklearn.utils._bitset import set_bitset_memoryview from sklearn.utils._openmp_helpers import _openmp_effective_n_threads from sklearn.utils.parallel import Parallel, delayed +from sklearn.utils.stats import _weighted_percentile from sklearn.utils.validation import check_is_fitted -def _find_binning_thresholds(col_data, max_bins): +def _find_binning_thresholds(col_data, max_bins, sample_weight=None): """Extract quantiles from a continuous feature. Missing values are ignored for finding the thresholds. @@ -50,31 +52,63 @@ def _find_binning_thresholds(col_data, max_bins): """ # ignore missing values when computing bin thresholds missing_mask = np.isnan(col_data) - if missing_mask.any(): + any_missing = missing_mask.any() + if any_missing: col_data = col_data[~missing_mask] + + # If sample_weight is not None and 0-weighted values exist, we need to + # remove those before calculating the distinct points. + if sample_weight is not None: + if any_missing: + sample_weight = sample_weight[~missing_mask] + nnz_sw = sample_weight != 0 + col_data = col_data[nnz_sw] + sample_weight = sample_weight[nnz_sw] + # The data will be sorted anyway in np.unique and again in percentile, so we do it # here. Sorting also returns a contiguous array. - col_data = np.sort(col_data) + sort_idx = np.argsort(col_data) + col_data = col_data[sort_idx] + if sample_weight is not None: + sample_weight = sample_weight[sort_idx] + distinct_values = np.unique(col_data).astype(X_DTYPE) + + if len(distinct_values) == 1: + return np.asarray([]) + if len(distinct_values) <= max_bins: - midpoints = distinct_values[:-1] + distinct_values[1:] - midpoints *= 0.5 + # Calculate midpoints if distinct values <= max_bins + bin_thresholds = sliding_window_view(distinct_values, 2).mean(axis=1) + elif sample_weight is None: + # We compute bin edges using the output of np.percentile with + # the "averaged_inverted_cdf" interpolation method that is consistent + # with the code for the sample_weight != None case. + percentiles = np.linspace(0, 100, num=max_bins + 1) + percentiles = percentiles[1:-1] + bin_thresholds = np.percentile( + col_data, percentiles, method="averaged_inverted_cdf" + ) + assert bin_thresholds.shape[0] == max_bins - 1 else: - # We could compute approximate midpoint percentiles using the output of - # np.unique(col_data, return_counts) instead but this is more - # work and the performance benefit will be limited because we - # work on a fixed-size subsample of the full data. percentiles = np.linspace(0, 100, num=max_bins + 1) percentiles = percentiles[1:-1] - midpoints = np.percentile(col_data, percentiles, method="midpoint").astype( - X_DTYPE + bin_thresholds = np.array( + [ + _weighted_percentile(col_data, sample_weight, percentile, average=True) + for percentile in percentiles + ] ) - assert midpoints.shape[0] == max_bins - 1 + assert bin_thresholds.shape[0] == max_bins - 1 + # Remove duplicated thresholds if they exist. + unique_bin_values = np.unique(bin_thresholds) + if unique_bin_values.shape[0] != bin_thresholds.shape[0]: + bin_thresholds = unique_bin_values # We avoid having +inf thresholds: +inf thresholds are only allowed in # a "split on nan" situation. - np.clip(midpoints, a_min=None, a_max=ALMOST_INF, out=midpoints) - return midpoints + np.clip(bin_thresholds, a_min=None, a_max=ALMOST_INF, out=bin_thresholds) + return bin_thresholds class _BinMapper(TransformerMixin, BaseEstimator): @@ -175,7 +209,7 @@ def __init__( self.random_state = random_state self.n_threads = n_threads - def fit(self, X, y=None): + def fit(self, X, y=None, sample_weight=None): """Fit data X by computing the binning thresholds. The last bin is reserved for missing values, whether missing values @@ -202,12 +236,25 @@ def fit(self, X, y=None): X = check_array(X, dtype=[X_DTYPE], ensure_all_finite=False) max_bins = self.n_bins - 1 - rng = check_random_state(self.random_state) if self.subsample is not None and X.shape[0] > self.subsample: - subset = rng.choice(X.shape[0], self.subsample, replace=False) + subsampling_probabilities = None + if sample_weight is not None: + subsampling_probabilities = sample_weight / np.sum(sample_weight) + # Sampling with replacement to implement frequency semantics + # for sample weights. Note that we need `replace=True` even when + # `sample_weight is None` to make sure that passing no weights is + # statistically equivalent to passing unit weights. + subset = rng.choice( + X.shape[0], self.subsample, p=subsampling_probabilities, replace=True + ) X = X.take(subset, axis=0) + # Add a switch to replace sample weights with None + # since sample weights were already used in subsampling + # and should not then be propagated to _find_binning_thresholds + sample_weight = None + if self.is_categorical is None: self.is_categorical_ = np.zeros(X.shape[1], dtype=np.uint8) else: @@ -238,11 +285,12 @@ def fit(self, X, y=None): n_bins_non_missing = [None] * n_features non_cat_thresholds = Parallel(n_jobs=self.n_threads, backend="threading")( - delayed(_find_binning_thresholds)(X[:, f_idx], max_bins) + delayed(_find_binning_thresholds)( + X[:, f_idx], max_bins, sample_weight=sample_weight + ) for f_idx in range(n_features) if not self.is_categorical_[f_idx] ) - non_cat_idx = 0 for f_idx in range(n_features): if self.is_categorical_[f_idx]: diff --git a/sklearn/ensemble/_hist_gradient_boosting/common.pxd b/sklearn/ensemble/_hist_gradient_boosting/common.pxd index 63ae2a3da2d3d..ec98206a685cc 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/common.pxd +++ b/sklearn/ensemble/_hist_gradient_boosting/common.pxd @@ -1,12 +1,14 @@ -from sklearn.utils._typedefs cimport float32_t, float64_t, intp_t, uint8_t, uint32_t +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +from sklearn.utils._bitset cimport BITSET_INNER_DTYPE_C, BITSET_INNER_DTYPE_C +from sklearn.utils._typedefs cimport float32_t, float64_t, intp_t, uint8_t ctypedef float64_t X_DTYPE_C ctypedef uint8_t X_BINNED_DTYPE_C ctypedef float64_t Y_DTYPE_C ctypedef float32_t G_H_DTYPE_C -ctypedef uint32_t BITSET_INNER_DTYPE_C -ctypedef BITSET_INNER_DTYPE_C[8] BITSET_DTYPE_C cdef packed struct hist_struct: diff --git a/sklearn/ensemble/_hist_gradient_boosting/common.pyx b/sklearn/ensemble/_hist_gradient_boosting/common.pyx index 6b20e32813d5b..184c80c72f088 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/common.pyx +++ b/sklearn/ensemble/_hist_gradient_boosting/common.pyx @@ -1,3 +1,6 @@ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + import numpy as np # Y_DYTPE is the dtype to which the targets y are converted to. This is also diff --git a/sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py b/sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py index 4bbc46d9ae135..fed9a945f4f7f 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py +++ b/sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py @@ -45,10 +45,10 @@ from sklearn.utils._param_validation import Interval, RealNotInt, StrOptions from sklearn.utils.multiclass import check_classification_targets from sklearn.utils.validation import ( + _check_categorical_features, _check_monotonic_cst, _check_sample_weight, _check_y, - _is_pandas_df, check_array, check_consistent_length, check_is_fitted, @@ -267,7 +267,7 @@ def _preprocess_X(self, X, *, reset): return self._preprocessor.transform(X) # At this point, reset is False, which runs during `fit`. - self.is_categorical_ = self._check_categorical_features(X) + self.is_categorical_ = _check_categorical_features(X, self.categorical_features) if self.is_categorical_ is None: self._preprocessor = None @@ -353,125 +353,6 @@ def _check_categories(self): known_categories[feature_idx] = np.arange(len(categories), dtype=X_DTYPE) return known_categories - def _check_categorical_features(self, X): - """Check and validate categorical features in X - - Parameters - ---------- - X : {array-like, pandas DataFrame} of shape (n_samples, n_features) - Input data. - - Return - ------ - is_categorical : ndarray of shape (n_features,) or None, dtype=bool - Indicates whether a feature is categorical. If no feature is - categorical, this is None. - """ - # Special code for pandas because of a bug in recent pandas, which is - # fixed in main and maybe included in 2.2.1, see - # https://github.com/pandas-dev/pandas/pull/57173. - # Also pandas versions < 1.5.1 do not support the dataframe interchange - if _is_pandas_df(X): - X_is_dataframe = True - categorical_columns_mask = np.asarray(X.dtypes == "category") - elif hasattr(X, "__dataframe__"): - X_is_dataframe = True - categorical_columns_mask = np.asarray( - [ - c.dtype[0].name == "CATEGORICAL" - for c in X.__dataframe__().get_columns() - ] - ) - else: - X_is_dataframe = False - categorical_columns_mask = None - - categorical_features = self.categorical_features - - categorical_by_dtype = ( - isinstance(categorical_features, str) - and categorical_features == "from_dtype" - ) - no_categorical_dtype = categorical_features is None or ( - categorical_by_dtype and not X_is_dataframe - ) - - if no_categorical_dtype: - return None - - use_pandas_categorical = categorical_by_dtype and X_is_dataframe - if use_pandas_categorical: - categorical_features = categorical_columns_mask - else: - categorical_features = np.asarray(categorical_features) - - if categorical_features.size == 0: - return None - - if categorical_features.dtype.kind not in ("i", "b", "U", "O"): - raise ValueError( - "categorical_features must be an array-like of bool, int or " - f"str, got: {categorical_features.dtype.name}." - ) - - if categorical_features.dtype.kind == "O": - types = set(type(f) for f in categorical_features) - if types != {str}: - raise ValueError( - "categorical_features must be an array-like of bool, int or " - f"str, got: {', '.join(sorted(t.__name__ for t in types))}." - ) - - n_features = X.shape[1] - # At this point `validate_data` was not called yet because we use the original - # dtypes to discover the categorical features. Thus `feature_names_in_` - # is not defined yet. - feature_names_in_ = getattr(X, "columns", None) - - if categorical_features.dtype.kind in ("U", "O"): - # check for feature names - if feature_names_in_ is None: - raise ValueError( - "categorical_features should be passed as an array of " - "integers or as a boolean mask when the model is fitted " - "on data without feature names." - ) - is_categorical = np.zeros(n_features, dtype=bool) - feature_names = list(feature_names_in_) - for feature_name in categorical_features: - try: - is_categorical[feature_names.index(feature_name)] = True - except ValueError as e: - raise ValueError( - f"categorical_features has an item value '{feature_name}' " - "which is not a valid feature name of the training " - f"data. Observed feature names: {feature_names}" - ) from e - elif categorical_features.dtype.kind == "i": - # check for categorical features as indices - if ( - np.max(categorical_features) >= n_features - or np.min(categorical_features) < 0 - ): - raise ValueError( - "categorical_features set as integer " - "indices must be in [0, n_features - 1]" - ) - is_categorical = np.zeros(n_features, dtype=bool) - is_categorical[categorical_features] = True - else: - if categorical_features.shape[0] != n_features: - raise ValueError( - "categorical_features set as a boolean mask " - "must have shape (n_features,), got: " - f"{categorical_features.shape}" - ) - is_categorical = categorical_features - - if not np.any(is_categorical): - return None - return is_categorical - def _check_interaction_cst(self, n_features): """Check and validation for interaction constraints.""" if self.interaction_cst is None: @@ -719,9 +600,13 @@ def fit( random_state=self._random_seed, n_threads=n_threads, ) - X_binned_train = self._bin_data(X_train, is_training_data=True) + X_binned_train = self._bin_data( + X_train, sample_weight_train, is_training_data=True + ) if X_val is not None: - X_binned_val = self._bin_data(X_val, is_training_data=False) + X_binned_val = self._bin_data( + X_val, sample_weight_val, is_training_data=False + ) else: X_binned_val = None @@ -1218,7 +1103,7 @@ def _should_stop(self, scores): recent_improvements = [score > reference_score for score in recent_scores] return not any(recent_improvements) - def _bin_data(self, X, is_training_data): + def _bin_data(self, X, sample_weight, is_training_data): """Bin data X. If is_training_data, then fit the _bin_mapper attribute. @@ -1234,7 +1119,9 @@ def _bin_data(self, X, is_training_data): ) tic = time() if is_training_data: - X_binned = self._bin_mapper.fit_transform(X) # F-aligned array + X_binned = self._bin_mapper.fit_transform( + X, sample_weight=sample_weight + ) # F-aligned array else: X_binned = self._bin_mapper.transform(X) # F-aligned array # We convert the array to C-contiguous since predicting is faster @@ -1487,7 +1374,7 @@ class HistGradientBoostingRegressor(RegressorMixin, BaseHistGradientBoosting): usecase example of this feature. This implementation is inspired by - `LightGBM <https://github.com/Microsoft/LightGBM>`_. + `LightGBM <https://github.com/lightgbm-org/LightGBM>`_. Read more in the :ref:`User Guide <histogram_based_gradient_boosting>`. @@ -1564,10 +1451,10 @@ class HistGradientBoostingRegressor(RegressorMixin, BaseHistGradientBoosting): features. - str array-like: names of categorical features (assuming the training data has feature names). - - `"from_dtype"`: dataframe columns with dtype "category" are - considered to be categorical features. The input must be an object - exposing a ``__dataframe__`` method such as pandas or polars - DataFrames to use this feature. + - `"from_dtype"`: dataframe columns with dtype "Categorical" and "Enum" are + considered to be categorical features. The input must be a dataframe that + is supported by narwhals (or supports it): :func:`narwhals.from_native` must + work. This is the case, for instance, for pandas and polars DataFrames. For each categorical feature, there must be at most `max_bins` unique categories. Negative values for categorical features encoded as numeric @@ -1736,7 +1623,7 @@ class HistGradientBoostingRegressor(RegressorMixin, BaseHistGradientBoosting): >>> X, y = load_diabetes(return_X_y=True) >>> est = HistGradientBoostingRegressor().fit(X, y) >>> est.score(X, y) - 0.92... + 0.93... """ _parameter_constraints: dict = { @@ -1888,7 +1775,7 @@ class HistGradientBoostingClassifier(ClassifierMixin, BaseHistGradientBoosting): missing values are mapped to whichever child has the most samples. This implementation is inspired by - `LightGBM <https://github.com/Microsoft/LightGBM>`_. + `LightGBM <https://github.com/lightgbm-org/LightGBM>`_. Read more in the :ref:`User Guide <histogram_based_gradient_boosting>`. @@ -1957,10 +1844,10 @@ class HistGradientBoostingClassifier(ClassifierMixin, BaseHistGradientBoosting): features. - str array-like: names of categorical features (assuming the training data has feature names). - - `"from_dtype"`: dataframe columns with dtype "category" are - considered to be categorical features. The input must be an object - exposing a ``__dataframe__`` method such as pandas or polars - DataFrames to use this feature. + - `"from_dtype"`: dataframe columns with dtype "Categorical" and "Enum" are + considered to be categorical features. The input must be a dataframe that + is supported by narwhals (or supports it): :func:`narwhals.from_native` must + work. This is the case, for instance, for pandas and polars DataFrames. For each categorical feature, there must be at most `max_bins` unique categories. Negative values for categorical features encoded as numeric diff --git a/sklearn/ensemble/_hist_gradient_boosting/grower.py b/sklearn/ensemble/_hist_gradient_boosting/grower.py index 6ebb5154bdf64..55bcb35d4df64 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/grower.py +++ b/sklearn/ensemble/_hist_gradient_boosting/grower.py @@ -14,9 +14,6 @@ import numpy as np -from sklearn.ensemble._hist_gradient_boosting._bitset import ( - set_raw_bitset_from_binned_bitset, -) from sklearn.ensemble._hist_gradient_boosting.common import ( PREDICTOR_RECORD_DTYPE, X_BITSET_INNER_DTYPE, @@ -25,6 +22,7 @@ from sklearn.ensemble._hist_gradient_boosting.histogram import HistogramBuilder from sklearn.ensemble._hist_gradient_boosting.predictor import TreePredictor from sklearn.ensemble._hist_gradient_boosting.splitting import Splitter +from sklearn.utils._bitset import set_raw_bitset_from_binned_bitset from sklearn.utils._openmp_helpers import _openmp_effective_n_threads @@ -661,7 +659,7 @@ def _compute_interactions(self, node): / \ / \ Right split at feature 2 has only group {1, 2} from now on. LightGBM uses the same logic for overlapping groups. See - https://github.com/microsoft/LightGBM/issues/4481 for details. + https://github.com/lightgbm-org/LightGBM/issues/4481 for details. Parameters: ---------- diff --git a/sklearn/ensemble/_hist_gradient_boosting/meson.build b/sklearn/ensemble/_hist_gradient_boosting/meson.build index 122a2102800f3..7b5f1c6b5963e 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/meson.build +++ b/sklearn/ensemble/_hist_gradient_boosting/meson.build @@ -1,3 +1,10 @@ +# We add sklearn_root_cython_tree and __init__.py so Cython can detect the +# package hierarchy and set the correct __module__ on extension types. +hist_gradient_boosting_cython_tree = [ + sklearn_root_cython_tree, + fs.copyfile('__init__.py'), +] + hist_gradient_boosting_extension_metadata = { '_gradient_boosting': {'sources': [cython_gen.process('_gradient_boosting.pyx')], 'dependencies': [openmp_dep]}, @@ -5,14 +12,13 @@ hist_gradient_boosting_extension_metadata = { 'splitting': {'sources': [cython_gen.process('splitting.pyx')], 'dependencies': [openmp_dep]}, '_binning': {'sources': [cython_gen.process('_binning.pyx')], 'dependencies': [openmp_dep]}, '_predictor': {'sources': [cython_gen.process('_predictor.pyx')], 'dependencies': [openmp_dep]}, - '_bitset': {'sources': [cython_gen.process('_bitset.pyx')]}, 'common': {'sources': [cython_gen.process('common.pyx')]}, } foreach ext_name, ext_dict : hist_gradient_boosting_extension_metadata py.extension_module( ext_name, - ext_dict.get('sources'), + [ext_dict.get('sources'), hist_gradient_boosting_cython_tree], dependencies: ext_dict.get('dependencies', []), subdir: 'sklearn/ensemble/_hist_gradient_boosting', install: true diff --git a/sklearn/ensemble/_hist_gradient_boosting/splitting.pyx b/sklearn/ensemble/_hist_gradient_boosting/splitting.pyx index 8b8b976415d81..6fae29e26ee4b 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/splitting.pyx +++ b/sklearn/ensemble/_hist_gradient_boosting/splitting.pyx @@ -16,16 +16,13 @@ from libc.math cimport INFINITY, ceil from libc.stdlib cimport malloc, free, qsort from libc.string cimport memcpy +from sklearn.utils._bitset cimport BITSET_DTYPE_C, BITSET_INNER_DTYPE_C +from sklearn.utils._bitset cimport in_bitset, init_bitset, set_bitset from sklearn.utils._typedefs cimport uint8_t from sklearn.ensemble._hist_gradient_boosting.common cimport X_BINNED_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common cimport Y_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common cimport hist_struct -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_INNER_DTYPE_C -from sklearn.ensemble._hist_gradient_boosting.common cimport BITSET_DTYPE_C from sklearn.ensemble._hist_gradient_boosting.common cimport MonotonicConstraint -from sklearn.ensemble._hist_gradient_boosting._bitset cimport init_bitset -from sklearn.ensemble._hist_gradient_boosting._bitset cimport set_bitset -from sklearn.ensemble._hist_gradient_boosting._bitset cimport in_bitset cdef struct split_info_struct: diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py index 6f9fcd0057141..6193594fa74b7 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py @@ -1,6 +1,7 @@ import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal +from scipy.stats import kstest from sklearn.ensemble._hist_gradient_boosting.binning import ( _BinMapper, @@ -113,6 +114,22 @@ def test_map_to_bins(max_bins): assert binned[max_idx, feature_idx] == max_bins - 1 +def test_unique_bins_repeated_weighted(): + # Test sample weight equivalence for the degenerate case + # when only one unique bin value exists and should be trimmed + # due to repeated values. Since the first discrete value of 1 + # is repeated/weighted 1000 times we expect the quantile bin + # threshold values found to be repeated values of 1, which are + # trimmed to return a single unique bin threshold of [1.] + col_data = np.asarray([1, 2, 3, 4, 5, 6]).reshape(-1, 1) + sample_weight = np.asarray([1000, 1, 1, 1, 1, 1]) + col_data_repeated = np.asarray(([1] * 1000) + [2, 3, 4, 5, 6]).reshape(-1, 1) + + binmapper = _BinMapper(n_bins=4).fit(col_data, sample_weight=sample_weight) + binmapper_repeated = _BinMapper(n_bins=4).fit(col_data_repeated) + assert_array_equal(binmapper.bin_thresholds_, binmapper_repeated.bin_thresholds_) + + @pytest.mark.parametrize("max_bins", [5, 10, 42]) def test_bin_mapper_random_data(max_bins): n_samples, n_features = DATA.shape @@ -198,6 +215,69 @@ def test_bin_mapper_repeated_values_invariance(n_distinct): assert_array_equal(binned_1, binned_2) +@pytest.mark.parametrize("n_bins", [50, 250]) +def test_binmapper_weighted_vs_repeated_equivalence(global_random_seed, n_bins): + rng = np.random.RandomState(global_random_seed) + + n_samples = 200 + X = rng.randn(n_samples, 3) + sw = rng.randint(0, 5, size=n_samples) + X_repeated = np.repeat(X, sw, axis=0) + + est_weighted = _BinMapper(n_bins=n_bins).fit(X, sample_weight=sw) + est_repeated = _BinMapper(n_bins=n_bins).fit(X_repeated, sample_weight=None) + assert_allclose(est_weighted.bin_thresholds_, est_repeated.bin_thresholds_) + + X_trans_weighted = est_weighted.transform(X) + X_trans_repeated = est_repeated.transform(X) + assert_array_equal(X_trans_weighted, X_trans_repeated) + + +# Note: we use a small number of RNG seeds to check that the tests is not seed +# dependent while keeping the statistical test valid. If we had used the +# global_random_seed fixture, it would have been expected to get some wrong +# rejections of the null hypothesis because of the large number of +# tests run by the fixture. +@pytest.mark.parametrize("seed", [0, 1, 42]) +@pytest.mark.parametrize("n_bins", [3, 5]) +def test_subsampled_weighted_vs_repeated_equivalence(seed, n_bins): + rng = np.random.RandomState(seed) + + n_samples = 500 + X = rng.randn(n_samples, 3) + + sw = rng.randint(0, 5, size=n_samples) + X_repeated = np.repeat(X, sw, axis=0) + + # Collect estimated bins thresholds on the weighted/repeated datasets for + # `n_resampling_iterations` subsampling. `n_resampling_iterations` is large + # enough to ensure a well-powered statistical test. + n_resampling_iterations = 500 + bins_weighted = [] + bins_repeated = [] + for _ in range(n_resampling_iterations): + params = dict(n_bins=n_bins, subsample=300, random_state=rng) + est_weighted = _BinMapper(**params).fit(X, sample_weight=sw) + est_repeated = _BinMapper(**params).fit(X_repeated, sample_weight=None) + bins_weighted.append(np.hstack(est_weighted.bin_thresholds_)) + bins_repeated.append(np.hstack(est_repeated.bin_thresholds_)) + + bins_weighted = np.stack(bins_weighted).T + bins_repeated = np.stack(bins_repeated).T + # bins_weighted and bins_weighted of shape (n_thresholds, n_subsample) + # kstest_pval of shape (n_thresholds,) + kstest_pval = np.asarray( + [ + kstest(bin_weighted, bin_repeated).pvalue + for bin_weighted, bin_repeated in zip(bins_weighted, bins_repeated) + ] + ) + # We should not be able to reject the null hypothesis that the two samples + # come from the same distribution for all bins at level 5% with Bonferroni + # correction. + assert np.min(kstest_pval) > 0.05 / len(kstest_pval) + + @pytest.mark.parametrize( "max_bins, scale, offset", [ diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_compare_lightgbm.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_compare_lightgbm.py index bbdcb38ef013a..0891457a0475d 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_compare_lightgbm.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_compare_lightgbm.py @@ -93,7 +93,7 @@ def test_same_predictions_regression( est_lightgbm.fit(X_train, y_train) est_sklearn.fit(X_train, y_train) - # We need X to be treated an numerical data, not pre-binned data. + # We need X to be treated a numerical data, not pre-binned data. X_train, X_test = X_train.astype(np.float32), X_test.astype(np.float32) pred_lightgbm = est_lightgbm.predict(X_train) @@ -170,7 +170,7 @@ def test_same_predictions_classification( est_lightgbm.fit(X_train, y_train) est_sklearn.fit(X_train, y_train) - # We need X to be treated an numerical data, not pre-binned data. + # We need X to be treated a numerical data, not pre-binned data. X_train, X_test = X_train.astype(np.float32), X_test.astype(np.float32) pred_lightgbm = est_lightgbm.predict(X_train) @@ -245,7 +245,7 @@ def test_same_predictions_multiclass_classification( est_lightgbm.fit(X_train, y_train) est_sklearn.fit(X_train, y_train) - # We need X to be treated an numerical data, not pre-binned data. + # We need X to be treated a numerical data, not pre-binned data. X_train, X_test = X_train.astype(np.float32), X_test.astype(np.float32) pred_lightgbm = est_lightgbm.predict(X_train) diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_gradient_boosting.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_gradient_boosting.py index e1d400ca07dd4..a42e15fd2b202 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_gradient_boosting.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_gradient_boosting.py @@ -2,6 +2,8 @@ import io import pickle import re +import sys +import sysconfig import warnings from unittest.mock import Mock @@ -11,7 +13,7 @@ from joblib.numpy_pickle import NumpyPickler from numpy.testing import assert_allclose, assert_array_equal -import sklearn +import sklearn.ensemble._hist_gradient_boosting.gradient_boosting as hgb_module from sklearn._loss.loss import ( AbsoluteError, HalfBinomialLoss, @@ -522,7 +524,7 @@ def test_small_trainset(): def test_missing_values_minmax_imputation(): - # Compare the buit-in missing value handling of Histogram GBC with an + # Compare the built-in missing value handling of Histogram GBC with an # a-priori missing value imputation strategy that should yield the same # results in terms of decision function. # @@ -706,6 +708,7 @@ def test_zero_sample_weights_classification(): X = [[1, 0], [1, 0], [1, 0], [0, 1], [1, 1]] y = [0, 0, 1, 0, 2] + # ignore the first 2 training samples by setting their weight to 0 sample_weight = [0, 0, 1, 1, 1] gb = HistGradientBoostingClassifier(loss="log_loss", min_samples_leaf=1) @@ -716,16 +719,19 @@ def test_zero_sample_weights_classification(): @pytest.mark.parametrize( "problem", ("regression", "binary_classification", "multiclass_classification") ) -@pytest.mark.parametrize("duplication", ("half", "all")) -def test_sample_weight_effect(problem, duplication): +def test_sample_weight_effect(problem, global_random_seed): # High level test to make sure that duplicating a sample is equivalent to # giving it weight of 2. - # fails for n_samples > 255 because binning does not take sample weights - # into account. Keeping n_samples <= 255 makes - # sure only unique values are used so SW have no effect on binning. - n_samples = 255 + # This test assumes that subsampling in `_BinMapper` is disabled + # (when `n_samples < 2e5`) and therefore the binning results should be + # deterministic. + # Otherwise, this test would require being rewritten as a statistical test. + # We also set `n_samples` large enough to ensure that columns have more than + # 255 distinct values and that we test the impact of weight-aware binning. + n_samples = 300 n_features = 2 + rng = np.random.RandomState(global_random_seed) if problem == "regression": X, y = make_regression( n_samples=n_samples, @@ -753,21 +759,17 @@ def test_sample_weight_effect(problem, duplication): # duplicated samples. est = Klass(min_samples_leaf=1) - # Create dataset with duplicate and corresponding sample weights - if duplication == "half": - lim = n_samples // 2 - else: - lim = n_samples - X_dup = np.r_[X, X[:lim]] - y_dup = np.r_[y, y[:lim]] - sample_weight = np.ones(shape=(n_samples)) - sample_weight[:lim] = 2 + # Create dataset with repetitions and corresponding sample weights + sample_weight = rng.randint(0, 3, size=X.shape[0]) + X_repeated = np.repeat(X, sample_weight, axis=0) + assert X_repeated.shape[0] < 2e5 + y_repeated = np.repeat(y, sample_weight, axis=0) - est_sw = clone(est).fit(X, y, sample_weight=sample_weight) - est_dup = clone(est).fit(X_dup, y_dup) + est_weighted = clone(est).fit(X, y, sample_weight=sample_weight) + est_repeated = clone(est).fit(X_repeated, y_repeated) # checking raw_predict is stricter than just predict for classification - assert np.allclose(est_sw._raw_predict(X_dup), est_dup._raw_predict(X_dup)) + assert_allclose(est_weighted._raw_predict(X), est_repeated._raw_predict(X)) @pytest.mark.parametrize("Loss", (HalfSquaredError, AbsoluteError)) @@ -870,11 +872,7 @@ def mock_check_scoring(estimator, scoring): assert scoring == "neg_median_absolute_error" return mock_scorer - monkeypatch.setattr( - sklearn.ensemble._hist_gradient_boosting.gradient_boosting, - "check_scoring", - mock_check_scoring, - ) + monkeypatch.setattr(hgb_module, "check_scoring", mock_check_scoring) X, y = make_regression(random_state=0) sample_weight = np.ones_like(y) @@ -1305,7 +1303,7 @@ def test_check_interaction_cst(interaction_cst, n_features, result): def test_interaction_cst_numerically(): """Check that interaction constraints have no forbidden interactions.""" rng = np.random.RandomState(42) - n_samples = 1000 + n_samples = 2000 X = rng.uniform(size=(n_samples, 2)) # Construct y with a strong interaction term # y = x0 + x1 + 5 * x0 * x1 @@ -1353,6 +1351,12 @@ def test_interaction_cst_numerically(): ) +@pytest.mark.xfail( + sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + and sys.version_info[:2] == (3, 13), + reason="Fails intermittently in the CI for Python 3.13 free-threaded," + " see https://github.com/scikit-learn/scikit-learn/issues/32631", +) def test_no_user_warning_with_scoring(): """Check that no UserWarning is raised when scoring is set. diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_grower.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_grower.py index a55cb871e3c72..4bd6a30c9d9d5 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_grower.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_grower.py @@ -146,7 +146,7 @@ def test_grow_tree(n_bins, constant_hessian, stopping_param, shrinkage): assert len(right_right_node.sample_indices) > 0.2 * n_samples assert len(right_right_node.sample_indices) < 0.4 * n_samples - # All the leafs are pure, it is not possible to split any further: + # All the leaves are pure, it is not possible to split any further: assert not grower.splittable_nodes grower._apply_shrinkage() diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_monotonic_constraints.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_monotonic_constraints.py index 56b6068d794e8..3b0be9ef8fa0e 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_monotonic_constraints.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_monotonic_constraints.py @@ -225,9 +225,9 @@ def test_predictions(global_random_seed, use_feature_names): f_c = rng.randint(low=0, high=9, size=n_samples) X = np.c_[f_a, f_0, f_b, f_1, f_c] - columns_name = ["f_a", "f_0", "f_b", "f_1", "f_c"] - constructor_name = "dataframe" if use_feature_names else "array" - X = _convert_container(X, constructor_name, columns_name=columns_name) + column_names = ["f_a", "f_0", "f_b", "f_1", "f_c"] + constructor_name = "pandas" if use_feature_names else "array" + X = _convert_container(X, constructor_name, column_names=column_names) noise = rng.normal(loc=0.0, scale=0.01, size=n_samples) y = 5 * f_0 + np.sin(10 * np.pi * f_0) - 5 * f_1 - np.cos(10 * np.pi * f_1) + noise @@ -261,24 +261,24 @@ def test_predictions(global_random_seed, use_feature_names): # First non-categorical feature (POS) # assert pred is all increasing when f_0 is all increasing X = np.c_[constant, linspace, constant, constant, constant] - X = _convert_container(X, constructor_name, columns_name=columns_name) + X = _convert_container(X, constructor_name, column_names=column_names) pred = gbdt.predict(X) assert is_increasing(pred) # assert pred actually follows the variations of f_0 X = np.c_[constant, sin, constant, constant, constant] - X = _convert_container(X, constructor_name, columns_name=columns_name) + X = _convert_container(X, constructor_name, column_names=column_names) pred = gbdt.predict(X) assert np.all((np.diff(pred) >= 0) == (np.diff(sin) >= 0)) # Second non-categorical feature (NEG) # assert pred is all decreasing when f_1 is all increasing X = np.c_[constant, constant, constant, linspace, constant] - X = _convert_container(X, constructor_name, columns_name=columns_name) + X = _convert_container(X, constructor_name, column_names=column_names) pred = gbdt.predict(X) assert is_decreasing(pred) # assert pred actually follows the inverse variations of f_1 X = np.c_[constant, constant, constant, sin, constant] - X = _convert_container(X, constructor_name, columns_name=columns_name) + X = _convert_container(X, constructor_name, column_names=column_names) pred = gbdt.predict(X) assert ((np.diff(pred) <= 0) == (np.diff(sin) >= 0)).all() diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_predictor.py b/sklearn/ensemble/_hist_gradient_boosting/tests/test_predictor.py index 3c3c9ae81bac2..0612e038aa0a8 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_predictor.py +++ b/sklearn/ensemble/_hist_gradient_boosting/tests/test_predictor.py @@ -3,10 +3,6 @@ from numpy.testing import assert_allclose from sklearn.datasets import make_regression -from sklearn.ensemble._hist_gradient_boosting._bitset import ( - set_bitset_memoryview, - set_raw_bitset_from_binned_bitset, -) from sklearn.ensemble._hist_gradient_boosting.binning import _BinMapper from sklearn.ensemble._hist_gradient_boosting.common import ( ALMOST_INF, @@ -20,6 +16,10 @@ from sklearn.ensemble._hist_gradient_boosting.predictor import TreePredictor from sklearn.metrics import r2_score from sklearn.model_selection import train_test_split +from sklearn.utils._bitset import ( + set_bitset_memoryview, + set_raw_bitset_from_binned_bitset, +) from sklearn.utils._openmp_helpers import _openmp_effective_n_threads n_threads = _openmp_effective_n_threads() @@ -28,7 +28,7 @@ @pytest.mark.parametrize("n_bins", [200, 256]) def test_regression_dataset(n_bins): X, y = make_regression( - n_samples=500, n_features=10, n_informative=5, random_state=42 + n_samples=1000, n_features=10, n_informative=5, random_state=42 ) X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) diff --git a/sklearn/ensemble/_hist_gradient_boosting/utils.py b/sklearn/ensemble/_hist_gradient_boosting/utils.py index a0f917d3926c2..1e9319b101b49 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/utils.py +++ b/sklearn/ensemble/_hist_gradient_boosting/utils.py @@ -72,7 +72,7 @@ def get_equivalent_estimator(estimator, lib="lightgbm", n_classes=None): # LightGBM 3.0 introduced a different scaling of the hessian for the multiclass # case. # It is equivalent of scaling the learning rate. - # See https://github.com/microsoft/LightGBM/pull/3256. + # See https://github.com/lightgbm-org/LightGBM/pull/3256. if n_classes is not None: lightgbm_params["learning_rate"] *= n_classes / (n_classes - 1) @@ -106,7 +106,7 @@ def get_equivalent_estimator(estimator, lib="lightgbm", n_classes=None): catboost_loss_mapping = { "squared_error": "RMSE", # catboost does not support MAE when leaf_estimation_method is Newton - "absolute_error": "LEAST_ASBOLUTE_DEV_NOT_SUPPORTED", + "absolute_error": "LEAST_ABSOLUTE_DEV_NOT_SUPPORTED", "log_loss": "Logloss" if n_classes == 2 else "MultiClass", "gamma": None, "poisson": "Poisson", diff --git a/sklearn/ensemble/_iforest.py b/sklearn/ensemble/_iforest.py index 9c709927d7bbc..0ff9a86f2c5c8 100644 --- a/sklearn/ensemble/_iforest.py +++ b/sklearn/ensemble/_iforest.py @@ -12,7 +12,6 @@ from sklearn.base import OutlierMixin, _fit_context from sklearn.ensemble._bagging import BaseBagging from sklearn.tree import ExtraTreeRegressor -from sklearn.tree._tree import DTYPE as tree_dtype from sklearn.utils import check_array, check_random_state, gen_batches from sklearn.utils._chunking import get_chunk_n_rows from sklearn.utils._param_validation import Interval, RealNotInt, StrOptions @@ -319,7 +318,7 @@ def fit(self, X, y=None, sample_weight=None): Fitted estimator. """ X = validate_data( - self, X, accept_sparse=["csc"], dtype=tree_dtype, ensure_all_finite=False + self, X, accept_sparse=["csc"], dtype=np.float32, ensure_all_finite=False ) if sample_weight is not None: @@ -441,7 +440,7 @@ def decision_function(self, X): of the leaf containing this observation, which is equivalent to the number of splittings required to isolate this point. In case of several observations n_left in the leaf, the average path length of - a n_left samples isolation tree is added. + an n_left samples isolation tree is added. Parameters ---------- @@ -492,7 +491,7 @@ def score_samples(self, X): of the leaf containing this observation, which is equivalent to the number of splittings required to isolate this point. In case of several observations n_left in the leaf, the average path length of - a n_left samples isolation tree is added. + an n_left samples isolation tree is added. Parameters ---------- @@ -528,7 +527,7 @@ def score_samples(self, X): self, X, accept_sparse="csr", - dtype=tree_dtype, + dtype=np.float32, reset=False, ensure_all_finite=False, ) @@ -647,7 +646,7 @@ def __sklearn_tags__(self): def _average_path_length(n_samples_leaf): """ - The average path length in a n_samples iTree, which is equal to + The average path length in an n_samples iTree, which is equal to the average path length of an unsuccessful BST search since the latter has the same structure as an isolation tree. Parameters diff --git a/sklearn/ensemble/tests/test_bagging.py b/sklearn/ensemble/tests/test_bagging.py index 611ea271b3f91..69656d2ee3f01 100644 --- a/sklearn/ensemble/tests/test_bagging.py +++ b/sklearn/ensemble/tests/test_bagging.py @@ -14,6 +14,7 @@ from sklearn import config_context from sklearn.base import BaseEstimator +from sklearn.calibration import CalibratedClassifierCV from sklearn.datasets import load_diabetes, load_iris, make_hastie_10_2 from sklearn.dummy import DummyClassifier, DummyRegressor from sklearn.ensemble import ( @@ -125,9 +126,8 @@ def test_classification(): ) def test_sparse_classification(sparse_container, params, method): # Check classification for various parameter settings on sparse input. - - class CustomSVC(SVC): - """SVC variant that records the nature of the training set""" + class CustomClassifier(LogisticRegression): + """LogisticRegression variant that records the nature of the training set""" def fit(self, X, y): super().fit(X, y) @@ -141,17 +141,19 @@ def fit(self, X, y): X_train_sparse = sparse_container(X_train) X_test_sparse = sparse_container(X_test) + # Trained on sparse format sparse_classifier = BaggingClassifier( - estimator=CustomSVC(kernel="linear", decision_function_shape="ovr"), + estimator=CustomClassifier(), random_state=1, **params, ).fit(X_train_sparse, y_train) + print(sparse_classifier, method) sparse_results = getattr(sparse_classifier, method)(X_test_sparse) # Trained on dense format dense_classifier = BaggingClassifier( - estimator=CustomSVC(kernel="linear", decision_function_shape="ovr"), + estimator=CustomClassifier(), random_state=1, **params, ).fit(X_train, y_train) @@ -371,7 +373,10 @@ def test_oob_score_classification(): iris.data, iris.target, random_state=rng ) - for estimator in [DecisionTreeClassifier(), SVC()]: + for estimator in [ + DecisionTreeClassifier(), + CalibratedClassifierCV(SVC(), ensemble=False), + ]: clf = BaggingClassifier( estimator=estimator, n_estimators=100, @@ -463,6 +468,9 @@ def test_error(): assert not hasattr(BaggingClassifier(base).fit(X, y), "decision_function") +# TODO: remove mark once loky bug is fixed: +# https://github.com/joblib/loky/issues/458 +@pytest.mark.thread_unsafe def test_parallel_classification(): # Check parallel classification. X_train, X_test, y_train, y_test = train_test_split( @@ -706,16 +714,17 @@ def test_warning_bootstrap_sample_weight(): def test_invalid_sample_weight_max_samples_bootstrap_combinations(): X, y = iris.data, iris.target - # Case 1: small weights and fractional max_samples would lead to sampling - # less than 1 sample, which is not allowed. + # Case 1: small weights and fractional max_samples lead to a small + # number of bootstrap samples, which raises a UserWarning. clf = BaggingClassifier(max_samples=1.0) sample_weight = np.ones_like(y) / (2 * len(y)) expected_msg = ( - r"The total sum of sample weights is 0.5(\d*), which prevents resampling with " - r"a fractional value for max_samples=1\.0\. Either pass max_samples as an " - r"integer or use a larger sample_weight\." + "Using the fractional value max_samples=1.0 when " + r"the total sum of sample weights is 0.5(\d*) " + r"results in a low number \(1\) of bootstrap samples. " + "We recommend passing `max_samples` as an integer." ) - with pytest.raises(ValueError, match=expected_msg): + with pytest.warns(UserWarning, match=expected_msg): clf.fit(X, y, sample_weight=sample_weight) # Case 2: large weights and bootstrap=False would lead to sampling without diff --git a/sklearn/ensemble/tests/test_bootstrap.py b/sklearn/ensemble/tests/test_bootstrap.py new file mode 100644 index 0000000000000..31d2c534a88d2 --- /dev/null +++ b/sklearn/ensemble/tests/test_bootstrap.py @@ -0,0 +1,81 @@ +""" +Testing for the utility function _get_n_samples_bootstrap +""" + +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import warnings + +import numpy as np +import pytest + +from sklearn.ensemble._bootstrap import _get_n_samples_bootstrap + + +def test_get_n_samples_bootstrap(): + # max_samples=None returns n_samples + n_samples, max_samples, sample_weight = 10, None, "not_used" + assert _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == n_samples + + # max_samples:int returns max_samples + n_samples, max_samples, sample_weight = 10, 5, "not_used" + assert ( + _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == max_samples + ) + + # cases where n_samples_bootstrap is small and should raise a warning + warning_msg = ".+the number of samples.+low number.+max_samples.+as an integer" + n_samples, max_samples, sample_weight = 10, 0.66, None + with pytest.warns(UserWarning, match=warning_msg): + assert _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == int( + max_samples * n_samples + ) + + n_samples, max_samples, sample_weight = 10, 0.01, None + with pytest.warns(UserWarning, match=warning_msg): + assert _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == 1 + + warning_msg_with_weights = ( + ".+the total sum of sample weights.+low number.+max_samples.+as an integer" + ) + rng = np.random.default_rng(0) + n_samples, max_samples, sample_weight = 10, 0.8, rng.uniform(size=10) + with pytest.warns(UserWarning, match=warning_msg_with_weights): + assert _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == int( + max_samples * sample_weight.sum() + ) + + # cases where n_samples_bootstrap is big enough and shouldn't raise a warning + with warnings.catch_warnings(): + warnings.simplefilter("error") + n_samples, max_samples, sample_weight = 100, 30, None + assert ( + _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) + == max_samples + ) + n_samples, max_samples, sample_weight = 100, 0.5, rng.uniform(size=100) + assert _get_n_samples_bootstrap(n_samples, max_samples, sample_weight) == int( + max_samples * sample_weight.sum() + ) + + +@pytest.mark.parametrize("max_samples", [None, 1, 5, 1000, 0.1, 1.0, 1.5]) +def test_n_samples_bootstrap_repeated_weighted_equivalence(max_samples): + # weighted dataset + n_samples = 100 + rng = np.random.RandomState(0) + sample_weight = rng.randint(2, 5, n_samples) + # repeated dataset + n_samples_repeated = sample_weight.sum() + + n_bootstrap_weighted = _get_n_samples_bootstrap( + n_samples, max_samples, sample_weight + ) + n_bootstrap_repeated = _get_n_samples_bootstrap( + n_samples_repeated, max_samples, None + ) + if max_samples is None: + assert n_bootstrap_weighted != n_bootstrap_repeated + else: + assert n_bootstrap_weighted == n_bootstrap_repeated diff --git a/sklearn/ensemble/tests/test_forest.py b/sklearn/ensemble/tests/test_forest.py index d22591d37ec9b..ca2a4d102a844 100644 --- a/sklearn/ensemble/tests/test_forest.py +++ b/sklearn/ensemble/tests/test_forest.py @@ -21,7 +21,7 @@ import sklearn from sklearn import clone, datasets -from sklearn.datasets import make_classification, make_hastie_10_2 +from sklearn.datasets import make_classification, make_hastie_10_2, make_regression from sklearn.decomposition import TruncatedSVD from sklearn.dummy import DummyRegressor from sklearn.ensemble import ( @@ -31,10 +31,8 @@ RandomForestRegressor, RandomTreesEmbedding, ) -from sklearn.ensemble._forest import ( - _generate_unsampled_indices, - _get_n_samples_bootstrap, -) +from sklearn.ensemble._bootstrap import _get_n_samples_bootstrap +from sklearn.ensemble._forest import _generate_unsampled_indices from sklearn.exceptions import NotFittedError from sklearn.metrics import ( explained_variance_score, @@ -117,6 +115,9 @@ FOREST_CLASSIFIERS_REGRESSORS: Dict[str, Any] = FOREST_CLASSIFIERS.copy() FOREST_CLASSIFIERS_REGRESSORS.update(FOREST_REGRESSORS) +CLF_CRITERIONS = ("gini", "log_loss") +REG_CRITERIONS = ("squared_error", "absolute_error", "friedman_mse", "poisson") + @pytest.mark.parametrize("name", FOREST_CLASSIFIERS) def test_classification_toy(name): @@ -157,9 +158,11 @@ def test_iris_criterion(name, criterion): assert score > 0.5, "Failed with criterion %s and score = %f" % (criterion, score) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("name", FOREST_REGRESSORS) @pytest.mark.parametrize( - "criterion", ("squared_error", "absolute_error", "friedman_mse") + "criterion", ("squared_error", "friedman_mse", "absolute_error") ) def test_regression_criterion(name, criterion): # Check consistency on regression dataset. @@ -294,7 +297,7 @@ def test_probability(name): "name, criterion", itertools.chain( product(FOREST_CLASSIFIERS, ["gini", "log_loss"]), - product(FOREST_REGRESSORS, ["squared_error", "friedman_mse", "absolute_error"]), + product(FOREST_REGRESSORS, ["squared_error", "absolute_error"]), ), ) def test_importances(dtype, name, criterion): @@ -643,7 +646,7 @@ def test_forest_multioutput_integral_regression_target(ForestRegressor): ) estimator.fit(X, y) - n_samples_bootstrap = _get_n_samples_bootstrap(len(X), estimator.max_samples) + n_samples_bootstrap = _get_n_samples_bootstrap(len(X), estimator.max_samples, None) n_samples_test = X.shape[0] // 4 oob_pred = np.zeros([n_samples_test, 2]) for sample_idx, sample in enumerate(X[:n_samples_test]): @@ -651,7 +654,7 @@ def test_forest_multioutput_integral_regression_target(ForestRegressor): oob_pred_sample = np.zeros(2) for tree in estimator.estimators_: oob_unsampled_indices = _generate_unsampled_indices( - tree.random_state, len(X), n_samples_bootstrap + tree.random_state, len(X), n_samples_bootstrap, None ) if sample_idx in oob_unsampled_indices: n_samples_oob += 1 @@ -1161,50 +1164,104 @@ def test_1d_input(name): @pytest.mark.parametrize("name", FOREST_CLASSIFIERS) -def test_class_weights(name): - # Check class_weights resemble sample_weights behavior. +@pytest.mark.parametrize("n_classes", [2, 3, 4]) +def test_validate_y_class_weight(name, n_classes, global_random_seed): ForestClassifier = FOREST_CLASSIFIERS[name] + clf = ForestClassifier(random_state=0) + # toy dataset with n_classes + y = np.repeat(np.arange(n_classes), 3) + rng = np.random.RandomState(global_random_seed) + sw = rng.randint(1, 5, size=len(y)) + weighted_frequency = np.bincount(y, weights=sw) / sw.sum() + balanced_class_weight = 1 / (n_classes * weighted_frequency) + # validation in fit reshapes y as (n_samples, 1) + y_reshaped = np.reshape(y, (-1, 1)) + # Manually set these attributes, as we are not calling `fit` + clf._n_samples, clf.n_outputs_ = y_reshaped.shape + + # checking dict class_weight + class_weight = rng.randint(1, 7, size=n_classes) + class_weight_dict = dict(enumerate(class_weight)) + clf.set_params(class_weight=class_weight_dict) + _, expanded_class_weight = clf._validate_y_class_weight(y_reshaped, sw) + assert_allclose(expanded_class_weight, class_weight[y]) + + # checking class_weight="balanced" + clf.set_params(class_weight="balanced") + _, expanded_class_weight = clf._validate_y_class_weight(y_reshaped, sw) + assert_allclose(expanded_class_weight, balanced_class_weight[y]) + + # checking class_weight="balanced_subsample" with bootstrap=False + # (should be equivalent to "balanced") + clf.set_params(class_weight="balanced_subsample", bootstrap=False) + _, expanded_class_weight = clf._validate_y_class_weight(y_reshaped, sw) + assert_allclose(expanded_class_weight, balanced_class_weight[y]) + + # checking class_weight="balanced_subsample" with bootstrap=True + # (should be None) + clf.set_params(class_weight="balanced_subsample", bootstrap=True) + _, expanded_class_weight = clf._validate_y_class_weight(y_reshaped, sw) + assert expanded_class_weight is None - # Iris is balanced, so no effect expected for using 'balanced' weights - clf1 = ForestClassifier(random_state=0) - clf1.fit(iris.data, iris.target) - clf2 = ForestClassifier(class_weight="balanced", random_state=0) + +@pytest.mark.parametrize("name", FOREST_CLASSIFIERS) +@pytest.mark.parametrize("bootstrap", [True, False]) +def test_class_weights_forest(name, bootstrap, global_random_seed): + # Check class_weights resemble sample_weights behavior. + ForestClassifier = FOREST_CLASSIFIERS[name] + clf = ForestClassifier(random_state=global_random_seed, bootstrap=bootstrap) + + # Iris is balanced, so no effect expected for using 'balanced' weights. + # Using the class_weight="balanced" option is then equivalent to fit with + # all ones sample_weight. However we cannot guarantee the same fit for + # sample_weight = None vs all ones, because the indices are drawn by + # different rng functions (choice vs randint). Thus we explicitly pass + # the sample_weight as all ones in clf1 fit. + clf1 = clone(clf) + clf1.fit(iris.data, iris.target, sample_weight=np.ones_like(iris.target)) + clf2 = clone(clf).set_params(class_weight="balanced") clf2.fit(iris.data, iris.target) + assert_almost_equal(clf2._sample_weight, 1) assert_almost_equal(clf1.feature_importances_, clf2.feature_importances_) # Make a multi-output problem with three copies of Iris iris_multi = np.vstack((iris.target, iris.target, iris.target)).T # Create user-defined weights that should balance over the outputs - clf3 = ForestClassifier( + clf3 = clone(clf).set_params( class_weight=[ {0: 2.0, 1: 2.0, 2: 1.0}, {0: 2.0, 1: 1.0, 2: 2.0}, {0: 1.0, 1: 2.0, 2: 2.0}, - ], - random_state=0, + ] ) clf3.fit(iris.data, iris_multi) - assert_almost_equal(clf2.feature_importances_, clf3.feature_importances_) + # for multi-output, weights are multiplied + assert_almost_equal(clf3._sample_weight, 2 * 2 * 1) + # FIXME why is this test brittle ? + assert_allclose(clf2.feature_importances_, clf3.feature_importances_, atol=0.002) # Check against multi-output "balanced" which should also have no effect - clf4 = ForestClassifier(class_weight="balanced", random_state=0) + clf4 = clone(clf).set_params(class_weight="balanced") clf4.fit(iris.data, iris_multi) + assert_almost_equal(clf4._sample_weight, 1) assert_almost_equal(clf3.feature_importances_, clf4.feature_importances_) # Inflate importance of class 1, check against user-defined weights sample_weight = np.ones(iris.target.shape) sample_weight[iris.target == 1] *= 100 class_weight = {0: 1.0, 1: 100.0, 2: 1.0} - clf1 = ForestClassifier(random_state=0) + clf1 = clone(clf) clf1.fit(iris.data, iris.target, sample_weight) - clf2 = ForestClassifier(class_weight=class_weight, random_state=0) + clf2 = clone(clf).set_params(class_weight=class_weight) clf2.fit(iris.data, iris.target) + assert_almost_equal(clf1._sample_weight, clf2._sample_weight) assert_almost_equal(clf1.feature_importances_, clf2.feature_importances_) # Check that sample_weight and class_weight are multiplicative - clf1 = ForestClassifier(random_state=0) + clf1 = clone(clf) clf1.fit(iris.data, iris.target, sample_weight**2) - clf2 = ForestClassifier(class_weight=class_weight, random_state=0) + clf2 = clone(clf).set_params(class_weight=class_weight) clf2.fit(iris.data, iris.target, sample_weight) + assert_almost_equal(clf1._sample_weight, clf2._sample_weight) assert_almost_equal(clf1.feature_importances_, clf2.feature_importances_) @@ -1529,6 +1586,25 @@ def test_forest_degenerate_feature_importances(): assert_array_equal(gbr.feature_importances_, np.zeros(10, dtype=np.float64)) +@pytest.mark.parametrize("name", FOREST_CLASSIFIERS_REGRESSORS) +def test_max_samples_geq_one(name): + # Check that `max_samples >= 1.0` and `max_samples >= n_samples ` + # is allowed, issue #28507 + X, y = hastie_X, hastie_y + max_samples_float = 1.5 + max_sample_int = int(max_samples_float * X.shape[0]) + est1 = FOREST_CLASSIFIERS_REGRESSORS[name]( + bootstrap=True, max_samples=max_samples_float, random_state=11 + ) + est1.fit(X, y) + est2 = FOREST_CLASSIFIERS_REGRESSORS[name]( + bootstrap=True, max_samples=max_sample_int, random_state=11 + ) + est2.fit(X, y) + assert est1._n_samples_bootstrap == est2._n_samples_bootstrap + assert_allclose(est1.score(X, y), est2.score(X, y)) + + @pytest.mark.parametrize("name", FOREST_CLASSIFIERS_REGRESSORS) def test_max_samples_bootstrap(name): # Check invalid `max_samples` values @@ -1542,15 +1618,6 @@ def test_max_samples_bootstrap(name): est.fit(X, y) -@pytest.mark.parametrize("name", FOREST_CLASSIFIERS_REGRESSORS) -def test_large_max_samples_exception(name): - # Check invalid `max_samples` - est = FOREST_CLASSIFIERS_REGRESSORS[name](bootstrap=True, max_samples=int(1e9)) - match = "`max_samples` must be <= n_samples=6 but got value 1000000000" - with pytest.raises(ValueError, match=match): - est.fit(X, y) - - @pytest.mark.parametrize("name", FOREST_REGRESSORS) def test_max_samples_boundary_regressors(name): X_train, X_test, y_train, y_test = train_test_split( @@ -1765,22 +1832,26 @@ def test_estimators_samples(ForestClass, bootstrap, seed): assert_allclose(orig_tree_values, new_tree_values) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize( - "make_data, Forest", + "Forest, criterion", [ - (datasets.make_regression, RandomForestRegressor), - (datasets.make_classification, RandomForestClassifier), - (datasets.make_regression, ExtraTreesRegressor), - (datasets.make_classification, ExtraTreesClassifier), + *product(FOREST_REGRESSORS.values(), REG_CRITERIONS), + *product(FOREST_CLASSIFIERS.values(), CLF_CRITERIONS), ], ) -def test_missing_values_is_resilient(make_data, Forest): +def test_missing_values_is_resilient(Forest, criterion): """Check that forest can deal with missing values and has decent performance.""" - rng = np.random.RandomState(0) - n_samples, n_features = 1000, 10 + n_samples, n_features = 500, 5 + make_data = make_regression if criterion in REG_CRITERIONS else make_classification X, y = make_data(n_samples=n_samples, n_features=n_features, random_state=rng) + # Make y non-negative for Poisson criterion + if criterion == "poisson": + y -= np.min(y) + # Create dataset with missing values X_missing = X.copy() X_missing[rng.choice([False, True], size=X.shape, p=[0.95, 0.05])] = np.nan @@ -1791,13 +1862,13 @@ def test_missing_values_is_resilient(make_data, Forest): ) # Train forest with missing values - forest_with_missing = Forest(random_state=rng, n_estimators=50) + forest_with_missing = Forest(random_state=rng, criterion=criterion, n_estimators=50) forest_with_missing.fit(X_missing_train, y_train) score_with_missing = forest_with_missing.score(X_missing_test, y_test) # Train forest without missing values X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) - forest = Forest(random_state=rng, n_estimators=50) + forest = Forest(random_state=rng, criterion=criterion, n_estimators=50) forest.fit(X_train, y_train) score_without_missing = forest.score(X_test, y_test) @@ -1805,36 +1876,36 @@ def test_missing_values_is_resilient(make_data, Forest): assert score_with_missing >= 0.80 * score_without_missing +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize( - "Forest", + "Forest, criterion", [ - RandomForestClassifier, - RandomForestRegressor, - ExtraTreesRegressor, - ExtraTreesClassifier, + *product(FOREST_REGRESSORS.values(), REG_CRITERIONS), + *product(FOREST_CLASSIFIERS.values(), CLF_CRITERIONS), ], ) -def test_missing_value_is_predictive(Forest): +def test_missing_value_is_predictive(Forest, criterion, global_random_seed): """Check that the forest learns when missing values are only present for a predictive feature.""" - rng = np.random.RandomState(0) - n_samples = 300 - expected_score = 0.75 + rng = np.random.RandomState(global_random_seed) + n_samples = 1000 + expected_score_gap = 0.3 + # Require a minimum 0.3 gap between `forest_predictive` and + # `forest_non_predictive`: meaningful for R2/accuracy, but robust in tests. - X_non_predictive = rng.standard_normal(size=(n_samples, 10)) - y = rng.randint(0, high=2, size=n_samples) + X_non_predictive = rng.randn(n_samples, 2) + y = rng.rand(n_samples) < 0.5 # Create a predictive feature using `y` and with some noise - X_random_mask = rng.choice([False, True], size=n_samples, p=[0.95, 0.05]) - y_mask = y.astype(bool) - y_mask[X_random_mask] = ~y_mask[X_random_mask] - - predictive_feature = rng.standard_normal(size=n_samples) - predictive_feature[y_mask] = np.nan + predictive_feature = rng.randn(n_samples) + noise_mask = rng.rand(n_samples) < 0.05 + # nan/non-nan indicates y is 1/0, except if noise_mask is true: + predictive_feature[y ^ noise_mask] = np.nan assert np.isnan(predictive_feature).any() X_predictive = X_non_predictive.copy() - X_predictive[:, 5] = predictive_feature + X_predictive[:, 1] = predictive_feature ( X_predictive_train, @@ -1844,25 +1915,21 @@ def test_missing_value_is_predictive(Forest): y_train, y_test, ) = train_test_split(X_predictive, X_non_predictive, y, random_state=0) - forest_predictive = Forest(random_state=0).fit(X_predictive_train, y_train) - forest_non_predictive = Forest(random_state=0).fit(X_non_predictive_train, y_train) + forest_predictive = Forest(random_state=0, criterion=criterion) + forest_predictive.fit(X_predictive_train, y_train) + forest_non_predictive = Forest(random_state=0, criterion=criterion) + forest_non_predictive.fit(X_non_predictive_train, y_train) predictive_test_score = forest_predictive.score(X_predictive_test, y_test) - - assert predictive_test_score >= expected_score - assert predictive_test_score >= forest_non_predictive.score( + non_predictive_test_score = forest_non_predictive.score( X_non_predictive_test, y_test ) + assert predictive_test_score >= non_predictive_test_score + expected_score_gap -@pytest.mark.parametrize("Forest", FOREST_REGRESSORS.values()) -def test_non_supported_criterion_raises_error_with_missing_values(Forest): - """Raise error for unsupported criterion when there are missing values.""" - X = np.array([[0, 1, 2], [np.nan, 0, 2.0]]) - y = [0.5, 1.0] - - forest = Forest(criterion="absolute_error") - msg = ".*does not accept missing values" - with pytest.raises(ValueError, match=msg): - forest.fit(X, y) +# TODO(1.11): remove test with the deprecation of friedman_mse criterion +@pytest.mark.parametrize("Forest", FOREST_REGRESSORS.values()) +def test_friedman_mse_deprecation(Forest): + with pytest.warns(FutureWarning, match="friedman_mse"): + _ = Forest(criterion="friedman_mse") diff --git a/sklearn/ensemble/tests/test_gradient_boosting.py b/sklearn/ensemble/tests/test_gradient_boosting.py index 20866348697f6..9be764c5d9ccf 100644 --- a/sklearn/ensemble/tests/test_gradient_boosting.py +++ b/sklearn/ensemble/tests/test_gradient_boosting.py @@ -963,7 +963,7 @@ def test_warm_start_sparse(Cls, sparse_container): @pytest.mark.parametrize("Cls", GRADIENT_BOOSTING_ESTIMATORS) def test_warm_start_fortran(Cls, global_random_seed): - # Test that feeding a X in Fortran-ordered is giving the same results as + # Test that feeding an X in Fortran-ordered is giving the same results as # in C-ordered X, y = datasets.make_hastie_10_2(n_samples=100, random_state=global_random_seed) est_c = Cls(n_estimators=1, random_state=global_random_seed, warm_start=True) @@ -1036,7 +1036,7 @@ def test_monitor_early_stopping(Cls): def test_complete_classification(): - # Test greedy trees with max_depth + 1 leafs. + # Test greedy trees with max_depth + 1 leaves. from sklearn.tree._tree import TREE_LEAF X, y = datasets.make_hastie_10_2(n_samples=100, random_state=1) @@ -1053,7 +1053,7 @@ def test_complete_classification(): def test_complete_regression(): - # Test greedy trees with max_depth + 1 leafs. + # Test greedy trees with max_depth + 1 leaves. from sklearn.tree._tree import TREE_LEAF k = 4 @@ -1391,7 +1391,7 @@ def test_gradient_boosting_with_init_pipeline(): # Passing sample_weight to a pipeline raises a ValueError. This test makes # sure we make the distinction between ValueError raised by a pipeline that - # was passed sample_weight, and a InvalidParameterError raised by a regular + # was passed sample_weight, and an InvalidParameterError raised by a regular # estimator whose input checking failed. invalid_nu = 1.5 err_msg = ( @@ -1551,12 +1551,8 @@ def test_squared_error_exact_backward_compat(): assert_allclose(gbt.train_score_[-10:], train_score, rtol=1e-3, atol=1e-11) -@skip_if_32bit -def test_huber_exact_backward_compat(): - """Test huber GBT backward compat on a simple dataset. - - The results to compare against are taken from scikit-learn v1.2.0. - """ +def test_huber_overfit(): + """Test huber GBT can completely overfit""" n_samples = 10 y = np.arange(n_samples) x1 = np.minimum(y, n_samples / 2) @@ -1564,39 +1560,9 @@ def test_huber_exact_backward_compat(): X = np.c_[x1, x2] gbt = GradientBoostingRegressor(loss="huber", n_estimators=100, alpha=0.8).fit(X, y) - assert_allclose(gbt._loss.closs.delta, 0.0001655688041282133) - - pred_result = np.array( - [ - 1.48120765e-04, - 9.99949174e-01, - 2.00116957e00, - 2.99986716e00, - 4.00012064e00, - 5.00002462e00, - 5.99998898e00, - 6.99692549e00, - 8.00006356e00, - 8.99985099e00, - ] - ) - assert_allclose(gbt.predict(X), pred_result, rtol=1e-8) - - train_score = np.array( - [ - 2.59484709e-07, - 2.19165900e-07, - 1.89644782e-07, - 1.64556454e-07, - 1.38705110e-07, - 1.20373736e-07, - 1.04746082e-07, - 9.13835687e-08, - 8.20245756e-08, - 7.17122188e-08, - ] - ) - assert_allclose(gbt.train_score_[-10:], train_score, rtol=1e-8) + assert gbt._loss.closs.delta < 2e-4 + assert_allclose(gbt.predict(X), y, atol=0.01) + assert np.all(gbt.train_score_[-10:] < 3e-7) def test_binomial_error_exact_backward_compat(): @@ -1711,7 +1677,14 @@ def test_gb_denominator_zero(global_random_seed): } clf = GradientBoostingClassifier(**params) - # _safe_devide would raise a RuntimeWarning + # _safe_divide would raise a RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter("error") clf.fit(X, y) + + +@pytest.mark.parametrize("GradientBoosting", GRADIENT_BOOSTING_ESTIMATORS) +def test_criterion_param_deprecation(GradientBoosting): + with pytest.warns(FutureWarning, match="criterion"): + reg = GradientBoosting(criterion="friedman_mse") + reg.fit(X, y) diff --git a/sklearn/ensemble/tests/test_iforest.py b/sklearn/ensemble/tests/test_iforest.py index d495bef8fc6d7..8abb2d73afc55 100644 --- a/sklearn/ensemble/tests/test_iforest.py +++ b/sklearn/ensemble/tests/test_iforest.py @@ -389,7 +389,7 @@ def test_iforest_predict_parallel(global_random_seed, contamination, n_jobs): ) clf_parallel.fit(X) with parallel_backend("threading", n_jobs=n_jobs): - pred_paralell = clf_parallel.predict(X) + pred_parallel = clf_parallel.predict(X) # assert the same results as non-parallel - assert_array_equal(pred, pred_paralell) + assert_array_equal(pred, pred_parallel) diff --git a/sklearn/ensemble/tests/test_voting.py b/sklearn/ensemble/tests/test_voting.py index 7ea3627ac2eca..47523705ccbd2 100644 --- a/sklearn/ensemble/tests/test_voting.py +++ b/sklearn/ensemble/tests/test_voting.py @@ -11,6 +11,7 @@ from sklearn.datasets import make_multilabel_classification from sklearn.dummy import DummyRegressor from sklearn.ensemble import ( + GradientBoostingClassifier, RandomForestClassifier, RandomForestRegressor, VotingClassifier, @@ -325,13 +326,13 @@ def test_parallel_fit(global_random_seed): def test_sample_weight(global_random_seed): """Tests sample_weight parameter of VotingClassifier""" clf1 = LogisticRegression(random_state=global_random_seed) - clf2 = RandomForestClassifier(n_estimators=10, random_state=global_random_seed) + clf2 = GradientBoostingClassifier(n_estimators=10, random_state=global_random_seed) clf3 = CalibratedClassifierCV(SVC(random_state=global_random_seed), ensemble=False) eclf1 = VotingClassifier( - estimators=[("lr", clf1), ("rf", clf2), ("svc", clf3)], voting="soft" + estimators=[("lr", clf1), ("gbdt", clf2), ("svc", clf3)], voting="soft" ).fit(X_scaled, y, sample_weight=np.ones((len(y),))) eclf2 = VotingClassifier( - estimators=[("lr", clf1), ("rf", clf2), ("svc", clf3)], voting="soft" + estimators=[("lr", clf1), ("gbdt", clf2), ("svc", clf3)], voting="soft" ).fit(X_scaled, y) assert_array_equal(eclf1.predict(X_scaled), eclf2.predict(X_scaled)) assert_array_almost_equal( diff --git a/sklearn/ensemble/tests/test_weight_boosting.py b/sklearn/ensemble/tests/test_weight_boosting.py index 2a430cbf9aec9..0e1250fe19ec2 100644 --- a/sklearn/ensemble/tests/test_weight_boosting.py +++ b/sklearn/ensemble/tests/test_weight_boosting.py @@ -9,7 +9,7 @@ from sklearn.base import BaseEstimator, clone from sklearn.dummy import DummyClassifier, DummyRegressor from sklearn.ensemble import AdaBoostClassifier, AdaBoostRegressor -from sklearn.linear_model import LinearRegression +from sklearn.linear_model import LinearRegression, LogisticRegression from sklearn.model_selection import GridSearchCV, train_test_split from sklearn.svm import SVC, SVR from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor @@ -283,8 +283,8 @@ def test_sample_weights_infinite(): def test_sparse_classification(sparse_container, expected_internal_type): # Check classification with sparse input. - class CustomSVC(SVC): - """SVC variant that records the nature of the training set.""" + class CustomProbabilisticClassifier(LogisticRegression): + """LogisticRegression variant that records the nature of the training set.""" def fit(self, X, y, sample_weight=None): """Modification on fit caries data type for later verification.""" @@ -305,13 +305,13 @@ def fit(self, X, y, sample_weight=None): # Trained on sparse format sparse_classifier = AdaBoostClassifier( - estimator=CustomSVC(probability=True), + estimator=CustomProbabilisticClassifier(), random_state=1, ).fit(X_train_sparse, y_train) # Trained on dense format dense_classifier = AdaBoostClassifier( - estimator=CustomSVC(probability=True), + estimator=CustomProbabilisticClassifier(), random_state=1, ).fit(X_train, y_train) @@ -367,7 +367,7 @@ def fit(self, X, y, sample_weight=None): # Verify sparsity of data is maintained during training types = [i.data_type_ for i in sparse_classifier.estimators_] - assert all([t == expected_internal_type for t in types]) + assert all(issubclass(t, expected_internal_type) for t in types) @pytest.mark.parametrize( @@ -427,7 +427,7 @@ def fit(self, X, y, sample_weight=None): types = [i.data_type_ for i in sparse_regressor.estimators_] - assert all([t == expected_internal_type for t in types]) + assert all(issubclass(t, expected_internal_type) for t in types) def test_sample_weight_adaboost_regressor(): diff --git a/sklearn/externals/_scipy/sparse/csgraph/_laplacian.py b/sklearn/externals/_scipy/sparse/csgraph/_laplacian.py index 34c816628ee73..ade61f3f10d43 100644 --- a/sklearn/externals/_scipy/sparse/csgraph/_laplacian.py +++ b/sklearn/externals/_scipy/sparse/csgraph/_laplacian.py @@ -283,19 +283,19 @@ def laplacian( Our final example illustrates the latter for a noisy directed linear graph. - >>> from scipy.sparse import diags, random + >>> from scipy.sparse import diags_array, random_array >>> from scipy.sparse.linalg import lobpcg Create a directed linear graph with ``N=35`` vertices using a sparse adjacency matrix ``G``: >>> N = 35 - >>> G = diags(np.ones(N-1), 1, format="csr") + >>> G = diags_array((np.ones(N-1), 1), format="csr") Fix a random seed ``rng`` and add a random sparse noise to the graph ``G``: >>> rng = np.random.default_rng() - >>> G += 1e-2 * random(N, N, density=0.1, random_state=rng) + >>> G += 1e-2 * random_array((N, N), density=0.1, random_state=rng) Set initial approximations for eigenvectors: diff --git a/sklearn/externals/array_api_compat/__init__.py b/sklearn/externals/array_api_compat/__init__.py index 653cb40a37607..4abca400a24f7 100644 --- a/sklearn/externals/array_api_compat/__init__.py +++ b/sklearn/externals/array_api_compat/__init__.py @@ -17,6 +17,6 @@ this implementation for the default when working with NumPy arrays. """ -__version__ = '1.12.0' +__version__ = '1.13.0' from .common import * # noqa: F401, F403 diff --git a/sklearn/externals/array_api_compat/_internal.py b/sklearn/externals/array_api_compat/_internal.py index cd8d939f36de2..baa39ded8decf 100644 --- a/sklearn/externals/array_api_compat/_internal.py +++ b/sklearn/externals/array_api_compat/_internal.py @@ -2,6 +2,7 @@ Internal helpers """ +import importlib from collections.abc import Callable from functools import wraps from inspect import signature @@ -46,14 +47,31 @@ def wrapped_f(*args: object, **kwargs: object) -> object: specification for more details. """ - wrapped_f.__signature__ = new_sig # pyright: ignore[reportAttributeAccessIssue] - return wrapped_f # pyright: ignore[reportReturnType] + wrapped_f.__signature__ = new_sig # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue] + return wrapped_f # type: ignore[return-value] # pyright: ignore[reportReturnType] return inner -__all__ = ["get_xp"] +def clone_module(mod_name: str, globals_: dict[str, object]) -> list[str]: + """Import everything from module, updating globals(). + Returns __all__. + """ + mod = importlib.import_module(mod_name) + # Neither of these two methods is sufficient by itself, + # depending on various idiosyncrasies of the libraries we're wrapping. + objs = {} + exec(f"from {mod.__name__} import *", objs) + + for n in dir(mod): + if not n.startswith("_") and hasattr(mod, n): + objs[n] = getattr(mod, n) + + globals_.update(objs) + return list(objs) + +__all__ = ["get_xp", "clone_module"] def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/common/_aliases.py b/sklearn/externals/array_api_compat/common/_aliases.py index 8ea9162a9edc8..3587ef16fa18b 100644 --- a/sklearn/externals/array_api_compat/common/_aliases.py +++ b/sklearn/externals/array_api_compat/common/_aliases.py @@ -5,11 +5,12 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Sequence, cast +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, NamedTuple, cast from ._helpers import _check_device, array_namespace from ._helpers import device as _get_device -from ._helpers import is_cupy_namespace as _is_cupy_namespace +from ._helpers import is_cupy_namespace from ._typing import Array, Device, DType, Namespace if TYPE_CHECKING: @@ -381,8 +382,8 @@ def clip( # TODO: np.clip has other ufunc kwargs out: Array | None = None, ) -> Array: - def _isscalar(a: object) -> TypeIs[int | float | None]: - return isinstance(a, (int, float, type(None))) + def _isscalar(a: object) -> TypeIs[float | None]: + return isinstance(a, int | float) or a is None min_shape = () if _isscalar(min) else min.shape max_shape = () if _isscalar(max) else max.shape @@ -450,7 +451,7 @@ def reshape( shape: tuple[int, ...], xp: Namespace, *, - copy: Optional[bool] = None, + copy: bool | None = None, **kwargs: object, ) -> Array: if copy is True: @@ -524,27 +525,6 @@ def nonzero(x: Array, /, xp: Namespace, **kwargs: object) -> tuple[Array, ...]: return xp.nonzero(x, **kwargs) -# ceil, floor, and trunc return integers for integer inputs - - -def ceil(x: Array, /, xp: Namespace, **kwargs: object) -> Array: - if xp.issubdtype(x.dtype, xp.integer): - return x - return xp.ceil(x, **kwargs) - - -def floor(x: Array, /, xp: Namespace, **kwargs: object) -> Array: - if xp.issubdtype(x.dtype, xp.integer): - return x - return xp.floor(x, **kwargs) - - -def trunc(x: Array, /, xp: Namespace, **kwargs: object) -> Array: - if xp.issubdtype(x.dtype, xp.integer): - return x - return xp.trunc(x, **kwargs) - - # linear algebra functions @@ -657,7 +637,7 @@ def sign(x: Array, /, xp: Namespace, **kwargs: object) -> Array: out = xp.sign(x, **kwargs) # CuPy sign() does not propagate nans. See # https://github.com/data-apis/array-api-compat/issues/136 - if _is_cupy_namespace(xp) and isdtype(x.dtype, "real floating", xp=xp): + if is_cupy_namespace(xp) and isdtype(x.dtype, "real floating", xp=xp): out[xp.isnan(x)] = xp.nan return out[()] @@ -707,9 +687,6 @@ def iinfo(type_: DType | Array, /, xp: Namespace) -> Any: "argsort", "sort", "nonzero", - "ceil", - "floor", - "trunc", "matmul", "matrix_transpose", "tensordot", @@ -720,8 +697,6 @@ def iinfo(type_: DType | Array, /, xp: Namespace) -> Any: "finfo", "iinfo", ] -_all_ignore = ["inspect", "array_namespace", "NamedTuple"] - def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/common/_helpers.py b/sklearn/externals/array_api_compat/common/_helpers.py index 77175d0d1e974..8194a083db92f 100644 --- a/sklearn/externals/array_api_compat/common/_helpers.py +++ b/sklearn/externals/array_api_compat/common/_helpers.py @@ -8,6 +8,7 @@ from __future__ import annotations +import enum import inspect import math import sys @@ -22,7 +23,6 @@ SupportsIndex, TypeAlias, TypeGuard, - TypeVar, cast, overload, ) @@ -30,32 +30,29 @@ from ._typing import Array, Device, HasShape, Namespace, SupportsArrayNamespace if TYPE_CHECKING: - + import cupy as cp import dask.array as da import jax import ndonnx as ndx import numpy as np import numpy.typing as npt - import sparse # pyright: ignore[reportMissingTypeStubs] + import sparse import torch # TODO: import from typing (requires Python >=3.13) - from typing_extensions import TypeIs, TypeVar - - _SizeT = TypeVar("_SizeT", bound = int | None) + from typing_extensions import TypeIs _ZeroGradientArray: TypeAlias = npt.NDArray[np.void] - _CupyArray: TypeAlias = Any # cupy has no py.typed _ArrayApiObj: TypeAlias = ( npt.NDArray[Any] + | cp.ndarray | da.Array | jax.Array | ndx.Array | sparse.SparseArray | torch.Tensor | SupportsArrayNamespace[Any] - | _CupyArray ) _API_VERSIONS_OLD: Final = frozenset({"2021.12", "2022.12", "2023.12"}) @@ -95,7 +92,7 @@ def _is_jax_zero_gradient_array(x: object) -> TypeGuard[_ZeroGradientArray]: return dtype == jax.float0 -def is_numpy_array(x: object) -> TypeGuard[npt.NDArray[Any]]: +def is_numpy_array(x: object) -> TypeIs[npt.NDArray[Any]]: """ Return True if `x` is a NumPy array. @@ -238,7 +235,17 @@ def is_jax_array(x: object) -> TypeIs[jax.Array]: is_pydata_sparse_array """ cls = cast(Hashable, type(x)) - return _issubclass_fast(cls, "jax", "Array") or _is_jax_zero_gradient_array(x) + # We test for jax.core.Tracer here to identify jax arrays during jit tracing. From jax 0.8.2 on, + # tracers are not a subclass of jax.Array anymore. Note that tracers can also represent + # non-array values and a fully correct implementation would need to use isinstance checks. Since + # we use hash-based caching with type names as keys, we cannot use instance checks without + # losing performance here. For more information, see + # https://github.com/data-apis/array-api-compat/pull/369 and the corresponding issue. + return ( + _issubclass_fast(cls, "jax", "Array") + or _issubclass_fast(cls, "jax.core", "Tracer") + or _is_jax_zero_gradient_array(x) + ) def is_pydata_sparse_array(x: object) -> TypeIs[sparse.SparseArray]: @@ -266,7 +273,7 @@ def is_pydata_sparse_array(x: object) -> TypeIs[sparse.SparseArray]: return _issubclass_fast(cls, "sparse", "SparseArray") -def is_array_api_obj(x: object) -> TypeIs[_ArrayApiObj]: # pyright: ignore[reportUnknownParameterType] +def is_array_api_obj(x: object) -> TypeGuard[_ArrayApiObj]: """ Return True if `x` is an array API compatible array object. @@ -299,6 +306,7 @@ def _is_array_api_cls(cls: type) -> bool: or _issubclass_fast(cls, "sparse", "SparseArray") # TODO: drop support for jax<0.4.32 which didn't have __array_namespace__ or _issubclass_fast(cls, "jax", "Array") + or _issubclass_fast(cls, "jax.core", "Tracer") # see is_jax_array for limitations ) @@ -485,6 +493,86 @@ def _check_api_version(api_version: str | None) -> None: ) +class _ClsToXPInfo(enum.Enum): + SCALAR = 0 + MAYBE_JAX_ZERO_GRADIENT = 1 + + +@lru_cache(100) +def _cls_to_namespace( + cls: type, + api_version: str | None, + use_compat: bool | None, +) -> tuple[Namespace | None, _ClsToXPInfo | None]: + if use_compat not in (None, True, False): + raise ValueError("use_compat must be None, True, or False") + _use_compat = use_compat in (None, True) + cls_ = cast(Hashable, cls) # Make mypy happy + + if ( + _issubclass_fast(cls_, "numpy", "ndarray") + or _issubclass_fast(cls_, "numpy", "generic") + ): + if use_compat is True: + _check_api_version(api_version) + from .. import numpy as xp + elif use_compat is False: + import numpy as xp # type: ignore[no-redef] + else: + # NumPy 2.0+ have __array_namespace__; however they are not + # yet fully array API compatible. + from .. import numpy as xp # type: ignore[no-redef] + return xp, _ClsToXPInfo.MAYBE_JAX_ZERO_GRADIENT + + # Note: this must happen _after_ the test for np.generic, + # because np.float64 and np.complex128 are subclasses of float and complex. + if issubclass(cls, int | float | complex | type(None)): + return None, _ClsToXPInfo.SCALAR + + if _issubclass_fast(cls_, "cupy", "ndarray"): + if _use_compat: + _check_api_version(api_version) + from .. import cupy as xp # type: ignore[no-redef] + else: + import cupy as xp # type: ignore[no-redef] + return xp, None + + if _issubclass_fast(cls_, "torch", "Tensor"): + if _use_compat: + _check_api_version(api_version) + from .. import torch as xp # type: ignore[no-redef] + else: + import torch as xp # type: ignore[no-redef] + return xp, None + + if _issubclass_fast(cls_, "dask.array", "Array"): + if _use_compat: + _check_api_version(api_version) + from ..dask import array as xp # type: ignore[no-redef] + else: + import dask.array as xp # type: ignore[no-redef] + return xp, None + + # Backwards compatibility for jax<0.4.32 + if _issubclass_fast(cls_, "jax", "Array"): + return _jax_namespace(api_version, use_compat), None + + return None, None + + +def _jax_namespace(api_version: str | None, use_compat: bool | None) -> Namespace: + if use_compat: + raise ValueError("JAX does not have an array-api-compat wrapper") + import jax.numpy as jnp + if not hasattr(jnp, "__array_namespace_info__"): + # JAX v0.4.32 and newer implements the array API directly in jax.numpy. + # For older JAX versions, it is available via jax.experimental.array_api. + # jnp.Array objects gain the __array_namespace__ method. + import jax.experimental.array_api # noqa: F401 + # Test api_version + return jnp.empty(0).__array_namespace__(api_version=api_version) + + def array_namespace( *xs: Array | complex | None, api_version: str | None = None, @@ -553,105 +641,40 @@ def your_function(x, y): is_pydata_sparse_array """ - if use_compat not in [None, True, False]: - raise ValueError("use_compat must be None, True, or False") - - _use_compat = use_compat in [None, True] - namespaces: set[Namespace] = set() for x in xs: - if is_numpy_array(x): - import numpy as np - - from .. import numpy as numpy_namespace - - if use_compat is True: - _check_api_version(api_version) - namespaces.add(numpy_namespace) - elif use_compat is False: - namespaces.add(np) - else: - # numpy 2.0+ have __array_namespace__, however, they are not yet fully array API - # compatible. - namespaces.add(numpy_namespace) - elif is_cupy_array(x): - if _use_compat: - _check_api_version(api_version) - from .. import cupy as cupy_namespace - - namespaces.add(cupy_namespace) - else: - import cupy as cp # pyright: ignore[reportMissingTypeStubs] - - namespaces.add(cp) - elif is_torch_array(x): - if _use_compat: - _check_api_version(api_version) - from .. import torch as torch_namespace - - namespaces.add(torch_namespace) - else: - import torch - - namespaces.add(torch) - elif is_dask_array(x): - if _use_compat: - _check_api_version(api_version) - from ..dask import array as dask_namespace - - namespaces.add(dask_namespace) - else: - import dask.array as da - - namespaces.add(da) - elif is_jax_array(x): - if use_compat is True: - _check_api_version(api_version) - raise ValueError("JAX does not have an array-api-compat wrapper") - elif use_compat is False: - import jax.numpy as jnp - else: - # JAX v0.4.32 and newer implements the array API directly in jax.numpy. - # For older JAX versions, it is available via jax.experimental.array_api. - import jax.numpy - - if hasattr(jax.numpy, "__array_api_version__"): - jnp = jax.numpy - else: - import jax.experimental.array_api as jnp # pyright: ignore[reportMissingImports] - namespaces.add(jnp) - elif is_pydata_sparse_array(x): - if use_compat is True: - _check_api_version(api_version) - raise ValueError("`sparse` does not have an array-api-compat wrapper") - else: - import sparse # pyright: ignore[reportMissingTypeStubs] - # `sparse` is already an array namespace. We do not have a wrapper - # submodule for it. - namespaces.add(sparse) - elif hasattr(x, "__array_namespace__"): - if use_compat is True: + xp, info = _cls_to_namespace(cast(Hashable, type(x)), api_version, use_compat) + if info is _ClsToXPInfo.SCALAR: + continue + + if ( + info is _ClsToXPInfo.MAYBE_JAX_ZERO_GRADIENT + and _is_jax_zero_gradient_array(x) + ): + xp = _jax_namespace(api_version, use_compat) + + if xp is None: + get_ns = getattr(x, "__array_namespace__", None) + if get_ns is None: + raise TypeError(f"{type(x).__name__} is not a supported array type") + if use_compat: raise ValueError( "The given array does not have an array-api-compat wrapper" ) - x = cast("SupportsArrayNamespace[Any]", x) - namespaces.add(x.__array_namespace__(api_version=api_version)) - elif isinstance(x, (bool, int, float, complex, type(None))): - continue - else: - # TODO: Support Python scalars? - raise TypeError(f"{type(x).__name__} is not a supported array type") + xp = get_ns(api_version=api_version) - if not namespaces: - raise TypeError("Unrecognized array input") + namespaces.add(xp) - if len(namespaces) != 1: + try: + (xp,) = namespaces + return xp + except ValueError: + if not namespaces: + raise TypeError( + "array_namespace requires at least one non-scalar array input" + ) raise TypeError(f"Multiple namespaces for array inputs: {namespaces}") - (xp,) = namespaces - - return xp - # backwards compatibility alias get_namespace = array_namespace @@ -732,7 +755,7 @@ def device(x: _ArrayApiObj, /) -> Device: return "cpu" elif is_dask_array(x): # Peek at the metadata of the Dask array to determine type - if is_numpy_array(x._meta): # pyright: ignore + if is_numpy_array(x._meta): # Must be on CPU since backed by numpy return "cpu" return _DASK_DEVICE @@ -761,7 +784,7 @@ def device(x: _ArrayApiObj, /) -> Device: return "cpu" # Return the device of the constituent array return device(inner) # pyright: ignore - return x.device # pyright: ignore + return x.device # type: ignore # pyright: ignore # Prevent shadowing, used below @@ -770,11 +793,11 @@ def device(x: _ArrayApiObj, /) -> Device: # Based on cupy.array_api.Array.to_device def _cupy_to_device( - x: _CupyArray, + x: cp.ndarray, device: Device, /, stream: int | Any | None = None, -) -> _CupyArray: +) -> cp.ndarray: import cupy as cp if device == "cpu": @@ -803,7 +826,7 @@ def _torch_to_device( x: torch.Tensor, device: torch.device | str | int, /, - stream: None = None, + stream: int | Any | None = None, ) -> torch.Tensor: if stream is not None: raise NotImplementedError @@ -869,7 +892,7 @@ def to_device(x: Array, device: Device, /, *, stream: int | Any | None = None) - # cupy does not yet have to_device return _cupy_to_device(x, device, stream=stream) elif is_torch_array(x): - return _torch_to_device(x, device, stream=stream) # pyright: ignore[reportArgumentType] + return _torch_to_device(x, device, stream=stream) elif is_dask_array(x): if stream is not None: raise ValueError("The stream argument to to_device() is not supported") @@ -896,8 +919,6 @@ def to_device(x: Array, device: Device, /, *, stream: int | Any | None = None) - @overload def size(x: HasShape[Collection[SupportsIndex]]) -> int: ... @overload -def size(x: HasShape[Collection[None]]) -> None: ... -@overload def size(x: HasShape[Collection[SupportsIndex | None]]) -> int | None: ... def size(x: HasShape[Collection[SupportsIndex | None]]) -> int | None: """ @@ -924,6 +945,7 @@ def _is_writeable_cls(cls: type) -> bool | None: if ( _issubclass_fast(cls, "numpy", "generic") or _issubclass_fast(cls, "jax", "Array") + or _issubclass_fast(cls, "jax.core", "Tracer") # see is_jax_array for limitations or _issubclass_fast(cls, "sparse", "SparseArray") ): return False @@ -932,7 +954,7 @@ def _is_writeable_cls(cls: type) -> bool | None: return None -def is_writeable_array(x: object) -> bool: +def is_writeable_array(x: object) -> TypeGuard[_ArrayApiObj]: """ Return False if ``x.__setitem__`` is expected to raise; True otherwise. Return False if `x` is not an array API compatible object. @@ -963,6 +985,7 @@ def _is_lazy_cls(cls: type) -> bool | None: return False if ( _issubclass_fast(cls, "jax", "Array") + or _issubclass_fast(cls, "jax.core", "Tracer") # see is_jax_array for limitations or _issubclass_fast(cls, "dask.array", "Array") or _issubclass_fast(cls, "ndonnx", "Array") ): @@ -970,7 +993,7 @@ def _is_lazy_cls(cls: type) -> bool | None: return None -def is_lazy_array(x: object) -> bool: +def is_lazy_array(x: object) -> TypeGuard[_ArrayApiObj]: """Return True if x is potentially a future or it may be otherwise impossible or expensive to eagerly read its contents, regardless of their size, e.g. by calling ``bool(x)`` or ``float(x)``. @@ -1052,7 +1075,5 @@ def is_lazy_array(x: object) -> bool: "to_device", ] -_all_ignore = ['lru_cache', 'sys', 'math', 'inspect', 'warnings'] - def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/common/_linalg.py b/sklearn/externals/array_api_compat/common/_linalg.py index 7ad87a1be9105..69672af768d06 100644 --- a/sklearn/externals/array_api_compat/common/_linalg.py +++ b/sklearn/externals/array_api_compat/common/_linalg.py @@ -8,7 +8,7 @@ if np.__version__[0] == "2": from numpy.lib.array_utils import normalize_axis_tuple else: - from numpy.core.numeric import normalize_axis_tuple + from numpy.core.numeric import normalize_axis_tuple # type: ignore[no-redef] from .._internal import get_xp from ._aliases import isdtype, matmul, matrix_transpose, tensordot, vecdot @@ -187,14 +187,14 @@ def vector_norm( # We can't reuse xp.linalg.norm(keepdims) because of the reshape hacks # above to avoid matrix norm logic. shape = list(x.shape) - _axis = cast( + axes = cast( "tuple[int, ...]", normalize_axis_tuple( # pyright: ignore[reportCallIssue] range(x.ndim) if axis is None else axis, x.ndim, ), ) - for i in _axis: + for i in axes: shape[i] = 1 res = xp.reshape(res, tuple(shape)) @@ -225,8 +225,6 @@ def trace( 'matrix_transpose', 'svdvals', 'vecdot', 'vector_norm', 'diagonal', 'trace'] -_all_ignore = ['math', 'normalize_axis_tuple', 'get_xp', 'np', 'isdtype'] - def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/common/_typing.py b/sklearn/externals/array_api_compat/common/_typing.py index cd26feeba4dff..11b00bd10395f 100644 --- a/sklearn/externals/array_api_compat/common/_typing.py +++ b/sklearn/externals/array_api_compat/common/_typing.py @@ -34,32 +34,29 @@ # - docs: https://github.com/jorenham/optype/blob/master/README.md#just # - code: https://github.com/jorenham/optype/blob/master/optype/_core/_just.py @final -class JustInt(Protocol): - @property +class JustInt(Protocol): # type: ignore[misc] + @property # type: ignore[override] def __class__(self, /) -> type[int]: ... @__class__.setter def __class__(self, value: type[int], /) -> None: ... # pyright: ignore[reportIncompatibleMethodOverride] @final -class JustFloat(Protocol): - @property +class JustFloat(Protocol): # type: ignore[misc] + @property # type: ignore[override] def __class__(self, /) -> type[float]: ... @__class__.setter def __class__(self, value: type[float], /) -> None: ... # pyright: ignore[reportIncompatibleMethodOverride] @final -class JustComplex(Protocol): - @property +class JustComplex(Protocol): # type: ignore[misc] + @property # type: ignore[override] def __class__(self, /) -> type[complex]: ... @__class__.setter def __class__(self, value: type[complex], /) -> None: ... # pyright: ignore[reportIncompatibleMethodOverride] -# - - class NestedSequence(Protocol[_T_co]): def __getitem__(self, key: int, /) -> _T_co | NestedSequence[_T_co]: ... def __len__(self, /) -> int: ... diff --git a/sklearn/externals/array_api_compat/cupy/__init__.py b/sklearn/externals/array_api_compat/cupy/__init__.py index 9a30f95ddf12c..af003c5adaa52 100644 --- a/sklearn/externals/array_api_compat/cupy/__init__.py +++ b/sklearn/externals/array_api_compat/cupy/__init__.py @@ -1,3 +1,4 @@ +from typing import Final from cupy import * # noqa: F403 # from cupy import * doesn't overwrite these builtin names @@ -5,9 +6,19 @@ # These imports may overwrite names from the import * above. from ._aliases import * # noqa: F403 +from ._info import __array_namespace_info__ # noqa: F401 # See the comment in the numpy __init__.py __import__(__package__ + '.linalg') __import__(__package__ + '.fft') -__array_api_version__ = '2024.12' +__array_api_version__: Final = '2024.12' + +__all__ = sorted( + {name for name in globals() if not name.startswith("__")} + - {"Final", "_aliases", "_info", "_typing"} + | {"__array_api_version__", "__array_namespace_info__", "linalg", "fft"} +) + +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/cupy/_aliases.py b/sklearn/externals/array_api_compat/cupy/_aliases.py index 90b48f059bafa..2e512fc896399 100644 --- a/sklearn/externals/array_api_compat/cupy/_aliases.py +++ b/sklearn/externals/array_api_compat/cupy/_aliases.py @@ -1,13 +1,12 @@ from __future__ import annotations -from typing import Optional +from builtins import bool as py_bool import cupy as cp from ..common import _aliases, _helpers from ..common._typing import NestedSequence, SupportsBufferProtocol from .._internal import get_xp -from ._info import __array_namespace_info__ from ._typing import Array, Device, DType bool = cp.bool_ @@ -54,9 +53,6 @@ argsort = get_xp(cp)(_aliases.argsort) sort = get_xp(cp)(_aliases.sort) nonzero = get_xp(cp)(_aliases.nonzero) -ceil = get_xp(cp)(_aliases.ceil) -floor = get_xp(cp)(_aliases.floor) -trunc = get_xp(cp)(_aliases.trunc) matmul = get_xp(cp)(_aliases.matmul) matrix_transpose = get_xp(cp)(_aliases.matrix_transpose) tensordot = get_xp(cp)(_aliases.tensordot) @@ -67,18 +63,13 @@ # asarray also adds the copy keyword, which is not present in numpy 1.0. def asarray( - obj: ( - Array - | bool | int | float | complex - | NestedSequence[bool | int | float | complex] - | SupportsBufferProtocol - ), + obj: Array | complex | NestedSequence[complex] | SupportsBufferProtocol, /, *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - copy: Optional[bool] = None, - **kwargs, + dtype: DType | None = None, + device: Device | None = None, + copy: py_bool | None = None, + **kwargs: object, ) -> Array: """ Array API compatibility wrapper for asarray(). @@ -101,8 +92,8 @@ def astype( dtype: DType, /, *, - copy: bool = True, - device: Optional[Device] = None, + copy: py_bool = True, + device: Device | None = None, ) -> Array: if device is None: return x.astype(dtype=dtype, copy=copy) @@ -113,8 +104,8 @@ def astype( # cupy.count_nonzero does not have keepdims def count_nonzero( x: Array, - axis=None, - keepdims=False + axis: int | tuple[int, ...] | None = None, + keepdims: py_bool = False, ) -> Array: result = cp.count_nonzero(x, axis) if keepdims: @@ -123,9 +114,28 @@ def count_nonzero( return cp.expand_dims(result, axis) return result +# ceil, floor, and trunc return integers for integer inputs + +def ceil(x: Array, /) -> Array: + if cp.issubdtype(x.dtype, cp.integer): + return x.copy() + return cp.ceil(x) + + +def floor(x: Array, /) -> Array: + if cp.issubdtype(x.dtype, cp.integer): + return x.copy() + return cp.floor(x) + + +def trunc(x: Array, /) -> Array: + if cp.issubdtype(x.dtype, cp.integer): + return x.copy() + return cp.trunc(x) + # take_along_axis: axis defaults to -1 but in cupy (and numpy) axis is a required arg -def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): +def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: return cp.take_along_axis(x, indices, axis=axis) @@ -146,11 +156,13 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): else: unstack = get_xp(cp)(_aliases.unstack) -__all__ = _aliases.__all__ + ['__array_namespace_info__', 'asarray', 'astype', +__all__ = _aliases.__all__ + ['asarray', 'astype', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'bitwise_left_shift', 'bitwise_invert', 'bitwise_right_shift', 'bool', 'concat', 'count_nonzero', 'pow', 'sign', - 'take_along_axis'] + 'ceil', 'floor', 'trunc', 'take_along_axis'] + -_all_ignore = ['cp', 'get_xp'] +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/cupy/_typing.py b/sklearn/externals/array_api_compat/cupy/_typing.py index d8e49ca773dc5..e5c202dc53e09 100644 --- a/sklearn/externals/array_api_compat/cupy/_typing.py +++ b/sklearn/externals/array_api_compat/cupy/_typing.py @@ -1,7 +1,6 @@ from __future__ import annotations __all__ = ["Array", "DType", "Device"] -_all_ignore = ["cp"] from typing import TYPE_CHECKING diff --git a/sklearn/externals/array_api_compat/cupy/fft.py b/sklearn/externals/array_api_compat/cupy/fft.py index 307e0f7277710..53a9a45438651 100644 --- a/sklearn/externals/array_api_compat/cupy/fft.py +++ b/sklearn/externals/array_api_compat/cupy/fft.py @@ -1,10 +1,11 @@ -from cupy.fft import * # noqa: F403 +from cupy.fft import * # noqa: F403 + # cupy.fft doesn't have __all__. If it is added, replace this with # # from cupy.fft import __all__ as linalg_all -_n = {} -exec('from cupy.fft import *', _n) -del _n['__builtins__'] +_n: dict[str, object] = {} +exec("from cupy.fft import *", _n) +del _n["__builtins__"] fft_all = list(_n) del _n @@ -30,7 +31,6 @@ __all__ = fft_all + _fft.__all__ -del get_xp -del cp -del fft_all -del _fft +def __dir__() -> list[str]: + return __all__ + diff --git a/sklearn/externals/array_api_compat/cupy/linalg.py b/sklearn/externals/array_api_compat/cupy/linalg.py index 7fcdd498e0073..da301574728a7 100644 --- a/sklearn/externals/array_api_compat/cupy/linalg.py +++ b/sklearn/externals/array_api_compat/cupy/linalg.py @@ -2,7 +2,7 @@ # cupy.linalg doesn't have __all__. If it is added, replace this with # # from cupy.linalg import __all__ as linalg_all -_n = {} +_n: dict[str, object] = {} exec('from cupy.linalg import *', _n) del _n['__builtins__'] linalg_all = list(_n) @@ -43,7 +43,5 @@ __all__ = linalg_all + _linalg.__all__ -del get_xp -del cp -del linalg_all -del _linalg +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/dask/array/__init__.py b/sklearn/externals/array_api_compat/dask/array/__init__.py index 1e47b9606b774..f78aa8b378444 100644 --- a/sklearn/externals/array_api_compat/dask/array/__init__.py +++ b/sklearn/externals/array_api_compat/dask/array/__init__.py @@ -1,12 +1,26 @@ from typing import Final -from dask.array import * # noqa: F403 +from ..._internal import clone_module + +__all__ = clone_module("dask.array", globals()) # These imports may overwrite names from the import * above. -from ._aliases import * # noqa: F403 +from . import _aliases +from ._aliases import * # type: ignore[assignment] # noqa: F403 +from ._info import __array_namespace_info__ # noqa: F401 __array_api_version__: Final = "2024.12" +del Final # See the comment in the numpy __init__.py __import__(__package__ + '.linalg') __import__(__package__ + '.fft') + +__all__ = sorted( + set(__all__) + | set(_aliases.__all__) + | {"__array_api_version__", "__array_namespace_info__", "linalg", "fft"} +) + +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/dask/array/_aliases.py b/sklearn/externals/array_api_compat/dask/array/_aliases.py index d43881ab18f1c..54d323b2a5b6f 100644 --- a/sklearn/externals/array_api_compat/dask/array/_aliases.py +++ b/sklearn/externals/array_api_compat/dask/array/_aliases.py @@ -41,7 +41,6 @@ NestedSequence, SupportsBufferProtocol, ) -from ._info import __array_namespace_info__ isdtype = get_xp(np)(_aliases.isdtype) unstack = get_xp(da)(_aliases.unstack) @@ -134,9 +133,6 @@ def arange( matrix_transpose = get_xp(da)(_aliases.matrix_transpose) vecdot = get_xp(da)(_aliases.vecdot) nonzero = get_xp(da)(_aliases.nonzero) -ceil = get_xp(np)(_aliases.ceil) -floor = get_xp(np)(_aliases.floor) -trunc = get_xp(np)(_aliases.trunc) matmul = get_xp(np)(_aliases.matmul) tensordot = get_xp(np)(_aliases.tensordot) sign = get_xp(np)(_aliases.sign) @@ -146,7 +142,7 @@ def arange( # asarray also adds the copy keyword, which is not present in numpy 1.0. def asarray( - obj: complex | NestedSequence[complex] | Array | SupportsBufferProtocol, + obj: Array | complex | NestedSequence[complex] | SupportsBufferProtocol, /, *, dtype: DType | None = None, @@ -355,7 +351,6 @@ def count_nonzero( __all__ = [ - "__array_namespace_info__", "count_nonzero", "bool", "int8", "int16", "int32", "int64", @@ -369,8 +364,6 @@ def count_nonzero( "bitwise_left_shift", "bitwise_right_shift", "bitwise_invert", ] # fmt: skip __all__ += _aliases.__all__ -_all_ignore = ["array_namespace", "get_xp", "da", "np"] - def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/dask/array/_info.py b/sklearn/externals/array_api_compat/dask/array/_info.py index 9e4d736f99657..2f39fc4b17ef7 100644 --- a/sklearn/externals/array_api_compat/dask/array/_info.py +++ b/sklearn/externals/array_api_compat/dask/array/_info.py @@ -12,9 +12,9 @@ from __future__ import annotations -from typing import Literal as L -from typing import TypeAlias, overload +from typing import Literal, TypeAlias, overload +import dask.array as da from numpy import bool_ as bool from numpy import ( complex64, @@ -33,7 +33,7 @@ uint64, ) -from ...common._helpers import _DASK_DEVICE, _dask_device +from ...common._helpers import _DASK_DEVICE, _check_device, _dask_device from ...common._typing import ( Capabilities, DefaultDTypes, @@ -49,8 +49,7 @@ DTypesSigned, DTypesUnsigned, ) - -_Device: TypeAlias = L["cpu"] | _dask_device +Device: TypeAlias = Literal["cpu"] | _dask_device class __array_namespace_info__: @@ -142,7 +141,7 @@ def capabilities(self) -> Capabilities: "max dimensions": 64, } - def default_device(self) -> L["cpu"]: + def default_device(self) -> Device: """ The default device used for new Dask arrays. @@ -169,7 +168,7 @@ def default_device(self) -> L["cpu"]: """ return "cpu" - def default_dtypes(self, /, *, device: _Device | None = None) -> DefaultDTypes: + def default_dtypes(self, /, *, device: Device | None = None) -> DefaultDTypes: """ The default data types used for new Dask arrays. @@ -208,11 +207,7 @@ def default_dtypes(self, /, *, device: _Device | None = None) -> DefaultDTypes: 'indexing': dask.int64} """ - if device not in ["cpu", _DASK_DEVICE, None]: - raise ValueError( - f'Device not understood. Only "cpu" or _DASK_DEVICE is allowed, ' - f"but received: {device!r}" - ) + _check_device(da, device) return { "real floating": dtype(float64), "complex floating": dtype(complex128), @@ -222,38 +217,38 @@ def default_dtypes(self, /, *, device: _Device | None = None) -> DefaultDTypes: @overload def dtypes( - self, /, *, device: _Device | None = None, kind: None = None + self, /, *, device: Device | None = None, kind: None = None ) -> DTypesAll: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["bool"] + self, /, *, device: Device | None = None, kind: Literal["bool"] ) -> DTypesBool: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["signed integer"] + self, /, *, device: Device | None = None, kind: Literal["signed integer"] ) -> DTypesSigned: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["unsigned integer"] + self, /, *, device: Device | None = None, kind: Literal["unsigned integer"] ) -> DTypesUnsigned: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["integral"] + self, /, *, device: Device | None = None, kind: Literal["integral"] ) -> DTypesIntegral: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["real floating"] + self, /, *, device: Device | None = None, kind: Literal["real floating"] ) -> DTypesReal: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["complex floating"] + self, /, *, device: Device | None = None, kind: Literal["complex floating"] ) -> DTypesComplex: ... @overload def dtypes( - self, /, *, device: _Device | None = None, kind: L["numeric"] + self, /, *, device: Device | None = None, kind: Literal["numeric"] ) -> DTypesNumeric: ... def dtypes( - self, /, *, device: _Device | None = None, kind: DTypeKind | None = None + self, /, *, device: Device | None = None, kind: DTypeKind | None = None ) -> DTypesAny: """ The array API data types supported by Dask. @@ -308,11 +303,7 @@ def dtypes( 'int64': dask.int64} """ - if device not in ["cpu", _DASK_DEVICE, None]: - raise ValueError( - 'Device not understood. Only "cpu" or _DASK_DEVICE is allowed, but received:' - f" {device}" - ) + _check_device(da, device) if kind is None: return { "bool": dtype(bool), @@ -381,14 +372,14 @@ def dtypes( "complex64": dtype(complex64), "complex128": dtype(complex128), } - if isinstance(kind, tuple): # type: ignore[reportUnnecessaryIsinstanceCall] + if isinstance(kind, tuple): res: dict[str, DType] = {} for k in kind: res.update(self.dtypes(kind=k)) return res raise ValueError(f"unsupported kind: {kind!r}") - def devices(self) -> list[_Device]: + def devices(self) -> list[Device]: """ The devices supported by Dask. diff --git a/sklearn/externals/array_api_compat/dask/array/fft.py b/sklearn/externals/array_api_compat/dask/array/fft.py index 3f40dffe7abd5..44b68e733984f 100644 --- a/sklearn/externals/array_api_compat/dask/array/fft.py +++ b/sklearn/externals/array_api_compat/dask/array/fft.py @@ -1,13 +1,6 @@ -from dask.array.fft import * # noqa: F403 -# dask.array.fft doesn't have __all__. If it is added, replace this with -# -# from dask.array.fft import __all__ as linalg_all -_n = {} -exec('from dask.array.fft import *', _n) -for k in ("__builtins__", "Sequence", "annotations", "warnings"): - _n.pop(k, None) -fft_all = list(_n) -del _n, k +from ..._internal import clone_module + +__all__ = clone_module("dask.array.fft", globals()) from ...common import _fft from ..._internal import get_xp @@ -17,5 +10,7 @@ fftfreq = get_xp(da)(_fft.fftfreq) rfftfreq = get_xp(da)(_fft.rfftfreq) -__all__ = fft_all + ["fftfreq", "rfftfreq"] -_all_ignore = ["da", "fft_all", "get_xp", "warnings"] +__all__ += ["fftfreq", "rfftfreq"] + +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/dask/array/linalg.py b/sklearn/externals/array_api_compat/dask/array/linalg.py index 0825386ed5dc3..6b3c10117b10b 100644 --- a/sklearn/externals/array_api_compat/dask/array/linalg.py +++ b/sklearn/externals/array_api_compat/dask/array/linalg.py @@ -8,22 +8,13 @@ from dask.array import matmul, outer, tensordot # Exports -from dask.array.linalg import * # noqa: F403 - -from ..._internal import get_xp +from ..._internal import clone_module, get_xp from ...common import _linalg -from ...common._typing import Array as _Array -from ._aliases import matrix_transpose, vecdot +from ...common._typing import Array -# dask.array.linalg doesn't have __all__. If it is added, replace this with -# -# from dask.array.linalg import __all__ as linalg_all -_n = {} -exec('from dask.array.linalg import *', _n) -for k in ('__builtins__', 'annotations', 'operator', 'warnings', 'Array'): - _n.pop(k, None) -linalg_all = list(_n) -del _n, k +__all__ = clone_module("dask.array.linalg", globals()) + +from ._aliases import matrix_transpose, vecdot EighResult = _linalg.EighResult QRResult = _linalg.QRResult @@ -33,8 +24,8 @@ # supports the mode keyword on QR # https://github.com/dask/dask/issues/10388 #qr = get_xp(da)(_linalg.qr) -def qr( - x: _Array, +def qr( # type: ignore[no-redef] + x: Array, mode: Literal["reduced", "complete"] = "reduced", **kwargs: object, ) -> QRResult: @@ -50,12 +41,12 @@ def qr( # Wrap the svd functions to not pass full_matrices to dask # when full_matrices=False (as that is the default behavior for dask), # and dask doesn't have the full_matrices keyword -def svd(x: _Array, full_matrices: bool = True, **kwargs) -> SVDResult: +def svd(x: Array, full_matrices: bool = True, **kwargs: object) -> SVDResult: # type: ignore[no-redef] if full_matrices: raise ValueError("full_matrics=True is not supported by dask.") return da.linalg.svd(x, coerce_signs=False, **kwargs) -def svdvals(x: _Array) -> _Array: +def svdvals(x: Array) -> Array: # TODO: can't avoid computing U or V for dask _, s, _ = svd(x) return s @@ -63,10 +54,11 @@ def svdvals(x: _Array) -> _Array: vector_norm = get_xp(da)(_linalg.vector_norm) diagonal = get_xp(da)(_linalg.diagonal) -__all__ = linalg_all + ["trace", "outer", "matmul", "tensordot", - "matrix_transpose", "vecdot", "EighResult", - "QRResult", "SlogdetResult", "SVDResult", "qr", - "cholesky", "matrix_rank", "matrix_norm", "svdvals", - "vector_norm", "diagonal"] +__all__ += ["trace", "outer", "matmul", "tensordot", + "matrix_transpose", "vecdot", "EighResult", + "QRResult", "SlogdetResult", "SVDResult", "qr", + "cholesky", "matrix_rank", "matrix_norm", "svdvals", + "vector_norm", "diagonal"] -_all_ignore = ['get_xp', 'da', 'linalg_all', 'warnings'] +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/numpy/__init__.py b/sklearn/externals/array_api_compat/numpy/__init__.py index 3e138f53db006..23379e44db6e7 100644 --- a/sklearn/externals/array_api_compat/numpy/__init__.py +++ b/sklearn/externals/array_api_compat/numpy/__init__.py @@ -1,16 +1,17 @@ # ruff: noqa: PLC0414 from typing import Final -from numpy import * # noqa: F403 # pyright: ignore[reportWildcardImportFromLibrary] +from .._internal import clone_module -# from numpy import * doesn't overwrite these builtin names -from numpy import abs as abs -from numpy import max as max -from numpy import min as min -from numpy import round as round +# This needs to be loaded explicitly before cloning +import numpy.typing # noqa: F401 + +__all__ = clone_module("numpy", globals()) # These imports may overwrite names from the import * above. -from ._aliases import * # noqa: F403 +from . import _aliases +from ._aliases import * # type: ignore[assignment,no-redef] # noqa: F403 +from ._info import __array_namespace_info__ # noqa: F401 # Don't know why, but we have to do an absolute import to import linalg. If we # instead do @@ -26,3 +27,12 @@ from .linalg import matrix_transpose, vecdot # type: ignore[no-redef] # noqa: F401 __array_api_version__: Final = "2024.12" + +__all__ = sorted( + set(__all__) + | set(_aliases.__all__) + | {"__array_api_version__", "__array_namespace_info__", "linalg", "fft"} +) + +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/numpy/_aliases.py b/sklearn/externals/array_api_compat/numpy/_aliases.py index a1aee5c0df796..87b3c2f398af0 100644 --- a/sklearn/externals/array_api_compat/numpy/_aliases.py +++ b/sklearn/externals/array_api_compat/numpy/_aliases.py @@ -2,23 +2,15 @@ from __future__ import annotations from builtins import bool as py_bool -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast +from typing import Any, cast import numpy as np from .._internal import get_xp from ..common import _aliases, _helpers from ..common._typing import NestedSequence, SupportsBufferProtocol -from ._info import __array_namespace_info__ from ._typing import Array, Device, DType -if TYPE_CHECKING: - from typing_extensions import Buffer, TypeIs - -# The values of the `_CopyMode` enum can be either `False`, `True`, or `2`: -# https://github.com/numpy/numpy/blob/5a8a6a79d9c2fff8f07dcab5d41e14f8508d673f/numpy/_globals.pyi#L7-L10 -_Copy: TypeAlias = py_bool | Literal[2] | np._CopyMode - bool = np.bool_ # Basic renames @@ -63,9 +55,6 @@ argsort = get_xp(np)(_aliases.argsort) sort = get_xp(np)(_aliases.sort) nonzero = get_xp(np)(_aliases.nonzero) -ceil = get_xp(np)(_aliases.ceil) -floor = get_xp(np)(_aliases.floor) -trunc = get_xp(np)(_aliases.trunc) matmul = get_xp(np)(_aliases.matmul) matrix_transpose = get_xp(np)(_aliases.matrix_transpose) tensordot = get_xp(np)(_aliases.tensordot) @@ -74,14 +63,6 @@ iinfo = get_xp(np)(_aliases.iinfo) -def _supports_buffer_protocol(obj: object) -> TypeIs[Buffer]: # pyright: ignore[reportUnusedFunction] - try: - memoryview(obj) # pyright: ignore[reportArgumentType] - except TypeError: - return False - return True - - # asarray also adds the copy keyword, which is not present in numpy 1.0. # asarray() is different enough between numpy, cupy, and dask, the logic # complicated enough that it's easier to define it separately for each module @@ -92,7 +73,7 @@ def asarray( *, dtype: DType | None = None, device: Device | None = None, - copy: _Copy | None = None, + copy: py_bool | None = None, **kwargs: Any, ) -> Array: """ @@ -103,14 +84,14 @@ def asarray( """ _helpers._check_device(np, device) + # None is unsupported in NumPy 1.0, but we can use an internal enum + # False in NumPy 1.0 means None in NumPy 2.0 and in the Array API if copy is None: - copy = np._CopyMode.IF_NEEDED + copy = np._CopyMode.IF_NEEDED # type: ignore[assignment,attr-defined] elif copy is False: - copy = np._CopyMode.NEVER - elif copy is True: - copy = np._CopyMode.ALWAYS + copy = np._CopyMode.NEVER # type: ignore[assignment,attr-defined] - return np.array(obj, copy=copy, dtype=dtype, **kwargs) # pyright: ignore + return np.array(obj, copy=copy, dtype=dtype, **kwargs) def astype( @@ -141,16 +122,36 @@ def count_nonzero( # take_along_axis: axis defaults to -1 but in numpy axis is a required arg -def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): +def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: return np.take_along_axis(x, indices, axis=axis) +# ceil, floor, and trunc return integers for integer inputs in NumPy < 2 + +def ceil(x: Array, /) -> Array: + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + return x.copy() + return np.ceil(x) + + +def floor(x: Array, /) -> Array: + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + return x.copy() + return np.floor(x) + + +def trunc(x: Array, /) -> Array: + if np.__version__ < '2' and np.issubdtype(x.dtype, np.integer): + return x.copy() + return np.trunc(x) + + # These functions are completely new here. If the library already has them # (i.e., numpy 2.0), use the library version instead of our wrapper. if hasattr(np, "vecdot"): vecdot = np.vecdot else: - vecdot = get_xp(np)(_aliases.vecdot) + vecdot = get_xp(np)(_aliases.vecdot) # type: ignore[assignment] if hasattr(np, "isdtype"): isdtype = np.isdtype @@ -162,8 +163,7 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): else: unstack = get_xp(np)(_aliases.unstack) -__all__ = [ - "__array_namespace_info__", +__all__ = _aliases.__all__ + [ "asarray", "astype", "acos", @@ -173,6 +173,9 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): "atan", "atan2", "atanh", + "ceil", + "floor", + "trunc", "bitwise_left_shift", "bitwise_invert", "bitwise_right_shift", @@ -182,8 +185,6 @@ def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1): "pow", "take_along_axis" ] -__all__ += _aliases.__all__ -_all_ignore = ["np", "get_xp"] def __dir__() -> list[str]: diff --git a/sklearn/externals/array_api_compat/numpy/_info.py b/sklearn/externals/array_api_compat/numpy/_info.py index f307f62c5d5d5..c625c13e36942 100644 --- a/sklearn/externals/array_api_compat/numpy/_info.py +++ b/sklearn/externals/array_api_compat/numpy/_info.py @@ -27,6 +27,7 @@ uint64, ) +from ..common._typing import DefaultDTypes from ._typing import Device, DType @@ -139,7 +140,7 @@ def default_dtypes( self, *, device: Device | None = None, - ) -> dict[str, dtype[intp | float64 | complex128]]: + ) -> DefaultDTypes: """ The default data types used for new NumPy arrays. diff --git a/sklearn/externals/array_api_compat/numpy/_typing.py b/sklearn/externals/array_api_compat/numpy/_typing.py index e771c788bbcab..b5fa188c52b69 100644 --- a/sklearn/externals/array_api_compat/numpy/_typing.py +++ b/sklearn/externals/array_api_compat/numpy/_typing.py @@ -23,7 +23,6 @@ Array: TypeAlias = np.ndarray __all__ = ["Array", "DType", "Device"] -_all_ignore = ["np"] def __dir__() -> list[str]: diff --git a/sklearn/externals/array_api_compat/numpy/fft.py b/sklearn/externals/array_api_compat/numpy/fft.py index 06875f00b4312..a492feb8cf690 100644 --- a/sklearn/externals/array_api_compat/numpy/fft.py +++ b/sklearn/externals/array_api_compat/numpy/fft.py @@ -1,6 +1,8 @@ import numpy as np -from numpy.fft import __all__ as fft_all -from numpy.fft import fft2, ifft2, irfft2, rfft2 + +from .._internal import clone_module + +__all__ = clone_module("numpy.fft", globals()) from .._internal import get_xp from ..common import _fft @@ -21,15 +23,8 @@ ifftshift = get_xp(np)(_fft.ifftshift) -__all__ = ["rfft2", "irfft2", "fft2", "ifft2"] -__all__ += _fft.__all__ - +__all__ = sorted(set(__all__) | set(_fft.__all__)) def __dir__() -> list[str]: return __all__ - -del get_xp -del np -del fft_all -del _fft diff --git a/sklearn/externals/array_api_compat/numpy/linalg.py b/sklearn/externals/array_api_compat/numpy/linalg.py index 2d3e731da3fc0..7168441c7517e 100644 --- a/sklearn/externals/array_api_compat/numpy/linalg.py +++ b/sklearn/externals/array_api_compat/numpy/linalg.py @@ -7,26 +7,11 @@ import numpy as np -# intersection of `np.linalg.__all__` on numpy 1.22 and 2.2, minus `_linalg.__all__` -from numpy.linalg import ( - LinAlgError, - cond, - det, - eig, - eigvals, - eigvalsh, - inv, - lstsq, - matrix_power, - multi_dot, - norm, - tensorinv, - tensorsolve, -) - -from .._internal import get_xp +from .._internal import clone_module, get_xp from ..common import _linalg +__all__ = clone_module("numpy.linalg", globals()) + # These functions are in both the main and linalg namespaces from ._aliases import matmul, matrix_transpose, tensordot, vecdot # noqa: F401 from ._typing import Array @@ -65,7 +50,7 @@ # https://github.com/cupy/cupy/blob/main/cupy/cublas.py#L43). def solve(x1: Array, x2: Array, /) -> Array: try: - from numpy.linalg._linalg import ( + from numpy.linalg._linalg import ( # type: ignore[attr-defined] _assert_stacked_2d, _assert_stacked_square, _commonType, @@ -74,7 +59,7 @@ def solve(x1: Array, x2: Array, /) -> Array: isComplexType, ) except ImportError: - from numpy.linalg.linalg import ( + from numpy.linalg.linalg import ( # type: ignore[attr-defined] _assert_stacked_2d, _assert_stacked_square, _commonType, @@ -120,7 +105,7 @@ def solve(x1: Array, x2: Array, /) -> Array: vector_norm = get_xp(np)(_linalg.vector_norm) -__all__ = [ +_all = [ "LinAlgError", "cond", "det", @@ -132,12 +117,12 @@ def solve(x1: Array, x2: Array, /) -> Array: "matrix_power", "multi_dot", "norm", + "solve", "tensorinv", "tensorsolve", + "vector_norm", ] -__all__ += _linalg.__all__ -__all__ += ["solve", "vector_norm"] - +__all__ = sorted(set(__all__) | set(_linalg.__all__) | set(_all)) def __dir__() -> list[str]: return __all__ diff --git a/sklearn/externals/array_api_compat/torch/__init__.py b/sklearn/externals/array_api_compat/torch/__init__.py index 69fd19ce83a56..6cbb6ec264869 100644 --- a/sklearn/externals/array_api_compat/torch/__init__.py +++ b/sklearn/externals/array_api_compat/torch/__init__.py @@ -1,22 +1,25 @@ -from torch import * # noqa: F403 +from typing import Final -# Several names are not included in the above import * -import torch -for n in dir(torch): - if (n.startswith('_') - or n.endswith('_') - or 'cuda' in n - or 'cpu' in n - or 'backward' in n): - continue - exec(f"{n} = torch.{n}") -del n +from .._internal import clone_module + +__all__ = clone_module("torch", globals()) # These imports may overwrite names from the import * above. +from . import _aliases from ._aliases import * # noqa: F403 +from ._info import __array_namespace_info__ # noqa: F401 # See the comment in the numpy __init__.py __import__(__package__ + '.linalg') __import__(__package__ + '.fft') -__array_api_version__ = '2024.12' +__array_api_version__: Final = '2024.12' + +__all__ = sorted( + set(__all__) + | set(_aliases.__all__) + | {"__array_api_version__", "__array_namespace_info__", "linalg", "fft"} +) + +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/torch/_aliases.py b/sklearn/externals/array_api_compat/torch/_aliases.py index de5d1a5d40eb5..4e8533f95e839 100644 --- a/sklearn/externals/array_api_compat/torch/_aliases.py +++ b/sklearn/externals/array_api_compat/torch/_aliases.py @@ -1,15 +1,15 @@ from __future__ import annotations +from collections.abc import Sequence from functools import reduce as _reduce, wraps as _wraps from builtins import all as _builtin_all, any as _builtin_any -from typing import Any, List, Optional, Sequence, Tuple, Union, Literal +from typing import Any, Literal import torch from .._internal import get_xp from ..common import _aliases from ..common._typing import NestedSequence, SupportsBufferProtocol -from ._info import __array_namespace_info__ from ._typing import Array, Device, DType _int_dtypes = { @@ -96,9 +96,7 @@ def _fix_promotion(x1, x2, only_scalar=True): _py_scalars = (bool, int, float, complex) -def result_type( - *arrays_and_dtypes: Array | DType | bool | int | float | complex -) -> DType: +def result_type(*arrays_and_dtypes: Array | DType | complex) -> DType: num = len(arrays_and_dtypes) if num == 0: @@ -129,10 +127,7 @@ def result_type( return _reduce(_result_type, others + scalars) -def _result_type( - x: Array | DType | bool | int | float | complex, - y: Array | DType | bool | int | float | complex, -) -> DType: +def _result_type(x: Array | DType | complex, y: Array | DType | complex) -> DType: if not (isinstance(x, _py_scalars) or isinstance(y, _py_scalars)): xdt = x if isinstance(x, torch.dtype) else x.dtype ydt = y if isinstance(y, torch.dtype) else y.dtype @@ -150,7 +145,7 @@ def _result_type( return torch.result_type(x, y) -def can_cast(from_: Union[DType, Array], to: DType, /) -> bool: +def can_cast(from_: DType | Array, to: DType, /) -> bool: if not isinstance(from_, torch.dtype): from_ = from_.dtype return torch.can_cast(from_, to) @@ -194,12 +189,7 @@ def can_cast(from_: Union[DType, Array], to: DType, /) -> bool: def asarray( - obj: ( - Array - | bool | int | float | complex - | NestedSequence[bool | int | float | complex] - | SupportsBufferProtocol - ), + obj: Array | complex | NestedSequence[complex] | SupportsBufferProtocol, /, *, dtype: DType | None = None, @@ -218,13 +208,13 @@ def asarray( # of 'axis'. # torch.min and torch.max return a tuple and don't support multiple axes https://github.com/pytorch/pytorch/issues/58745 -def max(x: Array, /, *, axis: Optional[Union[int, Tuple[int, ...]]] = None, keepdims: bool = False) -> Array: +def max(x: Array, /, *, axis: int | tuple[int, ...] | None = None, keepdims: bool = False) -> Array: # https://github.com/pytorch/pytorch/issues/29137 if axis == (): return torch.clone(x) return torch.amax(x, axis, keepdims=keepdims) -def min(x: Array, /, *, axis: Optional[Union[int, Tuple[int, ...]]] = None, keepdims: bool = False) -> Array: +def min(x: Array, /, *, axis: int | tuple[int, ...] |None = None, keepdims: bool = False) -> Array: # https://github.com/pytorch/pytorch/issues/29137 if axis == (): return torch.clone(x) @@ -240,9 +230,31 @@ def min(x: Array, /, *, axis: Optional[Union[int, Tuple[int, ...]]] = None, keep # torch.sort also returns a tuple # https://github.com/pytorch/pytorch/issues/70921 -def sort(x: Array, /, *, axis: int = -1, descending: bool = False, stable: bool = True, **kwargs) -> Array: +def sort( + x: Array, + /, + *, + axis: int = -1, + descending: bool = False, + stable: bool = True, + **kwargs: object, +) -> Array: return torch.sort(x, dim=axis, descending=descending, stable=stable, **kwargs).values + +# Wrap torch.argsort to set stable=True by default +def argsort( + x: Array, + /, + *, + axis: int = -1, + descending: bool = False, + stable: bool = True, + **kwargs: object, +) -> Array: + return torch.argsort(x, dim=axis, descending=descending, stable=stable, **kwargs) + + def _normalize_axes(axis, ndim): axes = [] if ndim == 0 and axis: @@ -307,10 +319,10 @@ def _sum_prod_no_axis(x: Array, dtype: DType | None) -> Array: def prod(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, - dtype: Optional[DType] = None, + axis: int | tuple[int, ...] | None = None, + dtype: DType | None = None, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: if axis == (): return _sum_prod_no_axis(x, dtype) @@ -331,10 +343,10 @@ def prod(x: Array, def sum(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, - dtype: Optional[DType] = None, + axis: int | tuple[int, ...] | None = None, + dtype: DType | None = None, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: if axis == (): return _sum_prod_no_axis(x, dtype) @@ -350,9 +362,9 @@ def sum(x: Array, def any(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, + axis: int | tuple[int, ...] | None = None, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: if axis == (): return x.to(torch.bool) @@ -374,9 +386,9 @@ def any(x: Array, def all(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, + axis: int | tuple[int, ...] | None = None, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: if axis == (): return x.to(torch.bool) @@ -398,9 +410,9 @@ def all(x: Array, def mean(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, + axis: int | tuple[int, ...] | None = None, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: # https://github.com/pytorch/pytorch/issues/29137 if axis == (): return torch.clone(x) @@ -415,10 +427,10 @@ def mean(x: Array, def std(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, - correction: Union[int, float] = 0.0, + axis: int | tuple[int, ...] | None = None, + correction: float = 0.0, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: # Note, float correction is not supported # https://github.com/pytorch/pytorch/issues/61492. We don't try to # implement it here for now. @@ -446,10 +458,10 @@ def std(x: Array, def var(x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, - correction: Union[int, float] = 0.0, + axis: int | tuple[int, ...] | None = None, + correction: float = 0.0, keepdims: bool = False, - **kwargs) -> Array: + **kwargs: object) -> Array: # Note, float correction is not supported # https://github.com/pytorch/pytorch/issues/61492. We don't try to # implement it here for now. @@ -472,11 +484,11 @@ def var(x: Array, # torch.concat doesn't support dim=None # https://github.com/pytorch/pytorch/issues/70925 -def concat(arrays: Union[Tuple[Array, ...], List[Array]], +def concat(arrays: tuple[Array, ...] | list[Array], /, *, - axis: Optional[int] = 0, - **kwargs) -> Array: + axis: int | None = 0, + **kwargs: object) -> Array: if axis is None: arrays = tuple(ar.flatten() for ar in arrays) axis = 0 @@ -485,7 +497,7 @@ def concat(arrays: Union[Tuple[Array, ...], List[Array]], # torch.squeeze only accepts int dim and doesn't require it # https://github.com/pytorch/pytorch/issues/70924. Support for tuple dim was # added at https://github.com/pytorch/pytorch/pull/89017. -def squeeze(x: Array, /, axis: Union[int, Tuple[int, ...]]) -> Array: +def squeeze(x: Array, /, axis: int | tuple[int, ...]) -> Array: if isinstance(axis, int): axis = (axis,) for a in axis: @@ -499,27 +511,27 @@ def squeeze(x: Array, /, axis: Union[int, Tuple[int, ...]]) -> Array: return x # torch.broadcast_to uses size instead of shape -def broadcast_to(x: Array, /, shape: Tuple[int, ...], **kwargs) -> Array: +def broadcast_to(x: Array, /, shape: tuple[int, ...], **kwargs: object) -> Array: return torch.broadcast_to(x, shape, **kwargs) # torch.permute uses dims instead of axes -def permute_dims(x: Array, /, axes: Tuple[int, ...]) -> Array: +def permute_dims(x: Array, /, axes: tuple[int, ...]) -> Array: return torch.permute(x, axes) # The axis parameter doesn't work for flip() and roll() # https://github.com/pytorch/pytorch/issues/71210. Also torch.flip() doesn't # accept axis=None -def flip(x: Array, /, *, axis: Optional[Union[int, Tuple[int, ...]]] = None, **kwargs) -> Array: +def flip(x: Array, /, *, axis: int | tuple[int, ...] | None = None, **kwargs: object) -> Array: if axis is None: axis = tuple(range(x.ndim)) # torch.flip doesn't accept dim as an int but the method does # https://github.com/pytorch/pytorch/issues/18095 return x.flip(axis, **kwargs) -def roll(x: Array, /, shift: Union[int, Tuple[int, ...]], *, axis: Optional[Union[int, Tuple[int, ...]]] = None, **kwargs) -> Array: +def roll(x: Array, /, shift: int | tuple[int, ...], *, axis: int | tuple[int, ...] | None = None, **kwargs: object) -> Array: return torch.roll(x, shift, axis, **kwargs) -def nonzero(x: Array, /, **kwargs) -> Tuple[Array, ...]: +def nonzero(x: Array, /, **kwargs: object) -> tuple[Array, ...]: if x.ndim == 0: raise ValueError("nonzero() does not support zero-dimensional arrays") return torch.nonzero(x, as_tuple=True, **kwargs) @@ -532,8 +544,8 @@ def diff( *, axis: int = -1, n: int = 1, - prepend: Optional[Array] = None, - append: Optional[Array] = None, + prepend: Array | None = None, + append: Array | None = None, ) -> Array: return torch.diff(x, dim=axis, n=n, prepend=prepend, append=append) @@ -543,7 +555,7 @@ def count_nonzero( x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, + axis: int | tuple[int, ...] | None = None, keepdims: bool = False, ) -> Array: result = torch.count_nonzero(x, dim=axis) @@ -564,12 +576,7 @@ def repeat(x: Array, repeats: int | Array, /, *, axis: int | None = None) -> Arr return torch.repeat_interleave(x, repeats, axis) -def where( - condition: Array, - x1: Array | bool | int | float | complex, - x2: Array | bool | int | float | complex, - /, -) -> Array: +def where(condition: Array, x1: Array | complex, x2: Array | complex, /) -> Array: x1, x2 = _fix_promotion(x1, x2) return torch.where(condition, x1, x2) @@ -577,10 +584,10 @@ def where( # torch.reshape doesn't have the copy keyword def reshape(x: Array, /, - shape: Tuple[int, ...], + shape: tuple[int, ...], *, - copy: Optional[bool] = None, - **kwargs) -> Array: + copy: bool | None = None, + **kwargs: object) -> Array: if copy is not None: raise NotImplementedError("torch.reshape doesn't yet support the copy keyword") return torch.reshape(x, shape, **kwargs) @@ -589,14 +596,14 @@ def reshape(x: Array, # (https://github.com/pytorch/pytorch/issues/70915), and doesn't support some # keyword argument combinations # (https://github.com/pytorch/pytorch/issues/70914) -def arange(start: Union[int, float], +def arange(start: float, /, - stop: Optional[Union[int, float]] = None, - step: Union[int, float] = 1, + stop: float | None = None, + step: float = 1, *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: if stop is None: start, stop = 0, start if step > 0 and stop <= start or step < 0 and stop >= start: @@ -611,13 +618,13 @@ def arange(start: Union[int, float], # torch.eye does not accept None as a default for the second argument and # doesn't support off-diagonals (https://github.com/pytorch/pytorch/issues/70910) def eye(n_rows: int, - n_cols: Optional[int] = None, + n_cols: int | None = None, /, *, k: int = 0, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: if n_cols is None: n_cols = n_rows z = torch.zeros(n_rows, n_cols, dtype=dtype, device=device, **kwargs) @@ -626,52 +633,52 @@ def eye(n_rows: int, return z # torch.linspace doesn't have the endpoint parameter -def linspace(start: Union[int, float], - stop: Union[int, float], +def linspace(start: float, + stop: float, /, num: int, *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, + dtype: DType | None = None, + device: Device | None = None, endpoint: bool = True, - **kwargs) -> Array: + **kwargs: object) -> Array: if not endpoint: return torch.linspace(start, stop, num+1, dtype=dtype, device=device, **kwargs)[:-1] return torch.linspace(start, stop, num, dtype=dtype, device=device, **kwargs) # torch.full does not accept an int size # https://github.com/pytorch/pytorch/issues/70906 -def full(shape: Union[int, Tuple[int, ...]], - fill_value: bool | int | float | complex, +def full(shape: int | tuple[int, ...], + fill_value: complex, *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: if isinstance(shape, int): shape = (shape,) return torch.full(shape, fill_value, dtype=dtype, device=device, **kwargs) # ones, zeros, and empty do not accept shape as a keyword argument -def ones(shape: Union[int, Tuple[int, ...]], +def ones(shape: int | tuple[int, ...], *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: return torch.ones(shape, dtype=dtype, device=device, **kwargs) -def zeros(shape: Union[int, Tuple[int, ...]], +def zeros(shape: int | tuple[int, ...], *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: return torch.zeros(shape, dtype=dtype, device=device, **kwargs) -def empty(shape: Union[int, Tuple[int, ...]], +def empty(shape: int | tuple[int, ...], *, - dtype: Optional[DType] = None, - device: Optional[Device] = None, - **kwargs) -> Array: + dtype: DType | None = None, + device: Device | None = None, + **kwargs: object) -> Array: return torch.empty(shape, dtype=dtype, device=device, **kwargs) # tril and triu do not call the keyword argument k @@ -693,14 +700,14 @@ def astype( /, *, copy: bool = True, - device: Optional[Device] = None, + device: Device | None = None, ) -> Array: if device is not None: return x.to(device, dtype=dtype, copy=copy) return x.to(dtype=dtype, copy=copy) -def broadcast_arrays(*arrays: Array) -> List[Array]: +def broadcast_arrays(*arrays: Array) -> list[Array]: shape = torch.broadcast_shapes(*[a.shape for a in arrays]) return [torch.broadcast_to(a, shape) for a in arrays] @@ -738,7 +745,7 @@ def unique_inverse(x: Array) -> UniqueInverseResult: def unique_values(x: Array) -> Array: return torch.unique(x) -def matmul(x1: Array, x2: Array, /, **kwargs) -> Array: +def matmul(x1: Array, x2: Array, /, **kwargs: object) -> Array: # torch.matmul doesn't type promote (but differently from _fix_promotion) x1, x2 = _fix_promotion(x1, x2, only_scalar=False) return torch.matmul(x1, x2, **kwargs) @@ -756,8 +763,8 @@ def tensordot( x2: Array, /, *, - axes: Union[int, Tuple[Sequence[int], Sequence[int]]] = 2, - **kwargs, + axes: int | tuple[Sequence[int], Sequence[int]] = 2, + **kwargs: object, ) -> Array: # Note: torch.tensordot fails with integer dtypes when there is only 1 # element in the axis (https://github.com/pytorch/pytorch/issues/84530). @@ -766,8 +773,10 @@ def tensordot( def isdtype( - dtype: DType, kind: Union[DType, str, Tuple[Union[DType, str], ...]], - *, _tuple=True, # Disallow nested tuples + dtype: DType, + kind: DType | str | tuple[DType | str, ...], + *, + _tuple: bool = True, # Disallow nested tuples ) -> bool: """ Returns a boolean indicating whether a provided dtype is of a specified data type ``kind``. @@ -801,16 +810,29 @@ def isdtype( else: return dtype == kind -def take(x: Array, indices: Array, /, *, axis: Optional[int] = None, **kwargs) -> Array: +def take(x: Array, indices: Array, /, *, axis: int | None = None, **kwargs: object) -> Array: if axis is None: if x.ndim != 1: raise ValueError("axis must be specified when ndim > 1") axis = 0 - return torch.index_select(x, axis, indices, **kwargs) + # torch does not support negative indices, + # see https://github.com/pytorch/pytorch/issues/146211 + return torch.index_select( + x, + axis, + torch.where(indices < 0, indices + x.shape[axis], indices), + **kwargs + ) def take_along_axis(x: Array, indices: Array, /, *, axis: int = -1) -> Array: - return torch.take_along_dim(x, indices, dim=axis) + # torch does not support negative indices, + # see https://github.com/pytorch/pytorch/issues/146211 + return torch.take_along_dim( + x, + torch.where(indices < 0, indices + x.shape[axis], indices), + dim=axis + ) def sign(x: Array, /) -> Array: @@ -828,13 +850,13 @@ def sign(x: Array, /) -> Array: return out -def meshgrid(*arrays: Array, indexing: Literal['xy', 'ij'] = 'xy') -> List[Array]: +def meshgrid(*arrays: Array, indexing: Literal['xy', 'ij'] = 'xy') -> list[Array]: # enforce the default of 'xy' # TODO: is the return type a list or a tuple - return list(torch.meshgrid(*arrays, indexing='xy')) + return list(torch.meshgrid(*arrays, indexing=indexing)) -__all__ = ['__array_namespace_info__', 'asarray', 'result_type', 'can_cast', +__all__ = ['asarray', 'result_type', 'can_cast', 'permute_dims', 'bitwise_invert', 'newaxis', 'conj', 'add', 'atan2', 'bitwise_and', 'bitwise_left_shift', 'bitwise_or', 'bitwise_right_shift', 'bitwise_xor', 'copysign', 'count_nonzero', @@ -842,14 +864,12 @@ def meshgrid(*arrays: Array, indexing: Literal['xy', 'ij'] = 'xy') -> List[Array 'equal', 'floor_divide', 'greater', 'greater_equal', 'hypot', 'less', 'less_equal', 'logaddexp', 'maximum', 'minimum', 'multiply', 'not_equal', 'pow', 'remainder', 'subtract', 'max', - 'min', 'clip', 'unstack', 'cumulative_sum', 'cumulative_prod', 'sort', 'prod', 'sum', - 'any', 'all', 'mean', 'std', 'var', 'concat', 'squeeze', - 'broadcast_to', 'flip', 'roll', 'nonzero', 'where', 'reshape', + 'min', 'clip', 'unstack', 'cumulative_sum', 'cumulative_prod', 'sort', + 'argsort', 'prod', 'sum', 'any', 'all', 'mean', 'std', 'var', 'concat', + 'squeeze', 'broadcast_to', 'flip', 'roll', 'nonzero', 'where', 'reshape', 'arange', 'eye', 'linspace', 'full', 'ones', 'zeros', 'empty', 'tril', 'triu', 'expand_dims', 'astype', 'broadcast_arrays', 'UniqueAllResult', 'UniqueCountsResult', 'UniqueInverseResult', 'unique_all', 'unique_counts', 'unique_inverse', 'unique_values', 'matmul', 'matrix_transpose', 'vecdot', 'tensordot', 'isdtype', 'take', 'take_along_axis', 'sign', 'finfo', 'iinfo', 'repeat', 'meshgrid'] - -_all_ignore = ['torch', 'get_xp'] diff --git a/sklearn/externals/array_api_compat/torch/fft.py b/sklearn/externals/array_api_compat/torch/fft.py index 50e6a0d0a3968..f11b3eb597563 100644 --- a/sklearn/externals/array_api_compat/torch/fft.py +++ b/sklearn/externals/array_api_compat/torch/fft.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Union, Sequence, Literal +from collections.abc import Sequence +from typing import Literal -import torch +import torch # noqa: F401 import torch.fft -from torch.fft import * # noqa: F403 from ._typing import Array +from .._internal import clone_module + +__all__ = clone_module("torch.fft", globals()) # Several torch fft functions do not map axes to dim @@ -17,7 +20,7 @@ def fftn( s: Sequence[int] = None, axes: Sequence[int] = None, norm: Literal["backward", "ortho", "forward"] = "backward", - **kwargs, + **kwargs: object, ) -> Array: return torch.fft.fftn(x, s=s, dim=axes, norm=norm, **kwargs) @@ -28,7 +31,7 @@ def ifftn( s: Sequence[int] = None, axes: Sequence[int] = None, norm: Literal["backward", "ortho", "forward"] = "backward", - **kwargs, + **kwargs: object, ) -> Array: return torch.fft.ifftn(x, s=s, dim=axes, norm=norm, **kwargs) @@ -39,7 +42,7 @@ def rfftn( s: Sequence[int] = None, axes: Sequence[int] = None, norm: Literal["backward", "ortho", "forward"] = "backward", - **kwargs, + **kwargs: object, ) -> Array: return torch.fft.rfftn(x, s=s, dim=axes, norm=norm, **kwargs) @@ -50,7 +53,7 @@ def irfftn( s: Sequence[int] = None, axes: Sequence[int] = None, norm: Literal["backward", "ortho", "forward"] = "backward", - **kwargs, + **kwargs: object, ) -> Array: return torch.fft.irfftn(x, s=s, dim=axes, norm=norm, **kwargs) @@ -58,8 +61,8 @@ def fftshift( x: Array, /, *, - axes: Union[int, Sequence[int]] = None, - **kwargs, + axes: int | Sequence[int] = None, + **kwargs: object, ) -> Array: return torch.fft.fftshift(x, dim=axes, **kwargs) @@ -67,19 +70,13 @@ def ifftshift( x: Array, /, *, - axes: Union[int, Sequence[int]] = None, - **kwargs, + axes: int | Sequence[int] = None, + **kwargs: object, ) -> Array: return torch.fft.ifftshift(x, dim=axes, **kwargs) -__all__ = torch.fft.__all__ + [ - "fftn", - "ifftn", - "rfftn", - "irfftn", - "fftshift", - "ifftshift", -] +__all__ += ["fftn", "ifftn", "rfftn", "irfftn", "fftshift", "ifftshift"] -_all_ignore = ['torch'] +def __dir__() -> list[str]: + return __all__ diff --git a/sklearn/externals/array_api_compat/torch/linalg.py b/sklearn/externals/array_api_compat/torch/linalg.py index 70d7240500ce4..08271d226734b 100644 --- a/sklearn/externals/array_api_compat/torch/linalg.py +++ b/sklearn/externals/array_api_compat/torch/linalg.py @@ -1,14 +1,11 @@ from __future__ import annotations import torch -from typing import Optional, Union, Tuple +import torch.linalg -from torch.linalg import * # noqa: F403 +from .._internal import clone_module -# torch.linalg doesn't define __all__ -# from torch.linalg import __all__ as linalg_all -from torch import linalg as torch_linalg -linalg_all = [i for i in dir(torch_linalg) if not i.startswith('_')] +__all__ = clone_module("torch.linalg", globals()) # outer is implemented in torch but aren't in the linalg namespace from torch import outer @@ -30,9 +27,9 @@ def cross(x1: Array, x2: Array, /, *, axis: int = -1) -> Array: if not (x1.shape[axis] == x2.shape[axis] == 3): raise ValueError(f"cross product axis must have size 3, got {x1.shape[axis]} and {x2.shape[axis]}") x1, x2 = torch.broadcast_tensors(x1, x2) - return torch_linalg.cross(x1, x2, dim=axis) + return torch.linalg.cross(x1, x2, dim=axis) -def vecdot(x1: Array, x2: Array, /, *, axis: int = -1, **kwargs) -> Array: +def vecdot(x1: Array, x2: Array, /, *, axis: int = -1, **kwargs: object) -> Array: from ._aliases import isdtype x1, x2 = _fix_promotion(x1, x2, only_scalar=False) @@ -54,7 +51,7 @@ def vecdot(x1: Array, x2: Array, /, *, axis: int = -1, **kwargs) -> Array: return res[..., 0, 0] return torch.linalg.vecdot(x1, x2, dim=axis, **kwargs) -def solve(x1: Array, x2: Array, /, **kwargs) -> Array: +def solve(x1: Array, x2: Array, /, **kwargs: object) -> Array: x1, x2 = _fix_promotion(x1, x2, only_scalar=False) # Torch tries to emulate NumPy 1 solve behavior by using batched 1-D solve # whenever @@ -75,7 +72,7 @@ def solve(x1: Array, x2: Array, /, **kwargs) -> Array: return torch.linalg.solve(x1, x2, **kwargs) # torch.trace doesn't support the offset argument and doesn't support stacking -def trace(x: Array, /, *, offset: int = 0, dtype: Optional[DType] = None) -> Array: +def trace(x: Array, /, *, offset: int = 0, dtype: DType | None = None) -> Array: # Use our wrapped sum to make sure it does upcasting correctly return sum(torch.diagonal(x, offset=offset, dim1=-2, dim2=-1), axis=-1, dtype=dtype) @@ -83,11 +80,11 @@ def vector_norm( x: Array, /, *, - axis: Optional[Union[int, Tuple[int, ...]]] = None, + axis: int | tuple[int, ...] | None = None, keepdims: bool = False, # JustFloat stands for inf | -inf, which are not valid for Literal ord: JustInt | JustFloat = 2, - **kwargs, + **kwargs: object, ) -> Array: # torch.vector_norm incorrectly treats axis=() the same as axis=None if axis == (): @@ -110,12 +107,8 @@ def vector_norm( return out return torch.linalg.vector_norm(x, ord=ord, axis=axis, keepdim=keepdims, **kwargs) -__all__ = linalg_all + ['outer', 'matmul', 'matrix_transpose', 'tensordot', - 'cross', 'vecdot', 'solve', 'trace', 'vector_norm'] - -_all_ignore = ['torch_linalg', 'sum'] - -del linalg_all +__all__ += ['outer', 'matmul', 'matrix_transpose', 'tensordot', + 'cross', 'vecdot', 'solve', 'trace', 'vector_norm'] def __dir__() -> list[str]: return __all__ diff --git a/sklearn/feature_extraction/_dict_vectorizer.py b/sklearn/feature_extraction/_dict_vectorizer.py index f862a03bb1d97..ce16566aafc9e 100644 --- a/sklearn/feature_extraction/_dict_vectorizer.py +++ b/sklearn/feature_extraction/_dict_vectorizer.py @@ -10,7 +10,7 @@ import scipy.sparse as sp from sklearn.base import BaseEstimator, TransformerMixin, _fit_context -from sklearn.utils import check_array, metadata_routing +from sklearn.utils import _align_api_if_sparse, check_array, metadata_routing from sklearn.utils.validation import check_is_fitted @@ -267,7 +267,7 @@ def _transform(self, X, fitting): indices = np.frombuffer(indices, dtype=np.intc) shape = (len(indptr) - 1, len(vocab)) - result_matrix = sp.csr_matrix( + result_matrix = sp.csr_array( (values, indices, indptr), shape=shape, dtype=dtype ) @@ -289,7 +289,7 @@ def _transform(self, X, fitting): self.feature_names_ = feature_names self.vocabulary_ = vocab - return result_matrix + return _align_api_if_sparse(result_matrix) @_fit_context(prefer_skip_nested_validation=True) def fit_transform(self, X, y=None): diff --git a/sklearn/feature_extraction/_hash.py b/sklearn/feature_extraction/_hash.py index 814bf912a42fc..a11c3db59c94f 100644 --- a/sklearn/feature_extraction/_hash.py +++ b/sklearn/feature_extraction/_hash.py @@ -9,7 +9,7 @@ from sklearn.base import BaseEstimator, TransformerMixin, _fit_context from sklearn.feature_extraction._hashing_fast import transform as _hashing_transform -from sklearn.utils import metadata_routing +from sklearn.utils import _align_api_if_sparse, metadata_routing from sklearn.utils._param_validation import Interval, StrOptions @@ -188,14 +188,14 @@ def transform(self, raw_X): if n_samples == 0: raise ValueError("Cannot vectorize empty sequence.") - X = sp.csr_matrix( + X = sp.csr_array( (values, indices, indptr), dtype=self.dtype, shape=(n_samples, self.n_features), ) X.sum_duplicates() # also sorts the indices - return X + return _align_api_if_sparse(X) def __sklearn_tags__(self): tags = super().__sklearn_tags__() diff --git a/sklearn/feature_extraction/image.py b/sklearn/feature_extraction/image.py index 020620adf6cfc..e8f58b5f37e0b 100644 --- a/sklearn/feature_extraction/image.py +++ b/sklearn/feature_extraction/image.py @@ -11,7 +11,7 @@ from scipy import sparse from sklearn.base import BaseEstimator, TransformerMixin, _fit_context -from sklearn.utils import check_array, check_random_state +from sklearn.utils import _align_api_if_sparse, check_array, check_random_state from sklearn.utils._param_validation import ( Hidden, Interval, @@ -94,7 +94,7 @@ def _mask_edges_weights(mask, edges, weights=None): def _to_graph( - n_x, n_y, n_z, mask=None, img=None, return_as=sparse.coo_matrix, dtype=None + n_x, n_y, n_z, mask=None, img=None, return_as=sparse.coo_array, dtype=None ): """Auxiliary function for img_to_graph and grid_to_graph""" edges = _make_edges_3d(n_x, n_y, n_z) @@ -127,7 +127,7 @@ def _to_graph( diag_idx = np.arange(n_voxels) i_idx = np.hstack((edges[0], edges[1])) j_idx = np.hstack((edges[1], edges[0])) - graph = sparse.coo_matrix( + graph = sparse.coo_array( ( np.hstack((weights, weights, diag)), (np.hstack((i_idx, diag_idx)), np.hstack((j_idx, diag_idx))), @@ -137,7 +137,7 @@ def _to_graph( ) if return_as is np.ndarray: return graph.toarray() - return return_as(graph) + return _align_api_if_sparse(return_as(graph)) @validate_params( @@ -149,7 +149,7 @@ def _to_graph( }, prefer_skip_nested_validation=True, ) -def img_to_graph(img, *, mask=None, return_as=sparse.coo_matrix, dtype=None): +def img_to_graph(img, *, mask=None, return_as=sparse.coo_array, dtype=None): """Graph of the pixel-to-pixel gradient connections. Edges are weighted with the gradient values. @@ -165,7 +165,7 @@ def img_to_graph(img, *, mask=None, return_as=sparse.coo_matrix, dtype=None): An optional mask of the image, to consider only part of the pixels. return_as : np.ndarray or a sparse matrix class, \ - default=sparse.coo_matrix + default=sparse.coo_array The class to use to build the returned adjacency matrix. dtype : dtype, default=None The data of the returned sparse matrix. By default it is the @@ -203,9 +203,7 @@ def img_to_graph(img, *, mask=None, return_as=sparse.coo_matrix, dtype=None): }, prefer_skip_nested_validation=True, ) -def grid_to_graph( - n_x, n_y, n_z=1, *, mask=None, return_as=sparse.coo_matrix, dtype=int -): +def grid_to_graph(n_x, n_y, n_z=1, *, mask=None, return_as=sparse.coo_array, dtype=int): """Graph of the pixel-to-pixel connections. Edges exist if 2 voxels are connected. @@ -224,7 +222,7 @@ def grid_to_graph( An optional mask of the image, to consider only part of the pixels. return_as : np.ndarray or a sparse matrix class, \ - default=sparse.coo_matrix + default=sparse.coo_array The class to use to build the returned adjacency matrix. dtype : dtype, default=int The data of the returned sparse matrix. By default it is int. @@ -523,7 +521,9 @@ def reconstruct_from_patches_2d(patches, image_size): for j in range(i_w): # divide by the amount of overlap # XXX: is this the most efficient way? memory-wise yes, cpu wise? - img[i, j] /= float(min(i + 1, p_h, i_h - i) * min(j + 1, p_w, i_w - j)) + img[i, j] /= float( + min(i + 1, p_h, n_h, i_h - i) * min(j + 1, p_w, n_w, i_w - j) + ) return img diff --git a/sklearn/feature_extraction/tests/test_feature_hasher.py b/sklearn/feature_extraction/tests/test_feature_hasher.py index d19abcc772ae6..ff09db8615a29 100644 --- a/sklearn/feature_extraction/tests/test_feature_hasher.py +++ b/sklearn/feature_extraction/tests/test_feature_hasher.py @@ -4,6 +4,7 @@ from sklearn.feature_extraction import FeatureHasher from sklearn.feature_extraction._hashing_fast import transform as _hashing_transform +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_12 def test_feature_hasher_dicts(): @@ -37,8 +38,12 @@ def test_feature_hasher_strings(): assert X.shape[0] == len(raw_X) assert X.shape[1] == n_features - assert X[0].sum() == 4 - assert X[1].sum() == 3 + if SCIPY_VERSION_BELOW_1_12: + assert X[[0], :].sum() == 4 + assert X[[1], :].sum() == 3 + else: + assert X[0].sum() == 4 + assert X[1].sum() == 3 assert X.nnz == 6 diff --git a/sklearn/feature_extraction/tests/test_image.py b/sklearn/feature_extraction/tests/test_image.py index cb490fcd576ee..6d393a85f740d 100644 --- a/sklearn/feature_extraction/tests/test_image.py +++ b/sklearn/feature_extraction/tests/test_image.py @@ -14,6 +14,7 @@ img_to_graph, reconstruct_from_patches_2d, ) +from sklearn.utils._testing import assert_allclose def test_img_to_graph(): @@ -223,6 +224,28 @@ def test_reconstruct_patches_perfect_color(orange_face): np.testing.assert_array_almost_equal(face, face_reconstructed) +@pytest.mark.parametrize( + "image_size, patch_size", + [ + ((128, 256), (128, 128)), # patch_h == image_h + ((256, 128), (128, 128)), # patch_w == image_w + ((128, 128), (128, 128)), # patch == image + ((128, 256, 3), (128, 128)), # patch_h == image_h, with channels + ], +) +def test_reconstruct_patches_edge_patch_size(image_size, patch_size): + """Check that reconstruct_from_patches_2d works when a patch dimension + equals the corresponding image dimension. + + Non-regression test for https://github.com/scikit-learn/scikit-learn/issues/10910 + """ + rng = np.random.RandomState(0) + image = rng.rand(*image_size) + patches = extract_patches_2d(image, patch_size) + reconstructed = reconstruct_from_patches_2d(patches, image_size) + assert_allclose(image, reconstructed) + + def test_patch_extractor_fit(downsampled_face_collection, global_random_seed): faces = downsampled_face_collection extr = PatchExtractor( diff --git a/sklearn/feature_extraction/tests/test_text.py b/sklearn/feature_extraction/tests/test_text.py index f584049282ac7..621268bed383a 100644 --- a/sklearn/feature_extraction/tests/test_text.py +++ b/sklearn/feature_extraction/tests/test_text.py @@ -27,6 +27,7 @@ from sklearn.model_selection import GridSearchCV, cross_val_score, train_test_split from sklearn.pipeline import Pipeline from sklearn.svm import LinearSVC +from sklearn.utils import _align_api_if_sparse from sklearn.utils._testing import ( assert_allclose_dense_sparse, assert_almost_equal, @@ -657,9 +658,9 @@ def test_hashing_vectorizer(): assert np.max(X.data) > 0 assert np.max(X.data) < 1 - # Check that the rows are normalized - for i in range(X.shape[0]): - assert_almost_equal(np.linalg.norm(X[0].data, 2), 1.0) + # Check that the rows are normalized (l2 norm) + for row in X: + assert_almost_equal(np.linalg.norm(row.data, 2), 1.0) # Check vectorization with some non-default parameters v = HashingVectorizer(ngram_range=(1, 2), norm="l1") @@ -676,9 +677,9 @@ def test_hashing_vectorizer(): assert np.min(X.data) > -1 assert np.max(X.data) < 1 - # Check that the rows are normalized - for i in range(X.shape[0]): - assert_almost_equal(np.linalg.norm(X[0].data, 1), 1.0) + # Check that the rows are normalized (l1 norm) + for row in X: + assert_almost_equal(np.linalg.norm(row.data, 1), 1.0) def test_feature_names(): @@ -1612,13 +1613,22 @@ def test_tfidf_transformer_copy(csr_container): assert X_transform is not X_csr X_transform = transformer.transform(X_csr, copy=False) - assert X_transform is X_csr + # allow for config["sparse_interface"] to change output type + # there should be no data copied, but the `id` will change. + if _align_api_if_sparse(X_csr) is X_csr: + assert X_transform is X_csr + else: + assert X_transform is not X_csr + assert X_transform.indptr is X_csr.indptr + assert X_transform.indices.base is X_csr.indices.base + assert X_transform.data.base is X_csr.data.base + with pytest.raises(AssertionError): assert_allclose_dense_sparse(X_csr, X_csr_original) @pytest.mark.parametrize("dtype", [np.float32, np.float64]) -def test_tfidf_vectorizer_perserve_dtype_idf(dtype): +def test_tfidf_vectorizer_preserve_dtype_idf(dtype): """Check that `idf_` has the same dtype as the input data. Non-regression test for: diff --git a/sklearn/feature_extraction/text.py b/sklearn/feature_extraction/text.py index b6da01063db1c..0c1d18b83dddd 100644 --- a/sklearn/feature_extraction/text.py +++ b/sklearn/feature_extraction/text.py @@ -28,7 +28,8 @@ from sklearn.preprocessing import normalize from sklearn.utils import metadata_routing from sklearn.utils._param_validation import HasMethods, Interval, RealNotInt, StrOptions -from sklearn.utils.fixes import _IS_32BIT +from sklearn.utils._sparse import _align_api_if_sparse +from sklearn.utils.fixes import _IS_32BIT, SCIPY_VERSION_BELOW_1_12 from sklearn.utils.validation import ( FLOAT_DTYPES, check_array, @@ -889,7 +890,7 @@ def transform(self, X): X.data.fill(1) if self.norm is not None: X = normalize(X, norm=self.norm, copy=False) - return X + return _align_api_if_sparse(X) def fit_transform(self, X, y=None): """Transform a sequence of documents to a document-term matrix. @@ -939,7 +940,7 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator): r"""Convert a collection of text documents to a matrix of token counts. This implementation produces a sparse representation of the counts using - scipy.sparse.csr_matrix. + scipy.sparse.csr_array. If you do not provide an a-priori dictionary and you do not use an analyzer that does some kind of feature selection then the number of features will @@ -1310,13 +1311,13 @@ def _count_vocab(self, raw_documents, fixed_vocab): indptr = np.asarray(indptr, dtype=indices_dtype) values = np.frombuffer(values, dtype=np.intc) - X = sp.csr_matrix( + X = sp.csr_array( (values, j_indices, indptr), shape=(len(indptr) - 1, len(vocabulary)), dtype=self.dtype, ) X.sort_indices() - return vocabulary, X + return vocabulary, _align_api_if_sparse(X) def fit(self, raw_documents, y=None): """Learn a vocabulary dictionary of all tokens in the raw documents. @@ -1403,7 +1404,7 @@ def fit_transform(self, raw_documents, y=None): X = self._sort_features(X, vocabulary) self.vocabulary_ = vocabulary - return X + return _align_api_if_sparse(X) def transform(self, raw_documents): """Transform documents to document-term matrix. @@ -1431,7 +1432,7 @@ def transform(self, raw_documents): _, X = self._count_vocab(raw_documents, fixed_vocab=True) if self.binary: X.data.fill(1) - return X + return _align_api_if_sparse(X) def inverse_transform(self, X): """Return terms per document with nonzero entries in X. @@ -1456,8 +1457,13 @@ def inverse_transform(self, X): inverse_vocabulary = terms[np.argsort(indices)] if sp.issparse(X): + if SCIPY_VERSION_BELOW_1_12: + return [ + inverse_vocabulary[X[[i], :].nonzero()[-1]].ravel() + for i in range(n_samples) + ] return [ - inverse_vocabulary[X[i, :].nonzero()[1]].ravel() + inverse_vocabulary[X[i, :].nonzero()[-1]].ravel() for i in range(n_samples) ] else: @@ -1665,7 +1671,7 @@ def fit(self, X, y=None): self, X, accept_sparse=("csr", "csc"), accept_large_sparse=not _IS_32BIT ) if not sp.issparse(X): - X = sp.csr_matrix(X) + X = sp.csr_array(X) dtype = X.dtype if X.dtype in (np.float64, np.float32) else np.float64 if self.use_idf: @@ -1716,7 +1722,7 @@ def transform(self, X, copy=True): reset=False, ) if not sp.issparse(X): - X = sp.csr_matrix(X, dtype=X.dtype) + X = sp.csr_array(X, dtype=X.dtype) if self.sublinear_tf: np.log(X.data, X.data) @@ -1730,7 +1736,7 @@ def transform(self, X, copy=True): if self.norm is not None: X = normalize(X, norm=self.norm, copy=False) - return X + return _align_api_if_sparse(X) def __sklearn_tags__(self): tags = super().__sklearn_tags__() diff --git a/sklearn/feature_selection/_base.py b/sklearn/feature_selection/_base.py index 3c12cd035d5c8..b001f84bb5c4b 100644 --- a/sklearn/feature_selection/_base.py +++ b/sklearn/feature_selection/_base.py @@ -8,15 +8,17 @@ from operator import attrgetter import numpy as np -from scipy.sparse import csc_matrix, issparse +import scipy.sparse +from scipy.sparse import csc_array, csr_array, issparse from sklearn.base import TransformerMixin from sklearn.utils import _safe_indexing, check_array, safe_sqr +from sklearn.utils._dataframe import is_pandas_df from sklearn.utils._set_output import _get_output_config +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils._tags import get_tags from sklearn.utils.validation import ( _check_feature_names_in, - _is_pandas_df, check_is_fitted, validate_data, ) @@ -24,7 +26,7 @@ class SelectorMixin(TransformerMixin, metaclass=ABCMeta): """ - Transformer mixin that performs feature selection given a support mask + Transformer mixin that performs feature selection given a support mask. This mixin provides a feature selector implementation with `transform` and `inverse_transform` functionality given an implementation of @@ -100,7 +102,7 @@ def transform(self, X): # Preserve X when X is a dataframe and the output is configured to # be pandas. output_config_dense = _get_output_config("transform", estimator=self)["dense"] - preserve_X = output_config_dense != "default" and _is_pandas_df(X) + preserve_X = output_config_dense != "default" and is_pandas_df(X) # note: we use get_tags instead of __sklearn_tags__ because this is a # public Mixin. @@ -153,12 +155,12 @@ def inverse_transform(self, X): it = self.inverse_transform(np.diff(X.indptr).reshape(1, -1)) col_nonzeros = it.ravel() indptr = np.concatenate([[0], np.cumsum(col_nonzeros)]) - Xt = csc_matrix( + Xt = csc_array( (X.data, X.indices, indptr), shape=(X.shape[0], len(indptr) - 1), dtype=X.dtype, ) - return Xt + return _align_api_if_sparse(Xt) support = self.get_support() X = check_array(X, dtype=None) @@ -245,13 +247,17 @@ def _get_feature_importances(estimator, getter, transform_func=None, norm_order= importances = getter(estimator) + if issparse(importances): + importances = _align_api_if_sparse(csr_array(importances)) + if transform_func is None: return importances elif transform_func == "norm": if importances.ndim == 1: importances = np.abs(importances) else: - importances = np.linalg.norm(importances, axis=0, ord=norm_order) + norm = scipy.sparse.linalg.norm if issparse(importances) else np.linalg.norm + importances = norm(importances, axis=0, ord=norm_order) elif transform_func == "square": if importances.ndim == 1: importances = safe_sqr(importances) diff --git a/sklearn/feature_selection/_rfe.py b/sklearn/feature_selection/_rfe.py index bbb735cda5f56..011dbc7b515f1 100644 --- a/sklearn/feature_selection/_rfe.py +++ b/sklearn/feature_selection/_rfe.py @@ -347,7 +347,7 @@ def _fit(self, X, y, step_score=None, **fit_params): self.importance_getter, transform_func="square", ) - ranks = np.argsort(importances) + ranks = np.argsort(importances, kind="stable") # for sparse case ranks is matrix ranks = np.ravel(ranks) @@ -597,9 +597,9 @@ class RFECV(RFE): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if ``y`` is binary or multiclass, :class:`~sklearn.model_selection.StratifiedKFold` is used. If the diff --git a/sklearn/feature_selection/_sequential.py b/sklearn/feature_selection/_sequential.py index fcfc01cac2037..3daad1e4fd42c 100644 --- a/sklearn/feature_selection/_sequential.py +++ b/sklearn/feature_selection/_sequential.py @@ -99,7 +99,7 @@ class SequentialFeatureSelector(SelectorMixin, MetaEstimatorMixin, BaseEstimator - None, to use the default 5-fold cross validation, - integer, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, diff --git a/sklearn/feature_selection/tests/test_from_model.py b/sklearn/feature_selection/tests/test_from_model.py index f1781f3f2f768..6c075c18aab39 100644 --- a/sklearn/feature_selection/tests/test_from_model.py +++ b/sklearn/feature_selection/tests/test_from_model.py @@ -670,3 +670,23 @@ def test_from_model_estimator_attribute_error(): from_model.fit(data, y).partial_fit(data) assert isinstance(exec_info.value.__cause__, AttributeError) assert inner_msg in str(exec_info.value.__cause__) + + +@pytest.mark.parametrize( + "feature_importance", + [ + lambda estimator: estimator.sparsify().coef_, + lambda estimator: estimator.sparsify().coef_.tocsc(), + ], +) +def test_feature_importance_sparse(feature_importance): + from_model_sparse = SelectFromModel( + estimator=LogisticRegression(), importance_getter=feature_importance + ) + from_model_dense = SelectFromModel(estimator=LogisticRegression()) + + from_model_sparse.fit(data, y) + from_model_dense.fit(data, y) + + assert_array_equal(from_model_sparse.get_support(), from_model_dense.get_support()) + assert_allclose(from_model_sparse.transform(data), from_model_dense.transform(data)) diff --git a/sklearn/feature_selection/tests/test_rfe.py b/sklearn/feature_selection/tests/test_rfe.py index 1f5672545874c..a559d0d79480e 100644 --- a/sklearn/feature_selection/tests/test_rfe.py +++ b/sklearn/feature_selection/tests/test_rfe.py @@ -665,7 +665,7 @@ def test_rfe_estimator_attribute_error(): ) def test_rfe_n_features_to_select_warning(ClsRFE, param): """Check if the correct warning is raised when trying to initialize a RFE - object with a n_features_to_select attribute larger than the number of + object with an n_features_to_select attribute larger than the number of features present in the X variable that is passed to the fit method """ X, y = make_classification(n_features=20, random_state=0) @@ -753,3 +753,26 @@ def test_results_per_cv_in_rfecv(global_random_seed): assert len(rfecv.cv_results_["split1_ranking"]) == len( rfecv.cv_results_["split2_ranking"] ) + + +@pytest.mark.parametrize( + "feature_importance", + [ + lambda estimator: estimator.sparsify().coef_, + lambda estimator: estimator.sparsify().coef_.tocsc(), + ], +) +def test_rfe_sparse_coef(feature_importance): + X = [[0, 1, 3], [1, 0, 0], [2, 0, 4], [0, 2, 4]] + y = [0, 1, 2, 3] + + estimator = LogisticRegression() + selector_sparse = RFE( + estimator, n_features_to_select=1, importance_getter=feature_importance + ) + selector_dense = RFE(estimator, n_features_to_select=1) + selector_sparse.fit(X, y) + selector_dense.fit(X, y) + + assert_array_equal(selector_sparse.support_, selector_dense.support_) + assert_array_equal(selector_sparse.ranking_, selector_dense.ranking_) diff --git a/sklearn/gaussian_process/_gpr.py b/sklearn/gaussian_process/_gpr.py index 40b0bd84aea30..d0f13187cc8fd 100644 --- a/sklearn/gaussian_process/_gpr.py +++ b/sklearn/gaussian_process/_gpr.py @@ -55,11 +55,13 @@ class GaussianProcessRegressor(MultiOutputMixin, RegressorMixin, BaseEstimator): Parameters ---------- kernel : kernel instance, default=None - The kernel specifying the covariance function of the GP. If None is - passed, the kernel ``ConstantKernel(1.0, constant_value_bounds="fixed") - * RBF(1.0, length_scale_bounds="fixed")`` is used as default. Note that - the kernel hyperparameters are optimized during fitting unless the - bounds are marked as "fixed". + The kernel specifying the covariance function of the GP. + If `None` is passed, + the kernel `ConstantKernel() * RBF()` is used as default. + Note that + the kernel hyperparameters are optimized during fitting + unless the bounds are marked as `"fixed"` + or the argument `optimizer` is set to `None`. alpha : float or ndarray of shape (n_samples,), default=1e-10 Value added to the diagonal of the kernel matrix during fitting. @@ -244,12 +246,7 @@ def fit(self, X, y): self : object GaussianProcessRegressor class instance. """ - if self.kernel is None: # Use an RBF kernel as default - self.kernel_ = C(1.0, constant_value_bounds="fixed") * RBF( - 1.0, length_scale_bounds="fixed" - ) - else: - self.kernel_ = clone(self.kernel) + self.kernel_ = C() * RBF() if self.kernel is None else clone(self.kernel) self._rng = check_random_state(self.random_state) @@ -643,7 +640,7 @@ def log_marginal_likelihood( # it is equivalent to: # for param_idx in range(n_kernel_params): # for output_idx in range(n_output): - # log_likehood_gradient_dims[param_idx, output_idx] = ( + # log_likelihood_gradient_dims[param_idx, output_idx] = ( # inner_term[..., output_idx] @ # K_gradient[..., param_idx] # ) diff --git a/sklearn/gaussian_process/kernels.py b/sklearn/gaussian_process/kernels.py index 8b4a16cb76adf..01cbac22a3713 100644 --- a/sklearn/gaussian_process/kernels.py +++ b/sklearn/gaussian_process/kernels.py @@ -21,11 +21,12 @@ # Note: this module is strongly inspired by the kernel module of the george # package. +import inspect import math import warnings from abc import ABCMeta, abstractmethod from collections import namedtuple -from inspect import signature +from functools import lru_cache import numpy as np from scipy.spatial.distance import cdist, pdist, squareform @@ -36,6 +37,11 @@ from sklearn.metrics.pairwise import pairwise_kernels from sklearn.utils.validation import _num_samples +# Cache constructor signature inspection for kernels as it empirically +# proves to account for 15% or more of the total grid-search time of GP +# model on small to medium data. +signature = lru_cache(maxsize=32)(inspect.signature) + def _check_length_scale(X, length_scale): length_scale = np.squeeze(length_scale).astype(float) @@ -194,8 +200,7 @@ def get_params(self, deep=True): # introspect the constructor arguments to find the model parameters # to represent cls = self.__class__ - init = getattr(cls.__init__, "deprecated_original", cls.__init__) - init_sign = signature(init) + init_sign = signature(cls.__init__) args, varargs = [], [] for parameter in init_sign.parameters.values(): if parameter.kind != parameter.VAR_KEYWORD and parameter.name != "self": diff --git a/sklearn/gaussian_process/tests/test_gpc.py b/sklearn/gaussian_process/tests/test_gpc.py index 365b8f5a11441..3f01c32042590 100644 --- a/sklearn/gaussian_process/tests/test_gpc.py +++ b/sklearn/gaussian_process/tests/test_gpc.py @@ -9,6 +9,7 @@ import pytest from scipy.optimize import approx_fprime +from sklearn.base import clone from sklearn.exceptions import ConvergenceWarning from sklearn.gaussian_process import GaussianProcessClassifier from sklearn.gaussian_process.kernels import ( @@ -20,7 +21,11 @@ ConstantKernel as C, ) from sklearn.gaussian_process.tests._mini_sequence_kernel import MiniSeqKernel -from sklearn.utils._testing import assert_almost_equal, assert_array_equal +from sklearn.utils._testing import ( + assert_allclose, + assert_almost_equal, + assert_array_equal, +) def f(x): @@ -105,17 +110,31 @@ def test_converged_to_local_maximum(kernel): ) -@pytest.mark.parametrize("kernel", kernels) -def test_lml_gradient(kernel): - # Compare analytic and numeric gradient of log marginal likelihood. - gpc = GaussianProcessClassifier(kernel=kernel).fit(X, y) +@pytest.mark.xfail( + raises=AssertionError, + reason="https://github.com/scikit-learn/scikit-learn/issues/31366", +) +@pytest.mark.parametrize("kernel", non_fixed_kernels) +@pytest.mark.parametrize("length_scale", np.logspace(-3, 3, 13)) +def test_lml_gradient(kernel, length_scale): + # Clone the kernel object prior to mutating it to avoid any side effects between + # GP tests: + kernel = clone(kernel) + length_scale_param_name = next( + name for name in kernel.get_params() if name.endswith("length_scale") + ) + kernel.set_params(**{length_scale_param_name: length_scale}) - lml, lml_gradient = gpc.log_marginal_likelihood(kernel.theta, True) + # Compare analytic and numeric gradient of log marginal likelihood. + gpr = GaussianProcessClassifier(kernel=kernel).fit(X, y) + _, lml_gradient = gpr.log_marginal_likelihood(kernel.theta, eval_gradient=True) + epsilon = 1e-9 lml_gradient_approx = approx_fprime( - kernel.theta, lambda theta: gpc.log_marginal_likelihood(theta, False), 1e-10 + kernel.theta.copy(), + lambda theta: gpr.log_marginal_likelihood(theta, False), + epsilon=epsilon, ) - - assert_almost_equal(lml_gradient, lml_gradient_approx, 3) + assert_allclose(lml_gradient, lml_gradient_approx, rtol=1e-4, atol=epsilon * 100) def test_random_starts(global_random_seed): diff --git a/sklearn/gaussian_process/tests/test_gpr.py b/sklearn/gaussian_process/tests/test_gpr.py index 3c841c479a8bd..a1f920c0d5bb4 100644 --- a/sklearn/gaussian_process/tests/test_gpr.py +++ b/sklearn/gaussian_process/tests/test_gpr.py @@ -11,6 +11,7 @@ import pytest from scipy.optimize import approx_fprime +from sklearn.base import clone from sklearn.exceptions import ConvergenceWarning from sklearn.gaussian_process import GaussianProcessRegressor from sklearn.gaussian_process.kernels import ( @@ -140,17 +141,31 @@ def test_solution_inside_bounds(kernel): assert_array_less(gpr.kernel_.theta, bounds[:, 1] + tiny) -@pytest.mark.parametrize("kernel", kernels) -def test_lml_gradient(kernel): +@pytest.mark.xfail( + raises=AssertionError, + reason="https://github.com/scikit-learn/scikit-learn/issues/31366", +) +@pytest.mark.parametrize("kernel", non_fixed_kernels) +@pytest.mark.parametrize("length_scale", np.logspace(-3, 3, 13)) +def test_lml_gradient(kernel, length_scale): + # Clone the kernel object prior to mutating it to avoid any side effects between + # GP tests: + kernel = clone(kernel) + length_scale_param_name = next( + name for name in kernel.get_params() if name.endswith("length_scale") + ) + kernel.set_params(**{length_scale_param_name: length_scale}) + # Compare analytic and numeric gradient of log marginal likelihood. gpr = GaussianProcessRegressor(kernel=kernel).fit(X, y) - - lml, lml_gradient = gpr.log_marginal_likelihood(kernel.theta, True) + _, lml_gradient = gpr.log_marginal_likelihood(kernel.theta, eval_gradient=True) + epsilon = 1e-9 lml_gradient_approx = approx_fprime( - kernel.theta, lambda theta: gpr.log_marginal_likelihood(theta, False), 1e-10 + kernel.theta.copy(), + lambda theta: gpr.log_marginal_likelihood(theta, False), + epsilon=epsilon, ) - - assert_almost_equal(lml_gradient, lml_gradient_approx, 3) + assert_allclose(lml_gradient, lml_gradient_approx, rtol=1e-4, atol=epsilon * 100) @pytest.mark.parametrize("kernel", kernels) @@ -186,11 +201,13 @@ def test_sample_statistics(kernel): ) -def test_no_optimizer(): - # Test that kernel parameters are unmodified when optimizer is None. - kernel = RBF(1.0) - gpr = GaussianProcessRegressor(kernel=kernel, optimizer=None).fit(X, y) - assert np.exp(gpr.kernel_.theta) == 1.0 +@pytest.mark.parametrize("optimizer", [None, "fmin_l_bfgs_b"]) +@pytest.mark.parametrize("kernel", [None, RBF()]) +def test_no_optimizer(optimizer, kernel): + """Test that kernel parameters are unmodified when optimizer is None.""" + gpr = GaussianProcessRegressor(kernel=kernel, optimizer=optimizer) + gpr.fit(X, y) + assert bool((gpr.kernel_.theta == 0.0).all()) is (optimizer is None) @pytest.mark.parametrize("kernel", kernels) @@ -289,7 +306,7 @@ def test_y_normalization(kernel): def test_large_variance_y(): """ - Here we test that, when noramlize_y=True, our GP can produce a + Here we test that, when normalize_y=True, our GP can produce a sensible fit to training data whose variance is significantly larger than unity. This test was made in response to issue #15612. diff --git a/sklearn/impute/_base.py b/sklearn/impute/_base.py index c1c480de1f387..ad9fcce8e621c 100644 --- a/sklearn/impute/_base.py +++ b/sklearn/impute/_base.py @@ -15,7 +15,8 @@ from sklearn.utils._mask import _get_mask from sklearn.utils._missing import is_pandas_na, is_scalar_nan from sklearn.utils._param_validation import MissingValues, StrOptions -from sklearn.utils.fixes import _mode +from sklearn.utils._sparse import _align_api_if_sparse +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_12, _mode from sklearn.utils.sparsefuncs import _get_median from sklearn.utils.validation import ( FLOAT_DTYPES, @@ -152,7 +153,7 @@ def _concatenate_indicator(self, X_imputed, X_indicator): "implementation." ) - return hstack((X_imputed, X_indicator)) + return _align_api_if_sparse(hstack((X_imputed, X_indicator))) def _concatenate_indicator_feature_names_out(self, names, input_features): if not self.add_indicator: @@ -483,9 +484,14 @@ def _sparse_fit(self, X, strategy, missing_values, fill_value): statistics.fill(fill_value) if not self.keep_empty_features: - for i in range(missing_mask.shape[1]): - if all(missing_mask[:, i].data): - statistics[i] = np.nan + if SCIPY_VERSION_BELOW_1_12: + for i in range(missing_mask.shape[1]): + if all(missing_mask[:, [i]].data): + statistics[i] = np.nan + else: + for i in range(missing_mask.shape[1]): + if all(missing_mask[:, i].data): + statistics[i] = np.nan else: for i in range(X.shape[1]): @@ -574,7 +580,7 @@ def _dense_fit(self, X, strategy, missing_values, fill_value): # Constant elif strategy == "constant": - # for constant strategy, self.statistcs_ is used to store + # for constant strategy, self.statistics_ is used to store # fill_value in each column, or np.nan for columns to drop statistics = np.full(X.shape[1], fill_value, dtype=np.object_) @@ -929,7 +935,7 @@ def _get_missing_features_info(self, X): n_missing = imputer_mask.sum(axis=0) if self.sparse is True: - imputer_mask = sp.csc_matrix(imputer_mask) + imputer_mask = _align_api_if_sparse(sp.csc_array(imputer_mask)) if self.features == "all": features_indices = np.arange(X.shape[1]) diff --git a/sklearn/impute/tests/test_base.py b/sklearn/impute/tests/test_base.py index 0c1bd83f7ca9e..35ed59db89028 100644 --- a/sklearn/impute/tests/test_base.py +++ b/sklearn/impute/tests/test_base.py @@ -90,7 +90,7 @@ def test_base_no_precomputed_mask_transform(data): imputer.fit_transform(data) -@pytest.mark.parametrize("X1_type", ["array", "dataframe"]) +@pytest.mark.parametrize("X1_type", ["array", "pandas"]) def test_assign_where(X1_type): """Check the behaviour of the private helpers `_assign_where`.""" rng = np.random.RandomState(0) @@ -102,6 +102,6 @@ def test_assign_where(X1_type): _assign_where(X1, X2, mask) - if X1_type == "dataframe": + if X1_type == "pandas": X1 = X1.to_numpy() assert_allclose(X1[mask], X2[mask]) diff --git a/sklearn/impute/tests/test_impute.py b/sklearn/impute/tests/test_impute.py index 013fd7eb8a810..09a07e1a3c832 100644 --- a/sklearn/impute/tests/test_impute.py +++ b/sklearn/impute/tests/test_impute.py @@ -34,6 +34,7 @@ CSC_CONTAINERS, CSR_CONTAINERS, LIL_CONTAINERS, + SCIPY_VERSION_BELOW_1_12, ) @@ -285,12 +286,12 @@ def test_imputation_mean_median_error_invalid_type(strategy, dtype): @pytest.mark.parametrize("strategy", ["mean", "median"]) -@pytest.mark.parametrize("type", ["list", "dataframe"]) -def test_imputation_mean_median_error_invalid_type_list_pandas(strategy, type): +@pytest.mark.parametrize("constructor_name", ["list", "pandas"]) +def test_imputation_mean_median_error_invalid_type_list_pandas( + strategy, constructor_name +): X = [["a", "b", 3], [4, "e", 6], ["g", "h", 9]] - if type == "dataframe": - pd = pytest.importorskip("pandas") - X = pd.DataFrame(X) + X = _convert_container(X, constructor_name) msg = "non-numeric data:\ncould not convert string to float:" with pytest.raises(ValueError, match=msg): imputer = SimpleImputer(strategy=strategy) @@ -1789,9 +1790,11 @@ def test_simple_imputer_keep_empty_features(strategy, array_type, keep_empty_fea X_imputed = getattr(imputer, method)(X) if keep_empty_features: assert X_imputed.shape == X.shape - constant_feature = ( - X_imputed[:, 0].toarray() if array_type == "sparse" else X_imputed[:, 0] - ) + if SCIPY_VERSION_BELOW_1_12 and array_type == "sparse": + constant_feature = X_imputed[:, [0]].toarray() + else: + col0 = X_imputed[:, 0] + constant_feature = col0.toarray() if array_type == "sparse" else col0 assert_array_equal(constant_feature, 0) else: assert X_imputed.shape == (X.shape[0], X.shape[1] - 1) diff --git a/sklearn/inspection/_partial_dependence.py b/sklearn/inspection/_partial_dependence.py index 4111f153c74e1..98786550425bd 100644 --- a/sklearn/inspection/_partial_dependence.py +++ b/sklearn/inspection/_partial_dependence.py @@ -3,7 +3,6 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -import warnings from collections.abc import Iterable import numpy as np @@ -653,7 +652,7 @@ def partial_dependence( if response_method != "decision_function": raise ValueError( "With the 'recursion' method, the response_method must be " - "'decision_function'. Got {}.".format(response_method) + f"'decision_function'. Got {response_method}." ) if sample_weight is not None: @@ -664,7 +663,7 @@ def partial_dependence( # the indexing to be positive. The upper bound will be checked # by _get_column_indices() if np.any(np.less(features, 0)): - raise ValueError("all features must be in [0, {}]".format(X.shape[1] - 1)) + raise ValueError(f"all features must be in [0, {X.shape[1] - 1}]") features_indices = np.asarray( _get_column_indices(X, features), dtype=np.intp, order="C" @@ -717,18 +716,13 @@ def partial_dependence( continue if _safe_indexing(X, feature_idx, axis=1).dtype.kind in "iu": - # TODO(1.9): raise a ValueError instead. - warnings.warn( + raise ValueError( f"The column {feature!r} contains integer data. Partial " "dependence plots are not supported for integer data: this " "can lead to implicit rounding with NumPy arrays or even errors " - "with newer pandas versions. Please convert numerical features" - "to floating point dtypes ahead of time to avoid problems. " - "This will raise ValueError in scikit-learn 1.9.", - FutureWarning, + "with newer pandas versions. Please convert numerical features " + "to floating point dtypes ahead of time to avoid problems." ) - # Do not warn again for other features to avoid spamming the caller. - break X_subset = _safe_indexing(X, features_indices, axis=1) diff --git a/sklearn/inspection/_plot/decision_boundary.py b/sklearn/inspection/_plot/decision_boundary.py index 22292053f7867..c44ec72ae1a2c 100644 --- a/sklearn/inspection/_plot/decision_boundary.py +++ b/sklearn/inspection/_plot/decision_boundary.py @@ -5,22 +5,23 @@ import numpy as np -from sklearn.base import is_regressor +from sklearn.base import is_classifier, is_clusterer, is_outlier_detector, is_regressor from sklearn.preprocessing import LabelEncoder from sklearn.utils import _safe_indexing +from sklearn.utils._dataframe import is_pandas_df, is_polars_df from sklearn.utils._optional_dependencies import check_matplotlib_support from sklearn.utils._response import _get_response_values from sklearn.utils._set_output import _get_adapter_from_container +from sklearn.utils.fixes import PETROFF_COLORS +from sklearn.utils.multiclass import type_of_target from sklearn.utils.validation import ( _is_arraylike_not_scalar, - _is_pandas_df, - _is_polars_df, _num_features, check_is_fitted, ) -def _check_boundary_response_method(estimator, response_method, class_of_interest): +def _check_boundary_response_method(estimator, response_method): """Validate the response methods to be used with the fitted estimator. Parameters @@ -33,12 +34,6 @@ def _check_boundary_response_method(estimator, response_method, class_of_interes :term:`predict` as the target response. If set to 'auto', the response method is tried in the before mentioned order. - class_of_interest : int, float, bool, str or None - The class considered when plotting the decision. Cannot be None if - multiclass and `response_method` is 'predict_proba' or 'decision_function'. - - .. versionadded:: 1.4 - Returns ------- prediction_method : list of str or str @@ -60,6 +55,79 @@ def _check_boundary_response_method(estimator, response_method, class_of_interes return prediction_method +def _select_colors(mpl, multiclass_colors, n_classes): + """Select colors for multiclass decision boundary display. + + Parameters + ---------- + mpl : module + Imported `matplotlib` module. + + multiclass_colors : str or list of matplotlib colors, default=None + The colormap or colors to select. + + Possible inputs are: + + * None: defaults to list of accessible `Petroff colors + <https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352>`_ + if `n_classes <= 10`, otherwise 'gist_rainbow' colormap + * str: name of :class:`matplotlib.colors.Colormap` + * list: list of length `n_classes` of `matplotlib colors + <https://matplotlib.org/stable/users/explain/colors/colors.html#colors-def>`_ + + n_classes : int + Number of colors to select. + + Returns + ------- + colors : ndarray of shape (n_classes, 4) + RGBA colors, one per class. + + """ + + if multiclass_colors is None: + # select accessible colors according to Matthew A. Petroff, see + # https://arxiv.org/abs/2107.02270 and + # https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352 + if n_classes <= 10: + multiclass_colors = PETROFF_COLORS[:n_classes] + else: + multiclass_colors = "gist_rainbow" + + if isinstance(multiclass_colors, str): + if multiclass_colors not in mpl.pyplot.colormaps(): + raise ValueError( + "When 'multiclass_colors' is a string, it must be a valid " + f"Matplotlib colormap. Got: {multiclass_colors}" + ) + cmap = mpl.pyplot.get_cmap(multiclass_colors) + if cmap.N < n_classes: + raise ValueError( + f"Colormap '{multiclass_colors}' only has {cmap.N} colors, but " + f"{n_classes} classes are to be displayed. Please specify a " + "different colormap or provide a list of colors via " + "'multiclass_colors'." + ) + return cmap(np.linspace(0, 1, n_classes)) + + elif isinstance(multiclass_colors, list): + if len(multiclass_colors) != n_classes: + raise ValueError( + "When 'multiclass_colors' is a list, it must be of the same " + f"length as the classes or labels to plot ({n_classes}), got: " + f"{len(multiclass_colors)}." + ) + elif any(not mpl.colors.is_color_like(col) for col in multiclass_colors): + raise ValueError( + "When 'multiclass_colors' is a list, it can only contain valid" + f" Matplotlib color names. Got: {multiclass_colors}" + ) + return mpl.colors.to_rgba_array(multiclass_colors) + + else: + raise TypeError("'multiclass_colors' must be a list or a str.") + + class DecisionBoundaryDisplay: """Decisions boundary visualization. @@ -84,27 +152,56 @@ class DecisionBoundaryDisplay: xx1 : ndarray of shape (grid_resolution, grid_resolution) Second output of :func:`meshgrid <numpy.meshgrid>`. + n_classes : int + Expected number of unique classes or labels if `response` was generated by a + :term:`classifier` or a :term:`clusterer`. + + For :term:`outlier detectors`, `n_classes` should be set to 2 by definition + (inlier or outlier). + + For :term:`regressors`, `n_classes` should also be set to 2 by convention + (continuous responses are displayed the same way as unthresholded binary + responses). + + .. versionadded:: 1.9 + response : ndarray of shape (grid_resolution, grid_resolution) or \ (grid_resolution, grid_resolution, n_classes) Values of the response function. - multiclass_colors : list of str or str, default=None - Specifies how to color each class when plotting all classes of multiclass - problem. Ignored for binary problems and multiclass problems when plotting a - single prediction value per point. + multiclass_colors : str or list of matplotlib colors, default=None + Specifies how to color each class when plotting all classes of + :term:`multiclass` problems. + Possible inputs are: - * list: list of Matplotlib - `color <https://matplotlib.org/stable/users/explain/colors/colors.html#colors-def>`_ - strings, of length `n_classes` + * None: defaults to list of accessible `Petroff colors + <https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352>`_ + if `n_classes <= 10`, otherwise 'gist_rainbow' colormap * str: name of :class:`matplotlib.colors.Colormap` - * None: 'viridis' colormap is used to sample colors + * list: list of length `n_classes` of `matplotlib colors + <https://matplotlib.org/stable/users/explain/colors/colors.html#colors-def>`_ + + Single color (fading to white) colormaps will be generated from the colors in + the list or colors taken from the colormap, and passed to the `cmap` parameter + of the `plot_method`. + + When `response_method='predict'` and `plot_method='contour'`, + `multiclass_colors` is ignored and the class boundaries are plotted in black + instead as the boundary lines may overlap and the colors don't necessarily + correspond to the classes. - Single color colormaps will be generated from the colors in the list or - colors taken from the colormap and passed to the `cmap` parameter of - the `plot_method`. + For :term:`binary` problems, `multiclass_colors` is also ignored and `cmap` or + `colors` can be passed as kwargs instead, otherwise, the default colormap + ('viridis') is used. .. versionadded:: 1.7 + .. versionchanged:: 1.9 + `multiclass_colors` is now also used when `response_method="predict"`, + except for when `plot_method='contour'`, where it is ignored and "black" is + used instead. + The default colors changed from 'tab10' to the more accessible `Petroff + colors <https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352>`_. xlabel : str, default=None Default label to place on x axis. @@ -122,7 +219,7 @@ class DecisionBoundaryDisplay: multiclass_colors_ : array of shape (n_classes, 4) Colors used to plot each class in multiclass problems. - Only defined when `color_of_interest` is None. + Only defined when `n_classes` > 2. .. versionadded:: 1.7 @@ -139,35 +236,61 @@ class DecisionBoundaryDisplay: Examples -------- >>> import matplotlib.pyplot as plt + >>> import matplotlib as mpl >>> import numpy as np - >>> from sklearn.datasets import load_iris + >>> from sklearn.linear_model import LogisticRegression >>> from sklearn.inspection import DecisionBoundaryDisplay - >>> from sklearn.tree import DecisionTreeClassifier - >>> iris = load_iris() - >>> feature_1, feature_2 = np.meshgrid( - ... np.linspace(iris.data[:, 0].min(), iris.data[:, 0].max()), - ... np.linspace(iris.data[:, 1].min(), iris.data[:, 1].max()) + >>> data = np.array([[0, 0], [1, 1], [2, 1], [2, 2], [3, 2], [3, 3]]) + >>> target = np.arange(data.shape[0]) + >>> clf = LogisticRegression().fit(data, target) + >>> plot_methods = ["contourf", "contour", "pcolormesh"] + >>> response_methods = ["predict_proba", "decision_function", "predict"] + >>> _, axes = plt.subplots( + ... nrows=3, + ... ncols=3, + ... figsize=(12, 12), + ... constrained_layout=True ... ) - >>> grid = np.vstack([feature_1.ravel(), feature_2.ravel()]).T - >>> tree = DecisionTreeClassifier().fit(iris.data[:, :2], iris.target) - >>> y_pred = np.reshape(tree.predict(grid), feature_1.shape) - >>> display = DecisionBoundaryDisplay( - ... xx0=feature_1, xx1=feature_2, response=y_pred - ... ) - >>> display.plot() - <...> - >>> display.ax_.scatter( - ... iris.data[:, 0], iris.data[:, 1], c=iris.target, edgecolor="black" - ... ) - <...> + >>> for plot_method_idx, plot_method in enumerate(plot_methods): + ... for response_method_idx, response_method in enumerate(response_methods): + ... ax = axes[plot_method_idx, response_method_idx] + ... display = DecisionBoundaryDisplay.from_estimator( + ... clf, + ... data, + ... grid_resolution=300, + ... response_method=response_method, + ... plot_method=plot_method, + ... ax=ax, + ... alpha=0.5, + ... ) + ... cmap = mpl.colors.ListedColormap(display.multiclass_colors_) + ... ax.scatter( + ... data[:, 0], + ... data[:, 1], + ... c=target.astype(int), + ... edgecolors="black", + ... cmap=cmap, + ... ) + ... ax.set_title( + ... f"plot_method={plot_method}\\nresponse_method={response_method}" + ... ) >>> plt.show() """ def __init__( - self, *, xx0, xx1, response, multiclass_colors=None, xlabel=None, ylabel=None + self, + *, + xx0, + xx1, + n_classes, + response, + multiclass_colors=None, + xlabel=None, + ylabel=None, ): self.xx0 = xx0 self.xx1 = xx1 + self.n_classes = n_classes self.response = response self.multiclass_colors = multiclass_colors self.xlabel = xlabel @@ -196,12 +319,56 @@ def plot(self, plot_method="contourf", ax=None, xlabel=None, ylabel=None, **kwar Overwrite the y-axis label. **kwargs : dict - Additional keyword arguments to be passed to the `plot_method`. + Additional keyword arguments to be passed to the `plot_method`. For + :term:`binary` problems, `cmap` or `colors` can be set here to specify the + colormap or colors, otherwise the default colormap ('viridis') is used. If + not specified by the user, `zorder` is set to -1 to ensure that the decision + boundary is plotted in the background (in case a scatter plot is added on + top). Returns ------- display: :class:`~sklearn.inspection.DecisionBoundaryDisplay` Object that stores computed values. + + See Also + -------- + DecisionBoundaryDisplay.from_estimator : Plot decision boundary given an + estimator. + + Examples + -------- + >>> import matplotlib as mpl + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from sklearn.datasets import load_iris + >>> from sklearn.inspection import DecisionBoundaryDisplay + >>> from sklearn.tree import DecisionTreeClassifier + >>> iris = load_iris() + >>> feature_1, feature_2 = np.meshgrid( + ... np.linspace(iris.data[:, 0].min(), iris.data[:, 0].max()), + ... np.linspace(iris.data[:, 1].min(), iris.data[:, 1].max()) + ... ) + >>> grid = np.vstack([feature_1.ravel(), feature_2.ravel()]).T + >>> tree = DecisionTreeClassifier().fit(iris.data[:, :2], iris.target) + >>> y_pred = np.reshape(tree.predict(grid), feature_1.shape) + >>> display = DecisionBoundaryDisplay( + ... xx0=feature_1, + ... xx1=feature_2, + ... n_classes=len(tree.classes_), + ... response=y_pred + ... ) + >>> display.plot() + <...> + >>> display.ax_.scatter( + ... iris.data[:, 0], + ... iris.data[:, 1], + ... c=iris.target, + ... cmap=mpl.colors.ListedColormap(display.multiclass_colors_), + ... edgecolor="black" + ... ) + <...> + >>> plt.show() """ check_matplotlib_support("DecisionBoundaryDisplay.plot") import matplotlib as mpl @@ -217,71 +384,78 @@ def plot(self, plot_method="contourf", ax=None, xlabel=None, ylabel=None, **kwar _, ax = plt.subplots() plot_func = getattr(ax, plot_method) - if self.response.ndim == 2: + if self.n_classes == 2: self.surface_ = plot_func(self.xx0, self.xx1, self.response, **kwargs) - else: # self.response.ndim == 3 - n_responses = self.response.shape[-1] + else: # multiclass for kwarg in ("cmap", "colors"): if kwarg in kwargs: warnings.warn( f"'{kwarg}' is ignored in favor of 'multiclass_colors' " - "in the multiclass case when the response method is " - "'decision_function' or 'predict_proba'." + "in the multiclass case." ) del kwargs[kwarg] - if self.multiclass_colors is None or isinstance( - self.multiclass_colors, str - ): - if self.multiclass_colors is None: - cmap = "tab10" if n_responses <= 10 else "gist_rainbow" - else: - cmap = self.multiclass_colors - - # Special case for the tab10 and tab20 colormaps that encode a - # discrete set of colors that are easily distinguishable - # contrary to other colormaps that are continuous. - if cmap == "tab10" and n_responses <= 10: - colors = plt.get_cmap("tab10", 10).colors[:n_responses] - elif cmap == "tab20" and n_responses <= 20: - colors = plt.get_cmap("tab20", 20).colors[:n_responses] - else: - cmap = plt.get_cmap(cmap, n_responses) - if not hasattr(cmap, "colors"): - # For LinearSegmentedColormap - colors = cmap(np.linspace(0, 1, n_responses)) - else: - colors = cmap.colors - elif isinstance(self.multiclass_colors, list): - colors = [mpl.colors.to_rgba(color) for color in self.multiclass_colors] - else: - raise ValueError("'multiclass_colors' must be a list or a str.") - - self.multiclass_colors_ = colors - if plot_method == "contour": - # Plot only argmax map for contour - class_map = self.response.argmax(axis=2) - self.surface_ = plot_func( - self.xx0, self.xx1, class_map, colors=colors, **kwargs - ) - else: + self.multiclass_colors_ = _select_colors( + mpl, self.multiclass_colors, self.n_classes + ) + + # If not set by the user, set default values for `zorder` to ensure that the + # decision boundary is plotted in the background (in case a scatter plot is + # added on top) + if "zorder" not in kwargs: + kwargs["zorder"] = -1 + + if self.response.ndim == 3: # predict_proba and decision_function multiclass_cmaps = [ mpl.colors.LinearSegmentedColormap.from_list( - f"colormap_{class_idx}", [(1.0, 1.0, 1.0, 1.0), (r, g, b, 1.0)] + f"colormap_{class_idx}", + [(1.0, 1.0, 1.0, 1.0), (r, g, b, 1.0)], ) - for class_idx, (r, g, b, _) in enumerate(colors) + for class_idx, (r, g, b, _) in enumerate(self.multiclass_colors_) ] - self.surface_ = [] for class_idx, cmap in enumerate(multiclass_cmaps): response = np.ma.array( self.response[:, :, class_idx], - mask=~(self.response.argmax(axis=2) == class_idx), + mask=(self.response.argmax(axis=2) != class_idx), ) self.surface_.append( plot_func(self.xx0, self.xx1, response, cmap=cmap, **kwargs) ) + if plot_method == "contour": + # Additionally plot the decision boundaries between classes. + self.surface_.append( + plot_func( + self.xx0, + self.xx1, + self.response.argmax(axis=2), + colors="black", + zorder=-1, + # set levels to ensure all boundaries are plotted correctly + levels=np.arange(self.n_classes), + ) + ) + + elif self.response.ndim == 2: # predict + # Set `levels` to ensure all class boundaries are displayed. + if "levels" not in kwargs: + if plot_method == "contour": + kwargs["levels"] = np.arange(self.n_classes) + elif plot_method == "contourf": + kwargs["levels"] = np.arange(self.n_classes + 1) - 0.5 + + if plot_method == "contour": + self.surface_ = plot_func( + self.xx0, self.xx1, self.response, colors="black", **kwargs + ) + else: + # `pcolormesh` requires cmap, for `contourf` it makes no difference + cmap = mpl.colors.ListedColormap(self.multiclass_colors_) + self.surface_ = plot_func( + self.xx0, self.xx1, self.response, cmap=cmap, **kwargs + ) + if xlabel is not None or not ax.get_xlabel(): xlabel = self.xlabel if xlabel is None else xlabel ax.set_xlabel(xlabel) @@ -349,36 +523,49 @@ def from_estimator( For multiclass problems, 'auto' no longer defaults to 'predict'. class_of_interest : int, float, bool or str, default=None - The class to be plotted when `response_method` is 'predict_proba' - or 'decision_function'. If None, `estimator.classes_[1]` is considered - the positive class for binary classifiers. For multiclass - classifiers, if None, all classes will be represented in the - decision boundary plot; the class with the highest response value + The class to be plotted. For :term:`binary` classifiers, if None, + `estimator.classes_[1]` is considered the positive class. For + :term:`multiclass` classifiers, if None, all classes will be represented in + the decision boundary plot; when `response_method` is :term:`predict_proba` + or :term:`decision_function`, the class with the highest response value at each point is plotted. The color of each class can be set via `multiclass_colors`. .. versionadded:: 1.4 - multiclass_colors : list of str, or str, default=None - Specifies how to color each class when plotting multiclass - 'predict_proba' or 'decision_function' and `class_of_interest` is - None. Ignored in all other cases. + multiclass_colors : str or list of matplotlib colors, default=None + Specifies how to color each class when plotting :term:`multiclass` problems + and `class_of_interest` is None. Possible inputs are: - * list: list of Matplotlib - `color <https://matplotlib.org/stable/users/explain/colors/colors.html#colors-def>`_ - strings, of length `n_classes` + * None: defaults to list of accessible `Petroff colors + <https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352>`_ + if `n_classes <= 10`, otherwise 'gist_rainbow' colormap * str: name of :class:`matplotlib.colors.Colormap` - * None: 'tab10' colormap is used to sample colors if the number of - classes is less than or equal to 10, otherwise 'gist_rainbow' - colormap. + * list: list of length `n_classes` of `matplotlib colors + <https://matplotlib.org/stable/users/explain/colors/colors.html#colors-def>`_ + + Single color (fading to white) colormaps will be generated from the colors + in the list or colors taken from the colormap, and passed to the `cmap` + parameter of the `plot_method`. - Single color colormaps will be generated from the colors in the list or - colors taken from the colormap, and passed to the `cmap` parameter of - the `plot_method`. + When `response_method='predict'` and `plot_method='contour'`, + `multiclass_colors` is ignored and the class boundaries are plotted in black + instead as the boundary lines may overlap and the colors don't necessarily + correspond to the classes. + + For :term:`binary` problems, `multiclass_colors` is also ignored and `cmap` + or `colors` can be passed as kwargs instead, otherwise, the default colormap + ('viridis') is used. .. versionadded:: 1.7 + .. versionchanged:: 1.9 + `multiclass_colors` is now also used when `response_method="predict"`, + except for when `plot_method='contour'`, where it is ignored and "black" + is used instead. + The default colors changed from 'tab10' to the more accessible `Petroff + colors <https://github.com/matplotlib/matplotlib/issues/9460#issuecomment-875185352>`_. xlabel : str, default=None The label used for the x-axis. If `None`, an attempt is made to @@ -395,8 +582,7 @@ def from_estimator( created. **kwargs : dict - Additional keyword arguments to be passed to the - `plot_method`. + Additional keyword arguments to be passed to the `plot_method`. Returns ------- @@ -413,6 +599,7 @@ def from_estimator( Examples -------- + >>> import matplotlib as mpl >>> import matplotlib.pyplot as plt >>> from sklearn.datasets import load_iris >>> from sklearn.linear_model import LogisticRegression @@ -425,13 +612,12 @@ def from_estimator( ... xlabel=iris.feature_names[0], ylabel=iris.feature_names[1], ... alpha=0.5, ... ) - >>> disp.ax_.scatter(X[:, 0], X[:, 1], c=iris.target, edgecolor="k") + >>> cmap = mpl.colors.ListedColormap(disp.multiclass_colors_) + >>> disp.ax_.scatter(X[:, 0], X[:, 1], c=iris.target, edgecolor="k", cmap=cmap) <...> >>> plt.show() """ - check_matplotlib_support(f"{cls.__name__}.from_estimator") check_is_fitted(estimator) - import matplotlib as mpl if not grid_resolution > 1: raise ValueError( @@ -458,33 +644,6 @@ def from_estimator( f"n_features must be equal to 2. Got {num_features} instead." ) - if ( - response_method in ("predict_proba", "decision_function", "auto") - and multiclass_colors is not None - and hasattr(estimator, "classes_") - and (n_classes := len(estimator.classes_)) > 2 - ): - if isinstance(multiclass_colors, list): - if len(multiclass_colors) != n_classes: - raise ValueError( - "When 'multiclass_colors' is a list, it must be of the same " - f"length as 'estimator.classes_' ({n_classes}), got: " - f"{len(multiclass_colors)}." - ) - elif any( - not mpl.colors.is_color_like(col) for col in multiclass_colors - ): - raise ValueError( - "When 'multiclass_colors' is a list, it can only contain valid" - f" Matplotlib color names. Got: {multiclass_colors}" - ) - if isinstance(multiclass_colors, str): - if multiclass_colors not in mpl.pyplot.colormaps(): - raise ValueError( - "When 'multiclass_colors' is a string, it must be a valid " - f"Matplotlib colormap. Got: {multiclass_colors}" - ) - x0, x1 = _safe_indexing(X, 0, axis=1), _safe_indexing(X, 1, axis=1) x0_min, x0_max = x0.min() - eps, x0.max() + eps @@ -496,7 +655,7 @@ def from_estimator( ) X_grid = np.c_[xx0.ravel(), xx1.ravel()] - if _is_pandas_df(X) or _is_polars_df(X): + if is_pandas_df(X) or is_polars_df(X): adapter = _get_adapter_from_container(X) X_grid = adapter.create_container( X_grid, @@ -504,27 +663,22 @@ def from_estimator( columns=X.columns, ) - prediction_method = _check_boundary_response_method( - estimator, response_method, class_of_interest - ) - try: - response, _, response_method_used = _get_response_values( - estimator, - X_grid, - response_method=prediction_method, - pos_label=class_of_interest, - return_response_method_used=True, + prediction_method = _check_boundary_response_method(estimator, response_method) + if (class_of_interest is not None and hasattr(estimator, "classes_")) and ( + class_of_interest not in estimator.classes_ + ): + raise ValueError( + f"class_of_interest={class_of_interest} is not a valid label: It " + f"should be one of {estimator.classes_}" ) - except ValueError as exc: - if "is not a valid label" in str(exc): - # re-raise a more informative error message since `pos_label` is unknown - # to our user when interacting with - # `DecisionBoundaryDisplay.from_estimator` - raise ValueError( - f"class_of_interest={class_of_interest} is not a valid label: It " - f"should be one of {estimator.classes_}" - ) from exc - raise + + response, _, response_method_used = _get_response_values( + estimator, + X_grid, + response_method=prediction_method, + pos_label=class_of_interest, + return_response_method_used=True, + ) # convert classes predictions into integers if response_method_used == "predict" and hasattr(estimator, "classes_"): @@ -532,6 +686,31 @@ def from_estimator( encoder.classes_ = estimator.classes_ response = encoder.transform(response) + # infer n_classes from the estimator + if ( + class_of_interest is not None + or is_regressor(estimator) + or is_outlier_detector(estimator) + ): + n_classes = 2 + elif is_classifier(estimator) and hasattr(estimator, "classes_"): + n_classes = len(estimator.classes_) + elif is_clusterer(estimator) and hasattr(estimator, "labels_"): + n_classes = len(np.unique(estimator.labels_)) + else: + target_type = type_of_target(response) + if target_type in ("binary", "continuous"): + n_classes = 2 + elif target_type == "multiclass": + n_classes = len(np.unique(response)) + else: + raise ValueError( + "Number of classes or labels cannot be inferred from " + f"{estimator.__class__.__name__}. Please make sure your estimator " + "follows scikit-learn's estimator API as described here: " + "https://scikit-learn.org/stable/developers/develop.html#rolling-your-own-estimator" + ) + if response.ndim == 1: response = response.reshape(*xx0.shape) else: @@ -556,6 +735,7 @@ def from_estimator( display = cls( xx0=xx0, xx1=xx1, + n_classes=n_classes, response=response, multiclass_colors=multiclass_colors, xlabel=xlabel, diff --git a/sklearn/inspection/_plot/partial_dependence.py b/sklearn/inspection/_plot/partial_dependence.py index a4104197e6b7a..958f988ff98ac 100644 --- a/sklearn/inspection/_plot/partial_dependence.py +++ b/sklearn/inspection/_plot/partial_dependence.py @@ -33,7 +33,7 @@ class PartialDependenceDisplay: :ref:`Inspection Guide <partial_dependence>`. For an example on how to use this class, see the following example: - :ref:`sphx_glr_auto_examples_miscellaneous_plot_partial_dependence_visualization_api.py`. + :ref:`sphx_glr_auto_examples_inspection_plot_partial_dependence_visualization_api.py`. .. versionadded:: 0.22 diff --git a/sklearn/inspection/_plot/tests/test_boundary_decision_display.py b/sklearn/inspection/_plot/tests/test_boundary_decision_display.py index f409a50ab58c0..84b373dc969de 100644 --- a/sklearn/inspection/_plot/tests/test_boundary_decision_display.py +++ b/sklearn/inspection/_plot/tests/test_boundary_decision_display.py @@ -3,10 +3,15 @@ import numpy as np import pytest -from sklearn.base import BaseEstimator, ClassifierMixin +from sklearn.base import ( + BaseEstimator, + ClassifierMixin, +) +from sklearn.cluster import KMeans from sklearn.datasets import ( load_diabetes, load_iris, + make_blobs, make_classification, make_multilabel_classification, ) @@ -14,14 +19,15 @@ from sklearn.inspection import DecisionBoundaryDisplay from sklearn.inspection._plot.decision_boundary import _check_boundary_response_method from sklearn.linear_model import LogisticRegression -from sklearn.preprocessing import scale +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler, scale from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from sklearn.utils._testing import ( _convert_container, assert_allclose, assert_array_equal, ) -from sklearn.utils.fixes import parse_version +from sklearn.utils.fixes import PETROFF_COLORS, parse_version X, y = make_classification( n_informative=1, @@ -63,57 +69,50 @@ class MultiLabelClassifier: err_msg = "Multi-label and multi-output multi-class classifiers are not supported" with pytest.raises(ValueError, match=err_msg): - _check_boundary_response_method(MultiLabelClassifier(), "predict", None) + _check_boundary_response_method(MultiLabelClassifier(), "predict") @pytest.mark.parametrize( - "estimator, response_method, class_of_interest, expected_prediction_method", + "estimator, response_method, expected_prediction_method", [ - (DecisionTreeRegressor(), "predict", None, "predict"), - (DecisionTreeRegressor(), "auto", None, "predict"), - (LogisticRegression().fit(*load_iris_2d_scaled()), "predict", None, "predict"), + (DecisionTreeRegressor(), "predict", "predict"), + (DecisionTreeRegressor(), "auto", "predict"), + (LogisticRegression().fit(*load_iris_2d_scaled()), "predict", "predict"), ( LogisticRegression().fit(*load_iris_2d_scaled()), "auto", - None, ["decision_function", "predict_proba", "predict"], ), ( LogisticRegression().fit(*load_iris_2d_scaled()), "predict_proba", - 0, "predict_proba", ), ( LogisticRegression().fit(*load_iris_2d_scaled()), "decision_function", - 0, "decision_function", ), ( LogisticRegression().fit(X, y), "auto", - None, ["decision_function", "predict_proba", "predict"], ), - (LogisticRegression().fit(X, y), "predict", None, "predict"), + (LogisticRegression().fit(X, y), "predict", "predict"), ( LogisticRegression().fit(X, y), ["predict_proba", "decision_function"], - None, ["predict_proba", "decision_function"], ), ], ) def test_check_boundary_response_method( - estimator, response_method, class_of_interest, expected_prediction_method + estimator, response_method, expected_prediction_method ): """Check the behaviour of `_check_boundary_response_method` for the supported cases. """ - prediction_method = _check_boundary_response_method( - estimator, response_method, class_of_interest - ) + prediction_method = _check_boundary_response_method(estimator, response_method) assert prediction_method == expected_prediction_method @@ -167,25 +166,42 @@ def test_input_validation_errors(pyplot, kwargs, error_msg, fitted_clf): @pytest.mark.parametrize( - "kwargs, error_msg", + "kwargs, error_type, error_msg", [ ( {"multiclass_colors": {"dict": "not_list"}}, + TypeError, "'multiclass_colors' must be a list or a str.", ), - ({"multiclass_colors": "not_cmap"}, "it must be a valid Matplotlib colormap"), - ({"multiclass_colors": ["red", "green"]}, "it must be of the same length"), + ( + {"multiclass_colors": "not_cmap"}, + ValueError, + "it must be a valid Matplotlib colormap", + ), + ( + {"multiclass_colors": ["red", "green"]}, + ValueError, + "it must be of the same length", + ), + ( + {"multiclass_colors": ["red", "green", "blue", "yellow"]}, + ValueError, + "it must be of the same length", + ), ( {"multiclass_colors": ["red", "green", "not color"]}, + ValueError, "it can only contain valid Matplotlib color names", ), ], ) -def test_input_validation_errors_multiclass_colors(pyplot, kwargs, error_msg): +def test_input_validation_errors_multiclass_colors( + pyplot, kwargs, error_type, error_msg +): """Check input validation for `multiclass_colors` in `from_estimator`.""" X, y = load_iris_2d_scaled() clf = LogisticRegression().fit(X, y) - with pytest.raises(ValueError, match=error_msg): + with pytest.raises(error_type, match=error_msg): DecisionBoundaryDisplay.from_estimator(clf, X, **kwargs) @@ -239,7 +255,7 @@ def test_decision_boundary_display_classifier( @pytest.mark.parametrize("response_method", ["auto", "predict", "decision_function"]) -@pytest.mark.parametrize("plot_method", ["contourf", "contour"]) +@pytest.mark.parametrize("plot_method", ["contourf", "contour", "pcolormesh"]) def test_decision_boundary_display_outlier_detector( pyplot, response_method, plot_method ): @@ -256,7 +272,10 @@ def test_decision_boundary_display_outlier_detector( eps=eps, ax=ax, ) - assert isinstance(disp.surface_, pyplot.matplotlib.contour.QuadContourSet) + if plot_method == "pcolormesh": + assert isinstance(disp.surface_, pyplot.matplotlib.collections.QuadMesh) + else: + assert isinstance(disp.surface_, pyplot.matplotlib.contour.QuadContourSet) assert disp.ax_ == ax assert disp.figure_ == fig @@ -288,7 +307,12 @@ def test_decision_boundary_display_regressor(pyplot, response_method, plot_metho eps=eps, plot_method=plot_method, ) - assert isinstance(disp.surface_, pyplot.matplotlib.contour.QuadContourSet) + if disp.n_classes == 2 or plot_method == "contour": + assert isinstance(disp.surface_, pyplot.matplotlib.contour.QuadContourSet) + else: + assert isinstance(disp.surface_, list) + for surface in disp.surface_: + assert isinstance(surface, pyplot.matplotlib.contour.QuadContourSet) assert disp.ax_ == ax assert disp.figure_ == fig @@ -390,20 +414,6 @@ def test_multioutput_regressor_error(pyplot): DecisionBoundaryDisplay.from_estimator(tree, X, response_method="predict") -@pytest.mark.parametrize( - "response_method", - ["predict_proba", "decision_function", ["predict_proba", "predict"]], -) -def test_regressor_unsupported_response(pyplot, response_method): - """Check that we can display the decision boundary for a regressor.""" - X, y = load_diabetes(return_X_y=True) - X = X[:, :2] - tree = DecisionTreeRegressor().fit(X, y) - err_msg = "should either be a classifier to be used with response_method" - with pytest.raises(ValueError, match=err_msg): - DecisionBoundaryDisplay.from_estimator(tree, X, response_method=response_method) - - @pytest.mark.filterwarnings( # We expect to raise the following warning because the classifier is fit on a # NumPy array @@ -475,7 +485,7 @@ def test_dataframe_support(pyplot, constructor_name): * https://github.com/scikit-learn/scikit-learn/issues/28717 """ df = _convert_container( - X, constructor_name=constructor_name, columns_name=["col_x", "col_y"] + X, constructor_name=constructor_name, column_names=["col_x", "col_y"] ) estimator = LogisticRegression().fit(df, y) @@ -568,9 +578,7 @@ def test_class_of_interest_multiclass(pyplot, response_method): response = getattr(estimator, response_method)(grid)[:, class_of_interest_idx] assert_allclose(response.reshape(*disp.response.shape), disp.response) - # check that we raise an error for unknown labels - # this test should already be handled in `_get_response_values` but we can have this - # test here as well + # Check that we raise an error for unknown labels. err_msg = "class_of_interest=2 is not a valid label: It should be one of" with pytest.raises(ValueError, match=err_msg): DecisionBoundaryDisplay.from_estimator( @@ -584,13 +592,6 @@ def test_class_of_interest_multiclass(pyplot, response_method): @pytest.mark.parametrize("response_method", ["predict_proba", "decision_function"]) def test_multiclass_plot_max_class(pyplot, response_method): """Check plot correct when plotting max multiclass class.""" - import matplotlib as mpl - - # In matplotlib < v3.5, default value of `pcolormesh(shading)` is 'flat', which - # results in the last row and column being dropped. Thus older versions produce - # a 99x99 grid, while newer versions produce a 100x100 grid. - if parse_version(mpl.__version__) < parse_version("3.5"): - pytest.skip("`pcolormesh` in Matplotlib >= 3.5 gives smaller grid size.") X, y = load_iris_2d_scaled() clf = LogisticRegression().fit(X, y) @@ -618,52 +619,342 @@ def test_multiclass_plot_max_class(pyplot, response_method): @pytest.mark.parametrize( - "multiclass_colors", + "multiclass_colors, n_classes", [ - "plasma", - "Blues", - ["red", "green", "blue"], + (None, 3), + (None, 11), + ("plasma", 3), + ("Blues", 3), + ("tab20", 20), + (["red", "green", "blue"], 3), ], ) +@pytest.mark.parametrize( + "response_method", ["decision_function", "predict_proba", "predict"] +) @pytest.mark.parametrize("plot_method", ["contourf", "contour", "pcolormesh"]) -def test_multiclass_colors_cmap(pyplot, plot_method, multiclass_colors): +def test_multiclass_colors_cmap( + pyplot, + n_classes, + response_method, + plot_method, + multiclass_colors, +): """Check correct cmap used for all `multiclass_colors` inputs.""" import matplotlib as mpl - if parse_version(mpl.__version__) < parse_version("3.5"): - pytest.skip( - "Matplotlib >= 3.5 is needed for `==` to check equivalence of colormaps" - ) - - X, y = load_iris_2d_scaled() + X, y = make_blobs(n_samples=150, centers=n_classes, n_features=2, random_state=42) clf = LogisticRegression().fit(X, y) disp = DecisionBoundaryDisplay.from_estimator( clf, X, + response_method=response_method, plot_method=plot_method, multiclass_colors=multiclass_colors, ) - if multiclass_colors == "plasma": - colors = mpl.pyplot.get_cmap(multiclass_colors, len(clf.classes_)).colors - elif multiclass_colors == "Blues": - cmap = mpl.pyplot.get_cmap(multiclass_colors, len(clf.classes_)) + # Non-regression test for PR #33651 + assert isinstance(disp.multiclass_colors_, np.ndarray) + + if multiclass_colors is None: + # Make sure the correct colors are selected from the corresponding petroff color + # sequences or "gist_rainbow" + if len(clf.classes_) == 3: + multiclass_colors = PETROFF_COLORS[:3] + else: + multiclass_colors = "gist_rainbow" + + if isinstance(multiclass_colors, str): + cmap = pyplot.get_cmap(multiclass_colors) colors = cmap(np.linspace(0, 1, len(clf.classes_))) else: - colors = [mpl.colors.to_rgba(color) for color in multiclass_colors] + colors = mpl.colors.to_rgba_array(multiclass_colors) + + # Make sure the colormap has enough distinct colors. + assert disp.n_classes == len(np.unique(colors, axis=0)) - if plot_method != "contour": + if response_method == "predict": + if plot_method == "contour": + assert disp.surface_.colors == "black" + else: + cmap = mpl.colors.ListedColormap(colors) + assert disp.surface_.cmap == cmap + + else: + if plot_method == "contour": + # the last display additionally contains the class boundary contours + assert disp.surface_[-1].colors == "black" + del disp.surface_[-1] cmaps = [ mpl.colors.LinearSegmentedColormap.from_list( f"colormap_{class_idx}", [(1.0, 1.0, 1.0, 1.0), (r, g, b, 1.0)] ) for class_idx, (r, g, b, _) in enumerate(colors) ] + # Make sure every class has its own surface. + assert len(disp.surface_) == disp.n_classes + for idx, quad in enumerate(disp.surface_): assert quad.cmap == cmaps[idx] + + +def test_multiclass_not_enough_colors_error(pyplot): + """ + Check that an error is raised if a qualitative colormap doesn't have enough colors. + + Non-regression test for PR 33419. + + Note: List length mismatch is already checked in + `test_input_validation_errors_multiclass_colors`. + """ + X = np.array( + [ + [-1, -1], + [-2, -1], + [1, 1], + [2, 1], + [2, 2], + [3, 2], + [3, 3], + [4, 3], + [4, 4], + [5, 4], + [5, 5], + ] + ) + y = np.arange(11) + clf = LogisticRegression().fit(X, y) + msg = "Colormap 'tab10' only has 10 colors, but 11 classes are to be displayed." + with pytest.raises(ValueError, match=msg): + DecisionBoundaryDisplay.from_estimator(clf, X, multiclass_colors="tab10") + + +@pytest.mark.parametrize("y", [np.arange(6), [str(i) for i in np.arange(6)]]) +@pytest.mark.parametrize( + "response_method, plot_method", + [ + ("decision_function", "contour"), + ("predict_proba", "contour"), + ("predict", "contour"), + ("predict", "contourf"), + ], +) +def test_multiclass_levels(pyplot, y, response_method, plot_method): + """ + Test that `levels` are set such that all classes and class boundaries are displayed. + + This is only relevant for "contour" and "predict" with "contourf". + Non-regression test for issue #32866. + """ + X = np.array([[-1, -1], [-2, -1], [1, 1], [2, 1], [2, 2], [3, 2]]) + + clf = LogisticRegression().fit(X, y) + + disp = DecisionBoundaryDisplay.from_estimator( + clf, + X, + response_method=response_method, + plot_method=plot_method, + multiclass_colors="gist_rainbow", + ) + + if plot_method == "contour": + expected_levels = np.arange(6) + else: + expected_levels = np.arange(7) - 0.5 + + if isinstance(disp.surface_, list): + # This is the case for "decision_function" and "predict_proba" with "contour", + # which have a separate surface for each class contour and contain the decision + # boundary contour (which requires the correct levels) on the last surface. + levels = disp.surface_[-1].levels + else: + levels = disp.surface_.levels + + assert_allclose(levels, expected_levels) + + # Check that all expected colors are visible for contourf: + if plot_method == "contourf": + expected_colors = pyplot.get_cmap("gist_rainbow")(np.linspace(0, 1, 6)) + # TODO: Remove version check and the else branch once 3.10 is the minimal + # supported matplotlib version. + import matplotlib as mpl + + # disp.surface_ is QuadContourSet. In matplotlib 3.10.0, the API for + # QuadContourSet was changed to produce only one collection per plot and + # `.collections` was deprecated, whereas before, a collection was created for + # each level separately. + if parse_version(mpl.__version__) >= parse_version("3.10.0"): + surface_colors = disp.surface_.get_facecolor() + else: + surface_colors = [ + collection.get_facecolor()[0] + for collection in disp.surface_.collections + ] + assert_allclose(expected_colors, surface_colors) + + +@pytest.mark.parametrize( + "response_method", ["decision_function", "predict_proba", "predict"] +) +@pytest.mark.parametrize("plot_method", ["contourf", "contour", "pcolormesh"]) +def test_zorder(pyplot, response_method, plot_method): + """Check that decision boundaries are plotted in the background.""" + X = np.array([[-1, -1], [-2, -1], [1, 1], [2, 1], [2, 2], [3, 2]]) + y = np.arange(6) + clf = LogisticRegression().fit(X, y) + disp = DecisionBoundaryDisplay.from_estimator( + clf, X, response_method=response_method, plot_method=plot_method + ) + # TODO: Remove version check and the else branch once 3.10 is the minimal + # supported matplotlib version. + import matplotlib as mpl + + # disp.surface_ is QuadContourSet or QuadMesh (for "pcolormesh"). In matplotlib + # 3.10.0, the API for QuadContourSet was changed to produce only one collection + # per plot (as it was and is the case for QuadMesh) and `.collections` was + # deprecated, whereas before, a collection was created for each level + # separately. + if ( + parse_version(mpl.__version__) >= parse_version("3.10.0") + or plot_method == "pcolormesh" + ): + if isinstance(disp.surface_, list): + for surface in disp.surface_: + assert surface.get_zorder() == -1 + else: + assert disp.surface_.get_zorder() == -1 else: - assert_allclose(disp.surface_.colors, colors) + if isinstance(disp.surface_, list): + for surface in disp.surface_: + for collection in surface.collections: + assert collection.get_zorder() == -1 + else: + for collection in disp.surface_.collections: + assert collection.get_zorder() == -1 + + +# estimator classes for non-regression test cases for issue #33194 +class CustomBinaryEstimator(BaseEstimator): + def fit(self, X, y): + self.fitted_ = True + return self + + def predict(self, X): + return np.arange(X.shape[0]) % 2 + + +class CustomMulticlassEstimator(BaseEstimator): + def fit(self, X, y): + self.fitted_ = True + return self + + def predict(self, X): + return np.arange(X.shape[0]) % 7 + + +class CustomContinuousEstimator(BaseEstimator): + def fit(self, X, y): + self.fitted_ = True + return self + + def predict(self, X): + return np.arange(X.shape[0]) * 0.5 + + +@pytest.mark.parametrize( + "estimator, n_blobs, expected_n_classes", + [ + (DecisionTreeClassifier(random_state=0), 7, 7), + (DecisionTreeClassifier(random_state=0), 2, 2), + (KMeans(n_clusters=7, random_state=0), 7, 7), + (KMeans(n_clusters=2, random_state=0), 2, 2), + (DecisionTreeRegressor(random_state=0), 7, 2), + (IsolationForest(random_state=0), 7, 2), + (CustomBinaryEstimator(), 2, 2), + (CustomMulticlassEstimator(), 7, 7), + (CustomContinuousEstimator(), 7, 2), + ( + Pipeline( + [ + ("scale", StandardScaler()), + ("dt", DecisionTreeClassifier(random_state=0)), + ] + ), + 7, + 7, + ), + # non-regression test case for issue #33194 + ( + Pipeline( + [ + ("scale", StandardScaler()), + ("kmeans", KMeans(n_clusters=7, random_state=0)), + ] + ), + 7, + 7, + ), + ( + Pipeline( + [ + ("scale", StandardScaler()), + ("reg", DecisionTreeRegressor(random_state=0)), + ] + ), + 7, + 2, + ), + ( + Pipeline( + [ + ("scale", StandardScaler()), + ("kmeans", IsolationForest(random_state=0)), + ] + ), + 7, + 2, + ), + ], +) +def test_n_classes_attribute(pyplot, estimator, n_blobs, expected_n_classes): + """Check that `n_classes` is set correctly. + + Introduced in https://github.com/scikit-learn/scikit-learn/pull/33015. + """ + + X, y = make_blobs(n_samples=150, centers=n_blobs, n_features=2, random_state=42) + clf = estimator.fit(X, y) + disp = DecisionBoundaryDisplay.from_estimator(clf, X, response_method="predict") + assert disp.n_classes == expected_n_classes + + # Test that setting class_of_interest always converts to a binary problem. + disp_coi = DecisionBoundaryDisplay.from_estimator( + clf, X, class_of_interest=y[0], response_method="predict" + ) + assert disp_coi.n_classes == 2 + + +def test_n_classes_raises_if_not_inferable(pyplot): + """Check behaviour if `n_classes` can't be inferred. + + Non-regression test for issue #33194. + """ + + class CustomUnknownEstimator(BaseEstimator): + def fit(self, X, y): + self.fitted_ = True + return self + + def predict(self, X): + return np.array(0) + + X, y = load_iris_2d_scaled() + est = CustomUnknownEstimator().fit(X, y) + msg = "Number of classes or labels cannot be inferred from CustomUnknownEstimator" + with pytest.raises(ValueError, match=msg): + DecisionBoundaryDisplay.from_estimator(est, X, response_method="predict") def test_cmap_and_colors_logic(pyplot): diff --git a/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py b/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py index 75869079be9cc..afdd042c79bde 100644 --- a/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py +++ b/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py @@ -178,17 +178,17 @@ def test_plot_partial_dependence_kind( @pytest.mark.parametrize( "input_type, feature_names_type", [ - ("dataframe", None), - ("dataframe", "list"), + ("pandas", None), + ("pandas", "list"), ("list", "list"), ("array", "list"), - ("dataframe", "array"), + ("pandas", "array"), ("list", "array"), ("array", "array"), - ("dataframe", "series"), + ("pandas", "series"), ("list", "series"), ("array", "series"), - ("dataframe", "index"), + ("pandas", "index"), ("list", "index"), ("array", "index"), ], @@ -205,13 +205,9 @@ def test_plot_partial_dependence_str_features( age = diabetes.data[:, diabetes.feature_names.index("age")] bmi = diabetes.data[:, diabetes.feature_names.index("bmi")] - if input_type == "dataframe": - pd = pytest.importorskip("pandas") - X = pd.DataFrame(diabetes.data, columns=diabetes.feature_names) - elif input_type == "list": - X = diabetes.data.tolist() - else: - X = diabetes.data + X = _convert_container( + diabetes.data, input_type, column_names=diabetes.feature_names + ) if feature_names_type is None: feature_names = None @@ -383,7 +379,7 @@ def test_plot_partial_dependence_passing_numpy_axes( @pytest.mark.parametrize("nrows, ncols", [(2, 2), (3, 1)]) @pytest.mark.parametrize("use_custom_values", [True, False]) -def test_plot_partial_dependence_incorrent_num_axes( +def test_plot_partial_dependence_incorrect_num_axes( pyplot, clf_diabetes, diabetes, @@ -803,7 +799,7 @@ def test_plot_partial_dependence_does_not_override_ylabel( @pytest.mark.parametrize( "categorical_features, array_type", [ - (["col_A", "col_C"], "dataframe"), + (["col_A", "col_C"], "pandas"), ([0, 2], "array"), ([True, False, True], "array"), ], @@ -812,8 +808,8 @@ def test_plot_partial_dependence_with_categorical( pyplot, categorical_features, array_type ): X = [[1, 1, "A"], [2, 0, "C"], [3, 2, "B"]] - column_name = ["col_A", "col_B", "col_C"] - X = _convert_container(X, array_type, columns_name=column_name) + column_names = ["col_A", "col_B", "col_C"] + X = _convert_container(X, array_type, column_names=column_names) y = np.array([1.2, 0.5, 0.45]).T preprocessor = make_column_transformer((OneHotEncoder(), categorical_features)) @@ -825,7 +821,7 @@ def test_plot_partial_dependence_with_categorical( model, X, features=["col_C"], - feature_names=column_name, + feature_names=column_names, categorical_features=categorical_features, ) @@ -847,7 +843,7 @@ def test_plot_partial_dependence_with_categorical( model, X, features=[("col_A", "col_C")], - feature_names=column_name, + feature_names=column_names, categorical_features=categorical_features, ) @@ -985,7 +981,7 @@ def test_partial_dependence_overwrite_labels( @pytest.mark.parametrize( "categorical_features, array_type", [ - (["col_A", "col_C"], "dataframe"), + (["col_A", "col_C"], "pandas"), ([0, 2], "array"), ([True, False, True], "array"), ], @@ -995,8 +991,8 @@ def test_grid_resolution_with_categorical(pyplot, categorical_features, array_ty respect to the number of categories in the categorical features targeted. """ X = [["A", 1, "A"], ["B", 0, "C"], ["C", 2, "B"]] - column_name = ["col_A", "col_B", "col_C"] - X = _convert_container(X, array_type, columns_name=column_name) + column_names = ["col_A", "col_B", "col_C"] + X = _convert_container(X, array_type, column_names=column_names) y = np.array([1.2, 0.5, 0.45]).T preprocessor = make_column_transformer((OneHotEncoder(), categorical_features)) @@ -1011,7 +1007,7 @@ def test_grid_resolution_with_categorical(pyplot, categorical_features, array_ty model, X, features=["col_C"], - feature_names=column_name, + feature_names=column_names, categorical_features=categorical_features, grid_resolution=2, ) diff --git a/sklearn/inspection/tests/test_partial_dependence.py b/sklearn/inspection/tests/test_partial_dependence.py index 816fe5512edc4..119c6899c0ed3 100644 --- a/sklearn/inspection/tests/test_partial_dependence.py +++ b/sklearn/inspection/tests/test_partial_dependence.py @@ -3,7 +3,6 @@ """ import re -import warnings import numpy as np import pytest @@ -411,7 +410,6 @@ def test_recursion_decision_tree_vs_forest_and_gbdt(seed): gbdt = GradientBoostingRegressor( n_estimators=1, learning_rate=1, - criterion="squared_error", max_depth=max_depth, random_state=equiv_random_state, ) @@ -642,7 +640,7 @@ def test_partial_dependence_unknown_feature_string(estimator): estimator = clone(estimator).fit(df, y) features = ["random"] - err_msg = "A given column is not a column of the dataframe" + err_msg = "Some column names are not columns of the dataframe" with pytest.raises(ValueError, match=err_msg): partial_dependence(estimator, df, features) @@ -1145,26 +1143,21 @@ def test_reject_array_with_integer_dtype(): y = np.array([0, 1, 0, 1]) clf = DummyClassifier() clf.fit(X, y) - with pytest.warns( - FutureWarning, match=re.escape("The column 0 contains integer data.") + with pytest.raises( + ValueError, match=re.escape("The column 0 contains integer data.") ): partial_dependence(clf, X, features=0) - - with pytest.warns( - FutureWarning, match=re.escape("The column 1 contains integer data.") + with pytest.raises( + ValueError, match=re.escape("The column 1 contains integer data.") ): partial_dependence(clf, X, features=[1], categorical_features=[0]) - - with pytest.warns( - FutureWarning, match=re.escape("The column 0 contains integer data.") + with pytest.raises( + ValueError, match=re.escape("The column 0 contains integer data.") ): partial_dependence(clf, X, features=[0, 1]) - # The following should not raise as we do not compute numerical partial # dependence on integer columns. - with warnings.catch_warnings(): - warnings.simplefilter("error") - partial_dependence(clf, X, features=1, categorical_features=[1]) + partial_dependence(clf, X, features=1, categorical_features=[1]) def test_reject_pandas_with_integer_dtype(): @@ -1180,22 +1173,18 @@ def test_reject_pandas_with_integer_dtype(): clf = DummyClassifier() clf.fit(X, y) - with pytest.warns( - FutureWarning, match=re.escape("The column 'c' contains integer data.") + with pytest.raises( + ValueError, match=re.escape("The column 'c' contains integer data.") ): partial_dependence(clf, X, features="c") - - with pytest.warns( - FutureWarning, match=re.escape("The column 'c' contains integer data.") + with pytest.raises( + ValueError, match=re.escape("The column 'c' contains integer data.") ): partial_dependence(clf, X, features=["a", "c"]) - # The following should not raise as we do not compute numerical partial # dependence on integer columns. - with warnings.catch_warnings(): - warnings.simplefilter("error") - partial_dependence(clf, X, features=["a"]) - partial_dependence(clf, X, features=["c"], categorical_features=["c"]) + partial_dependence(clf, X, features=["a"]) + partial_dependence(clf, X, features=["c"], categorical_features=["c"]) def test_partial_dependence_empty_categorical_features(): diff --git a/sklearn/inspection/tests/test_pd_utils.py b/sklearn/inspection/tests/test_pd_utils.py index 5dea3834a77a7..f113933a7e000 100644 --- a/sklearn/inspection/tests/test_pd_utils.py +++ b/sklearn/inspection/tests/test_pd_utils.py @@ -9,14 +9,14 @@ "feature_names, array_type, expected_feature_names", [ (None, "array", ["x0", "x1", "x2"]), - (None, "dataframe", ["a", "b", "c"]), + (None, "pandas", ["a", "b", "c"]), (np.array(["a", "b", "c"]), "array", ["a", "b", "c"]), ], ) def test_check_feature_names(feature_names, array_type, expected_feature_names): X = np.random.randn(10, 3) column_names = ["a", "b", "c"] - X = _convert_container(X, constructor_name=array_type, columns_name=column_names) + X = _convert_container(X, constructor_name=array_type, column_names=column_names) feature_names_validated = _check_feature_names(X, feature_names) assert feature_names_validated == expected_feature_names diff --git a/sklearn/inspection/tests/test_permutation_importance.py b/sklearn/inspection/tests/test_permutation_importance.py index b51ad7b71f66d..d105d0943be02 100644 --- a/sklearn/inspection/tests/test_permutation_importance.py +++ b/sklearn/inspection/tests/test_permutation_importance.py @@ -24,6 +24,7 @@ from sklearn.pipeline import make_pipeline from sklearn.preprocessing import KBinsDiscretizer, OneHotEncoder, StandardScaler, scale from sklearn.utils._testing import _convert_container +from sklearn.utils.estimator_checks import _NotAnArray @pytest.mark.parametrize("n_jobs", [1, 2]) @@ -239,7 +240,7 @@ def test_permutation_importance_mixed_types_pandas(): assert np.all(result.importances_mean[-1] > result.importances_mean[:-1]) -def test_permutation_importance_linear_regresssion(): +def test_permutation_importance_linear_regression(): X, y = make_regression(n_samples=500, n_features=10, random_state=0) X = scale(X) @@ -329,7 +330,7 @@ def test_permutation_importance_equivalence_array_dataframe(n_jobs, max_samples) X_df[new_col_idx] = cat_column assert X_df[new_col_idx].dtype == cat_column.dtype - # Stich an arbitrary index to the dataframe: + # Stitch an arbitrary index to the dataframe: X_df.index = np.arange(len(X_df)).astype(str) rf = RandomForestRegressor(n_estimators=5, max_depth=3, random_state=0) @@ -352,7 +353,7 @@ def test_permutation_importance_equivalence_array_dataframe(n_jobs, max_samples) imp_max = importance_array["importances"].max() assert imp_max - imp_min > 0.3 - # Now check that importances computed on dataframe matche the values + # Now check that importances computed on dataframe match the values # of those computed on the array with the same data. importance_dataframe = permutation_importance( rf, @@ -368,7 +369,7 @@ def test_permutation_importance_equivalence_array_dataframe(n_jobs, max_samples) ) -@pytest.mark.parametrize("input_type", ["array", "dataframe"]) +@pytest.mark.parametrize("input_type", ["array", "pandas"]) def test_permutation_importance_large_memmaped_data(input_type): # Smoke, non-regression test for: # https://github.com/scikit-learn/scikit-learn/issues/15810 @@ -538,3 +539,12 @@ def test_permutation_importance_max_samples_error(): with pytest.raises(ValueError, match=err_msg): permutation_importance(clf, X, y, max_samples=5) + + +def test_permutation_importance_array_function_not_called(): + """Check that `__array_function__` (NEP18) is not called.""" + X = _NotAnArray([[1, 1], [1, 2], [1, 3], [1, 4], [2, 1], [2, 2], [2, 3], [2, 4]]) + y = _NotAnArray([1, 1, 1, 2, 2, 2, 1, 1]) + estimator = LogisticRegression(random_state=0) + estimator.fit(X, y) + permutation_importance(estimator, X, y, n_repeats=2, random_state=0) diff --git a/sklearn/kernel_approximation.py b/sklearn/kernel_approximation.py index bd60f8494bf61..88d886355e00f 100644 --- a/sklearn/kernel_approximation.py +++ b/sklearn/kernel_approximation.py @@ -9,7 +9,6 @@ import numpy as np import scipy.sparse as sp from scipy.fft import fft, ifft -from scipy.linalg import svd from sklearn.base import ( BaseEstimator, @@ -20,9 +19,15 @@ from sklearn.metrics.pairwise import ( KERNEL_PARAMS, PAIRWISE_KERNEL_FUNCTIONS, + _find_floating_dtype_allow_sparse, pairwise_kernels, ) -from sklearn.utils import check_random_state +from sklearn.utils import _align_api_if_sparse, check_random_state +from sklearn.utils._array_api import ( + _find_matching_floating_dtype, + get_namespace_and_device, +) +from sklearn.utils._indexing import _safe_indexing from sklearn.utils._param_validation import Interval, StrOptions from sklearn.utils.extmath import safe_sparse_dot from sklearn.utils.validation import ( @@ -99,7 +104,7 @@ class PolynomialCountSketch( -------- AdditiveChi2Sampler : Approximate feature map for additive chi2 kernel. Nystroem : Approximate a kernel map using a subset of the training data. - RBFSampler : Approximate a RBF kernel feature map using random Fourier + RBFSampler : Approximate an RBF kernel feature map using random Fourier features. SkewedChi2Sampler : Approximate feature map for "skewed chi-squared" kernel. sklearn.metrics.pairwise.kernel_metrics : List of built-in kernels. @@ -246,7 +251,7 @@ def __sklearn_tags__(self): class RBFSampler(ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator): - """Approximate a RBF kernel feature map using random Fourier features. + """Approximate an RBF kernel feature map using random Fourier features. It implements a variant of Random Kitchen Sinks.[1] @@ -384,7 +389,6 @@ def fit(self, X, y=None): # output data type during `transform`. self.random_weights_ = self.random_weights_.astype(X.dtype, copy=False) self.random_offset_ = self.random_offset_.astype(X.dtype, copy=False) - self._n_features_out = self.n_components return self @@ -465,7 +469,7 @@ class SkewedChi2Sampler( -------- AdditiveChi2Sampler : Approximate feature map for additive chi2 kernel. Nystroem : Approximate a kernel map using a subset of the training data. - RBFSampler : Approximate a RBF kernel feature map using random Fourier + RBFSampler : Approximate an RBF kernel feature map using random Fourier features. SkewedChi2Sampler : Approximate feature map for "skewed chi-squared" kernel. sklearn.metrics.pairwise.chi2_kernel : The exact chi squared kernel. @@ -807,8 +811,10 @@ def _transform_sparse(X, sample_steps, sample_interval): indptr = X.indptr.copy() data_step = np.sqrt(X.data * sample_interval) - X_step = sp.csr_matrix( - (data_step, indices, indptr), shape=X.shape, dtype=X.dtype, copy=False + X_step = _align_api_if_sparse( + sp.csr_array( + (data_step, indices, indptr), shape=X.shape, dtype=X.dtype, copy=False + ) ) X_new = [X_step] @@ -819,14 +825,24 @@ def _transform_sparse(X, sample_steps, sample_interval): factor_nz = np.sqrt(step_nz / np.cosh(np.pi * j * sample_interval)) data_step = factor_nz * np.cos(j * log_step_nz) - X_step = sp.csr_matrix( - (data_step, indices, indptr), shape=X.shape, dtype=X.dtype, copy=False + X_step = _align_api_if_sparse( + sp.csr_array( + (data_step, indices, indptr), + shape=X.shape, + dtype=X.dtype, + copy=False, + ) ) X_new.append(X_step) data_step = factor_nz * np.sin(j * log_step_nz) - X_step = sp.csr_matrix( - (data_step, indices, indptr), shape=X.shape, dtype=X.dtype, copy=False + X_step = _align_api_if_sparse( + sp.csr_array( + (data_step, indices, indptr), + shape=X.shape, + dtype=X.dtype, + copy=False, + ) ) X_new.append(X_step) @@ -923,7 +939,7 @@ class Nystroem(ClassNamePrefixFeaturesOutMixin, TransformerMixin, BaseEstimator) -------- AdditiveChi2Sampler : Approximate feature map for additive chi2 kernel. PolynomialCountSketch : Polynomial kernel approximation via Tensor Sketch. - RBFSampler : Approximate a RBF kernel feature map using random Fourier + RBFSampler : Approximate an RBF kernel feature map using random Fourier features. SkewedChi2Sampler : Approximate feature map for "skewed chi-squared" kernel. sklearn.metrics.pairwise.kernel_metrics : List of built-in kernels. @@ -1013,6 +1029,7 @@ def fit(self, X, y=None): self : object Returns the instance itself. """ + xp, _, device = get_namespace_and_device(X) X = validate_data(self, X, accept_sparse="csr") rnd = check_random_state(self.random_state) n_samples = X.shape[0] @@ -1031,8 +1048,11 @@ def fit(self, X, y=None): n_components = self.n_components n_components = min(n_samples, n_components) inds = rnd.permutation(n_samples) - basis_inds = inds[:n_components] - basis = X[basis_inds] + basis_inds = xp.asarray(inds[:n_components], dtype=xp.int64, device=device) + if sp.issparse(X): + basis = X[basis_inds] + else: + basis = _safe_indexing(X, basis_inds, axis=0) basis_kernel = pairwise_kernels( basis, @@ -1043,9 +1063,11 @@ def fit(self, X, y=None): ) # sqrt of kernel matrix on basis vectors - U, S, V = svd(basis_kernel) - S = np.maximum(S, 1e-12) - self.normalization_ = np.dot(U / np.sqrt(S), V) + _, _, dtype = _find_floating_dtype_allow_sparse(basis_kernel, Y=None, xp=xp) + basis_kernel = xp.asarray(basis_kernel, dtype=dtype, device=device) + U, S, V = xp.linalg.svd(basis_kernel) + S = xp.clip(S, 1e-12, None) + self.normalization_ = U / xp.sqrt(S) @ V self.components_ = basis self.component_indices_ = basis_inds self._n_features_out = n_components @@ -1068,6 +1090,8 @@ def transform(self, X): Transformed data. """ check_is_fitted(self) + + xp, _, device = get_namespace_and_device(X) X = validate_data(self, X, accept_sparse="csr", reset=False) kernel_params = self._get_kernel_params() @@ -1079,7 +1103,9 @@ def transform(self, X): n_jobs=self.n_jobs, **kernel_params, ) - return np.dot(embedded, self.normalization_.T) + dtype = _find_matching_floating_dtype(embedded, xp=xp) + embedded = xp.asarray(embedded, dtype=dtype, device=device) + return embedded @ self.normalization_.T def _get_kernel_params(self): params = self.kernel_params @@ -1105,6 +1131,7 @@ def _get_kernel_params(self): def __sklearn_tags__(self): tags = super().__sklearn_tags__() + tags.array_api_support = True tags.input_tags.sparse = True tags.transformer_tags.preserves_dtype = ["float64", "float32"] return tags diff --git a/sklearn/linear_model/_base.py b/sklearn/linear_model/_base.py index b46d6a4f0a20b..829f0543f0b69 100644 --- a/sklearn/linear_model/_base.py +++ b/sklearn/linear_model/_base.py @@ -13,7 +13,6 @@ import scipy.sparse as sp from scipy import linalg, optimize, sparse from scipy.sparse.linalg import lsqr -from scipy.special import expit from sklearn.base import ( BaseEstimator, @@ -22,13 +21,16 @@ RegressorMixin, _fit_context, ) -from sklearn.utils import check_array, check_random_state +from sklearn.utils import _align_api_if_sparse, check_array, check_random_state from sklearn.utils._array_api import ( _asarray_with_order, _average, + _expit, + check_same_namespace, get_namespace, get_namespace_and_device, indexing_dtype, + move_to, supported_float_dtypes, ) from sklearn.utils._param_validation import Interval @@ -114,7 +116,6 @@ def _preprocess_data( *, fit_intercept, copy=True, - copy_y=True, sample_weight=None, check_input=True, rescale_with_sw=True, @@ -153,8 +154,7 @@ def _preprocess_data( inplace. If input X is dense, then X_out is centered. y_out : {ndarray, sparse matrix} of shape (n_samples,) or (n_samples, n_targets) - Centered version of y. Possibly performed inplace on input y depending - on the copy_y parameter. + Centered copy of y. X_offset : ndarray of shape (n_features,) The mean per column of input X. y_offset : float or ndarray of shape (n_features,) @@ -172,9 +172,9 @@ def _preprocess_data( X = check_array( X, copy=copy, accept_sparse=["csr", "csc"], dtype=supported_float_dtypes(xp) ) - y = check_array(y, dtype=X.dtype, copy=copy_y, ensure_2d=False) + y = check_array(y, dtype=X.dtype, copy=True, ensure_2d=False) else: - y = xp.astype(y, X.dtype, copy=copy_y) + y = xp.astype(y, X.dtype) if copy: if X_is_sparse: X = X.copy() @@ -210,7 +210,8 @@ def _preprocess_data( # For sparse X and y, it triggers copies anyway. # For dense X and y that already have been copied, we safely do inplace # rescaling. - X, y, sample_weight_sqrt = _rescale_data(X, y, sample_weight, inplace=copy) + # Hence, inplace=True here regardless of copy. + X, y, sample_weight_sqrt = _rescale_data(X, y, sample_weight, inplace=True) else: sample_weight_sqrt = None return X, y, X_offset, y_offset, X_scale, sample_weight_sqrt @@ -249,7 +250,7 @@ def _rescale_data(X, y, sample_weight, inplace=False): sample_weight_sqrt = xp.sqrt(sample_weight) if sp.issparse(X) or sp.issparse(y): - sw_matrix = sparse.dia_matrix( + sw_matrix = sparse.dia_array( (sample_weight_sqrt, 0), shape=(n_samples, n_samples) ) @@ -274,7 +275,7 @@ def _rescale_data(X, y, sample_weight, inplace=False): y = y * sample_weight_sqrt else: y = y * sample_weight_sqrt[:, None] - return X, y, sample_weight_sqrt + return _align_api_if_sparse(X), _align_api_if_sparse(y), sample_weight_sqrt class LinearModel(BaseEstimator, metaclass=ABCMeta): @@ -300,14 +301,15 @@ def predict(self, X): Parameters ---------- - X : array-like or sparse matrix, shape (n_samples, n_features) + X : array-like or sparse matrix of shape (n_samples, n_features) Samples. Returns ------- - C : array, shape (n_samples,) - Returns predicted values. + C : ndarray of shape (n_samples,) + Predicted values. """ + check_same_namespace(X, self, attribute="coef_", method="predict") return self._decision_function(X) def _set_intercept(self, X_offset, y_offset, X_scale=None): @@ -330,6 +332,26 @@ def _set_intercept(self, X_offset, y_offset, X_scale=None): self.intercept_ = 0.0 +class MultiOutputLinearModel(MultiOutputMixin, LinearModel): + # Provides consistent docstring to `predict` for linear models that support + # multi-output. + def predict(self, X): + """ + Predict using the linear model. + + Parameters + ---------- + X : array-like or sparse matrix of shape (n_samples, n_features) + Samples. + + Returns + ------- + C : ndarray of shape (n_samples,) or (n_samples, n_outputs) + Predicted values. + """ + return super().predict(X) + + # XXX Should this derive from LinearModel? It should be a mixin, not an ABC. # Maybe the n_features checking can be moved to LinearModel. class LinearClassifierMixin(ClassifierMixin): @@ -359,6 +381,7 @@ def decision_function(self, X): """ check_is_fitted(self) xp, _ = get_namespace(X) + check_same_namespace(X, self, attribute="coef_", method="decision_function") X = validate_data(self, X, accept_sparse="csr", reset=False) coef_T = self.coef_.T if self.coef_.ndim == 2 else self.coef_ @@ -383,14 +406,22 @@ def predict(self, X): y_pred : ndarray of shape (n_samples,) Vector containing the class labels for each sample. """ - xp, _ = get_namespace(X) + check_same_namespace(X, self, attribute="coef_", method="predict") + xp, _, device_ = get_namespace_and_device(X) scores = self.decision_function(X) if len(scores.shape) == 1: indices = xp.astype(scores > 0, indexing_dtype(xp)) else: indices = xp.argmax(scores, axis=1) - return xp.take(self.classes_, indices, axis=0) + xp_classes, _, device_classes = get_namespace_and_device(self.classes_) + indices = move_to(indices, xp=xp_classes, device=device_classes) + + y_pred = xp_classes.take(self.classes_, indices, axis=0) + if isinstance(y_pred[0], str): + return y_pred + else: + return move_to(y_pred, xp=xp, device=device_) def _predict_proba_lr(self, X): """Probability estimation for OvR logistic regression. @@ -399,13 +430,21 @@ def _predict_proba_lr(self, X): 1. / (1. + np.exp(-self.decision_function(X))); multiclass is handled by normalizing that over all classes. """ + xp, _ = get_namespace(X) prob = self.decision_function(X) - expit(prob, out=prob) + prob = _expit(prob, out=prob, xp=xp) if prob.ndim == 1: - return np.vstack([1 - prob, prob]).T + return xp.stack([1 - prob, prob], axis=1) else: # OvR normalization, like LibLinear's predict_probability - prob /= prob.sum(axis=1).reshape((prob.shape[0], -1)) + prob_sum = prob.sum(axis=1) + all_zero = prob_sum == 0 + if xp.any(all_zero): + # The above might assign zero to all classes, which doesn't + # normalize neatly; work around this to produce uniform probabilities. + prob[all_zero, :] = 1 + prob_sum[all_zero] = prob.shape[1] # n_classes + prob /= xp.reshape(prob_sum, (prob.shape[0], -1)) return prob @@ -463,11 +502,11 @@ def sparsify(self): """ msg = "Estimator, %(name)s, must be fitted before sparsifying." check_is_fitted(self, msg=msg) - self.coef_ = sp.csr_matrix(self.coef_) + self.coef_ = _align_api_if_sparse(sp.csr_array(self.coef_)) return self -class LinearRegression(MultiOutputMixin, RegressorMixin, LinearModel): +class LinearRegression(RegressorMixin, MultiOutputLinearModel): """ Ordinary least squares Linear Regression. @@ -487,12 +526,14 @@ class LinearRegression(MultiOutputMixin, RegressorMixin, LinearModel): tol : float, default=1e-6 The precision of the solution (`coef_`) is determined by `tol` which - specifies a different convergence criterion for the `lsqr` solver. - `tol` is set as `atol` and `btol` of :func:`scipy.sparse.linalg.lsqr` when - fitting on sparse training data. This parameter has no effect when fitting - on dense data. + specifies the convergence criterion of the underlying solver. `tol` is + set as `atol` and `btol` of :func:`scipy.sparse.linalg.lsqr` when + fitting on sparse training data. `tol` is set as `cond` of + :func:`scipy.linalg.lstsq` when fitting on dense training data. .. versionadded:: 1.7 + .. versionchanged:: 1.9 + Now supported on dense data, interpreted as the `cond` parameter. n_jobs : int, default=None The number of jobs to use for the computation. This will only provide @@ -698,9 +739,9 @@ def rmatvec(b): ) self.coef_ = np.vstack([out[0] for out in outs]) else: - # cut-off ratio for small singular values - cond = max(X.shape) * np.finfo(X.dtype).eps - self.coef_, _, self.rank_, self.singular_ = linalg.lstsq(X, y, cond=cond) + self.coef_, _, self.rank_, self.singular_ = linalg.lstsq( + X, y, cond=self.tol + ) self.coef_ = self.coef_.T if y.ndim == 1: diff --git a/sklearn/linear_model/_bayes.py b/sklearn/linear_model/_bayes.py index 966a8bf1cf39f..7ea22b05f1f5c 100644 --- a/sklearn/linear_model/_bayes.py +++ b/sklearn/linear_model/_bayes.py @@ -397,6 +397,7 @@ def predict(self, X, return_std=False): if not return_std: return y_mean else: + X = X - self.X_offset_ sigmas_squared_data = (np.dot(X, self.sigma_) * X).sum(axis=1) y_std = np.sqrt(sigmas_squared_data + (1.0 / self.alpha_)) return y_mean, y_std @@ -818,6 +819,7 @@ def predict(self, X, return_std=False): return y_mean else: col_index = self.lambda_ < self.threshold_lambda + X = X - self.X_offset_ X = _safe_indexing(X, indices=col_index, axis=1) sigmas_squared_data = (np.dot(X, self.sigma_) * X).sum(axis=1) y_std = np.sqrt(sigmas_squared_data + (1.0 / self.alpha_)) diff --git a/sklearn/linear_model/_cd_fast.pyx b/sklearn/linear_model/_cd_fast.pyx index 578d7f7fe2338..b5d3ae47a9350 100644 --- a/sklearn/linear_model/_cd_fast.pyx +++ b/sklearn/linear_model/_cd_fast.pyx @@ -12,7 +12,7 @@ from sklearn.utils._cython_blas cimport ( _axpy, _dot, _asum, _gemv, _nrm2, _copy, _scal ) from sklearn.utils._cython_blas cimport ColMajor, Trans, NoTrans -from sklearn.utils._typedefs cimport uint8_t, uint32_t +from sklearn.utils._typedefs cimport int32_t, uint8_t, uint32_t from sklearn.utils._random cimport our_rand_r @@ -83,6 +83,43 @@ cdef inline floating diff_abs_max(int n, const floating* a, floating* b) noexcep return m +cdef inline floating sparse_dot( + int32_t j, + const floating[::1] X_data, # in + const int32_t[::1] X_indices, # in + const int32_t[::1] X_indptr, # in + const floating[::1] y, +) noexcept nogil: + """BLAS X[:, j] @ y for sparse CSC X.""" + cdef int32_t i, i_ind + cdef int32_t startptr = X_indptr[j] + cdef int32_t endptr = X_indptr[j + 1] + cdef floating result = 0 + + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + result += X_data[i_ind] * y[i] + return result + + +cdef inline floating sparse_axpy( + int32_t j, + floating a, + const floating[::1] X_data, # in + const int32_t[::1] X_indices, # in + const int32_t[::1] X_indptr, # in + floating[::1] y, # out +) noexcept nogil: + """BLAS y += a * X[:, j] for sparse CSC X.""" + cdef int32_t i, i_ind + cdef int32_t startptr = X_indptr[j] + cdef int32_t endptr = X_indptr[j + 1] + + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + y[i] += a * X_data[i_ind] + + message_conv = ( "Objective did not converge. You might want to increase " "the number of iterations, check the scale of the " @@ -98,6 +135,30 @@ message_ridge = ( ) +cdef inline floating dual_gap_formulation_A( + floating alpha, # L1 penalty + floating beta, # L1 penalty + floating w_l1_norm, + floating w_l2_norm2, + floating R_norm2, # R @ R + floating Ry, # R @ y + floating dual_norm_XtA, +) noexcept nogil: + """Compute dual gap according to formulation A.""" + cdef floating gap, primal, dual + cdef floating scale # Scaling factor to achieve dual feasible point. + + primal = 0.5 * (R_norm2 + beta * w_l2_norm2) + alpha * w_l1_norm + + if (dual_norm_XtA > alpha): + scale = alpha / dual_norm_XtA + else: + scale = 1.0 + dual = -0.5 * (scale ** 2) * (R_norm2 + beta * w_l2_norm2) + scale * Ry + gap = primal - dual + return gap + + cdef (floating, floating) gap_enet( int n_samples, int n_features, @@ -110,14 +171,47 @@ cdef (floating, floating) gap_enet( floating[::1] XtA, # XtA = X.T @ R - beta * w is calculated inplace bint positive, ) noexcept nogil: - """Compute dual gap for use in enet_coordinate_descent.""" + """Compute dual gap for use in enet_coordinate_descent. + + alpha > 0: formulation A of the duality gap + alpha = 0 & beta > 0: formulation B of the duality gap + alpha = beta = 0: OLS first order condition (=gradient) + """ cdef floating gap = 0.0 cdef floating dual_norm_XtA cdef floating R_norm2 - cdef floating w_norm2 = 0.0 - cdef floating l1_norm - cdef floating A_norm2 - cdef floating const_ + cdef floating Ry + cdef floating w_l1_norm + cdef floating w_l2_norm2 = 0.0 + + # w_l2_norm2 = w @ w + if beta > 0: + w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) + # R_norm2 = R @ R + R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1) + # Ry = R @ y + if not (alpha == 0 and beta == 0): + Ry = _dot(n_samples, &R[0], 1, &y[0], 1) + + if alpha == 0: + # XtA = X.T @ R + _gemv( + ColMajor, Trans, n_samples, n_features, 1.0, &X[0, 0], + n_samples, &R[0], 1, 0, &XtA[0], 1, + ) + # ||X'R||_2^2 + dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1) + if beta == 0: + # This is OLS, no dual gap available. Resort to first order condition + # X'R = 0 + # gap = ||X'R||_2^2 + # Compare with stopping criterion of LSQR. + gap = dual_norm_XtA + return gap, dual_norm_XtA + # This is Ridge regression, we use formulation B for the dual gap. + gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry + gap += 1 / (2 * beta) * dual_norm_XtA + return gap, dual_norm_XtA # XtA = X.T @ R - beta * w _copy(n_features, &w[0], 1, &XtA[0], 1) @@ -125,32 +219,23 @@ cdef (floating, floating) gap_enet( n_samples, &R[0], 1, -beta, &XtA[0], 1) + # dual_norm_XtA if positive: dual_norm_XtA = max(n_features, &XtA[0]) else: dual_norm_XtA = abs_max(n_features, &XtA[0]) - # R_norm2 = R @ R - R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1) - - # w_norm2 = w @ w - if beta > 0: - w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) - - if (dual_norm_XtA > alpha): - const_ = alpha / dual_norm_XtA - A_norm2 = R_norm2 * (const_ ** 2) - gap = 0.5 * (R_norm2 + A_norm2) - else: - const_ = 1.0 - gap = R_norm2 - - l1_norm = _asum(n_features, &w[0], 1) - - gap += ( - alpha * l1_norm - - const_ * _dot(n_samples, &R[0], 1, &y[0], 1) # R @ y - + 0.5 * beta * (1 + const_ ** 2) * w_norm2 + # w_l1_norm = np.sum(np.abs(w)) + w_l1_norm = _asum(n_features, &w[0], 1) + + gap = dual_gap_formulation_A( + alpha=alpha, + beta=beta, + w_l1_norm=w_l1_norm, + w_l2_norm2=w_l2_norm2, + R_norm2=R_norm2, + Ry=Ry, + dual_norm_XtA=dual_norm_XtA, ) return gap, dual_norm_XtA @@ -178,7 +263,7 @@ def enet_coordinate_descent( The dual for beta = 0, see e.g. [Fercoq 2015] with v = alpha * theta, is - D(v) = -1/2 ||v||_2^2 + y v + D(v) = -1/2 ||v||_2^2 + y' v (formulation A) with dual feasible condition ||X^T v||_inf <= alpha. For beta > 0, one uses extended versions of X and y by adding n_features rows @@ -186,16 +271,22 @@ def enet_coordinate_descent( X -> ( X) y -> (y) (sqrt(beta) I) (0) - Note that the residual y - X w is an important ingredient for the estimation of a - dual feasible point v. + Note that the residual R = y - X w is an important ingredient for the estimation of + a dual feasible point v. At optimum of primal w* and dual v*, one has - v = y* - X w* + v* = y - X w* The duality gap is G(w, v) = P(w) - D(v) <= P(w) - P(w*) + Strong duality holds: G(w*, v*) = 0. + For testing convergence, one uses G(w, v) with current w and uses + + v = R if ||X^T R||_inf <= alpha + v = R * alpha / ||X^T R||_inf else + The final stopping criterion is based on the duality gap tol ||y||_2^2 <= G(w, v) @@ -203,6 +294,18 @@ def enet_coordinate_descent( The tolerance here is multiplied by ||y||_2^2 to have an inequality that scales the same on both sides and because one has G(0, 0) = 1/2 ||y||_2^2. + Note: + The above dual D(v) and duality gap G require alpha > 0 because of the dual + feasible condition. + There is, however, an alternative dual formulation, see [Dรผnner 2016] 5.2.3 and + https://github.com/scikit-learn/scikit-learn/issues/22836: + + D(v) = -1/2 ||v||_2^2 + y' v + -1/(2 beta) sum_j (|X_j' v| - alpha)_+^2 (formulation B) + + The dual feasible set is v element real numbers. It requires beta > 0, but + alpha = 0 is allowed. Strong duality holds and at optimum, v* = y - X w*. + Returns ------- w : ndarray of shape (n_features,) @@ -225,6 +328,11 @@ def enet_coordinate_descent( Olivier Fercoq, Alexandre Gramfort, Joseph Salmon. (2015) Mind the duality gap: safer rules for the Lasso https://arxiv.org/abs/1505.03410 + + .. [Dรผnner 2016] + Celestine Dรผnner, Simon Forte, Martin Takรกc, Martin Jaggi. (2016). + Primal-Dual Rates and Certificates. In ICML 2016. + https://arxiv.org/abs/1602.05205 """ if floating is float: @@ -266,9 +374,9 @@ def enet_coordinate_descent( cdef uint32_t rand_r_state_seed = rng.randint(0, RAND_R_MAX) cdef uint32_t* rand_r_state = &rand_r_state_seed - if alpha == 0 and beta == 0: - warnings.warn("Coordinate descent with no regularization may lead to " - "unexpected results and is discouraged.") + if alpha == 0: + # No screeing without L1-penalty. + do_screening = False if do_screening: active_set = np.empty(n_features, dtype=np.uint32) # map [:n_active] -> j @@ -307,9 +415,10 @@ def enet_coordinate_descent( excluded_set[j] = 0 n_active += 1 else: - # R += w[j] * X[:,j] - _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1) - w[j] = 0 + if w[j] != 0: + # R += w[j] * X[:,j] + _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1) + w[j] = 0 excluded_set[j] = 1 for n_iter in range(max_iter): @@ -377,9 +486,10 @@ def enet_coordinate_descent( excluded_set[j] = 0 n_active += 1 else: - # R += w[j] * X[:,j] - _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1) - w[j] = 0 + if w[j] != 0: + # R += w[j] * X[:,j] + _axpy(n_samples, w[j], &X[0, j], 1, &R[0], 1) + w[j] = 0 excluded_set[j] = 1 else: @@ -387,7 +497,7 @@ def enet_coordinate_descent( with gil: message = ( message_conv + - f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}" + f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}" ) if alpha < np.finfo(np.float64).eps: message += "\n" + message_ridge @@ -400,8 +510,8 @@ cdef inline void R_plus_wj_Xj( unsigned int n_samples, floating[::1] R, # out const floating[::1] X_data, - const int[::1] X_indices, - const int[::1] X_indptr, + const int32_t[::1] X_indices, + const int32_t[::1] X_indptr, const floating[::1] X_mean, bint center, const floating[::1] sample_weight, @@ -410,20 +520,23 @@ cdef inline void R_plus_wj_Xj( unsigned int j, ) noexcept nogil: """R += w_j * X[:,j]""" - cdef unsigned int startptr = X_indptr[j] - cdef unsigned int endptr = X_indptr[j + 1] + cdef int32_t i, i_ind + cdef int32_t startptr = X_indptr[j] + cdef int32_t endptr = X_indptr[j + 1] cdef floating sw cdef floating X_mean_j = X_mean[j] if no_sample_weights: - for i in range(startptr, endptr): - R[X_indices[i]] += X_data[i] * w_j + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + R[i] += X_data[i_ind] * w_j if center: for i in range(n_samples): R[i] -= X_mean_j * w_j else: - for i in range(startptr, endptr): - sw = sample_weight[X_indices[i]] - R[X_indices[i]] += sw * X_data[i] * w_j + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + sw = sample_weight[i] + R[i] += sw * X_data[i_ind] * w_j if center: for i in range(n_samples): R[i] -= sample_weight[i] * X_mean_j * w_j @@ -436,8 +549,8 @@ cdef (floating, floating) gap_enet_sparse( floating alpha, # L1 penalty floating beta, # L2 penalty const floating[::1] X_data, - const int[::1] X_indices, - const int[::1] X_indptr, + const int32_t[::1] X_indices, + const int32_t[::1] X_indptr, const floating[::1] y, const floating[::1] sample_weight, bint no_sample_weights, @@ -448,60 +561,91 @@ cdef (floating, floating) gap_enet_sparse( floating[::1] XtA, # XtA = X.T @ R - beta * w is calculated inplace bint positive, ) noexcept nogil: - """Compute dual gap for use in sparse_enet_coordinate_descent.""" + """Compute dual gap for use in sparse_enet_coordinate_descent. + + alpha > 0: formulation A of the duality gap + alpha = 0 & beta > 0: formulation B of the duality gap + alpha = beta = 0: OLS first order condition (=gradient) + """ cdef floating gap = 0.0 cdef floating dual_norm_XtA cdef floating R_norm2 - cdef floating w_norm2 = 0.0 - cdef floating l1_norm - cdef floating A_norm2 - cdef floating const_ - cdef unsigned int i, j + cdef floating Ry + cdef floating w_l1_norm + cdef floating w_l2_norm2 = 0.0 + cdef int32_t i, i_ind, j + + # w_l2_norm2 = w @ w + if beta > 0: + w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) + # R_norm2 = R @ R + if no_sample_weights: + R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1) + else: + R_norm2 = 0.0 + for i in range(n_samples): + # R is already multiplied by sample_weight + if sample_weight[i] != 0: + R_norm2 += (R[i] ** 2) / sample_weight[i] + # Ry = R @ y + if not (alpha == 0 and beta == 0): + # Note that with sample_weight, R equals R*sw and y is just y, such that + # Ry = (sw * R) @ y, as it should be. + Ry = _dot(n_samples, &R[0], 1, &y[0], 1) + + if alpha == 0: + # XtA = X.T @ R + for j in range(n_features): + XtA[j] = 0.0 + for i_ind in range(X_indptr[j], X_indptr[j + 1]): + i = X_indices[i_ind] + XtA[j] += X_data[i_ind] * R[i] + + if center: + XtA[j] -= X_mean[j] * R_sum + # ||X'R||_2^2 + dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1) + if beta == 0: + # This is OLS, no dual gap available. Resort to first order condition + # X'R = 0 + # gap = ||X'R||_2^2 + # Compare with stopping criterion of LSQR. + gap = dual_norm_XtA + return gap, dual_norm_XtA + # This is Ridge regression, we use formulation B for the dual gap. + gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry + gap += 1 / (2 * beta) * dual_norm_XtA + return gap, dual_norm_XtA # XtA = X.T @ R - beta * w # sparse X.T @ dense R for j in range(n_features): XtA[j] = 0.0 - for i in range(X_indptr[j], X_indptr[j + 1]): - XtA[j] += X_data[i] * R[X_indices[i]] + for i_ind in range(X_indptr[j], X_indptr[j + 1]): + i = X_indices[i_ind] + XtA[j] += X_data[i_ind] * R[i] if center: XtA[j] -= X_mean[j] * R_sum XtA[j] -= beta * w[j] + # dual_norm_XtA if positive: dual_norm_XtA = max(n_features, &XtA[0]) else: dual_norm_XtA = abs_max(n_features, &XtA[0]) - # R_norm2 = R @ R - if no_sample_weights: - R_norm2 = _dot(n_samples, &R[0], 1, &R[0], 1) - else: - R_norm2 = 0.0 - for i in range(n_samples): - # R is already multiplied by sample_weight - if sample_weight[i] != 0: - R_norm2 += (R[i] ** 2) / sample_weight[i] - - # w_norm2 = w @ w - if beta > 0: - w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) - - if (dual_norm_XtA > alpha): - const_ = alpha / dual_norm_XtA - A_norm2 = R_norm2 * const_**2 - gap = 0.5 * (R_norm2 + A_norm2) - else: - const_ = 1.0 - gap = R_norm2 - - l1_norm = _asum(n_features, &w[0], 1) - - gap += ( - alpha * l1_norm - - const_ * _dot(n_samples, &R[0], 1, &y[0], 1) # R @ y - + 0.5 * beta * (1 + const_ ** 2) * w_norm2 + # w_l1_norm = np.sum(np.abs(w)) + w_l1_norm = _asum(n_features, &w[0], 1) + + gap = dual_gap_formulation_A( + alpha=alpha, + beta=beta, + w_l1_norm=w_l1_norm, + w_l2_norm2=w_l2_norm2, + R_norm2=R_norm2, + Ry=Ry, + dual_norm_XtA=dual_norm_XtA, ) return gap, dual_norm_XtA @@ -511,8 +655,8 @@ def sparse_enet_coordinate_descent( floating alpha, floating beta, const floating[::1] X_data, - const int[::1] X_indices, - const int[::1] X_indptr, + const int32_t[::1] X_indices, + const int32_t[::1] X_indptr, const floating[::1] y, const floating[::1] sample_weight, const floating[::1] X_mean, @@ -593,19 +737,23 @@ def sparse_enet_coordinate_descent( cdef floating normalize_sum cdef unsigned int n_active = n_features cdef uint32_t[::1] active_set - # TODO: use binset insteaf of array of bools + # TODO: use binset instead of array of bools cdef uint8_t[::1] excluded_set - cdef unsigned int i + cdef int32_t i, i_ind cdef unsigned int j cdef unsigned int n_iter = 0 cdef unsigned int f_iter - cdef unsigned int startptr = X_indptr[0] - cdef unsigned int endptr + cdef int32_t startptr = X_indptr[0] + cdef int32_t endptr cdef uint32_t rand_r_state_seed = rng.randint(0, RAND_R_MAX) cdef uint32_t* rand_r_state = &rand_r_state_seed cdef bint center = False cdef bint no_sample_weights = sample_weight is None + if alpha == 0: + # No screeing without L1-penalty. + do_screening = False + if do_screening: active_set = np.empty(n_features, dtype=np.uint32) # map [:n_active] -> j excluded_set = np.empty(n_features, dtype=np.uint8) @@ -632,23 +780,25 @@ def sparse_enet_coordinate_descent( w_j = w[j] if no_sample_weights: - for i in range(startptr, endptr): - normalize_sum += (X_data[i] - X_mean_j) ** 2 - R[X_indices[i]] -= X_data[i] * w_j - norm2_cols_X[j] = normalize_sum + \ - (n_samples - endptr + startptr) * X_mean_j ** 2 + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + normalize_sum += (X_data[i_ind] - X_mean_j) ** 2 + R[i] -= X_data[i_ind] * w_j + norm2_cols_X[j] = normalize_sum if center: + norm2_cols_X[j] += (n_samples - endptr + startptr) * X_mean_j ** 2 for i in range(n_samples): R[i] += X_mean_j * w_j R_sum += R[i] else: # R = sw * (y - np.dot(X, w)) - for i in range(startptr, endptr): - tmp = sample_weight[X_indices[i]] + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + tmp = sample_weight[i] # second term will be subtracted by loop over range(n_samples) - normalize_sum += (tmp * (X_data[i] - X_mean_j) ** 2 + normalize_sum += (tmp * (X_data[i_ind] - X_mean_j) ** 2 - tmp * X_mean_j ** 2) - R[X_indices[i]] -= tmp * X_data[i] * w_j + R[i] -= tmp * X_data[i_ind] * w_j if center: for i in range(n_samples): normalize_sum += sample_weight[i] * X_mean_j ** 2 @@ -705,21 +855,22 @@ def sparse_enet_coordinate_descent( excluded_set[j] = 0 n_active += 1 else: - # R += w[j] * X[:,j] - R_plus_wj_Xj( - n_samples, - R, - X_data, - X_indices, - X_indptr, - X_mean, - center, - sample_weight, - no_sample_weights, - w[j], - j, - ) - w[j] = 0 + if w[j] != 0: + # R += w[j] * X[:,j] + R_plus_wj_Xj( + n_samples, + R, + X_data, + X_indices, + X_indptr, + X_mean, + center, + sample_weight, + no_sample_weights, + w[j], + j, + ) + w[j] = 0 excluded_set[j] = 1 for n_iter in range(max_iter): @@ -744,8 +895,9 @@ def sparse_enet_coordinate_descent( # tmp = X[:,j] @ (R + w_j * X[:,j]) tmp = 0.0 - for i in range(startptr, endptr): - tmp += R[X_indices[i]] * X_data[i] + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + tmp += R[i] * X_data[i_ind] tmp += w_j * norm2_cols_X[j] if center: @@ -821,21 +973,22 @@ def sparse_enet_coordinate_descent( excluded_set[j] = 0 n_active += 1 else: - # R += w[j] * X[:,j] - R_plus_wj_Xj( - n_samples, - R, - X_data, - X_indices, - X_indptr, - X_mean, - center, - sample_weight, - no_sample_weights, - w[j], - j, - ) - w[j] = 0 + if w[j] != 0: + # R += w[j] * X[:,j] + R_plus_wj_Xj( + n_samples, + R, + X_data, + X_indices, + X_indptr, + X_mean, + center, + sample_weight, + no_sample_weights, + w[j], + j, + ) + w[j] = 0 excluded_set[j] = 1 else: @@ -843,7 +996,7 @@ def sparse_enet_coordinate_descent( with gil: message = ( message_conv + - f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}" + f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}" ) if alpha < np.finfo(np.float64).eps: message += "\n" + message_ridge @@ -863,99 +1016,79 @@ cdef (floating, floating) gap_enet_gram( floating[::1] XtA, # XtA = X.T @ R - beta * w is calculated inplace bint positive, ) noexcept nogil: - """Compute dual gap for use in enet_coordinate_descent.""" + """Compute dual gap for use in enet_coordinate_descent. + + alpha > 0: formulation A of the duality gap + alpha = 0 & beta > 0: formulation B of the duality gap + alpha = beta = 0: OLS first order condition (=gradient) + """ cdef floating gap = 0.0 cdef floating dual_norm_XtA cdef floating R_norm2 - cdef floating w_norm2 = 0.0 - cdef floating l1_norm - cdef floating A_norm2 - cdef floating const_ + cdef floating Ry + cdef floating w_l1_norm + cdef floating w_l2_norm2 = 0.0 cdef floating q_dot_w cdef floating wQw cdef unsigned int j + # w_l2_norm2 = w @ w + if beta > 0: + w_l2_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) # q_dot_w = w @ q q_dot_w = _dot(n_features, &w[0], 1, &q[0], 1) + # wQw = w @ Q @ w + wQw = _dot(n_features, &w[0], 1, &Qw[0], 1) + # R_norm2 = R @ R, residual R = y - Xw + R_norm2 = y_norm2 + wQw - 2.0 * q_dot_w + # Ry = R @ y + if not (alpha == 0 and beta == 0): + # Note that R'y = (y - Xw)' y = ||y||_2^2 - w'X'y = y_norm2 - q_dot_w + Ry = y_norm2 - q_dot_w + + if alpha == 0: + # XtA = X'R + for j in range(n_features): + XtA[j] = q[j] - Qw[j] + # ||X'R||_2^2 + dual_norm_XtA = _dot(n_features, &XtA[0], 1, &XtA[0], 1) + if beta == 0: + # This is OLS, no dual gap available. Resort to first order condition + # X'R = 0 + # gap = ||X'R||_2^2 + # Compare with stopping criterion of LSQR. + gap = dual_norm_XtA + return gap, dual_norm_XtA + # This is Ridge regression, we use formulation B for the dual gap. + gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry + gap += 1 / (2 * beta) * dual_norm_XtA + return gap, dual_norm_XtA # XtA = X.T @ R - beta * w = X.T @ y - X.T @ X @ w - beta * w for j in range(n_features): XtA[j] = q[j] - Qw[j] - beta * w[j] + # dual_norm_XtA if positive: dual_norm_XtA = max(n_features, &XtA[0]) else: dual_norm_XtA = abs_max(n_features, &XtA[0]) - # wQw = w @ Q @ w - wQw = _dot(n_features, &w[0], 1, &Qw[0], 1) - # R_norm2 = R @ R - R_norm2 = y_norm2 + wQw - 2.0 * q_dot_w - - # w_norm2 = w @ w - if beta > 0: - w_norm2 = _dot(n_features, &w[0], 1, &w[0], 1) - - if (dual_norm_XtA > alpha): - const_ = alpha / dual_norm_XtA - A_norm2 = R_norm2 * (const_ ** 2) - gap = 0.5 * (R_norm2 + A_norm2) - else: - const_ = 1.0 - gap = R_norm2 - - l1_norm = _asum(n_features, &w[0], 1) - - gap += ( - alpha * l1_norm - - const_ * (y_norm2 - q_dot_w) # -const_ * R @ y - + 0.5 * beta * (1 + const_ ** 2) * w_norm2 + # w_l1_norm = np.sum(np.abs(w)) + w_l1_norm = _asum(n_features, &w[0], 1) + + gap = dual_gap_formulation_A( + alpha=alpha, + beta=beta, + w_l1_norm=w_l1_norm, + w_l2_norm2=w_l2_norm2, + R_norm2=R_norm2, + Ry=Ry, + dual_norm_XtA=dual_norm_XtA, ) return gap, dual_norm_XtA -cdef inline uint32_t screen_features_enet_gram( - const floating[:, ::1] Q, - const floating[::1] XtA, - floating[::1] w, - floating[::1] Qw, - uint32_t[::1] active_set, - uint8_t[::1] excluded_set, - floating alpha, - floating beta, - floating gap, - floating dual_norm_XtA, - uint32_t n_features, -) noexcept nogil: - """Apply gap safe screening for all features within enet_coordinate_descent_gram""" - cdef floating d_j - cdef floating Xj_theta - cdef uint32_t n_active = 0 - # Due to floating point issues, gap might be negative. - cdef floating radius = sqrt(2 * fabs(gap)) / alpha - - for j in range(n_features): - if Q[j, j] == 0: - w[j] = 0 - excluded_set[j] = 1 - continue - - Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA) # X[:,j] @ dual_theta - d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta) - if d_j <= radius: - # include feature j - active_set[n_active] = j - excluded_set[j] = 0 - n_active += 1 - else: - # Qw -= w[j] * Q[j] # Update Qw = Q @ w - _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1) - w[j] = 0 - excluded_set[j] = 1 - - return n_active - - def enet_coordinate_descent_gram( floating[::1] w, floating alpha, @@ -1007,6 +1140,9 @@ def enet_coordinate_descent_gram( cdef floating[::1] XtA = np.zeros(n_features, dtype=dtype) cdef floating y_norm2 = np.dot(y, y) + cdef floating d_j + cdef floating radius + cdef floating Xj_theta cdef floating tmp cdef floating w_j cdef floating d_w_max @@ -1017,7 +1153,7 @@ def enet_coordinate_descent_gram( cdef floating dual_norm_XtA cdef unsigned int n_active = n_features cdef uint32_t[::1] active_set - # TODO: use binset insteaf of array of bools + # TODO: use binset instead of array of bools cdef uint8_t[::1] excluded_set cdef unsigned int j cdef unsigned int n_iter = 0 @@ -1026,11 +1162,8 @@ def enet_coordinate_descent_gram( cdef uint32_t* rand_r_state = &rand_r_state_seed if alpha == 0: - warnings.warn( - "Coordinate descent without L1 regularization may " - "lead to unexpected results and is discouraged. " - "Set l1_ratio > 0 to add L1 regularization." - ) + # No screeing without L1-penalty. + do_screening = False if do_screening: active_set = np.empty(n_features, dtype=np.uint32) # map [:n_active] -> j @@ -1050,19 +1183,27 @@ def enet_coordinate_descent_gram( # Gap Safe Screening Rules, see https://arxiv.org/abs/1802.07481, Eq. 11 if do_screening: - n_active = screen_features_enet_gram( - Q=Q, - XtA=XtA, - w=w, - Qw=Qw, - active_set=active_set, - excluded_set=excluded_set, - alpha=alpha, - beta=beta, - gap=gap, - dual_norm_XtA=dual_norm_XtA, - n_features=n_features, - ) + # Due to floating point issues, gap might be negative. + radius = sqrt(2 * fabs(gap)) / alpha + n_active = 0 + for j in range(n_features): + if Q[j, j] == 0: + w[j] = 0 + excluded_set[j] = 1 + continue + Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA) # X[:,j] @ dual_theta + d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta) + if d_j <= radius: + # include feature j + active_set[n_active] = j + excluded_set[j] = 0 + n_active += 1 + else: + if w[j] != 0: + # Qw -= w[j] * Q[j] # Update Qw = Q @ w + _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1) + w[j] = 0 + excluded_set[j] = 1 for n_iter in range(max_iter): w_max = 0.0 @@ -1116,27 +1257,35 @@ def enet_coordinate_descent_gram( # Gap Safe Screening Rules, see https://arxiv.org/abs/1802.07481, Eq. 11 if do_screening: - n_active = screen_features_enet_gram( - Q=Q, - XtA=XtA, - w=w, - Qw=Qw, - active_set=active_set, - excluded_set=excluded_set, - alpha=alpha, - beta=beta, - gap=gap, - dual_norm_XtA=dual_norm_XtA, - n_features=n_features, - ) + # Due to floating point issues, gap might be negative. + radius = sqrt(2 * fabs(gap)) / alpha + n_active = 0 + for j in range(n_features): + if excluded_set[j]: + continue + Xj_theta = XtA[j] / fmax(alpha, dual_norm_XtA) # X @ dual_theta + d_j = (1 - fabs(Xj_theta)) / sqrt(Q[j, j] + beta) + if d_j <= radius: + # include feature j + active_set[n_active] = j + excluded_set[j] = 0 + n_active += 1 + else: + if w[j] != 0: + # Qw -= w[j] * Q[j] # Update Qw = Q @ w + _axpy(n_features, -w[j], &Q[j, 0], 1, &Qw[0], 1) + w[j] = 0 + excluded_set[j] = 1 else: # for/else, runs if for doesn't end with a `break` with gil: message = ( message_conv + - f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}" + f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}" ) + if alpha < np.finfo(np.float64).eps: + message += "\n" + message_ridge warnings.warn(message, ConvergenceWarning) return np.asarray(w), gap, tol, n_iter + 1 @@ -1146,12 +1295,21 @@ cdef (floating, floating) gap_enet_multi_task( int n_samples, int n_features, int n_tasks, - const floating[::1, :] W, # in - floating l1_reg, - floating l2_reg, - const floating[::1, :] X, # in - const floating[::1, :] Y, # in - const floating[::1, :] R, # in + const floating[::1, :] W, + floating alpha, + floating beta, + const floating[::1, :] X, + bint X_is_sparse, + const floating[::1] X_data, + const int32_t[::1] X_indices, + const int32_t[::1] X_indptr, + const floating[::1, :] Y, + const floating[::1] sample_weight, + bint no_sample_weights, + const floating[::1] X_mean, + bint center, + const floating[::1, :] R, # current residuals = y - X @ w + const floating[::1] R_sum, floating[:, ::1] XtA, # out floating[::1] XtA_row_norms, # out ) noexcept nogil: @@ -1165,23 +1323,72 @@ cdef (floating, floating) gap_enet_multi_task( R : memoryview of shape (n_samples, n_tasks) Current residuals = Y - X @ W.T XtA : memoryview of shape (n_features, n_tasks) - Inplace calculated as XtA = X.T @ R - l2_reg * W.T + Inplace calculated as XtA = X.T @ R - beta * W.T XtA_row_norms : memoryview of shape n_features Inplace calculated as np.sqrt(np.sum(XtA ** 2, axis=1)) """ cdef floating gap = 0.0 cdef floating dual_norm_XtA cdef floating R_norm2 - cdef floating w_norm2 = 0.0 - cdef floating l21_norm - cdef floating A_norm2 - cdef floating const_ + cdef floating Ry + cdef floating w_l21_norm + cdef floating w_l2_norm2 = 0.0 cdef unsigned int t, j + cdef int32_t i + + # w_l2_norm2 = linalg.norm(W, ord="fro") ** 2 + if beta > 0: + w_l2_norm2 = _dot(n_features * n_tasks, &W[0, 0], 1, &W[0, 0], 1) + # R_norm2 = linalg.norm(R, ord="fro") ** 2 + if not X_is_sparse or no_sample_weights: + R_norm2 = _dot(n_samples * n_tasks, &R[0, 0], 1, &R[0, 0], 1) + else: # sparse X and sample_weights + R_norm2 = 0.0 + for t in range(n_tasks): + for i in range(n_samples): + # R is already multiplied by sample_weight + if sample_weight[i] != 0: + R_norm2 += (R[i, t] ** 2) / sample_weight[i] + # Ry = np.sum(R * Y) + if not (alpha == 0 and beta == 0): + # Note that with sample_weight, R equals R*sw and y is just y, such that + # Ry = (sw * R) @ y, as it should be. + Ry = _dot(n_samples * n_tasks, &R[0, 0], 1, &Y[0, 0], 1) - # XtA = X.T @ R - l2_reg * W.T + if alpha == 0: + # XtA = X.T @ R + for j in range(n_features): + for t in range(n_tasks): + if not X_is_sparse: + XtA[j, t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1) + else: + XtA[j, t] = sparse_dot(j, X_data, X_indices, X_indptr, R[:, t]) + if center: + XtA[j, t] -= X_mean[j] * R_sum[t] + # ||X'R||_2^2 + dual_norm_XtA = _dot(n_features * n_tasks, &XtA[0, 0], 1, &XtA[0, 0], 1) + if beta == 0: + # This is OLS, no dual gap available. Resort to first order condition + # X'R = 0 + # gap = ||X'R||_2^2 + # Compare with stopping criterion of LSQR. + gap = dual_norm_XtA + return gap, dual_norm_XtA + # This is Ridge regression, we use formulation B for the dual gap. + gap = R_norm2 + 0.5 * beta * w_l2_norm2 - Ry + gap += 1 / (2 * beta) * dual_norm_XtA + return gap, dual_norm_XtA + + # XtA = X.T @ R - beta * W.T for j in range(n_features): for t in range(n_tasks): - XtA[j, t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1) - l2_reg * W[t, j] + if not X_is_sparse: + XtA[j, t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1) - beta * W[t, j] + else: + XtA[j, t] = sparse_dot(j, X_data, X_indices, X_indptr, R[:, t]) + if center: + XtA[j, t] -= X_mean[j] * R_sum[t] + XtA[j, t] -= beta * W[t, j] # dual_norm_XtA = np.max(np.sqrt(np.sum(XtA ** 2, axis=1))) dual_norm_XtA = 0.0 @@ -1191,40 +1398,35 @@ cdef (floating, floating) gap_enet_multi_task( if XtA_row_norms[j] > dual_norm_XtA: dual_norm_XtA = XtA_row_norms[j] - # R_norm2 = linalg.norm(R, ord="fro") ** 2 - R_norm2 = _dot(n_samples * n_tasks, &R[0, 0], 1, &R[0, 0], 1) - - # w_norm2 = linalg.norm(W, ord="fro") ** 2 - if l2_reg > 0: - w_norm2 = _dot(n_features * n_tasks, &W[0, 0], 1, &W[0, 0], 1) - - if (dual_norm_XtA > l1_reg): - const_ = l1_reg / dual_norm_XtA - A_norm2 = R_norm2 * (const_ ** 2) - gap = 0.5 * (R_norm2 + A_norm2) - else: - const_ = 1.0 - gap = R_norm2 - - # l21_norm = np.sqrt(np.sum(W ** 2, axis=0)).sum() - l21_norm = 0.0 + # w_l21_norm = np.sqrt(np.sum(W ** 2, axis=0)).sum() + w_l21_norm = 0.0 for ii in range(n_features): - l21_norm += _nrm2(n_tasks, &W[0, ii], 1) - - gap += ( - l1_reg * l21_norm - - const_ * _dot(n_samples * n_tasks, &R[0, 0], 1, &Y[0, 0], 1) # np.sum(R * Y) - + 0.5 * l2_reg * (1 + const_ ** 2) * w_norm2 + w_l21_norm += _nrm2(n_tasks, &W[0, ii], 1) + + gap = dual_gap_formulation_A( + alpha=alpha, + beta=beta, + w_l1_norm=w_l21_norm, + w_l2_norm2=w_l2_norm2, + R_norm2=R_norm2, + Ry=Ry, + dual_norm_XtA=dual_norm_XtA, ) return gap, dual_norm_XtA def enet_coordinate_descent_multi_task( floating[::1, :] W, - floating l1_reg, - floating l2_reg, + floating alpha, + floating beta, const floating[::1, :] X, + bint X_is_sparse, + const floating[::1] X_data, + const int32_t[::1] X_indices, + const int32_t[::1] X_indptr, const floating[::1, :] Y, + const floating[::1] sample_weight, + const floating[::1] X_mean, unsigned int max_iter, floating tol, object rng, @@ -1236,7 +1438,7 @@ def enet_coordinate_descent_multi_task( We minimize - 0.5 * norm(Y - X W.T, 2)^2 + l1_reg ||W.T||_21 + 0.5 * l2_reg norm(W.T, 2)^2 + 0.5 * norm(Y - X W.T, 2)^2 + alpha * ||W.T||_21 + 0.5 * beta * norm(W.T, 2)^2 The algorithm follows Noah Simon, Jerome Friedman, Trevor Hastie. 2013. @@ -1255,6 +1457,15 @@ def enet_coordinate_descent_multi_task( n_iter : int Number of coordinate descent iterations. """ + # Notes for sample_weight: + # For dense X, one centers X and y and then rescales them by sqrt(sample_weight). + # For sparse X, we get the sample_weight averaged center X_mean. We take care + # that every calculation results as if we had rescaled y and X (and therefore also + # X_mean) by sqrt(sample_weight) without actually calculating the square root. + # We work with: + # yw = sample_weight * y + # R = sample_weight * residual + # norm2_cols_X = np.sum(sample_weight * (X - X_mean)**2, axis=0) if floating is float: dtype = np.float32 @@ -1262,20 +1473,19 @@ def enet_coordinate_descent_multi_task( dtype = np.float64 # get the data information into easy vars - cdef unsigned int n_samples = X.shape[0] - cdef unsigned int n_features = X.shape[1] + cdef unsigned int n_samples = Y.shape[0] + cdef unsigned int n_features = W.shape[1] cdef unsigned int n_tasks = Y.shape[1] # compute squared norms of the columns of X - # same as norm2_cols_X = np.square(X).sum(axis=0) - cdef floating[::1] norm2_cols_X = np.einsum( - "ij,ij->j", X, X, dtype=dtype, order="C" - ) + norm2_cols_X_array = np.empty(shape=n_features, dtype=dtype) + cdef floating[::1] norm2_cols_X = norm2_cols_X_array # initial value of the residuals - cdef floating[::1, :] R = np.empty((n_samples, n_tasks), dtype=dtype, order='F') + cdef floating[::1, :] R # shape (n_samples, n_tasks) cdef floating[:, ::1] XtA = np.empty((n_features, n_tasks), dtype=dtype) cdef floating[::1] XtA_row_norms = np.empty(n_features, dtype=dtype) + cdef const floating[::1, :] Yw cdef floating d_j cdef floating Xj_theta @@ -1289,41 +1499,129 @@ def enet_coordinate_descent_multi_task( cdef floating gap = tol + 1.0 cdef floating d_w_tol = tol cdef floating dual_norm_XtA + cdef floating[::1] R_sum cdef unsigned int n_active = n_features cdef uint32_t[::1] active_set # TODO: use binset instead of array of bools cdef uint8_t[::1] excluded_set cdef unsigned int j cdef unsigned int t + cdef int32_t i, i_ind, startptr, endptr cdef unsigned int n_iter = 0 cdef unsigned int f_iter cdef uint32_t rand_r_state_seed = rng.randint(0, RAND_R_MAX) cdef uint32_t* rand_r_state = &rand_r_state_seed + cdef bint center = False + cdef bint no_sample_weights = sample_weight is None - if l1_reg == 0: - warnings.warn( - "Coordinate descent with l1_reg=0 may lead to unexpected" - " results and is discouraged." - ) + if alpha == 0: + # No screeing without L1-penalty. + do_screening = False if do_screening: active_set = np.empty(n_features, dtype=np.uint32) # map [:n_active] -> j excluded_set = np.empty(n_features, dtype=np.uint8) + if no_sample_weights or not X_is_sparse: + Yw = Y + R = np.copy(Y, order="F") + else: + Yw = np.multiply(sample_weight[:, None], Y) + R = np.copy(Yw, order="F") + + if X_is_sparse: + R_sum = np.zeros(n_tasks, dtype=dtype) + # center = (X_mean != 0).any() + for j in range(n_features): + if X_mean[j]: + center = True + break + + # compute squared norms of the columns of X + # same as norm2_cols_X = np.square(X).sum(axis=0) + if not X_is_sparse: + np.einsum("ij,ij->j", X, X, dtype=dtype, out=norm2_cols_X_array) + else: + for j in range(n_features): + norm2_cols_X[j] = 0 + startptr = X_indptr[j] + endptr = X_indptr[j + 1] + if no_sample_weights: + for i_ind in range(startptr, endptr): + norm2_cols_X[j] += (X_data[i_ind] - X_mean[j]) ** 2 + if center: + norm2_cols_X[j] += (n_samples - endptr + startptr) * X_mean[j] ** 2 + else: + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + norm2_cols_X[j] += sample_weight[i] * ( + (X_data[i_ind] - X_mean[j]) ** 2 - X_mean[j] ** 2 + ) + if center: + for i in range(n_samples): + norm2_cols_X[j] += sample_weight[i] * X_mean[j] ** 2 + with nogil: # R = Y - X @ W.T - _copy(n_samples * n_tasks, &Y[0, 0], 1, &R[0, 0], 1) + # R = Y was already set above. for j in range(n_features): for t in range(n_tasks): if W[t, j] != 0: - _axpy(n_samples, -W[t, j], &X[0, j], 1, &R[0, t], 1) + if not X_is_sparse: + _axpy(n_samples, -W[t, j], &X[0, j], 1, &R[0, t], 1) + else: + if no_sample_weights: + sparse_axpy(j, -W[t, j], X_data, X_indices, X_indptr, R[:, t]) + else: + startptr = X_indptr[j] + endptr = X_indptr[j + 1] + for i_ind in range(startptr, endptr): + i = X_indices[i_ind] + R[i, t] -= sample_weight[i] * X_data[i_ind] * W[t, j] + + if X_is_sparse and center: + # R = Y - (X - X_mean) @ W.T + if no_sample_weights: + for i in range(n_samples): + for t in range(n_tasks): + R[i, t] += X_mean[j] * W[t, j] + R_sum[t] += R[i, t] + else: + for i in range(n_samples): + for t in range(n_tasks): + R[i, t] += sample_weight[i] * X_mean[j] * W[t, j] + R_sum[t] += R[i, t] + + # Note: No need to update R_sum from here on because the update terms cancel + # each other: w_j[t] * np.sum(X[:,j] - X_mean[j]) = 0. R_sum is only ever + # needed and calculated if X_mean is provided. # tol = tol * linalg.norm(Y, ord='fro') ** 2 - tol = tol * _nrm2(n_samples * n_tasks, &Y[0, 0], 1) ** 2 + # with sample weights: tol *= y @ (sw * y) + tol *= _dot(n_samples * n_tasks, &Y[0, 0], 1, &Yw[0, 0], 1) # Check convergence before entering the main loop. gap, dual_norm_XtA = gap_enet_multi_task( - n_samples, n_features, n_tasks, W, l1_reg, l2_reg, X, Y, R, XtA, XtA_row_norms + n_samples=n_samples, + n_features=n_features, + n_tasks=n_tasks, + W=W, + alpha=alpha, + beta=beta, + X=X, + X_is_sparse=X_is_sparse, + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + Y=Y, + sample_weight=sample_weight, + no_sample_weights=no_sample_weights, + X_mean=X_mean, + center=center, + R=R, + R_sum=R_sum, + XtA=XtA, + XtA_row_norms=XtA_row_norms, ) if gap <= tol: with gil: @@ -1340,18 +1638,34 @@ def enet_coordinate_descent_multi_task( excluded_set[j] = 1 continue # Xj_theta = ||X[:,j] @ dual_theta||_2 - Xj_theta = XtA_row_norms[j] / fmax(l1_reg, dual_norm_XtA) - d_j = (1 - Xj_theta) / sqrt(norm2_cols_X[j] + l2_reg) - if d_j <= sqrt(2 * gap) / l1_reg: + Xj_theta = XtA_row_norms[j] / fmax(alpha, dual_norm_XtA) + d_j = (1 - Xj_theta) / sqrt(norm2_cols_X[j] + beta) + if d_j <= sqrt(2 * gap) / alpha: # include feature j active_set[n_active] = j excluded_set[j] = 0 n_active += 1 else: - # R += W[:, 1] * X[:, 1][:, None] + # R += W[:, j] * X[:, 1][:, None] for t in range(n_tasks): - _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1) - W[t, j] = 0 + if W[t, j] != 0: + if not X_is_sparse: + _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1) + else: + R_plus_wj_Xj( + n_samples=n_samples, + R=R[:, t], + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + X_mean=X_mean, + center=center, + sample_weight=sample_weight, + no_sample_weights=no_sample_weights, + w_j=W[t, j], + j=j, + ) + W[t, j] = 0 excluded_set[j] = 1 for n_iter in range(max_iter): @@ -1382,7 +1696,10 @@ def enet_coordinate_descent_multi_task( # _axpy(n_tasks, norm2_cols[j], &w_j[0], 1, &tmp[0], 1) # Using BLAS Level 1 (faster for small vectors like here): for t in range(n_tasks): - tmp[t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1) + if not X_is_sparse: + tmp[t] = _dot(n_samples, &X[0, j], 1, &R[0, t], 1) + else: + tmp[t] = sparse_dot(j, X_data, X_indices, X_indptr, R[:, t]) # As we have the loop already, we use it to replace the second BLAS # Level 1, i.e., _axpy, too. tmp[t] += w_j[t] * norm2_cols_X[j] @@ -1390,9 +1707,9 @@ def enet_coordinate_descent_multi_task( # nn = sqrt(np.sum(tmp ** 2)) nn = _nrm2(n_tasks, &tmp[0], 1) - # W[:, j] = tmp * fmax(1. - l1_reg / nn, 0) / (norm2_cols_X[j] + l2_reg) + # W[:, j] = tmp * fmax(1. - alpha / nn, 0) / (norm2_cols_X[j] + beta) _copy(n_tasks, &tmp[0], 1, &W[0, j], 1) - _scal(n_tasks, fmax(1. - l1_reg / nn, 0) / (norm2_cols_X[j] + l2_reg), + _scal(n_tasks, fmax(1. - alpha / nn, 0) / (norm2_cols_X[j] + beta), &W[0, j], 1) # Update residual @@ -1406,7 +1723,22 @@ def enet_coordinate_descent_multi_task( # Using BLAS Level 1 (faster for small vectors like here): for t in range(n_tasks): if W[t, j] != w_j[t]: - _axpy(n_samples, w_j[t] - W[t, j], &X[0, j], 1, &R[0, t], 1) + if not X_is_sparse: + _axpy(n_samples, w_j[t] - W[t, j], &X[0, j], 1, &R[0, t], 1) + else: + R_plus_wj_Xj( + n_samples=n_samples, + R=R[:, t], + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + X_mean=X_mean, + center=center, + sample_weight=sample_weight, + no_sample_weights=no_sample_weights, + w_j=w_j[t] - W[t, j], + j=j, + ) # update the maximum absolute coefficient update d_w_j = diff_abs_max(n_tasks, &W[0, j], &w_j[0]) @@ -1423,7 +1755,26 @@ def enet_coordinate_descent_multi_task( # the tolerance: check the duality gap as ultimate stopping # criterion gap, dual_norm_XtA = gap_enet_multi_task( - n_samples, n_features, n_tasks, W, l1_reg, l2_reg, X, Y, R, XtA, XtA_row_norms + n_samples=n_samples, + n_features=n_features, + n_tasks=n_tasks, + W=W, + alpha=alpha, + beta=beta, + X=X, + X_is_sparse=X_is_sparse, + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + Y=Y, + sample_weight=sample_weight, + no_sample_weights=no_sample_weights, + X_mean=X_mean, + center=center, + R=R, + R_sum=R_sum, + XtA=XtA, + XtA_row_norms=XtA_row_norms, ) if gap <= tol: # return if we reached desired tolerance @@ -1434,21 +1785,37 @@ def enet_coordinate_descent_multi_task( if do_screening: n_active = 0 for j in range(n_features): - if norm2_cols_X[j] == 0: + if excluded_set[j]: continue # Xj_theta = ||X[:,j] @ dual_theta||_2 - Xj_theta = XtA_row_norms[j] / fmax(l1_reg, dual_norm_XtA) - d_j = (1 - Xj_theta) / sqrt(norm2_cols_X[j] + l2_reg) - if d_j <= sqrt(2 * gap) / l1_reg: + Xj_theta = XtA_row_norms[j] / fmax(alpha, dual_norm_XtA) + d_j = (1 - Xj_theta) / sqrt(norm2_cols_X[j] + beta) + if d_j <= sqrt(2 * gap) / alpha: # include feature j active_set[n_active] = j excluded_set[j] = 0 n_active += 1 else: - # R += W[:, 1] * X[:, 1][:, None] + # R += W[:, j] * X[:, 1][:, None] for t in range(n_tasks): - _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1) - W[t, j] = 0 + if W[t, j] != 0: + if not X_is_sparse: + _axpy(n_samples, W[t, j], &X[0, j], 1, &R[0, t], 1) + else: + R_plus_wj_Xj( + n_samples=n_samples, + R=R[:, t], + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + X_mean=X_mean, + center=center, + sample_weight=sample_weight, + no_sample_weights=no_sample_weights, + w_j=W[t, j], + j=j, + ) + W[t, j] = 0 excluded_set[j] = 1 else: @@ -1456,8 +1823,10 @@ def enet_coordinate_descent_multi_task( with gil: message = ( message_conv + - f" Duality gap: {gap:.3e}, tolerance: {tol:.3e}" + f" Duality gap: {gap:.6e}, tolerance: {tol:.3e}" ) + if alpha < np.finfo(np.float64).eps: + message += "\n" + message_ridge warnings.warn(message, ConvergenceWarning) return np.asarray(W), gap, tol, n_iter + 1 diff --git a/sklearn/linear_model/_coordinate_descent.py b/sklearn/linear_model/_coordinate_descent.py index efa5a76adfad5..afbbc67308d6c 100644 --- a/sklearn/linear_model/_coordinate_descent.py +++ b/sklearn/linear_model/_coordinate_descent.py @@ -12,18 +12,20 @@ from joblib import effective_n_jobs from scipy import sparse -from sklearn.base import MultiOutputMixin, RegressorMixin, _fit_context +from sklearn.base import RegressorMixin, _fit_context # mypy error: Module 'sklearn.linear_model' has no attribute '_cd_fast' from sklearn.linear_model import _cd_fast as cd_fast # type: ignore[attr-defined] -from sklearn.linear_model._base import LinearModel, _pre_fit, _preprocess_data +from sklearn.linear_model._base import ( + MultiOutputLinearModel, + _pre_fit, +) from sklearn.model_selection import check_cv from sklearn.utils import Bunch, check_array, check_scalar, metadata_routing from sklearn.utils._metadata_requests import ( MetadataRouter, MethodMapping, _raise_for_params, - get_routing_for_object, ) from sklearn.utils._param_validation import ( Hidden, @@ -31,6 +33,7 @@ StrOptions, validate_params, ) +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import safe_sparse_dot from sklearn.utils.metadata_routing import _routing_enabled, process_routing from sklearn.utils.parallel import Parallel, delayed @@ -41,7 +44,6 @@ check_is_fitted, check_random_state, column_or_1d, - has_fit_parameter, validate_data, ) @@ -102,6 +104,8 @@ def _alpha_grid( eps=1e-3, n_alphas=100, sample_weight=None, + *, + positive: bool = False, ): """Compute the grid of alpha values for elastic net parameter search @@ -124,9 +128,8 @@ def _alpha_grid( l1_ratio : float, default=1.0 The elastic net mixing parameter, with ``0 < l1_ratio <= 1``. - For ``l1_ratio = 0`` the penalty is an L2 penalty. (currently not - supported) ``For l1_ratio = 1`` it is an L1 penalty. For - ``0 < l1_ratio <1``, the penalty is a combination of L1 and L2. + For ``l1_ratio = 0``, there would be no L1 penalty which is not supported + for the generation of alphas. eps : float, default=1e-3 Length of the path. ``eps=1e-3`` means that @@ -140,6 +143,9 @@ def _alpha_grid( sample_weight : ndarray of shape (n_samples,), default=None + positive : bool, default=False + If set to True, forces coefficients to be positive. + Returns ------- np.ndarray @@ -186,9 +192,15 @@ def _alpha_grid( n_samples = sample_weight.sum() else: n_samples = X.shape[0] - # Compute np.max(np.sqrt(np.sum(Xyw**2, axis=1))). We switch sqrt and max to avoid - # many computations of sqrt. This, however, needs an additional np.abs. - alpha_max = np.sqrt(np.max(np.abs(np.sum(Xyw**2, axis=1)))) / (n_samples * l1_ratio) + + if not positive: + # Compute np.max(np.sqrt(np.sum(Xyw**2, axis=1))). We switch sqrt and max to + # avoid many computations of sqrt. + alpha_max = np.sqrt(np.max(np.sum(Xyw**2, axis=1))) / (n_samples * l1_ratio) + else: + # We may safely assume Xyw.shape[1] == 1, MultiTask estimators do not support + # positive constraints. + alpha_max = max(0, np.max(Xyw)) / (n_samples * l1_ratio) if alpha_max <= np.finfo(np.float64).resolution: return np.full(n_alphas, np.finfo(np.float64).resolution) @@ -201,8 +213,17 @@ def _alpha_grid( "X": ["array-like", "sparse matrix"], "y": ["array-like", "sparse matrix"], "eps": [Interval(Real, 0, None, closed="neither")], - "n_alphas": [Interval(Integral, 1, None, closed="left")], - "alphas": ["array-like", None], + "n_alphas": [ + Interval(Integral, 1, None, closed="left"), + Hidden(StrOptions({"deprecated"})), + ], + # TODO(1.11): remove "warn" and None options. + "alphas": [ + Interval(Integral, 1, None, closed="left"), + "array-like", + None, + Hidden(StrOptions({"warn"})), + ], "precompute": [StrOptions({"auto"}), "boolean", "array-like"], "Xy": ["array-like", None], "copy_X": ["boolean"], @@ -218,8 +239,8 @@ def lasso_path( y, *, eps=1e-3, - n_alphas=100, - alphas=None, + n_alphas="deprecated", + alphas="warn", precompute="auto", Xy=None, copy_X=True, @@ -372,13 +393,46 @@ def lasso_path( [[0. 0. 0.46915237] [0.2159048 0.4425765 0.23668876]] """ + # TODO(1.11): remove n_alphas and alphas={"warn", None}; set alphas=100 by default. + # Remove these deprecations messages and use alphas directly instead of instead of + # _alphas. + if n_alphas == "deprecated": + _alphas = 100 # the old, current, and future default;-) + else: + warnings.warn( + "'n_alphas' was deprecated in 1.9 and will be removed in 1.11. " + "'alphas' now accepts an integer value which removes the need to pass " + "'n_alphas'. The default value of 'alphas' will change from None to " + "100 in 1.11. Pass an explicit value to 'alphas' and leave 'n_alphas' " + "to its default value to silence this warning.", + FutureWarning, + ) + _alphas = n_alphas + + if isinstance(alphas, str) and alphas == "warn": + # - If n_alphas == "deprecated", both are left to their default values so we + # don't warn since the future default behavior will be the same as the + # current default behavior. + # - self.n_alphas != "deprecated", then we already warned about it and the + # warning message mentions the future alphas default, so no need to warn a + # second time. + pass + elif alphas is None: + warnings.warn( + "'alphas=None' is deprecated and will be removed in 1.11, at which " + "point the default value will be set to 100. Set 'alphas=100' " + "to silence this warning.", + FutureWarning, + ) + else: + _alphas = alphas + return enet_path( X, y, l1_ratio=1.0, eps=eps, - n_alphas=n_alphas, - alphas=alphas, + alphas=_alphas, precompute=precompute, Xy=Xy, copy_X=copy_X, @@ -396,8 +450,17 @@ def lasso_path( "y": ["array-like", "sparse matrix"], "l1_ratio": [Interval(Real, 0.0, 1.0, closed="both")], "eps": [Interval(Real, 0.0, None, closed="neither")], - "n_alphas": [Interval(Integral, 1, None, closed="left")], - "alphas": ["array-like", None], + "n_alphas": [ + Interval(Integral, 1, None, closed="left"), + Hidden(StrOptions({"deprecated"})), + ], + # TODO(1.11): remove "warn" and None options. + "alphas": [ + Interval(Integral, 1, None, closed="left"), + "array-like", + None, + Hidden(StrOptions({"warn"})), + ], "precompute": [StrOptions({"auto"}), "boolean", "array-like"], "Xy": ["array-like", None], "copy_X": ["boolean"], @@ -415,8 +478,8 @@ def enet_path( *, l1_ratio=0.5, eps=1e-3, - n_alphas=100, - alphas=None, + n_alphas="deprecated", + alphas="warn", precompute="auto", Xy=None, copy_X=True, @@ -439,7 +502,7 @@ def enet_path( For multi-output tasks it is:: - (1 / (2 * n_samples)) * ||Y - XW||_Fro^2 + 1 / (2 * n_samples) * ||Y - XW||_Fro^2 + alpha * l1_ratio * ||W||_21 + 0.5 * alpha * (1 - l1_ratio) * ||W||_Fro^2 @@ -447,7 +510,7 @@ def enet_path( ||W||_21 = \\sum_i \\sqrt{\\sum_j w_{ij}^2} - i.e. the sum of norm of each row. + i.e. the sum of L2-norm of each row (task) (i=feature, j=task) Read more in the :ref:`User Guide <elastic_net>`. @@ -555,7 +618,7 @@ def enet_path( ... ) >>> true_coef array([ 0. , 0. , 0. , 97.9, 45.7]) - >>> alphas, estimated_coef, _ = enet_path(X, y, n_alphas=3) + >>> alphas, estimated_coef, _ = enet_path(X, y, alphas=3) >>> alphas.shape (3,) >>> estimated_coef @@ -565,6 +628,40 @@ def enet_path( [ 0., 23.046, 88.939], [ 0., 10.637, 41.566]]) """ + # TODO(1.11): remove n_alphas and alphas={"warn", None}; set alphas=100 by default. + # Remove these deprecations messages and use alphas directly instead of instead of + # _alphas. + if n_alphas == "deprecated": + _alphas = 100 # the old, current, and future default;-) + else: + warnings.warn( + "'n_alphas' was deprecated in 1.9 and will be removed in 1.11. " + "'alphas' now accepts an integer value which removes the need to pass " + "'n_alphas'. The default value of 'alphas' will change from None to " + "100 in 1.11. Pass an explicit value to 'alphas' and leave 'n_alphas' " + "to its default value to silence this warning.", + FutureWarning, + ) + _alphas = n_alphas + + if isinstance(alphas, str) and alphas == "warn": + # - If n_alphas == "deprecated", both are left to their default values so we + # don't warn since the future default behavior will be the same as the + # current default behavior. + # - self.n_alphas != "deprecated", then we already warned about it and the + # warning message mentions the future alphas default, so no need to warn a + # second time. + pass + elif alphas is None: + warnings.warn( + "'alphas=None' is deprecated and will be removed in 1.11, at which " + "point the default value will be set to 100. Set 'alphas=100' " + "to silence this warning.", + FutureWarning, + ) + else: + _alphas = alphas + X_offset_param = params.pop("X_offset", None) X_scale_param = params.pop("X_scale", None) sample_weight = params.pop("sample_weight", None) @@ -611,8 +708,8 @@ def enet_path( if multi_output and positive: raise ValueError("positive=True is not allowed for multi-output (y.ndim != 1)") - # MultiTaskElasticNet does not support sparse matrices - if not multi_output and sparse.issparse(X): + X_is_sparse = sparse.issparse(X) + if X_is_sparse: if X_offset_param is not None: # As sparse matrices are not actually centered we need this to be passed to # the CD solver. @@ -620,10 +717,12 @@ def enet_path( X_sparse_scaling = np.asarray(X_sparse_scaling, dtype=X.dtype) else: X_sparse_scaling = np.zeros(n_features, dtype=X.dtype) + else: + X_sparse_scaling = None # X should have been passed through _pre_fit already if function is called # from ElasticNet.fit - if check_input: + if check_input or precompute is not False: X, y, _, _, _, precompute, Xy = _pre_fit( X, y, @@ -631,9 +730,9 @@ def enet_path( precompute, fit_intercept=False, copy=False, - check_gram=True, + check_gram=check_input, ) - if alphas is None: + if isinstance(_alphas, Integral): # fit_intercept and sample_weight have already been dealt with in calling # methods like ElasticNet.fit. alphas = _alpha_grid( @@ -642,11 +741,12 @@ def enet_path( Xy=Xy, l1_ratio=l1_ratio, fit_intercept=False, + positive=positive, eps=eps, - n_alphas=n_alphas, + n_alphas=_alphas, ) - elif len(alphas) > 1: - alphas = np.sort(alphas)[::-1] # make sure alphas are properly ordered + elif len(_alphas) > 1: + alphas = np.sort(_alphas)[::-1] # make sure alphas are properly ordered n_alphas = len(alphas) dual_gaps = np.empty(n_alphas) @@ -667,18 +767,27 @@ def enet_path( else: coef_ = np.asfortranarray(coef_init, dtype=X.dtype) + if X_is_sparse: + X_data = X.data + X_indices = X.indices + X_indptr = X.indptr + else: + X_data = None + X_indices = None + X_indptr = None + for i, alpha in enumerate(alphas): # account for n_samples scaling in objectives between here and cd_fast l1_reg = alpha * l1_ratio * n_samples l2_reg = alpha * (1.0 - l1_ratio) * n_samples - if not multi_output and sparse.issparse(X): + if not multi_output and X_is_sparse: model = cd_fast.sparse_enet_coordinate_descent( w=coef_, alpha=l1_reg, beta=l2_reg, - X_data=X.data, - X_indices=X.indices, - X_indptr=X.indptr, + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, y=y, sample_weight=sample_weight, X_mean=X_sparse_scaling, @@ -691,7 +800,22 @@ def enet_path( ) elif multi_output: model = cd_fast.enet_coordinate_descent_multi_task( - coef_, l1_reg, l2_reg, X, y, max_iter, tol, rng, random, do_screening + W=coef_, + alpha=l1_reg, + beta=l2_reg, + X=None if X_is_sparse else X, + X_is_sparse=X_is_sparse, + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + Y=y, + sample_weight=sample_weight, + X_mean=X_sparse_scaling, + max_iter=max_iter, + tol=tol, + rng=rng, + random=random, + do_screening=do_screening, ) elif isinstance(precompute, np.ndarray): # We expect precompute to be already Fortran ordered when bypassing @@ -755,7 +879,7 @@ def enet_path( # ElasticNet model -class ElasticNet(MultiOutputMixin, RegressorMixin, LinearModel): +class ElasticNet(RegressorMixin, MultiOutputLinearModel): """Linear regression with combined L1 and L2 priors as regularizer. Minimizes the objective function: @@ -973,7 +1097,7 @@ def fit(self, X, y, sample_weight=None, check_input=True): Parameters ---------- - X : {ndarray, sparse matrix, sparse array} of (n_samples, n_features) + X : {ndarray, sparse matrix, sparse array} of shape (n_samples, n_features) Data. Note that large sparse matrices and arrays requiring `int64` @@ -1040,7 +1164,6 @@ def fit(self, X, y, sample_weight=None, check_input=True): ) n_samples, n_features = X.shape - alpha = self.alpha if isinstance(sample_weight, numbers.Number): sample_weight = None @@ -1125,8 +1248,7 @@ def fit(self, X, y, sample_weight=None, check_input=True): y[:, k], l1_ratio=self.l1_ratio, eps=None, - n_alphas=None, - alphas=[alpha], + alphas=[self.alpha], precompute=precompute, Xy=this_Xy, copy_X=True, @@ -1172,7 +1294,7 @@ def fit(self, X, y, sample_weight=None, check_input=True): @property def sparse_coef_(self): """Sparse representation of the fitted `coef_`.""" - return sparse.csr_matrix(self.coef_) + return _align_api_if_sparse(sparse.csr_array(np.atleast_2d(self.coef_))) def _decision_function(self, X): """Decision function of the linear model. @@ -1273,12 +1395,12 @@ class Lasso(ElasticNet): Parameter vector (w in the cost function formula). dual_gap_ : float or ndarray of shape (n_targets,) - Given param alpha, the dual gaps at the end of the optimization, + Given parameter ``alpha``, the dual gaps at the end of the optimization, same shape as each observation of y. sparse_coef_ : sparse matrix of shape (n_features, 1) or \ (n_targets, n_features) - Readonly property derived from ``coef_``. + Read-only property derived from ``coef_``. intercept_ : float or ndarray of shape (n_targets,) Independent term in decision function. @@ -1321,7 +1443,7 @@ class Lasso(ElasticNet): :class:`~sklearn.svm.LinearSVC`. The precise stopping criteria based on `tol` are the following: First, check that - that maximum coordinate update, i.e. :math:`\\max_j |w_j^{new} - w_j^{old}|` + the maximum coordinate update, i.e. :math:`\\max_j |w_j^{new} - w_j^{old}|` is smaller or equal to `tol` times the maximum absolute coefficient, :math:`\\max_j |w_j|`. If so, then additionally check whether the dual gap is smaller or equal to `tol` times :math:`||y||_2^2 / n_{\\text{samples}}`. @@ -1538,21 +1660,14 @@ def _path_residuals( return this_mse.mean(axis=0) -class LinearModelCV(MultiOutputMixin, LinearModel, ABC): +class LinearModelCV(MultiOutputLinearModel, ABC): """Base class for iterative model fitting along a regularization path.""" _parameter_constraints: dict = { "eps": [Interval(Real, 0, None, closed="neither")], - "n_alphas": [ - Interval(Integral, 1, None, closed="left"), - Hidden(StrOptions({"deprecated"})), - ], - # TODO(1.9): remove "warn" and None options. "alphas": [ Interval(Integral, 1, None, closed="left"), "array-like", - None, - Hidden(StrOptions({"warn"})), ], "fit_intercept": ["boolean"], "precompute": [StrOptions({"auto"}), "array-like", "boolean"], @@ -1571,8 +1686,7 @@ class LinearModelCV(MultiOutputMixin, LinearModel, ABC): def __init__( self, eps=1e-3, - n_alphas="deprecated", - alphas="warn", + alphas=100, fit_intercept=True, precompute="auto", max_iter=1000, @@ -1586,7 +1700,6 @@ def __init__( selection="cyclic", ): self.eps = eps - self.n_alphas = n_alphas self.alphas = alphas self.fit_intercept = fit_intercept self.precompute = precompute @@ -1615,7 +1728,7 @@ def path(X, y, **kwargs): @_fit_context(prefer_skip_nested_validation=True) def fit(self, X, y, sample_weight=None, **params): - """Fit linear model with coordinate descent. + """Fit model with internal cross-validation and with coordinate descent. Fit is on grid of alphas and best alpha estimated by cross-validation. @@ -1654,40 +1767,6 @@ def fit(self, X, y, sample_weight=None, **params): """ _raise_for_params(params, self, "fit") - # TODO(1.9): remove n_alphas and alphas={"warn", None}; set alphas=100 by - # default. Remove these deprecations messages and use self.alphas directly - # instead of self._alphas. - if self.n_alphas == "deprecated": - self._alphas = 100 - else: - warnings.warn( - "'n_alphas' was deprecated in 1.7 and will be removed in 1.9. " - "'alphas' now accepts an integer value which removes the need to pass " - "'n_alphas'. The default value of 'alphas' will change from None to " - "100 in 1.9. Pass an explicit value to 'alphas' and leave 'n_alphas' " - "to its default value to silence this warning.", - FutureWarning, - ) - self._alphas = self.n_alphas - - if isinstance(self.alphas, str) and self.alphas == "warn": - # - If self.n_alphas == "deprecated", both are left to their default values - # so we don't warn since the future default behavior will be the same as - # the current default behavior. - # - If self.n_alphas != "deprecated", then we already warned about it - # and the warning message mentions the future self.alphas default, so - # no need to warn a second time. - pass - elif self.alphas is None: - warnings.warn( - "'alphas=None' is deprecated and will be removed in 1.9, at which " - "point the default value will be set to 100. Set 'alphas=100' " - "to silence this warning.", - FutureWarning, - ) - else: - self._alphas = self.alphas - # This makes sure that there is no duplication in memory. # Dealing right with copy_X is important in the following: # Multiple functions touch X and subsamples of X and can induce a @@ -1755,9 +1834,7 @@ def fit(self, X, y, sample_weight=None, **params): ) y = column_or_1d(y, warn=True) else: - if sparse.issparse(X): - raise TypeError("X should be dense but a sparse matrix was passed.") - elif y.ndim == 1: + if y.ndim == 1: raise ValueError( "For mono-task outputs, use %sCV" % self.__class__.__name__[9:] ) @@ -1795,29 +1872,27 @@ def fit(self, X, y, sample_weight=None, **params): include_boundaries="left", ) - if isinstance(self._alphas, Integral): + if isinstance(self.alphas, Integral): alphas = [ _alpha_grid( X, y, l1_ratio=l1_ratio, fit_intercept=self.fit_intercept, + # Note: MultiTaskElasticNetCV has no attribute 'positive' + positive=getattr(self, "positive", False), eps=self.eps, - n_alphas=self._alphas, + n_alphas=self.alphas, sample_weight=sample_weight, ) for l1_ratio in l1_ratios ] else: # Making sure alphas entries are scalars. - for index, alpha in enumerate(self._alphas): + for index, alpha in enumerate(self.alphas): check_scalar_alpha(alpha, f"alphas[{index}]") # Making sure alphas is properly ordered. - alphas = np.tile(np.sort(self._alphas)[::-1], (n_l1_ratio, 1)) - - # We want n_alphas to be the number of alphas used for each l1_ratio. - n_alphas = len(alphas[0]) - path_params.update({"n_alphas": n_alphas}) + alphas = np.tile(np.sort(self.alphas)[::-1], (n_l1_ratio, 1)) path_params["copy_X"] = copy_X # We are not computing in parallel, we can modify X @@ -1829,29 +1904,12 @@ def fit(self, X, y, sample_weight=None, **params): cv = check_cv(self.cv) if _routing_enabled(): - splitter_supports_sample_weight = get_routing_for_object(cv).consumes( - method="split", params=["sample_weight"] + routed_params = process_routing( + self, + "fit", + sample_weight=sample_weight, + **params, ) - if ( - sample_weight is not None - and not splitter_supports_sample_weight - and not has_fit_parameter(self, "sample_weight") - ): - raise ValueError( - "The CV splitter and underlying estimator do not support" - " sample weights." - ) - - if splitter_supports_sample_weight: - params["sample_weight"] = sample_weight - - routed_params = process_routing(self, "fit", **params) - - if sample_weight is not None and not has_fit_parameter( - self, "sample_weight" - ): - # MultiTaskElasticNetCV does not (yet) support sample_weight - sample_weight = None else: routed_params = Bunch() routed_params.splitter = Bunch(split=Bunch()) @@ -1899,7 +1957,7 @@ def fit(self, X, y, sample_weight=None, **params): self.l1_ratio_ = best_l1_ratio self.alpha_ = best_alpha - if isinstance(self._alphas, Integral): + if isinstance(self.alphas, Integral): self.alphas_ = np.asarray(alphas) if n_l1_ratio == 1: self.alphas_ = self.alphas_[0] @@ -1962,7 +2020,8 @@ def get_metadata_routing(self): def __sklearn_tags__(self): tags = super().__sklearn_tags__() multitask = self._is_multitask() - tags.input_tags.sparse = not multitask + tags.input_tags.sparse = True + tags.target_tags.single_output = not multitask tags.target_tags.multi_output = multitask return tags @@ -1986,14 +2045,7 @@ class LassoCV(RegressorMixin, LinearModelCV): Length of the path. ``eps=1e-3`` means that ``alpha_min / alpha_max = 1e-3``. - n_alphas : int, default=100 - Number of alphas along the regularization path. - - .. deprecated:: 1.7 - `n_alphas` was deprecated in 1.7 and will be removed in 1.9. Use `alphas` - instead. - - alphas : array-like or int, default=None + alphas : array-like or int, default=100 Values of alphas to test along the regularization path. If int, `alphas` values are generated automatically. If array-like, list of alpha values to use. @@ -2002,10 +2054,6 @@ class LassoCV(RegressorMixin, LinearModelCV): `alphas` accepts an integer value which removes the need to pass `n_alphas`. - .. deprecated:: 1.7 - `alphas=None` was deprecated in 1.7 and will be removed in 1.9, at which - point the default value will be set to 100. - fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -2015,7 +2063,7 @@ class LassoCV(RegressorMixin, LinearModelCV): (n_features, n_features), default='auto' Whether to use a precomputed Gram matrix to speed up calculations. If set to ``'auto'`` let us decide. The Gram - matrix can also be passed as argument. + matrix can also be passed as an argument. max_iter : int, default=1000 The maximum number of iterations. @@ -2026,16 +2074,16 @@ class LassoCV(RegressorMixin, LinearModelCV): until it is smaller or equal to ``tol``. copy_X : bool, default=True - If ``True``, X will be copied; else, it may be overwritten. + If ``True``, `X` will be copied; otherwise, it may be overwritten. cv : int, cross-validation generator or iterable, default=None Determines the cross-validation splitting strategy. Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - int, to specify the number of folds. + - int, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, :class:`~sklearn.model_selection.KFold` is used. @@ -2135,7 +2183,7 @@ class LassoCV(RegressorMixin, LinearModelCV): regularization path. It tends to speed up the hyperparameter search. - The underlying coordinate descent solver uses gap safe screening rules to speedup + The underlying coordinate descent solver uses gap safe screening rules to speed up fitting time, see :ref:`User Guide on coordinate descent <coordinate_descent>`. Examples @@ -2156,8 +2204,7 @@ def __init__( self, *, eps=1e-3, - n_alphas="deprecated", - alphas="warn", + alphas=100, fit_intercept=True, precompute="auto", max_iter=1000, @@ -2172,7 +2219,6 @@ def __init__( ): super().__init__( eps=eps, - n_alphas=n_alphas, alphas=alphas, fit_intercept=fit_intercept, precompute=precompute, @@ -2259,14 +2305,7 @@ class ElasticNetCV(RegressorMixin, LinearModelCV): Length of the path. ``eps=1e-3`` means that ``alpha_min / alpha_max = 1e-3``. - n_alphas : int, default=100 - Number of alphas along the regularization path, used for each l1_ratio. - - .. deprecated:: 1.7 - `n_alphas` was deprecated in 1.7 and will be removed in 1.9. Use `alphas` - instead. - - alphas : array-like or int, default=None + alphas : array-like or int, default=100 Values of alphas to test along the regularization path, used for each l1_ratio. If int, `alphas` values are generated automatically. If array-like, list of alpha values to use. @@ -2275,10 +2314,6 @@ class ElasticNetCV(RegressorMixin, LinearModelCV): `alphas` accepts an integer value which removes the need to pass `n_alphas`. - .. deprecated:: 1.7 - `alphas=None` was deprecated in 1.7 and will be removed in 1.9, at which - point the default value will be set to 100. - fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -2303,9 +2338,9 @@ class ElasticNetCV(RegressorMixin, LinearModelCV): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - int, to specify the number of folds. + - int, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, :class:`~sklearn.model_selection.KFold` is used. @@ -2448,8 +2483,7 @@ def __init__( *, l1_ratio=0.5, eps=1e-3, - n_alphas="deprecated", - alphas="warn", + alphas=100, fit_intercept=True, precompute="auto", max_iter=1000, @@ -2464,7 +2498,6 @@ def __init__( ): self.l1_ratio = l1_ratio self.eps = eps - self.n_alphas = n_alphas self.alphas = alphas self.fit_intercept = fit_intercept self.precompute = precompute @@ -2529,7 +2562,7 @@ def fit(self, X, y, sample_weight=None, **params): # Multi Task ElasticNet and Lasso models (with joint feature selection) -class MultiTaskElasticNet(Lasso): +class MultiTaskElasticNet(ElasticNet): """Multi-task ElasticNet model trained with L1/L2 mixed-norm as regularizer. The optimization objective for MultiTaskElasticNet is:: @@ -2653,9 +2686,7 @@ class MultiTaskElasticNet(Lasso): [0.0872422 0.0872422] """ - _parameter_constraints: dict = { - **ElasticNet._parameter_constraints, - } + _parameter_constraints: dict = {**ElasticNet._parameter_constraints} for param in ("precompute", "positive"): _parameter_constraints.pop(param) @@ -2672,8 +2703,8 @@ def __init__( random_state=None, selection="cyclic", ): - self.l1_ratio = l1_ratio self.alpha = alpha + self.l1_ratio = l1_ratio self.fit_intercept = fit_intercept self.max_iter = max_iter self.copy_X = copy_X @@ -2683,16 +2714,27 @@ def __init__( self.selection = selection @_fit_context(prefer_skip_nested_validation=True) - def fit(self, X, y): + def fit(self, X, y, sample_weight=None): """Fit MultiTaskElasticNet model with coordinate descent. Parameters ---------- - X : ndarray of shape (n_samples, n_features) - Data. + X : {ndarray, sparse matrix, sparse array} of shape (n_samples, n_features) + Data. Pass directly as Fortran-contiguous data to avoid unnecessary memory + duplication. + + Note that large sparse matrices and arrays requiring `int64` + indices are not accepted. + y : ndarray of shape (n_samples, n_targets) Target. Will be cast to X's dtype if necessary. + sample_weight : float or array-like of shape (n_samples,), default=None + Sample weights. Internally, the `sample_weight` vector will be + rescaled to sum to `n_samples`. + + .. versionadded:: 1.9 + Returns ------- self : object @@ -2707,62 +2749,104 @@ def fit(self, X, y): To avoid memory re-allocation it is advised to allocate the initial data in memory directly using that format. """ + # Remember if X is copied + X_copied = self.copy_X and self.fit_intercept # Need to validate separately here. # We can't pass multi_output=True because that would allow y to be csr. check_X_params = dict( + accept_sparse="csc", dtype=[np.float64, np.float32], order="F", force_writeable=True, - copy=self.copy_X and self.fit_intercept, + accept_large_sparse=False, + copy=X_copied, + ) + check_y_params = dict( + copy=False, dtype=[np.float64, np.float32], ensure_2d=False, order="F" ) - check_y_params = dict(ensure_2d=False, order="F") X, y = validate_data( self, X, y, validate_separately=(check_X_params, check_y_params) ) check_consistent_length(X, y) - y = y.astype(X.dtype) - if hasattr(self, "l1_ratio"): - model_str = "ElasticNet" - else: - model_str = "Lasso" if y.ndim == 1: + if hasattr(self, "l1_ratio"): + model_str = "ElasticNet" + else: + model_str = "Lasso" raise ValueError("For mono-task outputs, use %s" % model_str) n_samples, n_features = X.shape n_targets = y.shape[1] - X, y, X_offset, y_offset, X_scale, _ = _preprocess_data( - X, y, fit_intercept=self.fit_intercept, copy=False + if isinstance(sample_weight, numbers.Number): + sample_weight = None + if sample_weight is not None: + sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) + # TLDR: Rescale sw to sum up to n_samples. + # Long: See comment in ElasticNet. + sample_weight = sample_weight * (n_samples / np.sum(sample_weight)) + + X, y, X_offset, y_offset, X_scale, _, _ = _pre_fit( + X=X, + y=y, + Xy=None, + precompute=False, + fit_intercept=self.fit_intercept, + copy=False, # TODO: improve + sample_weight=sample_weight, ) + # coordinate descent needs F-ordered arrays and _pre_fit might have + # called _rescale_data + if sample_weight is not None: + X, y = _set_order(X, y, order="F") if not self.warm_start or not hasattr(self, "coef_"): - self.coef_ = np.zeros( - (n_targets, n_features), dtype=X.dtype.type, order="F" - ) + self.coef_ = np.zeros((n_targets, n_features), dtype=X.dtype, order="F") + else: + self.coef_ = np.asfortranarray(self.coef_) # coef F-contiguous in memory + X_is_sparse = sparse.issparse(X) + if X_is_sparse: + X_data = X.data + X_indices = X.indices + X_indptr = X.indptr + # As sparse matrices are not actually centered we need this to be passed to + # the CD solver. + X_mean = np.asarray(X_offset / X_scale, dtype=X.dtype) + X = None + else: + X_data = None + X_indices = None + X_indptr = None + X_mean = None + + # account for n_samples scaling in objectives between here and cd_fast l1_reg = self.alpha * self.l1_ratio * n_samples l2_reg = self.alpha * (1.0 - self.l1_ratio) * n_samples - self.coef_ = np.asfortranarray(self.coef_) # coef contiguous in memory - - random = self.selection == "random" - ( self.coef_, self.dual_gap_, self.eps_, self.n_iter_, ) = cd_fast.enet_coordinate_descent_multi_task( - self.coef_, - l1_reg, - l2_reg, - X, - y, - self.max_iter, - self.tol, - check_random_state(self.random_state), - random, + W=self.coef_, + alpha=l1_reg, + beta=l2_reg, + X=X, + X_is_sparse=X_is_sparse, + X_data=X_data, + X_indices=X_indices, + X_indptr=X_indptr, + Y=y, + sample_weight=sample_weight, + X_mean=X_mean, + max_iter=self.max_iter, + tol=self.tol, + rng=check_random_state(self.random_state), + random=self.selection == "random", + do_screening=True, ) # account for different objective scaling here and in cd_fast @@ -2775,7 +2859,6 @@ def fit(self, X, y): def __sklearn_tags__(self): tags = super().__sklearn_tags__() - tags.input_tags.sparse = False tags.target_tags.multi_output = True tags.target_tags.single_output = False return tags @@ -2962,14 +3045,7 @@ class MultiTaskElasticNetCV(RegressorMixin, LinearModelCV): Length of the path. ``eps=1e-3`` means that ``alpha_min / alpha_max = 1e-3``. - n_alphas : int, default=100 - Number of alphas along the regularization path. - - .. deprecated:: 1.7 - `n_alphas` was deprecated in 1.7 and will be removed in 1.9. Use `alphas` - instead. - - alphas : array-like or int, default=None + alphas : array-like or int, default=100 Values of alphas to test along the regularization path, used for each l1_ratio. If int, `alphas` values are generated automatically. If array-like, list of alpha values to use. @@ -2978,10 +3054,6 @@ class MultiTaskElasticNetCV(RegressorMixin, LinearModelCV): `alphas` accepts an integer value which removes the need to pass `n_alphas`. - .. deprecated:: 1.7 - `alphas=None` was deprecated in 1.7 and will be removed in 1.9, at which - point the default value will be set to 100. - fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -3000,9 +3072,9 @@ class MultiTaskElasticNetCV(RegressorMixin, LinearModelCV): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - int, to specify the number of folds. + - int, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, :class:`~sklearn.model_selection.KFold` is used. @@ -3123,8 +3195,7 @@ def __init__( *, l1_ratio=0.5, eps=1e-3, - n_alphas="deprecated", - alphas="warn", + alphas=100, fit_intercept=True, max_iter=1000, tol=1e-4, @@ -3137,7 +3208,6 @@ def __init__( ): self.l1_ratio = l1_ratio self.eps = eps - self.n_alphas = n_alphas self.alphas = alphas self.fit_intercept = fit_intercept self.max_iter = max_iter @@ -3155,42 +3225,6 @@ def _get_estimator(self): def _is_multitask(self): return True - def __sklearn_tags__(self): - tags = super().__sklearn_tags__() - tags.target_tags.single_output = False - return tags - - # This is necessary as LinearModelCV now supports sample_weight while - # MultiTaskElasticNetCV does not (yet). - def fit(self, X, y, **params): - """Fit MultiTaskElasticNet model with coordinate descent. - - Fit is on grid of alphas and best alpha estimated by cross-validation. - - Parameters - ---------- - X : ndarray of shape (n_samples, n_features) - Training data. - y : ndarray of shape (n_samples, n_targets) - Training target variable. Will be cast to X's dtype if necessary. - - **params : dict, default=None - Parameters to be passed to the CV splitter. - - .. versionadded:: 1.4 - Only available if `enable_metadata_routing=True`, - which can be set by using - ``sklearn.set_config(enable_metadata_routing=True)``. - See :ref:`Metadata Routing User Guide <metadata_routing>` for - more details. - - Returns - ------- - self : object - Returns MultiTaskElasticNet instance. - """ - return super().fit(X, y, **params) - class MultiTaskLassoCV(RegressorMixin, LinearModelCV): """Multi-task Lasso model trained with L1/L2 mixed-norm as regularizer. @@ -3217,13 +3251,6 @@ class MultiTaskLassoCV(RegressorMixin, LinearModelCV): Length of the path. ``eps=1e-3`` means that ``alpha_min / alpha_max = 1e-3``. - n_alphas : int, default=100 - Number of alphas along the regularization path. - - .. deprecated:: 1.7 - `n_alphas` was deprecated in 1.7 and will be removed in 1.9. Use `alphas` - instead. - alphas : array-like or int, default=None Values of alphas to test along the regularization path. If int, `alphas` values are generated automatically. @@ -3233,10 +3260,6 @@ class MultiTaskLassoCV(RegressorMixin, LinearModelCV): `alphas` accepts an integer value which removes the need to pass `n_alphas`. - .. deprecated:: 1.7 - `alphas=None` was deprecated in 1.7 and will be removed in 1.9, at which - point the default value will be set to 100. - fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -3258,9 +3281,9 @@ class MultiTaskLassoCV(RegressorMixin, LinearModelCV): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - int, to specify the number of folds. + - int, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, :class:`~sklearn.model_selection.KFold` is used. @@ -3374,8 +3397,7 @@ def __init__( self, *, eps=1e-3, - n_alphas="deprecated", - alphas="warn", + alphas=100, fit_intercept=True, max_iter=1000, tol=1e-4, @@ -3388,7 +3410,6 @@ def __init__( ): super().__init__( eps=eps, - n_alphas=n_alphas, alphas=alphas, fit_intercept=fit_intercept, max_iter=max_iter, @@ -3406,39 +3427,3 @@ def _get_estimator(self): def _is_multitask(self): return True - - def __sklearn_tags__(self): - tags = super().__sklearn_tags__() - tags.target_tags.single_output = False - return tags - - # This is necessary as LinearModelCV now supports sample_weight while - # MultiTaskLassoCV does not (yet). - def fit(self, X, y, **params): - """Fit MultiTaskLasso model with coordinate descent. - - Fit is on grid of alphas and best alpha estimated by cross-validation. - - Parameters - ---------- - X : ndarray of shape (n_samples, n_features) - Data. - y : ndarray of shape (n_samples, n_targets) - Target. Will be cast to X's dtype if necessary. - - **params : dict, default=None - Parameters to be passed to the CV splitter. - - .. versionadded:: 1.4 - Only available if `enable_metadata_routing=True`, - which can be set by using - ``sklearn.set_config(enable_metadata_routing=True)``. - See :ref:`Metadata Routing User Guide <metadata_routing>` for - more details. - - Returns - ------- - self : object - Returns an instance of fitted model. - """ - return super().fit(X, y, **params) diff --git a/sklearn/linear_model/_glm/_newton_solver.py b/sklearn/linear_model/_glm/_newton_solver.py index 5979791f3ae2a..537be0b5ae098 100644 --- a/sklearn/linear_model/_glm/_newton_solver.py +++ b/sklearn/linear_model/_glm/_newton_solver.py @@ -179,6 +179,8 @@ def fallback_lbfgs_solve(self, X, y, sample_weight): - self.coef - self.converged """ + coef_shape = self.coef.shape + self.coef = self.coef.ravel(order="F") # scipy minimize expects 1d arrays max_iter = self.max_iter - self.iteration opt_res = scipy.optimize.minimize( self.linear_loss.loss_gradient, @@ -197,6 +199,8 @@ def fallback_lbfgs_solve(self, X, y, sample_weight): self.iteration += _check_optimize_result("lbfgs", opt_res, max_iter=max_iter) self.coef = opt_res.x self.converged = opt_res.status == 0 + if len(coef_shape) > 1: + self.coef = self.coef.reshape(coef_shape, order="F") def line_search(self, X, y, sample_weight): """Backtracking line search. @@ -212,7 +216,9 @@ def line_search(self, X, y, sample_weight): """ # line search parameters beta, sigma = 0.5, 0.00048828125 # 1/2, 1/2**11 - eps = 16 * np.finfo(self.loss_value.dtype).eps + # Remember: dtype follows X, also the one of self.loss_value. For Array API + # support, self.loss_value might be float instead of np.floatXX. + eps = 16 * np.finfo(X.dtype).eps t = 1 # step size # gradient_times_newton = self.gradient @ self.coef_newton @@ -289,8 +295,8 @@ def line_search(self, X, y, sample_weight): warnings.warn( ( f"Line search of Newton solver {self.__class__.__name__} at" - f" iteration #{self.iteration} did no converge after 21 line search" - " refinement iterations. It will now resort to lbfgs instead." + f" iteration #{self.iteration} did not converge after 21 line " + "search refinement iterations. It will now resort to lbfgs instead." ), ConvergenceWarning, ) diff --git a/sklearn/linear_model/_glm/glm.py b/sklearn/linear_model/_glm/glm.py index 8bad8e8193385..5e3136035c7fd 100644 --- a/sklearn/linear_model/_glm/glm.py +++ b/sklearn/linear_model/_glm/glm.py @@ -13,6 +13,7 @@ from sklearn._loss.loss import ( HalfGammaLoss, HalfPoissonLoss, + HalfPoissonLossArrayAPI, HalfSquaredError, HalfTweedieLoss, HalfTweedieLossIdentity, @@ -21,6 +22,14 @@ from sklearn.linear_model._glm._newton_solver import NewtonCholeskySolver, NewtonSolver from sklearn.linear_model._linear_loss import LinearModelLoss from sklearn.utils import check_array +from sklearn.utils._array_api import ( + _average, + _is_numpy_namespace, + _matching_numpy_dtype, + get_namespace, + get_namespace_and_device, + move_to, +) from sklearn.utils._openmp_helpers import _openmp_effective_n_threads from sklearn.utils._param_validation import Hidden, Interval, StrOptions from sklearn.utils.fixes import _get_additional_lbfgs_options_dict @@ -192,24 +201,17 @@ def fit(self, X, y, sample_weight=None): self : object Fitted model. """ + xp, _, device_ = get_namespace_and_device(X) X, y = validate_data( self, X, y, accept_sparse=["csc", "csr"], - dtype=[np.float64, np.float32], + dtype=[xp.float64, xp.float32], y_numeric=True, multi_output=False, ) - - # required by losses - if self.solver == "lbfgs": - # lbfgs will force coef and therefore raw_prediction to be float64. The - # base_loss needs y, X @ coef and sample_weight all of same dtype - # (and contiguous). - loss_dtype = np.float64 - else: - loss_dtype = min(max(y.dtype, X.dtype), np.float64) + loss_dtype = X.dtype y = check_array(y, dtype=loss_dtype, order="C", ensure_2d=False) if sample_weight is not None: @@ -217,8 +219,10 @@ def fit(self, X, y, sample_weight=None): # losses. sample_weight = _check_sample_weight(sample_weight, X, dtype=loss_dtype) + y, sample_weight = move_to(y, sample_weight, xp=xp, device=device_) + n_samples, n_features = X.shape - self._base_loss = self._get_loss() + self._base_loss = self._get_loss(xp=xp, device=device_) linear_loss = LinearModelLoss( base_loss=self._base_loss, @@ -248,18 +252,20 @@ def fit(self, X, y, sample_weight=None): # Thus, without rescaling, we have # obj = LinearModelLoss.loss(...) + loss_dtype_np = _matching_numpy_dtype(X, xp=xp) if self.warm_start and hasattr(self, "coef_"): + coef_xp, _ = get_namespace(self.coef_) + coef = move_to(self.coef_, xp=np, device="cpu") if self.fit_intercept: # LinearModelLoss needs intercept at the end of coefficient array. - coef = np.concatenate((self.coef_, np.array([self.intercept_]))) - else: - coef = self.coef_ - coef = coef.astype(loss_dtype, copy=False) + intercept = move_to(self.intercept_, xp=np, device="cpu") + coef = np.concatenate((coef, np.array([intercept]))) + coef = coef.astype(loss_dtype_np, copy=False) else: - coef = linear_loss.init_zero_coef(X, dtype=loss_dtype) + coef = linear_loss.init_zero_coef(X, dtype=loss_dtype_np) if self.fit_intercept: coef[-1] = linear_loss.base_loss.link.link( - np.average(y, weights=sample_weight) + _average(y, weights=sample_weight) ) l2_reg_strength = self.alpha @@ -291,6 +297,11 @@ def fit(self, X, y, sample_weight=None): "lbfgs", opt_res, max_iter=self.max_iter ) coef = opt_res.x + coef = xp.asarray( + coef.copy(order="C" if not _is_numpy_namespace(xp) else "K"), + dtype=X.dtype, + device=device_, + ) elif self.solver == "newton-cholesky": sol = NewtonCholeskySolver( coef=coef, @@ -342,12 +353,13 @@ def _linear_predictor(self, X): y_pred : array of shape (n_samples,) Returns predicted values of linear predictor. """ + xp, _ = get_namespace(X) check_is_fitted(self) X = validate_data( self, X, accept_sparse=["csr", "csc", "coo"], - dtype=[np.float64, np.float32], + dtype=[xp.float64, xp.float32], ensure_2d=True, allow_nd=False, reset=False, @@ -418,6 +430,9 @@ def score(self, X, y, sample_weight=None): # losses. sample_weight = _check_sample_weight(sample_weight, X, dtype=y.dtype) + xp, _, device_ = get_namespace_and_device(X) + y, sample_weight = move_to(y, sample_weight, xp=xp, device=device_) + base_loss = self._base_loss if not base_loss.in_y_true_range(y): @@ -426,7 +441,7 @@ def score(self, X, y, sample_weight=None): f" {base_loss.__name__}." ) - constant = np.average( + constant = _average( base_loss.constant_to_optimal_zero(y_true=y, sample_weight=None), weights=sample_weight, ) @@ -438,14 +453,14 @@ def score(self, X, y, sample_weight=None): sample_weight=sample_weight, n_threads=1, ) - y_mean = base_loss.link.link(np.average(y, weights=sample_weight)) + y_mean = base_loss.link.link(_average(y, weights=sample_weight)) deviance_null = base_loss( y_true=y, - raw_prediction=np.tile(y_mean, y.shape[0]), + raw_prediction=xp.tile(y_mean, (y.shape[0],)), sample_weight=sample_weight, n_threads=1, ) - return 1 - (deviance + constant) / (deviance_null + constant) + return float(1 - (deviance + constant) / (deviance_null + constant)) def __sklearn_tags__(self): tags = super().__sklearn_tags__() @@ -462,7 +477,7 @@ def __sklearn_tags__(self): pass # pragma: no cover return tags - def _get_loss(self): + def _get_loss(self, xp=None, device=None): """This is only necessary because of the link and power arguments of the TweedieRegressor. @@ -599,8 +614,16 @@ def __init__( verbose=verbose, ) - def _get_loss(self): - return HalfPoissonLoss() + def _get_loss(self, xp=None, device=None): + if xp is None or _is_numpy_namespace(xp): + return HalfPoissonLoss() + else: + return HalfPoissonLossArrayAPI(xp=xp, device=device) + + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + tags.array_api_support = self.solver == "lbfgs" + return tags class GammaRegressor(_GeneralizedLinearRegressor): @@ -731,7 +754,7 @@ def __init__( verbose=verbose, ) - def _get_loss(self): + def _get_loss(self, xp=None, device=None): return HalfGammaLoss() @@ -899,7 +922,7 @@ def __init__( self.link = link self.power = power - def _get_loss(self): + def _get_loss(self, xp=None, device=None): if self.link == "auto": if self.power <= 0: # identity link diff --git a/sklearn/linear_model/_glm/tests/test_glm.py b/sklearn/linear_model/_glm/tests/test_glm.py index 535651f3242f5..1d85aeb454493 100644 --- a/sklearn/linear_model/_glm/tests/test_glm.py +++ b/sklearn/linear_model/_glm/tests/test_glm.py @@ -11,6 +11,7 @@ from scipy import linalg from scipy.optimize import minimize, root +from sklearn import config_context from sklearn._loss import HalfBinomialLoss, HalfPoissonLoss, HalfTweedieLoss from sklearn._loss.link import IdentityLink, LogLink from sklearn.base import clone @@ -27,13 +28,21 @@ from sklearn.linear_model._linear_loss import LinearModelLoss from sklearn.metrics import d2_tweedie_score, mean_poisson_deviance from sklearn.model_selection import train_test_split -from sklearn.utils._testing import assert_allclose +from sklearn.utils._array_api import ( + _atol_for_type, + move_to, + yield_namespace_device_dtype_combinations, +) +from sklearn.utils._array_api import ( + device as array_api_device, +) +from sklearn.utils._testing import _array_api_for_tests, assert_allclose SOLVERS = ["lbfgs", "newton-cholesky"] class BinomialRegressor(_GeneralizedLinearRegressor): - def _get_loss(self): + def _get_loss(self, xp=None, device=None): return HalfBinomialLoss() @@ -991,7 +1000,7 @@ def test_linalg_warning_with_newton_solver(global_random_seed): # We check that the model could successfully fit information in X_orig to # improve upon the constant baseline by a large margin (when evaluated on - # the traing set). + # the training set). assert constant_model_deviance - original_newton_deviance > 0.1 # LBFGS is robust to a collinear design because its approximation of the @@ -1140,3 +1149,108 @@ def test_newton_solver_verbosity(capsys, verbose): "The inner solver detected a pointwise Hessian with many negative values" " and resorts to lbfgs instead." in captured.out ) + + +@pytest.mark.parametrize("use_sample_weight", [False, True]) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning") +def test_poisson_regressor_array_api_compliance( + use_sample_weight, + array_namespace, + device_name, + dtype_name, + global_random_seed, +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + rng = np.random.default_rng(global_random_seed) + n_samples = 1000 + n_features = 3 + X_np = rng.normal(size=(n_samples, n_features)) + beta = np.array([0.5, -0.3, 0.8]) # true coefficients + intercept = 1.0 + mu = np.exp(X_np @ beta + intercept) # Poisson mean with log-link. + y_np = rng.poisson(mu) + # Ensure that we have non-zero targets for meaningful testing: + assert (y_np > 0).mean() > 0.1 + + X_np = X_np.astype(dtype_name, copy=False) + y_np = y_np.astype(dtype_name, copy=False) + X_xp = xp.asarray(X_np, device=device) + y_xp = xp.asarray(y_np, device=device) + + if use_sample_weight: + sample_weight = ( + rng.uniform(-1, 5, size=n_samples) + .clip(0, None) # over-represent null weights to cover edge-cases. + .astype(dtype_name) + ) + else: + sample_weight = None + + params = dict(alpha=1, solver="lbfgs", max_iter=500) + params["tol"] = 3e-6 if dtype_name == "float32" else 1e-13 + glm_np = PoissonRegressor(**params).fit(X_np, y_np, sample_weight=sample_weight) + assert glm_np.n_iter_ < glm_np.max_iter + + # Test that alpha was not too large for meaningful testing. + assert np.abs(glm_np.coef_).max() > 0.1 + + predict_np = glm_np.predict(X_np) + atol = _atol_for_type(dtype_name) + rtol = 2e-3 if dtype_name == "float32" else 3e-7 + + with config_context(array_api_dispatch=True): + glm_xp = PoissonRegressor(**params).fit(X_xp, y_xp, sample_weight=sample_weight) + if dtype_name == "float64": + assert abs(glm_xp.n_iter_ - glm_np.n_iter_) <= 1 + + for attr_name in ("coef_", "intercept_"): + attr_xp = getattr(glm_xp, attr_name) + attr_np = getattr(glm_np, attr_name) + assert_allclose( + move_to(attr_xp, xp=np, device="cpu"), attr_np, rtol=rtol, atol=atol + ) + assert attr_xp.dtype == X_xp.dtype + assert array_api_device(attr_xp) == array_api_device(X_xp) + + predict_xp = glm_xp.predict(X_xp) + assert_allclose( + move_to(predict_xp, xp=np, device="cpu"), + predict_np, + rtol=rtol, + atol=atol, + ) + assert predict_xp.dtype == X_xp.dtype + assert array_api_device(predict_xp) == array_api_device(X_xp) + + +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning") +def test_poisson_regressor_array_api_warm_start( + array_namespace, + device_name, + dtype_name, +): + """Test that incremental fitting of PoissonRegressor works correctly + with the array API when warm_start is True.""" + rng = np.random.default_rng(42) + X = rng.standard_normal((200, 5)).astype(dtype_name) + y = np.abs(rng.standard_normal(200)) + 0.1 + y = y.astype(dtype_name) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + X_xp = xp.asarray(X, device=device) + y_xp = xp.asarray(y, device=device) + with config_context(array_api_dispatch=True): + reg_xp = PoissonRegressor( + alpha=1.0, solver="lbfgs", max_iter=300, warm_start=True + ) + reg_xp.fit(X_xp, y_xp) + reg_xp.predict(X_xp) + # fit again and ensure there is no error + reg_xp.fit(X_xp, y_xp) diff --git a/sklearn/linear_model/_least_angle.py b/sklearn/linear_model/_least_angle.py index 7c29f350fd200..c9c11f2abd543 100644 --- a/sklearn/linear_model/_least_angle.py +++ b/sklearn/linear_model/_least_angle.py @@ -15,9 +15,13 @@ from scipy import interpolate, linalg from scipy.linalg.lapack import get_lapack_funcs -from sklearn.base import MultiOutputMixin, RegressorMixin, _fit_context +from sklearn.base import RegressorMixin, _fit_context from sklearn.exceptions import ConvergenceWarning -from sklearn.linear_model._base import LinearModel, LinearRegression, _preprocess_data +from sklearn.linear_model._base import ( + LinearRegression, + MultiOutputLinearModel, + _preprocess_data, +) from sklearn.model_selection import check_cv # mypy error: Module 'sklearn.utils' has no attribute 'arrayfuncs' @@ -917,8 +921,8 @@ def _lars_path_solver( # Estimator classes -class Lars(MultiOutputMixin, RegressorMixin, LinearModel): - """Least Angle Regression model a.k.a. LAR. +class Lars(RegressorMixin, MultiOutputLinearModel): + """Least Angle Regression model aka LAR. Read more in the :ref:`User Guide <least_angle_regression>`. @@ -1208,7 +1212,7 @@ def fit(self, X, y, Xy=None): class LassoLars(Lars): - """Lasso model fit with Least Angle Regression a.k.a. Lars. + """Lasso model fit with Least Angle Regression aka Lars. It is a Linear Model trained with an L1 prior as regularizer. @@ -1542,9 +1546,9 @@ class LarsCV(Lars): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used. @@ -1862,9 +1866,9 @@ class LassoLarsCV(LarsCV): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used. diff --git a/sklearn/linear_model/_linear_loss.py b/sklearn/linear_model/_linear_loss.py index 200b391007951..1e156122d54df 100644 --- a/sklearn/linear_model/_linear_loss.py +++ b/sklearn/linear_model/_linear_loss.py @@ -8,6 +8,12 @@ import numpy as np from scipy import sparse +from sklearn.utils._array_api import ( + get_namespace, + get_namespace_and_device, + move_to, +) +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import safe_sparse_dot, squared_norm @@ -24,10 +30,12 @@ def sandwich_dot(X, W): # which (might) detect the symmetry and use BLAS SYRK under the hood. n_samples = X.shape[0] if sparse.issparse(X): - return safe_sparse_dot( - X.T, - sparse.dia_matrix((W, 0), shape=(n_samples, n_samples)) @ X, - dense_output=True, + return _align_api_if_sparse( + safe_sparse_dot( + X.T, + sparse.dia_array((W, 0), shape=(n_samples, n_samples)) @ X, + dense_output=True, + ) ) else: # np.einsum may use less memory but the following, using BLAS matrix @@ -131,9 +139,9 @@ def init_zero_coef(self, X, dtype=None): else: n_dof = n_features if self.base_loss.is_multiclass: - coef = np.zeros_like(X, shape=(n_classes, n_dof), dtype=dtype, order="F") + coef = np.zeros(shape=(n_classes, n_dof), dtype=dtype, order="F") else: - coef = np.zeros_like(X, shape=n_dof, dtype=dtype) + coef = np.zeros(shape=n_dof, dtype=dtype) return coef def weight_intercept(self, coef): @@ -198,19 +206,27 @@ def weight_intercept_raw(self, coef, X): (n_samples, n_classes) """ weights, intercept = self.weight_intercept(coef) - + xp, _, device_ = get_namespace_and_device(X) + + # The `weights` and `intercept` are only converted internally to the + # array API because the relevant `scipy.optimize` functions do not + # currently support the array API and we have to ensure that the final + # values returned to the respective `scipy.optimize` function are in + # the `numpy` namespace. + weights_xp = xp.asarray(weights, dtype=X.dtype, device=device_) + intercept_xp = xp.asarray(intercept, dtype=X.dtype, device=device_) if not self.base_loss.is_multiclass: - raw_prediction = X @ weights + intercept + raw_prediction = X @ weights_xp + intercept_xp else: # weights has shape (n_classes, n_dof) - raw_prediction = X @ weights.T + intercept # ndarray, likely C-contiguous + raw_prediction = X @ weights_xp.T + intercept_xp return weights, intercept, raw_prediction def l2_penalty(self, weights, l2_reg_strength): """Compute L2 penalty term l2_reg_strength/2 *||w||_2^2.""" norm2_w = weights @ weights if weights.ndim == 1 else squared_norm(weights) - return 0.5 * l2_reg_strength * norm2_w + return float(0.5 * l2_reg_strength * norm2_w) def loss( self, @@ -251,6 +267,7 @@ def loss( loss : float Weighted average of losses per sample, plus penalty. """ + n_samples = X.shape[0] if raw_prediction is None: weights, intercept, raw_prediction = self.weight_intercept_raw(coef, X) else: @@ -259,12 +276,17 @@ def loss( loss = self.base_loss.loss( y_true=y, raw_prediction=raw_prediction, - sample_weight=None, + sample_weight=sample_weight, n_threads=n_threads, ) - loss = np.average(loss, weights=sample_weight) + xp, _ = get_namespace(X, y, sample_weight) + sw_sum = n_samples if sample_weight is None else xp.sum(sample_weight) + loss = float(xp.sum(loss) / sw_sum) - return loss + self.l2_penalty(weights, l2_reg_strength) + if l2_reg_strength > 0: + loss += self.l2_penalty(weights, l2_reg_strength) + + return loss def loss_gradient( self, @@ -322,23 +344,35 @@ def loss_gradient( sample_weight=sample_weight, n_threads=n_threads, ) - sw_sum = n_samples if sample_weight is None else np.sum(sample_weight) - loss = loss.sum() / sw_sum + xp, _ = get_namespace(X, y, sample_weight) + sw_sum = n_samples if sample_weight is None else xp.sum(sample_weight) + loss = float(xp.sum(loss) / sw_sum) loss += self.l2_penalty(weights, l2_reg_strength) grad_pointwise /= sw_sum if not self.base_loss.is_multiclass: grad = np.empty_like(coef, dtype=weights.dtype) - grad[:n_features] = X.T @ grad_pointwise + l2_reg_strength * weights + X_grad = X.T @ grad_pointwise + grad[:n_features] = ( + move_to(X_grad, xp=np, device="cpu") + l2_reg_strength * weights + ) if self.fit_intercept: - grad[-1] = grad_pointwise.sum() + grad[-1] = xp.sum(grad_pointwise) else: + # The final value of `grad` needs to be in the `numpy` namespace + # because the relevant `scipy.optimize` functions do not currently + # support the array API. grad = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F") # grad_pointwise.shape = (n_samples, n_classes) - grad[:, :n_features] = grad_pointwise.T @ X + l2_reg_strength * weights + grad_X = grad_pointwise.T @ X + grad[:, :n_features] = ( + move_to(grad_X, xp=np, device="cpu") + l2_reg_strength * weights + ) if self.fit_intercept: - grad[:, -1] = grad_pointwise.sum(axis=0) + grad[:, -1] = move_to( + xp.sum(grad_pointwise, axis=0), xp=np, device="cpu" + ) if coef.ndim == 1: grad = grad.ravel(order="F") @@ -729,7 +763,7 @@ def gradient_hessian_product( hessian_sum = hess_pointwise.sum() if sparse.issparse(X): hX = ( - sparse.dia_matrix((hess_pointwise, 0), shape=(n_samples, n_samples)) + sparse.dia_array((hess_pointwise, 0), shape=(n_samples, n_samples)) @ X ) else: @@ -807,7 +841,7 @@ def hessp(s): else: s_intercept = 0 tmp = X @ s.T + s_intercept # X_{im} * s_k_m - tmp += (-proba * tmp).sum(axis=1)[:, np.newaxis] # - sum_l .. + tmp -= (proba * tmp).sum(axis=1)[:, np.newaxis] # - sum_l .. tmp *= proba # * p_i_k if sample_weight is not None: tmp *= sample_weight[:, np.newaxis] diff --git a/sklearn/linear_model/_logistic.py b/sklearn/linear_model/_logistic.py index a9c903465fae9..62b5d0885a6b9 100644 --- a/sklearn/linear_model/_logistic.py +++ b/sklearn/linear_model/_logistic.py @@ -5,6 +5,7 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause +import inspect import numbers import warnings from numbers import Integral, Real @@ -12,7 +13,12 @@ import numpy as np from scipy import optimize -from sklearn._loss.loss import HalfBinomialLoss, HalfMultinomialLoss +from sklearn._loss.loss import ( + HalfBinomialLoss, + HalfBinomialLossArrayAPI, + HalfMultinomialLoss, + HalfMultinomialLossArrayAPI, +) from sklearn.base import _fit_context from sklearn.linear_model._base import ( BaseEstimator, @@ -22,7 +28,7 @@ from sklearn.linear_model._glm.glm import NewtonCholeskySolver from sklearn.linear_model._linear_loss import LinearModelLoss from sklearn.linear_model._sag import sag_solver -from sklearn.metrics import get_scorer, get_scorer_names +from sklearn.metrics import get_scorer, get_scorer_names, make_scorer from sklearn.model_selection import check_cv from sklearn.preprocessing import LabelEncoder from sklearn.svm._base import _fit_liblinear @@ -33,6 +39,15 @@ check_random_state, compute_class_weight, ) +from sklearn.utils._array_api import ( + _is_numpy_namespace, + _matching_numpy_dtype, + check_same_namespace, + get_namespace, + get_namespace_and_device, + move_to, + size, +) from sklearn.utils._param_validation import Hidden, Interval, StrOptions from sklearn.utils.extmath import row_norms, softmax from sklearn.utils.fixes import _get_additional_lbfgs_options_dict @@ -250,23 +265,26 @@ def _logistic_regression_path( Cs = np.logspace(-4, 4, Cs) solver = _check_solver(solver, penalty, dual) + xp, _, device_ = get_namespace_and_device(X) # Preprocessing. if check_input: X = check_array( X, accept_sparse="csr", - dtype=np.float64, + dtype=[xp.float64, xp.float32], accept_large_sparse=solver not in ["liblinear", "sag", "saga"], ) y = check_array(y, ensure_2d=False, dtype=None) check_consistent_length(X, y) if sample_weight is not None or class_weight is not None: - sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype, copy=True) + sample_weight = _check_sample_weight( + sample_weight, X, dtype=X.dtype, copy=True, ensure_same_device=True + ) n_samples, n_features = X.shape - n_classes = len(classes) + n_classes = classes.shape[0] if hasattr(classes, "shape") else len(classes) is_binary = n_classes == 2 if solver == "liblinear" and not is_binary: @@ -284,12 +302,18 @@ def _logistic_regression_path( class_weight_ = compute_class_weight( class_weight, classes=classes, y=y, sample_weight=sample_weight ) - sample_weight *= class_weight_[le.transform(y)] + class_weight_ = xp.asarray( + class_weight_[le.transform(y)], dtype=X.dtype, device=device_ + ) + sample_weight *= class_weight_ if is_binary: - w0 = np.zeros(n_features + int(fit_intercept), dtype=X.dtype) - mask = y == classes[1] - y_bin = np.ones(y.shape, dtype=X.dtype) + w0 = np.zeros( + n_features + int(fit_intercept), dtype=_matching_numpy_dtype(X, xp=xp) + ) + # classes[1] is the "positive label" + mask = move_to(y == classes[1], xp=xp, device=device_) + y_bin = xp.ones(y.shape, dtype=X.dtype, device=device_) if solver == "liblinear": y_bin[~mask] = -1.0 else: @@ -300,10 +324,12 @@ def _logistic_regression_path( # All solvers capable of a multinomial need LabelEncoder, not LabelBinarizer, # i.e. y as a 1d-array of integers. LabelEncoder also saves memory # compared to LabelBinarizer, especially when n_classes is large. - Y_multi = le.transform(y).astype(X.dtype, copy=False) + Y_multi = xp.asarray(le.transform(y), dtype=X.dtype, device=device_) # It is important that w0 is F-contiguous. w0 = np.zeros( - (classes.size, n_features + int(fit_intercept)), order="F", dtype=X.dtype + (size(classes), n_features + int(fit_intercept)), + order="F", + dtype=_matching_numpy_dtype(X, xp=xp), ) # IMPORTANT NOTE: @@ -317,7 +343,7 @@ def _logistic_regression_path( # This needs to be calculated after sample_weight is multiplied by # class_weight. It is even tested that passing class_weight is equivalent to # passing sample_weights according to class_weight. - sw_sum = n_samples if sample_weight is None else np.sum(sample_weight) + sw_sum = n_samples if sample_weight is None else float(xp.sum(sample_weight)) if coef is not None: if is_binary: @@ -352,7 +378,12 @@ def _logistic_regression_path( if is_binary: target = y_bin loss = LinearModelLoss( - base_loss=HalfBinomialLoss(), fit_intercept=fit_intercept + base_loss=( + HalfBinomialLoss() + if _is_numpy_namespace(xp) + else HalfBinomialLossArrayAPI(xp=xp, device=device_) + ), + fit_intercept=fit_intercept, ) if solver == "lbfgs": func = loss.loss_gradient @@ -363,7 +394,13 @@ def _logistic_regression_path( warm_start_sag = {"coef": np.expand_dims(w0, axis=1)} else: # multinomial loss = LinearModelLoss( - base_loss=HalfMultinomialLoss(n_classes=classes.size), + base_loss=( + HalfMultinomialLoss(n_classes=size(classes)) + if _is_numpy_namespace(xp) + else HalfMultinomialLossArrayAPI( + n_classes=size(classes), xp=xp, device=device_ + ) + ), fit_intercept=fit_intercept, ) target = Y_multi @@ -382,7 +419,8 @@ def _logistic_regression_path( warm_start_sag = {"coef": w0.T} coefs = list() - n_iter = np.zeros(len(Cs), dtype=np.int32) + n_iter = xp.zeros(len(Cs), dtype=xp.int32, device=device_) + coefs_order = "C" if not _is_numpy_namespace(xp) else "K" for i, C in enumerate(Cs): if solver == "lbfgs": l2_reg_strength = 1.0 / (C * sw_sum) @@ -503,17 +541,23 @@ def _logistic_regression_path( raise ValueError(msg) if is_binary: - coefs.append(w0.copy()) + coefs.append( + xp.asarray(w0.copy(order=coefs_order), dtype=X.dtype, device=device_) + ) else: if solver in ["lbfgs", "newton-cg", "newton-cholesky"]: multi_w0 = np.reshape(w0, (n_classes, -1), order="F") else: multi_w0 = w0 - coefs.append(multi_w0.copy()) + coefs.append( + xp.asarray( + multi_w0.copy(order=coefs_order), dtype=X.dtype, device=device_ + ) + ) n_iter[i] = n_iter_i - return np.array(coefs), np.array(Cs), n_iter + return xp.stack(coefs), xp.asarray(Cs, device=device_), n_iter # helper function for LogisticCV @@ -711,8 +755,51 @@ def _log_reg_scoring_path( scores = list() + # Prepare the call to get the score per fold: calc_score scoring = get_scorer(scoring) - for w in coefs: + if scoring is None: + + def calc_score(log_reg): + return log_reg.score(X_test, y_test, sample_weight=sw_test) + + else: + is_binary = len(classes) <= 2 + score_params = score_params or {} + score_params = _check_method_params(X=X, params=score_params, indices=test) + # We need to pass the classes as "labels" argument to scorers that support + # it, e.g. scoring = "neg_brier_score", because y_test may not contain all + # class labels. + # There are at least 2 possibilities: + # 1. Metadata routing is enabled: A try except clause is possible with + # adding labels to score_params. We could then pass the already instantiated + # log_reg instance to scoring. + # 2. We reconstruct the scorer and pass labels as kwargs explicitly. + # We implement the 2nd option even if it seems a bit hacky because it works + # with and without metadata routing. + if hasattr(scoring, "_score_func"): + sig = inspect.signature(scoring._score_func).parameters + else: + sig = [] + + if "labels" in sig: + pos_label_kwarg = {} + if is_binary and "pos_label" in sig: + # see _logistic_regression_path + pos_label_kwarg["pos_label"] = classes[-1] + scoring = make_scorer( + scoring._score_func, + greater_is_better=True if scoring._sign == 1 else False, + response_method=scoring._response_method, + labels=classes, + **pos_label_kwarg, + **getattr(scoring, "_kwargs", {}), + ) + + def calc_score(log_reg): + return scoring(log_reg, X_test, y_test, **score_params) + + for w, C in zip(coefs, Cs): + log_reg.C = C if fit_intercept: log_reg.coef_ = w[..., :-1] log_reg.intercept_ = w[..., -1] @@ -720,15 +807,8 @@ def _log_reg_scoring_path( log_reg.coef_ = w log_reg.intercept_ = 0.0 - if scoring is None: - scores.append(log_reg.score(X_test, y_test, sample_weight=sw_test)) - else: - score_params = score_params or {} - score_params = _check_method_params(X=X, params=score_params, indices=test) - # FIXME: If scoring = "neg_brier_score" and if not all class labels - # are present in y_test, the following fails. Maybe we can pass - # "labels=classes" to the call of scoring. - scores.append(scoring(log_reg, X_test, y_test, **score_params)) + scores.append(calc_score(log_reg)) + return coefs, Cs, np.array(scores), n_iter @@ -761,8 +841,8 @@ class LogisticRegression(LinearClassifierMixin, SparseCoefMixin, BaseEstimator): Specify the norm of the penalty: - `None`: no penalty is added; - - `'l2'`: add a L2 penalty term and it is the default choice; - - `'l1'`: add a L1 penalty term; + - `'l2'`: add an L2 penalty term and it is the default choice; + - `'l1'`: add an L1 penalty term; - `'elasticnet'`: both L1 and L2 penalty terms are added. .. warning:: @@ -861,7 +941,7 @@ class LogisticRegression(LinearClassifierMixin, SparseCoefMixin, BaseEstimator): class of problems. - For :term:`multiclass` problems (`n_classes >= 3`), all solvers except 'liblinear' minimize the full multinomial loss, 'liblinear' will raise an - error. + error. - 'newton-cholesky' is a good choice for `n_samples` >> `n_features * n_classes`, especially with one-hot encoded categorical features with rare categories. Be aware that the memory usage @@ -1165,13 +1245,17 @@ def fit(self, X, y, sample_weight=None): if penalty == "elasticnet" and self.l1_ratio is None: raise ValueError("l1_ratio must be specified when penalty is elasticnet.") - if penalty is None: - if self.C != 1.0: # default values + xp, _, device_ = get_namespace_and_device(X) + sample_weight = move_to(sample_weight, xp=xp, device=device_) + xp_y, _ = get_namespace(y) + + if self.penalty is None: + if self.C != 1.0: # default value warnings.warn( "Setting penalty=None will ignore the C and l1_ratio parameters" ) # Note that check for l1_ratio is done right above - C_ = np.inf + C_ = xp.inf penalty = "l2" else: C_ = self.C @@ -1183,24 +1267,19 @@ def fit(self, X, y, sample_weight=None): if self.n_jobs is not None: warnings.warn(msg, category=FutureWarning) - if solver == "lbfgs": - _dtype = np.float64 - else: - _dtype = [np.float64, np.float32] - X, y = validate_data( self, X, y, accept_sparse="csr", - dtype=_dtype, + dtype=[xp.float64, xp.float32], order="C", accept_large_sparse=solver not in ["liblinear", "sag", "saga"], ) n_features = X.shape[1] check_classification_targets(y) - self.classes_ = np.unique(y) - n_classes = len(self.classes_) + self.classes_ = xp_y.unique_values(y) + n_classes = size(self.classes_) is_binary = n_classes == 2 if solver == "liblinear": @@ -1250,10 +1329,13 @@ def fit(self, X, y, sample_weight=None): warm_start_coef = getattr(self, "coef_", None) else: warm_start_coef = None - if warm_start_coef is not None and self.fit_intercept: - warm_start_coef = np.append( - warm_start_coef, self.intercept_[:, np.newaxis], axis=1 - ) + if warm_start_coef is not None: + warm_start_coef = move_to(warm_start_coef, xp=np, device="cpu") + if self.fit_intercept: + intercept_np = move_to(self.intercept_, xp=np, device="cpu") + warm_start_coef = np.concatenate( + [warm_start_coef, intercept_np[:, None]], axis=1 + ) # TODO: enable multi-threading if benchmarks show a positive effect, # see https://github.com/scikit-learn/scikit-learn/issues/32162 @@ -1280,9 +1362,9 @@ def fit(self, X, y, sample_weight=None): n_threads=n_threads, ) - self.n_iter_ = np.asarray(n_iter, dtype=np.int32) + self.n_iter_ = xp.asarray(n_iter, dtype=xp.int32) - self.coef_ = coefs[0] + self.coef_ = coefs[0, ...] if self.fit_intercept: if is_binary: self.intercept_ = self.coef_[-1:] @@ -1292,10 +1374,10 @@ def fit(self, X, y, sample_weight=None): self.coef_ = self.coef_[:, :-1] else: if is_binary: - self.intercept_ = np.zeros(1, dtype=X.dtype) + self.intercept_ = xp.zeros(1, dtype=X.dtype, device=device_) self.coef_ = self.coef_[None, :] else: - self.intercept_ = np.zeros(n_classes, dtype=X.dtype) + self.intercept_ = xp.zeros(n_classes, dtype=X.dtype, device=device_) return self @@ -1322,8 +1404,9 @@ def predict_proba(self, X): where classes are ordered as they are in ``self.classes_``. """ check_is_fitted(self) + check_same_namespace(X, self, attribute="coef_", method="predict_proba") - is_binary = self.classes_.size <= 2 + is_binary = size(self.classes_) <= 2 if is_binary: return super()._predict_proba_lr(X) else: @@ -1349,11 +1432,14 @@ def predict_log_proba(self, X): Returns the log-probability of the sample for each class in the model, where classes are ordered as they are in ``self.classes_``. """ - return np.log(self.predict_proba(X)) + check_same_namespace(X, self, attribute="coef_", method="predict_log_proba") + xp, _ = get_namespace(X) + return xp.log(self.predict_proba(X)) def __sklearn_tags__(self): tags = super().__sklearn_tags__() tags.input_tags.sparse = True + tags.array_api_support = self.solver == "lbfgs" if self.solver == "liblinear": tags.classifier_tags.multi_class = False @@ -1432,8 +1518,8 @@ class LogisticRegressionCV(LogisticRegression, LinearClassifierMixin, BaseEstima penalty : {'l1', 'l2', 'elasticnet'}, default='l2' Specify the norm of the penalty: - - `'l2'`: add a L2 penalty term (used by default); - - `'l1'`: add a L1 penalty term; + - `'l2'`: add an L2 penalty term (used by default); + - `'l1'`: add an L1 penalty term; - `'elasticnet'`: both L1 and L2 penalty terms are added. .. warning:: @@ -1455,6 +1541,10 @@ class LogisticRegressionCV(LogisticRegression, LinearClassifierMixin, BaseEstima ``scorer(estimator, X, y)``. See :ref:`scoring_callable` for details. - `None`: :ref:`accuracy <accuracy_score>` is used. + .. versionchanged:: 1.11 + The default will change from None, i.e. accuracy, to 'neg_log_loss' in + version 1.11. + solver : {'lbfgs', 'liblinear', 'newton-cg', 'newton-cholesky', 'sag', 'saga'}, \ default='lbfgs' @@ -1465,7 +1555,7 @@ class LogisticRegressionCV(LogisticRegression, LinearClassifierMixin, BaseEstima class of problems. - For :term:`multiclass` problems (`n_classes >= 3`), all solvers except 'liblinear' minimize the full multinomial loss, 'liblinear' will raise an - error. + error. - 'newton-cholesky' is a good choice for `n_samples` >> `n_features * n_classes`, especially with one-hot encoded categorical features with rare categories. Be aware that the memory usage @@ -1677,14 +1767,17 @@ class of problems. >>> from sklearn.linear_model import LogisticRegressionCV >>> X, y = load_iris(return_X_y=True) >>> clf = LogisticRegressionCV( - ... cv=5, random_state=0, use_legacy_attributes=False, l1_ratios=(0,) + ... cv=5, random_state=0, + ... use_legacy_attributes=False, + ... l1_ratios=(0,), + ... scoring="neg_log_loss", ... ).fit(X, y) >>> clf.predict(X[:2, :]) array([0, 0]) >>> clf.predict_proba(X[:2, :]).shape (2, 3) >>> clf.score(X, y) - 0.98... + -0.041... """ _parameter_constraints: dict = {**LogisticRegression._parameter_constraints} @@ -1697,7 +1790,12 @@ class of problems. "Cs": [Interval(Integral, 1, None, closed="left"), "array-like"], "l1_ratios": ["array-like", None, Hidden(StrOptions({"warn"}))], "cv": ["cv_object"], - "scoring": [StrOptions(set(get_scorer_names())), callable, None], + "scoring": [ + StrOptions(set(get_scorer_names())), + callable, + None, + Hidden(StrOptions({"warn"})), + ], "refit": ["boolean"], "penalty": [ StrOptions({"l1", "l2", "elasticnet"}), @@ -1716,7 +1814,7 @@ def __init__( cv=None, dual=False, penalty="deprecated", - scoring=None, + scoring="warn", solver="lbfgs", tol=1e-4, max_iter=100, @@ -1819,6 +1917,19 @@ def fit(self, X, y, sample_weight=None, **params): FutureWarning, ) + if self.scoring == "warn": + warnings.warn( + "The default value of the parameter 'scoring' will change from None, " + "i.e. accuracy, to 'neg_log_loss' in version 1.11. To silence this " + "warning, explicitly set the scoring parameter: " + "scoring='neg_log_loss' for the new, scoring='accuracy' or " + "scoring=None for the old default.", + FutureWarning, + ) + scoring = None + else: + scoring = self.scoring + if self.use_legacy_attributes == "warn": warnings.warn( f"The fitted attributes of {self.__class__.__name__} will be " @@ -1965,7 +2076,7 @@ def fit(self, X, y, sample_weight=None, **params): max_iter=self.max_iter, verbose=self.verbose, class_weight=class_weight, - scoring=self.scoring, + scoring=scoring, intercept_scaling=self.intercept_scaling, random_state=self.random_state, max_squared_sum=max_squared_sum, @@ -2084,7 +2195,7 @@ def fit(self, X, y, sample_weight=None, **params): best_indices_l1 = best_indices[:, 1] self.l1_ratio_.append(np.mean(l1_ratios_[best_indices_l1])) else: - self.l1_ratio_.append(None) + self.l1_ratio_.append(0.0) if is_binary: self.coef_ = w[:, :n_features] if w.ndim == 2 else w[:n_features][None, :] @@ -2232,10 +2343,13 @@ def _get_scorer(self): """Get the scorer based on the scoring method specified. The default scoring method is `accuracy`. """ + if self.scoring == "warn": # TODO(1.11): remove + return get_scorer("accuracy") scoring = self.scoring or "accuracy" return get_scorer(scoring) def __sklearn_tags__(self): tags = super().__sklearn_tags__() tags.input_tags.sparse = True + tags.array_api_support = False return tags diff --git a/sklearn/linear_model/_omp.py b/sklearn/linear_model/_omp.py index 98ddc93a49b20..e59fce98316bf 100644 --- a/sklearn/linear_model/_omp.py +++ b/sklearn/linear_model/_omp.py @@ -11,8 +11,8 @@ from scipy import linalg from scipy.linalg.lapack import get_lapack_funcs -from sklearn.base import MultiOutputMixin, RegressorMixin, _fit_context -from sklearn.linear_model._base import LinearModel, _pre_fit +from sklearn.base import RegressorMixin, _fit_context +from sklearn.linear_model._base import LinearModel, MultiOutputLinearModel, _pre_fit from sklearn.model_selection import check_cv from sklearn.utils import Bunch, as_float_array, check_array from sklearn.utils._param_validation import Interval, StrOptions, validate_params @@ -642,7 +642,7 @@ def orthogonal_mp_gram( return np.squeeze(coef) -class OrthogonalMatchingPursuit(MultiOutputMixin, RegressorMixin, LinearModel): +class OrthogonalMatchingPursuit(RegressorMixin, MultiOutputLinearModel): """Orthogonal Matching Pursuit model (OMP). Read more in the :ref:`User Guide <omp>`. @@ -926,9 +926,9 @@ class OrthogonalMatchingPursuitCV(RegressorMixin, LinearModel): Possible inputs for cv are: - None, to use the default 5-fold cross-validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used. diff --git a/sklearn/linear_model/_passive_aggressive.py b/sklearn/linear_model/_passive_aggressive.py index c5f62efd35bf6..0bf4ad964d0a3 100644 --- a/sklearn/linear_model/_passive_aggressive.py +++ b/sklearn/linear_model/_passive_aggressive.py @@ -40,7 +40,7 @@ class PassiveAggressiveClassifier(BaseSGDClassifier): Parameters ---------- C : float, default=1.0 - Aggressiveness parameter for the passive-agressive algorithm, see [1]. + Aggressiveness parameter for the passive-aggressive algorithm, see [1]. For PA-I it is the maximum step size. For PA-II it regularizes the step size (the smaller `C` the more it regularizes). As a general rule-of-thumb, `C` should be small when the data is noisy. @@ -367,7 +367,7 @@ class PassiveAggressiveRegressor(BaseSGDRegressor): ---------- C : float, default=1.0 - Aggressiveness parameter for the passive-agressive algorithm, see [1]. + Aggressiveness parameter for the passive-aggressive algorithm, see [1]. For PA-I it is the maximum step size. For PA-II it regularizes the step size (the smaller `C` the more it regularizes). As a general rule-of-thumb, `C` should be small when the data is noisy. diff --git a/sklearn/linear_model/_quantile.py b/sklearn/linear_model/_quantile.py index aba8c3e642ac1..6272dc275ad07 100644 --- a/sklearn/linear_model/_quantile.py +++ b/sklearn/linear_model/_quantile.py @@ -13,7 +13,7 @@ from sklearn.linear_model._base import LinearModel from sklearn.utils import _safe_indexing from sklearn.utils._param_validation import Interval, StrOptions -from sklearn.utils.fixes import parse_version, sp_version +from sklearn.utils.fixes import _sparse_eye_array, parse_version, sp_version from sklearn.utils.validation import _check_sample_weight, validate_data @@ -240,9 +240,9 @@ def fit(self, X, y, sample_weight=None): # even for optimization problems parametrized using dense numpy arrays. # Therefore, we work with CSC matrices as early as possible to limit # unnecessary repeated memory copies. - eye = sparse.eye(n_indices, dtype=X.dtype, format="csc") + eye = _sparse_eye_array(n_indices, dtype=X.dtype, format="csc") if self.fit_intercept: - ones = sparse.csc_matrix(np.ones(shape=(n_indices, 1), dtype=X.dtype)) + ones = sparse.csc_array(np.ones(shape=(n_indices, 1), dtype=X.dtype)) A_eq = sparse.hstack([ones, X, -ones, -X, eye, -eye], format="csc") else: A_eq = sparse.hstack([X, -X, eye, -eye], format="csc") diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 144c31c4a27ec..5d5ef7536450c 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -42,7 +42,6 @@ compute_sample_weight, ) from sklearn.utils._array_api import ( - _convert_to_numpy, _is_numpy_namespace, _max_precision_float_dtype, _ravel, @@ -61,7 +60,6 @@ _routing_enabled, process_routing, ) -from sklearn.utils.sparsefuncs import mean_variance_axis from sklearn.utils.validation import ( _check_sample_weight, check_is_fitted, @@ -455,6 +453,9 @@ def ridge_regression( If an array is passed, penalties are assumed to be specific to the targets. Hence they must correspond in number. + For an illustration of the effect of alpha on the model coefficients, see + :ref:`sphx_glr_auto_examples_linear_model_plot_ridge_coeffs.py`. + sample_weight : float or array-like of shape (n_samples,), default=None Individual weights for each sample. If given a float, every sample will have the same weight. If sample_weight is not None and @@ -720,7 +721,10 @@ def _ridge_regression( if alpha.shape[0] == 1 and n_targets > 1: alpha = xp.full( - shape=(n_targets,), fill_value=alpha[0], dtype=alpha.dtype, device=device_ + shape=(n_targets,), + fill_value=float(alpha[0]), + dtype=alpha.dtype, + device=device_, ) n_iter = None @@ -1055,6 +1059,9 @@ class Ridge(MultiOutputMixin, RegressorMixin, _BaseRidge): If an array is passed, penalties are assumed to be specific to the targets. Hence they must correspond in number. + See :ref:`sphx_glr_auto_examples_linear_model_plot_ridge_coeffs.py` + for an illustration of the effect of alpha on the model coefficients. + fit_intercept : bool, default=True Whether to fit the intercept for this model. If set to false, no intercept will be used in calculations @@ -1251,7 +1258,9 @@ def fit(self, X, y, sample_weight=None): Fitted estimator. """ _accept_sparse = _get_valid_accept_sparse(sparse.issparse(X), self.solver) - xp, _ = get_namespace(X, y, sample_weight) + xp, _, device_ = get_namespace_and_device(X) + y, sample_weight = move_to(y, sample_weight, xp=xp, device=device_) + X, y = validate_data( self, X, @@ -1264,6 +1273,22 @@ def fit(self, X, y, sample_weight=None): ) return super().fit(X, y, sample_weight=sample_weight) + def predict(self, X): + """ + Predict using the linear model. + + Parameters + ---------- + X : array-like or sparse matrix of shape (n_samples, n_features) + Samples. + + Returns + ------- + C : ndarray of shape (n_samples,) or (n_samples, n_outputs) + Predicted values. + """ + return super().predict(X) + def __sklearn_tags__(self): tags = super().__sklearn_tags__() tags.array_api_support = True @@ -1321,12 +1346,7 @@ def _prepare_data(self, X, y, sample_weight, solver): self._label_binarizer = LabelBinarizer(pos_label=1, neg_label=-1) xp_y, y_is_array_api = get_namespace(y) - # TODO: Update this line to avoid calling `_convert_to_numpy` - # once LabelBinarizer has been updated to accept non-NumPy array API - # compatible inputs. - Y = self._label_binarizer.fit_transform( - _convert_to_numpy(y, xp_y) if y_is_array_api else y - ) + Y = self._label_binarizer.fit_transform(y) Y = move_to(Y, xp=xp, device=device_) if y_is_array_api and xp_y.isdtype(y.dtype, "numeric"): self.classes_ = move_to( @@ -1366,10 +1386,8 @@ def predict(self, X): # is 1 to use the inverse transform of the label binarizer fitted # during fit. decision = self.decision_function(X) - xp, is_array_api = get_namespace(decision) + xp, _ = get_namespace(decision) scores = 2.0 * xp.astype(decision > 0, decision.dtype) - 1.0 - if is_array_api: - scores = _convert_to_numpy(scores, xp) return self._label_binarizer.inverse_transform(scores) return super().predict(X) @@ -1379,7 +1397,7 @@ def __sklearn_tags__(self): return tags def _get_scorer_instance(self): - """Return a scorer which corresponds to what's defined in ClassiferMixin + """Return a scorer which corresponds to what's defined in ClassifierMixin parent class. This is used for routing `sample_weight`. """ return get_scorer("accuracy") @@ -1404,6 +1422,9 @@ class RidgeClassifier(_RidgeClassifierMixin, _BaseRidge): :class:`~sklearn.linear_model.LogisticRegression` or :class:`~sklearn.svm.LinearSVC`. + For an illustration of the effect of alpha on the model coefficients, see + :ref:`sphx_glr_auto_examples_linear_model_plot_ridge_coeffs.py`. + fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations (e.g. data is expected to be @@ -1618,13 +1639,24 @@ def __sklearn_tags__(self): def _check_gcv_mode(X, gcv_mode): - if gcv_mode in ["eigen", "svd"]: - return gcv_mode - # if X has more rows than columns, use decomposition of X^T.X, - # otherwise X.X^T - if X.shape[0] > X.shape[1]: - return "svd" - return "eigen" + # svd only implemented for dense X + if gcv_mode == "svd": + if sparse.issparse(X): + # TODO(1.11) raise ValueError + msg = ( + "The 'svd' mode is not supported for sparse X, we fallback to " + "`gcv_mode='eigen'`. Passing `gcv_mode='svd'` on sparse X will raise " + "an error in 1.11, use the default or pass `gcv_mode='eigen'` to " + "suppress this warning." + ) + warnings.warn(msg, FutureWarning) + else: + return "svd" + + # All other cases ("auto", "eigen") + # fallbacks to gram (n <= p) or cov (p < n) + n, p = X.shape + return "gram" if n <= p else "cov" def _find_smallest_angle(query, vectors): @@ -1767,37 +1799,81 @@ class _RidgeGCV(LinearModel): Notes ----- - We want to solve (K + alpha*Id)c = y, - where K = X X^T is the kernel matrix. - - Let G = (K + alpha*Id). + 1. Unweighted and no intercept - Dual solution: c = G^-1y - Primal solution: w = X^T c + We start by the simplest case `fit_intercept=False` and `sample_weight=None`. + The other cases (see below) reduce to this one after proper scaling/centering + of the design matrix X. - Compute eigendecomposition K = Q V Q^T. - Then G^-1 = Q (V + alpha*Id)^-1 Q^T, - where (V + alpha*Id) is diagonal. - It is thus inexpensive to inverse for many alphas. + The design matrix X has shape (n, p) = (n_samples, n_features). - Let loov be the vector of prediction values for each example - when the model was fitted with all examples but this example. + Let G = (K + alpha*Id_n) where K = X X' is the Gram matrix and Id_n is the + identity matrix of size n. - loov = (KG^-1Y - diag(KG^-1)Y) / diag(I-KG^-1) + Let H = (C + alpha*Id_p) where C = X' X is the covariance matrix and Id_p + is the identity matrix of size p. - Let looe be the vector of prediction errors for each example - when the model was fitted with all examples but this example. + The solution of the regularized least square (fitted `coef_`) is given by: + w = H^-1 X' y = X' c where c = G^-1 y. - looe = y - loov = c / diag(G^-1) + Let loov (resp looe) be the leave-one-out values (resp errors), that is the + vector of predictions (resp errors) for each single observation when the model + was fitted with all examples but this example. As shown in [1]: + looe = y - loov = c / d where d = diag(G^-1). The best score (negative mean squared error or user-provided scoring) is stored in the `best_score_` attribute, and the selected hyperparameter in `alpha_`. + 2. Leveraging a precomputed matrix decomposition + + The leave-one-out errors and coefficients can be efficiently computed for any + alpha from the SVD of X, or the eigendecomposition of K = X X' or C = X' X. + + Reduced SVD X = U S V' when n < p (wide X) + Let D = 1 / (S^2 + alpha) + c = U D U' y + d = diag(U D U') + w = V S / (S^2 + alpha) U' y + + Eigendecomposition K = U L U' + Let D = 1 / (L + alpha) + c = U D U' y + d = diag(U D U') + w = X' c. + + Reduced SVD X = U S V' when p < n (long X) + Let M = alpha / (S^2 + alpha) - 1 + alpha c = y + U M U' y + alpha d = 1 + diag(U M U') + w = V S / (S^2 + alpha) U' y + + Eigendecomposition C = V L V' + H^-1 = V 1 / (L + alpha) V' + alpha c = y - X H^-1 X' y + alpha d = 1 - diag(X H^-1 X') + w = H^-1 X' y + + 3. Fitting with intercept or sample weights + + Fitting with intercept and/or sample weights reduces to the unweigthed no + intercept case after centering and/or rescaling of X and y, as done in + `_preprocess_data`: + X <- sqrt(s) (X - X_mean) + y <- sqrt(s) (y - y_mean) + + The returned looe are also rescaled by sample weights: + looe <- sqrt(s) looe + + If we fit an intercept, there is the following correction term: + d <- d - sqrt(s) * G^-1 sqrt(s) / sum(s) + References ---------- - [1] http://cbcl.mit.edu/publications/ps/MIT-CSAIL-TR-2007-025.pdf - [2] https://www.mit.edu/~9.520/spring07/Classes/rlsslides.pdf + .. [1] R. Rifkin and R. Lippert (2007). "Notes on Regularized Least Squares." + https://dspace.mit.edu/bitstream/handle/1721.1/37318/MIT-CSAIL-TR-2007-025.pdf + .. [2] R. Rifkin (2007). "Regularized Least Squares." + https://www.mit.edu/~9.520/spring07/Classes/rlsslides.pdf """ def __init__( @@ -1823,7 +1899,7 @@ def __init__( @staticmethod def _decomp_diag(v_prime, Q): - # compute diagonal of the matrix: dot(Q, dot(diag(v_prime), Q^T)) + # compute diagonal of the matrix: dot(Q, dot(diag(v_prime), Q.T)) xp, _ = get_namespace(v_prime, Q) return xp.sum(v_prime * Q**2, axis=1) @@ -1836,307 +1912,291 @@ def _diag_dot(D, B): D = D[(slice(None),) + (None,) * (len(B.shape) - 1)] return D * B - def _compute_gram(self, X, sqrt_sw): - """Computes the Gram matrix XX^T with possible centering. + def _compute_gram(self, X, X_mean, sqrt_sw): + """Computes the Gram matrix X X' with possible centering. Parameters ---------- - X : {ndarray, sparse matrix} of shape (n_samples, n_features) + X : {ndarray, sparse matrix, sparse array} of shape (n_samples, n_features) The preprocessed design matrix. + X_mean : ndarray of shape (n_feature,) + The weighted mean of X for each feature. + sqrt_sw : ndarray of shape (n_samples,) - square roots of sample weights + Square roots of sample weights. Returns ------- gram : ndarray of shape (n_samples, n_samples) The Gram matrix. - X_mean : ndarray of shape (n_feature,) - The weighted mean of ``X`` for each feature. Notes ----- - When X is dense the centering has been done in preprocessing - so the mean is 0 and we just compute XX^T. - - When X is sparse it has not been centered in preprocessing, but it has - been scaled by sqrt(sample weights). - When self.fit_intercept is False no centering is done. - The centered X is never actually computed because centering would break - the sparsity of X. + When X is dense the centering has been done in preprocessing + so the mean is 0 and we just compute X X'. + + When X is sparse it has not been centered in preprocessing, but + it has been scaled by sqrt_sw. The centered X is never actually + computed because centering would break the sparsity of X. """ - xp, _ = get_namespace(X) center = self.fit_intercept and sparse.issparse(X) if not center: # in this case centering has been done in preprocessing # or we are not fitting an intercept. - X_mean = xp.zeros(X.shape[1], dtype=X.dtype) - return safe_sparse_dot(X, X.T, dense_output=True), X_mean - # X is sparse - n_samples = X.shape[0] - sample_weight_matrix = sparse.dia_matrix( - (sqrt_sw, 0), shape=(n_samples, n_samples) - ) - X_weighted = sample_weight_matrix.dot(X) - X_mean, _ = mean_variance_axis(X_weighted, axis=0) - X_mean *= n_samples / sqrt_sw.dot(sqrt_sw) - X_mX = sqrt_sw[:, None] * safe_sparse_dot(X_mean, X.T, dense_output=True) - X_mX_m = np.outer(sqrt_sw, sqrt_sw) * np.dot(X_mean, X_mean) + return safe_sparse_dot(X, X.T, dense_output=True) + # X is sparse and fit_intercept is True + # centered matrix = X - sqrt_sw X_mean' + X_Xm = safe_sparse_dot(X, X_mean, dense_output=True) return ( - safe_sparse_dot(X, X.T, dense_output=True) + X_mX_m - X_mX - X_mX.T, - X_mean, + safe_sparse_dot(X, X.T, dense_output=True) + - X_Xm[:, None] * sqrt_sw[None, :] + - sqrt_sw[:, None] * X_Xm[None, :] + + (X_mean @ X_mean) * sqrt_sw[:, None] * sqrt_sw[None, :] ) - def _compute_covariance(self, X, sqrt_sw): - """Computes covariance matrix X^TX with possible centering. + def _compute_covariance(self, X, X_mean, sqrt_sw): + """Computes covariance matrix X' X with possible centering. Parameters ---------- - X : sparse matrix of shape (n_samples, n_features) + X : {ndarray, sparse matrix, sparse array} of shape (n_samples, n_features) The preprocessed design matrix. + X_mean : ndarray of shape (n_feature,) + The weighted mean of X for each feature. + sqrt_sw : ndarray of shape (n_samples,) - square roots of sample weights + Square roots of sample weights. Returns ------- covariance : ndarray of shape (n_features, n_features) The covariance matrix. - X_mean : ndarray of shape (n_feature,) - The weighted mean of ``X`` for each feature. Notes ----- - Since X is sparse it has not been centered in preprocessing, but it has - been scaled by sqrt(sample weights). - When self.fit_intercept is False no centering is done. - The centered X is never actually computed because centering would break - the sparsity of X. + When X is dense the centering has been done in preprocessing + so the mean is 0 and we just compute X' X. + + When X is sparse it has not been centered in preprocessing, but + it has been scaled by sqrt_sw. The centered X is never actually + computed because centering would break the sparsity of X. """ - if not self.fit_intercept: + center = self.fit_intercept and sparse.issparse(X) + if not center: # in this case centering has been done in preprocessing # or we are not fitting an intercept. - X_mean = np.zeros(X.shape[1], dtype=X.dtype) - return safe_sparse_dot(X.T, X, dense_output=True), X_mean - # this function only gets called for sparse X - n_samples = X.shape[0] - sample_weight_matrix = sparse.dia_matrix( - (sqrt_sw, 0), shape=(n_samples, n_samples) - ) - X_weighted = sample_weight_matrix.dot(X) - X_mean, _ = mean_variance_axis(X_weighted, axis=0) - X_mean = X_mean * n_samples / sqrt_sw.dot(sqrt_sw) - weight_sum = sqrt_sw.dot(sqrt_sw) + return safe_sparse_dot(X.T, X, dense_output=True) + # X is sparse and fit_intercept is True + # centered matrix = X - sqrt_sw X_mean' + sw_sum = sqrt_sw @ sqrt_sw return ( safe_sparse_dot(X.T, X, dense_output=True) - - weight_sum * np.outer(X_mean, X_mean), - X_mean, + - sw_sum * X_mean[:, None] * X_mean[None, :] ) def _sparse_multidot_diag(self, X, A, X_mean, sqrt_sw): - """Compute the diagonal of (X - X_mean).dot(A).dot((X - X_mean).T) - without explicitly centering X nor computing X.dot(A) - when X is sparse. + """Compute the diagonal of X A X' with possible centering. Parameters ---------- - X : sparse matrix of shape (n_samples, n_features) + X : {ndarray, sparse matrix, sparse array} of shape (n_samples, n_features) + The preprocessed design matrix. A : ndarray of shape (n_features, n_features) + The inner matrix. - X_mean : ndarray of shape (n_features,) + X_mean : ndarray of shape (n_feature,) + The weighted mean of X for each feature. - sqrt_sw : ndarray of shape (n_features,) - square roots of sample weights + sqrt_sw : ndarray of shape (n_samples,) + Square roots of sample weights. Returns ------- diag : np.ndarray, shape (n_samples,) The computed diagonal. - """ - intercept_col = scale = sqrt_sw - batch_size = X.shape[1] - diag = np.empty(X.shape[0], dtype=X.dtype) - for start in range(0, X.shape[0], batch_size): - batch = slice(start, min(X.shape[0], start + batch_size), 1) - X_batch = np.empty( - (X[batch].shape[0], X.shape[1] + self.fit_intercept), dtype=X.dtype - ) - if self.fit_intercept: - X_batch[:, :-1] = X[batch].toarray() - X_mean * scale[batch][:, None] - X_batch[:, -1] = intercept_col[batch] - else: - X_batch = X[batch].toarray() - diag[batch] = (X_batch.dot(A) * X_batch).sum(axis=1) - return diag - def _eigen_decompose_gram(self, X, y, sqrt_sw): - """Eigendecomposition of X.X^T, used when n_samples <= n_features.""" - # if X is dense it has already been centered in preprocessing - xp, is_array_api = get_namespace(X) - K, X_mean = self._compute_gram(X, sqrt_sw) - if self.fit_intercept: - # to emulate centering X with sample weights, - # ie removing the weighted average, we add a column - # containing the square roots of the sample weights. - # by centering, it is orthogonal to the other columns - K += xp.linalg.outer(sqrt_sw, sqrt_sw) - eigvals, Q = xp.linalg.eigh(K) - QT_y = Q.T @ y - return X_mean, eigvals, Q, QT_y + Notes + ----- + When self.fit_intercept is False no centering is done. - def _solve_eigen_gram(self, alpha, y, sqrt_sw, X_mean, eigvals, Q, QT_y): - """Compute dual coefficients and diagonal of G^-1. + When X is dense the centering has been done in preprocessing + so the mean is 0 and we just compute diag(X A X'). - Used when we have a decomposition of X.X^T (n_samples <= n_features). - """ - xp, is_array_api = get_namespace(eigvals) - w = 1.0 / (eigvals + alpha) - if self.fit_intercept: - # the vector containing the square roots of the sample weights (1 - # when no sample weights) is the eigenvector of XX^T which - # corresponds to the intercept; we cancel the regularization on - # this dimension. the corresponding eigenvalue is - # sum(sample_weight). - norm = xp.linalg.vector_norm if is_array_api else np.linalg.norm - normalized_sw = sqrt_sw / norm(sqrt_sw) - intercept_dim = _find_smallest_angle(normalized_sw, Q) - w[intercept_dim] = 0 # cancel regularization for the intercept - - c = Q @ self._diag_dot(w, QT_y) - G_inverse_diag = self._decomp_diag(w, Q) - # handle case where y is 2-d - if len(y.shape) != 1: - G_inverse_diag = G_inverse_diag[:, None] - return G_inverse_diag, c - - def _eigen_decompose_covariance(self, X, y, sqrt_sw): - """Eigendecomposition of X^T.X, used when n_samples > n_features - and X is sparse. + When X is sparse it has not been centered in preprocessing, but + it has been scaled by sqrt_sw. The centered X is never actually + computed because centering would break the sparsity of X. """ - n_samples, n_features = X.shape - cov = np.empty((n_features + 1, n_features + 1), dtype=X.dtype) - cov[:-1, :-1], X_mean = self._compute_covariance(X, sqrt_sw) - if not self.fit_intercept: - cov = cov[:-1, :-1] - # to emulate centering X with sample weights, - # ie removing the weighted average, we add a column - # containing the square roots of the sample weights. - # by centering, it is orthogonal to the other columns - # when all samples have the same weight we add a column of 1 + xp, _ = get_namespace(X) + XA = X @ A + if sparse.isspmatrix(X): + # sparse matrix use multiply for element wise multiplication + XAX = np.ravel(X.multiply(XA).sum(axis=1)) else: - cov[-1] = 0 - cov[:, -1] = 0 - cov[-1, -1] = sqrt_sw.dot(sqrt_sw) - nullspace_dim = max(0, n_features - n_samples) - eigvals, V = linalg.eigh(cov) - # remove eigenvalues and vectors in the null space of X^T.X - eigvals = eigvals[nullspace_dim:] - V = V[:, nullspace_dim:] - return X_mean, eigvals, V, X - - def _solve_eigen_covariance_no_intercept( - self, alpha, y, sqrt_sw, X_mean, eigvals, V, X + XAX = xp.sum(XA * X, axis=1) + center = self.fit_intercept and sparse.issparse(X) + if not center: + # in this case centering has been done in preprocessing + # or we are not fitting an intercept. + return XAX + # X is sparse and fit_intercept is True + # centered matrix = X - sqrt_sw X_mean' + XA_Xm = XA @ X_mean + A_Xm = A @ X_mean + sw = sqrt_sw * sqrt_sw + return XAX - 2 * sqrt_sw * XA_Xm + sw * (X_mean @ A_Xm) + + def _eigen_decompose_gram(self, X, X_mean, y, sqrt_sw): + """Eigendecomposition of Gram matrix X X'""" + xp, is_array_api = get_namespace(X) + K = self._compute_gram(X, X_mean, sqrt_sw) + eigvals, Q = xp.linalg.eigh(K) + QT_y = Q.T @ y + QT_sqrt_sw = Q.T @ sqrt_sw + XT = X.T + return eigvals, Q, QT_y, QT_sqrt_sw, XT, X_mean + + def _solve_eigen_gram( + self, alpha, y, sqrt_sw, eigvals, Q, QT_y, QT_sqrt_sw, XT, X_mean ): - """Compute dual coefficients and diagonal of G^-1. + """Compute looe and coef when we have a decomposition of X X'""" + D = 1.0 / (eigvals + alpha) + c = Q @ self._diag_dot(D, QT_y) + d = self._decomp_diag(D, Q) + if self.fit_intercept: + sw_sum = sqrt_sw @ sqrt_sw + Ginv_sqrt_sw = Q @ self._diag_dot(D, QT_sqrt_sw) + d -= Ginv_sqrt_sw * sqrt_sw / sw_sum + if y.ndim == 2: + d = d[:, None] + XT_c = XT @ c + if self.fit_intercept and sparse.issparse(XT): + # centered matrix = X - sqrt_sw X_mean' + if y.ndim == 2: + XT_c -= X_mean[:, None] * (sqrt_sw @ c) + else: + XT_c -= X_mean * (sqrt_sw @ c) + looe = c / d + coef = XT_c + return looe, coef - Used when we have a decomposition of X^T.X - (n_samples > n_features and X is sparse), and not fitting an intercept. - """ - w = 1 / (eigvals + alpha) - A = (V * w).dot(V.T) - AXy = A.dot(safe_sparse_dot(X.T, y, dense_output=True)) - y_hat = safe_sparse_dot(X, AXy, dense_output=True) - hat_diag = self._sparse_multidot_diag(X, A, X_mean, sqrt_sw) - if len(y.shape) != 1: - # handle case where y is 2-d - hat_diag = hat_diag[:, np.newaxis] - return (1 - hat_diag) / alpha, (y - y_hat) / alpha + def _eigen_decompose_covariance(self, X, X_mean, y, sqrt_sw): + """Eigendecomposition of covariance matrix X' X""" + xp, is_array_api = get_namespace(X) + cov = self._compute_covariance(X, X_mean, sqrt_sw) + eigvals, V = xp.linalg.eigh(cov) + XT_y = safe_sparse_dot(X.T, y, dense_output=True) + XT_sqrt_sw = safe_sparse_dot(X.T, sqrt_sw, dense_output=True) + if self.fit_intercept and sparse.issparse(X): + # centered matrix = X - sqrt_sw X_mean' + if y.ndim == 2: + XT_y -= X_mean[:, None] * (sqrt_sw @ y) + else: + XT_y -= X_mean * (sqrt_sw @ y) + XT_sqrt_sw -= X_mean * (sqrt_sw @ sqrt_sw) + return eigvals, V, X, X_mean, XT_y, XT_sqrt_sw - def _solve_eigen_covariance_intercept( - self, alpha, y, sqrt_sw, X_mean, eigvals, V, X + def _solve_eigen_covariance( + self, alpha, y, sqrt_sw, eigvals, V, X, X_mean, XT_y, XT_sqrt_sw ): - """Compute dual coefficients and diagonal of G^-1. - - Used when we have a decomposition of X^T.X - (n_samples > n_features and X is sparse), - and we are fitting an intercept. - """ - # the vector [0, 0, ..., 0, 1] - # is the eigenvector of X^TX which - # corresponds to the intercept; we cancel the regularization on - # this dimension. the corresponding eigenvalue is - # sum(sample_weight), e.g. n when uniform sample weights. - intercept_sv = np.zeros(V.shape[0]) - intercept_sv[-1] = 1 - intercept_dim = _find_smallest_angle(intercept_sv, V) - w = 1 / (eigvals + alpha) - w[intercept_dim] = 1 / eigvals[intercept_dim] - A = (V * w).dot(V.T) - # add a column to X containing the square roots of sample weights - X_op = _X_CenterStackOp(X, X_mean, sqrt_sw) - AXy = A.dot(X_op.T.dot(y)) - y_hat = X_op.dot(AXy) - hat_diag = self._sparse_multidot_diag(X, A, X_mean, sqrt_sw) - # return (1 - hat_diag), (y - y_hat) - if len(y.shape) != 1: - # handle case where y is 2-d - hat_diag = hat_diag[:, np.newaxis] - return (1 - hat_diag) / alpha, (y - y_hat) / alpha + """Compute looe and coef when we have a decomposition of X' X""" + D = 1 / (eigvals + alpha) + Hinv = (V * D) @ V.T + Hinv_XT_y = Hinv @ XT_y + Hinv_XT_sqrt_sw = Hinv @ XT_sqrt_sw + X_Hinv_XT_y = safe_sparse_dot(X, Hinv_XT_y, dense_output=True) + X_Hinv_XT_sqrt_sw = safe_sparse_dot(X, Hinv_XT_sqrt_sw, dense_output=True) + if self.fit_intercept and sparse.issparse(X): + # centered = X - sqrt_sw X_mean' + if y.ndim == 2: + X_Hinv_XT_y -= sqrt_sw[:, None] * (X_mean @ Hinv_XT_y) + else: + X_Hinv_XT_y -= sqrt_sw * (X_mean @ Hinv_XT_y) + X_Hinv_XT_sqrt_sw -= sqrt_sw * (X_mean @ Hinv_XT_sqrt_sw) + alpha_c = y - X_Hinv_XT_y + alpha_d = 1 - self._sparse_multidot_diag(X, Hinv, X_mean, sqrt_sw) + if self.fit_intercept: + sw_sum = sqrt_sw @ sqrt_sw + alpha_Ginv_sqrt_sw = sqrt_sw - X_Hinv_XT_sqrt_sw + alpha_d -= alpha_Ginv_sqrt_sw * sqrt_sw / sw_sum + if y.ndim == 2: + alpha_d = alpha_d[:, None] + looe = alpha_c / alpha_d + coef = Hinv_XT_y + return looe, coef + + def _svd_decompose_design_matrix(self, X, X_mean, y, sqrt_sw): + """Reduced SVD decomposition of X""" + xp, _ = get_namespace(X) + # reduced svd + U, singvals, VT = xp.linalg.svd(X, full_matrices=False) + UT_y = U.T @ y + UT_sqrt_sw = U.T @ sqrt_sw + V = VT.T + return singvals, U, V, UT_y, UT_sqrt_sw - def _solve_eigen_covariance(self, alpha, y, sqrt_sw, X_mean, eigvals, V, X): - """Compute dual coefficients and diagonal of G^-1. + def _solve_svd_design_matrix_long( + self, alpha, y, sqrt_sw, singvals, U, V, UT_y, UT_sqrt_sw + ): + """Compute looe and coef when we have an SVD decomposition of X. - Used when we have a decomposition of X^T.X - (n_samples > n_features and X is sparse). + Long X case (n_features < n_samples). """ + M = alpha / (singvals**2 + alpha) - 1 + alpha_c = U @ self._diag_dot(M, UT_y) + y + alpha_d = self._decomp_diag(M, U) + 1 if self.fit_intercept: - return self._solve_eigen_covariance_intercept( - alpha, y, sqrt_sw, X_mean, eigvals, V, X - ) - return self._solve_eigen_covariance_no_intercept( - alpha, y, sqrt_sw, X_mean, eigvals, V, X - ) - - def _svd_decompose_design_matrix(self, X, y, sqrt_sw): - xp, _, device_ = get_namespace_and_device(X) - # X already centered - X_mean = xp.zeros(X.shape[1], dtype=X.dtype, device=device_) - if self.fit_intercept: - # to emulate fit_intercept=True situation, add a column - # containing the square roots of the sample weights - # by centering, the other columns are orthogonal to that one - intercept_column = sqrt_sw[:, None] - X = xp.concat((X, intercept_column), axis=1) - U, singvals, _ = xp.linalg.svd(X, full_matrices=False) - singvals_sq = singvals**2 - UT_y = U.T @ y - return X_mean, singvals_sq, U, UT_y + sw_sum = sqrt_sw @ sqrt_sw + alpha_Ginv_sqrt_sw = U @ self._diag_dot(M, UT_sqrt_sw) + sqrt_sw + alpha_d -= alpha_Ginv_sqrt_sw * sqrt_sw / sw_sum + if y.ndim == 2: + # handle case where y is 2-d + alpha_d = alpha_d[:, None] + looe = alpha_c / alpha_d + coef = V @ self._diag_dot(singvals / (singvals**2 + alpha), UT_y) + return looe, coef - def _solve_svd_design_matrix(self, alpha, y, sqrt_sw, X_mean, singvals_sq, U, UT_y): - """Compute dual coefficients and diagonal of G^-1. + def _solve_svd_design_matrix_wide( + self, alpha, y, sqrt_sw, singvals, U, V, UT_y, UT_sqrt_sw + ): + """Compute looe and coef when we have an SVD decomposition of X. - Used when we have an SVD decomposition of X - (n_samples > n_features and X is dense). + Wide X case (n_samples < n_features). """ - xp, is_array_api = get_namespace(U) - w = ((singvals_sq + alpha) ** -1) - (alpha**-1) + alpha_D = alpha / (singvals**2 + alpha) + alpha_c = U @ self._diag_dot(alpha_D, UT_y) + alpha_d = self._decomp_diag(alpha_D, U) if self.fit_intercept: - # detect intercept column - normalized_sw = sqrt_sw / xp.linalg.vector_norm(sqrt_sw) - intercept_dim = int(_find_smallest_angle(normalized_sw, U)) - # cancel the regularization for the intercept - w[intercept_dim] = -(alpha**-1) - c = U @ self._diag_dot(w, UT_y) + (alpha**-1) * y - G_inverse_diag = self._decomp_diag(w, U) + (alpha**-1) - if len(y.shape) != 1: + sw_sum = sqrt_sw @ sqrt_sw + alpha_Ginv_sqrt_sw = U @ self._diag_dot(alpha_D, UT_sqrt_sw) + alpha_d -= alpha_Ginv_sqrt_sw * sqrt_sw / sw_sum + if y.ndim == 2: # handle case where y is 2-d - G_inverse_diag = G_inverse_diag[:, None] - return G_inverse_diag, c + alpha_d = alpha_d[:, None] + looe = alpha_c / alpha_d + coef = V @ self._diag_dot(singvals / (singvals**2 + alpha), UT_y) + return looe, coef + + def _solve_svd_design_matrix( + self, alpha, y, sqrt_sw, singvals, U, V, UT_y, UT_sqrt_sw + ): + """Compute looe and coef when we have an SVD decomposition of X.""" + n_samples = U.shape[0] + n_features = V.shape[0] + if n_samples <= n_features: + return self._solve_svd_design_matrix_wide( + alpha, y, sqrt_sw, singvals, U, V, UT_y, UT_sqrt_sw + ) + else: + return self._solve_svd_design_matrix_long( + alpha, y, sqrt_sw, singvals, U, V, UT_y, UT_sqrt_sw + ) def fit(self, X, y, sample_weight=None, score_params=None): """Fit Ridge regression model with gcv. @@ -2168,13 +2228,15 @@ def fit(self, X, y, sample_weight=None, score_params=None): """ xp, is_array_api, device_ = get_namespace_and_device(X) y, sample_weight = move_to(y, sample_weight, xp=xp, device=device_) - if is_array_api or hasattr(getattr(X, "dtype", None), "kind"): - original_dtype = X.dtype + if (is_array_api and xp.isdtype(X.dtype, "real floating")) or getattr( + getattr(X, "dtype", None), "kind", None + ) == "f": + original_floating_dtype = X.dtype else: # for X that does not have a simple dtype (e.g. pandas dataframe) # the attributes will be stored in the dtype chosen by # `validate_data``, i.e. np.float64 - original_dtype = None + original_floating_dtype = None # Using float32 can be numerically unstable for this estimator. So if # the array API namespace and device allow, convert the input values # to float64 whenever possible before converting the results back to @@ -2212,25 +2274,25 @@ def fit(self, X, y, sample_weight=None, score_params=None): gcv_mode = _check_gcv_mode(X, self.gcv_mode) - if gcv_mode == "eigen": + n_samples, n_features = X.shape + if gcv_mode == "gram": decompose = self._eigen_decompose_gram solve = self._solve_eigen_gram + elif gcv_mode == "cov": + decompose = self._eigen_decompose_covariance + solve = self._solve_eigen_covariance elif gcv_mode == "svd": - if sparse.issparse(X): - decompose = self._eigen_decompose_covariance - solve = self._solve_eigen_covariance - else: - decompose = self._svd_decompose_design_matrix - solve = self._solve_svd_design_matrix - - n_samples = X.shape[0] + decompose = self._svd_decompose_design_matrix + solve = self._solve_svd_design_matrix + else: + raise ValueError(f"Unknown {gcv_mode=}") if sqrt_sw is None: sqrt_sw = xp.ones(n_samples, dtype=X.dtype, device=device_) - X_mean, *decomposition = decompose(X, y, sqrt_sw) + decomposition = decompose(X, X_offset, y, sqrt_sw) - n_y = 1 if len(y.shape) == 1 else y.shape[1] + n_y = 1 if y.ndim == 1 else y.shape[1] if ( isinstance(self.alphas, numbers.Number) or getattr(self.alphas, "ndim", None) == 0 @@ -2242,20 +2304,20 @@ def fit(self, X, y, sample_weight=None, score_params=None): if self.store_cv_results: self.cv_results_ = xp.empty( - (n_samples * n_y, n_alphas), dtype=original_dtype, device=device_ + (n_samples * n_y, n_alphas), dtype=X.dtype, device=device_ ) best_coef, best_score, best_alpha = None, None, None for i, alpha in enumerate(alphas): - G_inverse_diag, c = solve(float(alpha), y, sqrt_sw, X_mean, *decomposition) + looe, coef = solve(float(alpha), y, sqrt_sw, *decomposition) if self.scoring is None: - squared_errors = (c / G_inverse_diag) ** 2 + squared_errors = looe**2 alpha_score = self._score_without_scorer(squared_errors=squared_errors) if self.store_cv_results: self.cv_results_[:, i] = _ravel(squared_errors) else: - predictions = y - (c / G_inverse_diag) + predictions = y - looe # Rescale predictions back to original scale if sample_weight is not None: # avoid the unnecessary division by ones if predictions.ndim > 1: @@ -2280,53 +2342,51 @@ def fit(self, X, y, sample_weight=None, score_params=None): if best_score is None: # initialize if self.alpha_per_target and n_y > 1: - best_coef = c + best_coef = coef best_score = xp.reshape(alpha_score, shape=(-1,)) best_alpha = xp.full(n_y, alpha, device=device_) else: - best_coef = c + best_coef = coef best_score = alpha_score best_alpha = alpha else: # update if self.alpha_per_target and n_y > 1: to_update = alpha_score > best_score - best_coef.T[to_update] = c.T[to_update] + best_coef[:, to_update] = coef[:, to_update] best_score[to_update] = alpha_score[to_update] best_alpha[to_update] = alpha elif alpha_score > best_score: - best_coef, best_score, best_alpha = c, alpha_score, alpha + best_coef, best_score, best_alpha = coef, alpha_score, alpha self.alpha_ = best_alpha self.best_score_ = best_score - self.dual_coef_ = best_coef - # avoid torch warning about x.T for x with ndim != 2 - if self.dual_coef_.ndim > 1: - dual_T = self.dual_coef_.T - else: - dual_T = self.dual_coef_ - self.coef_ = dual_T @ X + self.coef_ = best_coef + if y.ndim == 2: + self.coef_ = self.coef_.T if y.ndim == 1 or y.shape[1] == 1: self.coef_ = _ravel(self.coef_) - if sparse.issparse(X): - X_offset = X_mean * X_scale - else: - X_offset += X_mean * X_scale self._set_intercept(X_offset, y_offset, X_scale) if self.store_cv_results: - if len(y.shape) == 1: + if y.ndim == 1: cv_results_shape = n_samples, n_alphas else: cv_results_shape = n_samples, n_y, n_alphas self.cv_results_ = xp.reshape(self.cv_results_, shape=cv_results_shape) - if original_dtype is not None: + if original_floating_dtype: if type(self.intercept_) is not float: - self.intercept_ = xp.astype(self.intercept_, original_dtype, copy=False) - self.dual_coef_ = xp.astype(self.dual_coef_, original_dtype, copy=False) - self.coef_ = xp.astype(self.coef_, original_dtype, copy=False) + self.intercept_ = xp.astype( + self.intercept_, original_floating_dtype, copy=False + ) + self.coef_ = xp.astype(self.coef_, original_floating_dtype, copy=False) + if self.store_cv_results: + self.cv_results_ = xp.astype( + self.cv_results_, original_floating_dtype, copy=False + ) + return self def _score_without_scorer(self, squared_errors): @@ -2630,6 +2690,9 @@ class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV): :class:`~sklearn.svm.LinearSVC`. If using Leave-One-Out cross-validation, alphas must be strictly positive. + For an example on how regularization strength affects the model coefficients, + see :ref:`sphx_glr_auto_examples_linear_model_plot_ridge_coeffs.py`. + fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -2650,9 +2713,9 @@ class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV): Possible inputs for cv are: - None, to use the efficient Leave-One-Out cross-validation - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if ``y`` is binary or multiclass, :class:`~sklearn.model_selection.StratifiedKFold` is used, else, @@ -2665,13 +2728,14 @@ class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV): Flag indicating which strategy to use when performing Leave-One-Out Cross-Validation. Options are:: - 'auto' : use 'svd' if n_samples > n_features, otherwise use 'eigen' - 'svd' : force use of singular value decomposition of X when X is - dense, eigenvalue decomposition of X^T.X when X is sparse. - 'eigen' : force computation via eigendecomposition of X.X^T + 'auto' : same as 'eigen' + 'svd' : use singular value decomposition of X when X is dense, + fallback to 'eigen' when X is sparse + 'eigen' : use eigendecomposition of X X' when n_samples <= n_features + or X' X when n_features < n_samples The 'auto' mode is the default and is intended to pick the cheaper - option of the two depending on the shape of the training data. + option depending on the shape and sparsity of the training data. store_cv_results : bool, default=False Flag indicating if the cross-validation values corresponding to @@ -2688,6 +2752,8 @@ class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV): settings: multiple prediction targets). When set to `True`, after fitting, the `alpha_` attribute will contain a value for each target. When set to `False`, a single alpha is used for all targets. + This flag is only compatible with ``cv=None`` (i.e. using + Leave-One-Out Cross-Validation). .. versionadded:: 0.24 @@ -2791,6 +2857,22 @@ def fit(self, X, y, sample_weight=None, **params): super().fit(X, y, sample_weight=sample_weight, **params) return self + def predict(self, X): + """ + Predict using the linear model. + + Parameters + ---------- + X : array-like or sparse matrix of shape (n_samples, n_features) + Samples. + + Returns + ------- + C : ndarray of shape (n_samples,) or (n_samples, n_outputs) + Predicted values. + """ + return super().predict(X) + def _get_scorer_instance(self): """Return a scorer which corresponds to what's defined in RegressorMixin parent class. This is used for routing `sample_weight`. @@ -2820,6 +2902,9 @@ class RidgeClassifierCV(_RidgeClassifierMixin, _BaseRidgeCV): :class:`~sklearn.svm.LinearSVC`. If using Leave-One-Out cross-validation, alphas must be strictly positive. + For an example on how regularization strength affects the model coefficients, + see :ref:`sphx_glr_auto_examples_linear_model_plot_ridge_coeffs.py`. + fit_intercept : bool, default=True Whether to calculate the intercept for this model. If set to false, no intercept will be used in calculations @@ -2840,9 +2925,9 @@ class RidgeClassifierCV(_RidgeClassifierMixin, _BaseRidgeCV): Possible inputs for cv are: - None, to use the efficient Leave-One-Out cross-validation - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. Refer :ref:`User Guide <cross_validation>` for the various cross-validation strategies that can be used here. diff --git a/sklearn/linear_model/_sgd_fast.pyx.tp b/sklearn/linear_model/_sgd_fast.pyx.tp index 79699247f7a07..21201f78a8fa9 100644 --- a/sklearn/linear_model/_sgd_fast.pyx.tp +++ b/sklearn/linear_model/_sgd_fast.pyx.tp @@ -362,7 +362,7 @@ def _plain_sgd{{name_suffix}}( eta0 : double The initial learning rate. For PA-1 (`learning_rate=PA1`) and PA-II (`PA2`), it specifies the - aggressiveness parameter for the passive-agressive algorithm, see [1] where it + aggressiveness parameter for the passive-aggressive algorithm, see [1] where it is called C: - For PA-I it is the maximum step size. @@ -493,7 +493,7 @@ def _plain_sgd{{name_suffix}}( objective_sum += cur_loss_val # for PA1/PA2 (passive/aggressive model, online algorithm) use only the loss if learning_rate != PA1 and learning_rate != PA2: - # sum up all the terms in the optimization objective function + # sum up all the terms in the optimization objective function # (i.e. also include regularization in addition to the loss) # Note: for the L2 term SGD optimizes 0.5 * L2**2, due to using # weight decay that's why the 0.5 coefficient is required @@ -503,8 +503,8 @@ def _plain_sgd{{name_suffix}}( l1_ratio * w.l1norm() ) if one_class: # specific to One-Class SVM - # nu is alpha * 2 (alpha is set as nu / 2 by the caller) - objective_sum += intercept * (alpha * 2) + # nu is alpha + objective_sum += intercept * alpha if y > 0.0: class_weight = weight_pos @@ -549,7 +549,7 @@ def _plain_sgd{{name_suffix}}( if fit_intercept == 1: intercept_update = update if one_class: # specific for One-Class SVM - intercept_update -= 2. * eta * alpha + intercept_update -= eta * alpha if intercept_update != 0: intercept += intercept_update * intercept_decay diff --git a/sklearn/linear_model/_stochastic_gradient.py b/sklearn/linear_model/_stochastic_gradient.py index c65cdbdcf51ce..0fd47f41be097 100644 --- a/sklearn/linear_model/_stochastic_gradient.py +++ b/sklearn/linear_model/_stochastic_gradient.py @@ -627,7 +627,15 @@ def _partial_fit( self._expanded_class_weight = compute_class_weight( self.class_weight, classes=self.classes_, y=y ) - sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) + + # Skip check that validation weights are not all zero when `early_stopping` is + # set to True as `_make_validation_split` will raise a more informative error. + sample_weight = _check_sample_weight( + sample_weight, + X, + dtype=X.dtype, + allow_all_zero_weights=self.early_stopping, + ) if getattr(self, "coef_", None) is None or coef_init is not None: self._allocate_parameter_mem( @@ -1101,7 +1109,7 @@ class SGDClassifier(BaseSGDClassifier): Values must be in the range `(0.0, inf)`. For PA-1 (`learning_rate=pa1`) and PA-II (`pa2`), it specifies the - aggressiveness parameter for the passive-agressive algorithm, see [1] where it + aggressiveness parameter for the passive-aggressive algorithm, see [1] where it is called C: - For PA-I it is the maximum step size. @@ -1925,7 +1933,7 @@ class SGDRegressor(BaseSGDRegressor): Values must be in the range `(0.0, inf)`. For PA-1 (`learning_rate=pa1`) and PA-II (`pa2`), it specifies the - aggressiveness parameter for the passive-agressive algorithm, see [1] where it + aggressiveness parameter for the passive-aggressive algorithm, see [1] where it is called C: - For PA-I it is the maximum step size. @@ -2492,7 +2500,7 @@ def partial_fit(self, X, y=None, sample_weight=None): if not hasattr(self, "coef_"): self._more_validate_params(for_partial_fit=True) - alpha = self.nu / 2 + alpha = self.nu return self._partial_fit( X, alpha, @@ -2596,7 +2604,7 @@ def fit(self, X, y=None, coef_init=None, offset_init=None, sample_weight=None): """ self._more_validate_params() - alpha = self.nu / 2 + alpha = self.nu self._fit( X, alpha=alpha, diff --git a/sklearn/linear_model/meson.build b/sklearn/linear_model/meson.build index 6d8405c793389..31faad737c156 100644 --- a/sklearn/linear_model/meson.build +++ b/sklearn/linear_model/meson.build @@ -18,7 +18,7 @@ foreach name: name_list output: name + '.pyx', input: name + '.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [linear_model_cython_tree, utils_cython_tree, _loss_cython_tree], diff --git a/sklearn/linear_model/tests/test_base.py b/sklearn/linear_model/tests/test_base.py index 504ae6f024d65..88962f69b2380 100644 --- a/sklearn/linear_model/tests/test_base.py +++ b/sklearn/linear_model/tests/test_base.py @@ -1,30 +1,34 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -import warnings - import numpy as np import pytest from scipy import linalg, sparse +from sklearn import config_context +from sklearn.base import BaseEstimator from sklearn.datasets import load_iris, make_regression, make_sparse_uncorrelated from sklearn.linear_model import LinearRegression from sklearn.linear_model._base import ( + LinearClassifierMixin, _preprocess_data, _rescale_data, make_dataset, ) from sklearn.preprocessing import add_dummy_feature +from sklearn.utils._array_api import get_namespace_and_device, move_estimator_to from sklearn.utils._testing import ( assert_allclose, assert_array_almost_equal, assert_array_equal, + skip_if_array_api_compat_not_configured, ) from sklearn.utils.fixes import ( COO_CONTAINERS, CSC_CONTAINERS, CSR_CONTAINERS, LIL_CONTAINERS, + _sparse_eye_array, ) rtol = 1e-6 @@ -54,6 +58,31 @@ def test_linear_regression(): assert_array_almost_equal(reg.predict(X), [0]) +@pytest.mark.parametrize("dtype", [np.float64, np.float32]) +def test_linear_regression_vs_lstsq(dtype): + """ + Check that LinearRegression is as good as `scipy.linalg.lstsq`. + Non regression test for issue #33032. + """ + rng = np.random.RandomState(1137) + n_samples = 500_000 + + x1 = rng.rand(n_samples) + x2 = 0.3 * x1 + 0.1 * rng.rand(n_samples) + X = np.column_stack([x1, x2]) + y = X @ [0.5, 2.0] + 0.1 * rng.rand(n_samples) + + X = X.astype(dtype) + y = y.astype(dtype) + + coef_scipy = linalg.lstsq(X, y)[0] + coef_sklearn = LinearRegression(fit_intercept=False).fit(X, y).coef_ + + rmse_scipy = np.linalg.norm(y - X @ coef_scipy) + rmse_sklearn = np.linalg.norm(y - X @ coef_sklearn) + assert rmse_sklearn == pytest.approx(rmse_scipy, rel=1e-6) + + @pytest.mark.parametrize("sparse_container", [None] + CSR_CONTAINERS) @pytest.mark.parametrize("fit_intercept", [True, False]) def test_linear_regression_sample_weights( @@ -98,7 +127,7 @@ def test_linear_regression_sample_weights( def test_raises_value_error_if_positive_and_sparse(): error_msg = "Sparse data was passed for X, but dense data is required." # X must not be sparse if positive == True - X = sparse.eye(10) + X = _sparse_eye_array(10) y = np.ones(10) reg = LinearRegression(positive=True) @@ -148,7 +177,7 @@ def test_linear_regression_sparse(global_random_seed): # Test that linear regression also works with sparse data rng = np.random.RandomState(global_random_seed) n = 100 - X = sparse.eye(n, n) + X = _sparse_eye_array(n, n) beta = rng.rand(n) y = X @ beta @@ -292,7 +321,7 @@ def test_inplace_data_preprocessing(sparse_container, use_sw, global_random_seed rng = np.random.RandomState(global_random_seed) original_X_data = rng.randn(10, 12) original_y_data = rng.randn(10, 2) - orginal_sw_data = rng.rand(10) + original_sw_data = rng.rand(10) if sparse_container is not None: X = sparse_container(original_X_data) @@ -303,7 +332,7 @@ def test_inplace_data_preprocessing(sparse_container, use_sw, global_random_seed # implementation of LinearRegression. if use_sw: - sample_weight = orginal_sw_data.copy() + sample_weight = original_sw_data.copy() else: sample_weight = None @@ -317,7 +346,7 @@ def test_inplace_data_preprocessing(sparse_container, use_sw, global_random_seed assert_allclose(y, original_y_data) if use_sw: - assert_allclose(sample_weight, orginal_sw_data) + assert_allclose(sample_weight, original_sw_data) # Allow inplace preprocessing of X and y reg = LinearRegression(copy_X=False) @@ -337,35 +366,7 @@ def test_inplace_data_preprocessing(sparse_container, use_sw, global_random_seed if use_sw: # Sample weights have no reason to ever be modified inplace. - assert_allclose(sample_weight, orginal_sw_data) - - -def test_linear_regression_pd_sparse_dataframe_warning(): - pd = pytest.importorskip("pandas") - - # Warning is raised only when some of the columns is sparse - df = pd.DataFrame({"0": np.random.randn(10)}) - for col in range(1, 4): - arr = np.random.randn(10) - arr[:8] = 0 - # all columns but the first column is sparse - if col != 0: - arr = pd.arrays.SparseArray(arr, fill_value=0) - df[str(col)] = arr - - msg = "pandas.DataFrame with sparse columns found." - - reg = LinearRegression() - with pytest.warns(UserWarning, match=msg): - reg.fit(df.iloc[:, 0:2], df.iloc[:, 3]) - - # does not warn when the whole dataframe is sparse - df["0"] = pd.arrays.SparseArray(df["0"], fill_value=0) - assert hasattr(df, "sparse") - - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - reg.fit(df.iloc[:, 0:2], df.iloc[:, 3]) + assert_allclose(sample_weight, original_sw_data) def test_preprocess_data(global_random_seed): @@ -538,25 +539,33 @@ def test_csr_preprocess_data(csr_container): @pytest.mark.parametrize("sparse_container", [None] + CSR_CONTAINERS) @pytest.mark.parametrize("to_copy", (True, False)) -def test_preprocess_copy_data_no_checks(sparse_container, to_copy): +@pytest.mark.parametrize("use_sample_weight", (False, True)) +def test_preprocess_copy_data_no_checks(sparse_container, to_copy, use_sample_weight): X, y = make_regression() X[X < 2.5] = 0.0 + sample_weight = np.ones(len(y)) if use_sample_weight else None + if sparse_container is not None: X = sparse_container(X) X_, y_, _, _, _, _ = _preprocess_data( - X, y, fit_intercept=True, copy=to_copy, check_input=False + X, + y, + sample_weight=sample_weight, + fit_intercept=True, + copy=to_copy, + check_input=False, ) - if to_copy and sparse_container is not None: - assert not np.may_share_memory(X_.data, X.data) - elif to_copy: - assert not np.may_share_memory(X_, X) - elif sparse_container is not None: - assert np.may_share_memory(X_.data, X.data) + if sparse_container is not None: + if to_copy or use_sample_weight: + # sparse X, y always copied when use_sample_weight, regardless of to_copy + assert not np.may_share_memory(X_.data, X.data) + else: + assert np.may_share_memory(X_.data, X.data) else: - assert np.may_share_memory(X_, X) + assert np.may_share_memory(X_, X) == (not to_copy) @pytest.mark.parametrize("rescale_with_sw", [False, True]) @@ -844,3 +853,47 @@ def test_linear_regression_sample_weight_consistency( assert_allclose(reg1.coef_, reg2.coef_, rtol=1e-6) if fit_intercept: assert_allclose(reg1.intercept_, reg2.intercept_) + + +@skip_if_array_api_compat_not_configured +def test_array_api_move_estimator_to(): + xp = pytest.importorskip("array_api_strict") + rng = np.random.default_rng(0) + X = rng.normal(size=(10, 5)) + y = rng.normal(size=10) + + reg = LinearRegression().fit(X, y) + X_xp = xp.asarray(X) + reg.predict(X_xp) + + with config_context(array_api_dispatch=True): + with pytest.raises(ValueError, match=".*must use the same namespace"): + reg.predict(X_xp) + xp_target, _, device = get_namespace_and_device(X_xp) + reg = move_estimator_to(reg, xp_target, device) + reg.predict(X_xp) + + +def test_predict_proba_lr_large_values(): + """Test that _predict_proba_lr of LinearClassifierMixin deals with large + negative values. + + Note that exp(-1000) = 0. + """ + + class MockClassifier(LinearClassifierMixin, BaseEstimator): + def __init__(self): + pass + + def fit(self, X, y): + self.__sklearn_is_fitted__ = True + + def decision_function(self, X): + n_samples = X.shape[0] + return np.tile([-1000.0] * 4, [n_samples, 1]) + + clf = MockClassifier() + clf.fit(X=None, y=None) + + proba = clf._predict_proba_lr(np.ones(5)) + assert_allclose(np.sum(proba, axis=1), 1) diff --git a/sklearn/linear_model/tests/test_bayes.py b/sklearn/linear_model/tests/test_bayes.py index 9f7fabb749f52..af730a57a8364 100644 --- a/sklearn/linear_model/tests/test_bayes.py +++ b/sklearn/linear_model/tests/test_bayes.py @@ -152,12 +152,12 @@ def test_bayesian_initial_params(): assert_almost_equal(r2, 1.0) -def test_prediction_bayesian_ridge_ard_with_constant_input(): +def test_prediction_bayesian_ridge_ard_with_constant_input(global_random_seed): # Test BayesianRidge and ARDRegression predictions for edge case of # constant target vectors n_samples = 4 n_features = 5 - random_state = check_random_state(42) + random_state = check_random_state(global_random_seed) constant_value = random_state.rand() X = random_state.random_sample((n_samples, n_features)) y = np.full(n_samples, constant_value, dtype=np.array(constant_value).dtype) @@ -168,13 +168,13 @@ def test_prediction_bayesian_ridge_ard_with_constant_input(): assert_array_almost_equal(y_pred, expected) -def test_std_bayesian_ridge_ard_with_constant_input(): +def test_std_bayesian_ridge_ard_with_constant_input(global_random_seed): # Test BayesianRidge and ARDRegression standard dev. for edge case of # constant target vector # The standard dev. should be relatively small (< 0.01 is tested here) n_samples = 10 n_features = 5 - random_state = check_random_state(42) + random_state = check_random_state(global_random_seed) constant_value = random_state.rand() X = random_state.random_sample((n_samples, n_features)) y = np.full(n_samples, constant_value, dtype=np.array(constant_value).dtype) @@ -185,6 +185,21 @@ def test_std_bayesian_ridge_ard_with_constant_input(): assert_array_less(y_std, expected_upper_boundary) +@pytest.mark.parametrize("Estimator", [BayesianRidge, ARDRegression]) +def test_std_bayesian_ridge_noncentered(Estimator, global_random_seed): + # Test BayesianRidge and ARDRegression std when data is not centered. + # The std should be smallest at the center of the data, not at the origin. + # Non-regression test for issue #33757 + rng = np.random.RandomState(global_random_seed) + n_samples = 4 + X_train = np.linspace(80, 100, n_samples).reshape(-1, 1) + y_train = X_train.reshape(-1) + 10 * rng.standard_normal(n_samples) + model = Estimator(fit_intercept=True).fit(X_train, y_train) + X = np.array([[0.0], [90.0]]) + _, y_std = model.predict(X, return_std=True) + assert y_std[1] < y_std[0] + + def test_update_of_sigma_in_ard(): # Checks that `sigma_` is updated correctly after the last iteration # of the ARDRegression algorithm. See issue #10128. @@ -225,14 +240,16 @@ def test_ard_accuracy_on_easy_problem(global_random_seed, n_samples, n_features) assert abs_coef_error < 1e-10 -@pytest.mark.parametrize("constructor_name", ["array", "dataframe"]) -def test_return_std(constructor_name): +@pytest.mark.parametrize("constructor_name", ["array", "pandas"]) +def test_return_std(constructor_name, global_random_seed): # Test return_std option for both Bayesian regressors + rng = np.random.RandomState(global_random_seed) + def f(X): return np.dot(X, w) + b def f_noise(X, noise_mult): - return f(X) + np.random.randn(X.shape[0]) * noise_mult + return f(X) + rng.randn(X.shape[0]) * noise_mult d = 5 n_train = 50 @@ -241,10 +258,10 @@ def f_noise(X, noise_mult): w = np.array([1.0, 0.0, 1.0, -1.0, 0.0]) b = 1.0 - X = np.random.random((n_train, d)) + X = rng.random_sample((n_train, d)) X = _convert_container(X, constructor_name) - X_test = np.random.random((n_test, d)) + X_test = rng.random_sample((n_test, d)) X_test = _convert_container(X_test, constructor_name) for decimal, noise_mult in enumerate([1, 0.1, 0.01]): diff --git a/sklearn/linear_model/tests/test_common.py b/sklearn/linear_model/tests/test_common.py index a3796c9c0d7e1..6858e8e2b45c2 100644 --- a/sklearn/linear_model/tests/test_common.py +++ b/sklearn/linear_model/tests/test_common.py @@ -72,7 +72,12 @@ LogisticRegression(l1_ratio=0.5, solver="saga", tol=1e-15), marks=pytest.mark.xfail(reason="Missing importance sampling scheme"), ), - LogisticRegressionCV(tol=1e-6, use_legacy_attributes=False, l1_ratios=(0,)), + LogisticRegressionCV( + tol=1e-6, + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + l1_ratios=(0,), # TODO(1.10): remove because it is default now + ), MultiTaskElasticNet(), MultiTaskElasticNetCV(), MultiTaskLasso(), @@ -217,7 +222,8 @@ def test_linear_model_regressor_coef_shape(Regressor, ndim): { "solver": "newton-cholesky", "use_legacy_attributes": False, - "l1_ratios": (0,), + "l1_ratios": (0,), # TODO(1.10): remove + "scoring": "neg_log_loss", # TODO(1.11): remove }, ), (PassiveAggressiveClassifier, {}), diff --git a/sklearn/linear_model/tests/test_coordinate_descent.py b/sklearn/linear_model/tests/test_coordinate_descent.py index 2cb9eb9e9f45b..13e94e23ceb92 100644 --- a/sklearn/linear_model/tests/test_coordinate_descent.py +++ b/sklearn/linear_model/tests/test_coordinate_descent.py @@ -9,7 +9,7 @@ import pytest from scipy import interpolate, sparse -from sklearn.base import clone, config_context +from sklearn.base import clone from sklearn.datasets import load_diabetes, make_regression from sklearn.exceptions import ConvergenceWarning from sklearn.linear_model import ( @@ -18,6 +18,7 @@ Lasso, LassoCV, LassoLarsCV, + LinearRegression, MultiTaskElasticNet, MultiTaskElasticNetCV, MultiTaskLasso, @@ -30,11 +31,9 @@ from sklearn.linear_model import _cd_fast as cd_fast # type: ignore[attr-defined] from sklearn.linear_model._coordinate_descent import _set_order from sklearn.model_selection import ( - BaseCrossValidator, GridSearchCV, LeaveOneGroupOut, ) -from sklearn.model_selection._split import GroupsConsumerMixin from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.utils import check_array @@ -86,7 +85,8 @@ def test_set_order_sparse(order, input_order, coo_container): assert sparse.issparse(y2) and y2.format == format -def test_cython_solver_equivalence(): +@pytest.mark.parametrize("sparse_csc_type", [sparse.csc_array, sparse.csc_matrix]) +def test_cython_solver_equivalence(sparse_csc_type): """Test that all 3 Cython solvers for 1-d targets give same results.""" X, y = make_regression() X_mean = X.mean(axis=0) @@ -136,7 +136,7 @@ def zc(): assert_allclose(coef_2, coef_1) # Sparse - Xs = sparse.csc_matrix(X) + Xs = sparse_csc_type(X) for do_screening in [True, False]: coef_3 = zc() cd_fast.sparse_enet_coordinate_descent( @@ -580,16 +580,14 @@ def test_uniform_targets(): for model in models_single_task: for y_values in (0, 5): y1.fill(y_values) - with ignore_warnings(category=ConvergenceWarning): - assert_array_equal(model.fit(X_train, y1).predict(X_test), y1) + assert_array_equal(model.fit(X_train, y1).predict(X_test), y1) assert_array_equal(model.alphas_, [np.finfo(float).resolution] * 3) for model in models_multi_task: for y_values in (0, 5): y2[:, 0].fill(y_values) y2[:, 1].fill(2 * y_values) - with ignore_warnings(category=ConvergenceWarning): - assert_array_equal(model.fit(X_train, y2).predict(X_test), y2) + assert_array_equal(model.fit(X_train, y2).predict(X_test), y2) assert_array_equal(model.alphas_, [np.finfo(float).resolution] * 3) @@ -758,22 +756,22 @@ def test_1d_multioutput_lasso_and_multitask_lasso_cv(): assert_almost_equal(clf.intercept_, clf1.intercept_[0]) +@pytest.mark.parametrize( + "estimator", [ElasticNetCV, LassoCV, MultiTaskElasticNetCV, MultiTaskLassoCV] +) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_sparse_input_dtype_enet_and_lassocv(csr_container): - X, y, _, _ = build_dataset(n_features=10) - clf = ElasticNetCV(alphas=5) - clf.fit(csr_container(X), y) - clf1 = ElasticNetCV(alphas=5) - clf1.fit(csr_container(X, dtype=np.float32), y) - assert_almost_equal(clf.alpha_, clf1.alpha_, decimal=6) - assert_almost_equal(clf.coef_, clf1.coef_, decimal=6) - - clf = LassoCV(alphas=5) - clf.fit(csr_container(X), y) - clf1 = LassoCV(alphas=5) - clf1.fit(csr_container(X, dtype=np.float32), y) - assert_almost_equal(clf.alpha_, clf1.alpha_, decimal=6) - assert_almost_equal(clf.coef_, clf1.coef_, decimal=6) +def test_sparse_input_dtype_enet_and_lassocv(estimator, csr_container): + if issubclass(estimator, (MultiTaskElasticNetCV, MultiTaskLassoCV)): + n_targets = 2 + else: + n_targets = 1 + X, y, _, _ = build_dataset(n_targets=n_targets, n_features=10) + reg = estimator(alphas=5) + reg.fit(csr_container(X), y) + reg1 = estimator(alphas=5) + reg1.fit(csr_container(X, dtype=np.float32), y) + assert_allclose(reg.alpha_, reg1.alpha_, rtol=1e-5) + assert_allclose(reg.coef_, reg1.coef_, rtol=1e-5) def test_elasticnet_precompute_incorrect_gram(): @@ -838,11 +836,12 @@ def test_elasticnet_precompute_gram(): assert_allclose(clf1.coef_, clf2.coef_) +@pytest.mark.parametrize("sparse_csr_type", [sparse.csr_array, sparse.csr_matrix]) @pytest.mark.parametrize("sparse_X", [True, False]) -def test_warm_start_convergence(sparse_X): +def test_warm_start_convergence(sparse_X, sparse_csr_type): X, y, _, _ = build_dataset() if sparse_X: - X = sparse.csr_matrix(X) + X = sparse_csr_type(X) model = ElasticNet(alpha=1e-3, tol=1e-3).fit(X, y) n_iter_reference = model.n_iter_ @@ -893,66 +892,66 @@ def test_random_descent(csr_container): # This uses the coordinate descent algo using the gram trick. X, y, _, _ = build_dataset(n_samples=50, n_features=20) - clf_cyclic = ElasticNet(selection="cyclic", tol=1e-8) + clf_cyclic = ElasticNet(selection="cyclic", tol=1e-9) clf_cyclic.fit(X, y) - clf_random = ElasticNet(selection="random", tol=1e-8, random_state=42) + clf_random = ElasticNet(selection="random", tol=1e-9, random_state=42) clf_random.fit(X, y) - assert_array_almost_equal(clf_cyclic.coef_, clf_random.coef_) - assert_almost_equal(clf_cyclic.intercept_, clf_random.intercept_) + assert_allclose(clf_cyclic.coef_, clf_random.coef_) + assert_allclose(clf_cyclic.intercept_, clf_random.intercept_) # This uses the descent algo without the gram trick - clf_cyclic = ElasticNet(selection="cyclic", tol=1e-8) + clf_cyclic = ElasticNet(selection="cyclic", tol=1e-9) clf_cyclic.fit(X.T, y[:20]) - clf_random = ElasticNet(selection="random", tol=1e-8, random_state=42) + clf_random = ElasticNet(selection="random", tol=1e-9, random_state=42) clf_random.fit(X.T, y[:20]) - assert_array_almost_equal(clf_cyclic.coef_, clf_random.coef_) - assert_almost_equal(clf_cyclic.intercept_, clf_random.intercept_) + assert_allclose(clf_cyclic.coef_, clf_random.coef_) + assert_allclose(clf_cyclic.intercept_, clf_random.intercept_) # Sparse Case - clf_cyclic = ElasticNet(selection="cyclic", tol=1e-8) + clf_cyclic = ElasticNet(selection="cyclic", tol=1e-9) clf_cyclic.fit(csr_container(X), y) - clf_random = ElasticNet(selection="random", tol=1e-8, random_state=42) + clf_random = ElasticNet(selection="random", tol=1e-9, random_state=42) clf_random.fit(csr_container(X), y) - assert_array_almost_equal(clf_cyclic.coef_, clf_random.coef_) - assert_almost_equal(clf_cyclic.intercept_, clf_random.intercept_) + assert_allclose(clf_cyclic.coef_, clf_random.coef_) + assert_allclose(clf_cyclic.intercept_, clf_random.intercept_) # Multioutput case. new_y = np.hstack((y[:, np.newaxis], y[:, np.newaxis])) - clf_cyclic = MultiTaskElasticNet(selection="cyclic", tol=1e-8) - clf_cyclic.fit(X, new_y) - clf_random = MultiTaskElasticNet(selection="random", tol=1e-8, random_state=42) - clf_random.fit(X, new_y) - assert_array_almost_equal(clf_cyclic.coef_, clf_random.coef_) - assert_almost_equal(clf_cyclic.intercept_, clf_random.intercept_) + clf_cyclic = MultiTaskElasticNet(selection="cyclic", tol=1e-9) + clf_cyclic.fit(csr_container(X), new_y) + clf_random = MultiTaskElasticNet(selection="random", tol=1e-9, random_state=42) + clf_random.fit(csr_container(X), new_y) + assert_allclose(clf_cyclic.coef_, clf_random.coef_) + assert_allclose(clf_cyclic.intercept_, clf_random.intercept_) + + +@pytest.mark.parametrize("path", [enet_path, lasso_path]) +@pytest.mark.parametrize("n_targets", [1, 2]) +@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) +def test_sparse_dense_descent_paths(path, n_targets, csr_container): + # Test that dense and sparse input give the same result for descent paths. + X, y, _, _ = build_dataset(n_targets=n_targets, n_samples=50, n_features=20) + csr = csr_container(X) + _, coefs, _ = path(X, y, tol=1e-10) + _, sparse_coefs, _ = path(csr, y, tol=1e-10) + assert_allclose(coefs, sparse_coefs) -def test_enet_path_positive(): +@pytest.mark.parametrize("path", [enet_path, lasso_path]) +def test_enet_path_positive(path): # Test positive parameter X, Y, _, _ = build_dataset(n_samples=50, n_features=50, n_targets=2) # For mono output # Test that the coefs returned by positive=True in enet_path are positive - for path in [enet_path, lasso_path]: - pos_path_coef = path(X, Y[:, 0], positive=True)[1] - assert np.all(pos_path_coef >= 0) + pos_path_coef = path(X, Y[:, 0], positive=True)[1] + assert np.all(pos_path_coef >= 0) # For multi output, positive parameter is not allowed # Test that an error is raised - for path in [enet_path, lasso_path]: - with pytest.raises(ValueError): - path(X, Y, positive=True) - - -@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_sparse_dense_descent_paths(csr_container): - # Test that dense and sparse input give the same input for descent paths. - X, y, _, _ = build_dataset(n_samples=50, n_features=20) - csr = csr_container(X) - for path in [enet_path, lasso_path]: - _, coefs, _ = path(X, y, tol=1e-10) - _, sparse_coefs, _ = path(csr, y, tol=1e-10) - assert_allclose(coefs, sparse_coefs) + with pytest.raises(ValueError): + path(X, Y, positive=True) @pytest.mark.parametrize("path_func", [enet_path, lasso_path]) @@ -969,15 +968,14 @@ def test_check_input_false(): X, y, _, _ = build_dataset(n_samples=20, n_features=10) X = check_array(X, order="F", dtype="float64") y = check_array(X, order="F", dtype="float64") - clf = ElasticNet(selection="cyclic", tol=1e-8) + clf = ElasticNet(selection="cyclic", tol=1e-6) # Check that no error is raised if data is provided in the right format clf.fit(X, y, check_input=False) # With check_input=False, an exhaustive check is not made on y but its # dtype is still cast in _preprocess_data to X's dtype. So the test should # pass anyway X = check_array(X, order="F", dtype="float32") - with ignore_warnings(category=ConvergenceWarning): - clf.fit(X, y, check_input=False) + clf.fit(X, y, check_input=False) # With no input checking, providing X in C order should result in false # computation X = check_array(X, order="C", dtype="float64") @@ -1009,7 +1007,7 @@ def test_enet_copy_X_False_check_input_False(): assert np.any(np.not_equal(original_X, X)) -def test_overrided_gram_matrix(): +def test_overridden_gram_matrix(): X, y, _, _ = build_dataset(n_samples=20, n_features=10) Gram = X.T.dot(X) clf = ElasticNet(selection="cyclic", tol=1e-8, precompute=Gram) @@ -1093,7 +1091,6 @@ def test_enet_float_precision(): ) -@pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning") def test_enet_l1_ratio(): # Test that an error message is raised if an estimator that # uses _alpha_grid is called with l1_ratio=0 @@ -1111,14 +1108,10 @@ def test_enet_l1_ratio(): with pytest.raises(ValueError, match=msg): MultiTaskElasticNetCV(l1_ratio=0, random_state=42).fit(X, y[:, None]) - # Test that l1_ratio=0 with alpha>0 produces user warning - warning_message = ( - "Coordinate descent without L1 regularization may " - "lead to unexpected results and is discouraged. " - "Set l1_ratio > 0 to add L1 regularization." - ) + # But no error for ElasticNetCV with l1_ratio=0 and alpha>0. est = ElasticNetCV(l1_ratio=[0], alphas=[1]) - with pytest.warns(UserWarning, match=warning_message): + with warnings.catch_warnings(): + warnings.simplefilter("error") est.fit(X, y) # Test that l1_ratio=0 is allowed if we supply a grid manually @@ -1126,16 +1119,14 @@ def test_enet_l1_ratio(): estkwds = {"alphas": alphas, "random_state": 42} est_desired = ElasticNetCV(l1_ratio=0.00001, **estkwds) est = ElasticNetCV(l1_ratio=0, **estkwds) - with ignore_warnings(): - est_desired.fit(X, y) - est.fit(X, y) + est_desired.fit(X, y) + est.fit(X, y) assert_array_almost_equal(est.coef_, est_desired.coef_, decimal=5) est_desired = MultiTaskElasticNetCV(l1_ratio=0.00001, **estkwds) est = MultiTaskElasticNetCV(l1_ratio=0, **estkwds) - with ignore_warnings(): - est.fit(X, y[:, None]) - est_desired.fit(X, y[:, None]) + est.fit(X, y[:, None]) + est_desired.fit(X, y[:, None]) assert_array_almost_equal(est.coef_, est_desired.coef_, decimal=5) @@ -1158,15 +1149,15 @@ def test_warm_start_multitask_lasso(): @pytest.mark.parametrize( - "klass, n_classes, kwargs", + "est, kwargs", [ - (Lasso, 1, dict(precompute=True)), - (Lasso, 1, dict(precompute=False)), + (Lasso, dict(precompute=True)), + (Lasso, dict(precompute=False)), ], ) -def test_enet_coordinate_descent_raises_convergence(klass, n_classes, kwargs): +def test_enet_coordinate_descent_raises_convergence(est, kwargs): """Test that a warning is issued if model does not converge""" - clf = klass( + reg = est( alpha=1e-10, fit_intercept=False, warm_start=True, @@ -1175,7 +1166,7 @@ def test_enet_coordinate_descent_raises_convergence(klass, n_classes, kwargs): **kwargs, ) # Set initial coefficients to very bad values. - clf.coef_ = np.array([1, 1, 1, 1000]) + reg.coef_ = np.array([1, 1, 1, 1000]) X = np.array([[-1, -1, 1, 1], [1, 1, -1, -1]]) y = np.array([-1, 1]) warning_message = ( @@ -1183,7 +1174,7 @@ def test_enet_coordinate_descent_raises_convergence(klass, n_classes, kwargs): " increase the number of iterations." ) with pytest.warns(ConvergenceWarning, match=warning_message): - clf.fit(X, y) + reg.fit(X, y) def test_convergence_warnings(): @@ -1197,17 +1188,24 @@ def test_convergence_warnings(): MultiTaskElasticNet().fit(X, y) +@pytest.mark.parametrize( + "estimator", [ElasticNetCV, LassoCV, MultiTaskElasticNetCV, MultiTaskLassoCV] +) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_sparse_input_convergence_warning(csr_container): - X, y, _, _ = build_dataset(n_samples=1000, n_features=500) +def test_sparse_input_convergence_warning(estimator, csr_container): + if issubclass(estimator, (MultiTaskElasticNetCV, MultiTaskLassoCV)): + n_targets = 2 + else: + n_targets = 1 + X, y, _, _ = build_dataset(n_targets=n_targets, n_samples=50, n_features=25) with pytest.warns(ConvergenceWarning): - ElasticNet(max_iter=1, tol=0).fit(csr_container(X, dtype=np.float32), y) + estimator(max_iter=1, tol=0).fit(csr_container(X, dtype=np.float32), y) # check that the model converges w/o convergence warnings with warnings.catch_warnings(): warnings.simplefilter("error", ConvergenceWarning) - Lasso().fit(csr_container(X, dtype=np.float32), y) + estimator().fit(csr_container(X, dtype=np.float32), y) @pytest.mark.parametrize( @@ -1245,12 +1243,13 @@ def test_multi_task_lasso_cv_dtype(): assert_array_almost_equal(est.coef_, [[1, 0, 0]] * 2, decimal=3) +@pytest.mark.parametrize("estimator", [ElasticNet, MultiTaskElasticNet]) @pytest.mark.parametrize("fit_intercept", [True, False]) @pytest.mark.parametrize("alpha", [0.01]) @pytest.mark.parametrize("precompute", [False, True]) @pytest.mark.parametrize("sparse_container", [None] + CSR_CONTAINERS) def test_enet_sample_weight_consistency( - fit_intercept, alpha, precompute, sparse_container, global_random_seed + estimator, fit_intercept, alpha, precompute, sparse_container, global_random_seed ): """Test that the impact of sample_weight is consistent. @@ -1259,26 +1258,36 @@ def test_enet_sample_weight_consistency( """ rng = np.random.RandomState(global_random_seed) n_samples, n_features = 10, 5 - X = rng.rand(n_samples, n_features) y = rng.rand(n_samples) - if sparse_container is not None: - X = sparse_container(X) + params = dict( alpha=alpha, fit_intercept=fit_intercept, - precompute=precompute, tol=1e-6, l1_ratio=0.5, ) - reg = ElasticNet(**params).fit(X, y) + if issubclass(estimator, MultiTaskElasticNet): + n_tasks = 3 + y = np.tile(y[:, None], reps=(1, n_tasks)) + if precompute: + return + else: + n_tasks = 1 + params["precompute"] = precompute + + if sparse_container is not None: + X = sparse_container(X) + + reg = estimator(**params).fit(X, y) coef = reg.coef_.copy() if fit_intercept: intercept = reg.intercept_ + assert np.sum(coef != 0) > 1 # 1) sample_weight=np.ones(..) should be equivalent to sample_weight=None - sample_weight = np.ones_like(y) + sample_weight = np.ones_like(y, shape=y.shape[0]) reg.fit(X, y, sample_weight=sample_weight) assert_allclose(reg.coef_, coef, rtol=1e-6) if fit_intercept: @@ -1322,22 +1331,23 @@ def test_enet_sample_weight_consistency( X2 = sparse.vstack([X, X[: n_samples // 2]], format="csc") else: X2 = np.concatenate([X, X[: n_samples // 2]], axis=0) - y2 = np.concatenate([y, y[: n_samples // 2]]) + y2 = np.concatenate([y, y[: n_samples // 2]], axis=0) sample_weight_1 = sample_weight.copy() sample_weight_1[: n_samples // 2] *= 2 sample_weight_2 = np.concatenate( [sample_weight, sample_weight[: n_samples // 2]], axis=0 ) - reg1 = ElasticNet(**params).fit(X, y, sample_weight=sample_weight_1) - reg2 = ElasticNet(**params).fit(X2, y2, sample_weight=sample_weight_2) + reg1 = estimator(**params).fit(X, y, sample_weight=sample_weight_1) + reg2 = estimator(**params).fit(X2, y2, sample_weight=sample_weight_2) assert_allclose(reg1.coef_, reg2.coef_, rtol=1e-6) +@pytest.mark.parametrize("estimator", [ElasticNetCV, MultiTaskElasticNetCV]) @pytest.mark.parametrize("fit_intercept", [True, False]) @pytest.mark.parametrize("sparse_container", [None] + CSC_CONTAINERS) def test_enet_cv_sample_weight_correctness( - fit_intercept, sparse_container, global_random_seed + estimator, fit_intercept, sparse_container, global_random_seed ): """Test that ElasticNetCV with sample weights gives correct results. @@ -1353,9 +1363,12 @@ def test_enet_cv_sample_weight_correctness( rng = np.random.RandomState(global_random_seed) n_splits, n_samples_per_cv, n_features = 3, 10, 5 X_with_weights = rng.rand(n_splits * n_samples_per_cv, n_features) - beta = rng.rand(n_features) + beta = 10 * rng.rand(n_features) beta[0:2] = 0 y_with_weights = X_with_weights @ beta + rng.rand(n_splits * n_samples_per_cv) + if issubclass(estimator, MultiTaskElasticNetCV): + n_tasks = 3 + y_with_weights = np.tile(y_with_weights[:, None], reps=(1, n_tasks)) if sparse_container is not None: X_with_weights = sparse_container(X_with_weights) @@ -1365,7 +1378,7 @@ def test_enet_cv_sample_weight_correctness( # The samples in the other cross-validation groups are left with unit # weights. - sw = np.ones_like(y_with_weights) + sw = np.ones(y_with_weights.shape[0]) sw[:n_samples_per_cv] = rng.randint(0, 5, size=n_samples_per_cv) groups_with_weights = np.concatenate( [ @@ -1377,11 +1390,12 @@ def test_enet_cv_sample_weight_correctness( splits_with_weights = list( LeaveOneGroupOut().split(X_with_weights, groups=groups_with_weights) ) - reg_with_weights = ElasticNetCV( + reg_with_weights = estimator( cv=splits_with_weights, fit_intercept=fit_intercept, **params ) reg_with_weights.fit(X_with_weights, y_with_weights, sample_weight=sw) + assert np.sum(reg_with_weights.coef_ != 0) > 1 if sparse_container is not None: X_with_weights = X_with_weights.toarray() @@ -1395,7 +1409,7 @@ def test_enet_cv_sample_weight_correctness( splits_with_repetitions = list( LeaveOneGroupOut().split(X_with_repetitions, groups=groups_with_repetitions) ) - reg_with_repetitions = ElasticNetCV( + reg_with_repetitions = estimator( cv=splits_with_repetitions, fit_intercept=fit_intercept, **params ) reg_with_repetitions.fit(X_with_repetitions, y_with_repetitions) @@ -1410,12 +1424,22 @@ def test_enet_cv_sample_weight_correctness( assert reg_with_weights.intercept_ == pytest.approx(reg_with_repetitions.intercept_) +@pytest.mark.parametrize( + ["estimatorCV", "estimator"], + [(ElasticNetCV, ElasticNet), (MultiTaskElasticNetCV, MultiTaskElasticNet)], +) @pytest.mark.parametrize("sample_weight", [False, True]) -def test_enet_cv_grid_search(sample_weight): +def test_enet_cv_grid_search(estimatorCV, estimator, sample_weight): """Test that ElasticNetCV gives same result as GridSearchCV.""" n_samples, n_features = 200, 10 + if issubclass(estimatorCV, MultiTaskElasticNetCV): + n_targets = 3 + else: + n_targets = 1 + cv = 5 X, y = make_regression( + n_targets=n_targets, n_samples=n_samples, n_features=n_features, effective_rank=10, @@ -1430,12 +1454,12 @@ def test_enet_cv_grid_search(sample_weight): alphas = np.logspace(np.log10(1e-5), np.log10(1), num=10) l1_ratios = [0.1, 0.5, 0.9] - reg = ElasticNetCV(cv=cv, alphas=alphas, l1_ratio=l1_ratios) + reg = estimatorCV(cv=cv, alphas=alphas, l1_ratio=l1_ratios) reg.fit(X, y, sample_weight=sample_weight) param = {"alpha": alphas, "l1_ratio": l1_ratios} gs = GridSearchCV( - estimator=ElasticNet(), + estimator=estimator(), param_grid=param, cv=cv, scoring="neg_mean_squared_error", @@ -1445,12 +1469,22 @@ def test_enet_cv_grid_search(sample_weight): assert reg.alpha_ == pytest.approx(gs.best_params_["alpha"]) +@pytest.mark.parametrize( + ["estimator", "l1_ratio"], + [ + (LassoCV, 0), + (ElasticNetCV, 0.5), + (ElasticNetCV, 1), + (MultiTaskLassoCV, 0), + (MultiTaskElasticNetCV, 0.5), + (MultiTaskElasticNetCV, 1), + ], +) @pytest.mark.parametrize("fit_intercept", [True, False]) -@pytest.mark.parametrize("l1_ratio", [0, 0.5, 1]) @pytest.mark.parametrize("precompute", [False, True]) @pytest.mark.parametrize("sparse_container", [None] + CSC_CONTAINERS) def test_enet_cv_sample_weight_consistency( - fit_intercept, l1_ratio, precompute, sparse_container + estimator, l1_ratio, fit_intercept, precompute, sparse_container ): """Test that the impact of sample_weight is consistent.""" rng = np.random.RandomState(0) @@ -1459,26 +1493,28 @@ def test_enet_cv_sample_weight_consistency( X = rng.rand(n_samples, n_features) y = X.sum(axis=1) + rng.rand(n_samples) params = dict( - l1_ratio=l1_ratio, fit_intercept=fit_intercept, precompute=precompute, tol=1e-6, cv=3, ) + if l1_ratio > 0: + params["l1_ratio"] = l1_ratio + if issubclass(estimator, (MultiTaskElasticNetCV, MultiTaskLassoCV)): + n_tasks = 3 + y = np.tile(y[:, None], reps=(1, n_tasks)) + params.pop("precompute") if sparse_container is not None: X = sparse_container(X) - if l1_ratio == 0: - params.pop("l1_ratio", None) - reg = LassoCV(**params).fit(X, y) - else: - reg = ElasticNetCV(**params).fit(X, y) + reg = estimator(**params).fit(X, y) coef = reg.coef_.copy() if fit_intercept: intercept = reg.intercept_ + assert np.sum(coef != 0) > 1 # sample_weight=np.ones(..) should be equivalent to sample_weight=None - sample_weight = np.ones_like(y) + sample_weight = np.ones(n_samples) reg.fit(X, y, sample_weight=sample_weight) assert_allclose(reg.coef_, coef, rtol=1e-6) if fit_intercept: @@ -1492,32 +1528,62 @@ def test_enet_cv_sample_weight_consistency( assert_allclose(reg.intercept_, intercept) # scaling of sample_weight should have no effect, cf. np.average() - sample_weight = 2 * np.ones_like(y) + sample_weight = 2 * np.ones(n_samples) reg.fit(X, y, sample_weight=sample_weight) assert_allclose(reg.coef_, coef, rtol=1e-6) if fit_intercept: assert_allclose(reg.intercept_, intercept) -@pytest.mark.parametrize("X_is_sparse", [False, True]) +@pytest.mark.parametrize( + ["estimatorCV", "estimator"], + [ + (ElasticNetCV, ElasticNet), + (MultiTaskElasticNetCV, MultiTaskElasticNet), + ], +) +@pytest.mark.parametrize("X_is_sparse", [False, sparse.csc_array, sparse.csc_matrix]) @pytest.mark.parametrize("fit_intercept", [False, True]) -@pytest.mark.parametrize("sample_weight", [np.array([10, 1, 10, 1]), None]) -def test_enet_alpha_max(X_is_sparse, fit_intercept, sample_weight): - X = np.array([[3.0, 1.0], [2.0, 5.0], [5.0, 3.0], [1.0, 4.0]]) - beta = np.array([1, 1]) +@pytest.mark.parametrize("positive", [False, True]) +@pytest.mark.parametrize("sample_weight", [np.array([1, 10, 1, 10]), None]) +def test_enet_alpha_max( + estimatorCV, estimator, X_is_sparse, fit_intercept, positive, sample_weight +): + X = np.array([[3.0, -1.0], [2.0, -5.0], [5.0, -3.0], [1.0, -4.0]]) + beta = np.array([1, -2]) y = X @ beta + params = dict(fit_intercept=fit_intercept, positive=positive) + if issubclass(estimator, MultiTaskElasticNet): + n_tasks = 3 + y = np.tile(y[:, None], reps=(1, n_tasks)) + params.pop("positive") + if positive: + return + if X_is_sparse: - X = sparse.csc_matrix(X) + X = X_is_sparse(X) # Test alpha_max makes coefs zero. - reg = ElasticNetCV(alphas=1, cv=2, eps=1, fit_intercept=fit_intercept) + reg = estimatorCV(alphas=1, cv=2, eps=1, **params) reg.fit(X, y, sample_weight=sample_weight) assert_allclose(reg.coef_, 0, atol=1e-5) alpha_max = reg.alpha_ # Test smaller alpha makes coefs nonzero. - reg = ElasticNet(alpha=0.99 * alpha_max, fit_intercept=fit_intercept, tol=1e-8) + reg = estimator(alpha=0.99 * alpha_max, tol=1e-8, **params) reg.fit(X, y, sample_weight=sample_weight) assert_array_less(1e-3, np.max(np.abs(reg.coef_))) + if positive: + # Make sure that the positive constraint changes alpha_max, + # i.e. test the meaningfulness of the test data. + not_positive_alpha_max = ( + estimatorCV(alphas=1, cv=2, eps=1, **{**params, "positive": not positive}) + .fit(X, y, sample_weight=sample_weight) + .alpha_ + ) + assert not np.isclose(alpha_max, not_positive_alpha_max), ( + "Test data cannot distinguish alpha_max between positive=True and False." + ) + @pytest.mark.parametrize("estimator", [ElasticNetCV, LassoCV]) def test_linear_models_cv_fit_with_loky(estimator): @@ -1534,6 +1600,7 @@ def test_linear_models_cv_fit_with_loky(estimator): estimator(n_jobs=2, cv=3).fit(X, y) +# TODO: @pytest.mark.parametrize("check_input", [True, False]) def test_enet_sample_weight_does_not_overwrite_sample_weight(check_input): """Check that ElasticNet does not overwrite sample_weights.""" @@ -1553,39 +1620,83 @@ def test_enet_sample_weight_does_not_overwrite_sample_weight(check_input): assert_array_equal(sample_weight, sample_weight_1_25) -@pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning") -@pytest.mark.parametrize("ridge_alpha", [1e-1, 1.0, 1e6]) -def test_enet_ridge_consistency(ridge_alpha): +@pytest.mark.parametrize("ridge_alpha", [1e-6, 1e-1, 1.0, 1e6]) +@pytest.mark.parametrize( + ["precompute", "n_targets"], [(False, 1), (True, 1), (False, 3)] +) +def test_enet_ridge_consistency(ridge_alpha, precompute, n_targets, global_random_seed): # Check that ElasticNet(l1_ratio=0) converges to the same solution as Ridge # provided that the value of alpha is adapted. - # - # XXX: this test does not pass for weaker regularization (lower values of - # ridge_alpha): it could be either a problem of ElasticNet or Ridge (less - # likely) and depends on the dataset statistics: lower values for - # effective_rank are more problematic in particular. - rng = np.random.RandomState(42) + rng = np.random.RandomState(global_random_seed) n_samples = 300 X, y = make_regression( n_samples=n_samples, n_features=100, + n_targets=n_targets, effective_rank=10, n_informative=50, random_state=rng, ) sw = rng.uniform(low=0.01, high=10, size=X.shape[0]) - alpha = 1.0 - common_params = dict( - tol=1e-12, + + if n_targets == 1: + sw_arg = dict(sample_weight=sw) + else: + # MultiTaskElasticNet does not support sample weights (yet). + sw_arg = dict() + + ridge = Ridge(alpha=ridge_alpha, solver="svd").fit(X, y, **sw_arg) + + tol = 1e-11 if ridge_alpha >= 1e-2 else 1e-16 + if n_targets == 1: + alpha_enet = ridge_alpha / sw.sum() + enet = ElasticNet(alpha=alpha_enet, l1_ratio=0, precompute=precompute, tol=tol) + else: + alpha_enet = ridge_alpha / n_samples + enet = MultiTaskElasticNet(alpha=alpha_enet, l1_ratio=0, tol=tol) + enet.fit(X, y, **sw_arg) + + # The CD solver using the gram matrix (precompute = True) loses numerical precision + # by working with the squares of matrices like Q=X'X (=gram) and + # R^2 = y^2 + wQw - 2yQw (=square of residuals). + rtol = 1e-5 if precompute else 5e-7 + atol = 3e-11 + assert_allclose(enet.coef_, ridge.coef_, rtol=rtol, atol=atol) + assert_allclose(enet.intercept_, ridge.intercept_, atol=atol) + + +@pytest.mark.filterwarnings("ignore:With alpha=0, this algorithm:UserWarning") +@pytest.mark.parametrize("precompute", [False, True]) +@pytest.mark.parametrize("effective_rank", [None, 10]) +def test_enet_ols_consistency(precompute, effective_rank, global_random_seed): + """Test that ElasticNet(alpha=0) converges to the same solution as OLS.""" + rng = np.random.RandomState(global_random_seed) + n_samples = 300 + X, y = make_regression( + n_samples=n_samples, + n_features=100, + effective_rank=effective_rank, + n_informative=50, + random_state=rng, ) - ridge = Ridge(alpha=alpha, **common_params).fit(X, y, sample_weight=sw) + sw = rng.uniform(low=0.01, high=10, size=X.shape[0]) - alpha_enet = alpha / sw.sum() - enet = ElasticNet(alpha=alpha_enet, l1_ratio=0, **common_params).fit( + ols = LinearRegression().fit(X, y, sample_weight=sw) + enet = ElasticNet(alpha=0, precompute=precompute, tol=1e-15).fit( X, y, sample_weight=sw ) - assert_allclose(ridge.coef_, enet.coef_) - assert_allclose(ridge.intercept_, enet.intercept_) + + # Might be a singular problem, so check for same predictions + assert_allclose(enet.predict(X), ols.predict(X)) + # and for similar objective function (squared error) + se_ols = np.sum(sw * (y - ols.predict(X)) ** 2) + se_enet = np.sum(sw * (y - enet.predict(X)) ** 2) + assert se_ols <= 1e-19 + assert se_enet <= 1e-19 + # We check equal coefficients, but "only" with absolute tolerance. + assert_allclose(enet.coef_, ols.coef_, atol=1e-11) + assert_allclose(enet.intercept_, ols.intercept_, atol=1e-11) @pytest.mark.parametrize( @@ -1657,170 +1768,29 @@ def test_cv_estimators_reject_params_with_no_routing_enabled(EstimatorCV): estimator.fit(X, y, groups=groups) -@pytest.mark.parametrize( - "MultiTaskEstimatorCV", - [MultiTaskElasticNetCV, MultiTaskLassoCV], -) -@config_context(enable_metadata_routing=True) -def test_multitask_cv_estimators_with_sample_weight(MultiTaskEstimatorCV): - """Check that for :class:`MultiTaskElasticNetCV` and - class:`MultiTaskLassoCV` if `sample_weight` is passed and the - CV splitter does not support `sample_weight` an error is raised. - On the other hand if the splitter does support `sample_weight` - while `sample_weight` is passed there is no error and process - completes smoothly as before. - """ - - class CVSplitter(GroupsConsumerMixin, BaseCrossValidator): - def get_n_splits(self, X=None, y=None, groups=None, metadata=None): - pass # pragma: nocover - - class CVSplitterSampleWeight(CVSplitter): - def split(self, X, y=None, groups=None, sample_weight=None): - split_index = len(X) // 2 - train_indices = list(range(0, split_index)) - test_indices = list(range(split_index, len(X))) - yield test_indices, train_indices - yield train_indices, test_indices - - X, y = make_regression(random_state=42, n_targets=2) - sample_weight = np.ones(X.shape[0]) - - # If CV splitter does not support sample_weight an error is raised - splitter = CVSplitter().set_split_request(groups=True) - estimator = MultiTaskEstimatorCV(cv=splitter) - msg = "do not support sample weights" - with pytest.raises(ValueError, match=msg): - estimator.fit(X, y, sample_weight=sample_weight) - - # If CV splitter does support sample_weight no error is raised - splitter = CVSplitterSampleWeight().set_split_request( - groups=True, sample_weight=True - ) - estimator = MultiTaskEstimatorCV(cv=splitter) - estimator.fit(X, y, sample_weight=sample_weight) - - -# TODO(1.9): remove -@pytest.mark.parametrize( - "Estimator", [LassoCV, ElasticNetCV, MultiTaskLassoCV, MultiTaskElasticNetCV] -) -def test_linear_model_cv_deprecated_n_alphas(Estimator): - """Check the deprecation of n_alphas in favor of alphas.""" - X, y = make_regression(n_targets=2, random_state=42) - - # Asses warning message raised by LinearModelCV when n_alphas is used - with pytest.warns( - FutureWarning, - match="'n_alphas' was deprecated in 1.7 and will be removed in 1.9", - ): - clf = Estimator(n_alphas=5) - if clf._is_multitask(): - clf = clf.fit(X, y) - else: - clf = clf.fit(X, y[:, 0]) - - # Asses no warning message raised when n_alphas is not used - with warnings.catch_warnings(): - warnings.simplefilter("error") - clf = Estimator(alphas=5) - if clf._is_multitask(): - clf = clf.fit(X, y) - else: - clf = clf.fit(X, y[:, 0]) - +@pytest.mark.parametrize("precompute", ["auto", True, False]) +def test_enet_path_check_input_false(precompute): + """Test enet_path works with check_input=False and various precompute settings.""" + X, y = make_regression(n_samples=100, n_features=5, n_informative=2, random_state=0) + X = np.asfortranarray(X) + alphas, _, _ = enet_path(X, y, alphas=3, check_input=False, precompute=precompute) -# TODO(1.9): remove -@pytest.mark.parametrize( - "Estimator", [ElasticNetCV, LassoCV, MultiTaskLassoCV, MultiTaskElasticNetCV] -) -def test_linear_model_cv_deprecated_alphas_none(Estimator): - """Check the deprecation of alphas=None.""" - X, y = make_regression(n_targets=2, random_state=42) - with pytest.warns( - FutureWarning, match="'alphas=None' is deprecated and will be removed in 1.9" - ): - clf = Estimator(alphas=None) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) +# TODO(1.11): remove +@pytest.mark.parametrize("path_func", [lasso_path, enet_path]) +def test_path_function_deprecated_n_alphas(path_func): + """Check deprecation of n_alphas in favor of alphas.""" + X, y = make_regression(n_samples=9, n_features=5, n_informative=2, random_state=42) + msg = "'n_alphas' was deprecated in 1.9 and will be removed in 1.11" + with pytest.warns(FutureWarning, match=msg): + path_func(X, y, n_alphas=5) -# TODO(1.9): remove -@pytest.mark.filterwarnings("ignore:.*with no regularization.*:UserWarning") -@pytest.mark.parametrize( - "Estimator", [ElasticNetCV, LassoCV, MultiTaskLassoCV, MultiTaskElasticNetCV] -) -def test_linear_model_cv_alphas_n_alphas_unset(Estimator): - """Check that no warning is raised when both n_alphas and alphas are unset.""" - X, y = make_regression(n_targets=2, random_state=42) + msg = "'alphas=None' is deprecated and will be removed in 1.11" + with pytest.warns(FutureWarning, match=msg): + path_func(X, y, alphas=None) - # Asses no warning message raised when n_alphas is not used + # Assert that no warning is raised when n_alphas is not used. with warnings.catch_warnings(): warnings.simplefilter("error") - clf = Estimator() - if clf._is_multitask(): - clf = clf.fit(X, y) - else: - clf = clf.fit(X, y[:, 0]) - - -# TODO(1.9): remove -@pytest.mark.filterwarnings("ignore:'n_alphas' was deprecated in 1.7") -@pytest.mark.filterwarnings("ignore:.*with no regularization.*:UserWarning") -@pytest.mark.parametrize( - "Estimator", [ElasticNetCV, LassoCV, MultiTaskLassoCV, MultiTaskElasticNetCV] -) -def test_linear_model_cv_alphas(Estimator): - """Check that the behavior of alphas is consistent with n_alphas.""" - X, y = make_regression(n_targets=2, random_state=42) - - # n_alphas is set, alphas is not => n_alphas is used - clf = Estimator(n_alphas=5) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 5 - - # n_alphas is set, alphas is set => alphas has priority - clf = Estimator(n_alphas=5, alphas=10) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 10 - - # same with alphas array-like - clf = Estimator(n_alphas=5, alphas=np.arange(10)) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 10 - - # n_alphas is not set, alphas is set => alphas is used - clf = Estimator(alphas=10) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 10 - - # same with alphas array-like - clf = Estimator(alphas=np.arange(10)) - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 10 - - # both are not set => default = 100 - clf = Estimator() - if clf._is_multitask(): - clf.fit(X, y) - else: - clf.fit(X, y[:, 0]) - assert len(clf.alphas_) == 100 + path_func(X, y, alphas=5) diff --git a/sklearn/linear_model/tests/test_least_angle.py b/sklearn/linear_model/tests/test_least_angle.py index 39d93098dee58..e1011f8ac3915 100644 --- a/sklearn/linear_model/tests/test_least_angle.py +++ b/sklearn/linear_model/tests/test_least_angle.py @@ -501,8 +501,8 @@ def test_lars_path_readonly_data(): # fold data is in read-only mode # This is a non-regression test for: # https://github.com/scikit-learn/scikit-learn/issues/4597 - splitted_data = train_test_split(X, y, random_state=42) - with TempMemmap(splitted_data) as (X_train, X_test, y_train, y_test): + split_data = train_test_split(X, y, random_state=42) + with TempMemmap(split_data) as (X_train, X_test, y_train, y_test): # The following should not fail despite copy=False _lars_path_residues(X_train, y_train, X_test, y_test, copy=False) diff --git a/sklearn/linear_model/tests/test_logistic.py b/sklearn/linear_model/tests/test_logistic.py index 9cf34d9552307..99be5686c0d69 100644 --- a/sklearn/linear_model/tests/test_logistic.py +++ b/sklearn/linear_model/tests/test_logistic.py @@ -23,7 +23,7 @@ _log_reg_scoring_path, _logistic_regression_path, ) -from sklearn.metrics import brier_score_loss, get_scorer, log_loss, make_scorer +from sklearn.metrics import brier_score_loss, get_scorer, log_loss from sklearn.model_selection import ( GridSearchCV, KFold, @@ -36,7 +36,15 @@ from sklearn.preprocessing import LabelEncoder, StandardScaler, scale from sklearn.svm import l1_min_c from sklearn.utils import compute_class_weight, shuffle -from sklearn.utils._testing import ignore_warnings +from sklearn.utils._array_api import ( + _atol_for_type, + move_to, + yield_namespace_device_dtype_combinations, +) +from sklearn.utils._array_api import ( + device as array_api_device, +) +from sklearn.utils._testing import _array_api_for_tests, ignore_warnings from sklearn.utils.fixes import _IS_32BIT, COO_CONTAINERS, CSR_CONTAINERS pytestmark = pytest.mark.filterwarnings( @@ -68,7 +76,7 @@ def check_predictions(clf, X, y): probabilities = clf.predict_proba(X) assert probabilities.shape == (n_samples, n_classes) - assert_array_almost_equal(probabilities.sum(axis=1), np.ones(n_samples)) + assert_allclose(probabilities.sum(axis=1), np.ones(n_samples)) assert_array_equal(probabilities.argmax(axis=1), y) @@ -86,97 +94,12 @@ def test_predict_2_classes(csr_container): check_predictions(LogisticRegression(fit_intercept=False), csr_container(X), Y1) -def test_logistic_cv_mock_scorer(): - """Test that LogisticRegressionCV calls the scorer.""" - - class MockScorer: - def __init__(self): - self.calls = 0 - self.scores = [0.1, 0.4, 0.8, 0.5] - - def __call__(self, model, X, y, sample_weight=None): - score = self.scores[self.calls % len(self.scores)] - self.calls += 1 - return score - - mock_scorer = MockScorer() - Cs = [1, 2, 3, 4] - cv = 2 - - lr = LogisticRegressionCV( - Cs=Cs, - l1_ratios=(0,), # TODO(1.10): remove with new default of l1_ratios - scoring=mock_scorer, - cv=cv, - use_legacy_attributes=False, - ) - X, y = make_classification(random_state=0) - lr.fit(X, y) - - # Cs[2] has the highest score (0.8) from MockScorer - assert lr.C_ == Cs[2] - - # scorer called 8 times (cv*len(Cs)) - assert mock_scorer.calls == cv * len(Cs) - - # reset mock_scorer - mock_scorer.calls = 0 - custom_score = lr.score(X, lr.predict(X)) - - assert custom_score == mock_scorer.scores[0] - assert mock_scorer.calls == 1 - - @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_predict_3_classes(csr_container): check_predictions(LogisticRegression(C=10), X, Y2) check_predictions(LogisticRegression(C=10), csr_container(X), Y2) -@pytest.mark.parametrize( - "clf", - [ - LogisticRegression(C=len(iris.data), solver="lbfgs", max_iter=200), - LogisticRegression(C=len(iris.data), solver="newton-cg"), - LogisticRegression( - C=len(iris.data), - solver="sag", - tol=1e-2, - ), - LogisticRegression( - C=len(iris.data), - solver="saga", - tol=1e-2, - ), - LogisticRegression(C=len(iris.data), solver="newton-cholesky"), - OneVsRestClassifier(LogisticRegression(C=len(iris.data), solver="liblinear")), - ], -) -def test_predict_iris(clf, global_random_seed): - """Test logistic regression with the iris dataset. - - Test that different solvers handle multiclass data correctly and - give good accuracy score (>0.95) for the training data. - """ - clf = clone(clf) # Avoid side effects from shared instances - n_samples, _ = iris.data.shape - target = iris.target_names[iris.target] - - if getattr(clf, "solver", None) in ("sag", "saga", "liblinear"): - clf.set_params(random_state=global_random_seed) - clf.fit(iris.data, target) - assert_array_equal(np.unique(target), clf.classes_) - - pred = clf.predict(iris.data) - assert np.mean(pred == target) > 0.95 - - probabilities = clf.predict_proba(iris.data) - assert_allclose(probabilities.sum(axis=1), np.ones(n_samples)) - - pred = iris.target_names[probabilities.argmax(axis=1)] - assert np.mean(pred == target) > 0.95 - - @pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning") @pytest.mark.parametrize("solver", ["lbfgs", "newton-cholesky"]) def test_logistic_glmnet(solver): @@ -248,6 +171,8 @@ def test_logistic_glmnet(solver): ) +# TODO(1.11): remove filterwarnings with change of default scoring +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") # TODO(1.10): remove filterwarnings with deprecation period of use_legacy_attributes @pytest.mark.filterwarnings("ignore:.*use_legacy_attributes.*:FutureWarning") @pytest.mark.parametrize("LR", [LogisticRegression, LogisticRegressionCV]) @@ -297,6 +222,8 @@ def test_check_solver_option(LR): lr.fit(X, y) +# TODO(1.11): remove filterwarnings with change of default scoring +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") # TODO(1.10): remove test with removal of penalty @pytest.mark.filterwarnings("ignore::FutureWarning") @pytest.mark.parametrize( @@ -348,11 +275,13 @@ def test_inconsistent_input(): # Wrong dimensions for training data y_wrong = y_[:-1] - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="Found input variables with inconsistent number" + ): clf.fit(X, y_wrong) # Wrong dimensions for test data - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="X has 12 features, but"): clf.fit(X_, y_).predict(rng.random_sample((3, 12))) @@ -370,10 +299,10 @@ def test_nan(): # Regression test for Issue #252: fit used to go into an infinite loop. Xnan = np.array(X, dtype=np.float64) Xnan[0, 1] = np.nan - logistic = LogisticRegression() + clf = LogisticRegression() - with pytest.raises(ValueError): - logistic.fit(Xnan, Y1) + with pytest.raises(ValueError, match="Input X contains NaN."): + clf.fit(Xnan, Y1) def test_consistency_path(global_random_seed): @@ -515,6 +444,7 @@ def test_logistic_cv(global_random_seed, use_legacy_attributes): random_state=global_random_seed, solver="liblinear", cv=n_cv, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=use_legacy_attributes, ) lr_cv.fit(X_ref, y) @@ -543,6 +473,68 @@ def test_logistic_cv(global_random_seed, use_legacy_attributes): assert lr_cv.scores_.shape == (n_cv, n_l1_ratios, n_Cs) +# TODO(1.11): remove filterwarnings with change of default scoring +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") +def test_logistic_cv_refit_false_non_elasticnet(global_random_seed): + """Test that non-elasticnet penalty with refit=False and + use_legacy_attributes=False works without error. + + For non-elasticnet penalties, l1_ratio=0.0 (equivalent to pure L2). + Previously, None was stored, which caused float() to raise a + TypeError when use_legacy_attributes=False converted the value to a scalar. + """ + X, y = make_classification(random_state=global_random_seed) + lr_cv = LogisticRegressionCV( + l1_ratios=[0.0], + refit=False, + use_legacy_attributes=False, + random_state=global_random_seed, + ) + lr_cv.fit(X, y) + assert lr_cv.l1_ratio_ == 0.0 + + +def test_logistic_cv_mock_scorer(): + """Test that LogisticRegressionCV calls the scorer.""" + + class MockScorer: + def __init__(self): + self.calls = 0 + self.scores = [0.1, 0.4, 0.8, 0.5] + + def __call__(self, model, X, y, sample_weight=None): + score = self.scores[self.calls % len(self.scores)] + self.calls += 1 + return score + + mock_scorer = MockScorer() + Cs = [1, 2, 3, 4] + cv = 2 + + lr = LogisticRegressionCV( + Cs=Cs, + l1_ratios=(0,), # TODO(1.10): remove with new default of l1_ratios + scoring=mock_scorer, + cv=cv, + use_legacy_attributes=False, + ) + X, y = make_classification(random_state=0) + lr.fit(X, y) + + # Cs[2] has the highest score (0.8) from MockScorer + assert lr.C_ == Cs[2] + + # scorer called 8 times (cv*len(Cs)) + assert mock_scorer.calls == cv * len(Cs) + + # reset mock_scorer + mock_scorer.calls = 0 + custom_score = lr.score(X, lr.predict(X)) + + assert custom_score == mock_scorer.scores[0] + assert mock_scorer.calls == 1 + + @pytest.mark.parametrize( "scoring, multiclass_agg_list", [ @@ -558,18 +550,15 @@ def test_logistic_cv(global_random_seed, use_legacy_attributes): ("recall", ["_macro", "_weighted"]), ], ) -def test_logistic_cv_multinomial_score( - global_random_seed, scoring, multiclass_agg_list -): +def test_logistic_cv_multinomial_score(scoring, multiclass_agg_list): # test that LogisticRegressionCV uses the right score to compute its # cross-validation scores when using a multinomial scoring # see https://github.com/scikit-learn/scikit-learn/issues/8720 X, y = make_classification( - n_samples=100, random_state=global_random_seed, n_classes=3, n_informative=6 + n_samples=100, random_state=42, n_classes=3, n_informative=6 ) train, test = np.arange(80), np.arange(80, 100) - lr = LogisticRegression(C=1.0) - # we use lbfgs to support multinomial + lr = LogisticRegression(C=1.0, solver="lbfgs") params = lr.get_params() # Replace default penalty='deprecated' in 1.8 by the equivalent value that # can be used by _log_reg_scoring_path @@ -616,9 +605,17 @@ def test_multinomial_logistic_regression_string_inputs(): y = np.array(y) - 1 # Test for string labels lr = LogisticRegression() - lr_cv = LogisticRegressionCV(Cs=3, use_legacy_attributes=False) + lr_cv = LogisticRegressionCV( + Cs=3, + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) lr_str = LogisticRegression() - lr_cv_str = LogisticRegressionCV(Cs=3, use_legacy_attributes=False) + lr_cv_str = LogisticRegressionCV( + Cs=3, + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) lr.fit(X_ref, y) lr_cv.fit(X_ref, y) @@ -639,9 +636,11 @@ def test_multinomial_logistic_regression_string_inputs(): assert set(np.unique(lr_cv_str.predict(X_ref))) <= {"bar", "baz", "foo"} # We use explicit Cs parameter to make sure all labels are predicted for each C. - lr_cv_str = LogisticRegressionCV(Cs=[1, 2, 10], use_legacy_attributes=False).fit( - X_ref, y_str - ) + lr_cv_str = LogisticRegressionCV( + Cs=[1, 2, 10], + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ).fit(X_ref, y_str) assert sorted(np.unique(lr_cv_str.predict(X_ref))) == ["bar", "baz", "foo"] # Make sure class weights can be given with string labels @@ -652,23 +651,6 @@ def test_multinomial_logistic_regression_string_inputs(): assert sorted(np.unique(lr_cv_str.predict(X_ref))) == ["bar", "baz"] -@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_logistic_cv_sparse(global_random_seed, csr_container): - X, y = make_classification( - n_samples=100, n_features=5, random_state=global_random_seed - ) - X[X < 1.0] = 0.0 - csr = csr_container(X) - - clf = LogisticRegressionCV(use_legacy_attributes=False) - clf.fit(X, y) - clfs = LogisticRegressionCV(use_legacy_attributes=False) - clfs.fit(csr, y) - assert_array_almost_equal(clfs.coef_, clf.coef_) - assert_array_almost_equal(clfs.intercept_, clf.intercept_) - assert clfs.C_ == clf.C_ - - # TODO(1.12): remove deprecated use_legacy_attributes @pytest.mark.parametrize("use_legacy_attributes", [True, False]) def test_multinomial_cv_iris(use_legacy_attributes): @@ -683,7 +665,10 @@ def test_multinomial_cv_iris(use_legacy_attributes): # Train clf on the original dataset clf = LogisticRegressionCV( - cv=precomputed_folds, solver="newton-cholesky", use_legacy_attributes=True + cv=precomputed_folds, + solver="newton-cholesky", + use_legacy_attributes=True, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now ) clf.fit(X, y) @@ -700,6 +685,7 @@ def test_multinomial_cv_iris(use_legacy_attributes): clf_ovr = GridSearchCV( OneVsRestClassifier(LogisticRegression(solver="newton-cholesky")), {"estimator__C": np.logspace(-4, 4, num=10)}, + scoring="neg_log_loss", ).fit(X, y) for solver in ["lbfgs", "newton-cg", "sag", "saga"]: max_iter = 500 if solver in ["sag", "saga"] else 30 @@ -709,6 +695,7 @@ def test_multinomial_cv_iris(use_legacy_attributes): random_state=42, tol=1e-3 if solver in ["sag", "saga"] else 1e-2, cv=2, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=use_legacy_attributes, ) if solver == "lbfgs": @@ -778,32 +765,78 @@ def test_multinomial_cv_iris(use_legacy_attributes): assert len(np.unique(y[test])) == 1 assert set(y[train]) & set(y[test]) == set() - clf = LogisticRegressionCV(cv=cv, use_legacy_attributes=False).fit(X, y) + clf = LogisticRegressionCV( + cv=cv, + use_legacy_attributes=False, + scoring="accuracy", + ).fit(X, y) # We expect accuracy to be exactly 0 because train and test sets have # non-overlapping labels assert np.all(clf.scores_ == 0.0) # We use a proper scoring rule, i.e. the Brier score, to evaluate our classifier. - # Because of a bug in LogisticRegressionCV, we need to create our own scoring - # function to pass explicitly the labels. - scoring = make_scorer( - brier_score_loss, - greater_is_better=False, - response_method="predict_proba", - scale_by_half=True, - labels=classes, - ) # We set small Cs, that is strong penalty as the best C is likely the smallest one. clf = LogisticRegressionCV( - cv=cv, scoring=scoring, Cs=np.logspace(-6, 3, 10), use_legacy_attributes=False + cv=cv, + scoring="neg_brier_score", + Cs=np.logspace(-6, 3, 10), + use_legacy_attributes=False, ).fit(X, y) assert clf.C_ == 1e-6 # smallest value of provided Cs brier_scores = -clf.scores_ # We expect the scores to be bad because train and test sets have # non-overlapping labels - assert np.all(brier_scores > 0.7) + assert np.all(brier_scores > 0.7 * 2) # times 2 because scale_by_half=False # But the best score should be better than the worst value of 1. - assert np.min(brier_scores) < 0.8 + assert np.min(brier_scores) < 0.8 * 2 # times 2 because scale_by_half=False + + +@pytest.mark.parametrize("enable_metadata_routing", [False, True]) +@pytest.mark.parametrize("n_classes", [2, 3]) +def test_logistic_cv_folds_with_classes_missing(enable_metadata_routing, n_classes): + """Test that LogisticRegressionCV correctly computes scores even when classes are + missing on CV folds. + """ + with config_context(enable_metadata_routing=enable_metadata_routing): + y = np.array(["a", "a", "b", "b", "c", "c"])[: 2 * n_classes] + X = np.arange(2 * n_classes)[:, None] + + # Test CV folds have missing class labels. + cv = KFold(n_splits=n_classes) + # Check this assumption. + for train, test in cv.split(X, y): + assert len(np.unique(y[train])) == n_classes - 1 + assert len(np.unique(y[test])) == 1 + assert set(y[train]) & set(y[test]) == set() + + clf = LogisticRegressionCV( + cv=cv, + scoring="neg_brier_score", + Cs=np.logspace(-6, 6, 5), + l1_ratios=(0,), + use_legacy_attributes=False, + ).fit(X, y) + + assert clf.C_ == 1e-6 # smallest value of provided Cs + for i, (train, test) in enumerate(cv.split(X, y)): + # We need to construct the logistic regression model, clf2, as it was fit on + # a single training fold. + clf2 = LogisticRegression(C=clf.C_).fit(X, y) + clf2.coef_ = clf.coefs_paths_[i, 0, 0, :, :-1] + clf2.intercept_ = clf.coefs_paths_[i, 0, 0, :, -1] + if n_classes <= 2: + bs = brier_score_loss( + y[test], + clf2.predict_proba(X[test]), + pos_label="b", + labels=["a", "b"], + ) + else: + bs = brier_score_loss( + y[test], clf2.predict_proba(X[test]), labels=["a", "b", "c"] + ) + + assert_allclose(-clf.scores_[i, 0, 0], bs) def test_logistic_regression_solvers(global_random_seed): @@ -835,11 +868,12 @@ def test_logistic_regression_solvers(global_random_seed): @pytest.mark.parametrize("fit_intercept", [False, True]) def test_logistic_regression_solvers_multiclass(fit_intercept): """Test solvers converge to the same result for multiclass problems.""" + n_samples, n_features, n_classes = 20, 20, 3 X, y = make_classification( - n_samples=20, - n_features=20, + n_samples=n_samples, + n_features=n_features, n_informative=10, - n_classes=3, + n_classes=n_classes, random_state=0, ) tol = 1e-8 @@ -847,7 +881,7 @@ def test_logistic_regression_solvers_multiclass(fit_intercept): # Override max iteration count for specific solvers to allow for # proper convergence. - solver_max_iter = {"lbfgs": 200, "sag": 10_000, "saga": 10_000} + solver_max_iter = {"lbfgs": 200, "sag": 20_000, "saga": 20_000} classifiers = { solver: LogisticRegression( @@ -855,6 +889,10 @@ def test_logistic_regression_solvers_multiclass(fit_intercept): ).fit(X, y) for solver in set(SOLVERS) - set(["liblinear"]) } + for solver, clf in classifiers.items(): + assert clf.coef_.shape == (n_classes, n_features), ( + f"Solver {solver} generates coef_ with wrong shape." + ) for solver_1, solver_2 in itertools.combinations(classifiers, r=2): assert_allclose( @@ -871,6 +909,31 @@ def test_logistic_regression_solvers_multiclass(fit_intercept): err_msg=f"{solver_1} vs {solver_2}", ) + # Test that LogisticRegressionCV gives almost the same results for the same C. + # However, since in this case we take the average of the coefs after fitting across + # all the folds, it need not be exactly the same. + classifiers_cv = { + solver: LogisticRegressionCV( + Cs=[1.0], + solver=solver, + max_iter=solver_max_iter.get(solver, 100), + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + **params, + ).fit(X, y) + for solver in set(SOLVERS) - set(["liblinear"]) + } + for solver in classifiers_cv: + assert_allclose( + classifiers_cv[solver].coef_, classifiers[solver].coef_, rtol=1e-2 + ) + if fit_intercept: + assert_allclose( + classifiers_cv[solver].intercept_, + classifiers[solver].intercept_, + rtol=1e-2, + ) + @pytest.mark.parametrize("fit_intercept", [False, True]) def test_logistic_regression_solvers_multiclass_unpenalized( @@ -942,6 +1005,37 @@ def test_logistic_regression_solvers_multiclass_unpenalized( ) +@pytest.mark.parametrize("solver", SOLVERS) +@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) +def test_logistic_cv_sparse(global_random_seed, solver, csr_container): + """Test that sparse and dense X gives same result for each solver.""" + X, y = make_classification( + n_samples=100, n_features=5, random_state=global_random_seed + ) + X[X < 0.0] = 0.0 # make it a bit sparse + params = dict(Cs=[1e-1, 1, 1e1], max_iter=10_000, tol=1e-7, random_state=42) + + clf = LogisticRegressionCV( + solver=solver, + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + **params, + ) + clf.fit(X, y) + clfs = LogisticRegressionCV( + solver=solver, + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + **params, + ) + clfs.fit(csr_container(X), y) + + rtol = 6e-2 if solver in ("sag", "saga") else 1e-5 + assert_allclose(clfs.coef_, clf.coef_, rtol=rtol) + assert_allclose(clfs.intercept_, clf.intercept_, rtol=rtol) + assert clfs.C_ == clf.C_ + + @pytest.mark.parametrize("weight", [{0: 0.1, 1: 0.2}, {0: 0.1, 1: 0.2, 2: 0.5}]) @pytest.mark.parametrize("class_weight", ["weight", "balanced"]) def test_logistic_regressioncv_class_weights(weight, class_weight, global_random_seed): @@ -966,7 +1060,11 @@ def test_logistic_regressioncv_class_weights(weight, class_weight, global_random tol=1e-8, use_legacy_attributes=False, ) - clf_lbfgs = LogisticRegressionCV(solver="lbfgs", **params) + clf_lbfgs = LogisticRegressionCV( + solver="lbfgs", + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + **params, + ) # XXX: lbfgs' line search can fail and cause a ConvergenceWarning for some # 10% of the random seeds, but only on specific platforms (in particular @@ -979,7 +1077,11 @@ def test_logistic_regressioncv_class_weights(weight, class_weight, global_random clf_lbfgs.fit(X, y) for solver in set(SOLVERS) - set(["lbfgs", "liblinear", "newton-cholesky"]): - clf = LogisticRegressionCV(solver=solver, **params) + clf = LogisticRegressionCV( + solver=solver, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + **params, + ) if solver in ("sag", "saga"): clf.set_params( tol=1e-18, max_iter=10000, random_state=global_random_seed + 1 @@ -991,12 +1093,12 @@ def test_logistic_regressioncv_class_weights(weight, class_weight, global_random ) +# TODO(1.11): remove filterwarnings with change of default scoring +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") # TODO(1.10): remove filterwarnings with deprecation period of use_legacy_attributes @pytest.mark.filterwarnings("ignore:.*use_legacy_attributes.*:FutureWarning") @pytest.mark.parametrize("problem", ("single", "cv")) -@pytest.mark.parametrize( - "solver", ("lbfgs", "liblinear", "newton-cg", "newton-cholesky", "sag", "saga") -) +@pytest.mark.parametrize("solver", SOLVERS) def test_logistic_regression_sample_weights(problem, solver, global_random_seed): n_samples_per_cv_group = 200 n_cv_groups = 3 @@ -1036,13 +1138,13 @@ def test_logistic_regression_sample_weights(problem, solver, global_random_seed) ] ) splits_weighted = list(LeaveOneGroupOut().split(X, groups=groups_weighted)) - kw_weighted.update({"Cs": 100, "cv": splits_weighted}) + kw_weighted.update({"Cs": 10, "cv": splits_weighted}) groups_repeated = np.repeat(groups_weighted, sw.astype(int), axis=0) splits_repeated = list( LeaveOneGroupOut().split(X_repeated, groups=groups_repeated) ) - kw_repeated.update({"Cs": 100, "cv": splits_repeated}) + kw_repeated.update({"Cs": 10, "cv": splits_repeated}) clf_sw_weighted = LR(solver=solver, **kw_weighted) clf_sw_repeated = LR(solver=solver, **kw_repeated) @@ -1064,9 +1166,7 @@ def test_logistic_regression_sample_weights(problem, solver, global_random_seed) assert_allclose(clf_sw_weighted.coef_, clf_sw_repeated.coef_, atol=1e-5) -@pytest.mark.parametrize( - "solver", ("lbfgs", "newton-cg", "newton-cholesky", "sag", "saga") -) +@pytest.mark.parametrize("solver", SOLVERS) def test_logistic_regression_solver_class_weights(solver, global_random_seed): # Test that passing class_weight as [1, 2] is the same as # passing class weight = [1,1] but adjusting sample weights @@ -1204,69 +1304,6 @@ def test_logistic_regression_class_weights(global_random_seed, csr_container): assert_array_almost_equal(clf1.coef_, clf2.coef_, decimal=6) -def test_logistic_regression_multinomial(global_random_seed): - # Tests for the multinomial option in logistic regression - - # Some basic attributes of Logistic Regression - n_samples, n_features, n_classes = 200, 20, 3 - X, y = make_classification( - n_samples=n_samples, - n_features=n_features, - n_informative=10, - n_classes=n_classes, - random_state=global_random_seed, - ) - - X = StandardScaler(with_mean=False).fit_transform(X) - - # 'lbfgs' solver is used as a reference - it's the default - ref_i = LogisticRegression(tol=1e-10) - ref_w = LogisticRegression(fit_intercept=False, tol=1e-10) - ref_i.fit(X, y) - ref_w.fit(X, y) - assert ref_i.coef_.shape == (n_classes, n_features) - assert ref_w.coef_.shape == (n_classes, n_features) - for solver in ["sag", "saga", "newton-cg"]: - clf_i = LogisticRegression( - solver=solver, - random_state=global_random_seed, - max_iter=2000, - tol=1e-10, - ) - clf_w = LogisticRegression( - solver=solver, - random_state=global_random_seed, - max_iter=2000, - tol=1e-10, - fit_intercept=False, - ) - clf_i.fit(X, y) - clf_w.fit(X, y) - assert clf_i.coef_.shape == (n_classes, n_features) - assert clf_w.coef_.shape == (n_classes, n_features) - - # Compare solutions between lbfgs and the other solvers - assert_allclose(ref_i.coef_, clf_i.coef_, rtol=3e-3) - assert_allclose(ref_w.coef_, clf_w.coef_, rtol=1e-2) - assert_allclose(ref_i.intercept_, clf_i.intercept_, rtol=1e-3) - - # Test that the path give almost the same results. However since in this - # case we take the average of the coefs after fitting across all the - # folds, it need not be exactly the same. - for solver in ["lbfgs", "newton-cg", "sag", "saga"]: - clf_path = LogisticRegressionCV( - solver=solver, - random_state=global_random_seed, - max_iter=2000, - tol=1e-10, - Cs=[1.0], - use_legacy_attributes=False, - ) - clf_path.fit(X, y) - assert_allclose(clf_path.coef_, ref_i.coef_, rtol=1e-2) - assert_allclose(clf_path.intercept_, ref_i.intercept_, rtol=1e-2) - - def test_liblinear_decision_function_zero(global_random_seed): # Test negative prediction when decision_function values are zero. # Liblinear predicts the positive class when decision_function values @@ -1286,33 +1323,6 @@ def test_liblinear_decision_function_zero(global_random_seed): assert_array_equal(clf.predict(X), np.zeros(5)) -@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_liblinear_logregcv_sparse(csr_container, global_random_seed): - # Test LogRegCV with solver='liblinear' works for sparse matrices - - X, y = make_classification( - n_samples=10, n_features=5, random_state=global_random_seed - ) - clf = LogisticRegressionCV(solver="liblinear", use_legacy_attributes=False) - clf.fit(csr_container(X), y) - - -@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_saga_sparse(csr_container, global_random_seed): - # Test LogRegCV with solver='liblinear' works for sparse matrices - - X, y = make_classification( - n_samples=10, n_features=5, random_state=global_random_seed - ) - clf = LogisticRegressionCV( - solver="saga", - tol=1e-2, - random_state=global_random_seed, - use_legacy_attributes=False, - ) - clf.fit(csr_container(X), y) - - def test_logreg_intercept_scaling_zero(): # Test that intercept_scaling is ignored when fit_intercept is False @@ -1321,7 +1331,11 @@ def test_logreg_intercept_scaling_zero(): assert clf.intercept_ == 0.0 -def test_logreg_l1(global_random_seed): +# XXX: investigate thread-safety bug that might be related to: +# https://github.com/scikit-learn/scikit-learn/issues/31883 +@pytest.mark.thread_unsafe +@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) +def test_logreg_l1(global_random_seed, csr_container): # Because liblinear penalizes the intercept and saga does not, we do not # fit the intercept to make it possible to compare the coefficients of # the two models at convergence. @@ -1333,86 +1347,31 @@ def test_logreg_l1(global_random_seed): X_noise = rng.normal(size=(n_samples, 3)) X_constant = np.ones(shape=(n_samples, 2)) X = np.concatenate((X, X_noise, X_constant), axis=1) - lr_liblinear = LogisticRegression( + params = dict( l1_ratio=1, C=1.0, - solver="liblinear", fit_intercept=False, max_iter=10000, tol=1e-10, random_state=global_random_seed, ) + lr_liblinear = LogisticRegression(solver="liblinear", **params) lr_liblinear.fit(X, y) - lr_saga = LogisticRegression( - l1_ratio=1, - C=1.0, - solver="saga", - fit_intercept=False, - max_iter=10000, - tol=1e-10, - random_state=global_random_seed, - ) + lr_saga = LogisticRegression(solver="saga", **params) lr_saga.fit(X, y) assert_allclose(lr_saga.coef_, lr_liblinear.coef_, atol=0.3) - -@pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_logreg_l1_sparse_data(global_random_seed, csr_container): - # Because liblinear penalizes the intercept and saga does not, we do not - # fit the intercept to make it possible to compare the coefficients of - # the two models at convergence. - rng = np.random.RandomState(global_random_seed) - n_samples = 50 - X, y = make_classification( - n_samples=n_samples, n_features=20, random_state=global_random_seed - ) - X_noise = rng.normal(scale=0.1, size=(n_samples, 3)) - X_constant = np.zeros(shape=(n_samples, 2)) - X = np.concatenate((X, X_noise, X_constant), axis=1) - X[X < 1] = 0 - X = csr_container(X) - - lr_liblinear = LogisticRegression( - l1_ratio=1, - C=1.0, - solver="liblinear", - fit_intercept=False, - tol=1e-10, - max_iter=10000, - random_state=global_random_seed, - ) - lr_liblinear.fit(X, y) - - lr_saga = LogisticRegression( - l1_ratio=1, - C=1.0, - solver="saga", - fit_intercept=False, - max_iter=10000, - tol=1e-10, - random_state=global_random_seed, - ) - lr_saga.fit(X, y) - assert_array_almost_equal(lr_saga.coef_, lr_liblinear.coef_) - # Noise and constant features should be regularized to zero by the l1 - # penalty - assert_array_almost_equal(lr_liblinear.coef_[0, -5:], np.zeros(5)) - assert_array_almost_equal(lr_saga.coef_[0, -5:], np.zeros(5)) - # Check that solving on the sparse and dense data yield the same results - lr_saga_dense = LogisticRegression( - l1_ratio=1, - C=1.0, - solver="saga", - fit_intercept=False, - max_iter=10000, - tol=1e-10, - random_state=global_random_seed, - ) - lr_saga_dense.fit(X.toarray(), y) - assert_array_almost_equal(lr_saga.coef_, lr_saga_dense.coef_) + X_sp = csr_container(X) + lr_liblinear_sp = LogisticRegression(solver="liblinear", **params) + lr_liblinear_sp.fit(X_sp, y) + assert_allclose(lr_liblinear_sp.coef_, lr_liblinear.coef_) + + lr_saga_sp = LogisticRegression(solver="saga", **params) + lr_saga_sp.fit(X_sp, y) + assert_allclose(lr_saga_sp.coef_, lr_saga.coef_) @pytest.mark.parametrize("l1_ratio", [1, 0]) # L1 and L2 penalty @@ -1438,13 +1397,14 @@ def test_logistic_regression_cv_refit(global_random_seed, l1_ratio): Cs=[1.0], l1_ratios=(l1_ratio,), refit=True, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=False, **common_params, ) lr_cv.fit(X, y) lr = LogisticRegression(C=1.0, l1_ratio=l1_ratio, **common_params) lr.fit(X, y) - assert_array_almost_equal(lr_cv.coef_, lr.coef_) + assert_allclose(lr_cv.coef_, lr.coef_) def test_logreg_predict_proba_multinomial(global_random_seed): @@ -1544,6 +1504,7 @@ def test_n_iter(solver, use_legacy_attributes): cv=n_cv_fold, random_state=42, use_legacy_attributes=use_legacy_attributes, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now ) clf_cv.fit(X, y_bin) if use_legacy_attributes: @@ -1604,7 +1565,7 @@ def test_warm_start(global_random_seed, solver, warm_start, fit_intercept): @pytest.mark.parametrize("solver", ["newton-cholesky", "newton-cg"]) @pytest.mark.parametrize("fit_intercept", (True, False)) @pytest.mark.parametrize("C", (1, np.inf)) -def test_warm_start_newton_solver(global_random_seed, solver, fit_intercept, C): +def test_warm_start_newton_solver(solver, fit_intercept, C): """Test that 2 steps at once are the same as 2 single steps with warm start.""" X, y = iris.data, iris.target @@ -1613,7 +1574,6 @@ def test_warm_start_newton_solver(global_random_seed, solver, fit_intercept, C): max_iter=2, fit_intercept=fit_intercept, C=C, - random_state=global_random_seed, ) with ignore_warnings(category=ConvergenceWarning): clf1.fit(X, y) @@ -1624,7 +1584,6 @@ def test_warm_start_newton_solver(global_random_seed, solver, fit_intercept, C): warm_start=True, fit_intercept=fit_intercept, C=C, - random_state=global_random_seed, ) with ignore_warnings(category=ConvergenceWarning): clf2.fit(X, y) @@ -1681,9 +1640,7 @@ def test_saga_vs_liblinear(global_random_seed, csr_container, l1_ratio): assert_array_almost_equal(saga.coef_, liblinear.coef_, 3) -@pytest.mark.parametrize( - "solver", ["liblinear", "newton-cg", "newton-cholesky", "saga"] -) +@pytest.mark.parametrize("solver", SOLVERS) @pytest.mark.parametrize("fit_intercept", [False, True]) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_dtype_match(solver, fit_intercept, csr_container): @@ -1745,10 +1702,10 @@ def test_dtype_match(solver, fit_intercept, csr_container): # Check accuracy consistency assert_allclose(lr_32.coef_, lr_64.coef_.astype(np.float32), atol=atol) - if solver == "saga" and fit_intercept: + if solver in ("sag", "saga") and fit_intercept: # FIXME: SAGA on sparse data fits the intercept inaccurately with the # default tol and max_iter parameters. - atol = 1e-1 + atol = 2e-1 assert_allclose(lr_32.coef_, lr_32_sparse.coef_, atol=atol) assert_allclose(lr_64.coef_, lr_64_sparse.coef_, atol=atol) @@ -1930,6 +1887,7 @@ def test_LogisticRegressionCV_GridSearchCV_elastic_net(n_classes): cv=cv, random_state=0, tol=1e-2, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=False, ) lrcv.fit(X, y) @@ -1940,7 +1898,7 @@ def test_LogisticRegressionCV_GridSearchCV_elastic_net(n_classes): random_state=0, tol=1e-2, ) - gs = GridSearchCV(lr, param_grid, cv=cv) + gs = GridSearchCV(lr, param_grid, cv=cv, scoring="neg_log_loss") gs.fit(X, y) assert gs.best_params_["l1_ratio"] == lrcv.l1_ratio_ @@ -1969,6 +1927,7 @@ def test_LogisticRegressionCV_no_refit(l1_ratios, n_classes): random_state=0, tol=1e-2, refit=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=True, ) lrcv.fit(X, y) @@ -2008,6 +1967,7 @@ def test_LogisticRegressionCV_elasticnet_attribute_shapes(n_classes): cv=n_folds, random_state=0, tol=1e-2, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=True, ) lrcv.fit(X, y) @@ -2035,7 +1995,10 @@ def test_LogisticRegressionCV_on_folds(): """Test that LogisticRegressionCV produces the correct result on a fold.""" X, y = iris.data, iris.target lrcv = LogisticRegressionCV( - solver="newton-cholesky", tol=1e-8, use_legacy_attributes=True + solver="newton-cholesky", + tol=1e-8, + use_legacy_attributes=True, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now ).fit(X, y) # Reproduce the exact same split as default LogisticRegressionCV. @@ -2052,7 +2015,7 @@ def test_LogisticRegressionCV_on_folds(): ).fit(X[train_fold_0], y[train_fold_0]) for cl in np.unique(y): - # Coefficients without intecept + # Coefficients without intercept assert_allclose( lrcv.coefs_paths_[cl][idx_fold, idx_C, :-1], lr.coef_[cl], @@ -2257,6 +2220,23 @@ def test_penalty_none(global_random_seed, solver): assert_array_equal(pred_none, pred_l2_C_inf) +# TODO(1.10): remove whole test with the removal of penalty +@pytest.mark.parametrize("solver", sorted(set(SOLVERS) - set(["liblinear"]))) +def test_c_inf_no_warning(solver): + """Test that C=np.inf (recommended approach) produces no warnings. + + Non-regression test for: + https://github.com/scikit-learn/scikit-learn/issues/32927 + """ + X, y = make_classification(n_samples=100, n_redundant=0, random_state=42) + + lr = LogisticRegression(C=np.inf, solver=solver) + with warnings.catch_warnings(): + warnings.simplefilter("error") + warnings.filterwarnings("ignore", category=ConvergenceWarning) + lr.fit(X, y) + + # XXX: investigate thread-safety bug that might be related to: # https://github.com/scikit-learn/scikit-learn/issues/31883 @pytest.mark.thread_unsafe @@ -2336,6 +2316,7 @@ def test_scores_attribute_layout_elasticnet(): random_state=0, max_iter=250, tol=1e-3, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now use_legacy_attributes=True, ) lrcv.fit(X, y) @@ -2353,13 +2334,15 @@ def test_scores_attribute_layout_elasticnet(): tol=1e-3, ) - avg_score_lr = cross_val_score(lr, X, y, cv=cv).mean() - assert avg_scores_lrcv[i, j] == pytest.approx(avg_score_lr) + avg_score_lr = cross_val_score( + lr, X, y, cv=cv, scoring="neg_log_loss" + ).mean() + assert avg_scores_lrcv[i, j] == pytest.approx(avg_score_lr, rel=1e-3) @pytest.mark.parametrize("solver", ["lbfgs", "newton-cg", "newton-cholesky"]) @pytest.mark.parametrize("fit_intercept", [False, True]) -def test_multinomial_identifiability_on_iris(global_random_seed, solver, fit_intercept): +def test_multinomial_identifiability_on_iris(solver, fit_intercept): """Test that the multinomial classification is identifiable. A multinomial with c classes can be modeled with @@ -2388,7 +2371,6 @@ def test_multinomial_identifiability_on_iris(global_random_seed, solver, fit_int C=len(iris.data), solver=solver, fit_intercept=fit_intercept, - random_state=global_random_seed, ) # Scaling X to ease convergence. X_scaled = scale(iris.data) @@ -2401,7 +2383,7 @@ def test_multinomial_identifiability_on_iris(global_random_seed, solver, fit_int @pytest.mark.parametrize("class_weight", [{0: 1.0, 1: 10.0, 2: 1.0}, "balanced"]) -def test_sample_weight_not_modified(global_random_seed, class_weight): +def test_sample_weight_not_modified(class_weight): X, y = load_iris(return_X_y=True) n_features = len(X) W = np.ones(n_features) @@ -2410,7 +2392,6 @@ def test_sample_weight_not_modified(global_random_seed, class_weight): expected = W.copy() clf = LogisticRegression( - random_state=global_random_seed, class_weight=class_weight, max_iter=200, ) @@ -2420,15 +2401,15 @@ def test_sample_weight_not_modified(global_random_seed, class_weight): @pytest.mark.parametrize("solver", SOLVERS) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) -def test_large_sparse_matrix(solver, global_random_seed, csr_container): +def test_large_sparse_matrix(solver, csr_container): # Solvers either accept large sparse matrices, or raise helpful error. # Non-regression test for pull-request #21093. # generate sparse matrix with int64 indices - X = csr_container(sparse.rand(20, 10, random_state=global_random_seed)) + X = csr_container(sparse.rand(20, 10, random_state=42)) for attr in ["indices", "indptr"]: setattr(X, attr, getattr(X, attr).astype("int64")) - rng = np.random.RandomState(global_random_seed) + rng = np.random.RandomState(42) y = rng.randint(2, size=X.shape[0]) if solver in ["liblinear", "sag", "saga"]: @@ -2595,7 +2576,10 @@ def test_passing_params_without_enabling_metadata_routing(): """Test that the right error message is raised when metadata params are passed while not supported when `enable_metadata_routing=False`.""" X, y = make_classification(n_samples=10, random_state=0) - lr_cv = LogisticRegressionCV(use_legacy_attributes=False) + lr_cv = LogisticRegressionCV( + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) msg = "is only supported if enable_metadata_routing=True" with config_context(enable_metadata_routing=False): @@ -2608,13 +2592,11 @@ def test_passing_params_without_enabling_metadata_routing(): lr_cv.score(X, y, **params) -def test_newton_cholesky_fallback_to_lbfgs(global_random_seed): +def test_newton_cholesky_fallback_to_lbfgs(): # Wide data matrix should lead to a rank-deficient Hessian matrix # hence make the Newton-Cholesky solver raise a warning and fallback to # lbfgs. - X, y = make_classification( - n_samples=10, n_features=20, random_state=global_random_seed - ) + X, y = make_classification(n_samples=10, n_features=20, random_state=42) C = 1e30 # very high C to nearly disable regularization # Check that LBFGS can converge without any warning on this problem. @@ -2650,6 +2632,8 @@ def test_newton_cholesky_fallback_to_lbfgs(global_random_seed): assert n_iter_nc_limited == lr_nc_limited.max_iter - 1 +# TODO(1.11): remove filterwarnings with change of default scoring +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") # TODO(1.10): remove filterwarnings with deprecation period of use_legacy_attributes @pytest.mark.filterwarnings("ignore:.*use_legacy_attributes.*:FutureWarning") @pytest.mark.parametrize("Estimator", [LogisticRegression, LogisticRegressionCV]) @@ -2661,6 +2645,7 @@ def test_liblinear_multiclass_raises(Estimator): # TODO(1.10): remove after deprecation cycle of penalty. +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") @pytest.mark.filterwarnings("ignore:.*default.*use_legacy_attributes.*:FutureWarning") @pytest.mark.parametrize("est", [LogisticRegression, LogisticRegressionCV]) def test_penalty_deprecated(est): @@ -2675,7 +2660,9 @@ def test_penalty_deprecated(est): # TODO(1.10): use_legacy_attributes gets deprecated def test_logisticregressioncv_warns_with_use_legacy_attributes(): X, y = make_classification(n_classes=3, n_samples=50, n_informative=6) - lr = LogisticRegressionCV() + lr = LogisticRegressionCV( + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) msg = "The default value of use_legacy_attributes will change from True" with pytest.warns(FutureWarning, match=msg): lr.fit(X, y) @@ -2693,12 +2680,17 @@ def test_l1_ratio_None_deprecated(): with pytest.warns(FutureWarning, match=msg): lr.fit(X, y) - lr = LogisticRegressionCV() + lr = LogisticRegressionCV( + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) msg = "The default value for l1_ratios will change" with pytest.warns(FutureWarning, match=msg): lr.fit(X, y) - lr = LogisticRegressionCV(l1_ratios=None) + lr = LogisticRegressionCV( + l1_ratios=None, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) msg = "'l1_ratios=None' was deprecated" with pytest.warns(FutureWarning, match=msg): lr.fit(X, y) @@ -2713,6 +2705,139 @@ def test_logisticregression_warns_with_n_jobs(): lr.fit(X, y) +@pytest.mark.parametrize("binary", [False, True]) +@pytest.mark.parametrize("use_str_y", [False, True]) +@pytest.mark.parametrize("use_sample_weight", [False, True]) +@pytest.mark.parametrize("class_weight", [None, "balanced", "dict"]) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning") +def test_logistic_regression_array_api_compliance( + binary, + use_str_y, + use_sample_weight, + class_weight, + array_namespace, + device_name, + dtype_name, +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + X_np = iris.data.astype(dtype_name, copy=True) + n_samples, _ = X_np.shape + X_xp = xp.asarray(X_np, device=device) + if use_str_y: + if binary: + target = (iris.target > 0).astype(np.int64) + target = np.array(["setosa", "not-setosa"])[target] + if class_weight == "dict": + class_weight = {"setosa": 1.0, "not-setosa": 3.0} + else: + target = iris.target_names[iris.target] + if class_weight == "dict": + class_weight = {"virginica": 1.0, "setosa": 2.0, "versicolor": 3.0} + y_np = target.copy() + y_xp_or_np = np.asarray(y_np, copy=True) + else: + if binary: + target = (iris.target > 0).astype(np.int64) + if class_weight == "dict": + class_weight = {0: 1.0, 1: 3.0} + else: + target = iris.target + if class_weight == "dict": + class_weight = {0: 1.0, 1: 2.0, 2: 3.0} + y_np = target.astype(dtype_name) + y_xp_or_np = xp.asarray(y_np, device=device) + + if use_sample_weight: + sample_weight = ( + np.random.default_rng(0) + .uniform(-1, 5, size=n_samples) + .clip(0, None) + .astype(dtype_name) + ) + else: + sample_weight = None + + # Use a strong regularization to ensure coef_ can be identified to a higher + # precision even when taking into account the iterated discrepancies when + # the gradient is computed in float32. This is only necessary because the + # iris dataset is perfectly separable. + # We selected a low value of C (high coef_ regularization) to be able + # to identify coef_ to some strict enough precision level. However we + # also want to make sure that this choice of regularization does not + # constrain the fitted models to a trivial baseline classifier where only + # the intercept would be non-zero. + lr_params = dict( + C=1e-2, solver="lbfgs", tol=1e-12, max_iter=500, class_weight=class_weight + ) + with warnings.catch_warnings(): + # Make sure that we converge in the reference fit. + lr_np = LogisticRegression(**lr_params).fit( + X_np, y_np, sample_weight=sample_weight + ) + assert lr_np.n_iter_ < lr_np.max_iter + + # Test that C was not too large for meaningful testing. + assert np.abs(lr_np.coef_).max() > 0.1 + + predict_proba_np = lr_np.predict_proba(X_np) + preditct_log_proba_np = lr_np.predict_log_proba(X_np) + prediction_np = lr_np.predict(X_np) + # TODO: those tolerance levels seem quite high. Investigate further if we + # can hunt down the numerical discrepancies more precisely. + atol = _atol_for_type(dtype_name) * 10 + rtol = 5e-3 if dtype_name == "float32" else 1e-5 + + with config_context(array_api_dispatch=True): + with warnings.catch_warnings(): + # Make sure that we converge when using the namespace/device + # specific fit. + warnings.simplefilter("error", ConvergenceWarning) + lr_xp = LogisticRegression(**lr_params).fit( + X_xp, y_xp_or_np, sample_weight=sample_weight + ) + + assert lr_xp.n_iter_.shape == lr_np.n_iter_.shape + assert int(lr_xp.n_iter_[0]) < lr_xp.max_iter + + for attr_name in ("coef_", "intercept_"): + attr_xp = getattr(lr_xp, attr_name) + attr_np = getattr(lr_np, attr_name) + assert_allclose( + move_to(attr_xp, xp=np, device="cpu"), attr_np, rtol=rtol, atol=atol + ) + assert attr_xp.dtype == X_xp.dtype + assert array_api_device(attr_xp) == array_api_device(X_xp) + + predict_proba_xp = lr_xp.predict_proba(X_xp) + assert_allclose( + move_to(predict_proba_xp, xp=np, device="cpu"), + predict_proba_np, + rtol=rtol, + atol=atol, + ) + assert predict_proba_xp.dtype == X_xp.dtype + assert array_api_device(predict_proba_xp) == array_api_device(X_xp) + + predict_log_proba_xp = lr_xp.predict_log_proba(X_xp) + assert_allclose( + move_to(predict_log_proba_xp, xp=np, device="cpu"), + preditct_log_proba_np, + rtol=rtol, + atol=atol, + ) + assert predict_log_proba_xp.dtype == X_xp.dtype + assert array_api_device(predict_log_proba_xp) == array_api_device(X_xp) + + prediction_xp = lr_xp.predict(X_xp) + if not use_str_y: + prediction_xp = move_to(prediction_xp, xp=np, device="cpu") + assert_array_equal(prediction_xp, prediction_np) + + # TODO(1.10): remove when penalty is removed @pytest.mark.filterwarnings("ignore:'penalty' was deprecated") @pytest.mark.parametrize("penalty, l1_ratio", [("l1", 0.0), ("l2", 1.0)]) @@ -2723,3 +2848,53 @@ def test_lr_penalty_l1ratio_incompatible(penalty, l1_ratio): msg = f"Inconsistent values: penalty={penalty} with l1_ratio={l1_ratio}" with pytest.warns(UserWarning, match=msg): lr.fit(X, y) + + +# TODO(1.11): remove when default of scoring has changed +@pytest.mark.filterwarnings("ignore:.*default.*use_legacy_attributes.*:FutureWarning") +def test_lr_scoring_warns(): + """Check that scoring raises a warning.""" + X, y = make_classification(n_samples=20) + lr = LogisticRegressionCV(l1_ratios=[0]) + msg = "The default value of the parameter 'scoring' will change" + with pytest.warns(FutureWarning, match=msg): + lr.fit(X, y) + + +# TODO(1.11): remove test when default of scoring has changed +@pytest.mark.filterwarnings("ignore:The default value.*scoring.*:FutureWarning") +def test_get_default_scorer(): + """Test that LogisticRegressionCV gets correct default scorer.""" + lr = LogisticRegressionCV() + assert lr._get_scorer()._score_func.__name__ == "accuracy_score" + + +@pytest.mark.parametrize("binary", [True, False]) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning") +def test_logistic_regression_array_api_warm_start( + binary, + array_namespace, + device_name, + dtype_name, +): + """Test that warm_start=True works with array API inputs across + multiple fit calls for both binary and multiclass classification.""" + xp, device_ = _array_api_for_tests(array_namespace, device_name, dtype_name) + X_np = iris.data.astype(dtype_name, copy=True) + if binary: + y_np = (iris.target > 0).astype(dtype_name) + else: + y_np = iris.target.astype(dtype_name) + + X_xp = xp.asarray(X_np, device=device_) + y_xp = xp.asarray(y_np, device=device_) + + with config_context(array_api_dispatch=True): + lr = LogisticRegression(C=1e-2, solver="lbfgs", max_iter=300, warm_start=True) + lr.fit(X_xp, y_xp) + lr.predict(X_xp) + lr.fit(X_xp, y_xp) diff --git a/sklearn/linear_model/tests/test_ridge.py b/sklearn/linear_model/tests/test_ridge.py index de3d41ec18ee7..5ef8de3092d8f 100644 --- a/sklearn/linear_model/tests/test_ridge.py +++ b/sklearn/linear_model/tests/test_ridge.py @@ -44,9 +44,7 @@ from sklearn.utils._array_api import ( _NUMPY_NAMESPACE_NAMES, _atol_for_type, - _convert_to_numpy, - _get_namespace_device_dtype_ids, - _max_precision_float_dtype, + move_to, yield_namespace_device_dtype_combinations, yield_namespaces, ) @@ -665,8 +663,7 @@ def test_compute_gram(shape, uniform_weights, csr_container): true_gram = X_centered.dot(X_centered.T) X_sparse = csr_container(X * sqrt_sw[:, None]) gcv = _RidgeGCV(fit_intercept=True) - computed_gram, computed_mean = gcv._compute_gram(X_sparse, sqrt_sw) - assert_allclose(X_mean, computed_mean) + computed_gram = gcv._compute_gram(X_sparse, X_mean, sqrt_sw) assert_allclose(true_gram, computed_gram) @@ -686,8 +683,7 @@ def test_compute_covariance(shape, uniform_weights, csr_container): true_covariance = X_centered.T.dot(X_centered) X_sparse = csr_container(X * sqrt_sw[:, None]) gcv = _RidgeGCV(fit_intercept=True) - computed_cov, computed_mean = gcv._compute_covariance(X_sparse, sqrt_sw) - assert_allclose(X_mean, computed_mean) + computed_cov = gcv._compute_covariance(X_sparse, X_mean, sqrt_sw) assert_allclose(true_covariance, computed_cov) @@ -797,9 +793,32 @@ def test_solver_consistency( assert_allclose(ridge.intercept_, svd_ridge.intercept_, atol=1e-3, rtol=1e-3) +def test_ridge_gcv_integer_arrays(): + n_samples, n_features = 20, 10 + rng = np.random.RandomState(0) + X = rng.randint(0, 5, size=(n_samples, n_features)) + y = rng.randint(0, 5, size=(n_samples,)) + + X_float = X.astype(np.float64) + y_float = y.astype(np.float64) + + ridge_gcv = RidgeCV( + alphas=[0.1, 1.0, 10.0], scoring="neg_mean_squared_error", store_cv_results=True + ) + ridge_gcv.fit(X, y) + + ridge_gcv_float = clone(ridge_gcv) + ridge_gcv_float.fit(X_float, y_float) + + assert_allclose(ridge_gcv.coef_, ridge_gcv_float.coef_) + assert_allclose(ridge_gcv.cv_results_, ridge_gcv_float.cv_results_) + assert ridge_gcv.cv_results_.dtype == np.float64 + + @pytest.mark.parametrize("gcv_mode", ["svd", "eigen"]) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) @pytest.mark.parametrize("X_container", [np.asarray] + CSR_CONTAINERS) -@pytest.mark.parametrize("X_shape", [(11, 8), (11, 20)]) +@pytest.mark.parametrize("X_shape", [(11, 8), (11, 20)], ids=["tall", "wide"]) @pytest.mark.parametrize("fit_intercept", [True, False]) @pytest.mark.parametrize( "y_shape, noise", @@ -810,8 +829,10 @@ def test_solver_consistency( ], ) def test_ridge_gcv_vs_ridge_loo_cv( - gcv_mode, X_container, X_shape, y_shape, fit_intercept, noise + gcv_mode, dtype, X_container, X_shape, y_shape, fit_intercept, noise ): + if gcv_mode == "svd" and (X_container in CSR_CONTAINERS): + pytest.skip("`svd` mode not supported for sparse X.") n_samples, n_features = X_shape n_targets = y_shape[-1] if len(y_shape) == 2 else 1 X, y = _make_sparse_offset_regression( @@ -840,12 +861,153 @@ def test_ridge_gcv_vs_ridge_loo_cv( loo_ridge.fit(X, y) - X_gcv = X_container(X) - gcv_ridge.fit(X_gcv, y) + X = X_container(X) + X = X.astype(dtype) + y = y.astype(dtype) + gcv_ridge.fit(X, y) + atol = 1e-5 if dtype == np.float32 else 1e-10 assert gcv_ridge.alpha_ == pytest.approx(loo_ridge.alpha_) - assert_allclose(gcv_ridge.coef_, loo_ridge.coef_, rtol=1e-3) - assert_allclose(gcv_ridge.intercept_, loo_ridge.intercept_, rtol=1e-3) + assert_allclose(gcv_ridge.coef_, loo_ridge.coef_, atol=atol) + assert_allclose(gcv_ridge.intercept_, loo_ridge.intercept_, atol=atol) + + +def _ridge_regularization_limits(alpha, X, y, fit_intercept): + "Expected coef and intercept when alpha near 0 or inf" + if np.isclose(alpha, 0): + # Ridge should recover LinearRegression for near-zero alpha. + lin_reg = LinearRegression(fit_intercept=fit_intercept) + lin_reg.fit(X, y) + return lin_reg.coef_, lin_reg.intercept_ + else: + # Ridge should recover zero coefficients for near-infinite alpha. + n_features = X.shape[1] + return np.zeros(n_features), np.mean(y) if fit_intercept else 0.0 + + +@pytest.mark.parametrize("alpha", [1e-16, 1e16], ids=["zero_alpha", "inf_alpha"]) +@pytest.mark.parametrize("solver", ["svd", "cholesky", "lsqr", "sparse_cg"]) +@pytest.mark.parametrize("fit_intercept", [True, False]) +@pytest.mark.parametrize("X_shape", [(100, 50), (50, 100)], ids=["tall", "wide"]) +@pytest.mark.parametrize("X_container", [np.asarray] + CSR_CONTAINERS) +def test_regularization_limits_ridge( + alpha, solver, fit_intercept, X_shape, X_container +): + "Check regularization limits of Ridge (alpha near 0 or inf)" + sparse_X = X_container in CSR_CONTAINERS + if solver == "svd" and sparse_X: + pytest.skip("solver='svd' does not support sparse data") + if solver == "cholesky" and sparse_X and fit_intercept: + pytest.skip( + "solver='cholesky' does not support fitting the intercept on sparse data" + ) + n_samples, n_features = X_shape + X, y = make_regression( + n_samples=n_samples, n_features=n_features, noise=0, bias=10, random_state=42 + ) + expected_coef, expected_intercept = _ridge_regularization_limits( + alpha, X, y, fit_intercept + ) + X = X_container(X) + ridge = Ridge(alpha=alpha, solver=solver, fit_intercept=fit_intercept, tol=1e-12) + ridge.fit(X, y) + assert_allclose(ridge.coef_, expected_coef, atol=1e-10) + assert_allclose(ridge.intercept_, expected_intercept, atol=1e-10) + + +@pytest.mark.parametrize("alpha", [1e-16, 1e16], ids=["zero_alpha", "inf_alpha"]) +@pytest.mark.parametrize("gcv_mode", ["ignored"]) +@pytest.mark.parametrize("fit_intercept", [True, False]) +@pytest.mark.parametrize( + "X_shape", + [(100, 50), (50, 50), (50, 100)], + ids=["tall", "square", "wide"], +) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize("X_container", [np.asarray] + CSR_CONTAINERS) +def test_regularization_limits_ridge_classifier_gcv( + alpha, gcv_mode, fit_intercept, X_shape, dtype, X_container +): + "Check regularization limits of RidgeClassifierCV (alpha near 0 or inf)" + sparse_X = X_container in CSR_CONTAINERS + alphas = [alpha] + n_samples, n_features = X_shape + X, y = make_classification( + n_samples=n_samples, n_features=n_features, random_state=42 + ) + # RidgeClassifier is Ridge with y mapped to {-1, +1} + y = 2 * y - 1 + if np.isclose(alpha, 0): + # FIXME : test fails on square or tall X + if n_features < n_samples: + pytest.xfail( + "RidgeClassifierCV does not recover LinearRegression " + "on tall X in the small alpha limit" + ) + elif n_features == n_samples: + pytest.xfail( + "RidgeClassifierCV does not recover LinearRegression " + "on square X in the small alpha limit" + ) + expected_coef, expected_intercept = _ridge_regularization_limits( + alpha, X, y, fit_intercept + ) + X = X_container(X) + X = X.astype(dtype) + y = y.astype(dtype) + # FIXME : add `gcv_mode` parameter to RidgeClassifierCV + gcv_ridge = RidgeClassifierCV(alphas=alphas, fit_intercept=fit_intercept) + if gcv_mode == "svd" and sparse_X: + # TODO(1.11) should raises ValueError + expected_msg = "The 'svd' mode is not supported for sparse X" + with pytest.warns(FutureWarning, match=expected_msg): + gcv_ridge.fit(X, y) + else: + gcv_ridge.fit(X, y) + + atol = 1e-5 if dtype == np.float32 else 1e-10 + assert_allclose(gcv_ridge.coef_, expected_coef, atol=atol) + assert_allclose(gcv_ridge.intercept_, expected_intercept, atol=atol) + + +@pytest.mark.parametrize("alpha", [1e-16, 1e16], ids=["zero_alpha", "inf_alpha"]) +@pytest.mark.parametrize("gcv_mode", ["svd", "eigen"]) +@pytest.mark.parametrize("fit_intercept", [True, False]) +@pytest.mark.parametrize( + "X_shape", + [(100, 50), (50, 50), (50, 100)], + ids=["tall", "square", "wide"], +) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize("X_container", [np.asarray] + CSR_CONTAINERS) +def test_regularization_limits_ridge_gcv( + alpha, gcv_mode, fit_intercept, X_shape, dtype, X_container +): + "Check regularization limits of _RidgeGCV (alpha near 0 or inf)" + sparse_X = X_container in CSR_CONTAINERS + alphas = [alpha] + n_samples, n_features = X_shape + X, y = make_regression( + n_samples=n_samples, n_features=n_features, noise=0, bias=10, random_state=42 + ) + expected_coef, expected_intercept = _ridge_regularization_limits( + alpha, X, y, fit_intercept + ) + X = X_container(X) + X = X.astype(dtype) + y = y.astype(dtype) + gcv_ridge = RidgeCV(alphas=alphas, gcv_mode=gcv_mode, fit_intercept=fit_intercept) + if gcv_mode == "svd" and sparse_X: + # TODO(1.11) should raises ValueError + expected_msg = "The 'svd' mode is not supported for sparse X" + with pytest.warns(FutureWarning, match=expected_msg): + gcv_ridge.fit(X, y) + else: + gcv_ridge.fit(X, y) + + atol = 1e-5 if dtype == np.float32 else 1e-10 + assert_allclose(gcv_ridge.coef_, expected_coef, atol=atol) + assert_allclose(gcv_ridge.intercept_, expected_intercept, atol=atol) def test_ridge_loo_cv_asym_scoring(): @@ -895,6 +1057,8 @@ def test_ridge_loo_cv_asym_scoring(): def test_ridge_gcv_sample_weights( gcv_mode, X_container, fit_intercept, n_features, y_shape, noise ): + if gcv_mode == "svd" and (X_container in CSR_CONTAINERS): + pytest.skip("`svd` mode not supported for sparse X.") alphas = [1e-3, 0.1, 1.0, 10.0, 1e3] rng = np.random.RandomState(0) n_targets = y_shape[-1] if len(y_shape) == 2 else 1 @@ -956,22 +1120,29 @@ def test_ridge_gcv_sample_weights( @pytest.mark.parametrize("sparse_container", [None] + CSR_CONTAINERS) @pytest.mark.parametrize( - "mode, mode_n_greater_than_p, mode_p_greater_than_n", - [ - (None, "svd", "eigen"), - ("auto", "svd", "eigen"), - ("eigen", "eigen", "eigen"), - ("svd", "svd", "svd"), - ], + "X_shape", + [(5, 2), (5, 5), (2, 5)], + ids=["tall", "square", "wide"], ) -def test_check_gcv_mode_choice( - sparse_container, mode, mode_n_greater_than_p, mode_p_greater_than_n -): - X, _ = make_regression(n_samples=5, n_features=2) - if sparse_container is not None: +@pytest.mark.parametrize("gcv_mode", ["auto", "svd", "eigen"]) +def test_check_gcv_mode_choice(sparse_container, X_shape, gcv_mode): + n, p = X_shape + X, _ = make_regression(n_samples=n, n_features=p) + sparse_X = sparse_container is not None + if sparse_X: X = sparse_container(X) - assert _check_gcv_mode(X, mode) == mode_n_greater_than_p - assert _check_gcv_mode(X.T, mode) == mode_p_greater_than_n + eigen_mode = "gram" if n <= p else "cov" + + if gcv_mode == "svd" and not sparse_X: + assert _check_gcv_mode(X, gcv_mode) == "svd" + elif gcv_mode == "svd" and sparse_X: + # TODO(1.11) should raises ValueError + expected_msg = "The 'svd' mode is not supported for sparse X" + with pytest.warns(FutureWarning, match=expected_msg): + actual_gcv_mode = _check_gcv_mode(X, gcv_mode) + assert actual_gcv_mode == eigen_mode + else: + assert _check_gcv_mode(X, gcv_mode) == eigen_mode def _test_ridge_loo(sparse_container): @@ -1237,9 +1408,9 @@ def _test_tolerance(sparse_container): def check_array_api_attributes( - name, estimator, array_namespace, device, dtype_name, rtol=None + name, estimator, array_namespace, device_name, dtype_name, rtol=None ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X_iris_np = X_iris.astype(dtype_name) y_iris_np = y_iris.astype(dtype_name) @@ -1258,7 +1429,7 @@ def check_array_api_attributes( assert coef_xp.dtype == X_iris_xp.dtype assert_allclose( - _convert_to_numpy(coef_xp, xp=xp), + move_to(coef_xp, xp=np, device="cpu"), coef_np, rtol=rtol, atol=_atol_for_type(dtype_name), @@ -1268,7 +1439,7 @@ def check_array_api_attributes( assert intercept_xp.dtype == X_iris_xp.dtype assert_allclose( - _convert_to_numpy(intercept_xp, xp=xp), + move_to(intercept_xp, xp=np, device="cpu"), intercept_np, rtol=rtol, atol=_atol_for_type(dtype_name), @@ -1276,9 +1447,8 @@ def check_array_api_attributes( @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "check", @@ -1290,28 +1460,22 @@ def check_array_api_attributes( [ Ridge(solver="svd"), RidgeClassifier(solver="svd"), - RidgeCV(), + RidgeCV(gcv_mode="svd"), + RidgeCV(gcv_mode="eigen"), RidgeClassifierCV(), ], ids=_get_check_estimator_ids, ) def test_ridge_array_api_compliance( - estimator, check, array_namespace, device, dtype_name + estimator, check, array_namespace, device_name, dtype_name ): name = estimator.__class__.__name__ - tols = {} - xp = _array_api_for_tests(array_namespace, device) - if ( - "CV" in name - and check is check_array_api_attributes - and _max_precision_float_dtype(xp, device) == xp.float32 - ): - # RidgeGCV is not very numerically stable with float32. It casts the - # input to float64 unless the device and namespace combination does - # not allow float64 (specifically torch with mps) - tols["rtol"] = 1e-3 check( - name, estimator, array_namespace, device=device, dtype_name=dtype_name, **tols + name, + estimator, + array_namespace, + device_name=device_name, + dtype_name=dtype_name, ) @@ -1319,32 +1483,31 @@ def test_ridge_array_api_compliance( "estimator", [RidgeClassifier(solver="svd"), RidgeClassifierCV()] ) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_ridge_classifier_multilabel_array_api( - estimator, array_namespace, device_, dtype_name + estimator, array_namespace, device_name, dtype_name ): - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X, y = make_multilabel_classification(random_state=0) X_np = X.astype(dtype_name) y_np = y.astype(dtype_name) ridge_np = estimator.fit(X_np, y_np) pred_np = ridge_np.predict(X_np) with config_context(array_api_dispatch=True): - X_xp, y_xp = xp.asarray(X_np, device=device_), xp.asarray(y_np, device=device_) + X_xp, y_xp = xp.asarray(X_np, device=device), xp.asarray(y_np, device=device) ridge_xp = estimator.fit(X_xp, y_xp) pred_xp = ridge_xp.predict(X_xp) assert pred_xp.shape == pred_np.shape == y.shape - assert_allclose(pred_xp, pred_np) + assert_allclose(move_to(pred_xp, xp=np, device="cpu"), pred_np) @pytest.mark.parametrize( "array_namespace", yield_namespaces(include_numpy_namespaces=False) ) def test_array_api_error_and_warnings_for_solver_parameter(array_namespace): - xp = _array_api_for_tests(array_namespace, device=None) + xp, _ = _array_api_for_tests(array_namespace, device_name=None) X_iris_xp = xp.asarray(X_iris[:5]) y_iris_xp = xp.asarray(y_iris[:5]) @@ -1387,7 +1550,7 @@ def test_array_api_error_and_warnings_for_solver_parameter(array_namespace): @pytest.mark.parametrize("array_namespace", sorted(_NUMPY_NAMESPACE_NAMES)) def test_array_api_numpy_namespace_no_warning(array_namespace): - xp = _array_api_for_tests(array_namespace, device=None) + xp, _ = _array_api_for_tests(array_namespace, device_name=None) X_iris_xp = xp.asarray(X_iris[:5]) y_iris_xp = xp.asarray(y_iris[:5]) @@ -1900,6 +2063,7 @@ def test_ridge_regression_check_arguments_validity( return_intercept=return_intercept, positive=positive, tol=tol, + random_state=rng, ) return @@ -1912,6 +2076,7 @@ def test_ridge_regression_check_arguments_validity( positive=positive, return_intercept=return_intercept, tol=tol, + random_state=rng, ) if return_intercept: @@ -2295,10 +2460,16 @@ def test_ridge_sample_weight_consistency( assert_allclose(reg1.intercept_, reg2.intercept_) -@pytest.mark.parametrize("with_sample_weight", [False, True]) -@pytest.mark.parametrize("fit_intercept", [False, True]) +@pytest.mark.parametrize("X_shape", [(50, 10), (10, 50)], ids=["tall", "wide"]) +@pytest.mark.parametrize("with_sample_weight", [False, True], ids=["no_sw", "with_sw"]) +@pytest.mark.parametrize( + "fit_intercept", [False, True], ids=["no_intercept", "with_intercept"] +) +@pytest.mark.parametrize("gcv_mode", ["svd", "eigen"]) @pytest.mark.parametrize("n_targets", [1, 2]) -def test_ridge_cv_results_predictions(with_sample_weight, fit_intercept, n_targets): +def test_ridge_cv_results_predictions( + gcv_mode, X_shape, with_sample_weight, fit_intercept, n_targets +): """Check that the predictions stored in `cv_results_` are on the original scale. The GCV approach works on scaled data: centered by an offset and scaled by the @@ -2312,17 +2483,25 @@ def test_ridge_cv_results_predictions(with_sample_weight, fit_intercept, n_targe Non-regression test for: https://github.com/scikit-learn/scikit-learn/issues/13998 """ + n_samples, n_features = X_shape X, y = make_regression( - n_samples=100, n_features=10, n_targets=n_targets, random_state=0 + n_samples=n_samples, n_features=n_features, n_targets=n_targets, random_state=0 ) - sample_weight = np.ones(shape=(X.shape[0],)) if with_sample_weight: + sample_weight = np.ones(shape=(X.shape[0],)) sample_weight[::2] = 0.5 + else: + sample_weight = None - alphas = (0.1, 1.0, 10.0) + # TODO: widening the range of alphas causes failures in the test, in + # particular for wide datasets. Not sure if this is an intrinsic limitation + # of the underlying linear algebra or if this points to a numerical issue + # in RidgeCV or in Ridge(solver="svd"). + alphas = np.logspace(-5, 7, 5) # scoring should be set to store predictions and not the squared error ridge_cv = RidgeCV( + gcv_mode=gcv_mode, alphas=alphas, scoring="neg_mean_squared_error", fit_intercept=fit_intercept, @@ -2335,23 +2514,39 @@ def test_ridge_cv_results_predictions(with_sample_weight, fit_intercept, n_targe cv = LeaveOneOut() for alpha_idx, alpha in enumerate(alphas): for idx, (train_idx, test_idx) in enumerate(cv.split(X, y)): - ridge = Ridge(alpha=alpha, fit_intercept=fit_intercept) - ridge.fit(X[train_idx], y[train_idx], sample_weight[train_idx]) + ridge = Ridge(alpha=alpha, fit_intercept=fit_intercept, solver="svd") + if with_sample_weight: + ridge.fit( + X[train_idx], y[train_idx], sample_weight=sample_weight[train_idx] + ) + else: + ridge.fit(X[train_idx], y[train_idx]) predictions[idx, ..., alpha_idx] = ridge.predict(X[test_idx]) - assert_allclose(ridge_cv.cv_results_, predictions) + # A few cases are just above the rtol=1e-7 threshold + assert_allclose(ridge_cv.cv_results_, predictions, rtol=1e-6) -def test_ridge_cv_multioutput_sample_weight(global_random_seed): +@pytest.mark.parametrize("gcv_mode", ["svd", "eigen"]) +@pytest.mark.parametrize("X_shape", [(50, 10), (10, 50)], ids=["tall", "wide"]) +def test_ridge_cv_multioutput_sample_weight(gcv_mode, X_shape, global_random_seed): """Check that `RidgeCV` works properly with multioutput and sample_weight when `scoring != None`. We check the error reported by the RidgeCV is close to a naive LOO-CV using a Ridge estimator. """ - X, y = make_regression(n_targets=2, random_state=global_random_seed) - sample_weight = np.ones(shape=(X.shape[0],)) + n_samples, n_features = X_shape + X, y = make_regression( + n_samples=n_samples, + n_features=n_features, + n_targets=2, + random_state=global_random_seed, + ) + sample_weight = np.ones(n_samples) - ridge_cv = RidgeCV(scoring="neg_mean_squared_error", store_cv_results=True) + ridge_cv = RidgeCV( + gcv_mode=gcv_mode, scoring="neg_mean_squared_error", store_cv_results=True + ) ridge_cv.fit(X, y, sample_weight=sample_weight) cv = LeaveOneOut() @@ -2367,9 +2562,13 @@ def test_ridge_cv_multioutput_sample_weight(global_random_seed): assert_allclose(ridge_cv.best_score_, -mean_squared_error(y, y_pred_loo)) -def test_ridge_cv_custom_multioutput_scorer(): +@pytest.mark.parametrize("X_shape", [(50, 10), (10, 50)], ids=["tall", "wide"]) +def test_ridge_cv_custom_multioutput_scorer(X_shape): """Check that `RidgeCV` works properly with a custom multioutput scorer.""" - X, y = make_regression(n_targets=2, random_state=0) + n_samples, n_features = X_shape + X, y = make_regression( + n_samples=n_samples, n_features=n_features, n_targets=2, random_state=0 + ) def custom_error(y_true, y_pred): errors = (y_true - y_pred) ** 2 diff --git a/sklearn/linear_model/tests/test_sag.py b/sklearn/linear_model/tests/test_sag.py index 575838f8e8497..f6b0405c23168 100644 --- a/sklearn/linear_model/tests/test_sag.py +++ b/sklearn/linear_model/tests/test_sag.py @@ -577,7 +577,13 @@ def test_sag_regressor(seed, csr_container): # simple linear function with noise y = 0.5 * X.ravel() + rng.randn(n_samples, 1).ravel() - clf1 = Ridge(tol=tol, solver="sag", max_iter=max_iter, alpha=alpha * n_samples) + clf1 = Ridge( + tol=tol, + solver="sag", + max_iter=max_iter, + alpha=alpha * n_samples, + random_state=rng, + ) clf2 = clone(clf1) clf1.fit(X, y) clf2.fit(csr_container(X), y) diff --git a/sklearn/linear_model/tests/test_sgd.py b/sklearn/linear_model/tests/test_sgd.py index 87284f117d0e4..f69fd22d5cfc7 100644 --- a/sklearn/linear_model/tests/test_sgd.py +++ b/sklearn/linear_model/tests/test_sgd.py @@ -6,9 +6,11 @@ import numpy as np import pytest import scipy.sparse as sp +from scipy.optimize import minimize from sklearn import datasets, linear_model, metrics from sklearn.base import clone, is_classifier +from sklearn.datasets import make_blobs from sklearn.exceptions import ConvergenceWarning from sklearn.kernel_approximation import Nystroem from sklearn.linear_model import _sgd_fast as sgd_fast @@ -22,12 +24,14 @@ from sklearn.preprocessing import LabelEncoder, MinMaxScaler, StandardScaler, scale from sklearn.svm import OneClassSVM from sklearn.utils import get_tags +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils._testing import ( assert_allclose, assert_almost_equal, assert_array_almost_equal, assert_array_equal, ) +from sklearn.utils.fixes import _sparse_random_array def _update_kwargs(kwargs): @@ -42,48 +46,49 @@ def _update_kwargs(kwargs): class _SparseSGDClassifier(linear_model.SGDClassifier): def fit(self, X, y, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return super().fit(X, y, *args, **kw) def partial_fit(self, X, y, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return super().partial_fit(X, y, *args, **kw) def decision_function(self, X): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return super().decision_function(X) def predict_proba(self, X): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return super().predict_proba(X) class _SparseSGDRegressor(linear_model.SGDRegressor): def fit(self, X, y, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return linear_model.SGDRegressor.fit(self, X, y, *args, **kw) def partial_fit(self, X, y, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return linear_model.SGDRegressor.partial_fit(self, X, y, *args, **kw) def decision_function(self, X, *args, **kw): # XXX untested as of v0.22 - X = sp.csr_matrix(X) - return linear_model.SGDRegressor.decision_function(self, X, *args, **kw) + return linear_model.SGDRegressor.decision_function( + self, _align_api_if_sparse(X), *args, **kw + ) class _SparseSGDOneClassSVM(linear_model.SGDOneClassSVM): def fit(self, X, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return linear_model.SGDOneClassSVM.fit(self, X, *args, **kw) def partial_fit(self, X, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return linear_model.SGDOneClassSVM.partial_fit(self, X, *args, **kw) def decision_function(self, X, *args, **kw): - X = sp.csr_matrix(X) + X = _align_api_if_sparse(sp.csr_array(X)) return linear_model.SGDOneClassSVM.decision_function(self, X, *args, **kw) @@ -879,6 +884,7 @@ def test_sgd_proba(klass): assert_array_almost_equal(p[0], [1 / 3.0] * 3) +@pytest.mark.no_check_spmatrix # pickle breaks check_spmatrix @pytest.mark.parametrize("klass", [SGDClassifier, SparseSGDClassifier]) def test_sgd_l1(klass): # Test L1 regularization @@ -1006,7 +1012,7 @@ def test_balanced_weight(klass): # to use "balanced" assert_array_almost_equal(clf.coef_, clf_balanced.coef_, 6) - # build an very very imbalanced dataset out of iris data + # build a very very imbalanced dataset out of iris data X_0 = X[y == 0, :] y_0 = y[y == 0] @@ -1496,7 +1502,7 @@ def asgd_oneclass(klass, X, eta, nu, coef_init=None, offset_init=0.0): gradient = -1 else: gradient = 0 - coef *= max(0, 1.0 - (eta * nu / 2)) + coef *= max(0, 1.0 - eta * nu) coef += -(eta * gradient * entry) intercept += -(eta * (nu + gradient)) * decay @@ -1708,28 +1714,6 @@ def test_average_sparse_oneclass(klass): assert_allclose(clf.offset_, average_offset) -def test_sgd_oneclass(): - # Test fit, decision_function, predict and score_samples on a toy - # dataset - X_train = np.array([[-2, -1], [-1, -1], [1, 1]]) - X_test = np.array([[0.5, -2], [2, 2]]) - clf = SGDOneClassSVM( - nu=0.5, eta0=1, learning_rate="constant", shuffle=False, max_iter=1 - ) - clf.fit(X_train) - assert_allclose(clf.coef_, np.array([-0.125, 0.4375])) - assert clf.offset_[0] == -0.5 - - scores = clf.score_samples(X_test) - assert_allclose(scores, np.array([-0.9375, 0.625])) - - dec = clf.score_samples(X_test) - clf.offset_ - assert_allclose(clf.decision_function(X_test), dec) - - pred = clf.predict(X_test) - assert_array_equal(pred, np.array([-1, 1])) - - def test_ocsvm_vs_sgdocsvm(): # Checks SGDOneClass SVM gives a good approximation of kernelized # One-Class SVM @@ -1785,12 +1769,13 @@ def test_sgd_oneclass_convergence(): assert model.n_iter_ > 6 -def test_sgd_oneclass_vs_linear_oneclass(): +@pytest.mark.parametrize("eta0, max_iter", [(1e-3, 10000), (3e-4, 20000)]) +def test_sgd_oneclass_vs_linear_oneclass(eta0, max_iter): # Test convergence vs. liblinear `OneClassSVM` with kernel="linear" for nu in [0.1, 0.5, 0.9]: # allow enough iterations, small dataset model = SGDOneClassSVM( - nu=nu, max_iter=20000, tol=None, learning_rate="constant", eta0=1e-3 + nu=nu, max_iter=max_iter, tol=None, learning_rate="constant", eta0=eta0 ) model_ref = OneClassSVM(kernel="linear", nu=nu, tol=1e-6) # reference model model.fit(iris.data) @@ -1815,7 +1800,30 @@ def test_sgd_oneclass_vs_linear_oneclass(): assert dec_fn_corr > 0.99 assert preds_corr > 0.95 assert coef_corr > 0.99 - assert_allclose(1 - share_ones, nu) + assert_allclose(1 - share_ones, nu, atol=1e-2) + + +@pytest.mark.parametrize("nu", [0.1, 0.9]) +def test_sgd_oneclass_vs_linear_oneclass_offsets_match(nu): + """Test that the `offset_` of `SGDOneClassSVM` is close to the `offset_` + of `OneClassSVM` with `kernel="linear"`, given enough iterations and a + suitable value for the `eta0` parameter, while also ensuring that the + dataset is scaled. + """ + X = iris.data + X_scaled = StandardScaler().fit_transform(X) + model = SGDOneClassSVM( + nu=nu, + max_iter=40000, + tol=None, + learning_rate="optimal", + eta0=1e-6, + random_state=42, + ) + model_ref = OneClassSVM(kernel="linear", nu=nu, tol=5e-6) + model.fit(X_scaled) + model_ref.fit(X_scaled) + assert_allclose(model.offset_, model_ref.offset_, atol=1.3e-6) def test_l1_ratio(): @@ -1853,7 +1861,7 @@ def test_l1_ratio(): assert_array_almost_equal(est_en.coef_, est_l2.coef_) -def test_underflow_or_overlow(): +def test_underflow_or_overflow(): with np.errstate(all="raise"): # Generate some weird data with hugely unscaled features rng = np.random.RandomState(0) @@ -2125,14 +2133,14 @@ def test_SGDClassifier_fit_for_all_backends(backend): # Create a classification problem with 50000 features and 20 classes. Using # loky or multiprocessing this make the clf.coef_ exceed the threshold # above which memmaping is used in joblib and loky (1MB as of 2018/11/1). - X = sp.random(500, 2000, density=0.02, format="csr", random_state=random_state) + X = _sparse_random_array((500, 2000), density=0.02, format="csr", rng=random_state) y = random_state.choice(20, 500) - # Begin by fitting a SGD classifier sequentially + # Begin by fitting an SGD classifier sequentially clf_sequential = SGDClassifier(max_iter=1000, n_jobs=1, random_state=42) clf_sequential.fit(X, y) - # Fit a SGDClassifier using the specified backend, and make sure the + # Fit an SGDClassifier using the specified backend, and make sure the # coefficients are equal to those obtained using a sequential fit clf_parallel = SGDClassifier(max_iter=1000, n_jobs=4, random_state=42) with joblib.parallel_backend(backend=backend): @@ -2265,10 +2273,10 @@ def test_sgd_numerical_consistency(SGDEstimator): X_32 = X.astype(dtype=np.float32) Y_32 = np.array(Y, dtype=np.float32) - sgd_64 = SGDEstimator(max_iter=20) + sgd_64 = SGDEstimator(max_iter=22, shuffle=False) sgd_64.fit(X_64, Y_64) - sgd_32 = SGDEstimator(max_iter=20) + sgd_32 = SGDEstimator(max_iter=22, shuffle=False) sgd_32.fit(X_32, Y_32) assert_allclose(sgd_64.coef_, sgd_32.coef_) @@ -2281,3 +2289,52 @@ def test_sgd_one_class_svm_estimator_type(): """ sgd_ocsvm = SGDOneClassSVM() assert get_tags(sgd_ocsvm).estimator_type == "outlier_detector" + + +def test_sgd_one_class_svm_formulation_with_scipy_minimize(): + """Test that SGDOneClassSVM minimizes the correct objective function.""" + nu = 0.5 + hinge_threshold = 1.0 + n_samples, n_features = 300, 3 + random_seed = 42 + + def objective(w, X, y, alpha): + weights = w[:-1] + intercept = w[-1] + p = X @ weights + intercept + z = p * y + avg_loss = np.mean(np.maximum(hinge_threshold - z, 0.0)) + reg = 0.5 * alpha * weights @ weights + obj = avg_loss + reg + intercept * alpha + return obj + + X, _ = make_blobs( + n_samples=n_samples, + n_features=n_features, + random_state=random_seed, + ) + y = np.ones(n_samples, dtype=X.dtype) + w0 = np.zeros(n_features + 1) + scipy_output = minimize( + objective, + w0, + method="Nelder-Mead", + args=(X, y, nu), + options={"maxiter": 1000}, + ) + w_out = scipy_output.x + expected_coef = w_out[:-1] + expected_offset = 1 - w_out[-1] + + model = SGDOneClassSVM( + nu=nu, + learning_rate="constant", + max_iter=4000, + tol=None, + eta0=1e-4, + random_state=random_seed, + ) + model.fit(X, y) + + assert_allclose(model.coef_, expected_coef, rtol=5e-3) + assert_allclose(model.offset_, expected_offset, rtol=1e-2) diff --git a/sklearn/linear_model/tests/test_sparse_coordinate_descent.py b/sklearn/linear_model/tests/test_sparse_coordinate_descent.py index d7d85763f8a86..0e34e8b2db4c3 100644 --- a/sklearn/linear_model/tests/test_sparse_coordinate_descent.py +++ b/sklearn/linear_model/tests/test_sparse_coordinate_descent.py @@ -12,7 +12,12 @@ create_memmap_backed_data, ignore_warnings, ) -from sklearn.utils.fixes import COO_CONTAINERS, CSC_CONTAINERS, LIL_CONTAINERS +from sklearn.utils.fixes import ( + COO_CONTAINERS, + CSC_CONTAINERS, + LIL_CONTAINERS, + _sparse_random_array, +) def test_sparse_coef(): @@ -271,11 +276,18 @@ def test_path_parameters(csc_container): @pytest.mark.parametrize("Model", [Lasso, ElasticNet, LassoCV, ElasticNetCV]) @pytest.mark.parametrize("fit_intercept", [False, True]) +@pytest.mark.parametrize("l1_ratio", [0.5, 0]) @pytest.mark.parametrize("n_samples, n_features", [(24, 6), (6, 24)]) @pytest.mark.parametrize("with_sample_weight", [True, False]) @pytest.mark.parametrize("csc_container", CSC_CONTAINERS) def test_sparse_dense_equality( - Model, fit_intercept, n_samples, n_features, with_sample_weight, csc_container + Model, + fit_intercept, + l1_ratio, + n_samples, + n_features, + with_sample_weight, + csc_container, ): X, y = make_regression( n_samples=n_samples, @@ -292,6 +304,11 @@ def test_sparse_dense_equality( sw = None Xs = csc_container(X) params = {"fit_intercept": fit_intercept, "tol": 1e-6} + if Model != ElasticNet: + if l1_ratio == 0: + return + else: + params["l1_ratio"] = l1_ratio reg_dense = Model(**params).fit(X, y, sample_weight=sw) reg_sparse = Model(**params).fit(Xs, y, sample_weight=sw) if fit_intercept: @@ -378,7 +395,7 @@ def test_sparse_read_only_buffer(copy_X): rng = np.random.RandomState(0) clf = ElasticNet(alpha=0.1, copy_X=copy_X, random_state=rng) - X = sp.random(100, 20, format="csc", random_state=rng) + X = _sparse_random_array((100, 20), format="csc", rng=rng) # Make X.data read-only X.data = create_memmap_backed_data(X.data) diff --git a/sklearn/manifold/_isomap.py b/sklearn/manifold/_isomap.py index 07ef626ab8101..727a163fb6292 100644 --- a/sklearn/manifold/_isomap.py +++ b/sklearn/manifold/_isomap.py @@ -21,6 +21,7 @@ from sklearn.neighbors import NearestNeighbors, kneighbors_graph, radius_neighbors_graph from sklearn.preprocessing import KernelCenterer from sklearn.utils._param_validation import Interval, StrOptions +from sklearn.utils.fixes import _ensure_sparse_index_int32 from sklearn.utils.graph import _fix_connected_components from sklearn.utils.validation import check_is_fitted @@ -297,6 +298,7 @@ def _fit_transform(self, X): **self.nbrs_.effective_metric_params_, ) + _ensure_sparse_index_int32(nbg) self.dist_matrix_ = shortest_path(nbg, method=self.path_method, directed=False) if self.nbrs_._fit_X.dtype == np.float32: diff --git a/sklearn/manifold/_locally_linear.py b/sklearn/manifold/_locally_linear.py index 02b5257f0244a..d7b33fcff8fa5 100644 --- a/sklearn/manifold/_locally_linear.py +++ b/sklearn/manifold/_locally_linear.py @@ -7,7 +7,7 @@ import numpy as np from scipy.linalg import eigh, qr, solve, svd -from scipy.sparse import csr_matrix, eye, lil_matrix +from scipy.sparse import csr_array, lil_array from scipy.sparse.linalg import eigsh from sklearn.base import ( @@ -21,6 +21,8 @@ from sklearn.utils import check_array, check_random_state from sklearn.utils._arpack import _init_arpack_v0 from sklearn.utils._param_validation import Interval, StrOptions, validate_params +from sklearn.utils._sparse import _align_api_if_sparse +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_15, _sparse_eye_array from sklearn.utils.validation import FLOAT_DTYPES, check_is_fitted, validate_data @@ -117,7 +119,8 @@ def barycenter_kneighbors_graph(X, n_neighbors, reg=1e-3, n_jobs=None): ind = knn.kneighbors(X, return_distance=False)[:, 1:] data = barycenter_weights(X, X, ind, reg=reg) indptr = np.arange(0, n_samples * n_neighbors + 1, n_neighbors) - return csr_matrix((data.ravel(), ind.ravel(), indptr), shape=(n_samples, n_samples)) + csr = csr_array((data.ravel(), ind.ravel(), indptr), shape=(n_samples, n_samples)) + return _align_api_if_sparse(csr) def null_space( @@ -228,7 +231,7 @@ def _locally_linear_embedding( ) M_sparse = eigen_solver != "dense" - M_container_constructor = lil_matrix if M_sparse else np.zeros + M_container_constructor = lil_array if M_sparse else np.zeros if method == "standard": W = barycenter_kneighbors_graph( @@ -238,8 +241,8 @@ def _locally_linear_embedding( # we'll compute M = (I-W)'(I-W) # depending on the solver, we'll do this differently if M_sparse: - M = eye(*W.shape, format=W.format) - W - M = M.T @ M + M = _sparse_eye_array(*W.shape, format=W.format, dtype=W.dtype) - W + M = M.T @ M # M = (I - W)' (I - W) = W' W - W' - W + I else: M = (W.T @ W - W.T - W).toarray() M.flat[:: M.shape[0] + 1] += 1 # M = W' W - W' - W + I @@ -394,8 +397,12 @@ def _locally_linear_embedding( nbrs_x, nbrs_y = np.meshgrid(neighbors[i], neighbors[i]) M[nbrs_x, nbrs_y] += np.dot(Wi, Wi.T) Wi_sum1 = Wi.sum(1) - M[i, neighbors[i]] -= Wi_sum1 - M[neighbors[i], [i]] -= Wi_sum1 + if SCIPY_VERSION_BELOW_1_15: + M[[i], neighbors[i]] -= Wi_sum1 + M[neighbors[i], [i]] -= Wi_sum1 + else: + M[i, neighbors[i]] -= Wi_sum1 + M[neighbors[i], i] -= Wi_sum1 M[i, i] += s_i elif method == "ltsa": @@ -431,7 +438,7 @@ def _locally_linear_embedding( M[neighbors[i], neighbors[i]] += np.ones(shape=n_neighbors) if M_sparse: - M = M.tocsr() + M = _align_api_if_sparse(M.tocsr()) return null_space( M, diff --git a/sklearn/manifold/_mds.py b/sklearn/manifold/_mds.py index 0946d4dec0a67..a5d3ac81c1d55 100644 --- a/sklearn/manifold/_mds.py +++ b/sklearn/manifold/_mds.py @@ -195,14 +195,13 @@ def _smacof_single( return X, stress, it + 1 -# TODO(1.9): change default `n_init` to 1, see PR #31117 @validate_params( { "dissimilarities": ["array-like"], "metric": ["boolean"], "n_components": [Interval(Integral, 1, None, closed="left")], "init": ["array-like", None], - "n_init": [Interval(Integral, 1, None, closed="left"), StrOptions({"warn"})], + "n_init": [Interval(Integral, 1, None, closed="left")], "n_jobs": [Integral, None], "max_iter": [Interval(Integral, 1, None, closed="left")], "verbose": ["verbose"], @@ -219,7 +218,7 @@ def smacof( metric=True, n_components=2, init=None, - n_init="warn", + n_init=1, n_jobs=None, max_iter=300, verbose=0, @@ -268,14 +267,14 @@ def smacof( Starting configuration of the embedding to initialize the algorithm. By default, the algorithm is initialized with a randomly chosen array. - n_init : int, default=8 + n_init : int, default=1 Number of times the SMACOF algorithm will be run with different initializations. The final results will be the best output of the runs, determined by the run with the smallest final stress. If ``init`` is provided, this option is overridden and a single run is performed. .. versionchanged:: 1.9 - The default value for `n_iter` will change from 8 to 1 in version 1.9. + The default value for `n_iter` changed from 8 to 1. n_jobs : int, default=None The number of jobs to use for the computation. If multiple @@ -364,13 +363,6 @@ def smacof( 3.2e-05 """ - if n_init == "warn": - warnings.warn( - "The default value of `n_init` will change from 8 to 1 in 1.9.", - FutureWarning, - ) - n_init = 8 - dissimilarities = check_array(dissimilarities) random_state = check_random_state(random_state) @@ -433,7 +425,6 @@ def smacof( return best_pos, best_stress -# TODO(1.9): change default `n_init` to 1, see PR #31117 # TODO(1.10): change default `init` to "classical_mds", see PR #32229 # TODO(1.10): drop support for boolean `metric`, see PR #32229 # TODO(1.10): drop support for `dissimilarity`, see PR #32229 @@ -455,13 +446,13 @@ class MDS(BaseEstimator): .. versionchanged:: 1.8 The parameter `metric` was renamed into `metric_mds`. - n_init : int, default=4 + n_init : int, default=1 Number of times the SMACOF algorithm will be run with different initializations. The final results will be the best output of the runs, determined by the run with the smallest final stress. .. versionchanged:: 1.9 - The default value for `n_init` will change from 4 to 1 in version 1.9. + The default value for `n_init` changed from 4 to 1. init : {'random', 'classical_mds'}, default='random' The initialization approach. If `random`, random initialization is used. @@ -654,7 +645,7 @@ def __init__( n_components=2, *, metric_mds=True, - n_init="warn", + n_init=1, init="warn", max_iter=300, verbose=0, @@ -740,16 +731,6 @@ def fit_transform(self, X, y=None, init=None): X transformed in the new space. """ - if self.n_init == "warn": - warnings.warn( - "The default value of `n_init` will change from 4 to 1 in 1.9. " - "To suppress this warning, provide some value of `n_init`.", - FutureWarning, - ) - self._n_init = 4 - else: - self._n_init = self.n_init - if self.init == "warn": warnings.warn( "The default value of `init` will change from 'random' to " @@ -813,7 +794,7 @@ def fit_transform(self, X, y=None, init=None): if init is not None: init_array = init elif self._init == "classical_mds": - cmds = ClassicalMDS(metric="precomputed") + cmds = ClassicalMDS(metric="precomputed", n_components=self.n_components) init_array = cmds.fit_transform(self.dissimilarity_matrix_) else: init_array = None @@ -823,7 +804,7 @@ def fit_transform(self, X, y=None, init=None): metric=self._metric_mds, n_components=self.n_components, init=init_array, - n_init=self._n_init, + n_init=self.n_init, n_jobs=self.n_jobs, max_iter=self.max_iter, verbose=self.verbose, diff --git a/sklearn/manifold/_spectral_embedding.py b/sklearn/manifold/_spectral_embedding.py index 39310232269e8..ef8e3c6b1bd94 100644 --- a/sklearn/manifold/_spectral_embedding.py +++ b/sklearn/manifold/_spectral_embedding.py @@ -19,8 +19,8 @@ from sklearn.utils._arpack import _init_arpack_v0 from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.extmath import _deterministic_vector_sign_flip +from sklearn.utils.fixes import _sparse_eye_array, parse_version, sp_version from sklearn.utils.fixes import laplacian as csgraph_laplacian -from sklearn.utils.fixes import parse_version, sp_version from sklearn.utils.validation import validate_data @@ -306,11 +306,12 @@ def _spectral_embedding( if eigen_solver == "amg": try: - from pyamg import smoothed_aggregation_solver + from pyamg import aggregation, smoothed_aggregation_solver except ImportError as e: raise ValueError( "The eigen_solver was set to 'amg', but pyamg is not available." ) from e + pyamg_supports_sparray = hasattr(aggregation.aggregation, "csr_array") if eigen_solver is None: eigen_solver = "arpack" @@ -328,60 +329,70 @@ def _spectral_embedding( laplacian, dd = csgraph_laplacian( adjacency, normed=norm_laplacian, return_diag=True ) - if eigen_solver == "arpack" or ( - eigen_solver != "lobpcg" - and (not sparse.issparse(laplacian) or n_nodes < 5 * n_components) - ): - # lobpcg used with eigen_solver='amg' has bugs for low number of nodes + + if eigen_solver == "amg" and n_nodes < 5 * n_components: + # LOBPCG used with eigen_solver='amg' has bugs for low number of nodes # for details see the source code in scipy: # https://github.com/scipy/scipy/blob/v0.11.0/scipy/sparse/linalg/eigen # /lobpcg/lobpcg.py#L237 # or matlab: # https://www.mathworks.com/matlabcentral/fileexchange/48-lobpcg-m + warnings.warn( + "AMG solver does not work well with small graphs, using ARPACK instead.", + RuntimeWarning, + ) + eigen_solver = "arpack" + + if eigen_solver == "amg" and not sparse.issparse(laplacian): + warnings.warn( + "AMG solver does not work well with dense matrices, using ARPACK instead.", + RuntimeWarning, + ) + eigen_solver = "arpack" + + if eigen_solver == "arpack": laplacian = _set_diag(laplacian, 1, norm_laplacian) # Here we'll use shift-invert mode for fast eigenvalues - # (see https://docs.scipy.org/doc/scipy/reference/tutorial/arpack.html - # for a short explanation of what this means) - # Because the normalized Laplacian has eigenvalues between 0 and 2, - # I - L has eigenvalues between -1 and 1. ARPACK is most efficient - # when finding eigenvalues of largest magnitude (keyword which='LM') - # and when these eigenvalues are very large compared to the rest. - # For very large, very sparse graphs, I - L can have many, many - # eigenvalues very near 1.0. This leads to slow convergence. So - # instead, we'll use ARPACK's shift-invert mode, asking for the - # eigenvalues near 1.0. This effectively spreads-out the spectrum - # near 1.0 and leads to much faster convergence: potentially an - # orders-of-magnitude speedup over simply using keyword which='LA' - # in standard mode. + # (see https://docs.scipy.org/doc/scipy/tutorial/arpack.html + # for a short explanation of what this means) + # Laplacian (normalized or not) has non-negative eigenvalues + # and we need to find the smallest ones, i.e. closest to 0. + # The efficient way to do it, according to the scipy docs, + # is to use which="LM" and sigma=0. + # Andrew Kniazev recommends to set small negative sigma + # (see https://github.com/scikit-learn/scikit-learn/ + # pull/14647#issuecomment-521304431) because a Laplacian + # has exact at least one exact zero eigenvalue, so sigma=0 + # can lead to problems. try: - # We are computing the opposite of the laplacian inplace so as - # to spare a memory allocation of a possibly very large array tol = 0 if eigen_tol == "auto" else eigen_tol - laplacian *= -1 + v0 = _init_arpack_v0(laplacian.shape[0], random_state) laplacian = check_array( laplacian, accept_sparse="csr", accept_large_sparse=False ) _, diffusion_map = eigsh( - laplacian, k=n_components, sigma=1.0, which="LM", tol=tol, v0=v0 + laplacian, k=n_components, sigma=-1e-5, which="LM", tol=tol, v0=v0 ) - embedding = diffusion_map.T[n_components::-1] + embedding = diffusion_map.T[:n_components] if norm_laplacian: # recover u = D^-1/2 x from the eigenvector output x embedding = embedding / dd - except RuntimeError: - # When submatrices are exactly singular, an LU decomposition - # in arpack fails. We fallback to lobpcg + except RuntimeError: # pragma: no cover + # When submatrices are exactly singular, the LU decomposition + # in ARPACK can fail. In this case, we fallback to LOBPCG. + # Note: this should actually never happen with sigma < 0, + # so the entire `try ... except` structure could be removed. + # There is no unit test for this (hence `pragma: no cover`) + # because it is unclear how to trigger this RuntimeError. + # (https://github.com/scikit-learn/scikit-learn/pull/33262) + warnings.warn("ARPACK has failed, falling back to LOBPCG.", RuntimeWarning) eigen_solver = "lobpcg" - # Revert the laplacian to its opposite to have lobpcg work - laplacian *= -1 elif eigen_solver == "amg": # Use AMG to get a preconditioner and speed up the eigenvalue # problem. - if not sparse.issparse(laplacian): - warnings.warn("AMG works better for sparse matrices") laplacian = check_array( laplacian, dtype=[np.float64, np.float32], accept_sparse=True ) @@ -396,12 +407,16 @@ def _spectral_embedding( # Shift the Laplacian so its diagononal is not all ones. The shift # does change the eigenpairs however, so we'll feed the shifted # matrix to the solver and afterward set it back to the original. - diag_shift = 1e-5 * sparse.eye(laplacian.shape[0]) + diag_shift = 1e-5 * _sparse_eye_array(laplacian.shape[0]) laplacian += diag_shift if hasattr(sparse, "csr_array") and isinstance(laplacian, sparse.csr_array): - # `pyamg` does not work with `csr_array` and we need to convert it to a - # `csr_matrix` object. - laplacian = sparse.csr_matrix(laplacian) + # old version `pyamg` may not work with `csr_array` and new version + # may not work with `csr_matrix`. But we need to convert to CSR. + if pyamg_supports_sparray: + laplacian = sparse.csr_array(laplacian) + else: + laplacian = sparse.csr_matrix(laplacian) + ml = smoothed_aggregation_solver(check_array(laplacian, accept_sparse="csr")) laplacian -= diag_shift @@ -425,9 +440,8 @@ def _spectral_embedding( laplacian, dtype=[np.float64, np.float32], accept_sparse=True ) if n_nodes < 5 * n_components + 1: - # see note above under arpack why lobpcg has problems with small - # number of nodes - # lobpcg will fallback to eigh, so we short circuit it + # See note above why lobpcg has problems with small number of nodes. + # lobpcg will fallback to eigh, so we short-circuit it if sparse.issparse(laplacian): laplacian = laplacian.toarray() _, diffusion_map = eigh(laplacian, check_finite=False) diff --git a/sklearn/manifold/_t_sne.py b/sklearn/manifold/_t_sne.py index 2527fbc0959fb..ccab7aad234f6 100644 --- a/sklearn/manifold/_t_sne.py +++ b/sklearn/manifold/_t_sne.py @@ -11,7 +11,7 @@ import numpy as np from scipy import linalg -from scipy.sparse import csr_matrix, issparse +from scipy.sparse import csr_array, issparse from scipy.spatial.distance import pdist, squareform from sklearn.base import ( @@ -27,7 +27,7 @@ from sklearn.manifold import _barnes_hut_tsne, _utils # type: ignore[attr-defined] from sklearn.metrics.pairwise import _VALID_METRICS, pairwise_distances from sklearn.neighbors import NearestNeighbors -from sklearn.utils import check_random_state +from sklearn.utils import _align_api_if_sparse, check_random_state from sklearn.utils._openmp_helpers import _openmp_effective_n_threads from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.validation import _num_samples, check_non_negative, validate_data @@ -108,7 +108,7 @@ def _joint_probabilities_nn(distances, desired_perplexity, verbose): assert np.all(np.isfinite(conditional_P)), "All probabilities should be finite" # Symmetrize the joint probability distribution using sparse operations - P = csr_matrix( + P = csr_array( (conditional_P.ravel(), distances.indices, distances.indptr), shape=(n_samples, n_samples), ) @@ -122,7 +122,7 @@ def _joint_probabilities_nn(distances, desired_perplexity, verbose): if verbose >= 2: duration = time() - t0 print("[t-SNE] Computed conditional probabilities in {:.3f}s".format(duration)) - return P + return _align_api_if_sparse(P) def _kl_divergence( diff --git a/sklearn/manifold/tests/test_mds.py b/sklearn/manifold/tests/test_mds.py index 808856b1167ff..1e5fe94a0a87b 100644 --- a/sklearn/manifold/tests/test_mds.py +++ b/sklearn/manifold/tests/test_mds.py @@ -4,7 +4,7 @@ import pytest from numpy.testing import assert_allclose, assert_array_almost_equal, assert_equal -from sklearn.datasets import load_digits, load_iris +from sklearn.datasets import load_digits, load_iris, make_blobs from sklearn.manifold import ClassicalMDS from sklearn.manifold import _mds as mds from sklearn.metrics import euclidean_distances @@ -242,19 +242,6 @@ def test_convergence_does_not_depend_on_scale(metric_mds): assert_equal(n_iter1, n_iter2) -# TODO(1.9): delete this test -def test_future_warning_n_init(): - X = np.array([[1, 1], [1, 4], [1, 5], [3, 3]]) - sim = np.array([[0, 5, 3, 4], [5, 0, 2, 2], [3, 2, 0, 1], [4, 2, 1, 0]]) - - with pytest.warns(FutureWarning): - mds.smacof(sim) - - with pytest.warns(FutureWarning): - mds.MDS(init="random").fit(X) - - -# TODO(1.9): delete the n_init warning check # TODO(1.10): delete this test def test_future_warning_init_and_metric(): X = np.array([[1, 1], [1, 4], [1, 5], [3, 3]]) @@ -276,11 +263,6 @@ def test_future_warning_init_and_metric(): with pytest.warns(FutureWarning, match="The default value of `init`"): mds.MDS(metric="euclidean", n_init=1).fit(X) - # TODO (1.9): delete this check - # n_init=1 will become default in the future - with pytest.warns(FutureWarning, match="The default value of `n_init`"): - mds.MDS(metric="euclidean", init="random").fit(X) - # providing both metric and dissimilarity raises an error with pytest.raises(ValueError, match="provided both `dissimilarity`"): mds.MDS( @@ -288,8 +270,6 @@ def test_future_warning_init_and_metric(): ).fit(X) -# TODO(1.9): remove warning filter -@pytest.mark.filterwarnings("ignore::FutureWarning") def test_classical_mds_init_to_mds(): X, _ = load_iris(return_X_y=True) @@ -303,3 +283,14 @@ def test_classical_mds_init_to_mds(): Z2 = mds1.fit_transform(X, init=Z_classical) assert_allclose(Z1, Z2) + + +@pytest.mark.parametrize("init", ["random", "classical_mds"]) +@pytest.mark.parametrize("n_components", [1, 2, 5, 10]) +def test_correct_n_components(init, n_components): + X, _ = make_blobs(n_features=10) + + model = mds.MDS(init=init, n_components=n_components, n_init=1) + Z = model.fit_transform(X) + + assert Z.shape[1] == n_components diff --git a/sklearn/manifold/tests/test_spectral_embedding.py b/sklearn/manifold/tests/test_spectral_embedding.py index 4c4115734a404..e55d001c2a3d1 100644 --- a/sklearn/manifold/tests/test_spectral_embedding.py +++ b/sklearn/manifold/tests/test_spectral_embedding.py @@ -23,6 +23,8 @@ COO_CONTAINERS, CSC_CONTAINERS, CSR_CONTAINERS, + _sparse_diags_array, + _sparse_random_array, parse_version, sp_version, ) @@ -38,7 +40,7 @@ not pyamg_available, reason="PyAMG is required for the tests in this function." ) -# non centered, sparse centers to check the +# non centered, sparse centers centers = np.array( [ [0.0, 5.0, 0.0, 0.0, 0.0], @@ -46,7 +48,7 @@ [1.0, 0.0, 0.0, 5.0, 1.0], ] ) -n_samples = 1000 +n_samples = 100 n_clusters, n_features = centers.shape S, true_labels = make_blobs( n_samples=n_samples, centers=centers, cluster_std=1.0, random_state=42 @@ -104,6 +106,25 @@ def test_sparse_graph_connected_component(coo_container): assert_array_equal(component_1, component_2) +@pytest.mark.skipif( + not pyamg_available, reason="PyAMG is required for the tests in this function." +) +def test_fallback_amg(): + random_state = np.random.RandomState(36) + data = random_state.randn(10, 30) + sims = rbf_kernel(data) + + # eigen_solver='amg' should raise a warning and fallback to 'arpack' + # when the Laplacian is dense. + with pytest.warns(RuntimeWarning, match="dense matrices"): + _ = spectral_embedding(sims, eigen_solver="amg", n_components=1) + + # eigen_solver='amg' should raise a warning and fallback to 'arpack' + # when the graph is very small (n_nodes < 5 * n_components + 1). + with pytest.warns(RuntimeWarning, match="small graphs"): + _ = spectral_embedding(sims, eigen_solver="amg", n_components=5) + + # TODO: investigate why this test is seed-sensitive on 32-bit Python # runtimes. Is this revealing a numerical stability problem ? Or is it # expected from the test numerical design ? In the latter case the test @@ -199,11 +220,11 @@ def test_spectral_embedding_precomputed_affinity( def test_precomputed_nearest_neighbors_filtering(): # Test precomputed graph filtering when containing too many neighbors - n_neighbors = 2 + n_neighbors = 10 results = [] for additional_neighbors in [0, 10]: nn = NearestNeighbors(n_neighbors=n_neighbors + additional_neighbors).fit(S) - graph = nn.kneighbors_graph(S, mode="connectivity") + graph = nn.kneighbors_graph(S, mode="distance") embedding = ( SpectralEmbedding( random_state=0, @@ -245,24 +266,53 @@ def test_spectral_embedding_callable_affinity(sparse_container, seed=36): _assert_equal_with_sign_flipping(embed_rbf, embed_callable, 0.05) +@pytest.mark.parametrize("dtype", (np.float32, np.float64)) +def test_spectral_embedding_lobpcg_solver(dtype, global_random_seed): + # Tests that the results are the same when using arpack + # and lobpcg solvers. Note that we use RBF kernel here + # to make the graph connected, so that eigenvectors + # are non-trivial and eigenvalues are non-repeated. + se_lobpcg = SpectralEmbedding( + n_components=2, + affinity="rbf", + eigen_solver="lobpcg", + eigen_tol=1e-5, + random_state=np.random.RandomState(global_random_seed), + ) + se_arpack = SpectralEmbedding( + n_components=2, + affinity="rbf", + eigen_solver="arpack", + eigen_tol=0, + random_state=np.random.RandomState(global_random_seed), + ) + embed_lobpcg = se_lobpcg.fit_transform(S.astype(dtype)) + embed_arpack = se_arpack.fit_transform(S.astype(dtype)) + _assert_equal_with_sign_flipping(embed_lobpcg, embed_arpack, 1e-5) + + @pytest.mark.skipif( not pyamg_available, reason="PyAMG is required for the tests in this function." ) @pytest.mark.parametrize("dtype", (np.float32, np.float64)) @pytest.mark.parametrize("coo_container", COO_CONTAINERS) def test_spectral_embedding_amg_solver(dtype, coo_container, seed=36): + # Tests that the results are the same when using arpack + # and amg solvers. Note that we use RBF kernel here + # to make the graph connected, so that eigenvectors + # are non-trivial and eigenvalues are non-repeated. se_amg = SpectralEmbedding( n_components=2, - affinity="nearest_neighbors", + affinity="rbf", eigen_solver="amg", - n_neighbors=5, + eigen_tol=1e-5, random_state=np.random.RandomState(seed), ) se_arpack = SpectralEmbedding( n_components=2, - affinity="nearest_neighbors", + affinity="rbf", eigen_solver="arpack", - n_neighbors=5, + eigen_tol=0, random_state=np.random.RandomState(seed), ) embed_amg = se_amg.fit_transform(S.astype(dtype)) @@ -311,9 +361,9 @@ def test_spectral_embedding_amg_solver(dtype, coo_container, seed=36): def test_spectral_embedding_amg_solver_failure(dtype, seed=36): # Non-regression test for amg solver failure (issue #13393 on github) num_nodes = 100 - X = sparse.rand(num_nodes, num_nodes, density=0.1, random_state=seed) + X = _sparse_random_array((num_nodes, num_nodes), density=0.1, random_state=seed) X = X.astype(dtype) - upper = sparse.triu(X) - sparse.diags(X.diagonal()) + upper = sparse.triu(X) - _sparse_diags_array(X.diagonal()) sym_matrix = upper + upper.T embedding = spectral_embedding( sym_matrix, n_components=10, eigen_solver="amg", random_state=0 @@ -329,12 +379,16 @@ def test_spectral_embedding_amg_solver_failure(dtype, seed=36): def test_pipeline_spectral_clustering(seed=36): # Test using pipeline to do spectral clustering + # Note that SpectralEmbedding drops the first eigenvector, + # contrary to SpectralClustering. So here for n_clusters + # we will use n_components = n_clusters - 1 + # eigenvectors (since the first one is dropped). random_state = np.random.RandomState(seed) se_rbf = SpectralEmbedding( - n_components=n_clusters, affinity="rbf", random_state=random_state + n_components=n_clusters - 1, affinity="rbf", random_state=random_state ) se_knn = SpectralEmbedding( - n_components=n_clusters, + n_components=n_clusters - 1, affinity="nearest_neighbors", n_neighbors=5, random_state=random_state, diff --git a/sklearn/metrics/__init__.py b/sklearn/metrics/__init__.py index 85ea7035e738f..0d6e530e266ec 100644 --- a/sklearn/metrics/__init__.py +++ b/sklearn/metrics/__init__.py @@ -42,6 +42,7 @@ det_curve, label_ranking_average_precision_score, label_ranking_loss, + metric_at_thresholds, ndcg_score, precision_recall_curve, roc_auc_score, @@ -161,6 +162,7 @@ "mean_squared_log_error", "mean_tweedie_deviance", "median_absolute_error", + "metric_at_thresholds", "multilabel_confusion_matrix", "mutual_info_score", "nan_euclidean_distances", diff --git a/sklearn/metrics/_base.py b/sklearn/metrics/_base.py index c7668bce9fceb..e2305c82d350c 100644 --- a/sklearn/metrics/_base.py +++ b/sklearn/metrics/_base.py @@ -10,7 +10,13 @@ import numpy as np +import sklearn.externals.array_api_extra as xpx from sklearn.utils import check_array, check_consistent_length +from sklearn.utils._array_api import ( + _average, + _ravel, + get_namespace_and_device, +) from sklearn.utils.multiclass import type_of_target @@ -19,12 +25,16 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight Parameters ---------- + binary_metric : callable, returns shape [n_classes] + The binary metric function to use. + y_true : array, shape = [n_samples] or [n_samples, n_classes] True binary labels in binary label indicators. y_score : array, shape = [n_samples] or [n_samples, n_classes] Target scores, can either be probability estimates of the positive - class, confidence values, or binary decisions. + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). average : {None, 'micro', 'macro', 'samples', 'weighted'}, default='macro' If ``None``, the scores for each class are returned. Otherwise, @@ -47,9 +57,6 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight sample_weight : array-like of shape (n_samples,), default=None Sample weights. - binary_metric : callable, returns shape [n_classes] - The binary metric function to use. - Returns ------- score : float or array of shape [n_classes] @@ -57,6 +64,7 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight classes. """ + xp, _, _device = get_namespace_and_device(y_score, sample_weight) average_options = (None, "micro", "macro", "weighted", "samples") if average not in average_options: raise ValueError("average has to be one of {0}".format(average_options)) @@ -78,18 +86,23 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight if average == "micro": if score_weight is not None: - score_weight = np.repeat(score_weight, y_true.shape[1]) - y_true = y_true.ravel() - y_score = y_score.ravel() + score_weight = xp.repeat(score_weight, y_true.shape[1]) + y_true = _ravel(y_true) + y_score = _ravel(y_score) elif average == "weighted": if score_weight is not None: - average_weight = np.sum( - np.multiply(y_true, np.reshape(score_weight, (-1, 1))), axis=0 + # Mixed integer and float type promotion not defined in array standard + y_true = xp.asarray(y_true, dtype=score_weight.dtype) + average_weight = xp.sum( + xp.multiply(y_true, xp.reshape(score_weight, (-1, 1))), axis=0 ) else: - average_weight = np.sum(y_true, axis=0) - if np.isclose(average_weight.sum(), 0.0): + average_weight = xp.sum(y_true, axis=0) + if xpx.isclose( + xp.sum(average_weight), + xp.asarray(0, dtype=average_weight.dtype, device=_device), + ): return 0 elif average == "samples": @@ -99,16 +112,20 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight not_average_axis = 0 if y_true.ndim == 1: - y_true = y_true.reshape((-1, 1)) + y_true = xp.reshape(y_true, (-1, 1)) if y_score.ndim == 1: - y_score = y_score.reshape((-1, 1)) + y_score = xp.reshape(y_score, (-1, 1)) n_classes = y_score.shape[not_average_axis] - score = np.zeros((n_classes,)) + score = xp.zeros((n_classes,), device=_device) for c in range(n_classes): - y_true_c = y_true.take([c], axis=not_average_axis).ravel() - y_score_c = y_score.take([c], axis=not_average_axis).ravel() + y_true_c = _ravel( + xp.take(y_true, xp.asarray([c], device=_device), axis=not_average_axis) + ) + y_score_c = _ravel( + xp.take(y_score, xp.asarray([c], device=_device), axis=not_average_axis) + ) score[c] = binary_metric(y_true_c, y_score_c, sample_weight=score_weight) # Average the results @@ -116,9 +133,8 @@ def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight if average_weight is not None: # Scores with 0 weights are forced to be 0, preventing the average # score from being affected by 0-weighted NaN elements. - average_weight = np.asarray(average_weight) score[average_weight == 0] = 0 - return float(np.average(score, weights=average_weight)) + return float(_average(score, weights=average_weight, xp=xp)) else: return score diff --git a/sklearn/metrics/_classification.py b/sklearn/metrics/_classification.py index b9bc8129f5a6a..83d3a91581b5b 100644 --- a/sklearn/metrics/_classification.py +++ b/sklearn/metrics/_classification.py @@ -16,12 +16,12 @@ from numbers import Integral, Real import numpy as np -from scipy.sparse import coo_matrix, csr_matrix, issparse -from scipy.special import xlogy +from scipy.sparse import coo_array, csr_array, issparse from sklearn.exceptions import UndefinedMetricWarning from sklearn.preprocessing import LabelBinarizer, LabelEncoder from sklearn.utils import ( + _align_api_if_sparse, assert_all_finite, check_array, check_consistent_length, @@ -31,15 +31,14 @@ from sklearn.utils._array_api import ( _average, _bincount, - _convert_to_numpy, _count_nonzero, _fill_diagonal, _find_matching_floating_dtype, _is_numpy_namespace, _is_xp_namespace, + _isin, _max_precision_float_dtype, - _tolist, - _union1d, + _xlogy, get_namespace, get_namespace_and_device, move_to, @@ -76,9 +75,14 @@ def _check_targets(y_true, y_pred, sample_weight=None): """Check that y_true and y_pred belong to the same classification task. This converts multiclass or binary types to a common shape, and raises a - ValueError for a mix of multilabel and multiclass targets, a mix of - multilabel formats, for the presence of continuous-valued or multioutput - targets, or for targets of different lengths. + ValueError for: + + - targets of different lengths, + - a mix of multilabel and multiclass targets, + - a mix of multilabel target and anything else + (because there are no explicit labels), + - the presence of continuous-valued or multioutput (or 'unknown') targets, + - mix of string and integer labels. Column vectors are squeezed to 1d, while multilabel formats are returned as CSR sparse label indicators. @@ -97,6 +101,10 @@ def _check_targets(y_true, y_pred, sample_weight=None): The type of the true target data, as output by ``utils.multiclass.type_of_target``. + unique_labels : array + An ordered array of unique labels occurring either in `y_true`, `y_pred` or + both. + y_true : array or indicator matrix y_pred : array or indicator matrix @@ -129,7 +137,7 @@ def _check_targets(y_true, y_pred, sample_weight=None): ) ) - # We can't have more than one value on y_type => The set is no more needed + # We can't have more than one value in y_type => The set is no more needed y_type = y_type.pop() # No metrics support "multiclass-multioutput" format @@ -148,36 +156,23 @@ def _check_targets(y_true, y_pred, sample_weight=None): else: raise - xp, _ = get_namespace(y_true, y_pred) - if y_type == "binary": - try: - unique_values = _union1d(y_true, y_pred, xp) - except TypeError as e: - # We expect y_true and y_pred to be of the same data type. - # If `y_true` was provided to the classifier as strings, - # `y_pred` given by the classifier will also be encoded with - # strings. So we raise a meaningful error - raise TypeError( - "Labels in y_true and y_pred should be of the same type. " - f"Got y_true={xp.unique(y_true)} and " - f"y_pred={xp.unique(y_pred)}. Make sure that the " - "predictions provided by the classifier coincides with " - "the true labels." - ) from e - if unique_values.shape[0] > 2: - y_type = "multiclass" + unique_labels_ = unique_labels(y_true, y_pred, ys_types={y_type}) + if y_type == "binary": + if unique_labels_.shape[0] > 2: + y_type = "multiclass" + xp, _ = get_namespace(y_true, y_pred) if y_type.startswith("multilabel"): if _is_numpy_namespace(xp): # XXX: do we really want to sparse-encode multilabel indicators when # they are passed as a dense arrays? This is not possible for array # API inputs in general hence we only do it for NumPy inputs. But even # for NumPy the usefulness is questionable. - y_true = csr_matrix(y_true) - y_pred = csr_matrix(y_pred) + y_true = _align_api_if_sparse(csr_array(y_true)) + y_pred = _align_api_if_sparse(csr_array(y_pred)) y_type = "multilabel-indicator" - return y_type, y_true, y_pred, sample_weight + return y_type, unique_labels_, y_true, y_pred, sample_weight def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device): @@ -186,24 +181,14 @@ def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device Also return the classes provided by `LabelBinarizer` in additional to the integer encoded array. """ - xp_y_true, is_y_true_array_api = get_namespace(y_true) - - # For classification metrics both array API compatible and non array API - # compatible inputs are allowed for `y_true`. This is because arrays that - # store class labels as strings cannot be represented in namespaces other - # than Numpy. Thus to avoid unnecessary complexity, we always convert - # `y_true` to a Numpy array so that it can be processed appropriately by - # `LabelBinarizer` and then transfer the integer encoded output back to the - # target namespace and device. - if is_y_true_array_api: - y_true = _convert_to_numpy(y_true, xp=xp_y_true) + xp, _ = get_namespace(y_true) lb = LabelBinarizer() if labels is not None: lb = lb.fit(labels) # LabelBinarizer does not respect the order implied by labels, which # can be misleading. - if not np.all(lb.classes_ == labels): + if not xp.all(lb.classes_ == labels): warnings.warn( f"Labels passed were {labels}. But this function " "assumes labels are ordered lexicographically. " @@ -211,7 +196,7 @@ def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device "the columns of y_prob correspond to this ordering.", UserWarning, ) - if not np.isin(y_true, labels).all(): + if not xp.all(_isin(y_true, labels, xp=xp)): undeclared_labels = set(y_true) - set(labels) raise ValueError( f"y_true contains values {undeclared_labels} not belonging " @@ -221,7 +206,7 @@ def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device else: lb = lb.fit(y_true) - if len(lb.classes_) == 1: + if lb.classes_.shape[0] == 1: if labels is None: raise ValueError( "y_true contains only one label ({0}). Please " @@ -235,7 +220,7 @@ def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device ) transformed_labels = lb.transform(y_true) - transformed_labels = target_xp.asarray(transformed_labels, device=target_device) + transformed_labels = move_to(transformed_labels, xp=target_xp, device=target_device) if transformed_labels.shape[1] == 1: transformed_labels = target_xp.concat( (1 - transformed_labels, transformed_labels), axis=1 @@ -260,7 +245,7 @@ def _validate_multiclass_probabilistic_prediction( y_true : array-like or label indicator matrix Ground truth (correct) labels for n_samples samples. - y_prob : array-like of float, shape=(n_samples, n_classes) or (n_samples,) + y_prob : array of floats, shape=(n_samples, n_classes) or (n_samples,) Predicted probabilities, as returned by a classifier's predict_proba method. If `y_prob.shape = (n_samples,)` the probabilities provided are assumed to be that of the @@ -284,10 +269,6 @@ def _validate_multiclass_probabilistic_prediction( """ xp, _, device_ = get_namespace_and_device(y_prob) - y_prob = check_array( - y_prob, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_) - ) - if xp.max(y_prob) > 1: raise ValueError(f"y_prob contains values greater than 1: {xp.max(y_prob)}") if xp.min(y_prob) < 0: @@ -326,8 +307,7 @@ def _validate_multiclass_probabilistic_prediction( ) # Check if dimensions are consistent. - transformed_labels = check_array(transformed_labels) - if len(lb_classes) != y_prob.shape[1]: + if lb_classes.shape[0] != y_prob.shape[1]: if labels is None: raise ValueError( "y_true and y_prob contain different number of " @@ -422,7 +402,7 @@ def accuracy_score(y_true, y_pred, *, normalize=True, sample_weight=None): y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) # Compute accuracy for each possible representation y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, _, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) @@ -548,19 +528,19 @@ def confusion_matrix( ensure_min_samples=0, ) # Convert the input arrays to NumPy (on CPU) irrespective of the original - # namespace and device so as to be able to leverage the the efficient + # namespace and device so as to be able to leverage the efficient # counting operations implemented by SciPy in the coo_matrix constructor. # The final results will be converted back to the input namespace and device # for the sake of consistency with other metric functions with array API support. - y_true = _convert_to_numpy(y_true, xp) - y_pred = _convert_to_numpy(y_pred, xp) + y_true = move_to(y_true, xp=np, device="cpu") + y_pred = move_to(y_pred, xp=np, device="cpu") if sample_weight is None: sample_weight = np.ones(y_true.shape[0], dtype=np.int64) else: - sample_weight = _convert_to_numpy(sample_weight, xp) + sample_weight = move_to(sample_weight, xp=np, device="cpu") if len(sample_weight) > 0: - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, unique_labels, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) else: @@ -569,16 +549,16 @@ def confusion_matrix( # In this case we don't pass sample_weight to _check_targets that would # check that sample_weight is not empty and we don't reuse the returned # sample_weight - y_type, y_true, y_pred, _ = _check_targets(y_true, y_pred) + y_type, unique_labels, y_true, y_pred, _ = _check_targets(y_true, y_pred) y_true, y_pred = attach_unique(y_true, y_pred) if y_type not in ("binary", "multiclass"): raise ValueError("%s is not supported" % y_type) if labels is None: - labels = unique_labels(y_true, y_pred) + labels = unique_labels else: - labels = _convert_to_numpy(labels, xp) + labels = move_to(labels, xp=np, device="cpu") n_labels = labels.size if n_labels == 0: raise ValueError("'labels' should contain at least one label.") @@ -615,7 +595,7 @@ def confusion_matrix( else: dtype = np.float32 if str(device_).startswith("mps") else np.float64 - cm = coo_matrix( + cm = coo_array( (sample_weight, (y_true, y_pred)), shape=(n_labels, n_labels), dtype=dtype, @@ -756,15 +736,16 @@ def multilabel_confusion_matrix( [1, 2]]]) """ y_true, y_pred = attach_unique(y_true, y_pred) - xp, _, device_ = get_namespace_and_device(y_true, y_pred, sample_weight) - y_type, y_true, y_pred, sample_weight = _check_targets( + + xp, _, device_ = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device_) + y_type, present_labels, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) if y_type not in ("binary", "multiclass", "multilabel-indicator"): raise ValueError("%s is not supported" % y_type) - present_labels = unique_labels(y_true, y_pred) if labels is None: labels = present_labels n_labels = None @@ -898,10 +879,22 @@ def multilabel_confusion_matrix( "labels": ["array-like", None], "weights": [StrOptions({"linear", "quadratic"}), None], "sample_weight": ["array-like", None], + "replace_undefined_by": [ + Interval(Real, -1.0, 1.0, closed="both"), + np.nan, + ], }, prefer_skip_nested_validation=True, ) -def cohen_kappa_score(y1, y2, *, labels=None, weights=None, sample_weight=None): +def cohen_kappa_score( + y1, + y2, + *, + labels=None, + weights=None, + sample_weight=None, + replace_undefined_by=np.nan, +): r"""Compute Cohen's kappa: a statistic that measures inter-annotator agreement. This function computes Cohen's kappa [1]_, a score that expresses the level @@ -942,11 +935,25 @@ class labels [2]_. sample_weight : array-like of shape (n_samples,), default=None Sample weights. + replace_undefined_by : np.nan, float in [-1.0, 1.0], default=np.nan + Sets the return value when the metric is undefined. This can happen when no + label of interest (as defined in the `labels` param) is assigned by the second + annotator, or when both `y1` and `y2`only have one label in common that is also + in `labels`. In these cases, an + :class:`~sklearn.exceptions.UndefinedMetricWarning` is raised. Can take the + following values: + + - `np.nan` to return `np.nan` + - a floating point value in the range of [-1.0, 1.0] to return a specific value + + .. versionadded:: 1.9 + Returns ------- kappa : float - The kappa statistic, which is a number between -1 and 1. The maximum - value means complete agreement; zero or lower means chance agreement. + The kappa statistic, which is a number between -1.0 and 1.0. The maximum value + means complete agreement; the minimum value means complete disagreement; 0.0 + indicates no agreement beyond what would be expected by chance. References ---------- @@ -989,7 +996,20 @@ class labels [2]_. confusion = xp.astype(confusion, max_float_dtype, copy=False) sum0 = xp.sum(confusion, axis=0) sum1 = xp.sum(confusion, axis=1) - expected = xp.linalg.outer(sum0, sum1) / xp.sum(sum0) + + numerator = xp.linalg.outer(sum0, sum1) + denominator = xp.sum(sum0) + msg_zero_division = ( + "`y2` contains no labels that are present in both `y1` and `labels`." + "`cohen_kappa_score` is undefined and set to the value defined by " + f"the `replace_undefined_by` param, which is set to {replace_undefined_by}." + ) + # exact equality is safe here, since denominator is a sum of positive terms: + if denominator == 0: + warnings.warn(msg_zero_division, UndefinedMetricWarning, stacklevel=2) + return replace_undefined_by + + expected = numerator / denominator if weights is None: w_mat = xp.ones([n_classes, n_classes], dtype=max_float_dtype, device=device_) @@ -1002,7 +1022,19 @@ class labels [2]_. else: w_mat = (w_mat - w_mat.T) ** 2 - k = xp.sum(w_mat * confusion) / xp.sum(w_mat * expected) + numerator = xp.sum(w_mat * confusion) + denominator = xp.sum(w_mat * expected) + msg_zero_division = ( + "`y1`, `y2` and `labels` have only one label in common. " + "`cohen_kappa_score` is undefined and set to the value defined by the " + f"the `replace_undefined_by` param, which is set to {replace_undefined_by}." + ) + # exact equality is safe here, since denominator is a sum of positive terms: + if denominator == 0: + warnings.warn(msg_zero_division, UndefinedMetricWarning, stacklevel=2) + return replace_undefined_by + + k = numerator / denominator return float(1 - k) @@ -1281,7 +1313,7 @@ def matthews_corrcoef(y_true, y_pred, *, sample_weight=None): -0.33 """ y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, _, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) if y_type not in {"binary", "multiclass"}: @@ -1875,10 +1907,7 @@ def _check_set_wise_labels(y_true, y_pred, average, labels, pos_label): raise ValueError("average has to be one of " + str(average_options)) y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, _ = _check_targets(y_true, y_pred) - # Convert to Python primitive type to avoid NumPy type / Python str - # comparison. See https://github.com/numpy/numpy/issues/6784 - present_labels = _tolist(unique_labels(y_true, y_pred)) + y_type, present_labels, y_true, y_pred, _ = _check_targets(y_true, y_pred) if average == "binary": if y_type == "binary": if pos_label not in present_labels: @@ -2111,6 +2140,8 @@ def precision_recall_fscore_support( array([2, 2, 2])) """ _check_zero_division(zero_division) + xp, _, device_ = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device_) labels = _check_set_wise_labels(y_true, y_pred, average, labels, pos_label) # Calculate tp_sum, pred_sum, true_sum ### @@ -2126,7 +2157,6 @@ def precision_recall_fscore_support( pred_sum = tp_sum + MCM[:, 0, 1] true_sum = tp_sum + MCM[:, 1, 0] - xp, _, device_ = get_namespace_and_device(y_true, y_pred) if average == "micro": tp_sum = xp.reshape(xp.sum(tp_sum), (1,)) pred_sum = xp.reshape(xp.sum(pred_sum), (1,)) @@ -2194,7 +2224,6 @@ def precision_recall_fscore_support( "y_pred": ["array-like", "sparse matrix"], "labels": ["array-like", None], "sample_weight": ["array-like", None], - "raise_warning": ["boolean", Hidden(StrOptions({"deprecated"}))], "replace_undefined_by": [ Options(Real, {1.0, np.nan}), dict, @@ -2208,7 +2237,6 @@ def class_likelihood_ratios( *, labels=None, sample_weight=None, - raise_warning="deprecated", replace_undefined_by=np.nan, ): """Compute binary classification positive and negative likelihood ratios. @@ -2266,15 +2294,6 @@ class after being classified as negative. This is the case when the sample_weight : array-like of shape (n_samples,), default=None Sample weights. - raise_warning : bool, default=True - Whether or not a case-specific warning message is raised when there is division - by zero. - - .. deprecated:: 1.7 - `raise_warning` was deprecated in version 1.7 and will be removed in 1.9, - when an :class:`~sklearn.exceptions.UndefinedMetricWarning` will always - raise in case of a division by zero. - replace_undefined_by : np.nan, 1.0, or dict, default=np.nan Sets the return values for LR+ and LR- when there is a division by zero. Can take the following values: @@ -2304,10 +2323,8 @@ class after being classified as negative. This is the case when the Raises :class:`~sklearn.exceptions.UndefinedMetricWarning` when `y_true` and `y_pred` lead to the following conditions: - - The number of false positives is 0 and `raise_warning` is set to `True` - (default): positive likelihood ratio is undefined. - - The number of true negatives is 0 and `raise_warning` is set to `True` - (default): negative likelihood ratio is undefined. + - The number of false positives is 0: positive likelihood ratio is undefined. + - The number of true negatives is 0: negative likelihood ratio is undefined. - The sum of true positives and false negatives is 0 (no samples of the positive class are present in `y_true`): both likelihood ratios are undefined. @@ -2342,12 +2359,8 @@ class are present in `y_true`): both likelihood ratios are undefined. >>> class_likelihood_ratios(y_true, y_pred, labels=["non-cat", "cat"]) (1.5, 0.75) """ - # TODO(1.9): When `raise_warning` is removed, the following changes need to be made: - # The checks for `raise_warning==True` need to be removed and we will always warn, - # remove `FutureWarning`, and the Warns section in the docstring should not mention - # `raise_warning` anymore. y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, _, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) if y_type != "binary": @@ -2356,16 +2369,6 @@ class are present in `y_true`): both likelihood ratios are undefined. f"problems, got targets of type: {y_type}" ) - msg_deprecated_param = ( - "`raise_warning` was deprecated in version 1.7 and will be removed in 1.9. An " - "`UndefinedMetricWarning` will always be raised in case of a division by zero " - "and the value set with the `replace_undefined_by` param will be returned." - ) - if raise_warning != "deprecated": - warnings.warn(msg_deprecated_param, FutureWarning) - else: - raise_warning = True - if replace_undefined_by == 1.0: replace_undefined_by = {"LR+": 1.0, "LR-": 1.0} @@ -2430,18 +2433,17 @@ class are present in `y_true`): both likelihood ratios are undefined. # if `fp == 0`a division by zero will occur if fp == 0: - if raise_warning: - if tp == 0: - msg_beginning = ( - "No samples were predicted for the positive class and " - "`positive_likelihood_ratio` is " - ) - else: - msg_beginning = "`positive_likelihood_ratio` is ill-defined and " - msg_end = "set to `np.nan`. Use the `replace_undefined_by` param to " - "control this behavior. To suppress this warning or turn it into an error, " - "see Python's `warnings` module and `warnings.catch_warnings()`." - warnings.warn(msg_beginning + msg_end, UndefinedMetricWarning, stacklevel=2) + if tp == 0: + msg_beginning = ( + "No samples were predicted for the positive class and " + "`positive_likelihood_ratio` is " + ) + else: + msg_beginning = "`positive_likelihood_ratio` is ill-defined and " + msg_end = "set to `np.nan`. Use the `replace_undefined_by` param to " + "control this behavior. To suppress this warning or turn it into an error, " + "see Python's `warnings` module and `warnings.catch_warnings()`." + warnings.warn(msg_beginning + msg_end, UndefinedMetricWarning, stacklevel=2) if isinstance(replace_undefined_by, float) and np.isnan(replace_undefined_by): positive_likelihood_ratio = replace_undefined_by else: @@ -2454,14 +2456,13 @@ class are present in `y_true`): both likelihood ratios are undefined. # if `tn == 0`a division by zero will occur if tn == 0: - if raise_warning: - msg = ( - "`negative_likelihood_ratio` is ill-defined and set to `np.nan`. " - "Use the `replace_undefined_by` param to control this behavior. To " - "suppress this warning or turn it into an error, see Python's " - "`warnings` module and `warnings.catch_warnings()`." - ) - warnings.warn(msg, UndefinedMetricWarning, stacklevel=2) + msg = ( + "`negative_likelihood_ratio` is ill-defined and set to `np.nan`. " + "Use the `replace_undefined_by` param to control this behavior. To " + "suppress this warning or turn it into an error, see Python's " + "`warnings` module and `warnings.catch_warnings()`." + ) + warnings.warn(msg, UndefinedMetricWarning, stacklevel=2) if isinstance(replace_undefined_by, float) and np.isnan(replace_undefined_by): negative_likelihood_ratio = replace_undefined_by else: @@ -3084,12 +3085,12 @@ class 2 1.00 0.67 0.80 3 """ y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, unique_labels_, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) if labels is None: - labels = unique_labels(y_true, y_pred) + labels = unique_labels_ labels_given = False else: labels = np.asarray(labels) @@ -3255,8 +3256,10 @@ def hamming_loss(y_true, y_pred, *, sample_weight=None): References ---------- - .. [1] Grigorios Tsoumakas, Ioannis Katakis. Multi-Label Classification: - An Overview. International Journal of Data Warehousing & Mining, + .. [1] Grigorios Tsoumakas, Ioannis Katakis. + `Multi-Label Classification: An Overview + <https://people.iee.ihu.gr/~stoug/odep/papers/Multi-Label%20Classification:%20An%20Overview.pdf>`_. + International Journal of Data Warehousing & Mining, 3(3), 1-13, July-September 2007. .. [2] `Wikipedia entry on the Hamming distance @@ -3277,7 +3280,7 @@ def hamming_loss(y_true, y_pred, *, sample_weight=None): 0.75 """ y_true, y_pred = attach_unique(y_true, y_pred) - y_type, y_true, y_pred, sample_weight = _check_targets( + y_type, _, y_true, y_pred, sample_weight = _check_targets( y_true, y_pred, sample_weight ) @@ -3307,19 +3310,28 @@ def hamming_loss(y_true, y_pred, *, sample_weight=None): @validate_params( { "y_true": ["array-like"], - "y_pred": ["array-like"], + "y_proba": ["array-like", Hidden(None)], "normalize": ["boolean"], "sample_weight": ["array-like", None], "labels": ["array-like", None], + "y_pred": ["array-like", str], }, prefer_skip_nested_validation=True, ) -def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None): +def log_loss( + y_true, + y_proba=None, + *, + normalize=True, + sample_weight=None, + labels=None, + y_pred="deprecated", +): r"""Log loss, aka logistic loss or cross-entropy loss. This is the loss function used in (multinomial) logistic regression and extensions of it such as neural networks, defined as the negative - log-likelihood of a logistic model that returns ``y_pred`` probabilities + log-likelihood of a logistic model that returns ``y_proba`` probabilities for its training data ``y_true``. The log loss is only defined for two or more labels. For a single sample with true label :math:`y \in \{0,1\}` and @@ -3336,16 +3348,19 @@ def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None) y_true : array-like or label indicator matrix Ground truth (correct) labels for n_samples samples. - y_pred : array-like of float, shape = (n_samples, n_classes) or (n_samples,) + y_proba : array-like of float, shape = (n_samples, n_classes) or (n_samples,) Predicted probabilities, as returned by a classifier's - predict_proba method. If ``y_pred.shape = (n_samples,)`` + predict_proba method. If ``y_proba.shape = (n_samples,)`` the probabilities provided are assumed to be that of the - positive class. The labels in ``y_pred`` are assumed to be + positive class. The labels in ``y_proba`` are assumed to be ordered alphabetically, as done by :class:`~sklearn.preprocessing.LabelBinarizer`. - `y_pred` values are clipped to `[eps, 1-eps]` where `eps` is the machine - precision for `y_pred`'s dtype. + `y_proba` values are clipped to `[eps, 1-eps]` where `eps` is the machine + precision for `y_proba`'s dtype. + + .. versionadded:: 1.9 + `y_pred` was renamed to `y_proba`. normalize : bool, default=True If true, return the mean loss per sample. @@ -3361,6 +3376,21 @@ def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None) .. versionadded:: 0.18 + y_pred : array-like of float, shape = (n_samples, n_classes) or (n_samples,) + Predicted probabilities, as returned by a classifier's + predict_proba method. If ``y_pred.shape = (n_samples,)`` + the probabilities provided are assumed to be that of the + positive class. The labels in ``y_pred`` are assumed to be + ordered alphabetically, as done by + :class:`~sklearn.preprocessing.LabelBinarizer`. + + `y_pred` values are clipped to `[eps, 1-eps]` where `eps` is the machine + precision for `y_pred`'s dtype. + + .. deprecated:: 1.9 + `y_pred` was deprecated in favor of `y_proba` in v1.9 and will + be removed in v1.11. + Returns ------- loss : float @@ -3372,8 +3402,9 @@ def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None) References ---------- - C.M. Bishop (2006). Pattern Recognition and Machine Learning. Springer, - p. 209. + C.M. Bishop (2006). `Pattern Recognition and Machine Learning + <https://www.microsoft.com/en-us/research/wp-content/uploads/2006/01/Bishop-Pattern-Recognition-and-Machine-Learning-2006.pdf>`_. + Springer, p. 209. Examples -------- @@ -3382,29 +3413,48 @@ def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None) ... [[.1, .9], [.9, .1], [.8, .2], [.35, .65]]) 0.21616 """ + # TODO(1.11): Remove check and remove default value for `y_proba`. + if not (isinstance(y_pred, str) and y_pred == "deprecated"): + if y_proba is not None: + raise ValueError( + "Cannot use both `y_pred` and `y_proba`. `y_pred` is deprecated, " + "use `y_proba` instead." + ) + else: + warnings.warn( + "`y_pred` was renamed to `y_proba` in version 1.9 and will be removed " + "in 1.11. Use `y_proba` instead.", + FutureWarning, + ) + y_proba = y_pred + + xp, _, device_ = get_namespace_and_device(y_proba) + y_proba = check_array( + y_proba, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_) + ) if sample_weight is not None: - xp, _, device_ = get_namespace_and_device(y_pred) sample_weight = move_to(sample_weight, xp=xp, device=device_) - transformed_labels, y_pred = _validate_multiclass_probabilistic_prediction( - y_true, y_pred, sample_weight, labels + transformed_labels, y_proba = _validate_multiclass_probabilistic_prediction( + y_true, y_proba, sample_weight, labels ) return _log_loss( transformed_labels, - y_pred, + y_proba, normalize=normalize, sample_weight=sample_weight, ) -def _log_loss(transformed_labels, y_pred, *, normalize=True, sample_weight=None): +def _log_loss(transformed_labels, y_proba, *, normalize=True, sample_weight=None): """Log loss for transformed labels and validated probabilistic predictions.""" - xp, _, device_ = get_namespace_and_device(y_pred) + xp, _, device_ = get_namespace_and_device(y_proba) if sample_weight is not None: sample_weight = move_to(sample_weight, xp=xp, device=device_) - eps = xp.finfo(y_pred.dtype).eps - y_pred = xp.clip(y_pred, eps, 1 - eps) - loss = -xp.sum(xlogy(transformed_labels, y_pred), axis=1) + eps = xp.finfo(y_proba.dtype).eps + y_proba = xp.clip(y_proba, eps, 1 - eps) + transformed_labels = xp.astype(transformed_labels, y_proba.dtype, copy=False) + loss = -xp.sum(_xlogy(transformed_labels, y_proba, xp=xp), axis=1) return float(_average(loss, weights=sample_weight, normalize=normalize)) @@ -3420,15 +3470,15 @@ def _log_loss(transformed_labels, y_pred, *, normalize=True, sample_weight=None) def hinge_loss(y_true, pred_decision, *, labels=None, sample_weight=None): """Average hinge loss (non-regularized). - In binary class case, assuming labels in y_true are encoded with +1 and -1, - when a prediction mistake is made, ``margin = y_true * pred_decision`` is - always negative (since the signs disagree), implying ``1 - margin`` is + In :term:`binary` class case, assuming labels in `y_true` are encoded with +1 + and -1, when a prediction mistake is made, `margin = y_true * pred_decision` is + always negative (since the signs are opposite), implying `1 - margin` is always greater than 1. The cumulated hinge loss is therefore an upper bound of the number of mistakes made by the classifier. - In multiclass case, the function expects that either all the labels are - included in y_true or an optional labels argument is provided which - contains all the labels. The multilabel margin is calculated according + In :term:`multiclass` case, the function expects that either all the labels are + present in `y_true` or an optional `labels` argument is provided which + contains all the labels. The multiclass margin is calculated according to Crammer-Singer's method. As in the binary case, the cumulated hinge loss is an upper bound of the number of mistakes made by the classifier. @@ -3437,11 +3487,13 @@ def hinge_loss(y_true, pred_decision, *, labels=None, sample_weight=None): Parameters ---------- y_true : array-like of shape (n_samples,) - True target, consisting of integers of two values. The positive label - must be greater than the negative label. + True target. For :term:`binary` data, it should only contain two unique + values, with the positive label being greater than the negative label. + For :term:`multiclass` data, all labels should be present, or provided + via `labels`. pred_decision : array-like of shape (n_samples,) or (n_samples, n_classes) - Predicted decisions, as output by decision_function (floats). + Predicted decisions, as output by :term:`decision_function` (floats). labels : array-like, default=None Contains all the labels for the problem. Used in multiclass hinge loss. @@ -3459,14 +3511,15 @@ def hinge_loss(y_true, pred_decision, *, labels=None, sample_weight=None): .. [1] `Wikipedia entry on the Hinge loss <https://en.wikipedia.org/wiki/Hinge_loss>`_. - .. [2] Koby Crammer, Yoram Singer. On the Algorithmic + .. [2] `Koby Crammer, Yoram Singer. On the Algorithmic Implementation of Multiclass Kernel-based Vector Machines. Journal of Machine Learning Research 2, - (2001), 265-292. + (2001), 265-292 + <https://jmlr.csail.mit.edu/papers/volume2/crammer01a/crammer01a.pdf>`_. - .. [3] `L1 AND L2 Regularization for Multiclass Hinge Loss Models + .. [3] `L1 and L2 Regularization for Multiclass Hinge Loss Models by Robert C. Moore, John DeNero - <https://storage.googleapis.com/pub-tools-public-publication-data/pdf/37362.pdf>`_. + <https://www.isca-archive.org/mlslp_2011/moore11_mlslp.pdf>`_. Examples -------- @@ -3566,7 +3619,7 @@ def _one_hot_encoding_binary_target(y_true, pos_label, target_xp, target_device) """ xp_y_true, _ = get_namespace(y_true) y_true_pos = xp_y_true.asarray(y_true == pos_label, dtype=xp_y_true.int64) - y_true_pos = target_xp.asarray(y_true_pos, device=target_device) + y_true_pos = move_to(y_true_pos, xp=target_xp, device=target_device) return target_xp.stack((1 - y_true_pos, y_true_pos), axis=1) @@ -3628,10 +3681,11 @@ def _validate_binary_probabilistic_prediction(y_true, y_prob, sample_weight, pos try: pos_label = _check_pos_label_consistency(pos_label, y_true) except ValueError: - classes = np.unique(y_true) - if classes.dtype.kind not in ("O", "U", "S"): - # for backward compatibility, if classes are not string then - # `pos_label` will correspond to the greater label + xp_y_true, _ = get_namespace(y_true) + classes = xp_y_true.unique_values(y_true) + # For backward compatibility, if classes are not string then + # `pos_label` will correspond to the greater label. + if not (_is_numpy_namespace(xp_y_true) and classes.dtype.kind in "OUS"): pos_label = classes[-1] else: raise @@ -3805,15 +3859,17 @@ def brier_score_loss( @validate_params( { "y_true": ["array-like"], - "y_pred": ["array-like"], + "y_proba": ["array-like", Hidden(None)], "sample_weight": ["array-like", None], "labels": ["array-like", None], + "y_pred": ["array-like", str], }, prefer_skip_nested_validation=True, ) -def d2_log_loss_score(y_true, y_pred, *, sample_weight=None, labels=None): - """ - :math:`D^2` score function, fraction of log loss explained. +def d2_log_loss_score( + y_true, y_proba=None, *, sample_weight=None, labels=None, y_pred="deprecated" +): + """:math:`D^2` score function, fraction of log loss explained. Best possible score is 1.0 and it can be negative (because the model can be arbitrarily worse). A model that always predicts the per-class proportions @@ -3828,14 +3884,17 @@ def d2_log_loss_score(y_true, y_pred, *, sample_weight=None, labels=None): y_true : array-like or label indicator matrix The actuals labels for the n_samples samples. - y_pred : array-like of shape (n_samples, n_classes) or (n_samples,) + y_proba : array-like of shape (n_samples, n_classes) or (n_samples,) Predicted probabilities, as returned by a classifier's - predict_proba method. If ``y_pred.shape = (n_samples,)`` + predict_proba method. If ``y_proba.shape = (n_samples,)`` the probabilities provided are assumed to be that of the - positive class. The labels in ``y_pred`` are assumed to be + positive class. The labels in ``y_proba`` are assumed to be ordered alphabetically, as done by :class:`~sklearn.preprocessing.LabelBinarizer`. + .. versionadded:: 1.9 + `y_pred` was renamed to `y_proba`. + sample_weight : array-like of shape (n_samples,), default=None Sample weights. @@ -3844,6 +3903,18 @@ def d2_log_loss_score(y_true, y_pred, *, sample_weight=None, labels=None): is ``None`` and ``y_pred`` has shape (n_samples,) the labels are assumed to be binary and are inferred from ``y_true``. + y_pred : array-like of shape (n_samples, n_classes) or (n_samples,) + Predicted probabilities, as returned by a classifier's + predict_proba method. If ``y_pred.shape = (n_samples,)`` + the probabilities provided are assumed to be that of the + positive class. The labels in ``y_pred`` are assumed to be + ordered alphabetically, as done by + :class:`~sklearn.preprocessing.LabelBinarizer`. + + .. deprecated:: 1.9 + `y_pred` was deprecated in favor of `y_proba` in v1.9 and will + be removed in v1.11. + Returns ------- d2 : float or ndarray of floats @@ -3859,33 +3930,50 @@ def d2_log_loss_score(y_true, y_pred, *, sample_weight=None, labels=None): This metric is not well-defined for a single sample and will return a NaN value if n_samples is less than two. """ - check_consistent_length(y_pred, y_true, sample_weight) - if _num_samples(y_pred) < 2: + # TODO(1.11): Remove check and remove default value for `y_proba`. + if not (isinstance(y_pred, str) and y_pred == "deprecated"): + if y_proba is not None: + raise ValueError( + "Cannot use both `y_pred` and `y_proba`. `y_pred` is deprecated, " + "use `y_proba` instead." + ) + else: + warnings.warn( + "`y_pred` was renamed to `y_proba` in version 1.9 and will be removed " + "in 1.11. Use `y_proba` instead.", + FutureWarning, + ) + y_proba = y_pred + + check_consistent_length(y_proba, y_true, sample_weight) + if _num_samples(y_proba) < 2: msg = "D^2 score is not well-defined with less than two samples." warnings.warn(msg, UndefinedMetricWarning) return float("nan") - y_pred = check_array(y_pred, ensure_2d=False, dtype="numeric") + xp, _, device_ = get_namespace_and_device(y_proba) + y_proba = check_array( + y_proba, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_) + ) if sample_weight is not None: - xp, _, device_ = get_namespace_and_device(y_pred) sample_weight = move_to(sample_weight, xp=xp, device=device_) - transformed_labels, y_pred = _validate_multiclass_probabilistic_prediction( - y_true, y_pred, sample_weight, labels + transformed_labels, y_proba = _validate_multiclass_probabilistic_prediction( + y_true, y_proba, sample_weight, labels ) - xp, _ = get_namespace(y_pred, transformed_labels) - y_pred_null = _average(transformed_labels, axis=0, weights=sample_weight) - y_pred_null = xp.tile(y_pred_null, (y_pred.shape[0], 1)) + xp, _ = get_namespace(y_proba, transformed_labels) + y_proba_null = _average(transformed_labels, axis=0, weights=sample_weight) + y_proba_null = xp.tile(y_proba_null, (y_proba.shape[0], 1)) numerator = _log_loss( transformed_labels, - y_pred, + y_proba, normalize=False, sample_weight=sample_weight, ) denominator = _log_loss( transformed_labels, - y_pred_null, + y_proba_null, normalize=False, sample_weight=sample_weight, ) @@ -3912,6 +4000,8 @@ def d2_brier_score( ): """:math:`D^2` score function, fraction of Brier score explained. + This is also known as Brier Skill Score (BSS) and scaled Brier score. + Best possible score is 1.0 and it can be negative because the model can be arbitrarily worse than the null model. The null model, also known as the optimal intercept model, is a model that constantly predicts the per-class diff --git a/sklearn/metrics/_pairwise_distances_reduction/_datasets_pair.pyx.tp b/sklearn/metrics/_pairwise_distances_reduction/_datasets_pair.pyx.tp index 67ed362c05884..93c772cfce5ca 100644 --- a/sklearn/metrics/_pairwise_distances_reduction/_datasets_pair.pyx.tp +++ b/sklearn/metrics/_pairwise_distances_reduction/_datasets_pair.pyx.tp @@ -17,7 +17,7 @@ from cython cimport final from sklearn.utils._typedefs cimport float64_t, float32_t, intp_t -from scipy.sparse import issparse, csr_matrix +from scipy.sparse import issparse, csr_matrix, csr_array {{for name_suffix, DistanceMetric, INPUT_DTYPE_t, INPUT_DTYPE in implementation_specific_values}} @@ -64,12 +64,12 @@ cdef class DatasetsPair{{name_suffix}}: ---------- X : {ndarray, sparse matrix} of shape (n_samples_X, n_features) Input data. - If provided as a ndarray, it must be C-contiguous. + If provided as an ndarray, it must be C-contiguous. If provided as a sparse matrix, it must be in CSR format. Y : {ndarray, sparse matrix} of shape (n_samples_Y, n_features) Input data. - If provided as a ndarray, it must be C-contiguous. + If provided as an ndarray, it must be C-contiguous. If provided as a sparse matrix, it must be in CSR format. metric : str or DistanceMetric object, default='euclidean' @@ -124,8 +124,8 @@ cdef class DatasetsPair{{name_suffix}}: return DenseSparseDatasetsPair{{name_suffix}}(X, Y, distance_metric) @classmethod - def unpack_csr_matrix(cls, X: csr_matrix): - """Ensure that the CSR matrix is indexed with np.int32.""" + def unpack_csr(cls, X: csr_matrix | csr_array): + """Ensure that the CSR sparse is indexed with np.int32.""" X_data = np.asarray(X.data, dtype={{INPUT_DTYPE}}) X_indices = np.asarray(X.indices, dtype=np.int32) X_indptr = np.asarray(X.indptr, dtype=np.int32) @@ -223,8 +223,8 @@ cdef class SparseSparseDatasetsPair{{name_suffix}}(DatasetsPair{{name_suffix}}): def __init__(self, X, Y, {{DistanceMetric}} distance_metric): super().__init__(distance_metric, n_features=X.shape[1]) - self.X_data, self.X_indices, self.X_indptr = self.unpack_csr_matrix(X) - self.Y_data, self.Y_indices, self.Y_indptr = self.unpack_csr_matrix(Y) + self.X_data, self.X_indices, self.X_indptr = self.unpack_csr(X) + self.Y_data, self.Y_indices, self.Y_indptr = self.unpack_csr(Y) @final cdef intp_t n_samples_X(self) noexcept nogil: @@ -269,7 +269,7 @@ cdef class SparseDenseDatasetsPair{{name_suffix}}(DatasetsPair{{name_suffix}}): Parameters ---------- - X: sparse matrix of shape (n_samples_X, n_features) + X: sparse matrix/array of shape (n_samples_X, n_features) Rows represent vectors. Must be in CSR format. Y: ndarray of shape (n_samples_Y, n_features) @@ -283,7 +283,7 @@ cdef class SparseDenseDatasetsPair{{name_suffix}}(DatasetsPair{{name_suffix}}): def __init__(self, X, Y, {{DistanceMetric}} distance_metric): super().__init__(distance_metric, n_features=X.shape[1]) - self.X_data, self.X_indices, self.X_indptr = self.unpack_csr_matrix(X) + self.X_data, self.X_indices, self.X_indptr = self.unpack_csr(X) # We support the sparse-dense case by using the sparse-sparse interfaces # of `DistanceMetric` (namely `DistanceMetric.{dist_csr,rdist_csr}`) to diff --git a/sklearn/metrics/_pairwise_distances_reduction/_middle_term_computer.pyx.tp b/sklearn/metrics/_pairwise_distances_reduction/_middle_term_computer.pyx.tp index 04c1b61310bb7..ea62db24c788c 100644 --- a/sklearn/metrics/_pairwise_distances_reduction/_middle_term_computer.pyx.tp +++ b/sklearn/metrics/_pairwise_distances_reduction/_middle_term_computer.pyx.tp @@ -27,7 +27,7 @@ from sklearn.utils._cython_blas cimport ( from sklearn.utils._typedefs cimport float64_t, float32_t, int32_t, intp_t import numpy as np -from scipy.sparse import issparse, csr_matrix +from scipy.sparse import issparse, csr_matrix, csr_array cdef void _middle_term_sparse_sparse_64( @@ -129,11 +129,11 @@ cdef class MiddleTermComputer{{name_suffix}}: ---------- X : ndarray or CSR sparse matrix of shape (n_samples_X, n_features) Input data. - If provided as a ndarray, it must be C-contiguous. + If provided as an ndarray, it must be C-contiguous. Y : ndarray or CSR sparse matrix of shape (n_samples_Y, n_features) Input data. - If provided as a ndarray, it must be C-contiguous. + If provided as an ndarray, it must be C-contiguous. Returns ------- @@ -197,7 +197,7 @@ cdef class MiddleTermComputer{{name_suffix}}: ) @classmethod - def unpack_csr_matrix(cls, X: csr_matrix): + def unpack_csr(cls, X: csr_matrix | csr_array): """Ensure that the CSR matrix is indexed with np.int32.""" X_data = np.asarray(X.data, dtype=np.float64) X_indices = np.asarray(X.indices, dtype=np.int32) @@ -471,8 +471,8 @@ cdef class SparseSparseMiddleTermComputer{{name_suffix}}(MiddleTermComputer{{nam n_features, chunk_size, ) - self.X_data, self.X_indices, self.X_indptr = self.unpack_csr_matrix(X) - self.Y_data, self.Y_indices, self.Y_indptr = self.unpack_csr_matrix(Y) + self.X_data, self.X_indices, self.X_indptr = self.unpack_csr(X) + self.Y_data, self.Y_indices, self.Y_indptr = self.unpack_csr(Y) cdef void _parallel_on_X_pre_compute_and_reduce_distances_on_chunks( self, @@ -534,7 +534,7 @@ cdef class SparseSparseMiddleTermComputer{{name_suffix}}(MiddleTermComputer{{nam return dist_middle_terms cdef class SparseDenseMiddleTermComputer{{name_suffix}}(MiddleTermComputer{{name_suffix}}): - """Middle term of the Euclidean distance between chunks of a CSR matrix and a np.ndarray. + """Middle term of the Euclidean distance between chunks of a CSR matrix and an np.ndarray. The logic of the computation is wrapped in the routine _middle_term_sparse_dense_{{name_suffix}}. This routine iterates over the data, indices and indptr arrays of the sparse matrices @@ -559,7 +559,7 @@ cdef class SparseDenseMiddleTermComputer{{name_suffix}}(MiddleTermComputer{{name n_features, chunk_size, ) - self.X_data, self.X_indices, self.X_indptr = self.unpack_csr_matrix(X) + self.X_data, self.X_indices, self.X_indptr = self.unpack_csr(X) self.Y = Y self.c_ordered_middle_term = c_ordered_middle_term diff --git a/sklearn/metrics/_pairwise_distances_reduction/_radius_neighbors.pyx.tp b/sklearn/metrics/_pairwise_distances_reduction/_radius_neighbors.pyx.tp index 5e56cde30e5cd..6003e570ef003 100644 --- a/sklearn/metrics/_pairwise_distances_reduction/_radius_neighbors.pyx.tp +++ b/sklearn/metrics/_pairwise_distances_reduction/_radius_neighbors.pyx.tp @@ -26,7 +26,7 @@ cnp.import_array() cdef cnp.ndarray[object, ndim=1] coerce_vectors_to_nd_arrays( shared_ptr[vector_vector_double_intp_t] vecs ): - """Coerce a std::vector of std::vector to a ndarray of ndarray.""" + """Coerce a std::vector of std::vector to an ndarray of ndarray.""" cdef: intp_t n = deref(vecs).size() cnp.ndarray[object, ndim=1] nd_arrays_of_nd_arrays = np.empty(n, dtype=np.ndarray) diff --git a/sklearn/metrics/_pairwise_distances_reduction/meson.build b/sklearn/metrics/_pairwise_distances_reduction/meson.build index 0f7eaa286399c..7ae3417668a7e 100644 --- a/sklearn/metrics/_pairwise_distances_reduction/meson.build +++ b/sklearn/metrics/_pairwise_distances_reduction/meson.build @@ -31,7 +31,7 @@ _datasets_pair_pyx = custom_target( output: '_datasets_pair.pyx', input: '_datasets_pair.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_datasets_pair_pxd, _pairwise_distances_reduction_cython_tree, utils_cython_tree], @@ -55,7 +55,7 @@ _base_pyx = custom_target( output: '_base.pyx', input: '_base.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_base_pxd, _pairwise_distances_reduction_cython_tree, @@ -80,7 +80,7 @@ _middle_term_computer_pyx = custom_target( output: '_middle_term_computer.pyx', input: '_middle_term_computer.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_middle_term_computer_pxd, @@ -106,7 +106,7 @@ _argkmin_pyx = custom_target( output: '_argkmin.pyx', input: '_argkmin.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_argkmin_pxd, @@ -132,7 +132,7 @@ _radius_neighbors_pyx = custom_target( output: '_radius_neighbors.pyx', input: '_radius_neighbors.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_radius_neighbors_pxd, @@ -152,7 +152,7 @@ _argkmin_classmode_pyx = custom_target( output: '_argkmin_classmode.pyx', input: '_argkmin_classmode.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_classmode_pxd, @@ -176,7 +176,7 @@ _radius_neighbors_classmode_pyx = custom_target( output: '_radius_neighbors_classmode.pyx', input: '_radius_neighbors_classmode.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [_classmode_pxd, diff --git a/sklearn/metrics/_plot/det_curve.py b/sklearn/metrics/_plot/det_curve.py index 01b6f34e776df..b428aca90177a 100644 --- a/sklearn/metrics/_plot/det_curve.py +++ b/sklearn/metrics/_plot/det_curve.py @@ -231,8 +231,8 @@ def from_predictions( y_score : array-like of shape (n_samples,) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by `decision_function` on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). .. versionadded:: 1.8 `y_pred` has been renamed to `y_score`. @@ -262,8 +262,8 @@ def from_predictions( y_pred : array-like of shape (n_samples,) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by โ€œdecision_functionโ€ on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). .. deprecated:: 1.8 `y_pred` is deprecated and will be removed in 1.10. Use diff --git a/sklearn/metrics/_plot/precision_recall_curve.py b/sklearn/metrics/_plot/precision_recall_curve.py index 43d24cac4d530..dd94cdb9584f3 100644 --- a/sklearn/metrics/_plot/precision_recall_curve.py +++ b/sklearn/metrics/_plot/precision_recall_curve.py @@ -1,16 +1,20 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -from collections import Counter +import numpy as np from sklearn.metrics._ranking import average_precision_score, precision_recall_curve +from sklearn.utils import _safe_indexing, check_array from sklearn.utils._plotting import ( _BinaryClassifierCurveDisplayMixin, + _check_param_lengths, + _convert_to_list_leaving_none, _deprecate_estimator_name, _deprecate_y_pred_parameter, _despine, _validate_style_kwargs, ) +from sklearn.utils._response import _get_response_values_binary class PrecisionRecallDisplay(_BinaryClassifierCurveDisplayMixin): @@ -29,34 +33,64 @@ class PrecisionRecallDisplay(_BinaryClassifierCurveDisplayMixin): Parameters ---------- - precision : ndarray - Precision values. + precision : ndarray or list of ndarrays + Precision values. Each ndarray should contain values for a single curve. + If plotting multiple curves, list should be of same length as `recall`. - recall : ndarray - Recall values. + .. versionchanged:: 1.9 + Now accepts a list for plotting multiple curves. - average_precision : float, default=None - Average precision. If None, the average precision is not shown. + recall : ndarray or list of ndarrays + Recall values. Each ndarray should contain values for a single curve. + If plotting multiple curves, list should be of same length as `precision`. - name : str, default=None - Name of estimator. If None, then the estimator name is not shown. + .. versionchanged:: 1.9 + Now accepts a list for plotting multiple curves. + + average_precision : float or list of floats, default=None + Average precision, used for labeling each curve in the legend. + If plotting multiple curves, should be a list of the same length as `precision` + and `recall`. If `None`, average precision values are not shown in the legend. + + .. versionchanged:: 1.9 + Now accepts a list for plotting multiple curves. + + name : str or list of str, default=None + Name for labeling legend entries. The number of legend entries is determined + by the `curve_kwargs` passed to `plot`, and is not affected by `name`. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, no name is shown in the legend. .. versionchanged:: 1.8 `estimator_name` was deprecated in favor of `name`. + .. versionchanged:: 1.9 + `name` can now take a list of str for multiple curves. + pos_label : int, float, bool or str, default=None The class considered the positive class when precision and recall metrics computed. If not `None`, this value is displayed in the x- and y-axes labels. .. versionadded:: 0.24 - prevalence_pos_label : float, default=None + prevalence_pos_label : float or list of floats, default=None The prevalence of the positive label. It is used for plotting the - chance level line. If None, the chance level line will not be plotted + chance level lines. If None, no chance level line will be plotted even if `plot_chance_level` is set to True when plotting. .. versionadded:: 1.3 + .. versionchanged:: 1.9 + May now be list of floats for when multiple curves plotted. + estimator_name : str, default=None Name of estimator. If None, the estimator name is not shown. @@ -66,14 +100,22 @@ class PrecisionRecallDisplay(_BinaryClassifierCurveDisplayMixin): Attributes ---------- - line_ : matplotlib Artist - Precision recall curve. + line_ : matplotlib Artist or list of Artists + Precision recall curve(s). - chance_level_ : matplotlib Artist or None - The chance level line. It is `None` if the chance level is not plotted. + .. versionchanged:: 1.9 + This attribute can now be a list of Artists, for when multiple curves + are plotted. + + chance_level_ : matplotlib Artist or list of Artists or None + Chance level line(s). It is `None` if the chance level is not plotted. .. versionadded:: 1.3 + .. versionchanged:: 1.9 + This attribute can now be a list of Artists, for when multiple curves + are plotted. + ax_ : matplotlib Axes Axes with precision recall curve. @@ -96,10 +138,10 @@ class PrecisionRecallDisplay(_BinaryClassifierCurveDisplayMixin): this metric, the precision-recall curve is plotted without any interpolation as well (step-wise style). - You can change this style by passing the keyword argument - `drawstyle="default"` in :meth:`plot`, :meth:`from_estimator`, or - :meth:`from_predictions`. However, the curve will not be strictly - consistent with the reported average precision. + To enable interpolation, pass `curve_kwargs={"drawstyle": "default"}` to + meth:`plot`, :meth:`from_estimator`, or :meth:`from_predictions`. + However, the curve will not be strictly consistent with the reported + average precision. Examples -------- @@ -134,18 +176,41 @@ def __init__( prevalence_pos_label=None, estimator_name="deprecated", ): - self.name = _deprecate_estimator_name(estimator_name, name, "1.8") self.precision = precision self.recall = recall self.average_precision = average_precision + self.name = _deprecate_estimator_name(estimator_name, name, "1.8") self.pos_label = pos_label self.prevalence_pos_label = prevalence_pos_label + def _validate_plot_params(self, *, ax, name): + self.ax_, self.figure_, name = super()._validate_plot_params(ax=ax, name=name) + + precision = _convert_to_list_leaving_none(self.precision) + recall = _convert_to_list_leaving_none(self.recall) + average_precision = _convert_to_list_leaving_none(self.average_precision) + prevalence_pos_label = _convert_to_list_leaving_none(self.prevalence_pos_label) + name = _convert_to_list_leaving_none(name) + + optional = { + "self.average_precision": average_precision, + "self.prevalence_pos_label": prevalence_pos_label, + } + if isinstance(name, list) and len(name) != 1: + optional.update({"'name' (or self.name)": name}) + _check_param_lengths( + required={"self.precision": precision, "self.recall": recall}, + optional=optional, + class_name="PrecisionRecallDisplay", + ) + return precision, recall, average_precision, name, prevalence_pos_label + def plot( self, ax=None, *, name=None, + curve_kwargs=None, plot_chance_level=False, chance_level_kw=None, despine=False, @@ -153,17 +218,41 @@ def plot( ): """Plot visualization. - Extra keyword arguments will be passed to matplotlib's `plot`. - Parameters ---------- ax : Matplotlib Axes, default=None Axes object to plot on. If `None`, a new figure and axes is created. - name : str, default=None - Name of precision recall curve for labeling. If `None`, use - `name` if not `None`, otherwise no labeling is shown. + name : str or list of str, default=None + Name for labeling legend entries. The number of legend entries + is determined by `curve_kwargs`, and is not affected by `name`. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, set to `name` provided at `PrecisionRecallDisplay` + initialization. If still `None`, no name is shown in the legend. + + .. versionchanged:: 1.9 + Now accepts a list for plotting multiple curves. + + curve_kwargs : dict or list of dict, default=None + Keywords arguments to be passed to matplotlib's `plot` function + to draw individual precision-recall curves. For single curve plotting, this + should be a dictionary. For multi-curve plotting, if a list is provided, + the parameters are applied to each precision-recall curve + sequentially and a legend entry is added for each curve. + If a single dictionary is provided, the same parameters are applied + to all curves and a single legend entry for all curves is added, + labeled with the mean average precision. + + .. versionadded:: 1.9 plot_chance_level : bool, default=False Whether to plot the chance level. The chance level is the prevalence @@ -186,6 +275,10 @@ def plot( **kwargs : dict Keyword arguments to be passed to matplotlib's `plot`. + .. deprecated:: 1.9 + kwargs is deprecated and will be removed in 1.11. Pass matplotlib + arguments to `curve_kwargs` as a dictionary instead. + Returns ------- display : :class:`~sklearn.metrics.PrecisionRecallDisplay` @@ -198,25 +291,41 @@ def plot( with this metric, the precision-recall curve is plotted without any interpolation as well (step-wise style). - You can change this style by passing the keyword argument - `drawstyle="default"`. However, the curve will not be strictly - consistent with the reported average precision. + To enable interpolation, pass `curve_kwargs={"drawstyle": "default"}`. + However, the curve will not be strictly consistent with the reported + average precision. """ - self.ax_, self.figure_, name = self._validate_plot_params(ax=ax, name=name) - - default_line_kwargs = {"drawstyle": "steps-post"} - if self.average_precision is not None and name is not None: - default_line_kwargs["label"] = ( - f"{name} (AP = {self.average_precision:0.2f})" - ) - elif self.average_precision is not None: - default_line_kwargs["label"] = f"AP = {self.average_precision:0.2f}" - elif name is not None: - default_line_kwargs["label"] = name - - line_kwargs = _validate_style_kwargs(default_line_kwargs, kwargs) + precision, recall, average_precision, name, prevalence_pos_label = ( + self._validate_plot_params(ax=ax, name=name) + ) + n_curves = len(precision) + average_precision, legend_metric = self._get_legend_metric( + curve_kwargs, n_curves, average_precision + ) - (self.line_,) = self.ax_.plot(self.recall, self.precision, **line_kwargs) + curve_kwargs = self._validate_curve_kwargs( + n_curves, + name, + legend_metric, + "AP", + curve_kwargs=curve_kwargs, + default_curve_kwargs={"drawstyle": "steps-post"}, + default_multi_curve_kwargs={ + "alpha": 0.5, + "linestyle": "--", + "color": "blue", + }, + removed_version="1.11", + **kwargs, + ) + self.line_ = [] + for recall_val, precision_val, curve_kwarg in zip( + recall, precision, curve_kwargs + ): + self.line_.extend(self.ax_.plot(recall_val, precision_val, **curve_kwarg)) + # Return single artist if only one curve is plotted + if len(self.line_) == 1: + self.line_ = self.line_[0] info_pos_label = ( f" (Positive label: {self.pos_label})" if self.pos_label is not None else "" @@ -243,31 +352,52 @@ def plot( "to automatically set prevalence_pos_label" ) - default_chance_level_line_kw = { - "label": f"Chance level (AP = {self.prevalence_pos_label:0.2f})", + default_chance_level_kwargs = { "color": "k", "linestyle": "--", } + if n_curves > 1: + default_chance_level_kwargs["alpha"] = 0.3 if chance_level_kw is None: chance_level_kw = {} - chance_level_line_kw = _validate_style_kwargs( - default_chance_level_line_kw, chance_level_kw + chance_level_kw = _validate_style_kwargs( + default_chance_level_kwargs, chance_level_kw ) + self.chance_level_ = [] + for prevalence in prevalence_pos_label: + self.chance_level_.extend( + self.ax_.plot( + (0, 1), + (prevalence, prevalence), + **chance_level_kw, + ) + ) - (self.chance_level_,) = self.ax_.plot( - (0, 1), - (self.prevalence_pos_label, self.prevalence_pos_label), - **chance_level_line_kw, - ) + if "label" not in chance_level_kw: + label = ( + f"Chance level (AP = {prevalence_pos_label[0]:0.2f})" + if n_curves == 1 + else f"Chance level (AP = {np.mean(prevalence_pos_label):0.2f} " + f"+/- {np.std(prevalence_pos_label):0.2f})" + ) + # Only label first curve with mean AP, to get single legend entry + self.chance_level_[0].set_label(label) + + if n_curves == 1: + # Return single artist if only one curve is plotted + self.chance_level_ = self.chance_level_[0] else: self.chance_level_ = None if despine: _despine(self.ax_) - if "label" in line_kwargs or plot_chance_level: + # Note: if 'label' present in one `line_kwargs`, it should be present in all + if curve_kwargs[0].get("label") is not None or ( + plot_chance_level and chance_level_kw.get("label") is not None + ): self.ax_.legend(loc="lower left") return self @@ -285,6 +415,7 @@ def from_estimator( pos_label=None, name=None, ax=None, + curve_kwargs=None, plot_chance_level=False, chance_level_kw=None, despine=False, @@ -337,6 +468,11 @@ def from_estimator( ax : matplotlib axes, default=None Axes object to plot on. If `None`, a new figure and axes is created. + curve_kwargs : dict, default=None + Keywords arguments to be passed to matplotlib's `plot` function. + + .. versionadded:: 1.9 + plot_chance_level : bool, default=False Whether to plot the chance level. The chance level is the prevalence of the positive label computed from the data passed during @@ -358,6 +494,10 @@ def from_estimator( **kwargs : dict Keyword arguments to be passed to matplotlib's `plot`. + .. deprecated:: 1.9 + kwargs is deprecated and will be removed in 1.11. Pass matplotlib + arguments to `curve_kwargs` as a dictionary instead. + Returns ------- display : :class:`~sklearn.metrics.PrecisionRecallDisplay` @@ -374,9 +514,9 @@ def from_estimator( with this metric, the precision-recall curve is plotted without any interpolation as well (step-wise style). - You can change this style by passing the keyword argument - `drawstyle="default"`. However, the curve will not be strictly - consistent with the reported average precision. + To enable interpolation, pass `curve_kwargs={"drawstyle": "default"}`. + However, the curve will not be strictly consistent with the reported + average precision. Examples -------- @@ -409,10 +549,11 @@ def from_estimator( y, y_score, sample_weight=sample_weight, - name=name, - pos_label=pos_label, drop_intermediate=drop_intermediate, + pos_label=pos_label, + name=name, ax=ax, + curve_kwargs=curve_kwargs, plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, despine=despine, @@ -430,6 +571,7 @@ def from_predictions( pos_label=None, name=None, ax=None, + curve_kwargs=None, plot_chance_level=False, chance_level_kw=None, despine=False, @@ -477,6 +619,11 @@ def from_predictions( ax : matplotlib axes, default=None Axes object to plot on. If `None`, a new figure and axes is created. + curve_kwargs : dict, default=None + Keywords arguments to be passed to matplotlib's `plot` function. + + .. versionadded:: 1.9 + plot_chance_level : bool, default=False Whether to plot the chance level. The chance level is the prevalence of the positive label computed from the data passed during @@ -505,6 +652,10 @@ def from_predictions( **kwargs : dict Keyword arguments to be passed to matplotlib's `plot`. + .. deprecated:: 1.9 + kwargs is deprecated and will be removed in 1.11. Pass matplotlib + arguments to `curve_kwargs` as a dictionary instead. + Returns ------- display : :class:`~sklearn.metrics.PrecisionRecallDisplay` @@ -521,9 +672,9 @@ def from_predictions( with this metric, the precision-recall curve is plotted without any interpolation as well (step-wise style). - You can change this style by passing the keyword argument - `drawstyle="default"`. However, the curve will not be strictly - consistent with the reported average precision. + To enable interpolation, pass `curve_kwargs={"drawstyle": "default"}`. + However, the curve will not be strictly consistent with the reported + average precision. Examples -------- @@ -544,6 +695,8 @@ def from_predictions( <...> >>> plt.show() """ + + y_true = check_array(y_true, ensure_2d=False, dtype=None) y_score = _deprecate_y_pred_parameter(y_score, y_pred, "1.8") pos_label, name = cls._validate_from_predictions_params( y_true, y_score, sample_weight=sample_weight, pos_label=pos_label, name=name @@ -560,8 +713,7 @@ def from_predictions( y_true, y_score, pos_label=pos_label, sample_weight=sample_weight ) - class_count = Counter(y_true) - prevalence_pos_label = class_count[pos_label] / sum(class_count.values()) + prevalence_pos_label = (y_true == pos_label).sum() / len(y_true) viz = cls( precision=precision, @@ -575,8 +727,199 @@ def from_predictions( return viz.plot( ax=ax, name=name, + curve_kwargs=curve_kwargs, plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, despine=despine, **kwargs, ) + + @classmethod + def from_cv_results( + cls, + cv_results, + X, + y, + *, + sample_weight=None, + drop_intermediate=True, + response_method="auto", + pos_label=None, + name=None, + ax=None, + curve_kwargs=None, + plot_chance_level=False, + chance_level_kwargs=None, + despine=False, + ): + """Plot multi-fold precision-recall curves given cross-validation results. + + .. versionadded:: 1.9 + + Parameters + ---------- + cv_results : dict + Dictionary as returned by :func:`~sklearn.model_selection.cross_validate` + using `return_estimator=True` and `return_indices=True` (i.e., dictionary + should contain the keys "estimator" and "indices"). + + X : {array-like, sparse matrix} of shape (n_samples, n_features) + Input values. + + y : array-like of shape (n_samples,) + Target values. + + sample_weight : array-like of shape (n_samples,), default=None + Sample weights. + + drop_intermediate : bool, default=True + Whether to drop some suboptimal thresholds which would not appear + on a plotted precision-recall curve. This is useful in order to + create lighter precision-recall curves. + + response_method : {'predict_proba', 'decision_function', 'auto'} \ + default='auto' + Specifies whether to use :term:`predict_proba` or + :term:`decision_function` as the target response. If set to 'auto', + :term:`predict_proba` is tried first and if it does not exist + :term:`decision_function` is tried next. + + pos_label : int, float, bool or str, default=None + The class considered as the positive class when computing the precision + and recall metrics. By default, `estimators.classes_[1]` is considered + as the positive class. + + name : str or list of str, default=None + Name for labeling legend entries. The number of legend entries + is determined by `curve_kwargs`, and is not affected by `name`. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, no name is shown in the legend. + + ax : matplotlib axes, default=None + Axes object to plot on. If `None`, a new figure and axes is + created. + + curve_kwargs : dict or list of dict, default=None + Dictionary with keywords passed to the matplotlib's `plot` function + to draw the individual precision-recall curves. If a list is provided, the + parameters are applied to the precision-recall curves of each CV fold + sequentially. If a single dictionary is provided, the same + parameters are applied to all precision-recall curves. + + plot_chance_level : bool, default=False + Whether to plot the chance level lines. + + chance_level_kwargs : dict, default=None + Keyword arguments to be passed to matplotlib's `plot` for rendering + the chance level lines. + + despine : bool, default=False + Whether to remove the top and right spines from the plot. + + Returns + ------- + display : :class:`~sklearn.metrics.PrecisionRecallDisplay` + + See Also + -------- + PrecisionRecallDisplay.from_predictions : Plot precision-recall curve + using estimated probabilities or output of decision function. + PrecisionRecallDisplay.from_estimator : Plot precision-recall curve + using an estimator. + precision_recall_curve : Compute precision-recall pairs for different + probability thresholds. + average_precision_score : Compute average precision (AP) from prediction scores. + + Notes + ----- + The average precision (cf. :func:`~sklearn.metrics.average_precision_score`) + in scikit-learn is computed without any interpolation. To be consistent + with this metric, the precision-recall curve is plotted without any + interpolation as well (step-wise style). + + To enable interpolation, pass `curve_kwargs={"drawstyle": "default"}`. + However, the curve will not be strictly consistent with the reported + average precision. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> from sklearn.datasets import make_classification + >>> from sklearn.metrics import PrecisionRecallDisplay + >>> from sklearn.model_selection import cross_validate + >>> from sklearn.svm import SVC + >>> X, y = make_classification(random_state=0) + >>> clf = SVC(random_state=0) + >>> cv_results = cross_validate( + ... clf, X, y, cv=3, return_estimator=True, return_indices=True) + >>> PrecisionRecallDisplay.from_cv_results(cv_results, X, y) + <...> + >>> plt.show() + """ + cls._validate_from_cv_results_params( + cv_results, X, y, sample_weight=sample_weight + ) + + precision_folds, recall_folds = [], [] + ap_folds, prevalence_pos_label_folds = [], [] + + for estimator, test_indices in zip( + cv_results["estimator"], cv_results["indices"]["test"] + ): + y_true = _safe_indexing(y, test_indices) + y_pred, pos_label_ = _get_response_values_binary( + estimator, + _safe_indexing(X, test_indices), + response_method=response_method, + pos_label=pos_label, + ) + sample_weight_fold = ( + None + if sample_weight is None + else _safe_indexing(sample_weight, test_indices) + ) + precision, recall, _ = precision_recall_curve( + y_true, + y_pred, + pos_label=pos_label_, + sample_weight=sample_weight_fold, + drop_intermediate=drop_intermediate, + ) + # `average_precision_score` is only metric where default `pos_label=1`, + # thus `pos_label` cannot be None and we use `pos_label_` from + # `_get_response_values_binary` + average_precision = average_precision_score( + y_true, y_pred, pos_label=pos_label_, sample_weight=sample_weight_fold + ) + prevalence_pos_label = ( + np.count_nonzero(y_true == pos_label_) / y_true.shape[0] + ) + + precision_folds.append(precision) + recall_folds.append(recall) + ap_folds.append(average_precision) + prevalence_pos_label_folds.append(prevalence_pos_label) + + viz = cls( + precision=precision_folds, + recall=recall_folds, + average_precision=ap_folds, + name=name, + pos_label=pos_label_, + prevalence_pos_label=prevalence_pos_label_folds, + ) + return viz.plot( + ax=ax, + curve_kwargs=curve_kwargs, + plot_chance_level=plot_chance_level, + chance_level_kw=chance_level_kwargs, + despine=despine, + ) diff --git a/sklearn/metrics/_plot/roc_curve.py b/sklearn/metrics/_plot/roc_curve.py index 0ea96733dcf4f..4cf2257f64435 100644 --- a/sklearn/metrics/_plot/roc_curve.py +++ b/sklearn/metrics/_plot/roc_curve.py @@ -2,16 +2,12 @@ # SPDX-License-Identifier: BSD-3-Clause -import numpy as np - from sklearn.metrics._ranking import auc, roc_curve from sklearn.utils import _safe_indexing from sklearn.utils._plotting import ( _BinaryClassifierCurveDisplayMixin, _check_param_lengths, _convert_to_list_leaving_none, - _deprecate_estimator_name, - _deprecate_y_pred_parameter, _despine, _validate_style_kwargs, ) @@ -60,15 +56,20 @@ class RocCurveDisplay(_BinaryClassifierCurveDisplayMixin): name : str or list of str, default=None Name for labeling legend entries. The number of legend entries is determined by the `curve_kwargs` passed to `plot`, and is not affected by `name`. - To label each curve, provide a list of strings. To avoid labeling - individual curves that have the same appearance, a list cannot be used in - conjunction with `curve_kwargs` being a dictionary or None. If a - string is provided, it will be used to either label the single legend entry - or if there are multiple legend entries, label each individual curve with - the same name. If `None`, no name is shown in the legend. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, no name is shown in the legend. .. versionchanged:: 1.7 - `estimator_name` was deprecated in favor of `name`. + `estimator_name` was deprecated in favor of `name` and now accepts + a list for plotting multiple curves. pos_label : int, float, bool or str, default=None The class considered the positive class when ROC AUC metrics computed. @@ -76,13 +77,6 @@ class RocCurveDisplay(_BinaryClassifierCurveDisplayMixin): .. versionadded:: 0.24 - estimator_name : str, default=None - Name of estimator. If None, the estimator name is not shown. - - .. deprecated:: 1.7 - `estimator_name` is deprecated and will be removed in 1.9. Use `name` - instead. - Attributes ---------- line_ : matplotlib Artist or list of matplotlib Artists @@ -138,12 +132,11 @@ def __init__( roc_auc=None, name=None, pos_label=None, - estimator_name="deprecated", ): self.fpr = fpr self.tpr = tpr self.roc_auc = roc_auc - self.name = _deprecate_estimator_name(estimator_name, name, "1.7") + self.name = name self.pos_label = pos_label def _validate_plot_params(self, *, ax, name): @@ -173,7 +166,6 @@ def plot( plot_chance_level=False, chance_level_kw=None, despine=False, - **kwargs, ): """Plot visualization. @@ -184,15 +176,19 @@ def plot( created. name : str or list of str, default=None - Name for labeling legend entries. The number of legend entries - is determined by `curve_kwargs`, and is not affected by `name`. - To label each curve, provide a list of strings. To avoid labeling - individual curves that have the same appearance, a list cannot be used in - conjunction with `curve_kwargs` being a dictionary or None. If a - string is provided, it will be used to either label the single legend entry - or if there are multiple legend entries, label each individual curve with - the same name. If `None`, set to `name` provided at `RocCurveDisplay` - initialization. If still `None`, no name is shown in the legend. + Name for labeling legend entries. The number of legend entries is determined + by the `curve_kwargs` passed to `plot`, and is not affected by `name`. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, set to `name` provided at `RocCurveDisplay` initialization. If + still `None`, no name is shown in the legend. .. versionadded:: 1.7 @@ -224,13 +220,6 @@ def plot( .. versionadded:: 1.6 - **kwargs : dict - Keyword arguments to be passed to matplotlib's `plot`. - - .. deprecated:: 1.7 - kwargs is deprecated and will be removed in 1.9. Pass matplotlib - arguments to `curve_kwargs` as a dictionary instead. - Returns ------- display : :class:`~sklearn.metrics.RocCurveDisplay` @@ -238,14 +227,9 @@ def plot( """ fpr, tpr, roc_auc, name = self._validate_plot_params(ax=ax, name=name) n_curves = len(fpr) - if not isinstance(curve_kwargs, list) and n_curves > 1: - if roc_auc: - legend_metric = {"mean": np.mean(roc_auc), "std": np.std(roc_auc)} - else: - legend_metric = {"mean": None, "std": None} - else: - roc_auc = roc_auc if roc_auc is not None else [None] * n_curves - legend_metric = {"metric": roc_auc} + roc_auc, legend_metric = self._get_legend_metric( + curve_kwargs, n_curves, roc_auc + ) curve_kwargs = self._validate_curve_kwargs( n_curves, @@ -258,7 +242,6 @@ def plot( "linestyle": "--", "color": "blue", }, - **kwargs, ) default_chance_level_line_kw = { @@ -327,7 +310,6 @@ def from_estimator( plot_chance_level=False, chance_level_kw=None, despine=False, - **kwargs, ): """Create a ROC Curve display from an estimator. @@ -397,13 +379,6 @@ def from_estimator( .. versionadded:: 1.6 - **kwargs : dict - Keyword arguments to be passed to matplotlib's `plot`. - - .. deprecated:: 1.7 - kwargs is deprecated and will be removed in 1.9. Pass matplotlib - arguments to `curve_kwargs` as a dictionary instead. - Returns ------- display : :class:`~sklearn.metrics.RocCurveDisplay` @@ -455,7 +430,6 @@ def from_estimator( plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, despine=despine, - **kwargs, ) @classmethod @@ -473,8 +447,6 @@ def from_predictions( plot_chance_level=False, chance_level_kw=None, despine=False, - y_pred="deprecated", - **kwargs, ): """Plot ROC curve given the true and predicted values. @@ -492,8 +464,8 @@ def from_predictions( y_score : array-like of shape (n_samples,) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by โ€œdecision_functionโ€ on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). .. versionadded:: 1.7 `y_pred` has been renamed to `y_score`. @@ -541,22 +513,6 @@ def from_predictions( .. versionadded:: 1.6 - y_pred : array-like of shape (n_samples,) - Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by โ€œdecision_functionโ€ on some classifiers). - - .. deprecated:: 1.7 - `y_pred` is deprecated and will be removed in 1.9. Use - `y_score` instead. - - **kwargs : dict - Additional keywords arguments passed to matplotlib `plot` function. - - .. deprecated:: 1.7 - kwargs is deprecated and will be removed in 1.9. Pass matplotlib - arguments to `curve_kwargs` as a dictionary instead. - Returns ------- display : :class:`~sklearn.metrics.RocCurveDisplay` @@ -587,7 +543,6 @@ def from_predictions( <...> >>> plt.show() """ - y_score = _deprecate_y_pred_parameter(y_score, y_pred, "1.7") pos_label_validated, name = cls._validate_from_predictions_params( y_true, y_score, sample_weight=sample_weight, pos_label=pos_label, name=name ) @@ -615,7 +570,6 @@ def from_predictions( plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, despine=despine, - **kwargs, ) @classmethod @@ -678,14 +632,18 @@ def from_cv_results( created. name : str or list of str, default=None - Name for labeling legend entries. The number of legend entries - is determined by `curve_kwargs`, and is not affected by `name`. - To label each curve, provide a list of strings. To avoid labeling - individual curves that have the same appearance, a list cannot be used in - conjunction with `curve_kwargs` being a dictionary or None. If a - string is provided, it will be used to either label the single legend entry - or if there are multiple legend entries, label each individual curve with - the same name. If `None`, no name is shown in the legend. + Name for labeling legend entries. The number of legend entries is determined + by the `curve_kwargs` passed to `plot`, and is not affected by `name`. + + If a string is provided, it will be used to either label the single legend + entry or if there are multiple legend entries, label each individual curve + with the same name. + + If a list is provided, it will be used to label each curve individually. + Passing a list will raise an error if `curve_kwargs` is not a list to avoid + labeling individual curves that have the same appearance. + + If `None`, no name is shown in the legend. curve_kwargs : dict or list of dict, default=None Keywords arguments to be passed to matplotlib's `plot` function @@ -747,7 +705,7 @@ def from_cv_results( cv_results["estimator"], cv_results["indices"]["test"] ): y_true = _safe_indexing(y, test_indices) - y_pred, pos_label_ = _get_response_values_binary( + y_score, pos_label_ = _get_response_values_binary( estimator, _safe_indexing(X, test_indices), response_method=response_method, @@ -760,7 +718,7 @@ def from_cv_results( ) fpr, tpr, _ = roc_curve( y_true, - y_pred, + y_score, pos_label=pos_label_, sample_weight=sample_weight_fold, drop_intermediate=drop_intermediate, diff --git a/sklearn/metrics/_plot/tests/test_common_curve_display.py b/sklearn/metrics/_plot/tests/test_common_curve_display.py index 675cb26e17fba..b9433571cbef8 100644 --- a/sklearn/metrics/_plot/tests/test_common_curve_display.py +++ b/sklearn/metrics/_plot/tests/test_common_curve_display.py @@ -1,10 +1,13 @@ +from collections.abc import Mapping +from contextlib import suppress + import numpy as np import pytest from sklearn.base import BaseEstimator, ClassifierMixin, clone from sklearn.calibration import CalibrationDisplay from sklearn.compose import make_column_transformer -from sklearn.datasets import load_iris +from sklearn.datasets import load_breast_cancer, load_iris, make_classification from sklearn.exceptions import NotFittedError from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( @@ -14,9 +17,11 @@ PredictionErrorDisplay, RocCurveDisplay, ) +from sklearn.model_selection import cross_validate, train_test_split from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor +from sklearn.utils import shuffle @pytest.fixture(scope="module") @@ -30,13 +35,105 @@ def data_binary(data): return X[y < 2], y[y < 2] +def _check_pos_label_statistics( + display_class, response_method, constructor_name, check_metric +): + """Test switching `pos_label` gives correct statistics, using imbalanced data.""" + X, y = load_breast_cancer(return_X_y=True) + # create highly imbalanced classes + idx_positive = np.flatnonzero(y == 1) + idx_negative = np.flatnonzero(y == 0) + idx_selected = np.hstack([idx_negative, idx_positive[:25]]) + X, y = X[idx_selected], y[idx_selected] + X, y = shuffle(X, y, random_state=42) + # only use 2 features to make the problem even harder + X = X[:, :2] + y = np.array(["cancer" if c == 1 else "not cancer" for c in y], dtype=object) + X_train, X_test, y_train, y_test = train_test_split( + X, + y, + stratify=y, + random_state=0, + ) + + classifier = LogisticRegression() + classifier.fit(X_train, y_train) + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + + # Sanity check to be sure the positive class is `classes_[0]`. + # Class imbalance ensures a large difference in prediction values between classes, + # allowing us to catch errors when we switch `pos_label`. + assert classifier.classes_.tolist() == ["cancer", "not cancer"] + + y_score = getattr(classifier, response_method)(X_test) + # we select the corresponding probability columns or reverse the decision + # function otherwise + y_score_cancer = -1 * y_score if y_score.ndim == 1 else y_score[:, 0] + y_score_not_cancer = y_score if y_score.ndim == 1 else y_score[:, 1] + + pos_label = "cancer" + y_score = y_score_cancer + if constructor_name == "from_estimator": + display = display_class.from_estimator( + classifier, + X_test, + y_test, + pos_label=pos_label, + response_method=response_method, + ) + elif constructor_name == "from_predictions": + display = display_class.from_predictions( + y_test, + y_score, + pos_label=pos_label, + ) + else: # constructor_name = "from_cv_results" + display = display_class.from_cv_results( + cv_results, + X, + y, + response_method=response_method, + pos_label=pos_label, + ) + + check_metric(display, constructor_name, pos_label) + + pos_label = "not cancer" + y_score = y_score_not_cancer + if constructor_name == "from_estimator": + display = display_class.from_estimator( + classifier, + X_test, + y_test, + response_method=response_method, + pos_label=pos_label, + ) + elif constructor_name == "from_predictions": + display = display_class.from_predictions( + y_test, + y_score, + pos_label=pos_label, + ) + else: # constructor_name = "from_cv_results" + display = display_class.from_cv_results( + cv_results, + X, + y, + response_method=response_method, + pos_label=pos_label, + ) + + check_metric(display, constructor_name, pos_label) + + @pytest.mark.parametrize( "Display", [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) -def test_display_curve_error_classifier(pyplot, data, data_binary, Display): - """Check that a proper error is raised when only binary classification is - supported.""" +def test_display_curve_error_binary_classifier(pyplot, data, data_binary, Display): + """Check correct error raised when only binary classification supported.""" X, y = data X_binary, y_binary = data_binary clf = DecisionTreeClassifier().fit(X, y) @@ -110,15 +207,14 @@ def test_display_curve_error_regression(pyplot, data_binary, Display): @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) -def test_display_curve_error_no_response( +def test_display_curve_error_no_response_method( pyplot, data_binary, response_method, msg, Display, ): - """Check that a proper error is raised when the response method requested - is not defined for the given trained classifier.""" + """Check error raised when `response_method` not defined for `estimator`.""" X, y = data_binary class MyClassifier(ClassifierMixin, BaseEstimator): @@ -133,29 +229,37 @@ def fit(self, X, y): @pytest.mark.parametrize( - "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] + "Display", + [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) -@pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) -def test_display_curve_estimator_name_multiple_calls( +@pytest.mark.parametrize( + "constructor_name", ["from_estimator", "from_predictions", "from_cv_results"] +) +def test_display_curve_name_overwritten_by_plot_multiple_calls( pyplot, data_binary, Display, constructor_name, ): - """Check that passing `name` when calling `plot` will overwrite the original name - in the legend.""" + """Check passing `name` in `plot` overwrites name passed in `from_*` method.""" X, y = data_binary clf_name = "my hand-crafted name" clf = LogisticRegression().fit(X, y) y_pred = clf.predict_proba(X)[:, 1] - - # safe guard for the binary if/else construction - assert constructor_name in ("from_estimator", "from_predictions") + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) if constructor_name == "from_estimator": disp = Display.from_estimator(clf, X, y, name=clf_name) - else: + elif constructor_name == "from_predictions": disp = Display.from_predictions(y, y_pred, name=clf_name) + else: # constructor_name = "from_cv_results" + if Display in (RocCurveDisplay, PrecisionRecallDisplay): + disp = Display.from_cv_results(cv_results, X, y, name=clf_name) + else: + pytest.skip(f"`from_cv_results` not implemented in {Display}") + # TODO: Clean-up once `estimator_name` deprecated in all displays if Display in (PrecisionRecallDisplay, RocCurveDisplay): assert disp.name == clf_name @@ -163,11 +267,17 @@ def test_display_curve_estimator_name_multiple_calls( assert disp.estimator_name == clf_name pyplot.close("all") disp.plot() - assert clf_name in disp.line_.get_label() + if constructor_name == "from_cv_results": + assert clf_name in disp.line_[0].get_label() + else: + assert clf_name in disp.line_.get_label() pyplot.close("all") clf_name = "another_name" disp.plot(name=clf_name) - assert clf_name in disp.line_.get_label() + if constructor_name == "from_cv_results": + assert clf_name in disp.line_[0].get_label() + else: + assert clf_name in disp.line_.get_label() @pytest.mark.parametrize( @@ -181,11 +291,11 @@ def test_display_curve_estimator_name_multiple_calls( ], ) @pytest.mark.parametrize( - "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] + "Display", + [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) -def test_display_curve_not_fitted_errors_old_name(pyplot, data_binary, clf, Display): - """Check that a proper error is raised when the classifier is not - fitted.""" +def test_display_curve_not_fitted_errors(pyplot, data_binary, clf, Display): + """Check correct error raised when `estimator` is not fitted.""" X, y = data_binary # clone since we parametrize the test and the classifier will be fitted # when testing the second and subsequent plotting function @@ -203,36 +313,11 @@ def test_display_curve_not_fitted_errors_old_name(pyplot, data_binary, clf, Disp @pytest.mark.parametrize( - "clf", - [ - LogisticRegression(), - make_pipeline(StandardScaler(), LogisticRegression()), - make_pipeline( - make_column_transformer((StandardScaler(), [0, 1])), LogisticRegression() - ), - ], -) -@pytest.mark.parametrize("Display", [RocCurveDisplay]) -def test_display_curve_not_fitted_errors(pyplot, data_binary, clf, Display): - """Check that a proper error is raised when the classifier is not fitted.""" - X, y = data_binary - # clone since we parametrize the test and the classifier will be fitted - # when testing the second and subsequent plotting function - model = clone(clf) - with pytest.raises(NotFittedError): - Display.from_estimator(model, X, y) - model.fit(X, y) - disp = Display.from_estimator(model, X, y) - assert model.__class__.__name__ in disp.line_.get_label() - assert disp.name == model.__class__.__name__ - - -@pytest.mark.parametrize( - "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] + "Display", + [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) def test_display_curve_n_samples_consistency(pyplot, data_binary, Display): - """Check the error raised when `y_pred` or `sample_weight` have inconsistent - length.""" + """Check error raised when `y_pred` or `sample_weight` have inconsistent length.""" X, y = data_binary classifier = DecisionTreeClassifier().fit(X, y) @@ -241,15 +326,20 @@ def test_display_curve_n_samples_consistency(pyplot, data_binary, Display): Display.from_estimator(classifier, X[:-2], y) with pytest.raises(ValueError, match=msg): Display.from_estimator(classifier, X, y[:-2]) - with pytest.raises(ValueError, match=msg): - Display.from_estimator(classifier, X, y, sample_weight=np.ones(X.shape[0] - 2)) + # `CalibrationDisplay` does not support `sample_weight` + if Display != CalibrationDisplay: + with pytest.raises(ValueError, match=msg): + Display.from_estimator( + classifier, X, y, sample_weight=np.ones(X.shape[0] - 2) + ) @pytest.mark.parametrize( - "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] + "Display", + [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) def test_display_curve_error_pos_label(pyplot, data_binary, Display): - """Check consistence of error message when `pos_label` should be specified.""" + """Check consistency of error message when `pos_label` should be specified.""" X, y = data_binary y = y + 10 @@ -273,7 +363,7 @@ def test_display_curve_error_pos_label(pyplot, data_binary, Display): ) @pytest.mark.parametrize( "constructor", - ["from_predictions", "from_estimator"], + ["from_predictions", "from_estimator", "from_cv_results"], ) def test_classifier_display_curve_named_constructor_return_type( pyplot, data_binary, Display, constructor @@ -290,19 +380,420 @@ def test_classifier_display_curve_named_constructor_return_type( y_pred = y classifier = LogisticRegression().fit(X, y) + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) class SubclassOfDisplay(Display): pass if constructor == "from_predictions": curve = SubclassOfDisplay.from_predictions(y, y_pred) - else: # constructor == "from_estimator" + elif constructor == "from_estimator": curve = SubclassOfDisplay.from_estimator(classifier, X, y) + else: # `from_cv_results` + if Display in (RocCurveDisplay, PrecisionRecallDisplay): + curve = SubclassOfDisplay.from_cv_results(cv_results, X, y) + else: + pytest.skip(f"`from_cv_results` not implemented in {Display}") assert isinstance(curve, SubclassOfDisplay) -# TODO(1.10): Remove once deprecated in all Displays +@pytest.mark.parametrize( + "Display, display_args", + [ + ( + PrecisionRecallDisplay, + { + "precision": np.array([1, 0.5, 0]), + "recall": [np.array([0, 0.5, 1])], + "average_precision": None, + "name": "test_curve", + "prevalence_pos_label": 0.5, + }, + ), + ( + RocCurveDisplay, + { + "fpr": np.array([0, 0.5, 1]), + "tpr": [np.array([0, 0.5, 1])], + "roc_auc": None, + "name": "test_curve", + }, + ), + ], +) +def test_display_validate_plot_params(pyplot, Display, display_args): + """Check `_validate_plot_params` returns the correct variables. + + `display_args` should be given in the same order as output by + `_validate_plot_params`. All `display_args` should be for a single curve. + """ + display = Display(**display_args) + results = display._validate_plot_params(ax=None, name=None) + + # Check if the number of parameters match + assert len(results) == len(display_args) + + for idx, (param, value) in enumerate(display_args.items()): + if param == "name": + assert results[idx] == [value] if isinstance(value, str) else value + elif value is None: + assert results[idx] is None + else: + assert isinstance(results[idx], list) + assert len(results[idx]) == 1 + + +auc_metrics = [[1.0, 1.0, 1.0], None] + + +@pytest.mark.parametrize( + "Display, auc_metric_name, auc_arg_name, display_args", + [ + pytest.param( + RocCurveDisplay, + "AUC", + "roc_auc", + { + "fpr": [np.array([0, 0.5, 1])] * 3, + "tpr": [np.array([0, 0.5, 1])] * 3, + "roc_auc": auc_metric, + }, + ) + for auc_metric in auc_metrics + ] + + [ + pytest.param( + PrecisionRecallDisplay, + "AP", + "average_precision", + { + "precision": [np.array([1, 0.5, 0])] * 3, + "recall": [np.array([0, 0.5, 1])] * 3, + "average_precision": auc_metric, + }, + ) + for auc_metric in auc_metrics + ], +) +@pytest.mark.parametrize( + "curve_kwargs", + [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], +) +@pytest.mark.parametrize("name", [None, "single", ["one", "two", "three"]]) +def test_display_plot_legend_label( + pyplot, Display, auc_metric_name, auc_arg_name, display_args, curve_kwargs, name +): + """Check legend label correct with all `curve_kwargs`, `name` combinations. + + Checks `from_estimator` and `from_predictions` methods, when plotting multiple + curves. + """ + if not isinstance(curve_kwargs, list) and isinstance(name, list): + with pytest.raises(ValueError, match="To avoid labeling individual curves"): + Display(**display_args).plot(name=name, curve_kwargs=curve_kwargs) + return + + display = Display(**display_args).plot(name=name, curve_kwargs=curve_kwargs) + legend = display.ax_.get_legend() + auc_metric = display_args[auc_arg_name] + + if legend is None: + # No legend is created, exit test early + assert name is None + assert auc_metric is None + return + else: + legend_labels = [text.get_text() for text in legend.get_texts()] + + if isinstance(curve_kwargs, list): + # Multiple labels in legend + assert len(legend_labels) == 3 + for idx, label in enumerate(legend_labels): + if name is None: + expected_label = f"{auc_metric_name} = 1.00" if auc_metric else None + assert label == expected_label + elif isinstance(name, str): + expected_label = ( + f"single ({auc_metric_name} = 1.00)" if auc_metric else "single" + ) + assert label == expected_label + else: + # `name` is a list of different strings + expected_label = ( + f"{name[idx]} ({auc_metric_name} = 1.00)" + if auc_metric + else f"{name[idx]}" + ) + assert label == expected_label + else: + # Single label in legend + assert len(legend_labels) == 1 + if name is None: + expected_label = ( + f"{auc_metric_name} = 1.00 +/- 0.00" if auc_metric else None + ) + assert legend_labels[0] == expected_label + else: + # name is single string + expected_label = ( + f"single ({auc_metric_name} = 1.00 +/- 0.00)" + if auc_metric + else "single" + ) + assert legend_labels[0] == expected_label + # Close plots, prevents "more than 20 figures" opened warning + pyplot.close("all") + + +@pytest.mark.parametrize("Display", [PrecisionRecallDisplay, RocCurveDisplay]) +@pytest.mark.parametrize( + "constructor_name, expected_clf_name", + [ + ("from_estimator", "LogisticRegression"), + ("from_predictions", "Classifier"), + ], +) +def test_display_default_name( + pyplot, + data_binary, + constructor_name, + expected_clf_name, + Display, +): + # Check the default name display in the figure when `name` is not provided + X, y = data_binary + + lr = LogisticRegression().fit(X, y) + y_score = lr.predict_proba(X)[:, 1] + + if constructor_name == "from_estimator": + disp = Display.from_estimator(lr, X, y) + else: # constructor_name = "from_predictions" + disp = Display.from_predictions(y, y_score) + + assert expected_clf_name in disp.name + assert expected_clf_name in disp.line_.get_label() + + +@pytest.mark.parametrize( + "Display, auc_metrics, auc_metric_name", + [ + (PrecisionRecallDisplay, [0.97, 1.00, 1.00], "AP"), + (RocCurveDisplay, [0.96, 1.00, 1.00], "AUC"), + ], +) +@pytest.mark.parametrize( + "curve_kwargs", + [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], +) +@pytest.mark.parametrize("name", [None, "single", ["one", "two", "three"]]) +def test_display_from_cv_results_legend_label( + pyplot, Display, auc_metrics, auc_metric_name, curve_kwargs, name +): + """Check legend label correct with all `curve_kwargs`, `name` combinations. + + This function verifies that the legend labels in a Display object created from + cross-validation results are correctly formatted based on the provided parameters. + """ + X, y = X, y = make_classification(n_classes=2, n_samples=50, random_state=0) + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + + if not isinstance(curve_kwargs, list) and isinstance(name, list): + with pytest.raises(ValueError, match="To avoid labeling individual curves"): + Display.from_cv_results( + cv_results, X, y, name=name, curve_kwargs=curve_kwargs + ) + else: + display = Display.from_cv_results( + cv_results, X, y, name=name, curve_kwargs=curve_kwargs + ) + + legend = display.ax_.get_legend() + legend_labels = [text.get_text() for text in legend.get_texts()] + if isinstance(curve_kwargs, list): + # Multiple labels in legend + assert len(legend_labels) == 3 + for idx, label in enumerate(legend_labels): + if name is None: + assert label == f"{auc_metric_name} = {auc_metrics[idx]:.2f}" + elif isinstance(name, str): + assert ( + label == f"single ({auc_metric_name} = {auc_metrics[idx]:.2f})" + ) + else: + # `name` is a list of different strings + assert ( + label + == f"{name[idx]} ({auc_metric_name} = {auc_metrics[idx]:.2f})" + ) + else: + # Single label in legend + assert len(legend_labels) == 1 + if name is None: + assert legend_labels[0] == ( + f"{auc_metric_name} = {np.mean(auc_metrics):.2f} +/- " + f"{np.std(auc_metrics):.2f}" + ) + else: + # name is single string + assert legend_labels[0] == ( + f"single ({auc_metric_name} = {np.mean(auc_metrics):.2f} +/- " + f"{np.std(auc_metrics):.2f})" + ) + # Close plots, prevents "more than 20 figures" opened warning + pyplot.close("all") + + +@pytest.mark.parametrize("Display", [PrecisionRecallDisplay, RocCurveDisplay]) +def test_display_from_cv_results_param_validation(pyplot, data_binary, Display): + """Check parameter validation is correct.""" + X, y = data_binary + + # `cv_results` missing key + cv_results_no_est = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False + ) + cv_results_no_indices = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False + ) + for cv_results in (cv_results_no_est, cv_results_no_indices): + with pytest.raises( + ValueError, + match="`cv_results` does not contain one of the following required", + ): + Display.from_cv_results(cv_results, X, y) + + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + + # `X` wrong length + with pytest.raises(ValueError, match="`X` does not contain the correct"): + Display.from_cv_results(cv_results, X[:10, :], y) + + # `y` not binary + y_multi = y.copy() + y_multi[0] = 2 + with pytest.raises(ValueError, match="The target `y` is not binary."): + Display.from_cv_results(cv_results, X, y_multi) + + # input inconsistent length + with pytest.raises(ValueError, match="Found input variables with inconsistent"): + Display.from_cv_results(cv_results, X, y[:10]) + with pytest.raises(ValueError, match="Found input variables with inconsistent"): + Display.from_cv_results(cv_results, X, y, sample_weight=[1, 2]) + + # `pos_label` inconsistency + y_multi[y_multi == 1] = 2 + with suppress(ValueError): # ignore any `pos_label` side errors + with pytest.warns( + # Also captures subclass warnings e.g., `UndefinedMetricWarning` + UserWarning, + match="No positive .* in y_true", + ): + Display.from_cv_results(cv_results, X, y_multi) + + # `name` is list while `curve_kwargs` is None or dict + for curve_kwargs in (None, {"alpha": 0.2}): + with pytest.raises(ValueError, match="To avoid labeling individual curves"): + Display.from_cv_results( + cv_results, + X, + y, + name=["one", "two", "three"], + curve_kwargs=curve_kwargs, + ) + + # `curve_kwargs` incorrect length + with pytest.raises(ValueError, match="`curve_kwargs` must be None, a dictionary"): + Display.from_cv_results(cv_results, X, y, curve_kwargs=[{"alpha": 1}]) + + # `curve_kwargs` both alias provided + with pytest.raises(TypeError, match="Got both c and"): + Display.from_cv_results( + cv_results, X, y, curve_kwargs={"c": "blue", "color": "red"} + ) + + +@pytest.mark.parametrize("Display", [PrecisionRecallDisplay, RocCurveDisplay]) +def test_display_from_cv_results_pos_label_inferred(pyplot, data_binary, Display): + """Check `pos_label` inferred correctly by `from_cv_results(pos_label=None)`.""" + X, y = data_binary + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + + disp = Display.from_cv_results(cv_results, X, y, pos_label=None) + # Should be `estimator.classes_[1]` + assert disp.pos_label == 1 + + +@pytest.mark.parametrize("Display", [PrecisionRecallDisplay, RocCurveDisplay]) +@pytest.mark.parametrize( + "curve_kwargs", + [None, {"alpha": 0.2}, [{"alpha": 0.2}, {"alpha": 0.3}, {"alpha": 0.4}]], +) +def test_display_from_cv_results_curve_kwargs( + pyplot, data_binary, curve_kwargs, Display +): + """Check `curve_kwargs` correctly passed from `from_cv_results`.""" + X, y = data_binary + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + display = Display.from_cv_results( + cv_results, + X, + y, + curve_kwargs=curve_kwargs, + ) + if curve_kwargs is None: + # Default `alpha` used + assert all(line.get_alpha() == 0.5 for line in display.line_) + elif isinstance(curve_kwargs, Mapping): + # `alpha` from dict used for all curves + assert all(line.get_alpha() == 0.2 for line in display.line_) + else: + # Different `alpha` used for each curve + assert all( + line.get_alpha() == curve_kwargs[i]["alpha"] + for i, line in enumerate(display.line_) + ) + + +@pytest.mark.parametrize("Display", [PrecisionRecallDisplay, RocCurveDisplay]) +@pytest.mark.parametrize( + "curve_kwargs", + [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], +) +def test_display_from_cv_results_curve_kwargs_default_kwargs( + pyplot, data_binary, curve_kwargs, Display +): + """Check `curve_kwargs` and default color handled correctly in `from_cv_results`.""" + + X, y = data_binary + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + display = Display.from_cv_results(cv_results, X, y, curve_kwargs=curve_kwargs) + + for idx, line in enumerate(display.line_): + color = line.get_color() + if curve_kwargs is None: + # Default color + assert color == "blue" + elif isinstance(curve_kwargs, Mapping): + # All curves "red" + assert color == "red" + else: + assert color == curve_kwargs[idx]["c"] + + @pytest.mark.parametrize( "Display, display_kwargs", [ @@ -311,11 +802,49 @@ class SubclassOfDisplay(Display): PrecisionRecallDisplay, {"precision": np.array([1, 0.5, 0]), "recall": np.array([0, 0.5, 1])}, ), - # TODO(1.9): Remove - (RocCurveDisplay, {"fpr": np.array([0, 0.5, 1]), "tpr": np.array([0, 0.5, 1])}), ], ) def test_display_estimator_name_deprecation(pyplot, Display, display_kwargs): """Check deprecation of `estimator_name`.""" with pytest.warns(FutureWarning, match="`estimator_name` is deprecated in"): Display(**display_kwargs, estimator_name="test") + + +@pytest.mark.parametrize( + "Display, display_kwargs", + [ + # TODO(1.11): Remove + ( + PrecisionRecallDisplay, + {"precision": np.array([1, 0.5, 0]), "recall": np.array([0, 0.5, 1])}, + ), + ], +) +@pytest.mark.parametrize( + "constructor_name", ["from_estimator", "from_predictions", "plot"] +) +def test_display_kwargs_deprecation( + pyplot, data_binary, constructor_name, Display, display_kwargs +): + """Check **kwargs deprecated correctly in favour of `curve_kwargs`.""" + X, y = data_binary + lr = LogisticRegression() + lr.fit(X, y) + + # Error when both `curve_kwargs` and `**kwargs` provided + with pytest.raises(ValueError, match="Cannot provide both `curve_kwargs`"): + if constructor_name == "from_estimator": + Display.from_estimator(lr, X, y, curve_kwargs={"alpha": 1}, label="test") + elif constructor_name == "from_predictions": + Display.from_predictions(y, y, curve_kwargs={"alpha": 1}, label="test") + else: # constructor_name = "plot" + Display(**display_kwargs).plot(curve_kwargs={"alpha": 1}, label="test") + + # Warning when `**kwargs`` provided + with pytest.warns(FutureWarning, match=r"`\*\*kwargs` is deprecated and will be"): + if constructor_name == "from_estimator": + Display.from_estimator(lr, X, y, label="test") + elif constructor_name == "from_predictions": + Display.from_predictions(y, y, label="test") + else: # constructor_name = "plot" + Display(**display_kwargs).plot(label="test") diff --git a/sklearn/metrics/_plot/tests/test_precision_recall_display.py b/sklearn/metrics/_plot/tests/test_precision_recall_display.py index 68b187a829061..e7bb8da37fb70 100644 --- a/sklearn/metrics/_plot/tests/test_precision_recall_display.py +++ b/sklearn/metrics/_plot/tests/test_precision_recall_display.py @@ -2,7 +2,6 @@ import numpy as np import pytest -from scipy.integrate import trapezoid from sklearn.compose import make_column_transformer from sklearn.datasets import load_breast_cancer, make_classification @@ -13,25 +12,57 @@ average_precision_score, precision_recall_curve, ) -from sklearn.model_selection import train_test_split +from sklearn.metrics._plot.tests.test_common_curve_display import ( + _check_pos_label_statistics, +) +from sklearn.model_selection import cross_validate from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler -from sklearn.utils import shuffle +from sklearn.utils import _safe_indexing +from sklearn.utils._response import _get_response_values_binary +from sklearn.utils._testing import assert_allclose + + +def _check_figure_axes_and_labels(display, pos_label): + """Check mpl figure and axes are correct.""" + import matplotlib as mpl + + assert isinstance(display.ax_, mpl.axes.Axes) + assert isinstance(display.figure_, mpl.figure.Figure) + + assert display.ax_.get_xlabel() == f"Recall (Positive label: {pos_label})" + assert display.ax_.get_ylabel() == f"Precision (Positive label: {pos_label})" + assert display.ax_.get_adjustable() == "box" + assert display.ax_.get_aspect() in ("equal", 1.0) + assert display.ax_.get_xlim() == display.ax_.get_ylim() == (-0.01, 1.01) @pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) @pytest.mark.parametrize("response_method", ["predict_proba", "decision_function"]) @pytest.mark.parametrize("drop_intermediate", [True, False]) +@pytest.mark.parametrize("with_sample_weight", [True, False]) def test_precision_recall_display_plotting( - pyplot, constructor_name, response_method, drop_intermediate + pyplot, + constructor_name, + response_method, + drop_intermediate, + with_sample_weight, ): """Check the overall plotting rendering.""" + import matplotlib as mpl + X, y = make_classification(n_classes=2, n_samples=50, random_state=0) pos_label = 1 classifier = LogisticRegression().fit(X, y) classifier.fit(X, y) + if with_sample_weight: + rng = np.random.RandomState(42) + sample_weight = rng.randint(1, 4, size=(X.shape[0])) + else: + sample_weight = None + y_score = getattr(classifier, response_method)(X) y_score = y_score if y_score.ndim == 1 else y_score[:, pos_label] @@ -43,37 +74,41 @@ def test_precision_recall_display_plotting( classifier, X, y, + sample_weight=sample_weight, response_method=response_method, drop_intermediate=drop_intermediate, ) else: display = PrecisionRecallDisplay.from_predictions( - y, y_score, pos_label=pos_label, drop_intermediate=drop_intermediate + y, + y_score, + sample_weight=sample_weight, + pos_label=pos_label, + drop_intermediate=drop_intermediate, ) precision, recall, _ = precision_recall_curve( - y, y_score, pos_label=pos_label, drop_intermediate=drop_intermediate + y, + y_score, + pos_label=pos_label, + sample_weight=sample_weight, + drop_intermediate=drop_intermediate, + ) + average_precision = average_precision_score( + y, y_score, pos_label=pos_label, sample_weight=sample_weight ) - average_precision = average_precision_score(y, y_score, pos_label=pos_label) - np.testing.assert_allclose(display.precision, precision) - np.testing.assert_allclose(display.recall, recall) + assert_allclose(display.precision, precision) + assert_allclose(display.recall, recall) assert display.average_precision == pytest.approx(average_precision) - import matplotlib as mpl - + _check_figure_axes_and_labels(display, pos_label) assert isinstance(display.line_, mpl.lines.Line2D) - assert isinstance(display.ax_, mpl.axes.Axes) - assert isinstance(display.figure_, mpl.figure.Figure) - - assert display.ax_.get_xlabel() == "Recall (Positive label: 1)" - assert display.ax_.get_ylabel() == "Precision (Positive label: 1)" - assert display.ax_.get_adjustable() == "box" - assert display.ax_.get_aspect() in ("equal", 1.0) - assert display.ax_.get_xlim() == display.ax_.get_ylim() == (-0.01, 1.01) + # Check default curve kwarg + assert display.line_.get_drawstyle() == "steps-post" # plotting passing some new parameters - display.plot(alpha=0.8, name="MySpecialEstimator") + display.plot(name="MySpecialEstimator", curve_kwargs={"alpha": 0.8}) expected_label = f"MySpecialEstimator (AP = {average_precision:0.2f})" assert display.line_.get_label() == expected_label assert display.line_.get_alpha() == pytest.approx(0.8) @@ -82,14 +117,168 @@ def test_precision_recall_display_plotting( assert display.chance_level_ is None +@pytest.mark.parametrize("response_method", ["predict_proba", "decision_function"]) +@pytest.mark.parametrize("drop_intermediate", [True, False]) +@pytest.mark.parametrize("with_sample_weight", [True, False]) +def test_precision_recall_display_from_cv_results_plotting( + pyplot, response_method, drop_intermediate, with_sample_weight +): + """Check the overall plotting of `from_cv_results`.""" + import matplotlib as mpl + + X, y = make_classification(n_classes=2, n_samples=50, random_state=0) + pos_label = 1 + + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) + + if with_sample_weight: + rng = np.random.RandomState(42) + sample_weight = rng.randint(1, 4, size=(X.shape[0])) + else: + sample_weight = None + + display = PrecisionRecallDisplay.from_cv_results( + cv_results, + X, + y, + sample_weight=sample_weight, + response_method=response_method, + drop_intermediate=drop_intermediate, + pos_label=pos_label, + ) + + for idx, (estimator, test_indices) in enumerate( + zip(cv_results["estimator"], cv_results["indices"]["test"]) + ): + y_true = _safe_indexing(y, test_indices) + y_score = getattr(estimator, response_method)(_safe_indexing(X, test_indices)) + y_score = y_score if y_score.ndim == 1 else y_score[:, 1] + sample_weight_test = ( + _safe_indexing(sample_weight, test_indices) + if sample_weight is not None + else None + ) + precision, recall, _ = precision_recall_curve( + y_true, + y_score, + pos_label=pos_label, + drop_intermediate=drop_intermediate, + sample_weight=sample_weight_test, + ) + average_precision = average_precision_score( + y_true, y_score, pos_label=pos_label, sample_weight=sample_weight_test + ) + + assert_allclose(display.precision[idx], precision) + assert_allclose(display.recall[idx], recall) + assert display.average_precision[idx] == pytest.approx(average_precision) + + assert isinstance(display.line_[idx], mpl.lines.Line2D) + # Check default curve kwarg + assert display.line_[idx].get_drawstyle() == "steps-post" + + _check_figure_axes_and_labels(display, pos_label) + # Check that the chance level line is not plotted by default + assert display.chance_level_ is None + + +@pytest.mark.parametrize( + "params, err_msg", + [ + ( + { + "precision": [np.array([1, 0.5, 0]), np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1])], + "average_precision": None, + "prevalence_pos_label": None, + "name": None, + }, + "self.precision and self.recall from `PrecisionRecallDisplay`", + ), + ( + { + "precision": [np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1]), np.array([0, 0.5, 1])], + "average_precision": [0.8, 0.9], + "prevalence_pos_label": None, + "name": None, + }, + "self.precision, self.recall and self.average_precision", + ), + ( + { + "precision": [np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1]), np.array([0, 0.5, 1])], + "average_precision": [0.8, 0.9], + "prevalence_pos_label": [0.5, 0.5, 0.5], + "name": None, + }, + ( + "self.precision, self.recall, self.average_precision and " + "self.prevalence_pos_label" + ), + ), + ( + { + "precision": [np.array([1, 0.5, 0]), np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1]), np.array([0, 0.5, 1])], + "average_precision": [0.8], + "prevalence_pos_label": [0.8, 0.6, 0.5], + "name": None, + }, + ( + "Got: self.precision: 2, self.recall: 2, self.average_precision: 1, " + "self.prevalence_pos_label: 3" + ), + ), + ( + { + "precision": [np.array([1, 0.5, 0]), np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1]), np.array([0, 0.5, 1])], + "average_precision": [0.8, 0.9], + "prevalence_pos_label": None, + "name": ["curve1", "curve2", "curve3"], + }, + ( + "self.precision, self.recall, self.average_precision and 'name' " + r"\(or self.name\)" + ), + ), + ( + { + "precision": [np.array([1, 0.5, 0]), np.array([1, 0.5, 0])], + "recall": [np.array([0, 0.5, 1]), np.array([0, 0.5, 1])], + "average_precision": [0.8, 0.9], + "prevalence_pos_label": [0.5, 0.4], + # List of length 1 is always allowed + "name": ["curve1"], + }, + None, + ), + ], +) +def test_precision_recall_plot_parameter_length_validation(pyplot, params, err_msg): + """Check `plot` parameter length validation performed correctly.""" + display = PrecisionRecallDisplay(**params) + if err_msg: + with pytest.raises(ValueError, match=err_msg): + display.plot() + else: + # No error should be raised + display.plot() + + +@pytest.mark.parametrize("plot_chance_level", [True, False]) @pytest.mark.parametrize("chance_level_kw", [None, {"color": "r"}, {"c": "r"}]) @pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) def test_precision_recall_chance_level_line( - pyplot, - chance_level_kw, - constructor_name, + pyplot, plot_chance_level, chance_level_kw, constructor_name ): - """Check the chance level line plotting behavior.""" + """Check chance level plotting behavior, for `from_estimator`/`from_predictions`.""" + import matplotlib as mpl + X, y = make_classification(n_classes=2, n_samples=50, random_state=0) pos_prevalence = Counter(y)[1] / len(y) @@ -101,18 +290,21 @@ def test_precision_recall_chance_level_line( lr, X, y, - plot_chance_level=True, + plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, ) else: display = PrecisionRecallDisplay.from_predictions( y, y_score, - plot_chance_level=True, + plot_chance_level=plot_chance_level, chance_level_kw=chance_level_kw, ) - import matplotlib as mpl + if not plot_chance_level: + assert display.chance_level_ is None + # Early return if chance level not plotted + return assert isinstance(display.chance_level_, mpl.lines.Line2D) assert tuple(display.chance_level_.get_xdata()) == (0, 1) @@ -124,12 +316,72 @@ def test_precision_recall_chance_level_line( else: assert display.chance_level_.get_color() == "r" + assert display.chance_level_.get_label() == f"Chance level (AP = {pos_prevalence})" + + +@pytest.mark.parametrize("plot_chance_level", [True, False]) +@pytest.mark.parametrize("chance_level_kw", [None, {"color": "r"}, {"c": "r"}]) +def test_precision_recall_chance_level_line_from_cv_results( + pyplot, plot_chance_level, chance_level_kw +): + """Check chance level plotting behavior for `from_cv_results`.""" + import matplotlib as mpl + + # Note a separate chance line is plotted for each cv split + X, y = make_classification(n_classes=2, n_samples=50, random_state=0) + n_cv = 3 + cv_results = cross_validate( + LogisticRegression(), X, y, cv=n_cv, return_estimator=True, return_indices=True + ) + + display = PrecisionRecallDisplay.from_cv_results( + cv_results, + X, + y, + plot_chance_level=plot_chance_level, + chance_level_kwargs=chance_level_kw, + ) + + if not plot_chance_level: + assert display.chance_level_ is None + # Early return if chance level not plotted + return + + pos_prevalence_folds = [] + for idx in range(n_cv): + assert isinstance(display.chance_level_[idx], mpl.lines.Line2D) + assert tuple(display.chance_level_[idx].get_xdata()) == (0, 1) + test_indices = cv_results["indices"]["test"][idx] + pos_prevalence = Counter(_safe_indexing(y, test_indices))[1] / len(test_indices) + pos_prevalence_folds.append(pos_prevalence) + assert tuple(display.chance_level_[idx].get_ydata()) == ( + pos_prevalence, + pos_prevalence, + ) + + # Checking for chance level line styles + if chance_level_kw is None: + assert display.chance_level_[idx].get_color() == "k" + else: + assert display.chance_level_[idx].get_color() == "r" + + for idx in range(n_cv): + # Only the first chance line should have a label + if idx == 0: + assert display.chance_level_[idx].get_label() == ( + f"Chance level (AP = {np.mean(pos_prevalence_folds):0.2f} +/- " + f"{np.std(pos_prevalence_folds):0.2f})" + ) + else: + assert display.chance_level_[idx].get_label() == f"_child{3 + idx}" + @pytest.mark.parametrize( "constructor_name, default_label", [ ("from_estimator", "LogisticRegression (AP = {:.2f})"), ("from_predictions", "Classifier (AP = {:.2f})"), + ("from_cv_results", "AP = {:.2f} +/- {:.2f}"), ], ) def test_precision_recall_display_name(pyplot, constructor_name, default_label): @@ -137,32 +389,61 @@ def test_precision_recall_display_name(pyplot, constructor_name, default_label): X, y = make_classification(n_classes=2, n_samples=100, random_state=0) pos_label = 1 - classifier = LogisticRegression().fit(X, y) + classifier = LogisticRegression() + n_cv = 3 + cv_results = cross_validate( + classifier, X, y, cv=n_cv, return_estimator=True, return_indices=True + ) classifier.fit(X, y) - y_score = classifier.predict_proba(X)[:, pos_label] - # safe guard for the binary if/else construction - assert constructor_name in ("from_estimator", "from_predictions") - if constructor_name == "from_estimator": display = PrecisionRecallDisplay.from_estimator(classifier, X, y) - else: + elif constructor_name == "from_predictions": display = PrecisionRecallDisplay.from_predictions( y, y_score, pos_label=pos_label ) + else: # constructor_name = "from_cv_results" + display = PrecisionRecallDisplay.from_cv_results(cv_results, X, y) + + if constructor_name == "from_cv_results": + average_precision = [] + for idx in range(n_cv): + test_indices = cv_results["indices"]["test"][idx] + y_score, _ = _get_response_values_binary( + cv_results["estimator"][idx], + _safe_indexing(X, test_indices), + response_method="auto", + ) + average_precision.append( + average_precision_score( + _safe_indexing(y, test_indices), y_score, pos_label=pos_label + ) + ) + # By default, only the first curve is labelled + assert display.line_[0].get_label() == default_label.format( + np.mean(average_precision), np.std(average_precision) + ) - average_precision = average_precision_score(y, y_score, pos_label=pos_label) + # check that the name can be set + display.plot(name="MySpecialEstimator") + # Sets only first labelled curve + assert display.line_[0].get_label() == ( + f"MySpecialEstimator (AP = {np.mean(average_precision):.2f} +/- " + f"{np.std(average_precision):.2f})" + ) + else: + average_precision = average_precision_score(y, y_score, pos_label=pos_label) - # check that the default name is used - assert display.line_.get_label() == default_label.format(average_precision) + # check that the default name is used + assert display.line_.get_label() == default_label.format(average_precision) - # check that the name can be set - display.plot(name="MySpecialEstimator") - assert ( - display.line_.get_label() - == f"MySpecialEstimator (AP = {average_precision:.2f})" - ) + # check that the name can be set + display.plot(name="MySpecialEstimator") + assert ( + display.line_.get_label() + == f"MySpecialEstimator (AP = {average_precision:.2f})" + ) @pytest.mark.parametrize( @@ -189,9 +470,15 @@ def test_precision_recall_display_string_labels(pyplot): X, y = cancer.data, cancer.target_names[cancer.target] lr = make_pipeline(StandardScaler(), LogisticRegression()) + n_cv = 3 + cv_results = cross_validate( + lr, X, y, cv=n_cv, return_estimator=True, return_indices=True + ) lr.fit(X, y) for klass in cancer.target_names: assert klass in lr.classes_ + + # `from_estimator` display = PrecisionRecallDisplay.from_estimator(lr, X, y) y_score = lr.predict_proba(X)[:, 1] @@ -200,6 +487,7 @@ def test_precision_recall_display_string_labels(pyplot): assert display.average_precision == pytest.approx(avg_prec) assert display.name == lr.__class__.__name__ + # `from_predictions` err_msg = r"y_true takes value in {'benign', 'malignant'}" with pytest.raises(ValueError, match=err_msg): PrecisionRecallDisplay.from_predictions(y, y_score) @@ -209,6 +497,26 @@ def test_precision_recall_display_string_labels(pyplot): ) assert display.average_precision == pytest.approx(avg_prec) + # `from_cv_results` + display = PrecisionRecallDisplay.from_cv_results(cv_results, X, y) + average_precision = [] + for idx in range(n_cv): + test_indices = cv_results["indices"]["test"][idx] + y_pred, _ = _get_response_values_binary( + cv_results["estimator"][idx], + _safe_indexing(X, test_indices), + response_method="auto", + ) + # Note `pos_label` cannot be `None` (default=1), unlike other metrics + average_precision.append( + average_precision_score( + _safe_indexing(y, test_indices), + y_pred, + pos_label=cv_results["estimator"][idx].classes_[1], + ) + ) + assert_allclose(display.average_precision, average_precision) + @pytest.mark.parametrize( "average_precision, name, expected_label", @@ -235,104 +543,83 @@ def test_default_labels(pyplot, average_precision, name, expected_label): @pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) @pytest.mark.parametrize("response_method", ["predict_proba", "decision_function"]) def test_plot_precision_recall_pos_label(pyplot, constructor_name, response_method): - # check that we can provide the positive label and display the proper - # statistics - X, y = load_breast_cancer(return_X_y=True) - # create a highly imbalanced version of the breast cancer dataset - idx_positive = np.flatnonzero(y == 1) - idx_negative = np.flatnonzero(y == 0) - idx_selected = np.hstack([idx_negative, idx_positive[:25]]) - X, y = X[idx_selected], y[idx_selected] - X, y = shuffle(X, y, random_state=42) - # only use 2 features to make the problem even harder - X = X[:, :2] - y = np.array(["cancer" if c == 1 else "not cancer" for c in y], dtype=object) - X_train, X_test, y_train, y_test = train_test_split( - X, - y, - stratify=y, - random_state=0, + """Check switching `pos_label` give correct statistics, using imbalanced data.""" + + def _check_average_precision(display, constructor_name, pos_label): + if pos_label == "cancer": + avg_prec_limit = 0.6338 + avg_prec_limit_multi = [0.8189, 0.8802, 0.8795] + else: + avg_prec_limit = 0.9953 + avg_prec_limit_multi = [0.9966, 0.9984, 0.9976] + + def average_precision_uninterpolated(precision, recall): + return -np.sum(np.diff(recall) * np.array(precision)[:-1]) + + if constructor_name == "from_cv_results": + for idx, average_precision in enumerate(display.average_precision): + assert average_precision == pytest.approx( + avg_prec_limit_multi[idx], rel=1e-3 + ) + assert average_precision_uninterpolated( + display.precision[idx], display.recall[idx] + ) == pytest.approx(avg_prec_limit_multi[idx], rel=1e-3) + else: + assert display.average_precision == pytest.approx(avg_prec_limit, rel=1e-3) + assert average_precision_uninterpolated( + display.precision, display.recall + ) == pytest.approx(avg_prec_limit, rel=1e-3) + + _check_pos_label_statistics( + PrecisionRecallDisplay, + response_method, + constructor_name, + _check_average_precision, ) - classifier = LogisticRegression() - classifier.fit(X_train, y_train) - - # sanity check to be sure the positive class is classes_[0] and that we - # are betrayed by the class imbalance - assert classifier.classes_.tolist() == ["cancer", "not cancer"] - - y_score = getattr(classifier, response_method)(X_test) - # we select the corresponding probability columns or reverse the decision - # function otherwise - y_score_cancer = -1 * y_score if y_score.ndim == 1 else y_score[:, 0] - y_score_not_cancer = y_score if y_score.ndim == 1 else y_score[:, 1] - - if constructor_name == "from_estimator": - display = PrecisionRecallDisplay.from_estimator( - classifier, - X_test, - y_test, - pos_label="cancer", - response_method=response_method, - ) - else: - display = PrecisionRecallDisplay.from_predictions( - y_test, - y_score_cancer, - pos_label="cancer", - ) - # we should obtain the statistics of the "cancer" class - avg_prec_limit = 0.65 - assert display.average_precision < avg_prec_limit - assert -trapezoid(display.precision, display.recall) < avg_prec_limit - - # otherwise we should obtain the statistics of the "not cancer" class - if constructor_name == "from_estimator": - display = PrecisionRecallDisplay.from_estimator( - classifier, - X_test, - y_test, - response_method=response_method, - pos_label="not cancer", - ) - else: - display = PrecisionRecallDisplay.from_predictions( - y_test, - y_score_not_cancer, - pos_label="not cancer", - ) - avg_prec_limit = 0.95 - assert display.average_precision > avg_prec_limit - assert -trapezoid(display.precision, display.recall) > avg_prec_limit - -@pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) +@pytest.mark.parametrize( + "constructor_name", ["from_estimator", "from_predictions", "from_cv_results"] +) def test_precision_recall_prevalence_pos_label_reusable(pyplot, constructor_name): # Check that even if one passes plot_chance_level=False the first time # one can still call disp.plot with plot_chance_level=True and get the # chance level line + + import matplotlib as mpl + X, y = make_classification(n_classes=2, n_samples=50, random_state=0) lr = LogisticRegression() + n_cv = 3 + cv_results = cross_validate( + lr, X, y, cv=n_cv, return_estimator=True, return_indices=True + ) y_score = lr.fit(X, y).predict_proba(X)[:, 1] if constructor_name == "from_estimator": display = PrecisionRecallDisplay.from_estimator( lr, X, y, plot_chance_level=False ) - else: + elif constructor_name == "from_predictions": display = PrecisionRecallDisplay.from_predictions( y, y_score, plot_chance_level=False ) + else: + display = PrecisionRecallDisplay.from_cv_results( + cv_results, X, y, plot_chance_level=False + ) assert display.chance_level_ is None - import matplotlib as mpl - # When calling from_estimator or from_predictions, # prevalence_pos_label should have been set, so that directly # calling plot_chance_level=True should plot the chance level line display.plot(plot_chance_level=True) - assert isinstance(display.chance_level_, mpl.lines.Line2D) + if constructor_name == "from_cv_results": + for idx in range(n_cv): + assert isinstance(display.chance_level_[idx], mpl.lines.Line2D) + else: + assert isinstance(display.chance_level_, mpl.lines.Line2D) def test_precision_recall_raise_no_prevalence(pyplot): @@ -356,23 +643,29 @@ def test_precision_recall_raise_no_prevalence(pyplot): @pytest.mark.parametrize("despine", [True, False]) -@pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) +@pytest.mark.parametrize( + "constructor_name", ["from_estimator", "from_predictions", "from_cv_results"] +) def test_plot_precision_recall_despine(pyplot, despine, constructor_name): # Check that the despine keyword is working correctly X, y = make_classification(n_classes=2, n_samples=50, random_state=0) clf = LogisticRegression().fit(X, y) clf.fit(X, y) + cv_results = cross_validate( + LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True + ) y_score = clf.decision_function(X) - # safe guard for the binary if/else construction - assert constructor_name in ("from_estimator", "from_predictions") - if constructor_name == "from_estimator": display = PrecisionRecallDisplay.from_estimator(clf, X, y, despine=despine) - else: + elif constructor_name == "from_predictions": display = PrecisionRecallDisplay.from_predictions(y, y_score, despine=despine) + else: + display = PrecisionRecallDisplay.from_cv_results( + cv_results, X, y, despine=despine + ) for s in ["top", "right"]: assert display.ax_.spines[s].get_visible() is not despine @@ -398,3 +691,38 @@ def test_y_score_and_y_pred_specified_error(pyplot): with pytest.warns(FutureWarning, match="y_pred was deprecated in 1.8"): PrecisionRecallDisplay.from_predictions(y_true, y_pred=y_score) + + +@pytest.mark.parametrize("array_lib", ["torch", "numpy", "list"]) +@pytest.mark.parametrize( + "y_true, pos_label, expected_prevalence_pos_label", + [ + ([1, 0, 0, 0, 0], None, 0.2), + ([1, 1, 0, 0, 0], 1, 0.4), + ([1, 1, 0, 1, 0], 0, 0.4), + ([1, 1, 0, 1, 1], None, 0.8), + ], +) +def test_correct_prevalence_pos_label_with_array_types( + pyplot, array_lib, y_true, pos_label, expected_prevalence_pos_label +): + """ + Non-regression test for issue #33342 + Checks whether the prevalence_pos_label is calculated correctly when using + different array types. This used to fail for pytorch arrays. + """ + + torch = pytest.importorskip("torch") + + if array_lib == "torch": + y_true = torch.tensor(y_true) + elif array_lib == "numpy": + y_true = np.array(y_true) + + y_score = [0.08, 0.15, 0.16, 0.23, 0.42] + + display = PrecisionRecallDisplay.from_predictions( + y_true, y_score, pos_label=pos_label, plot_chance_level=True + ) + + assert display.prevalence_pos_label == expected_prevalence_pos_label diff --git a/sklearn/metrics/_plot/tests/test_roc_curve_display.py b/sklearn/metrics/_plot/tests/test_roc_curve_display.py index 72c636acd33cf..68699dac9c7cb 100644 --- a/sklearn/metrics/_plot/tests/test_roc_curve_display.py +++ b/sklearn/metrics/_plot/tests/test_roc_curve_display.py @@ -1,5 +1,3 @@ -from collections.abc import Mapping - import numpy as np import pytest from numpy.testing import assert_allclose @@ -7,14 +5,17 @@ from sklearn import clone from sklearn.compose import make_column_transformer -from sklearn.datasets import load_breast_cancer, make_classification -from sklearn.exceptions import NotFittedError, UndefinedMetricWarning +from sklearn.datasets import make_classification +from sklearn.exceptions import NotFittedError from sklearn.linear_model import LogisticRegression from sklearn.metrics import RocCurveDisplay, auc, roc_curve -from sklearn.model_selection import cross_validate, train_test_split +from sklearn.metrics._plot.tests.test_common_curve_display import ( + _check_pos_label_statistics, +) +from sklearn.model_selection import cross_validate from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler -from sklearn.utils import _safe_indexing, shuffle +from sklearn.utils import _safe_indexing from sklearn.utils._response import _get_response_values_binary @@ -197,172 +198,6 @@ def test_roc_curve_plot_parameter_length_validation(pyplot, params, err_msg): display.plot() -def test_validate_plot_params(pyplot): - """Check `_validate_plot_params` returns the correct variables.""" - fpr = np.array([0, 0.5, 1]) - tpr = [np.array([0, 0.5, 1])] - roc_auc = None - name = "test_curve" - - # Initialize display with test inputs - display = RocCurveDisplay( - fpr=fpr, - tpr=tpr, - roc_auc=roc_auc, - name=name, - pos_label=None, - ) - fpr_out, tpr_out, roc_auc_out, name_out = display._validate_plot_params( - ax=None, name=None - ) - - assert isinstance(fpr_out, list) - assert isinstance(tpr_out, list) - assert len(fpr_out) == 1 - assert len(tpr_out) == 1 - assert roc_auc_out is None - assert name_out == ["test_curve"] - - -def test_roc_curve_from_cv_results_param_validation(pyplot, data_binary): - """Check parameter validation is correct.""" - X, y = data_binary - - # `cv_results` missing key - cv_results_no_est = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False - ) - cv_results_no_indices = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=False - ) - for cv_results in (cv_results_no_est, cv_results_no_indices): - with pytest.raises( - ValueError, - match="`cv_results` does not contain one of the following required", - ): - RocCurveDisplay.from_cv_results(cv_results, X, y) - - cv_results = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True - ) - - # `X` wrong length - with pytest.raises(ValueError, match="`X` does not contain the correct"): - RocCurveDisplay.from_cv_results(cv_results, X[:10, :], y) - - # `y` not binary - y_multi = y.copy() - y_multi[0] = 2 - with pytest.raises(ValueError, match="The target `y` is not binary."): - RocCurveDisplay.from_cv_results(cv_results, X, y_multi) - - # input inconsistent length - with pytest.raises(ValueError, match="Found input variables with inconsistent"): - RocCurveDisplay.from_cv_results(cv_results, X, y[:10]) - with pytest.raises(ValueError, match="Found input variables with inconsistent"): - RocCurveDisplay.from_cv_results(cv_results, X, y, sample_weight=[1, 2]) - - # `pos_label` inconsistency - y_multi[y_multi == 1] = 2 - with pytest.warns(UndefinedMetricWarning, match="No positive samples in y_true"): - RocCurveDisplay.from_cv_results(cv_results, X, y_multi) - - # `name` is list while `curve_kwargs` is None or dict - for curve_kwargs in (None, {"alpha": 0.2}): - with pytest.raises(ValueError, match="To avoid labeling individual curves"): - RocCurveDisplay.from_cv_results( - cv_results, - X, - y, - name=["one", "two", "three"], - curve_kwargs=curve_kwargs, - ) - - # `curve_kwargs` incorrect length - with pytest.raises(ValueError, match="`curve_kwargs` must be None, a dictionary"): - RocCurveDisplay.from_cv_results(cv_results, X, y, curve_kwargs=[{"alpha": 1}]) - - # `curve_kwargs` both alias provided - with pytest.raises(TypeError, match="Got both c and"): - RocCurveDisplay.from_cv_results( - cv_results, X, y, curve_kwargs={"c": "blue", "color": "red"} - ) - - -@pytest.mark.parametrize( - "curve_kwargs", - [None, {"alpha": 0.2}, [{"alpha": 0.2}, {"alpha": 0.3}, {"alpha": 0.4}]], -) -def test_roc_curve_display_from_cv_results_curve_kwargs( - pyplot, data_binary, curve_kwargs -): - """Check `curve_kwargs` correctly passed.""" - X, y = data_binary - n_cv = 3 - cv_results = cross_validate( - LogisticRegression(), X, y, cv=n_cv, return_estimator=True, return_indices=True - ) - display = RocCurveDisplay.from_cv_results( - cv_results, - X, - y, - curve_kwargs=curve_kwargs, - ) - if curve_kwargs is None: - # Default `alpha` used - assert all(line.get_alpha() == 0.5 for line in display.line_) - elif isinstance(curve_kwargs, Mapping): - # `alpha` from dict used for all curves - assert all(line.get_alpha() == 0.2 for line in display.line_) - else: - # Different `alpha` used for each curve - assert all( - line.get_alpha() == curve_kwargs[i]["alpha"] - for i, line in enumerate(display.line_) - ) - # Other default kwargs should be the same - for line in display.line_: - assert line.get_linestyle() == "--" - assert line.get_color() == "blue" - - -# TODO(1.9): Remove in 1.9 -@pytest.mark.parametrize( - "constructor_name", ["from_estimator", "from_predictions", "plot"] -) -def test_roc_curve_display_kwargs_deprecation(pyplot, data_binary, constructor_name): - """Check **kwargs deprecated correctly in favour of `curve_kwargs`.""" - X, y = data_binary - lr = LogisticRegression() - lr.fit(X, y) - fpr = np.array([0, 0.5, 1]) - tpr = np.array([0, 0.5, 1]) - - # Error when both `curve_kwargs` and `**kwargs` provided - with pytest.raises(ValueError, match="Cannot provide both `curve_kwargs`"): - if constructor_name == "from_estimator": - RocCurveDisplay.from_estimator( - lr, X, y, curve_kwargs={"alpha": 1}, label="test" - ) - elif constructor_name == "from_predictions": - RocCurveDisplay.from_predictions( - y, y, curve_kwargs={"alpha": 1}, label="test" - ) - else: - RocCurveDisplay(fpr=fpr, tpr=tpr).plot( - curve_kwargs={"alpha": 1}, label="test" - ) - - # Warning when `**kwargs`` provided - with pytest.warns(FutureWarning, match=r"`\*\*kwargs` is deprecated and will be"): - if constructor_name == "from_estimator": - RocCurveDisplay.from_estimator(lr, X, y, label="test") - elif constructor_name == "from_predictions": - RocCurveDisplay.from_predictions(y, y, label="test") - else: - RocCurveDisplay(fpr=fpr, tpr=tpr).plot(label="test") - - @pytest.mark.parametrize( "curve_kwargs", [ @@ -459,151 +294,6 @@ def test_roc_curve_display_plotting_from_cv_results( assert line.get_label() == aggregate_expected_labels[idx] -@pytest.mark.parametrize("roc_auc", [[1.0, 1.0, 1.0], None]) -@pytest.mark.parametrize( - "curve_kwargs", - [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], -) -@pytest.mark.parametrize("name", [None, "single", ["one", "two", "three"]]) -def test_roc_curve_plot_legend_label(pyplot, data_binary, name, curve_kwargs, roc_auc): - """Check legend label correct with all `curve_kwargs`, `name` combinations.""" - fpr = [np.array([0, 0.5, 1]), np.array([0, 0.5, 1]), np.array([0, 0.5, 1])] - tpr = [np.array([0, 0.5, 1]), np.array([0, 0.5, 1]), np.array([0, 0.5, 1])] - if not isinstance(curve_kwargs, list) and isinstance(name, list): - with pytest.raises(ValueError, match="To avoid labeling individual curves"): - RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc).plot( - name=name, curve_kwargs=curve_kwargs - ) - - else: - display = RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=roc_auc).plot( - name=name, curve_kwargs=curve_kwargs - ) - legend = display.ax_.get_legend() - if legend is None: - # No legend is created, exit test early - assert name is None - assert roc_auc is None - return - else: - legend_labels = [text.get_text() for text in legend.get_texts()] - - if isinstance(curve_kwargs, list): - # Multiple labels in legend - assert len(legend_labels) == 3 - for idx, label in enumerate(legend_labels): - if name is None: - expected_label = "AUC = 1.00" if roc_auc else None - assert label == expected_label - elif isinstance(name, str): - expected_label = "single (AUC = 1.00)" if roc_auc else "single" - assert label == expected_label - else: - # `name` is a list of different strings - expected_label = ( - f"{name[idx]} (AUC = 1.00)" if roc_auc else f"{name[idx]}" - ) - assert label == expected_label - else: - # Single label in legend - assert len(legend_labels) == 1 - if name is None: - expected_label = "AUC = 1.00 +/- 0.00" if roc_auc else None - assert legend_labels[0] == expected_label - else: - # name is single string - expected_label = "single (AUC = 1.00 +/- 0.00)" if roc_auc else "single" - assert legend_labels[0] == expected_label - - -@pytest.mark.parametrize( - "curve_kwargs", - [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], -) -@pytest.mark.parametrize("name", [None, "single", ["one", "two", "three"]]) -def test_roc_curve_from_cv_results_legend_label( - pyplot, data_binary, name, curve_kwargs -): - """Check legend label correct with all `curve_kwargs`, `name` combinations.""" - X, y = data_binary - n_cv = 3 - cv_results = cross_validate( - LogisticRegression(), X, y, cv=n_cv, return_estimator=True, return_indices=True - ) - - if not isinstance(curve_kwargs, list) and isinstance(name, list): - with pytest.raises(ValueError, match="To avoid labeling individual curves"): - RocCurveDisplay.from_cv_results( - cv_results, X, y, name=name, curve_kwargs=curve_kwargs - ) - else: - display = RocCurveDisplay.from_cv_results( - cv_results, X, y, name=name, curve_kwargs=curve_kwargs - ) - - legend = display.ax_.get_legend() - legend_labels = [text.get_text() for text in legend.get_texts()] - if isinstance(curve_kwargs, list): - # Multiple labels in legend - assert len(legend_labels) == 3 - auc = ["0.62", "0.66", "0.55"] - for idx, label in enumerate(legend_labels): - if name is None: - assert label == f"AUC = {auc[idx]}" - elif isinstance(name, str): - assert label == f"single (AUC = {auc[idx]})" - else: - # `name` is a list of different strings - assert label == f"{name[idx]} (AUC = {auc[idx]})" - else: - # Single label in legend - assert len(legend_labels) == 1 - if name is None: - assert legend_labels[0] == "AUC = 0.61 +/- 0.05" - else: - # name is single string - assert legend_labels[0] == "single (AUC = 0.61 +/- 0.05)" - - -@pytest.mark.parametrize( - "curve_kwargs", - [None, {"color": "red"}, [{"c": "red"}, {"c": "green"}, {"c": "yellow"}]], -) -def test_roc_curve_from_cv_results_curve_kwargs(pyplot, data_binary, curve_kwargs): - """Check line kwargs passed correctly in `from_cv_results`.""" - - X, y = data_binary - cv_results = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True - ) - display = RocCurveDisplay.from_cv_results( - cv_results, X, y, curve_kwargs=curve_kwargs - ) - - for idx, line in enumerate(display.line_): - color = line.get_color() - if curve_kwargs is None: - # Default color - assert color == "blue" - elif isinstance(curve_kwargs, Mapping): - # All curves "red" - assert color == "red" - else: - assert color == curve_kwargs[idx]["c"] - - -def test_roc_curve_from_cv_results_pos_label_inferred(pyplot, data_binary): - """Check `pos_label` inferred correctly by `from_cv_results(pos_label=None)`.""" - X, y = data_binary - cv_results = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True - ) - - disp = RocCurveDisplay.from_cv_results(cv_results, X, y, pos_label=None) - # Should be `estimator.classes_[1]` - assert disp.pos_label == 1 - - def _check_chance_level(plot_chance_level, chance_level_kw, display): """Check chance level line and line styles correct.""" import matplotlib as mpl @@ -822,137 +512,27 @@ def test_roc_curve_display_default_labels( assert disp.line_[idx].get_label() == expected_label -def _check_auc(display, constructor_name): - roc_auc_limit = 0.95679 - roc_auc_limit_multi = [0.97007, 0.985915, 0.980952] - - if constructor_name == "from_cv_results": - for idx, roc_auc in enumerate(display.roc_auc): - assert roc_auc == pytest.approx(roc_auc_limit_multi[idx]) - else: - assert display.roc_auc == pytest.approx(roc_auc_limit) - assert trapezoid(display.tpr, display.fpr) == pytest.approx(roc_auc_limit) - - @pytest.mark.parametrize("response_method", ["predict_proba", "decision_function"]) @pytest.mark.parametrize( "constructor_name", ["from_estimator", "from_predictions", "from_cv_results"] ) def test_plot_roc_curve_pos_label(pyplot, response_method, constructor_name): - # check that we can provide the positive label and display the proper - # statistics - X, y = load_breast_cancer(return_X_y=True) - # create a highly imbalanced version of the breast cancer dataset - idx_positive = np.flatnonzero(y == 1) - idx_negative = np.flatnonzero(y == 0) - idx_selected = np.hstack([idx_negative, idx_positive[:25]]) - X, y = X[idx_selected], y[idx_selected] - X, y = shuffle(X, y, random_state=42) - # only use 2 features to make the problem even harder - X = X[:, :2] - y = np.array(["cancer" if c == 1 else "not cancer" for c in y], dtype=object) - X_train, X_test, y_train, y_test = train_test_split( - X, - y, - stratify=y, - random_state=0, - ) + """Test switching `pos_label` gives correct statistics, using imbalanced data.""" - classifier = LogisticRegression() - classifier.fit(X_train, y_train) - cv_results = cross_validate( - LogisticRegression(), X, y, cv=3, return_estimator=True, return_indices=True - ) - - # Sanity check to be sure the positive class is `classes_[0]` - # Class imbalance ensures a large difference in prediction values between classes, - # allowing us to catch errors when we switch `pos_label` - assert classifier.classes_.tolist() == ["cancer", "not cancer"] - - y_score = getattr(classifier, response_method)(X_test) - # we select the corresponding probability columns or reverse the decision - # function otherwise - y_score_cancer = -1 * y_score if y_score.ndim == 1 else y_score[:, 0] - y_score_not_cancer = y_score if y_score.ndim == 1 else y_score[:, 1] + def _check_auc(display, constructor_name, pos_label): + roc_auc_limit = 0.95679 + roc_auc_limit_multi = [0.97007, 0.985915, 0.980952] - pos_label = "cancer" - y_score = y_score_cancer - if constructor_name == "from_estimator": - display = RocCurveDisplay.from_estimator( - classifier, - X_test, - y_test, - pos_label=pos_label, - response_method=response_method, - ) - elif constructor_name == "from_predictions": - display = RocCurveDisplay.from_predictions( - y_test, - y_score, - pos_label=pos_label, - ) - else: - display = RocCurveDisplay.from_cv_results( - cv_results, - X, - y, - response_method=response_method, - pos_label=pos_label, - ) - - _check_auc(display, constructor_name) - - pos_label = "not cancer" - y_score = y_score_not_cancer - if constructor_name == "from_estimator": - display = RocCurveDisplay.from_estimator( - classifier, - X_test, - y_test, - response_method=response_method, - pos_label=pos_label, - ) - elif constructor_name == "from_predictions": - display = RocCurveDisplay.from_predictions( - y_test, - y_score, - pos_label=pos_label, - ) - else: - display = RocCurveDisplay.from_cv_results( - cv_results, - X, - y, - response_method=response_method, - pos_label=pos_label, - ) - - _check_auc(display, constructor_name) - - -# TODO(1.9): remove -def test_y_score_and_y_pred_specified_error(pyplot): - """1. Check that an error is raised when both y_score and y_pred are specified. - 2. Check that a warning is raised when y_pred is specified. - """ - y_true = np.array([0, 1, 1, 0]) - y_score = np.array([0.1, 0.4, 0.35, 0.8]) - y_pred = np.array([0.2, 0.3, 0.5, 0.1]) - - with pytest.raises( - ValueError, match="`y_pred` and `y_score` cannot be both specified" - ): - RocCurveDisplay.from_predictions(y_true, y_score=y_score, y_pred=y_pred) - - with pytest.warns(FutureWarning, match="y_pred was deprecated in 1.7"): - display_y_pred = RocCurveDisplay.from_predictions(y_true, y_pred=y_score) - desired_fpr, desired_fnr, _ = roc_curve(y_true, y_score) - assert_allclose(display_y_pred.fpr, desired_fpr) - assert_allclose(display_y_pred.tpr, desired_fnr) + if constructor_name == "from_cv_results": + for idx, roc_auc in enumerate(display.roc_auc): + assert roc_auc == pytest.approx(roc_auc_limit_multi[idx]) + else: + assert display.roc_auc == pytest.approx(roc_auc_limit) + assert trapezoid(display.tpr, display.fpr) == pytest.approx(roc_auc_limit) - display_y_score = RocCurveDisplay.from_predictions(y_true, y_score) - assert_allclose(display_y_score.fpr, desired_fpr) - assert_allclose(display_y_score.tpr, desired_fnr) + _check_pos_label_statistics( + RocCurveDisplay, response_method, constructor_name, _check_auc + ) @pytest.mark.parametrize("despine", [True, False]) diff --git a/sklearn/metrics/_ranking.py b/sklearn/metrics/_ranking.py index eeb88f8bb0d98..ef1fba35189c3 100644 --- a/sklearn/metrics/_ranking.py +++ b/sklearn/metrics/_ranking.py @@ -16,7 +16,7 @@ import numpy as np from scipy.integrate import trapezoid -from scipy.sparse import csr_matrix, issparse +from scipy.sparse import csr_array, issparse from scipy.stats import rankdata from sklearn.exceptions import UndefinedMetricWarning @@ -30,14 +30,20 @@ ) from sklearn.utils._array_api import ( _max_precision_float_dtype, + get_namespace, get_namespace_and_device, + move_to, size, ) from sklearn.utils._encode import _encode, _unique from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.multiclass import type_of_target from sklearn.utils.sparsefuncs import count_nonzero -from sklearn.utils.validation import _check_pos_label_consistency, _check_sample_weight +from sklearn.utils.validation import ( + _check_pos_label_consistency, + _check_sample_weight, + _deprecate_positional_args, +) @validate_params( @@ -142,12 +148,13 @@ def average_precision_score( Parameters ---------- y_true : array-like of shape (n_samples,) or (n_samples, n_classes) - True binary labels or binary label indicators. + True binary labels, :term:`multi-label` indicators (as a + :term:`multilabel indicator matrix`) or :term:`multi-class` labels. y_score : array-like of shape (n_samples,) or (n_samples, n_classes) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by :term:`decision_function` on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -224,27 +231,40 @@ def average_precision_score( >>> average_precision_score(y_true, y_scores) 0.77 """ + xp, _, device = get_namespace_and_device(y_score) + # To allow mixed string `y_true`/numeric `y_score` input, cannot move `y_true` + # until it has been converted to an integer (e.g., via `label_binarize`) + # Ensures `test_array_api_classification_mixed_string_numeric_input` passes. + sample_weight = move_to(sample_weight, xp=xp, device=device) + + if sample_weight is not None: + sample_weight = column_or_1d(sample_weight) def _binary_uninterpolated_average_precision( - y_true, y_score, pos_label=1, sample_weight=None + y_true, + y_score, + pos_label=1, + sample_weight=None, + xp=xp, ): precision, recall, _ = precision_recall_curve( - y_true, y_score, pos_label=pos_label, sample_weight=sample_weight + y_true, + y_score, + pos_label=pos_label, + sample_weight=sample_weight, ) # Return the step function integral # The following works because the last entry of precision is # guaranteed to be 1, as returned by precision_recall_curve. # Due to numerical error, we can get `-0.0` and we therefore clip it. - return float(max(0.0, -np.sum(np.diff(recall) * np.array(precision)[:-1]))) + return float(max(0.0, -xp.sum(xp.diff(recall) * precision[:-1]))) y_type = type_of_target(y_true, input_name="y_true") - - # Convert to Python primitive type to avoid NumPy type / Python str - # comparison. See https://github.com/numpy/numpy/issues/6784 - present_labels = np.unique(y_true).tolist() + xp_y_true, _ = get_namespace(y_true) + present_labels = xp_y_true.unique_values(y_true) if y_type == "binary": - if len(present_labels) == 2 and pos_label not in present_labels: + if present_labels.shape[0] == 2 and pos_label not in present_labels: raise ValueError( f"pos_label={pos_label} is not a valid label. It should be " f"one of {present_labels}" @@ -263,9 +283,16 @@ def _binary_uninterpolated_average_precision( "Do not set pos_label or set pos_label to 1." ) y_true = label_binarize(y_true, classes=present_labels) + y_true = move_to(y_true, xp=xp, device=device) + if not y_score.shape == y_true.shape: + raise ValueError( + "`y_score` needs to be of shape `(n_samples, n_classes)`, since " + "`y_true` contains multiple classes. Got " + f"`y_score.shape={y_score.shape}`." + ) average_precision = partial( - _binary_uninterpolated_average_precision, pos_label=pos_label + _binary_uninterpolated_average_precision, pos_label=pos_label, xp=xp ) return _average_binary_score( average_precision, y_true, y_score, average, sample_weight=sample_weight @@ -304,14 +331,14 @@ def det_curve( Parameters ---------- - y_true : ndarray of shape (n_samples,) + y_true : array-like of shape (n_samples,) True binary labels. If labels are not either {-1, 1} or {0, 1}, then pos_label should be explicitly given. - y_score : ndarray of shape of (n_samples,) + y_score : array-like of shape of (n_samples,) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by "decision_function" on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -502,9 +529,9 @@ def roc_auc_score( Parameters ---------- y_true : array-like of shape (n_samples,) or (n_samples, n_classes) - True labels or binary label indicators. The binary and multiclass cases + True labels or :term:`label indicator matrix`. The binary and multiclass cases expect labels with shape (n_samples,) while the multilabel case expects - binary label indicators with shape (n_samples, n_classes). + a :term:`multilabel indicator matrix` with shape (n_samples, n_classes). y_score : array-like of shape (n_samples,) or (n_samples, n_classes) Target scores. @@ -681,6 +708,8 @@ class scores must correspond to the order of ``labels``, y_type = type_of_target(y_true, input_name="y_true") y_true = check_array(y_true, ensure_2d=False, dtype=None) y_score = check_array(y_score, ensure_2d=False) + if sample_weight is not None: + sample_weight = column_or_1d(sample_weight) if y_type == "multiclass" or ( y_type == "binary" and y_score.ndim == 2 and y_score.shape[1] > 2 @@ -766,7 +795,12 @@ def _multiclass_roc_auc_score( Sample weights. """ - # validation of the input y_score + if not y_score.ndim == 2: + raise ValueError( + "`y_score` needs to be of shape `(n_samples, n_classes)`, since " + "`y_true` contains multiple classes. Got " + f"`y_score.shape={y_score.shape}`." + ) if not np.allclose(1, y_score.sum(axis=1)): raise ValueError( "Target scores need to be probabilities for multiclass " @@ -841,6 +875,53 @@ def _multiclass_roc_auc_score( ) +def _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight=None +): + """Validate and sort inputs, and compute classification thresholds. + + Performs the following functions: + + * Array validation on `y_true`, `y_score` and `sample_weight` + * Filters out 0-weighted samples + * Sorts `y_score`, `y_true` and `sample_weight` according to descending `y_score` + * Computes thresholds i.e. indices where sorted `y_score` changes + """ + xp, _, device = get_namespace_and_device(y_score) + + check_consistent_length(y_true, y_score, sample_weight) + y_true = column_or_1d(y_true) + y_score = column_or_1d(y_score) + assert_all_finite(y_true) + assert_all_finite(y_score) + + # Filter out zero-weighted samples, as they should not impact the result + if sample_weight is not None: + sample_weight = column_or_1d(sample_weight) + sample_weight = _check_sample_weight(sample_weight, y_true) + nonzero_weight_mask = sample_weight != 0 + y_true = y_true[nonzero_weight_mask] + y_score = y_score[nonzero_weight_mask] + sample_weight = sample_weight[nonzero_weight_mask] + + # sort scores and corresponding truth values + desc_score_indices = xp.argsort(y_score, stable=True, descending=True) + y_score = y_score[desc_score_indices] + y_true = y_true[desc_score_indices] + if sample_weight is not None: + sample_weight = sample_weight[desc_score_indices] + + # y_score typically has many tied values. Here we extract + # the indices associated with the distinct values. We also + # concatenate a value for the end of the curve. + distinct_value_indices = xp.nonzero(xp.diff(y_score))[0] + threshold_idxs = xp.concat( + [distinct_value_indices, xp.asarray([size(y_true) - 1], device=device)] + ) + return y_true, y_score, sample_weight, threshold_idxs + + +@_deprecate_positional_args(version="1.11") @validate_params( { "y_true": ["array-like"], @@ -850,7 +931,9 @@ def _multiclass_roc_auc_score( }, prefer_skip_nested_validation=True, ) -def confusion_matrix_at_thresholds(y_true, y_score, pos_label=None, sample_weight=None): +def confusion_matrix_at_thresholds( + y_true, y_score, *, pos_label=None, sample_weight=None +): """Calculate :term:`binary` confusion matrix terms per classification threshold. Read more in the :ref:`User Guide <confusion_matrix>`. @@ -859,10 +942,10 @@ def confusion_matrix_at_thresholds(y_true, y_score, pos_label=None, sample_weigh Parameters ---------- - y_true : ndarray of shape (n_samples,) + y_true : array-like of shape (n_samples,) True targets of binary classification. - y_score : ndarray of shape (n_samples,) + y_score : array-like of shape (n_samples,) Estimated probabilities or output of a decision function. pos_label : int, float, bool or str, default=None @@ -925,44 +1008,22 @@ def confusion_matrix_at_thresholds(y_true, y_score, pos_label=None, sample_weigh if not (y_type == "binary" or (y_type == "multiclass" and pos_label is not None)): raise ValueError("{0} format is not supported".format(y_type)) - xp, _, device = get_namespace_and_device(y_true, y_score, sample_weight) - - check_consistent_length(y_true, y_score, sample_weight) - y_true = column_or_1d(y_true) - y_score = column_or_1d(y_score) - assert_all_finite(y_true) - assert_all_finite(y_score) - - # Filter out zero-weighted samples, as they should not impact the result - if sample_weight is not None: - sample_weight = column_or_1d(sample_weight) - sample_weight = _check_sample_weight(sample_weight, y_true) - nonzero_weight_mask = sample_weight != 0 - y_true = y_true[nonzero_weight_mask] - y_score = y_score[nonzero_weight_mask] - sample_weight = sample_weight[nonzero_weight_mask] - + xp, _, device = get_namespace_and_device(y_score) pos_label = _check_pos_label_consistency(pos_label, y_true) + xp_y_true, _ = get_namespace(y_true) + # Make `y_true` a boolean vector. Use `asarray` as `y_true` could be a list + y_true = xp_y_true.asarray( + xp_y_true.asarray(y_true) == pos_label, dtype=xp_y_true.int32 + ) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) - # make y_true a boolean vector - y_true = y_true == pos_label - - # sort scores and corresponding truth values - desc_score_indices = xp.argsort(y_score, stable=True, descending=True) - y_score = y_score[desc_score_indices] - y_true = y_true[desc_score_indices] - if sample_weight is not None: - weight = sample_weight[desc_score_indices] - else: - weight = 1.0 - - # y_score typically has many tied values. Here we extract - # the indices associated with the distinct values. We also - # concatenate a value for the end of the curve. - distinct_value_indices = xp.nonzero(xp.diff(y_score))[0] - threshold_idxs = xp.concat( - [distinct_value_indices, xp.asarray([size(y_true) - 1], device=device)] + y_true, y_score, weight, threshold_idxs = ( + _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight + ) ) + if weight is None: + weight = 1.0 # accumulate the true positives with decreasing threshold max_float_dtype = _max_precision_float_dtype(xp, device) @@ -1095,7 +1156,7 @@ def precision_recall_curve( >>> thresholds array([0.1 , 0.35, 0.4 , 0.8 ]) """ - xp, _, device = get_namespace_and_device(y_true, y_score) + xp, _, device = get_namespace_and_device(y_score) _, fps, _, tps, thresholds = confusion_matrix_at_thresholds( y_true, y_score, pos_label=pos_label, sample_weight=sample_weight @@ -1132,7 +1193,7 @@ def precision_recall_curve( "No positive class found in y_true, " "recall is set to one for all thresholds." ) - recall = xp.full(tps.shape, 1.0) + recall = xp.full(tps.shape, 1.0, device=device) else: recall = tps / tps[-1] @@ -1172,8 +1233,8 @@ def roc_curve( y_score : array-like of shape (n_samples,) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by "decision_function" on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -1337,12 +1398,12 @@ def label_ranking_average_precision_score(y_true, y_score, *, sample_weight=None Parameters ---------- y_true : {array-like, sparse matrix} of shape (n_samples, n_labels) - True binary labels in binary indicator format. + True binary labels in :term:`label indicator format`. y_score : array-like of shape (n_samples, n_labels) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by "decision_function" on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -1380,7 +1441,7 @@ def label_ranking_average_precision_score(y_true, y_score, *, sample_weight=None raise ValueError("{0} format is not supported".format(y_type)) if not issparse(y_true): - y_true = csr_matrix(y_true) + y_true = csr_array(y_true) y_score = -y_score @@ -1439,12 +1500,12 @@ def coverage_error(y_true, y_score, *, sample_weight=None): Parameters ---------- y_true : array-like of shape (n_samples, n_labels) - True binary labels in binary indicator format. + True binary labels in :term:`label indicator format`. y_score : array-like of shape (n_samples, n_labels) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by "decision_function" on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -1516,12 +1577,12 @@ def label_ranking_loss(y_true, y_score, *, sample_weight=None): Parameters ---------- y_true : {array-like, sparse matrix} of shape (n_samples, n_labels) - True binary labels in binary indicator format. + True binary labels in :term:`label indicator format`. y_score : array-like of shape (n_samples, n_labels) Target scores, can either be probability estimates of the positive - class, confidence values, or non-thresholded measure of decisions - (as returned by "decision_function" on some classifiers). + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). For :term:`decision_function` scores, values greater than or equal to zero should indicate the positive class. @@ -1562,7 +1623,7 @@ def label_ranking_loss(y_true, y_score, *, sample_weight=None): n_samples, n_labels = y_true.shape - y_true = csr_matrix(y_true) + y_true = csr_array(y_true) loss = np.zeros(n_samples) for i, (start, stop) in enumerate(zip(y_true.indptr, y_true.indptr[1:])): @@ -1607,9 +1668,9 @@ def _dcg_sample_scores(y_true, y_score, k=None, log_base=2, ignore_ties=False): to be ranked. y_score : ndarray of shape (n_samples, n_labels) - Target scores, can either be probability estimates, confidence values, - or non-thresholded measure of decisions (as returned by - "decision_function" on some classifiers). + Target scores, can either be probability estimates of the positive + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). k : int, default=None Only consider the highest k scores in the ranking. If `None`, use all @@ -1746,9 +1807,9 @@ def dcg_score( to be ranked. y_score : array-like of shape (n_samples, n_labels) - Target scores, can either be probability estimates, confidence values, - or non-thresholded measure of decisions (as returned by - "decision_function" on some classifiers). + Target scores, can either be probability estimates of the positive + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). k : int, default=None Only consider the highest k scores in the ranking. If None, use all @@ -1852,9 +1913,9 @@ def _ndcg_sample_scores(y_true, y_score, k=None, ignore_ties=False): to be ranked. y_score : ndarray of shape (n_samples, n_labels) - Target scores, can either be probability estimates, confidence values, - or non-thresholded measure of decisions (as returned by - "decision_function" on some classifiers). + Target scores, can either be probability estimates of the positive + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). k : int, default=None Only consider the highest k scores in the ranking. If None, use all @@ -1914,9 +1975,9 @@ def ndcg_score(y_true, y_score, *, k=None, sample_weight=None, ignore_ties=False that is not between 0 and 1. y_score : array-like of shape (n_samples, n_labels) - Target scores, can either be probability estimates, confidence values, - or non-thresholded measure of decisions (as returned by - "decision_function" on some classifiers). + Target scores, can either be probability estimates of the positive + class or non-thresholded decision values (as returned by + :term:`decision_function` on some classifiers). k : int, default=None Only consider the highest k scores in the ranking. If `None`, use all @@ -2113,6 +2174,13 @@ def top_k_accuracy_score( " labels, `labels` must be provided." ) y_score = column_or_1d(y_score) + else: + if not y_score.ndim == 2: + raise ValueError( + "`y_score` needs to be of shape `(n_samples, n_classes)`, since " + "`y_true` contains multiple classes. Got " + f"`y_score.shape={y_score.shape}`." + ) check_consistent_length(y_true, y_score, sample_weight) y_score_n_classes = y_score.shape[1] if y_score.ndim == 2 else 2 @@ -2177,3 +2245,103 @@ def top_k_accuracy_score( return float(np.sum(hits)) else: return float(np.dot(hits, sample_weight)) + + +@validate_params( + { + "y_true": ["array-like"], + "y_score": ["array-like"], + "metric_func": [callable], + "sample_weight": ["array-like", None], + "metric_params": [dict, None], + }, + prefer_skip_nested_validation=True, +) +def metric_at_thresholds( + y_true, + y_score, + metric_func, + *, + sample_weight=None, + metric_params=None, +): + r"""Compute `metric_func` per threshold for :term:`binary` data. + + Aids visualization of metric values across thresholds when tuning the + :ref:`decision threshold <threshold_tuning>`. + + Read more in the :ref:`User Guide <metric_at_thresholds>`. + + .. versionadded:: 1.9 + + Parameters + ---------- + y_true : array-like of shape (n_samples,) + Ground truth (correct) target labels. + + y_score : array-like of shape (n_samples,) + Continuous prediction scores, either estimated probabilities of the + positive class or output of a :term:`decision_function`. + + metric_func : callable + The metric function to use. It will be called as + `metric_func(y_true, y_pred, **metric_params)`, where `y_pred` are + thresholded predictions, internally computed as + `y_pred = (y_score >= threshold)`. The output should be + a single numeric or a collection where each element has the same size. + + sample_weight : array-like of shape (n_samples,), default=None + Sample weights. If not `None`, will be passed to `metric_func`. + + metric_params : dict, default=None + Parameters to pass to `metric_func`. + + Returns + ------- + metric_values : ndarray of shape (n_thresholds,) or (n_thresholds, \*n_outputs) + The scores associated with each threshold. If `metric_func` returns a + collection (e.g., a tuple of floats), the output would be a 2D array + of shape (n_thresholds, \*n_outputs). + + thresholds : ndarray of shape (n_thresholds,) + The thresholds used to compute the scores. + + See Also + -------- + confusion_matrix_at_thresholds : Compute binary confusion matrix per threshold. + precision_recall_curve : Compute precision-recall pairs for different + probability thresholds. + det_curve : Compute error rates for different probability thresholds. + roc_curve : Compute Receiver operating characteristic (ROC) curve. + + Examples + -------- + >>> import numpy as np + >>> from sklearn.metrics import accuracy_score, metric_at_thresholds + >>> y_true = np.array([0, 0, 1, 1]) + >>> y_score = np.array([0.1, 0.4, 0.35, 0.8]) + >>> metric_values, thresholds = metric_at_thresholds( + ... y_true, y_score, accuracy_score) + >>> thresholds + array([0.8 , 0.4 , 0.35, 0.1 ]) + >>> metric_values + array([0.75, 0.5 , 0.75, 0.5 ]) + """ + y_true, y_score, sample_weight, threshold_idxs = ( + _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight + ) + ) + metric_params = { + **(metric_params or {}), + **({"sample_weight": sample_weight} if sample_weight is not None else {}), + } + + thresholds = y_score[threshold_idxs] + metric_values = [] + for threshold in thresholds: + y_pred = (y_score >= threshold).astype(np.int32) + metric_values.append(metric_func(y_true, y_pred, **metric_params)) + + metric_values = np.asarray(metric_values) + return metric_values, thresholds diff --git a/sklearn/metrics/_regression.py b/sklearn/metrics/_regression.py index 955014484fc5d..c70df88e098e4 100644 --- a/sklearn/metrics/_regression.py +++ b/sklearn/metrics/_regression.py @@ -22,6 +22,7 @@ _median, get_namespace, get_namespace_and_device, + move_to, size, ) from sklearn.utils._array_api import _xlogy as xlogy @@ -277,7 +278,8 @@ def mean_absolute_error( >>> mean_absolute_error(y_true, y_pred, multioutput=[0.3, 0.7]) 0.85... """ - xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -376,7 +378,8 @@ def mean_pinball_loss( >>> mean_pinball_loss(y_true, y_true, alpha=0.9) 0.0 """ - xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -481,9 +484,8 @@ def mean_absolute_percentage_error( >>> mean_absolute_percentage_error(y_true, y_pred) 112589990684262.48 """ - xp, _, device_ = get_namespace_and_device( - y_true, y_pred, sample_weight, multioutput - ) + xp, _, device_ = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device_) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( y_true, y_pred, sample_weight, multioutput, xp=xp @@ -574,7 +576,8 @@ def mean_squared_error( >>> mean_squared_error(y_true, y_pred, multioutput=[0.3, 0.7]) 0.825... """ - xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( y_true, y_pred, sample_weight, multioutput, xp=xp @@ -660,7 +663,8 @@ def root_mean_squared_error( 0.822... """ - xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) output_errors = xp.sqrt( mean_squared_error( @@ -751,7 +755,8 @@ def mean_squared_log_error( >>> mean_squared_log_error(y_true, y_pred, multioutput=[0.3, 0.7]) 0.060... """ - xp, _ = get_namespace(y_true, y_pred) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -829,7 +834,8 @@ def root_mean_squared_log_error( >>> root_mean_squared_log_error(y_true, y_pred) 0.199... """ - xp, _ = get_namespace(y_true, y_pred) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -916,9 +922,10 @@ def median_absolute_error( >>> median_absolute_error(y_true, y_pred, multioutput=[0.3, 0.7]) 0.85 """ - xp, _ = get_namespace(y_true, y_pred, multioutput, sample_weight) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = _check_reg_targets( - y_true, y_pred, sample_weight, multioutput + y_true, y_pred, sample_weight, multioutput, xp=xp ) if sample_weight is None: output_errors = _median(xp.abs(y_pred - y_true), axis=0) @@ -936,7 +943,7 @@ def median_absolute_error( return float(_average(output_errors, weights=multioutput, xp=xp)) -def _assemble_r2_explained_variance( +def _assemble_fraction_of_explained_deviance( numerator, denominator, n_outputs, multioutput, force_finite, xp, device ): """Common part used by explained variance score and :math:`R^2` score.""" @@ -1103,7 +1110,8 @@ def explained_variance_score( >>> explained_variance_score(y_true, y_pred, force_finite=False) -inf """ - xp, _, device = get_namespace_and_device(y_true, y_pred, sample_weight, multioutput) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -1121,7 +1129,7 @@ def explained_variance_score( (y_true - y_true_avg) ** 2, weights=sample_weight, axis=0, xp=xp ) - return _assemble_r2_explained_variance( + return _assemble_fraction_of_explained_deviance( numerator=numerator, denominator=denominator, n_outputs=y_true.shape[1], @@ -1273,9 +1281,8 @@ def r2_score( >>> r2_score(y_true, y_pred, force_finite=False) -inf """ - xp, _, device_ = get_namespace_and_device( - y_true, y_pred, sample_weight, multioutput - ) + xp, _, device_ = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device_) _, y_true, y_pred, sample_weight, multioutput = ( _check_reg_targets_with_floating_dtype( @@ -1300,7 +1307,7 @@ def r2_score( axis=0, ) - return _assemble_r2_explained_variance( + return _assemble_fraction_of_explained_deviance( numerator=numerator, denominator=denominator, n_outputs=y_true.shape[1], @@ -1345,7 +1352,8 @@ def max_error(y_true, y_pred): >>> max_error(y_true, y_pred) 1.0 """ - xp, _ = get_namespace(y_true, y_pred) + xp, _, device = get_namespace_and_device(y_pred) + y_true = move_to(y_true, xp=xp, device=device) y_type, y_true, y_pred, _, _ = _check_reg_targets( y_true, y_pred, sample_weight=None, multioutput=None, xp=xp ) @@ -1447,7 +1455,8 @@ def mean_tweedie_deviance(y_true, y_pred, *, sample_weight=None, power=0): >>> mean_tweedie_deviance(y_true, y_pred, power=1) 1.4260... """ - xp, _ = get_namespace(y_true, y_pred) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype( y_true, y_pred, sample_weight, multioutput=None, xp=xp ) @@ -1659,7 +1668,8 @@ def d2_tweedie_score(y_true, y_pred, *, sample_weight=None, power=0): >>> d2_tweedie_score(y_true, y_true, power=2) 1.0 """ - xp, _ = get_namespace(y_true, y_pred) + xp, _, device = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device) y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype( y_true, y_pred, sample_weight, multioutput=None, xp=xp @@ -1779,9 +1789,9 @@ def d2_pinball_score( >>> d2_pinball_score(y_true, y_pred) 0.5 >>> d2_pinball_score(y_true, y_pred, alpha=0.9) - 0.772... + 0.666... >>> d2_pinball_score(y_true, y_pred, alpha=0.1) - -1.045... + -1.999... >>> d2_pinball_score(y_true, y_true, alpha=0.1) 1.0 @@ -1803,8 +1813,12 @@ def d2_pinball_score( >>> grid.best_params_ {'fit_intercept': True} """ - _, y_true, y_pred, sample_weight, multioutput = _check_reg_targets( - y_true, y_pred, sample_weight, multioutput + xp, _, device_ = get_namespace_and_device(y_pred) + y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device_) + _, y_true, y_pred, sample_weight, multioutput = ( + _check_reg_targets_with_floating_dtype( + y_true, y_pred, sample_weight, multioutput, xp=xp + ) ) if _num_samples(y_pred) < 2: @@ -1821,16 +1835,18 @@ def d2_pinball_score( ) if sample_weight is None: - y_quantile = np.tile( - np.percentile(y_true, q=alpha * 100, axis=0), (len(y_true), 1) - ) - else: - y_quantile = np.tile( - _weighted_percentile( - y_true, sample_weight=sample_weight, percentile_rank=alpha * 100 - ), - (len(y_true), 1), - ) + sample_weight = xp.ones([y_true.shape[0]], dtype=y_true.dtype, device=device_) + + y_quantile = xp.tile( + _weighted_percentile( + y_true, + sample_weight=sample_weight, + percentile_rank=alpha * 100, + average=True, + xp=xp, + ), + (y_true.shape[0], 1), + ) denominator = mean_pinball_loss( y_true, @@ -1840,25 +1856,15 @@ def d2_pinball_score( multioutput="raw_values", ) - nonzero_numerator = numerator != 0 - nonzero_denominator = denominator != 0 - valid_score = nonzero_numerator & nonzero_denominator - output_scores = np.ones(y_true.shape[1]) - - output_scores[valid_score] = 1 - (numerator[valid_score] / denominator[valid_score]) - output_scores[nonzero_numerator & ~nonzero_denominator] = 0.0 - - if isinstance(multioutput, str): - if multioutput == "raw_values": - # return scores individually - return output_scores - else: # multioutput == "uniform_average" - # passing None as weights to np.average results in uniform mean - avg_weights = None - else: - avg_weights = multioutput - - return float(np.average(output_scores, weights=avg_weights)) + return _assemble_fraction_of_explained_deviance( + numerator=numerator, + denominator=denominator, + n_outputs=y_true.shape[1], + multioutput=multioutput, + force_finite=True, + xp=xp, + device=device_, + ) @validate_params( diff --git a/sklearn/metrics/cluster/_supervised.py b/sklearn/metrics/cluster/_supervised.py index 409cd74e4e007..2476a431afe51 100644 --- a/sklearn/metrics/cluster/_supervised.py +++ b/sklearn/metrics/cluster/_supervised.py @@ -17,13 +17,12 @@ from sklearn.metrics.cluster._expected_mutual_info_fast import ( expected_mutual_information, ) -from sklearn.utils import deprecated +from sklearn.utils import _align_api_if_sparse, deprecated from sklearn.utils._array_api import ( _max_precision_float_dtype, get_namespace_and_device, ) from sklearn.utils._param_validation import ( - Hidden, Interval, StrOptions, validate_params, @@ -139,11 +138,11 @@ def contingency_matrix( ------- contingency : {array-like, sparse}, shape=[n_classes_true, n_classes_pred] Matrix :math:`C` such that :math:`C_{i, j}` is the number of samples in - true class :math:`i` and in predicted class :math:`j`. If - ``eps is None``, the dtype of this array will be integer unless set + true class :math:`i` and in predicted class :math:`j`. + If ``eps is None``, the dtype of this array will be integer unless set otherwise with the ``dtype`` argument. If ``eps`` is given, the dtype will be float. - Will be a ``sklearn.sparse.csr_matrix`` if ``sparse=True``. + If ``sparse=True`` will be a sparse CSR contingency. Examples -------- @@ -166,12 +165,13 @@ def contingency_matrix( # Using coo_matrix to accelerate simple histogram calculation, # i.e. bins are consecutive integers # Currently, coo_matrix is faster than histogram2d for simple cases - contingency = sp.coo_matrix( + contingency = sp.coo_array( (np.ones(class_idx.shape[0]), (class_idx, cluster_idx)), shape=(n_classes, n_clusters), dtype=dtype, ) if sparse: + contingency = _align_api_if_sparse(contingency) contingency = contingency.tocsr() contingency.sum_duplicates() else: @@ -209,10 +209,10 @@ def pair_confusion_matrix(labels_true, labels_pred): Parameters ---------- - labels_true : array-like of shape (n_samples,), dtype=integral + labels_true : array-like of shape (n_samples,) Ground truth class labels to be used as a reference. - labels_pred : array-like of shape (n_samples,), dtype=integral + labels_pred : array-like of shape (n_samples,) Cluster labels to evaluate. Returns @@ -295,10 +295,10 @@ def rand_score(labels_true, labels_pred): Parameters ---------- - labels_true : array-like of shape (n_samples,), dtype=integral + labels_true : array-like of shape (n_samples,) Ground truth class labels to be used as a reference. - labels_pred : array-like of shape (n_samples,), dtype=integral + labels_pred : array-like of shape (n_samples,) Cluster labels to evaluate. Returns @@ -384,10 +384,10 @@ def adjusted_rand_score(labels_true, labels_pred): Parameters ---------- - labels_true : array-like of shape (n_samples,), dtype=int + labels_true : array-like of shape (n_samples,) Ground truth class labels to be used as a reference. - labels_pred : array-like of shape (n_samples,), dtype=int + labels_pred : array-like of shape (n_samples,) Cluster labels to evaluate. Returns @@ -855,11 +855,11 @@ def mutual_info_score(labels_true, labels_pred, *, contingency=None): Parameters ---------- - labels_true : array-like of shape (n_samples,), dtype=integral + labels_true : array-like of shape (n_samples,) A clustering of the data into disjoint subsets, called :math:`U` in the above formula. - labels_pred : array-like of shape (n_samples,), dtype=integral + labels_pred : array-like of shape (n_samples,) A clustering of the data into disjoint subsets, called :math:`V` in the above formula. @@ -972,11 +972,11 @@ def adjusted_mutual_info_score( Parameters ---------- - labels_true : int array-like of shape (n_samples,) + labels_true : array-like of shape (n_samples,) A clustering of the data into disjoint subsets, called :math:`U` in the above formula. - labels_pred : int array-like of shape (n_samples,) + labels_pred : array-like of shape (n_samples,) A clustering of the data into disjoint subsets, called :math:`V` in the above formula. @@ -1108,10 +1108,10 @@ def normalized_mutual_info_score( Parameters ---------- - labels_true : int array-like of shape (n_samples,) + labels_true : array-like of shape (n_samples,) A clustering of the data into disjoint subsets. - labels_pred : int array-like of shape (n_samples,) + labels_pred : array-like of shape (n_samples,) A clustering of the data into disjoint subsets. average_method : {'min', 'geometric', 'arithmetic', 'max'}, default='arithmetic' @@ -1189,11 +1189,10 @@ def normalized_mutual_info_score( { "labels_true": ["array-like"], "labels_pred": ["array-like"], - "sparse": ["boolean", Hidden(StrOptions({"deprecated"}))], }, prefer_skip_nested_validation=True, ) -def fowlkes_mallows_score(labels_true, labels_pred, *, sparse="deprecated"): +def fowlkes_mallows_score(labels_true, labels_pred): """Measure the similarity of two clusterings of a set of points. .. versionadded:: 0.18 @@ -1218,19 +1217,12 @@ def fowlkes_mallows_score(labels_true, labels_pred, *, sparse="deprecated"): Parameters ---------- - labels_true : array-like of shape (n_samples,), dtype=int + labels_true : array-like of shape (n_samples,) A clustering of the data into disjoint subsets. - labels_pred : array-like of shape (n_samples,), dtype=int + labels_pred : array-like of shape (n_samples,) A clustering of the data into disjoint subsets. - sparse : bool, default=False - Compute contingency matrix internally with sparse matrix. - - .. deprecated:: 1.7 - The ``sparse`` parameter is deprecated and will be removed in 1.9. It has - no effect. - Returns ------- score : float @@ -1264,13 +1256,6 @@ def fowlkes_mallows_score(labels_true, labels_pred, *, sparse="deprecated"): >>> fowlkes_mallows_score([0, 0, 0, 0], [0, 1, 2, 3]) 0.0 """ - # TODO(1.9): remove the sparse parameter - if sparse != "deprecated": - warnings.warn( - "The 'sparse' parameter was deprecated in 1.7 and will be removed in 1.9. " - "It has no effect. Leave it to its default value to silence this warning.", - FutureWarning, - ) labels_true, labels_pred = check_clusterings(labels_true, labels_pred) (n_samples,) = labels_true.shape @@ -1288,7 +1273,7 @@ def _entropy(labels): Parameters ---------- - labels : array-like of shape (n_samples,), dtype=int + labels : array-like of shape (n_samples,) The labels. Returns @@ -1326,7 +1311,7 @@ def entropy(labels): Parameters ---------- - labels : array-like of shape (n_samples,), dtype=int + labels : array-like of shape (n_samples,) The labels. Returns diff --git a/sklearn/metrics/cluster/_unsupervised.py b/sklearn/metrics/cluster/_unsupervised.py index 95b7ff8ce2164..f5006cfcf9031 100644 --- a/sklearn/metrics/cluster/_unsupervised.py +++ b/sklearn/metrics/cluster/_unsupervised.py @@ -19,10 +19,10 @@ from sklearn.utils import _safe_indexing, check_random_state, check_X_y from sklearn.utils._array_api import ( _average, - _convert_to_numpy, _is_numpy_namespace, _max_precision_float_dtype, get_namespace_and_device, + move_to, xpx, ) from sklearn.utils._param_validation import Interval, StrOptions, validate_params @@ -376,7 +376,7 @@ def calinski_harabasz_score(X, labels): if _is_numpy_namespace(xp) and not is_numpy_array(X): # This is required to handle the case where `array_api_dispatch` is False but # we are still dealing with `X` as a non-NumPy array e.g. a PyTorch tensor. - X = _convert_to_numpy(X, xp=xp) + X = move_to(X, xp=np, device="cpu") else: X = xp.astype(X, _max_precision_float_dtype(xp, device_), copy=False) X, labels = check_X_y(X, labels) diff --git a/sklearn/metrics/cluster/tests/test_common.py b/sklearn/metrics/cluster/tests/test_common.py index f439d4cb5e33a..5e76e6bfd6963 100644 --- a/sklearn/metrics/cluster/tests/test_common.py +++ b/sklearn/metrics/cluster/tests/test_common.py @@ -20,7 +20,6 @@ ) from sklearn.metrics.tests.test_common import check_array_api_metric from sklearn.utils._array_api import ( - _get_namespace_device_dtype_ids, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import assert_allclose @@ -239,14 +238,16 @@ def test_returned_value_consistency(name): assert not isinstance(score, (np.float64, np.float32)) -def check_array_api_unsupervised_metric(metric, array_namespace, device, dtype_name): +def check_array_api_unsupervised_metric( + metric, array_namespace, device_name, dtype_name +): y_pred = np.array([1, 0, 1, 0, 1, 1, 0]) X = np.random.randint(10, size=(7, 10)) check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=X, b_np=y_pred, @@ -270,10 +271,11 @@ def yield_metric_checker_combinations(metric_checkers=array_api_metric_checkers) @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("metric, check_func", yield_metric_checker_combinations()) -def test_array_api_compliance(metric, array_namespace, device, dtype_name, check_func): - check_func(metric, array_namespace, device, dtype_name) +def test_array_api_compliance( + metric, array_namespace, device_name, dtype_name, check_func +): + check_func(metric, array_namespace, device_name, dtype_name) diff --git a/sklearn/metrics/cluster/tests/test_supervised.py b/sklearn/metrics/cluster/tests/test_supervised.py index fe4bd8b6dd5df..c62b21535d99c 100644 --- a/sklearn/metrics/cluster/tests/test_supervised.py +++ b/sklearn/metrics/cluster/tests/test_supervised.py @@ -28,7 +28,6 @@ ) from sklearn.utils import assert_all_finite from sklearn.utils._array_api import ( - _get_namespace_device_dtype_ids, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import _array_api_for_tests, assert_almost_equal @@ -284,12 +283,11 @@ def test_entropy(): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_entropy_array_api(array_namespace, device, dtype_name): - xp = _array_api_for_tests(array_namespace, device) +def test_entropy_array_api(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) float_labels = xp.asarray(np.asarray([0, 0, 42.0], dtype=dtype_name), device=device) empty_int32_labels = xp.asarray([], dtype=xp.int32, device=device) int_labels = xp.asarray([1, 1, 1, 1], device=device) @@ -520,13 +518,3 @@ def test_normalized_mutual_info_score_bounded(average_method): # non constant, non perfect matching labels nmi = normalized_mutual_info_score(labels2, labels3, average_method=average_method) assert 0 <= nmi < 1 - - -# TODO(1.9): remove -@pytest.mark.parametrize("sparse", [True, False]) -def test_fowlkes_mallows_sparse_deprecated(sparse): - """Check deprecation warning for 'sparse' parameter of fowlkes_mallows_score.""" - with pytest.warns( - FutureWarning, match="The 'sparse' parameter was deprecated in 1.7" - ): - fowlkes_mallows_score([0, 1], [1, 1], sparse=sparse) diff --git a/sklearn/metrics/meson.build b/sklearn/metrics/meson.build index f0f9894cc6f59..b648d9be8df91 100644 --- a/sklearn/metrics/meson.build +++ b/sklearn/metrics/meson.build @@ -23,7 +23,7 @@ _dist_metrics_pyx = custom_target( output: '_dist_metrics.pyx', input: '_dist_metrics.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: metrics_cython_tree, diff --git a/sklearn/metrics/pairwise.py b/sklearn/metrics/pairwise.py index 005a353b8d778..02e501d13b4ab 100644 --- a/sklearn/metrics/pairwise.py +++ b/sklearn/metrics/pairwise.py @@ -11,7 +11,7 @@ import numpy as np from joblib import effective_n_jobs -from scipy.sparse import csr_matrix, issparse +from scipy.sparse import csr_array, issparse from scipy.spatial import distance from sklearn import config_context @@ -40,6 +40,7 @@ StrOptions, validate_params, ) +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import row_norms, safe_sparse_dot from sklearn.utils.fixes import parse_version, sp_base_version from sklearn.utils.parallel import Parallel, delayed @@ -52,8 +53,9 @@ def _return_float_dtype(X, Y): 1. If dtype of X and Y is float32, then dtype float32 is returned. 2. Else dtype float is returned. """ + xp, _ = get_namespace(X, Y) if not issparse(X) and not isinstance(X, np.ndarray): - X = np.asarray(X) + X = xp.asarray(X) if Y is None: Y_dtype = X.dtype @@ -649,7 +651,8 @@ def _argmin_reduce(dist, start): # `start` is specified in the signature but not used. This is because the higher # order `pairwise_distances_chunked` function needs reduction functions that are # passed as argument to have a two arguments signature. - return dist.argmin(axis=1) + xp, _ = get_namespace(dist) + return xp.argmin(dist, axis=1) _VALID_METRICS = [ @@ -936,6 +939,7 @@ def pairwise_distances_argmin(X, Y, *, axis=1, metric="euclidean", metric_kwargs """ ensure_all_finite = "allow-nan" if metric == "nan_euclidean" else True X, Y = check_pairwise_arrays(X, Y, ensure_all_finite=ensure_all_finite) + xp, _ = get_namespace(X, Y) if axis == 0: X, Y = Y, X @@ -943,7 +947,7 @@ def pairwise_distances_argmin(X, Y, *, axis=1, metric="euclidean", metric_kwargs if metric_kwargs is None: metric_kwargs = {} - if ArgKmin.is_usable_for(X, Y, metric): + if ArgKmin.is_usable_for(X, Y, metric) and _is_numpy_namespace(xp): # This is an adaptor for one "sqeuclidean" specification. # For this backend, we can directly use "sqeuclidean". if metric_kwargs.get("squared", False) and metric == "euclidean": @@ -971,14 +975,13 @@ def pairwise_distances_argmin(X, Y, *, axis=1, metric="euclidean", metric_kwargs # Turn off check for finiteness because this is costly and because arrays # have already been validated. with config_context(assume_finite=True): - indices = np.concatenate( + indices = xp.concat( list( - # This returns a np.ndarray generator whose arrays we need - # to flatten into one. pairwise_distances_chunked( X, Y, reduce_func=_argmin_reduce, metric=metric, **metric_kwargs ) - ) + ), + axis=0, ) return indices @@ -1092,8 +1095,8 @@ def manhattan_distances(X, Y=None): n_x, n_y = X.shape[0], Y.shape[0] if issparse(X) or issparse(Y): - X = csr_matrix(X, copy=False) - Y = csr_matrix(Y, copy=False) + X = csr_array(X, copy=False) + Y = csr_array(Y, copy=False) X.sum_duplicates() # this also sorts indices in-place Y.sum_duplicates() D = np.zeros((n_x, n_y)) @@ -1251,12 +1254,13 @@ def paired_manhattan_distances(X, Y): array([1., 2., 1.]) """ X, Y = check_paired_arrays(X, Y) + xp, _ = get_namespace(X, Y) diff = X - Y if issparse(diff): diff.data = np.abs(diff.data) return np.squeeze(np.array(diff.sum(axis=1))) else: - return np.abs(diff).sum(axis=-1) + return xp.sum(xp.abs(diff), axis=-1) @validate_params( @@ -1545,12 +1549,14 @@ def sigmoid_kernel(X, Y=None, gamma=None, coef0=1): """ xp, _ = get_namespace(X, Y) X, Y = check_pairwise_arrays(X, Y) + if gamma is None: gamma = 1.0 / X.shape[1] K = safe_sparse_dot(X, Y.T, dense_output=True) K *= gamma K += coef0 + # compute tanh in-place for numpy K = _modify_in_place_if_numpy(xp, xp.tanh, K, out=K) return K @@ -1743,7 +1749,7 @@ def cosine_similarity(X, Y=None, dense_output=True): K = safe_sparse_dot(X_normalized, Y_normalized.T, dense_output=dense_output) - return K + return _align_api_if_sparse(K) @validate_params( @@ -1977,7 +1983,16 @@ def _parallel_pairwise(X, Y, func, n_jobs, **kwds): # allocate 2D arrays using the C-contiguity convention by default. ret = xp.empty((X.shape[0], Y.shape[0]), device=device, dtype=dtype_float).T Parallel(backend="threading", n_jobs=n_jobs)( - fd(func, ret, s, X, Y[s, ...], **kwds) + fd( + func, + ret, + s, + X, + Y[s, ...], + # Y_norm_squared for euclidean distance is a precomputed per-sample norm + # passed through kwds; slice it to match the current Y chunk. + **{k: (v[s] if k == "Y_norm_squared" else v) for k, v in kwds.items()}, + ) for s in gen_even_slices(_num_samples(Y), effective_n_jobs(n_jobs)) ) @@ -2037,7 +2052,7 @@ def _get_slice(array, index): else: # Calculate all cells - out = xp.empty((X.shape[0], Y.shape[0]), dtype=dtype_float) + out = xp.empty((X.shape[0], Y.shape[0]), dtype=dtype_float, device=device) iterator = itertools.product(range(X.shape[0]), range(Y.shape[0])) for i, j in iterator: x = _get_slice(X, i) diff --git a/sklearn/metrics/tests/test_classification.py b/sklearn/metrics/tests/test_classification.py index b8dc67b298be7..d98c62e2068bd 100644 --- a/sklearn/metrics/tests/test_classification.py +++ b/sklearn/metrics/tests/test_classification.py @@ -11,6 +11,7 @@ from sklearn import datasets, svm from sklearn.base import config_context +from sklearn.calibration import CalibratedClassifierCV from sklearn.datasets import make_multilabel_classification from sklearn.exceptions import UndefinedMetricWarning from sklearn.metrics import ( @@ -69,7 +70,7 @@ def make_prediction(dataset=None, binary=False): - """Make some classification predictions on a toy dataset using a SVC + """Make some classification predictions on a toy dataset using an SVC If binary is True restrict to a binary classification problem instead of a multiclass classification problem @@ -99,7 +100,9 @@ def make_prediction(dataset=None, binary=False): X = np.c_[X, rng.randn(n_samples, 200 * n_features)] # run classifier, get class probabilities and label predictions - clf = svm.SVC(kernel="linear", probability=True, random_state=0) + clf = CalibratedClassifierCV( + svm.SVC(kernel="linear", random_state=0), ensemble=False, cv=3 + ) y_pred_proba = clf.fit(X[:half], y[:half]).predict_proba(X[half:]) if binary: @@ -746,19 +749,6 @@ def test_likelihood_ratios(): assert_allclose(neg, 12 / 27) -# TODO(1.9): remove test -@pytest.mark.parametrize("raise_warning", [True, False]) -def test_likelihood_ratios_raise_warning_deprecation(raise_warning): - """Test that class_likelihood_ratios raises a `FutureWarning` when `raise_warning` - param is set.""" - y_true = np.array([1, 0]) - y_pred = np.array([1, 0]) - - msg = "`raise_warning` was deprecated in version 1.7 and will be removed in 1.9." - with pytest.warns(FutureWarning, match=msg): - class_likelihood_ratios(y_true, y_pred, raise_warning=raise_warning) - - def test_likelihood_ratios_replace_undefined_by_worst(): """Test that class_likelihood_ratios returns the worst scores `1.0` for both LR+ and LR- when `replace_undefined_by=1` is set.""" @@ -894,6 +884,68 @@ def test_cohen_kappa(): ) +@ignore_warnings(category=UndefinedMetricWarning) +@pytest.mark.parametrize( + "test_case", + [ + # annotator y2 does not assign any label specified in `labels` (note: also + # applicable if `labels` is default and `y2` does not contain any label that is + # in `y1`): + ([1] * 5 + [2] * 5, [3] * 10, [1, 2], None), + # both inputs (`y1` and `y2`) only have one label: + ([3] * 10, [3] * 10, None, None), + # both inputs only have one label in common that is also in `labels`: + ([1] * 5 + [2] * 5, [1] * 5 + [3] * 5, [1, 2], None), + # like the last test case, but with `weights="linear"` (note that + # weights="linear" and weights="quadratic" are different branches, though the + # latter is so similar to the former that the test case is skipped here): + ([1] * 5 + [2] * 5, [1] * 5 + [3] * 5, [1, 2], "linear"), + ], +) +@pytest.mark.parametrize("replace_undefined_by", [0.0, np.nan]) +def test_cohen_kappa_undefined(test_case, replace_undefined_by): + """Test that cohen_kappa_score handles divisions by 0 correctly by returning the + `replace_undefined_by` param. (The first test case covers the first possible + location in the function for an occurrence of a division by zero, the last three + test cases cover a zero division in the second possible location in the + function.""" + + y1, y2, labels, weights = test_case + y1, y2 = np.array(y1), np.array(y2) + + score = cohen_kappa_score( + y1, + y2, + labels=labels, + weights=weights, + replace_undefined_by=replace_undefined_by, + ) + assert_allclose(score, replace_undefined_by, equal_nan=True) + + +def test_cohen_kappa_zero_division_warning(): + """Test that cohen_kappa_score raises UndefinedMetricWarning when a division by 0 + occurs.""" + + labels = [1, 2] + y1 = np.array([1] * 5 + [2] * 5) + y2 = np.array([3] * 10) + with pytest.warns( + UndefinedMetricWarning, + match="`y2` contains no labels that are present in both `y1` and `labels`.", + ): + cohen_kappa_score(y1, y2, labels=labels) + + labels = [1, 2] + y1 = np.array([1] * 5 + [2] * 5) + y2 = np.array([1] * 5 + [3] * 5) + with pytest.warns( + UndefinedMetricWarning, + match="`y1`, `y2` and `labels` have only one label in common.", + ): + cohen_kappa_score(y1, y2, labels=labels) + + def test_cohen_kappa_score_error_wrong_label(): """Test that correct error is raised when users pass labels that are not in y1.""" labels = [1, 2] @@ -1571,7 +1623,7 @@ def test_multilabel_hamming_loss(): def test_jaccard_score_validation(): y_true = np.array([0, 1, 0, 1, 1]) y_pred = np.array([0, 1, 0, 1, 1]) - err_msg = r"pos_label=2 is not a valid label. It should be one of \[0, 1\]" + err_msg = re.escape("pos_label=2 is not a valid label. It should be one of [0 1]") with pytest.raises(ValueError, match=err_msg): jaccard_score(y_true, y_pred, average="binary", pos_label=2) @@ -2455,7 +2507,6 @@ def test__check_targets(): MCN = "continuous-multioutput" # all of length 3 EXAMPLES = [ - (IND, np.array([[0, 1, 1], [1, 0, 0], [0, 0, 1]])), # must not be considered binary (IND, np.array([[0, 1], [1, 0], [1, 1]])), (MC, [2, 3, 1]), @@ -2518,7 +2569,7 @@ def test__check_targets(): _check_targets(y1, y2) else: - merged_type, y1out, y2out, _ = _check_targets(y1, y2) + merged_type, _, y1out, y2out, _ = _check_targets(y1, y2) assert merged_type == expected if merged_type.startswith("multilabel"): assert y1out.format == "csr" @@ -2572,7 +2623,7 @@ def test__check_targets_sparse_inputs(y, target_type): _check_targets(y, y) else: # This should not raise an error - y_type, y_true_out, y_pred_out, _ = _check_targets(y, y) + y_type, _, y_true_out, y_pred_out, _ = _check_targets(y, y) assert y_type == "multilabel-indicator" assert y_true_out.format == "csr" @@ -2750,65 +2801,65 @@ def test_hinge_loss_multiclass_invariance_lists(): def test_log_loss(): # binary case with symbolic labels ("no" < "yes") y_true = ["no", "no", "no", "yes", "yes", "yes"] - y_pred = np.array( + y_proba = np.array( [[0.5, 0.5], [0.1, 0.9], [0.01, 0.99], [0.9, 0.1], [0.75, 0.25], [0.001, 0.999]] ) - loss = log_loss(y_true, y_pred) - loss_true = -np.mean(bernoulli.logpmf(np.array(y_true) == "yes", y_pred[:, 1])) + loss = log_loss(y_true, y_proba) + loss_true = -np.mean(bernoulli.logpmf(np.array(y_true) == "yes", y_proba[:, 1])) assert_allclose(loss, loss_true) # multiclass case; adapted from http://bit.ly/RJJHWA y_true = [1, 0, 2] - y_pred = [[0.2, 0.7, 0.1], [0.6, 0.2, 0.2], [0.6, 0.1, 0.3]] - loss = log_loss(y_true, y_pred, normalize=True) + y_proba = [[0.2, 0.7, 0.1], [0.6, 0.2, 0.2], [0.6, 0.1, 0.3]] + loss = log_loss(y_true, y_proba, normalize=True) assert_allclose(loss, 0.6904911) # check that we got all the shapes and axes right - # by doubling the length of y_true and y_pred + # by doubling the length of y_true and y_proba y_true *= 2 - y_pred *= 2 - loss = log_loss(y_true, y_pred, normalize=False) + y_proba *= 2 + loss = log_loss(y_true, y_proba, normalize=False) assert_allclose(loss, 0.6904911 * 6) # raise error if number of classes are not equal. y_true = [1, 0, 2] - y_pred = [[0.3, 0.7], [0.6, 0.4], [0.4, 0.6]] + y_proba = [[0.3, 0.7], [0.6, 0.4], [0.4, 0.6]] with pytest.raises(ValueError): - log_loss(y_true, y_pred) + log_loss(y_true, y_proba) # raise error if labels do not contain all values of y_true y_true = ["a", "b", "c"] - y_pred = [[0.9, 0.1, 0.0], [0.1, 0.9, 0.0], [0.1, 0.1, 0.8]] + y_proba = [[0.9, 0.1, 0.0], [0.1, 0.9, 0.0], [0.1, 0.1, 0.8]] labels = ["a", "c", "d"] error_str = ( "y_true contains values {'b'} not belonging to the passed " "labels ['a', 'c', 'd']." ) with pytest.raises(ValueError, match=re.escape(error_str)): - log_loss(y_true, y_pred, labels=labels) + log_loss(y_true, y_proba, labels=labels) # case when y_true is a string array object y_true = ["ham", "spam", "spam", "ham"] - y_pred = [[0.3, 0.7], [0.6, 0.4], [0.4, 0.6], [0.7, 0.3]] - loss = log_loss(y_true, y_pred) + y_proba = [[0.3, 0.7], [0.6, 0.4], [0.4, 0.6], [0.7, 0.3]] + loss = log_loss(y_true, y_proba) assert_allclose(loss, 0.7469410) # test labels option y_true = [2, 2] - y_pred = [[0.2, 0.8], [0.6, 0.4]] + y_proba = [[0.2, 0.8], [0.6, 0.4]] y_score = np.array([[0.1, 0.9], [0.1, 0.9]]) error_str = ( "y_true contains only one label (2). Please provide the list of all " "expected class labels explicitly through the labels argument." ) with pytest.raises(ValueError, match=re.escape(error_str)): - log_loss(y_true, y_pred) + log_loss(y_true, y_proba) - y_pred = [[0.2, 0.8], [0.6, 0.4], [0.7, 0.3]] + y_proba = [[0.2, 0.8], [0.6, 0.4], [0.7, 0.3]] error_str = "Found input variables with inconsistent numbers of samples: [3, 2]" with pytest.raises(ValueError, match=re.escape(error_str)): - log_loss(y_true, y_pred) + log_loss(y_true, y_proba) # works when the labels argument is used @@ -2816,7 +2867,7 @@ def test_log_loss(): calculated_log_loss = log_loss(y_true, y_score, labels=[1, 2]) assert_allclose(calculated_log_loss, true_log_loss) - # ensure labels work when len(np.unique(y_true)) != y_pred.shape[1] + # ensure labels work when len(np.unique(y_true)) != y_proba.shape[1] y_true = [1, 2, 2] y_score2 = [[0.7, 0.1, 0.2], [0.2, 0.7, 0.1], [0.1, 0.7, 0.2]] loss = log_loss(y_true, y_score2, labels=[1, 2, 3]) @@ -2831,34 +2882,34 @@ def test_log_loss_eps(dtype): https://github.com/scikit-learn/scikit-learn/issues/24315 """ y_true = np.array([0, 1], dtype=dtype) - y_pred = np.array([1, 0], dtype=dtype) + y_proba = np.array([1, 0], dtype=dtype) - loss = log_loss(y_true, y_pred) + loss = log_loss(y_true, y_proba) assert np.isfinite(loss) @pytest.mark.parametrize("dtype", [np.float64, np.float32, np.float16]) def test_log_loss_not_probabilities_warning(dtype): - """Check that log_loss raises a warning when y_pred values don't sum to 1.""" + """Check that log_loss raises a warning when y_proba values don't sum to 1.""" y_true = np.array([0, 1, 1, 0]) - y_pred = np.array([[0.2, 0.7], [0.6, 0.3], [0.4, 0.7], [0.8, 0.3]], dtype=dtype) + y_proba = np.array([[0.2, 0.7], [0.6, 0.3], [0.4, 0.7], [0.8, 0.3]], dtype=dtype) with pytest.warns(UserWarning, match="The y_prob values do not sum to one."): - log_loss(y_true, y_pred) + log_loss(y_true, y_proba) @pytest.mark.parametrize( - "y_true, y_pred", + "y_true, y_proba", [ ([0, 1, 0], [0, 1, 0]), ([0, 1, 0], [[1, 0], [0, 1], [1, 0]]), ([0, 1, 2], [[1, 0, 0], [0, 1, 0], [0, 0, 1]]), ], ) -def test_log_loss_perfect_predictions(y_true, y_pred): +def test_log_loss_perfect_predictions(y_true, y_proba): """Check that log_loss returns 0 for perfect predictions.""" # Because of the clipping, the result is not exactly 0 - assert log_loss(y_true, y_pred) == pytest.approx(0) + assert log_loss(y_true, y_proba) == pytest.approx(0) def test_log_loss_pandas_input(): @@ -2873,9 +2924,9 @@ def test_log_loss_pandas_input(): except ImportError: pass for TrueInputType, PredInputType in types: - # y_pred dataframe, y_true series - y_true, y_pred = TrueInputType(y_tr), PredInputType(y_pr) - loss = log_loss(y_true, y_pred) + # y_proba dataframe, y_true series + y_true, y_proba = TrueInputType(y_tr), PredInputType(y_pr) + loss = log_loss(y_true, y_proba) assert_allclose(loss, 0.7469410) @@ -2894,6 +2945,24 @@ def test_log_loss_warnings(): ) +# TODO(1.11): Remove +def test_log_loss_y_pred_deprecation(): + """Test `y_pred` deprecation in favor of `y_proba` for `log_loss`.""" + y_true = np.array([0, 1, 1, 0]) + y_proba = np.array([[0.1, 0.9], [0.9, 0.1], [0.8, 0.2], [0.35, 0.65]]) + + # Check no error raised + log_loss(y_true, y_proba) + + msg = "`y_pred` was renamed to `y_proba` in version 1.9 and will be removed " + with pytest.warns(FutureWarning, match=re.escape(msg)): + log_loss(y_true, y_pred=y_proba) + + msg = "Cannot use both `y_pred` and `y_proba`. `y_pred` is deprecated, " + with pytest.raises(ValueError, match=re.escape(msg)): + log_loss(y_true, y_pred=y_proba, y_proba=y_proba) + + def test_brier_score_loss_binary(): # Check brier_score_loss function y_true = np.array([0, 1, 1, 0, 1, 1]) @@ -3153,7 +3222,7 @@ def test_f1_for_small_binary_inputs_with_zero_division(y_true, y_pred, expected_ make_scorer(recall_score, zero_division=np.nan), ], ) -def test_classification_metric_division_by_zero_nan_validaton(scoring): +def test_classification_metric_division_by_zero_nan_validation(scoring): """Check that we validate `np.nan` properly for classification metrics. With `n_jobs=2` in cross-validation, the `np.nan` used for the singleton will be @@ -3171,7 +3240,7 @@ def test_classification_metric_division_by_zero_nan_validaton(scoring): def test_d2_log_loss_score(): y_true = [0, 0, 0, 1, 1, 1] y_true_string = ["no", "no", "no", "yes", "yes", "yes"] - y_pred = np.array( + y_proba = np.array( [ [0.5, 0.5], [0.9, 0.1], @@ -3181,7 +3250,7 @@ def test_d2_log_loss_score(): [0.01, 0.99], ] ) - y_pred_null = np.array( + y_proba_null = np.array( [ [0.5, 0.5], [0.5, 0.5], @@ -3191,28 +3260,28 @@ def test_d2_log_loss_score(): [0.5, 0.5], ] ) - d2_score = d2_log_loss_score(y_true=y_true, y_pred=y_pred) - log_likelihood = log_loss(y_true=y_true, y_pred=y_pred, normalize=False) - log_likelihood_null = log_loss(y_true=y_true, y_pred=y_pred_null, normalize=False) + d2_score = d2_log_loss_score(y_true=y_true, y_proba=y_proba) + log_likelihood = log_loss(y_true=y_true, y_proba=y_proba, normalize=False) + log_likelihood_null = log_loss(y_true=y_true, y_proba=y_proba_null, normalize=False) d2_score_true = 1 - log_likelihood / log_likelihood_null assert d2_score == pytest.approx(d2_score_true) # check that using sample weight also gives the correct d2 score sample_weight = np.array([2, 1, 3, 4, 3, 1]) - y_pred_null[:, 0] = sample_weight[:3].sum() / sample_weight.sum() - y_pred_null[:, 1] = sample_weight[3:].sum() / sample_weight.sum() + y_proba_null[:, 0] = sample_weight[:3].sum() / sample_weight.sum() + y_proba_null[:, 1] = sample_weight[3:].sum() / sample_weight.sum() d2_score = d2_log_loss_score( - y_true=y_true, y_pred=y_pred, sample_weight=sample_weight + y_true=y_true, y_proba=y_proba, sample_weight=sample_weight ) log_likelihood = log_loss( y_true=y_true, - y_pred=y_pred, + y_proba=y_proba, sample_weight=sample_weight, normalize=False, ) log_likelihood_null = log_loss( y_true=y_true, - y_pred=y_pred_null, + y_proba=y_proba_null, sample_weight=sample_weight, normalize=False, ) @@ -3220,7 +3289,7 @@ def test_d2_log_loss_score(): assert d2_score == pytest.approx(d2_score_true) # check if good predictions give a relatively higher value for the d2 score - y_pred = np.array( + y_proba = np.array( [ [0.9, 0.1], [0.8, 0.2], @@ -3230,14 +3299,14 @@ def test_d2_log_loss_score(): [0.1, 0.9], ] ) - d2_score = d2_log_loss_score(y_true, y_pred) + d2_score = d2_log_loss_score(y_true, y_proba) assert 0.5 < d2_score < 1.0 # check that a similar value is obtained for string labels - d2_score_string = d2_log_loss_score(y_true_string, y_pred) + d2_score_string = d2_log_loss_score(y_true_string, y_proba) assert d2_score_string == pytest.approx(d2_score) # check if poor predictions gives a relatively low value for the d2 score - y_pred = np.array( + y_proba = np.array( [ [0.5, 0.5], [0.1, 0.9], @@ -3247,16 +3316,16 @@ def test_d2_log_loss_score(): [0.1, 0.9], ] ) - d2_score = d2_log_loss_score(y_true, y_pred) + d2_score = d2_log_loss_score(y_true, y_proba) assert d2_score < 0 # check that a similar value is obtained for string labels - d2_score_string = d2_log_loss_score(y_true_string, y_pred) + d2_score_string = d2_log_loss_score(y_true_string, y_proba) assert d2_score_string == pytest.approx(d2_score) # check if simply using the average of the classes as the predictions # gives a d2 score of 0 y_true = [0, 0, 0, 1, 1, 1] - y_pred = np.array( + y_proba = np.array( [ [0.5, 0.5], [0.5, 0.5], @@ -3266,23 +3335,23 @@ def test_d2_log_loss_score(): [0.5, 0.5], ] ) - d2_score = d2_log_loss_score(y_true, y_pred) + d2_score = d2_log_loss_score(y_true, y_proba) assert d2_score == 0 - d2_score_string = d2_log_loss_score(y_true_string, y_pred) + d2_score_string = d2_log_loss_score(y_true_string, y_proba) assert d2_score_string == 0 # check if simply using the average of the classes as the predictions # gives a d2 score of 0 when the positive class has a higher proportion y_true = [0, 1, 1, 1] y_true_string = ["no", "yes", "yes", "yes"] - y_pred = np.array([[0.25, 0.75], [0.25, 0.75], [0.25, 0.75], [0.25, 0.75]]) - d2_score = d2_log_loss_score(y_true, y_pred) + y_proba = np.array([[0.25, 0.75], [0.25, 0.75], [0.25, 0.75], [0.25, 0.75]]) + d2_score = d2_log_loss_score(y_true, y_proba) assert d2_score == 0 - d2_score_string = d2_log_loss_score(y_true_string, y_pred) + d2_score_string = d2_log_loss_score(y_true_string, y_proba) assert d2_score_string == 0 sample_weight = [2, 2, 2, 2] d2_score_with_sample_weight = d2_log_loss_score( - y_true, y_pred, sample_weight=sample_weight + y_true, y_proba, sample_weight=sample_weight ) assert d2_score_with_sample_weight == 0 @@ -3291,7 +3360,7 @@ def test_d2_log_loss_score(): y_true = ["high", "high", "low", "neutral"] sample_weight = [1.4, 0.6, 0.8, 0.2] - y_pred = np.array( + y_proba = np.array( [ [0.8, 0.1, 0.1], [0.8, 0.1, 0.1], @@ -3299,12 +3368,12 @@ def test_d2_log_loss_score(): [0.1, 0.1, 0.8], ] ) - d2_score = d2_log_loss_score(y_true, y_pred) + d2_score = d2_log_loss_score(y_true, y_proba) assert 0.5 < d2_score < 1.0 - d2_score = d2_log_loss_score(y_true, y_pred, sample_weight=sample_weight) + d2_score = d2_log_loss_score(y_true, y_proba, sample_weight=sample_weight) assert 0.5 < d2_score < 1.0 - y_pred = np.array( + y_proba = np.array( [ [0.2, 0.5, 0.3], [0.1, 0.7, 0.2], @@ -3312,9 +3381,9 @@ def test_d2_log_loss_score(): [0.2, 0.7, 0.1], ] ) - d2_score = d2_log_loss_score(y_true, y_pred) + d2_score = d2_log_loss_score(y_true, y_proba) assert d2_score < 0 - d2_score = d2_log_loss_score(y_true, y_pred, sample_weight=sample_weight) + d2_score = d2_log_loss_score(y_true, y_proba, sample_weight=sample_weight) assert d2_score < 0 @@ -3326,23 +3395,23 @@ def test_d2_log_loss_score_missing_labels(): y_true = [2, 0, 2, 0] labels = [0, 1, 2] sample_weight = [1.4, 0.6, 0.7, 0.3] - y_pred = np.tile([1, 0, 0], (4, 1)) + y_proba = np.tile([1, 0, 0], (4, 1)) - log_loss_obs = log_loss(y_true, y_pred, sample_weight=sample_weight, labels=labels) + log_loss_obs = log_loss(y_true, y_proba, sample_weight=sample_weight, labels=labels) # Null model consists of weighted average of the classes. # Given that the sum of the weights is 3, # - weighted average of 0s is (0.6 + 0.3) / 3 = 0.3 # - weighted average of 1s is 0 # - weighted average of 2s is (1.4 + 0.7) / 3 = 0.7 - y_pred_null = np.tile([0.3, 0, 0.7], (4, 1)) + y_proba_null = np.tile([0.3, 0, 0.7], (4, 1)) log_loss_null = log_loss( - y_true, y_pred_null, sample_weight=sample_weight, labels=labels + y_true, y_proba_null, sample_weight=sample_weight, labels=labels ) expected_d2_score = 1 - log_loss_obs / log_loss_null d2_score = d2_log_loss_score( - y_true, y_pred, sample_weight=sample_weight, labels=labels + y_true, y_proba, sample_weight=sample_weight, labels=labels ) assert_allclose(d2_score, expected_d2_score) @@ -3350,10 +3419,10 @@ def test_d2_log_loss_score_missing_labels(): def test_d2_log_loss_score_label_order(): """Check that d2_log_loss_score doesn't depend on the order of the labels.""" y_true = [2, 0, 2, 0] - y_pred = np.tile([1, 0, 0], (4, 1)) + y_proba = np.tile([1, 0, 0], (4, 1)) - d2_score = d2_log_loss_score(y_true, y_pred, labels=[0, 1, 2]) - d2_score_other = d2_log_loss_score(y_true, y_pred, labels=[0, 2, 1]) + d2_score = d2_log_loss_score(y_true, y_proba, labels=[0, 1, 2]) + d2_score_other = d2_log_loss_score(y_true, y_proba, labels=[0, 2, 1]) assert_allclose(d2_score, d2_score_other) @@ -3362,49 +3431,67 @@ def test_d2_log_loss_score_raises(): """Test that d2_log_loss_score raises the appropriate errors on invalid inputs.""" y_true = [0, 1, 2] - y_pred = [[0.2, 0.8], [0.5, 0.5], [0.4, 0.6]] + y_proba = [[0.2, 0.8], [0.5, 0.5], [0.4, 0.6]] err = "contain different number of classes" with pytest.raises(ValueError, match=err): - d2_log_loss_score(y_true, y_pred) + d2_log_loss_score(y_true, y_proba) # check error if the number of classes in labels do not match the number - # of classes in y_pred. + # of classes in y_proba. y_true = [0, 1, 2] - y_pred = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] + y_proba = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] labels = [0, 1, 2] err = "number of classes in labels is different" with pytest.raises(ValueError, match=err): - d2_log_loss_score(y_true, y_pred, labels=labels) + d2_log_loss_score(y_true, y_proba, labels=labels) - # check error if y_true and y_pred do not have equal lengths + # check error if y_true and y_proba do not have equal lengths y_true = [0, 1, 2] - y_pred = [[0.5, 0.5, 0.5], [0.6, 0.3, 0.1]] + y_proba = [[0.5, 0.5, 0.5], [0.6, 0.3, 0.1]] err = "inconsistent numbers of samples" with pytest.raises(ValueError, match=err): - d2_log_loss_score(y_true, y_pred) + d2_log_loss_score(y_true, y_proba) # check warning for samples < 2 y_true = [1] - y_pred = [[0.5, 0.5]] + y_proba = [[0.5, 0.5]] err = "score is not well-defined" with pytest.warns(UndefinedMetricWarning, match=err): - d2_log_loss_score(y_true, y_pred) + d2_log_loss_score(y_true, y_proba) # check error when y_true only has 1 label y_true = [1, 1, 1] - y_pred = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] + y_proba = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] err = "y_true contains only one label" with pytest.raises(ValueError, match=err): - d2_log_loss_score(y_true, y_pred) + d2_log_loss_score(y_true, y_proba) # check error when y_true only has 1 label and labels also has # only 1 label y_true = [1, 1, 1] labels = [1] - y_pred = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] + y_proba = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]] err = "The labels array needs to contain at least two" with pytest.raises(ValueError, match=err): - d2_log_loss_score(y_true, y_pred, labels=labels) + d2_log_loss_score(y_true, y_proba, labels=labels) + + +# TODO(1.11): Remove +def test_d2_log_loss_score_y_pred_deprecation(): + """Test `y_pred` deprecation in favor of `y_proba` for `d2_log_loss_score`.""" + y_true = np.array([0, 1, 1, 0]) + y_proba = np.array([[0.1, 0.9], [0.9, 0.1], [0.8, 0.2], [0.35, 0.65]]) + + # Check no error raised + d2_log_loss_score(y_true, y_proba) + + msg = "`y_pred` was renamed to `y_proba` in version 1.9 and will be removed " + with pytest.warns(FutureWarning, match=re.escape(msg)): + d2_log_loss_score(y_true, y_pred=y_proba) + + msg = "Cannot use both `y_pred` and `y_proba`. `y_pred` is deprecated, " + with pytest.raises(ValueError, match=re.escape(msg)): + d2_log_loss_score(y_true, y_pred=y_proba, y_proba=y_proba) def test_d2_brier_score(): @@ -3617,12 +3704,13 @@ def test_d2_brier_score_warning_on_less_than_two_samples(): @pytest.mark.parametrize( - "array_namespace, device, _", yield_namespace_device_dtype_combinations() + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) -def test_confusion_matrix_array_api(array_namespace, device, _): +def test_confusion_matrix_array_api(array_namespace, device_name, dtype_name): """Test that `confusion_matrix` works for all array types when `labels` are passed such that the inner boolean `need_index_conversion` evaluates to `True`.""" - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) y_true = xp.asarray([1, 2, 3], device=device) y_pred = xp.asarray([4, 5, 6], device=device) @@ -3632,114 +3720,3 @@ def test_confusion_matrix_array_api(array_namespace, device, _): result = confusion_matrix(y_true, y_pred, labels=labels) assert get_namespace(result)[0] == get_namespace(y_pred)[0] assert array_api_device(result) == array_api_device(y_pred) - - -@pytest.mark.parametrize( - "prob_metric", [brier_score_loss, log_loss, d2_brier_score, d2_log_loss_score] -) -@pytest.mark.parametrize("str_y_true", [False, True]) -@pytest.mark.parametrize("use_sample_weight", [False, True]) -@pytest.mark.parametrize( - "array_namespace, device_, dtype_name", yield_namespace_device_dtype_combinations() -) -def test_probabilistic_metrics_array_api( - prob_metric, str_y_true, use_sample_weight, array_namespace, device_, dtype_name -): - """Test that :func:`brier_score_loss`, :func:`log_loss`, func:`d2_brier_score` - and :func:`d2_log_loss_score` work correctly with the array API for binary - and mutli-class inputs. - """ - xp = _array_api_for_tests(array_namespace, device_) - sample_weight = np.array([1, 2, 3, 1]) if use_sample_weight else None - - # binary case - extra_kwargs = {} - if str_y_true: - y_true_np = np.array(["yes", "no", "yes", "no"]) - y_true_xp_or_np = np.asarray(y_true_np) - if "brier" in prob_metric.__name__: - # `brier_score_loss` and `d2_brier_score` require specifying the - # `pos_label` - extra_kwargs["pos_label"] = "yes" - else: - y_true_np = np.array([1, 0, 1, 0]) - y_true_xp_or_np = xp.asarray(y_true_np, device=device_) - - y_prob_np = np.array([0.5, 0.2, 0.7, 0.6], dtype=dtype_name) - y_prob_xp = xp.asarray(y_prob_np, device=device_) - metric_score_np = prob_metric( - y_true_np, y_prob_np, sample_weight=sample_weight, **extra_kwargs - ) - with config_context(array_api_dispatch=True): - metric_score_xp = prob_metric( - y_true_xp_or_np, y_prob_xp, sample_weight=sample_weight, **extra_kwargs - ) - - assert metric_score_xp == pytest.approx(metric_score_np) - - # multi-class case - if str_y_true: - y_true_np = np.array(["a", "b", "c", "d"]) - y_true_xp_or_np = np.asarray(y_true_np) - else: - y_true_np = np.array([0, 1, 2, 3]) - y_true_xp_or_np = xp.asarray(y_true_np, device=device_) - - y_prob_np = np.array( - [ - [0.5, 0.2, 0.2, 0.1], - [0.4, 0.4, 0.1, 0.1], - [0.1, 0.1, 0.7, 0.1], - [0.1, 0.2, 0.6, 0.1], - ], - dtype=dtype_name, - ) - y_prob_xp = xp.asarray(y_prob_np, device=device_) - metric_score_np = prob_metric(y_true_np, y_prob_np) - with config_context(array_api_dispatch=True): - metric_score_xp = prob_metric(y_true_xp_or_np, y_prob_xp) - - assert metric_score_xp == pytest.approx(metric_score_np) - - -@pytest.mark.parametrize( - "prob_metric", [brier_score_loss, log_loss, d2_brier_score, d2_log_loss_score] -) -@pytest.mark.parametrize("use_sample_weight", [False, True]) -@pytest.mark.parametrize( - "array_namespace, device_, dtype_name", yield_namespace_device_dtype_combinations() -) -def test_probabilistic_metrics_multilabel_array_api( - prob_metric, use_sample_weight, array_namespace, device_, dtype_name -): - """Test that :func:`brier_score_loss`, :func:`log_loss`, func:`d2_brier_score` - and :func:`d2_log_loss_score` work correctly with the array API for - multi-label inputs. - """ - xp = _array_api_for_tests(array_namespace, device_) - sample_weight = np.array([1, 2, 3, 1]) if use_sample_weight else None - y_true_np = np.array( - [ - [0, 0, 1, 1], - [1, 0, 1, 0], - [0, 1, 0, 0], - [1, 1, 0, 1], - ], - dtype=dtype_name, - ) - y_true_xp = xp.asarray(y_true_np, device=device_) - y_prob_np = np.array( - [ - [0.15, 0.27, 0.46, 0.12], - [0.33, 0.38, 0.06, 0.23], - [0.06, 0.28, 0.03, 0.63], - [0.14, 0.31, 0.26, 0.29], - ], - dtype=dtype_name, - ) - y_prob_xp = xp.asarray(y_prob_np, device=device_) - metric_score_np = prob_metric(y_true_np, y_prob_np, sample_weight=sample_weight) - with config_context(array_api_dispatch=True): - metric_score_xp = prob_metric(y_true_xp, y_prob_xp, sample_weight=sample_weight) - - assert metric_score_xp == pytest.approx(metric_score_np) diff --git a/sklearn/metrics/tests/test_common.py b/sklearn/metrics/tests/test_common.py index 34bfbc8b26252..1e5e69d8b0869 100644 --- a/sklearn/metrics/tests/test_common.py +++ b/sklearn/metrics/tests/test_common.py @@ -3,6 +3,7 @@ from functools import partial from inspect import signature from itertools import chain, permutations, product +from typing import Tuple import numpy as np import pytest @@ -18,6 +19,7 @@ classification_report, cohen_kappa_score, confusion_matrix, + confusion_matrix_at_thresholds, coverage_error, d2_absolute_error_score, d2_brier_score, @@ -71,7 +73,9 @@ manhattan_distances, paired_cosine_distances, paired_euclidean_distances, + paired_manhattan_distances, pairwise_distances, + pairwise_distances_argmin, pairwise_kernels, polynomial_kernel, rbf_kernel, @@ -81,10 +85,15 @@ from sklearn.utils import shuffle from sklearn.utils._array_api import ( _atol_for_type, - _convert_to_numpy, - _get_namespace_device_dtype_ids, + _max_precision_float_dtype, + get_namespace, + move_to, + yield_mixed_namespace_input_permutations, yield_namespace_device_dtype_combinations, ) +from sklearn.utils._array_api import ( + device as array_api_device, +) from sklearn.utils._testing import ( _array_api_for_tests, assert_allclose, @@ -92,6 +101,7 @@ assert_array_equal, assert_array_less, ignore_warnings, + skip_if_array_api_compat_not_configured, ) from sklearn.utils.fixes import COO_CONTAINERS, parse_version, sp_version from sklearn.utils.multiclass import type_of_target @@ -146,8 +156,14 @@ "mean_poisson_deviance": mean_poisson_deviance, "mean_gamma_deviance": mean_gamma_deviance, "mean_compound_poisson_deviance": partial(mean_tweedie_deviance, power=1.4), + "mean_tweedie_deviance": mean_tweedie_deviance, "d2_tweedie_score": partial(d2_tweedie_score, power=1.4), "d2_pinball_score": d2_pinball_score, + # The default `alpha=0.5` (median) masks differences between quantile methods, + # so we also test `alpha=0.1` and `alpha=0.9` to ensure correctness + # for non-median quantiles. + "d2_pinball_score_01": partial(d2_pinball_score, alpha=0.1), + "d2_pinball_score_09": partial(d2_pinball_score, alpha=0.9), "d2_absolute_error_score": d2_absolute_error_score, } @@ -156,17 +172,13 @@ "balanced_accuracy_score": balanced_accuracy_score, "adjusted_balanced_accuracy_score": partial(balanced_accuracy_score, adjusted=True), "unnormalized_accuracy_score": partial(accuracy_score, normalize=False), - # `confusion_matrix` returns absolute values and hence behaves unnormalized - # . Naming it with an unnormalized_ prefix is necessary for this module to - # skip sample_weight scaling checks which will fail for unnormalized - # metrics. - "unnormalized_confusion_matrix": confusion_matrix, + "confusion_matrix": confusion_matrix, "normalized_confusion_matrix": lambda *args, **kwargs: ( confusion_matrix(*args, **kwargs).astype("float") / confusion_matrix(*args, **kwargs).sum(axis=1)[:, np.newaxis] ), - "unnormalized_multilabel_confusion_matrix": multilabel_confusion_matrix, - "unnormalized_multilabel_confusion_matrix_sample": partial( + "multilabel_confusion_matrix": multilabel_confusion_matrix, + "multilabel_confusion_matrix_sample": partial( multilabel_confusion_matrix, samplewise=True ), "hamming_loss": hamming_loss, @@ -212,7 +224,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): """ The dimensions of precision-recall pairs and the threshold array as returned by the precision_recall_curve do not match. See - func:`sklearn.metrics.precision_recall_curve` + :func:`sklearn.metrics.precision_recall_curve` This prevents implicit conversion of return value triple to a higher dimensional np.array of dtype('float64') (it will be of dtype('object) @@ -223,7 +235,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): """ precision, recall, thresholds = precision_recall_curve(*args, **kwargs) - pad_threshholds = len(precision) - len(thresholds) + pad_thresholds = len(precision) - len(thresholds) return np.array( [ @@ -231,7 +243,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): recall, np.pad( thresholds.astype(np.float64), - pad_width=(0, pad_threshholds), + pad_width=(0, pad_thresholds), mode="constant", constant_values=[np.nan], ), @@ -240,6 +252,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): CURVE_METRICS = { + "confusion_matrix_at_thresholds": confusion_matrix_at_thresholds, "roc_curve": roc_curve, "precision_recall_curve": precision_recall_curve_padded_thresholds, "det_curve": det_curve, @@ -305,7 +318,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "samples_recall_score", "samples_jaccard_score", "coverage_error", - "unnormalized_multilabel_confusion_matrix_sample", + "multilabel_confusion_matrix_sample", "label_ranking_loss", "label_ranking_average_precision_score", "dcg_score", @@ -327,6 +340,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "f2_score", "f0.5_score", # curves + "confusion_matrix_at_thresholds", "roc_curve", "precision_recall_curve", "det_curve", @@ -348,7 +362,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): } # Threshold-based metrics with an "average" argument -CONTINOUS_CLASSIFICATION_METRICS_WITH_AVERAGING = { +CONTINUOUS_CLASSIFICATION_METRICS_WITH_AVERAGING = { "roc_auc_score", "average_precision_score", "partial_roc_auc", @@ -356,6 +370,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): # Metrics with a "pos_label" argument METRICS_WITH_POS_LABEL = { + "confusion_matrix_at_thresholds", "roc_curve", "precision_recall_curve", "det_curve", @@ -377,11 +392,8 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): # TODO: Handle multi_class metrics that has a labels argument as well as a # decision function argument. e.g hinge_loss METRICS_WITH_LABELS = { - "unnormalized_confusion_matrix", + "confusion_matrix", "normalized_confusion_matrix", - "roc_curve", - "precision_recall_curve", - "det_curve", "precision_score", "recall_score", "f1_score", @@ -406,8 +418,8 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "macro_precision_score", "macro_recall_score", "macro_jaccard_score", - "unnormalized_multilabel_confusion_matrix", - "unnormalized_multilabel_confusion_matrix_sample", + "multilabel_confusion_matrix", + "multilabel_confusion_matrix_sample", "cohen_kappa_score", "log_loss", "d2_log_loss_score", @@ -418,6 +430,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): # Metrics with a "normalize" option METRICS_WITH_NORMALIZE_OPTION = { "accuracy_score", + "log_loss", "top_k_accuracy_score", "zero_one_loss", } @@ -470,7 +483,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "micro_precision_score", "micro_recall_score", "micro_jaccard_score", - "unnormalized_multilabel_confusion_matrix", + "multilabel_confusion_matrix", "samples_f0.5_score", "samples_f1_score", "samples_f2_score", @@ -492,6 +505,8 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "mean_absolute_percentage_error", "mean_pinball_loss", "d2_pinball_score", + "d2_pinball_score_01", + "d2_pinball_score_09", "d2_absolute_error_score", } @@ -512,6 +527,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "macro_f1_score", "weighted_recall_score", "mean_squared_log_error", + "mean_tweedie_deviance", "root_mean_squared_error", "root_mean_squared_log_error", # P = R = F = accuracy in multiclass case @@ -538,8 +554,9 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "adjusted_balanced_accuracy_score", "explained_variance_score", "r2_score", - "unnormalized_confusion_matrix", + "confusion_matrix", "normalized_confusion_matrix", + "confusion_matrix_at_thresholds", "roc_curve", "precision_recall_curve", "det_curve", @@ -552,7 +569,7 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "weighted_f2_score", "weighted_precision_score", "weighted_jaccard_score", - "unnormalized_multilabel_confusion_matrix", + "multilabel_confusion_matrix", "macro_f0.5_score", "macro_f2_score", "macro_precision_score", @@ -563,6 +580,8 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "mean_compound_poisson_deviance", "d2_tweedie_score", "d2_pinball_score", + "d2_pinball_score_01", + "d2_pinball_score_09", "d2_absolute_error_score", "mean_absolute_percentage_error", } @@ -575,6 +594,19 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "weighted_ovo_roc_auc", } +WEIGHT_SCALE_DEPENDENT_METRICS = { + # 'confusion_matrix' metrics returns absolute `tps`, `fps` etc values, which + # are scaled by weights, so will vary e.g., scaling by 3 will result in 3 * `tps` + "confusion_matrix", + "confusion_matrix_at_thresholds", + "multilabel_confusion_matrix", + "multilabel_confusion_matrix_sample", + # Metrics where we set `normalize=False` + "unnormalized_accuracy_score", + "unnormalized_zero_one_loss", + "unnormalized_log_loss", +} + METRICS_REQUIRE_POSITIVE_Y = { "mean_poisson_deviance", "mean_gamma_deviance", @@ -588,6 +620,43 @@ def precision_recall_curve_padded_thresholds(*args, **kwargs): "root_mean_squared_log_error", } +# Metrics that support mixed namespace/device array API inputs +# Mixed namespace/device support is NOT planned for pairwise metrics +METRICS_SUPPORTING_MIXED_NAMESPACE = [ + "accuracy_score", + "average_precision_score", + "brier_score_loss", + "confusion_matrix_at_thresholds", + "d2_absolute_error_score", + "d2_brier_score", + "d2_log_loss_score", + "d2_pinball_score", + "d2_pinball_score_01", + "d2_pinball_score_09", + "d2_tweedie_score", + "explained_variance_score", + "f1_score", + "log_loss", + "max_error", + "mean_absolute_error", + "mean_absolute_percentage_error", + "mean_compound_poisson_deviance", + "mean_gamma_deviance", + "mean_normal_deviance", + "mean_pinball_loss", + "mean_poisson_deviance", + "mean_squared_error", + "mean_squared_log_error", + "mean_tweedie_deviance", + "median_absolute_error", + "multilabel_confusion_matrix", + "precision_score", + "r2_score", + "recall_score", + "root_mean_squared_error", + "root_mean_squared_log_error", +] + def _require_positive_targets(y1, y2): """Make targets strictly positive""" @@ -1288,7 +1357,7 @@ def test_normalize_option_binary_classification(name): y_true = random_state.randint(0, n_classes, size=(n_samples,)) y_pred = random_state.randint(0, n_classes, size=(n_samples,)) - y_score = random_state.normal(size=y_true.shape) + y_score = random_state.uniform(size=y_true.shape) metrics = ALL_METRICS[name] pred = y_score if name in CONTINUOUS_CLASSIFICATION_METRICS else y_pred @@ -1317,7 +1386,9 @@ def test_normalize_option_multiclass_classification(name): y_true = random_state.randint(0, n_classes, size=(n_samples,)) y_pred = random_state.randint(0, n_classes, size=(n_samples,)) - y_score = random_state.uniform(size=(n_samples, n_classes)) + y_score = random_state.rand(n_samples, n_classes) + temp = np.exp(-y_score) + y_score = temp / temp.sum(axis=-1).reshape(-1, 1) metrics = ALL_METRICS[name] pred = y_score if name in CONTINUOUS_CLASSIFICATION_METRICS else y_pred @@ -1450,7 +1521,7 @@ def check_averaging(name, y_true, y_true_binarize, y_pred, y_pred_binarize, y_sc _check_averaging( metric, y_true, y_pred, y_true_binarize, y_pred_binarize, is_multilabel ) - elif name in CONTINOUS_CLASSIFICATION_METRICS_WITH_AVERAGING: + elif name in CONTINUOUS_CLASSIFICATION_METRICS_WITH_AVERAGING: _check_averaging( metric, y_true, y_score, y_true_binarize, y_score, is_multilabel ) @@ -1475,7 +1546,7 @@ def test_averaging_multiclass(name): @pytest.mark.parametrize( "name", - sorted(METRICS_WITH_AVERAGING | CONTINOUS_CLASSIFICATION_METRICS_WITH_AVERAGING), + sorted(METRICS_WITH_AVERAGING | CONTINUOUS_CLASSIFICATION_METRICS_WITH_AVERAGING), ) def test_averaging_multilabel(name): n_samples, n_classes = 40, 5 @@ -1612,12 +1683,15 @@ def check_sample_weight_invariance(name, metric, y1, y2, sample_weight=None): % (weighted_score_zeroed, weighted_score_subset, name), ) - if not name.startswith("unnormalized"): - # check that the score is invariant under scaling of the weights by a - # common factor - # Due to numerical instability of floating points in `cumulative_sum` in - # `median_absolute_error`, it is not always equivalent when scaling by a float. - scaling_values = [2] if name == "median_absolute_error" else [2, 0.3] + # Check the score is invariant under scaling of weights by a constant factor + if name not in WEIGHT_SCALE_DEPENDENT_METRICS: + # Numerical instability of floating points in `cumulative_sum` in + # `median_absolute_error`, and in `diff` when in calculating collinear points + # and points in between to drop `roc_curve` means they are not always + # equivalent when scaling by a float. + scaling_values = ( + [2] if name in {"median_absolute_error", "roc_curve"} else [2, 0.3] + ) for scaling in scaling_values: assert_allclose( weighted_score, @@ -1715,7 +1789,7 @@ def test_binary_sample_weight_invariance(name): y_pred = random_state.randint(0, 2, size=(n_samples,)) y_score = random_state.random_sample(size=(n_samples,)) metric = ALL_METRICS[name] - if name in CONTINUOUS_CLASSIFICATION_METRICS: + if name in (CONTINUOUS_CLASSIFICATION_METRICS | CURVE_METRICS.keys()): check_sample_weight_invariance(name, metric, y_true, y_score) else: check_sample_weight_invariance(name, metric, y_true, y_pred) @@ -1816,7 +1890,7 @@ def test_no_averaging_labels(): @pytest.mark.parametrize( - "name", sorted(MULTILABELS_METRICS - {"unnormalized_multilabel_confusion_matrix"}) + "name", sorted(MULTILABELS_METRICS - {"multilabel_confusion_matrix"}) ) def test_multilabel_label_permutations_invariance(name): random_state = check_random_state(0) @@ -1897,16 +1971,25 @@ def test_continuous_metric_permutation_invariance(name): assert_almost_equal(score, current_score) +@pytest.mark.parametrize( + "y1", + [ + np.array(["spam"] * 3 + ["eggs"] * 2, dtype=object), # str object + np.array(["spam"] * 3 + ["eggs"] * 2), # fixed width str + np.array(["spam"] * 3 + ["eggs"] * 2), # list + ], +) @pytest.mark.parametrize("metric_name", CLASSIFICATION_METRICS) -def test_metrics_consistent_type_error(metric_name): +def test_metrics_consistent_type_error(y1, metric_name): # check that an understable message is raised when the type between y_true # and y_pred mismatch rng = np.random.RandomState(42) - y1 = np.array(["spam"] * 3 + ["eggs"] * 2, dtype=object) - y2 = rng.randint(0, 2, size=y1.size) + n_samples = 5 - err_msg = "Labels in y_true and y_pred should be of the same type." - with pytest.raises(TypeError, match=err_msg): + y2 = rng.randint(0, 2, size=n_samples) + + err_msg = r"Mix of label input types \(string and number\)" + with pytest.raises(ValueError, match=err_msg): CLASSIFICATION_METRICS[metric_name](y1, y2) @@ -1940,8 +2023,8 @@ def test_metrics_pos_label_error_str(metric, y_pred_threshold, dtype_y_str): "specified: either make y_true take value in {0, 1} or {-1, 1} or " "pass pos_label explicit" ) - err_msg_pos_label_1 = ( - r"pos_label=1 is not a valid label. It should be one of \['eggs', 'spam'\]" + err_msg_pos_label_1 = re.escape( + "pos_label=1 is not a valid label. It should be one of ['eggs' 'spam']" ) pos_label_default = signature(metric).parameters["pos_label"].default @@ -1952,9 +2035,9 @@ def test_metrics_pos_label_error_str(metric, y_pred_threshold, dtype_y_str): def check_array_api_metric( - metric, array_namespace, device, dtype_name, a_np, b_np, **metric_kwargs + metric, array_namespace, device_name, dtype_name, a_np, b_np, **metric_kwargs ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) a_xp = xp.asarray(a_np, device=device) b_xp = xp.asarray(b_np, device=device) @@ -1987,7 +2070,7 @@ def check_array_api_metric( def _check_metric_matches(metric_a, metric_b, convert_a=False): if convert_a: - metric_a = _convert_to_numpy(xp.asarray(metric_a), xp) + metric_a = move_to(xp.asarray(metric_a), xp=np, device="cpu") assert_allclose(metric_a, metric_b, atol=_atol_for_type(dtype_name)) def _check_each_metric_matches(metric_a, metric_b, convert_a=False): @@ -2031,7 +2114,7 @@ def _check_each_metric_matches(metric_a, metric_b, convert_a=False): def check_array_api_binary_classification_metric( - metric, array_namespace, device, dtype_name + metric, array_namespace, device_name, dtype_name ): y_true_np = np.array([0, 0, 1, 1]) y_pred_np = np.array([0, 1, 0, 1]) @@ -2043,7 +2126,7 @@ def check_array_api_binary_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2056,7 +2139,7 @@ def check_array_api_binary_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2066,11 +2149,23 @@ def check_array_api_binary_classification_metric( def check_array_api_multiclass_classification_metric( - metric, array_namespace, device, dtype_name + metric, array_namespace, device_name, dtype_name ): y_true_np = np.array([0, 1, 2, 3]) y_pred_np = np.array([0, 1, 0, 2]) + if metric.__name__ == "average_precision_score": + # we need y_pred_nd to be of shape (n_samples, n_classes) + y_pred_np = np.array( + [ + [0.7, 0.2, 0.05, 0.05], + [0.1, 0.8, 0.05, 0.05], + [0.1, 0.1, 0.7, 0.1], + [0.05, 0.05, 0.1, 0.8], + ], + dtype=dtype_name, + ) + additional_params = { "average": ("micro", "macro", "weighted"), "beta": (0.2, 0.5, 0.8), @@ -2084,7 +2179,7 @@ def check_array_api_multiclass_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2097,7 +2192,7 @@ def check_array_api_multiclass_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2107,7 +2202,7 @@ def check_array_api_multiclass_classification_metric( def check_array_api_multilabel_classification_metric( - metric, array_namespace, device, dtype_name + metric, array_namespace, device_name, dtype_name ): y_true_np = np.array([[1, 1], [0, 1], [0, 0]], dtype=dtype_name) y_pred_np = np.array([[1, 1], [1, 1], [1, 1]], dtype=dtype_name) @@ -2124,7 +2219,7 @@ def check_array_api_multilabel_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2137,7 +2232,7 @@ def check_array_api_multilabel_classification_metric( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2146,7 +2241,117 @@ def check_array_api_multilabel_classification_metric( ) -def check_array_api_regression_metric(metric, array_namespace, device, dtype_name): +def check_array_api_binary_continuous_classification_metric( + metric, array_namespace, device_name, dtype_name +): + y_true_np = np.array([1, 0, 1, 0]) + y_prob_np = np.array([0.5, 0.2, 0.7, 0.6], dtype=dtype_name) + + check_array_api_metric( + metric, + array_namespace, + device_name, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=None, + ) + + sample_weight = np.array([1, 2, 3, 1], dtype=dtype_name) + check_array_api_metric( + metric, + array_namespace, + device_name, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=sample_weight, + ) + + +def check_array_api_multiclass_continuous_classification_metric( + metric, array_namespace, device_name, dtype_name +): + y_true_np = np.array([0, 1, 2, 3]) + y_prob_np = np.array( + [ + [0.5, 0.2, 0.2, 0.1], + [0.4, 0.4, 0.1, 0.1], + [0.1, 0.1, 0.7, 0.1], + [0.1, 0.2, 0.6, 0.1], + ], + dtype=dtype_name, + ) + + check_array_api_metric( + metric, + array_namespace, + device_name, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=None, + ) + + sample_weight = np.array([1, 2, 3, 1], dtype=dtype_name) + + check_array_api_metric( + metric, + array_namespace, + device_name, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=sample_weight, + ) + + +def check_array_api_multilabel_continuous_classification_metric( + metric, array_namespace, device, dtype_name +): + y_true_np = np.array( + [ + [0, 0, 1, 1], + [1, 0, 1, 0], + [0, 1, 0, 0], + [1, 1, 0, 1], + ], + dtype=dtype_name, + ) + y_prob_np = np.array( + [ + [0.15, 0.27, 0.46, 0.12], + [0.33, 0.38, 0.06, 0.23], + [0.06, 0.28, 0.03, 0.63], + [0.14, 0.31, 0.26, 0.29], + ], + dtype=dtype_name, + ) + + check_array_api_metric( + metric, + array_namespace, + device, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=None, + ) + + sample_weight = np.array([1, 2, 3, 1], dtype=dtype_name) + + check_array_api_metric( + metric, + array_namespace, + device, + dtype_name, + a_np=y_true_np, + b_np=y_prob_np, + sample_weight=sample_weight, + ) + + +def check_array_api_regression_metric(metric, array_namespace, device_name, dtype_name): func_name = metric.func.__name__ if isinstance(metric, partial) else metric.__name__ if func_name == "mean_poisson_deviance" and sp_version < parse_version("1.14.0"): pytest.skip( @@ -2165,7 +2370,7 @@ def check_array_api_regression_metric(metric, array_namespace, device, dtype_nam check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2180,7 +2385,7 @@ def check_array_api_regression_metric(metric, array_namespace, device, dtype_nam check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2189,7 +2394,7 @@ def check_array_api_regression_metric(metric, array_namespace, device, dtype_nam def check_array_api_regression_metric_multioutput( - metric, array_namespace, device, dtype_name + metric, array_namespace, device_name, dtype_name ): y_true_np = np.array([[1, 3, 2], [1, 2, 2]], dtype=dtype_name) y_pred_np = np.array([[1, 4, 4], [1, 1, 1]], dtype=dtype_name) @@ -2197,7 +2402,7 @@ def check_array_api_regression_metric_multioutput( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2209,7 +2414,7 @@ def check_array_api_regression_metric_multioutput( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2219,7 +2424,7 @@ def check_array_api_regression_metric_multioutput( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2229,7 +2434,7 @@ def check_array_api_regression_metric_multioutput( check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=y_true_np, b_np=y_pred_np, @@ -2237,7 +2442,7 @@ def check_array_api_regression_metric_multioutput( ) -def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name): +def check_array_api_metric_pairwise(metric, array_namespace, device_name, dtype_name): X_np = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=dtype_name) Y_np = np.array([[0.2, 0.3, 0.4], [0.5, 0.6, 0.7]], dtype=dtype_name) @@ -2247,7 +2452,7 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=X_np, b_np=Y_np, @@ -2258,7 +2463,7 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_metric( metric, array_namespace, - device, + device_name, dtype_name, a_np=X_np, b_np=Y_np, @@ -2272,6 +2477,11 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_multiclass_classification_metric, check_array_api_multilabel_classification_metric, ], + average_precision_score: [ + check_array_api_binary_classification_metric, + check_array_api_multiclass_classification_metric, + check_array_api_multilabel_classification_metric, + ], balanced_accuracy_score: [ check_array_api_binary_classification_metric, check_array_api_multiclass_classification_metric, @@ -2326,6 +2536,26 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_multiclass_classification_metric, check_array_api_multilabel_classification_metric, ], + brier_score_loss: [ + check_array_api_binary_continuous_classification_metric, + check_array_api_multiclass_continuous_classification_metric, + check_array_api_multilabel_continuous_classification_metric, + ], + log_loss: [ + check_array_api_binary_continuous_classification_metric, + check_array_api_multiclass_continuous_classification_metric, + check_array_api_multilabel_continuous_classification_metric, + ], + d2_brier_score: [ + check_array_api_binary_continuous_classification_metric, + check_array_api_multiclass_continuous_classification_metric, + check_array_api_multilabel_continuous_classification_metric, + ], + d2_log_loss_score: [ + check_array_api_binary_continuous_classification_metric, + check_array_api_multiclass_continuous_classification_metric, + check_array_api_multilabel_continuous_classification_metric, + ], mean_tweedie_deviance: [check_array_api_regression_metric], partial(mean_tweedie_deviance, power=-0.5): [check_array_api_regression_metric], partial(mean_tweedie_deviance, power=1.5): [check_array_api_regression_metric], @@ -2358,6 +2588,22 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_regression_metric, check_array_api_regression_metric_multioutput, ], + d2_absolute_error_score: [ + check_array_api_regression_metric, + check_array_api_regression_metric_multioutput, + ], + d2_pinball_score: [ + check_array_api_regression_metric, + check_array_api_regression_metric_multioutput, + ], + partial(d2_pinball_score, alpha=0.1): [ + check_array_api_regression_metric, + check_array_api_regression_metric_multioutput, + ], + partial(d2_pinball_score, alpha=0.9): [ + check_array_api_regression_metric, + check_array_api_regression_metric_multioutput, + ], d2_tweedie_score: [ check_array_api_regression_metric, ], @@ -2372,6 +2618,7 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) ], chi2_kernel: [check_array_api_metric_pairwise], paired_euclidean_distances: [check_array_api_metric_pairwise], + paired_manhattan_distances: [check_array_api_metric_pairwise], cosine_distances: [check_array_api_metric_pairwise], euclidean_distances: [check_array_api_metric_pairwise], manhattan_distances: [check_array_api_metric_pairwise], @@ -2393,6 +2640,7 @@ def check_array_api_metric_pairwise(metric, array_namespace, device, dtype_name) check_array_api_binary_classification_metric, ], pairwise_distances: [check_array_api_metric_pairwise], + pairwise_distances_argmin: [check_array_api_metric_pairwise], } @@ -2403,13 +2651,227 @@ def yield_metric_checker_combinations(metric_checkers=array_api_metric_checkers) @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("metric, check_func", yield_metric_checker_combinations()) -def test_array_api_compliance(metric, array_namespace, device, dtype_name, check_func): - check_func(metric, array_namespace, device, dtype_name) +def test_array_api_compliance( + metric, array_namespace, device_name, dtype_name, check_func +): + check_func(metric, array_namespace, device_name, dtype_name) + + +def _check_output(out_np, out_xp, xp_to, y2_xp): + if isinstance(out_np, float): + assert isinstance(out_xp, float) + elif hasattr(out_np, "shape"): + assert hasattr(out_xp, "shape") + assert get_namespace(out_xp)[0] == xp_to + assert array_api_device(out_xp) == array_api_device(y2_xp) + # `classification_report` returns str (with default `output_dict=False`) + elif isinstance(out_np, str): + assert isinstance(out_xp, str) + + +@pytest.mark.parametrize( + "other_ns_and_device, y_pred_ns_and_device", + [ + pytest.param(*args[:2], id=args[2]) + for args in yield_mixed_namespace_input_permutations() + ], +) +@pytest.mark.parametrize("metric_name", sorted(METRICS_SUPPORTING_MIXED_NAMESPACE)) +def test_mixed_array_api_namespace_input_compliance( + metric_name, other_ns_and_device, y_pred_ns_and_device +): + """Check `y_true` and `sample_weight` follows `y_pred` for mixed namespace inputs. + + Compares the output for all-numpy vs mixed-type inputs. + If the output is a float, checks that both all-numpy and mixed-type inputs return + a float. + If output is an array, checks it is of the same namespace and device as `y_pred` + (`y_pred_ns_and_device`). + If the output is a tuple, checks that each element, whether float or array, + is correct, as detailed above. + """ + xp_y_pred, device_y_pred = _array_api_for_tests( + y_pred_ns_and_device.xp, device_name=y_pred_ns_and_device.device + ) + xp_other, device_other = _array_api_for_tests( + other_ns_and_device.xp, device_name=other_ns_and_device.device + ) + + metric = ALL_METRICS[metric_name] + + data_all = { + "binary": ([0, 0, 1, 1], [0, 1, 0, 1]), + "binary_continuous": ([1, 0, 1, 0], [0.5, 0.2, 0.7, 0.6]), + "label_indicator_continuous": ([[1, 0, 1, 0]], [[0.5, 0.2, 0.7, 0.6]]), + "regression_integer": ([2, 1, 3, 4], [2, 1, 2, 2]), + "regression_continuous": ([2.1, 1.0, 3.0, 4.0], [2.2, 1.1, 2.0, 2.0]), + } + sample_weight = [1, 1, 2, 2] + + # Deal with max mps float precision being float32 + def _get_dtype(data, xp, device): + # Assume list is all float if first element is float + if isinstance(data[0], float): + dtype = _max_precision_float_dtype(xp, device) + else: + dtype = xp.int64 + return dtype + + if metric_name in CLASSIFICATION_METRICS: + # These should all accept binary label input as there are no + # `CLASSIFICATION_METRICS` that are in `METRIC_UNDEFINED_BINARY` and are + # NOT `partial`s (which we do not test for in array API compliance) + data_cases = ["binary"] + elif metric_name in {**CONTINUOUS_CLASSIFICATION_METRICS, **CURVE_METRICS}: + if metric_name not in METRIC_UNDEFINED_BINARY: + data_cases = ["binary_continuous"] + else: + data_cases = ["label_indicator_continuous"] + elif metric_name in REGRESSION_METRICS: + data_cases = ["regression_integer", "regression_continuous"] + + with config_context(array_api_dispatch=True): + for data_case in data_cases: + y1, y2 = data_all[data_case] + + dtype = _get_dtype(y1, xp_other, device_other) + y1_xp = xp_other.asarray(y1, device=device_other, dtype=dtype) + + metric_kwargs_xp = metric_kwargs_np = {} + if metric_name not in METRICS_WITHOUT_SAMPLE_WEIGHT: + # use `other_ns_and_device` for `sample_weight` as well + sample_weight_np = np.array(sample_weight) + metric_kwargs_np = {"sample_weight": sample_weight_np} + sample_weight_xp = xp_other.asarray( + sample_weight_np, device=device_other + ) + metric_kwargs_xp = {"sample_weight": sample_weight_xp} + + dtype = _get_dtype(y2, xp_y_pred, device_y_pred) + y2_xp = xp_y_pred.asarray(y2, device=device_y_pred, dtype=dtype) + + metric_xp = metric(y1_xp, y2_xp, **metric_kwargs_xp) + metric_np = metric(y1, y2, **metric_kwargs_np) + + if isinstance(metric_np, Tuple): + for out_np, out_xp in zip(metric_np, metric_xp): + _check_output(out_np, out_xp, xp_y_pred, y2_xp) + else: + _check_output(metric_np, metric_xp, xp_y_pred, y2_xp) + + +# Check thresholded classification metrics, minus multilabel ranking metrics +# (`METRIC_UNDEFINED_BINARY`), which take label indicator input (and thus never +# string input). +@pytest.mark.parametrize( + "metric_name", + sorted( + set(METRICS_SUPPORTING_MIXED_NAMESPACE) + & (set(CLASSIFICATION_METRICS.keys()) - METRIC_UNDEFINED_BINARY) + ), +) +@skip_if_array_api_compat_not_configured +def test_array_api_classification_string_input(metric_name): + """Check string inputs accepted with array API dispatch enabled. + + All thresholded classification metrics that do not require label indicator format + input should work when both inputs (e.g.,`y_true` and `y_pred`) are string (numpy + namespace only) and dispatch is enabled. + Note thresholded classification metrics do not support mixed string and numeric + inputs. + """ + metric = ALL_METRICS[metric_name] + y_true = np.array(["a", "b", "a", "a"]) + y_pred = np.array(["a", "b", "b", "a"]) + + kwargs = {} + if metric_name in METRICS_WITH_POS_LABEL: + kwargs["pos_label"] = "a" + + with config_context(array_api_dispatch=True): + metric_enabled = metric(y_true, y_pred, **kwargs) + + with config_context(array_api_dispatch=False): + metric_disabled = metric(y_true, y_pred, **kwargs) + + _check_output(metric_enabled, metric_disabled, get_namespace(y_pred)[0], y_pred) + + +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +# All continuous classification metrics, minus multilabel ranking metrics +# (`METRIC_UNDEFINED_BINARY`), which take label indicator input (and thus never +# string input) +@pytest.mark.parametrize( + "metric_name", + sorted( + set(METRICS_SUPPORTING_MIXED_NAMESPACE) + & ( + (set(CONTINUOUS_CLASSIFICATION_METRICS.keys()) | set(CURVE_METRICS.keys())) + - METRIC_UNDEFINED_BINARY + ) + ), +) +def test_array_api_classification_mixed_string_numeric_input( + metric_name, array_namespace, device_name, dtype_name +): + """Check string inputs and numeric inputs from mixed namespace and devices accepted. + + Non-thresholded (aka continuous/ranking) classification metrics should accept + a mix of string and numeric inputs (numeric input should be able to be of + any supported namespace/device), with array API dispatch enabled. + """ + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + metric = ALL_METRICS[metric_name] + + # Binary + y_true = np.array(["a", "b", "a", "a"]) + y_prob_np = np.array([0.5, 0.2, 0.7, 0.6], dtype=dtype_name) + y_prob_xp = xp.asarray(y_prob_np, device=device) + + kwargs = {} + if metric_name in METRICS_WITH_POS_LABEL: + kwargs["pos_label"] = "a" + + with config_context(array_api_dispatch=True): + metric_np = metric(y_true, y_prob_np, **kwargs) + metric_xp = metric(y_true, y_prob_xp, **kwargs) + + if isinstance(metric_np, Tuple): + for out_np, out_xp in zip(metric_np, metric_xp): + _check_output(out_np, out_xp, xp, y_prob_xp) + else: + _check_output(metric_np, metric_xp, xp, y_prob_xp) + + # Multiclass + if metric_name not in METRIC_UNDEFINED_MULTICLASS: + y_true = np.array(["a", "b", "c", "d"]) + y_prob_np = np.array( + [ + [0.5, 0.2, 0.2, 0.1], + [0.4, 0.4, 0.1, 0.1], + [0.1, 0.1, 0.7, 0.1], + [0.1, 0.2, 0.6, 0.1], + ], + dtype=dtype_name, + ) + y_prob_xp = xp.asarray(y_prob_np, device=device) + + with config_context(array_api_dispatch=True): + metric_np = metric(y_true, y_prob_np) + metric_xp = metric(y_true, y_prob_xp) + + if isinstance(metric_np, Tuple): + for out_np, out_xp in zip(metric_np, metric_xp): + _check_output(out_np, out_xp, xp, y_prob_xp) + else: + _check_output(metric_np, metric_xp, xp, y_prob_xp) @pytest.mark.parametrize("df_lib_name", ["pandas", "polars"]) diff --git a/sklearn/metrics/tests/test_pairwise.py b/sklearn/metrics/tests/test_pairwise.py index 0efa3647f5122..8aa300b5e28d2 100644 --- a/sklearn/metrics/tests/test_pairwise.py +++ b/sklearn/metrics/tests/test_pairwise.py @@ -49,9 +49,8 @@ ) from sklearn.preprocessing import normalize from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, get_namespace, + move_to, xpx, yield_namespace_device_dtype_combinations, ) @@ -152,14 +151,13 @@ def test_pairwise_distances_for_dense_data(global_dtype): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("metric", ["cosine", "euclidean", "manhattan"]) -def test_pairwise_distances_array_api(array_namespace, device, dtype_name, metric): +def test_pairwise_distances_array_api(array_namespace, device_name, dtype_name, metric): # Test array API support in pairwise_distances. - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) # Euclidean distance should be equivalent to calling the function. @@ -171,7 +169,7 @@ def test_pairwise_distances_array_api(array_namespace, device, dtype_name, metri with config_context(array_api_dispatch=True): # Test with Y=None D_xp = pairwise_distances(X_xp, metric=metric) - D_xp_np = _convert_to_numpy(D_xp, xp=xp) + D_xp_np = move_to(D_xp, xp=np, device="cpu") assert get_namespace(D_xp)[0].__name__ == xp.__name__ assert D_xp.device == X_xp.device assert D_xp.dtype == X_xp.dtype @@ -181,7 +179,7 @@ def test_pairwise_distances_array_api(array_namespace, device, dtype_name, metri # Test with Y=Y_np/Y_xp D_xp = pairwise_distances(X_xp, Y=Y_xp, metric=metric) - D_xp_np = _convert_to_numpy(D_xp, xp=xp) + D_xp_np = move_to(D_xp, xp=np, device="cpu") assert get_namespace(D_xp)[0].__name__ == xp.__name__ assert D_xp.device == X_xp.device assert D_xp.dtype == X_xp.dtype @@ -190,6 +188,24 @@ def test_pairwise_distances_array_api(array_namespace, device, dtype_name, metri assert_allclose(D_xp_np, D_np) +def test_pairwise_distances_array_api_no_warnings(): + # Regression test for https://github.com/scikit-learn/scikit-learn/issues/33829 + # pairwise_distances should not emit cross-library dtype comparison warnings + # when called with Array API inputs under array_api_dispatch=True. + xp, device = _array_api_for_tests("array_api_strict") + + rng = np.random.RandomState(0) + X_np = rng.random_sample((5, 4)) + Y_np = rng.random_sample((3, 4)) + X_xp = xp.asarray(X_np, device=device) + Y_xp = xp.asarray(Y_np, device=device) + + with config_context(array_api_dispatch=True): + with warnings.catch_warnings(): + warnings.simplefilter("error") + pairwise_distances(X_xp, Y_xp, metric="euclidean") + + @pytest.mark.parametrize("coo_container", COO_CONTAINERS) @pytest.mark.parametrize("csc_container", CSC_CONTAINERS) @pytest.mark.parametrize("bsr_container", BSR_CONTAINERS) @@ -390,9 +406,8 @@ def test_pairwise_parallel(func, metric, kwds, dtype): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "func, metric, kwds", @@ -405,9 +420,9 @@ def test_pairwise_parallel(func, metric, kwds, dtype): ], ) def test_pairwise_parallel_array_api( - func, metric, kwds, array_namespace, device, dtype_name + func, metric, kwds, array_namespace, device_name, dtype_name ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) X_np = np.array(5 * rng.random_sample((5, 4)), dtype=dtype_name) Y_np = np.array(5 * rng.random_sample((3, 4)), dtype=dtype_name) @@ -420,13 +435,13 @@ def test_pairwise_parallel_array_api( Y_np = None if y_val is None else Y_np n_job1_xp = func(X_xp, Y_xp, metric=metric, n_jobs=1, **kwds) - n_job1_xp_np = _convert_to_numpy(n_job1_xp, xp=xp) + n_job1_xp_np = move_to(n_job1_xp, xp=np, device="cpu") assert get_namespace(n_job1_xp)[0].__name__ == xp.__name__ assert n_job1_xp.device == X_xp.device assert n_job1_xp.dtype == X_xp.dtype n_job2_xp = func(X_xp, Y_xp, metric=metric, n_jobs=2, **kwds) - n_job2_xp_np = _convert_to_numpy(n_job2_xp, xp=xp) + n_job2_xp_np = move_to(n_job2_xp, xp=np, device="cpu") assert get_namespace(n_job2_xp)[0].__name__ == xp.__name__ assert n_job2_xp.device == X_xp.device assert n_job2_xp.dtype == X_xp.dtype @@ -482,17 +497,16 @@ def test_pairwise_kernels(metric, csr_container): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "metric", ["rbf", "sigmoid", "polynomial", "linear", "laplacian", "chi2", "additive_chi2"], ) -def test_pairwise_kernels_array_api(metric, array_namespace, device, dtype_name): +def test_pairwise_kernels_array_api(metric, array_namespace, device_name, dtype_name): # Test array API support in pairwise_kernels. - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) X_np = 10 * rng.random_sample((5, 4)) @@ -505,7 +519,7 @@ def test_pairwise_kernels_array_api(metric, array_namespace, device, dtype_name) with config_context(array_api_dispatch=True): # Test with Y=None K_xp = pairwise_kernels(X_xp, metric=metric) - K_xp_np = _convert_to_numpy(K_xp, xp=xp) + K_xp_np = move_to(K_xp, xp=np, device="cpu") assert get_namespace(K_xp)[0].__name__ == xp.__name__ assert K_xp.device == X_xp.device assert K_xp.dtype == X_xp.dtype @@ -515,7 +529,7 @@ def test_pairwise_kernels_array_api(metric, array_namespace, device, dtype_name) # Test with Y=Y_np/Y_xp K_xp = pairwise_kernels(X_xp, Y=Y_xp, metric=metric) - K_xp_np = _convert_to_numpy(K_xp, xp=xp) + K_xp_np = move_to(K_xp, xp=np, device="cpu") assert get_namespace(K_xp)[0].__name__ == xp.__name__ assert K_xp.device == X_xp.device assert K_xp.dtype == X_xp.dtype @@ -862,6 +876,25 @@ def test_parallel_pairwise_distances_diagonal(metric, global_dtype): assert_allclose(np.diag(distances), 0, atol=1e-10) +def test_parallel_pairwise_distances_y_norm_squared(): + """Check that Y_norm_squared is correctly sliced alongside Y. + + Non-regression test for issue #33877. + """ + rng = np.random.RandomState(42) + X = rng.rand(13, 4) + Y = rng.rand(15, 4) + Y_norm_squared = (Y**2).sum(axis=1) + + D_single = pairwise_distances( + X, Y, metric="euclidean", n_jobs=1, Y_norm_squared=Y_norm_squared + ) + D_parallel = pairwise_distances( + X, Y, metric="euclidean", n_jobs=2, Y_norm_squared=Y_norm_squared + ) + assert_allclose(D_parallel, D_single) + + @pytest.mark.filterwarnings("ignore:Could not adhere to working_memory config") def test_pairwise_distances_chunked(global_dtype): # Test the pairwise_distance helper function. @@ -1205,7 +1238,7 @@ def test_nan_euclidean_distances_complete_nan(missing_value): @pytest.mark.parametrize("missing_value", [np.nan, -1]) -def test_nan_euclidean_distances_not_trival(missing_value): +def test_nan_euclidean_distances_not_trivial(missing_value): X = np.array( [ [1.0, missing_value, 3.0, 4.0, 2.0], @@ -1465,7 +1498,7 @@ def test_rbf_kernel(): rng = np.random.RandomState(0) X = rng.random_sample((5, 4)) K = rbf_kernel(X, X) - # the diagonal elements of a rbf kernel are 1 + # the diagonal elements of an rbf kernel are 1 assert_allclose(K.flat[::6], np.ones(5)) diff --git a/sklearn/metrics/tests/test_ranking.py b/sklearn/metrics/tests/test_ranking.py index 81d14b0265276..247fe6f658491 100644 --- a/sklearn/metrics/tests/test_ranking.py +++ b/sklearn/metrics/tests/test_ranking.py @@ -13,19 +13,27 @@ accuracy_score, auc, average_precision_score, + confusion_matrix, confusion_matrix_at_thresholds, coverage_error, dcg_score, det_curve, label_ranking_average_precision_score, label_ranking_loss, + metric_at_thresholds, ndcg_score, precision_recall_curve, + precision_score, + recall_score, roc_auc_score, roc_curve, top_k_accuracy_score, ) -from sklearn.metrics._ranking import _dcg_sample_scores, _ndcg_sample_scores +from sklearn.metrics._ranking import ( + _dcg_sample_scores, + _ndcg_sample_scores, + _sort_inputs_and_compute_classification_thresholds, +) from sklearn.model_selection import train_test_split from sklearn.preprocessing import label_binarize from sklearn.random_projection import _sparse_random_matrix @@ -56,7 +64,7 @@ def make_prediction(dataset=None, binary=False): - """Make some classification predictions on a toy dataset using a SVC + """Make some classification predictions on a toy dataset using an SVC If binary is True restrict to a binary classification problem instead of a multiclass classification problem @@ -195,25 +203,6 @@ def _partial_roc(y_true, y_predict, max_fpr): return 0.5 * (1 + (partial_auc - min_area) / (max_area - min_area)) -def test_confusion_matrix_at_thresholds(global_random_seed): - """Smoke test for confusion_matrix_at_thresholds.""" - rng = np.random.RandomState(global_random_seed) - - n_samples = 100 - y_true = rng.randint(0, 2, size=100) - y_score = rng.uniform(size=100) - - n_pos = np.sum(y_true) - n_neg = n_samples - n_pos - - tns, fps, fns, tps, thresholds = confusion_matrix_at_thresholds(y_true, y_score) - - assert len(tns) == len(fps) == len(fns) == len(tps) == len(thresholds) - assert_allclose(tps + fns, n_pos) - assert_allclose(tns + fps, n_neg) - assert_allclose(tns + fps + fns + tps, n_samples) - - @pytest.mark.parametrize("drop", [True, False]) def test_roc_curve(drop): # Test Area under Receiver Operating Characteristic (ROC) curve @@ -859,6 +848,104 @@ def test_auc_score_non_binary_class(): roc_auc_score(y_true, y_pred) +def test_sort_inputs_and_compute_classification_thresholds_input_validation(): + """Test `_sort_inputs_and_compute_classification_thresholds` input validation.""" + # Inconsistent lengths + y_true = np.array([0, 1, 0]) + y_score = np.array([0.1, 0.9]) + + with pytest.raises(ValueError, match="inconsistent numbers of samples"): + _sort_inputs_and_compute_classification_thresholds(y_true, y_score) + + # Non-finite value + y_true = np.array([0, 1, 0, 1]) + y_score = np.array([0.1, np.nan, 0.3, 0.7]) + + with pytest.raises(ValueError, match="Input.*contains NaN"): + _sort_inputs_and_compute_classification_thresholds(y_true, y_score) + + +def test_sort_inputs_and_compute_classification_thresholds_zero_weights(): + """Test zero weights in `_sort_inputs_and_compute_classification_thresholds`.""" + y_true = np.array([0, 1, 0, 1, 0, 1]) + y_score = np.array([0.1, 0.9, 0.3, 0.7, 0.5, 0.2]) + # Indices 0 and 4 zero weight + sample_weight = np.array([0.0, 2.0, 1.0, 1.5, 0.0, 0.8]) + + y_true_sorted, y_score_sorted, weight_sorted, _ = ( + _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight + ) + ) + + assert len(y_true_sorted) == len(y_score_sorted) == len(weight_sorted) == 4 + assert 0.1 not in y_score_sorted + assert 0.5 not in y_score_sorted + + # Check default `sample_weight=None` gives None + _, _, weight, _ = _sort_inputs_and_compute_classification_thresholds( + y_true, y_score + ) + assert weight is None + + # All zero weights raises error + y_true = np.array([0, 1, 0]) + y_score = np.array([0.1, 0.9, 0.3]) + sample_weight = np.array([0.0, 0.0, 0.0]) + + with pytest.raises(ValueError, match="Sample weights must contain at least"): + _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight + ) + + +def test_sort_inputs_and_compute_classification_thresholds_sorting(): + """Test sorting in `_sort_inputs_and_compute_classification_thresholds`.""" + y_true = np.array([0, 1, 0, 1, 1]) + y_score = np.array([0.1, 0.9, 0.3, 0.7, 0.3]) + sample_weight = np.array([1.0, 2.0, 1.5, 0.5, 0.3]) + + y_true_sorted, y_score_sorted, weight_sorted, threshold_idxs = ( + _sort_inputs_and_compute_classification_thresholds( + y_true, y_score, sample_weight + ) + ) + # Check descending sort + assert np.all(y_score_sorted[:-1] >= y_score_sorted[1:]) + assert_array_equal(weight_sorted, np.array([2.0, 0.5, 1.5, 0.3, 1.0])) + assert_array_equal(threshold_idxs, np.array([0, 1, 3, 4])) + # Check stable sort + assert_array_equal(y_score_sorted[2:4], [0.3, 0.3]) + assert_array_equal(y_true_sorted[2:4], [0, 1]) + + # All identical scores + y_score_same = np.array([0.5, 0.5, 0.5, 0.5, 0.5]) + _, _, _, threshold_idxs = _sort_inputs_and_compute_classification_thresholds( + y_true, y_score_same + ) + # Threshold is the final index + assert_array_equal(threshold_idxs, np.array([4])) + + +def test_confusion_matrix_at_thresholds(global_random_seed): + """Smoke test for confusion_matrix_at_thresholds.""" + rng = np.random.RandomState(global_random_seed) + + n_samples = 100 + y_true = rng.randint(0, 2, size=100) + y_score = rng.uniform(size=100) + + n_pos = np.sum(y_true) + n_neg = n_samples - n_pos + + tns, fps, fns, tps, thresholds = confusion_matrix_at_thresholds(y_true, y_score) + + assert len(tns) == len(fps) == len(fns) == len(tps) == len(thresholds) + assert_allclose(tps + fns, n_pos) + assert_allclose(tns + fps, n_neg) + assert_allclose(tns + fps + fns + tps, n_samples) + + @pytest.mark.parametrize("curve_func", CURVE_FUNCS) def test_confusion_matrix_at_thresholds_multiclass_error(curve_func): rng = check_random_state(404) @@ -1191,7 +1278,7 @@ def test_average_precision_score_binary_pos_label_errors(): # Raise an error when pos_label is not in binary y_true y_true = np.array([0, 1]) y_pred = np.array([0, 1]) - err_msg = r"pos_label=2 is not a valid label. It should be one of \[0, 1\]" + err_msg = re.escape("pos_label=2 is not a valid label. It should be one of [0 1]") with pytest.raises(ValueError, match=err_msg): average_precision_score(y_true, y_pred, pos_label=2) @@ -1212,7 +1299,7 @@ def test_average_precision_score_multilabel_pos_label_errors(): def test_average_precision_score_multiclass_pos_label_errors(): # Raise an error for multiclass y_true with pos_label other than 1 y_true = np.array([0, 1, 2, 0, 1, 2]) - y_pred = np.array( + y_score = np.array( [ [0.5, 0.2, 0.1], [0.4, 0.5, 0.3], @@ -1227,7 +1314,21 @@ def test_average_precision_score_multiclass_pos_label_errors(): "Do not set pos_label or set pos_label to 1." ) with pytest.raises(ValueError, match=err_msg): - average_precision_score(y_true, y_pred, pos_label=3) + average_precision_score(y_true, y_score, pos_label=3) + + +def test_multiclass_ranking_metrics_raise_for_incorrect_shape_of_y_score(): + """Test ranking metrics, with multiclass support, raise if shape `y_score` is 1D.""" + y_true = np.array([0, 1, 2, 0, 1, 2]) + y_score = np.array([0.5, 0.4, 0.8, 0.9, 0.8, 0.7]) + + msg = re.escape("`y_score` needs to be of shape `(n_samples, n_classes)`") + with pytest.raises(ValueError, match=msg): + average_precision_score(y_true, y_score) + with pytest.raises(ValueError, match=msg): + roc_auc_score(y_true, y_score, multi_class="ovr") + with pytest.raises(ValueError, match=msg): + top_k_accuracy_score(y_true, y_score) def test_score_scale_invariance(): @@ -2279,7 +2380,7 @@ def test_ranking_metric_pos_label_types(metric, classes): assert not np.isnan(thresholds).any() -def test_roc_curve_with_probablity_estimates(global_random_seed): +def test_roc_curve_with_probability_estimates(global_random_seed): """Check that thresholds do not exceed 1.0 when `y_score` is a probability estimate. @@ -2291,3 +2392,221 @@ def test_roc_curve_with_probablity_estimates(global_random_seed): y_score = rng.rand(10) _, _, thresholds = roc_curve(y_true, y_score) assert np.isinf(thresholds[0]) + + +def _dummy_metric(y_true, y_pred, sample_weight=None): + """Dummy metric that returns a tuple of two values.""" + if sample_weight is None: + sample_weight = np.ones_like(y_pred) + y_pred_sum = np.sum(y_pred * sample_weight) + y_true_sum = np.sum(y_true * sample_weight) + return (y_pred_sum, y_true_sum) + + +@pytest.mark.parametrize( + "metric_func", + [ + accuracy_score, + precision_score, + roc_auc_score, + # Test metric that returns tuple instead of single float + _dummy_metric, + ], +) +@pytest.mark.parametrize("sample_weight", [None, np.array([1, 2, 1, 0, 2])]) +def test_metric_at_thresholds(metric_func, sample_weight): + """Test `metric_at_thresholds` outputs correct.""" + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.1, 0.6, 0.4, 0.9, 0.4]) + + metric_values, thresholds = metric_at_thresholds( + y_true, y_score, metric_func, sample_weight=sample_weight + ) + + # Calculate expected scores manually at each threshold + expected_scores = [] + for threshold in thresholds: + y_pred = (y_score >= threshold).astype(int) + expected_scores.append(metric_func(y_true, y_pred, sample_weight=sample_weight)) + + assert len(metric_values) == len(thresholds) + # Thresholds are descending + assert np.all(np.diff(thresholds) <= 0) + # Thresholds correspond to unique `y_score`s + if sample_weight is not None: + # Filter out 0 weight in `y_score` + assert_allclose( + thresholds, np.sort(np.unique(y_score[sample_weight != 0]))[::-1] + ) + else: + assert_allclose(thresholds, np.sort(np.unique(y_score))[::-1]) + assert_allclose(metric_values, expected_scores) + + +def _dummy_metric_no_sample_weight(y_true, y_pred): + """Dummy metric that does not accept `sample_weight`.""" + return (np.sum(y_pred), np.sum(y_true)) + + +def test_metric_at_thresholds_sample_weight_error(): + """Test `TypeError` is raised when `metric_func` does not take `sample_weight`.""" + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.1, 0.4, 0.35, 0.6, 0.9]) + sample_weight = np.array([1, 2, 3, 1, 2]) + + with pytest.raises(TypeError, match="got an unexpected keyword argument"): + _, _ = metric_at_thresholds( + y_true, y_score, _dummy_metric_no_sample_weight, sample_weight=sample_weight + ) + + +@pytest.mark.parametrize("normalize", [True, False]) +def test_metric_at_thresholds_metric_params(normalize): + """Test `metric_params` passed correctly to `metric_at_thresholds`.""" + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.1, 0.4, 0.35, 0.6, 0.9]) + + metric_values, thresholds = metric_at_thresholds( + y_true, y_score, accuracy_score, metric_params={"normalize": normalize} + ) + + expected_values = [] + for threshold in thresholds: + y_pred = (y_score >= threshold).astype(int) + expected_values.append(accuracy_score(y_true, y_pred, normalize=normalize)) + + assert_allclose(metric_values, expected_values) + assert len(thresholds) == len(np.unique(y_score)) + + +@pytest.mark.parametrize("pos_label", [0, 1]) +def test_metric_at_thresholds_pos_label(pos_label): + """Test `pos_label` is passed correctly to `metric_at_thresholds`.""" + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.1, 0.6, 0.4, 0.9, 0.4]) + + metric_values, thresholds = metric_at_thresholds( + y_true, + y_score, + precision_score, + metric_params={"pos_label": pos_label, "zero_division": 0}, + ) + + expected_scores = [] + for threshold in thresholds: + y_pred = (y_score >= threshold).astype(int) + expected_scores.append( + precision_score(y_true, y_pred, pos_label=pos_label, zero_division=0) + ) + + assert_allclose(metric_values, expected_scores) + + +def test_metric_at_thresholds_y_score_order(): + """Test `y_score` order does not effect `metric_at_thresholds`.""" + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.9, 0.6, 0.5, 0.1, 0.4]) + sample_weight = np.array([1, 2, 3, 1, 2]) + # Permutate `y_true`, `y_score` and `sample_weight` + rng = check_random_state(42) + perm_indices = rng.permutation(len(y_score)) + y_true_perm = y_true[perm_indices] + y_score_perm = y_score[perm_indices] + sample_weight_perm = sample_weight[perm_indices] + + metric_1, thresh_1 = metric_at_thresholds( + y_true, y_score, accuracy_score, sample_weight=sample_weight + ) + metric_2, thresh_2 = metric_at_thresholds( + y_true_perm, y_score_perm, accuracy_score, sample_weight=sample_weight_perm + ) + + assert_allclose(metric_1, metric_2) + assert_allclose(thresh_1, thresh_2) + + +def test_metric_at_thresholds_y_score_order_duplicate_y_score(): + """Test duplicate `y_score` edge cases in `metric_at_thresholds`. + + If there are duplicate `y_score` values and `y_true` differs between + these duplicate values, `y_score` order will not affect metric output. + + However, if there are duplicate `y_score` values and `sample_weight` differs + between these duplicate values, `y_score` order can affect metric output, + as stable sort preserves relative order. + """ + # duplicate scores + y_score = np.array([0.6, 0.9, 0.1, 0.4, 0.6]) + # `y_true` differs between duplicates + y_true_1 = np.array([1, 0, 1, 1, 0]) + y_true_2 = np.array([0, 0, 1, 1, 1]) + + metric_1, thresh_1 = metric_at_thresholds(y_true_1, y_score, accuracy_score) + metric_2, thresh_2 = metric_at_thresholds(y_true_2, y_score, accuracy_score) + + assert_allclose(thresh_1, thresh_2) + assert_allclose(metric_1, metric_2) + + # `sample_weight` differs between duplicates + sample_weight_1 = np.array([1, 2, 1, 0, 2]) + sample_weight_2 = np.array([2, 2, 1, 0, 1]) + + metric_1, thresh_1 = metric_at_thresholds( + y_true_1, y_score, accuracy_score, sample_weight=sample_weight_1 + ) + metric_2, thresh_2 = metric_at_thresholds( + y_true_1, y_score, accuracy_score, sample_weight=sample_weight_2 + ) + + # Thresholds should still be the same + assert_allclose(thresh_1, thresh_2) + # Metric output differs for the test data + with pytest.raises(AssertionError): + assert_allclose(metric_1, metric_2) + + +def test_metric_at_thresholds_consistency_with_confusion_matrix(): + """Test `metric_at_thresholds` consistency with `confusion_matrix_at_thresholds`. + + This also checks output when `metric_func` returns a tuple of arrays. + """ + y_true = np.array([0, 0, 1, 1, 1]) + y_score = np.array([0.1, 0.4, 0.4, 0.6, 0.9]) + + tns, fps, fns, tps, thresholds_cm = confusion_matrix_at_thresholds( + y_true, + y_score, + ) + + metric_values, thresholds = metric_at_thresholds( + y_true, y_score, confusion_matrix, metric_params={"labels": [0, 1]} + ) + + assert_array_equal(thresholds, thresholds_cm) + + # As `labels=[0, 1]` -> [TN, FP, FN, TP] + assert_array_equal(metric_values[:, 0, 0], tns) + assert_array_equal(metric_values[:, 0, 1], fps) + assert_array_equal(metric_values[:, 1, 0], fns) + assert_array_equal(metric_values[:, 1, 1], tps) + + +def test_metric_at_thresholds_with_nan_outputs(): + """Test `metric_at_thresholds` with NaN output.""" + # No positive labels means recall undefined (TP + FN = 0) at all thresholds + y_true = np.array([0, 0, 0, 0, 0]) + y_score = np.array([0.1, 0.2, 0.3, 0.4, 0.5]) + + metric_values, _ = metric_at_thresholds( + y_true, y_score, recall_score, metric_params={"zero_division": np.nan} + ) + + assert np.all(np.isnan(metric_values)) + + +# TODO(1.11): remove this test +def test_confusion_matrix_at_thresholds_positional_args_deprecation(): + y_true = np.array([0, 1, 1, 0]) + y_score = np.array([0.2, 0.1, 0.7, 0.7]) + with pytest.warns(FutureWarning, match="Pass pos_label=None as keyword arg"): + confusion_matrix_at_thresholds(y_true, y_score, None) diff --git a/sklearn/metrics/tests/test_score_objects.py b/sklearn/metrics/tests/test_score_objects.py index 17df56846a664..d536884d7f759 100644 --- a/sklearn/metrics/tests/test_score_objects.py +++ b/sklearn/metrics/tests/test_score_objects.py @@ -14,6 +14,7 @@ from sklearn.cluster import KMeans from sklearn.datasets import ( load_diabetes, + load_iris, make_blobs, make_classification, make_multilabel_classification, @@ -63,6 +64,7 @@ ignore_warnings, ) from sklearn.utils.metadata_routing import MetadataRouter, MethodMapping +from sklearn.utils.multiclass import type_of_target REGRESSION_SCORERS = [ "d2_absolute_error_score", @@ -1180,6 +1182,38 @@ def test_scorer_select_proba_error(scorer): scorer(lr, X, y) +def test_invalid_default_pos_label_ignored_on_multiclass(): + iris = load_iris() + X = iris.data + y = np.array(iris.target_names)[iris.target] + + assert type_of_target(y) == "multiclass" + + clf = LogisticRegression(max_iter=1000, random_state=0).fit(X, y) + + # The default of average_precision_score pos_label is 1. It's not one of + # the string class labels but it should be ignored when the scorer is + # called on a multiclass problem. + scorer = make_scorer( + average_precision_score, + response_method=("decision_function", "predict_proba"), + ) + assert scorer(clf, X, y) > 0.7 + + # Passing an invalid pos_label explicitly should raise an error. + scorer = make_scorer( + average_precision_score, + response_method=("decision_function", "predict_proba"), + pos_label="invalid_label", + ) + expected_msg = re.escape( + "Parameter pos_label is fixed to 1 for multiclass y_true. Do not set pos_label " + "or set pos_label to 1." + ) + with pytest.raises(ValueError, match=expected_msg): + scorer(clf, X, y) + + def test_get_scorer_return_copy(): # test that get_scorer returns a copy assert get_scorer("roc_auc") is not get_scorer("roc_auc") @@ -1521,7 +1555,7 @@ def raising_scorer(estimator, X, y): X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) clf = LogisticRegression().fit(X_train, y_train) - # "raising_scorer" is raising ValueError and should return an string representation + # "raising_scorer" is raising ValueError and should return a string representation # of the error of the last scorer: scoring = { "accuracy": make_scorer(accuracy_score), diff --git a/sklearn/mixture/_base.py b/sklearn/mixture/_base.py index 30c4800b20c05..5603232c1b303 100644 --- a/sklearn/mixture/_base.py +++ b/sklearn/mixture/_base.py @@ -17,12 +17,12 @@ from sklearn.exceptions import ConvergenceWarning from sklearn.utils import check_random_state from sklearn.utils._array_api import ( - _convert_to_numpy, _is_numpy_namespace, _logsumexp, _max_precision_float_dtype, get_namespace, get_namespace_and_device, + move_to, ) from sklearn.utils._param_validation import Interval, StrOptions from sklearn.utils.validation import check_is_fitted, validate_data @@ -459,7 +459,7 @@ def sample(self, n_samples=1): _, n_features = self.means_.shape rng = check_random_state(self.random_state) n_samples_comp = rng.multinomial( - n_samples, _convert_to_numpy(self.weights_, xp) + n_samples, move_to(self.weights_, xp=np, device="cpu") ) if self.covariance_type == "full": @@ -467,8 +467,8 @@ def sample(self, n_samples=1): [ rng.multivariate_normal(mean, covariance, int(sample)) for (mean, covariance, sample) in zip( - _convert_to_numpy(self.means_, xp), - _convert_to_numpy(self.covariances_, xp), + move_to(self.means_, xp=np, device="cpu"), + move_to(self.covariances_, xp=np, device="cpu"), n_samples_comp, ) ] @@ -477,10 +477,12 @@ def sample(self, n_samples=1): X = np.vstack( [ rng.multivariate_normal( - mean, _convert_to_numpy(self.covariances_, xp), int(sample) + mean, + move_to(self.covariances_, xp=np, device="cpu"), + int(sample), ) for (mean, sample) in zip( - _convert_to_numpy(self.means_, xp), n_samples_comp + move_to(self.means_, xp=np, device="cpu"), n_samples_comp ) ] ) @@ -491,8 +493,8 @@ def sample(self, n_samples=1): + rng.standard_normal(size=(sample, n_features)) * np.sqrt(covariance) for (mean, covariance, sample) in zip( - _convert_to_numpy(self.means_, xp), - _convert_to_numpy(self.covariances_, xp), + move_to(self.means_, xp=np, device="cpu"), + move_to(self.covariances_, xp=np, device="cpu"), n_samples_comp, ) ] diff --git a/sklearn/mixture/tests/test_gaussian_mixture.py b/sklearn/mixture/tests/test_gaussian_mixture.py index 794a4dfc070ce..e6dac3f96e37c 100644 --- a/sklearn/mixture/tests/test_gaussian_mixture.py +++ b/sklearn/mixture/tests/test_gaussian_mixture.py @@ -31,10 +31,11 @@ _estimate_gaussian_parameters, ) from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, - device, + device as array_api_device, +) +from sklearn.utils._array_api import ( get_namespace, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import ( @@ -1486,32 +1487,31 @@ def test_gaussian_mixture_all_init_does_not_estimate_gaussian_parameters( @pytest.mark.parametrize("init_params", ["random", "random_from_data"]) @pytest.mark.parametrize("covariance_type", ["full", "tied", "diag", "spherical"]) @pytest.mark.parametrize( - "array_namespace, device_, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("use_gmm_array_constructor_arguments", [False, True]) def test_gaussian_mixture_array_api_compliance( init_params, covariance_type, array_namespace, - device_, - dtype, + device_name, + dtype_name, use_gmm_array_constructor_arguments, ): """Test that array api works in GaussianMixture.fit().""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) rand_data = RandomData(rng) X = rand_data.X[covariance_type] - X = X.astype(dtype) + X = X.astype(dtype_name) if use_gmm_array_constructor_arguments: additional_kwargs = { - "means_init": rand_data.means.astype(dtype), - "precisions_init": rand_data.precisions[covariance_type].astype(dtype), - "weights_init": rand_data.weights.astype(dtype), + "means_init": rand_data.means.astype(dtype_name), + "precisions_init": rand_data.precisions[covariance_type].astype(dtype_name), + "weights_init": rand_data.weights.astype(dtype_name), } else: additional_kwargs = {} @@ -1525,20 +1525,20 @@ def test_gaussian_mixture_array_api_compliance( ) gmm.fit(X) - X_xp = xp.asarray(X, device=device_) + X_xp = xp.asarray(X, device=device) with sklearn.config_context(array_api_dispatch=True): gmm_xp = sklearn.clone(gmm) for param_name, param_value in additional_kwargs.items(): - arg_xp = xp.asarray(param_value, device=device_) + arg_xp = xp.asarray(param_value, device=device) setattr(gmm_xp, param_name, arg_xp) gmm_xp.fit(X_xp) assert get_namespace(gmm_xp.means_)[0] == xp assert get_namespace(gmm_xp.covariances_)[0] == xp - assert device(gmm_xp.means_) == device(X_xp) - assert device(gmm_xp.covariances_) == device(X_xp) + assert array_api_device(gmm_xp.means_) == array_api_device(X_xp) + assert array_api_device(gmm_xp.covariances_) == array_api_device(X_xp) predict_xp = gmm_xp.predict(X_xp) predict_proba_xp = gmm_xp.predict_proba(X_xp) @@ -1557,51 +1557,52 @@ def test_gaussian_mixture_array_api_compliance( ] for result in results: assert get_namespace(result)[0] == xp - assert device(result) == device(X_xp) + assert array_api_device(result) == array_api_device(X_xp) for score in [score_xp, aic_xp, bic_xp]: assert isinstance(score, float) # Define specific rtol to make tests pass - default_rtol = 1e-4 if dtype == "float32" else 1e-7 - increased_atol = 5e-4 if dtype == "float32" else 0 - increased_rtol = 1e-3 if dtype == "float32" else 1e-7 + default_rtol = 1e-4 if dtype_name == "float32" else 1e-7 + increased_atol = 5e-4 if dtype_name == "float32" else 0 + increased_rtol = 1e-3 if dtype_name == "float32" else 1e-7 # Check fitted attributes - assert_allclose(gmm.means_, _convert_to_numpy(gmm_xp.means_, xp=xp)) - assert_allclose(gmm.weights_, _convert_to_numpy(gmm_xp.weights_, xp=xp)) + assert_allclose(gmm.means_, move_to(gmm_xp.means_, xp=np, device="cpu")) + assert_allclose(gmm.weights_, move_to(gmm_xp.weights_, xp=np, device="cpu")) assert_allclose( gmm.covariances_, - _convert_to_numpy(gmm_xp.covariances_, xp=xp), + move_to(gmm_xp.covariances_, xp=np, device="cpu"), atol=increased_atol, rtol=increased_rtol, ) assert_allclose( gmm.precisions_cholesky_, - _convert_to_numpy(gmm_xp.precisions_cholesky_, xp=xp), + move_to(gmm_xp.precisions_cholesky_, xp=np, device="cpu"), atol=increased_atol, rtol=increased_rtol, ) assert_allclose( gmm.precisions_, - _convert_to_numpy(gmm_xp.precisions_, xp=xp), + move_to(gmm_xp.precisions_, xp=np, device="cpu"), atol=increased_atol, rtol=increased_rtol, ) # Check methods assert ( - adjusted_rand_score(gmm.predict(X), _convert_to_numpy(predict_xp, xp=xp)) > 0.95 + adjusted_rand_score(gmm.predict(X), move_to(predict_xp, xp=np, device="cpu")) + > 0.95 ) assert_allclose( gmm.predict_proba(X), - _convert_to_numpy(predict_proba_xp, xp=xp), + move_to(predict_proba_xp, xp=np, device="cpu"), rtol=increased_rtol, atol=increased_atol, ) assert_allclose( gmm.score_samples(X), - _convert_to_numpy(score_samples_xp, xp=xp), + move_to(score_samples_xp, xp=np, device="cpu"), rtol=increased_rtol, ) # comparing Python float so need explicit rtol when X has dtype float32 @@ -1610,19 +1611,20 @@ def test_gaussian_mixture_array_api_compliance( assert_allclose(gmm.bic(X), bic_xp, rtol=default_rtol) sample_X, sample_y = gmm.sample(10) # generated samples are float64 so need explicit rtol when X has dtype float32 - assert_allclose(sample_X, _convert_to_numpy(sample_X_xp, xp=xp), rtol=default_rtol) - assert_allclose(sample_y, _convert_to_numpy(sample_y_xp, xp=xp)) + assert_allclose( + sample_X, move_to(sample_X_xp, xp=np, device="cpu"), rtol=default_rtol + ) + assert_allclose(sample_y, move_to(sample_y_xp, xp=np, device="cpu")) @skip_if_array_api_compat_not_configured @pytest.mark.parametrize("init_params", ["kmeans", "k-means++"]) @pytest.mark.parametrize( - "array_namespace, device_, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_gaussian_mixture_raises_where_array_api_not_implemented( - init_params, array_namespace, device_, dtype + init_params, array_namespace, device_name, dtype_name ): X, _ = make_blobs( n_samples=100, diff --git a/sklearn/model_selection/_classification_threshold.py b/sklearn/model_selection/_classification_threshold.py index ea16b91dbe6e2..3dfa02fd1238a 100644 --- a/sklearn/model_selection/_classification_threshold.py +++ b/sklearn/model_selection/_classification_threshold.py @@ -504,7 +504,7 @@ class TunedThresholdClassifierCV(BaseThresholdClassifier): into a class label. The tuning is done by optimizing a binary metric, potentially constrained by another metric. - Read more in the :ref:`User Guide <TunedThresholdClassifierCV>`. + Read more in the :ref:`User Guide <threshold_tuning>`. .. versionadded:: 1.5 diff --git a/sklearn/model_selection/_plot.py b/sklearn/model_selection/_plot.py index 16da45b03e65d..c6d74a9aeba95 100644 --- a/sklearn/model_selection/_plot.py +++ b/sklearn/model_selection/_plot.py @@ -354,7 +354,7 @@ def from_estimator( - None, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, if the estimator is a classifier and `y` is either binary or multiclass, @@ -741,7 +741,7 @@ def from_estimator( - None, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, if the estimator is a classifier and `y` is either binary or multiclass, diff --git a/sklearn/model_selection/_search.py b/sklearn/model_selection/_search.py index 362c652b660e9..39f2034451d2b 100644 --- a/sklearn/model_selection/_search.py +++ b/sklearn/model_selection/_search.py @@ -8,6 +8,7 @@ import numbers import operator +import re import time import warnings from abc import ABCMeta, abstractmethod @@ -554,6 +555,27 @@ def score(self, X, y=None, **params): score = score[self.refit] return score + def _wrap_namespace_error(self, method_name, call, *args, **kwargs): + """Call ``call`` and rewrite namespace mismatch errors from inner estimator.""" + try: + return call(*args, **kwargs) + except ValueError as e: + if "must use the same namespace" not in str(e): + raise + inner_class = self.best_estimator_.__class__.__name__ + outer_class = self.__class__.__name__ + msg = str(e) + # The inner estimator may raise from a different method than the + # one the user called on the meta-estimator (e.g. predict -> + # decision_function). Replace the inner "Class.method()" with the + # outer one so the message is actionable. + msg = re.sub( + rf"{re.escape(inner_class)}\.\w+\(\)", + f"{outer_class}.{method_name}()", + msg, + ) + raise ValueError(msg) from None + @available_if(_search_estimator_has("score_samples")) def score_samples(self, X): """Call score_samples on the estimator with the best found parameters. @@ -575,7 +597,9 @@ def score_samples(self, X): The ``best_estimator_.score_samples`` method. """ check_is_fitted(self) - return self.best_estimator_.score_samples(X) + return self._wrap_namespace_error( + "score_samples", self.best_estimator_.score_samples, X + ) @available_if(_search_estimator_has("predict")) def predict(self, X): @@ -597,7 +621,7 @@ def predict(self, X): the best found parameters. """ check_is_fitted(self) - return self.best_estimator_.predict(X) + return self._wrap_namespace_error("predict", self.best_estimator_.predict, X) @available_if(_search_estimator_has("predict_proba")) def predict_proba(self, X): @@ -620,7 +644,9 @@ def predict_proba(self, X): to that in the fitted attribute :term:`classes_`. """ check_is_fitted(self) - return self.best_estimator_.predict_proba(X) + return self._wrap_namespace_error( + "predict_proba", self.best_estimator_.predict_proba, X + ) @available_if(_search_estimator_has("predict_log_proba")) def predict_log_proba(self, X): @@ -643,7 +669,9 @@ def predict_log_proba(self, X): corresponds to that in the fitted attribute :term:`classes_`. """ check_is_fitted(self) - return self.best_estimator_.predict_log_proba(X) + return self._wrap_namespace_error( + "predict_log_proba", self.best_estimator_.predict_log_proba, X + ) @available_if(_search_estimator_has("decision_function")) def decision_function(self, X): @@ -666,7 +694,9 @@ def decision_function(self, X): the best found parameters. """ check_is_fitted(self) - return self.best_estimator_.decision_function(X) + return self._wrap_namespace_error( + "decision_function", self.best_estimator_.decision_function, X + ) @available_if(_search_estimator_has("transform")) def transform(self, X): @@ -688,7 +718,9 @@ def transform(self, X): the best found parameters. """ check_is_fitted(self) - return self.best_estimator_.transform(X) + return self._wrap_namespace_error( + "transform", self.best_estimator_.transform, X + ) @available_if(_search_estimator_has("inverse_transform")) def inverse_transform(self, X): @@ -1347,7 +1379,7 @@ class GridSearchCV(BaseSearchCV): - None, to use the default 5-fold cross validation, - integer, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -1360,14 +1392,15 @@ class GridSearchCV(BaseSearchCV): .. versionchanged:: 0.22 ``cv`` default value if None changed from 3-fold to 5-fold. - verbose : int - Controls the verbosity: the higher, the more messages. + verbose : int, default=0 + Controls the verbosity of information printed during fitting, with higher + values yielding more detailed logging. - - >1 : the computation time for each fold and parameter candidate is - displayed; - - >2 : the score is also displayed; - - >3 : the fold and candidate parameter indexes are also displayed - together with the starting time of the computation. + - 0 : no messages are printed; + - >=1 : summary of the total number of fits; + - >=2 : computation time for each fold and parameter candidate; + - >=3 : fold indices and scores; + - >=10 : parameter candidate indices and START messages before each fit. pre_dispatch : int, or str, default='2*n_jobs' Controls the number of jobs that get dispatched during parallel @@ -1731,7 +1764,7 @@ class RandomizedSearchCV(BaseSearchCV): - None, to use the default 5-fold cross validation, - integer, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -1744,14 +1777,15 @@ class RandomizedSearchCV(BaseSearchCV): .. versionchanged:: 0.22 ``cv`` default value if None changed from 3-fold to 5-fold. - verbose : int - Controls the verbosity: the higher, the more messages. + verbose : int, default = 0 + Controls the verbosity of information printed during fitting, with higher + values yielding more detailed logging. - - >1 : the computation time for each fold and parameter candidate is - displayed; - - >2 : the score is also displayed; - - >3 : the fold and candidate parameter indexes are also displayed - together with the starting time of the computation. + - 0 : no messages are printed; + - >=1 : summary of the total number of fits; + - >=2 : computation time for each fold and parameter candidate; + - >=3 : fold indices and scores; + - >=10 : parameter candidate indices and START messages before each fit. pre_dispatch : int, or str, default='2*n_jobs' Controls the number of jobs that get dispatched during parallel diff --git a/sklearn/model_selection/_search_successive_halving.py b/sklearn/model_selection/_search_successive_halving.py index 825b44ed2d5c1..bbe3a4f6e6a27 100644 --- a/sklearn/model_selection/_search_successive_halving.py +++ b/sklearn/model_selection/_search_successive_halving.py @@ -370,6 +370,13 @@ def _run_search(self, evaluate_candidates): def _generate_candidate_params(self): pass + def __sklearn_tags__(self): + # TODO: remove this when we add array API support to + # `BaseSuccessiveHalving` + tags = super().__sklearn_tags__() + tags.array_api_support = False + return tags + class HalvingGridSearchCV(BaseSuccessiveHalving): """Search over specified parameter values with successive halving. @@ -461,7 +468,7 @@ class HalvingGridSearchCV(BaseSuccessiveHalving): - integer, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -820,7 +827,7 @@ class HalvingRandomSearchCV(BaseSuccessiveHalving): - integer, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For integer/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all diff --git a/sklearn/model_selection/_split.py b/sklearn/model_selection/_split.py index 6582427d80d24..90055a5a543c1 100644 --- a/sklearn/model_selection/_split.py +++ b/sklearn/model_selection/_split.py @@ -25,7 +25,6 @@ metadata_routing, ) from sklearn.utils._array_api import ( - _convert_to_numpy, get_namespace, get_namespace_and_device, move_to, @@ -211,7 +210,7 @@ class LeaveOneOut(_UnsupportedGroupCVMixin, BaseCrossValidator): See Also -------- LeaveOneGroupOut : For splitting the data according to explicit, - domain-specific stratification of the dataset. + domain-specific grouping of the dataset. GroupKFold : K-fold iterator variant with non-overlapping groups. """ @@ -598,8 +597,8 @@ class GroupKFold(GroupsConsumerMixin, _BaseKFold): See Also -------- - LeaveOneGroupOut : For splitting the data according to explicit - domain-specific stratification of the dataset. + LeaveOneGroupOut : For splitting the data according to explicit, + domain-specific grouping of the dataset. StratifiedKFold : Takes class information into account to avoid building folds with imbalanced class proportions (for binary or multiclass @@ -638,7 +637,7 @@ def _iter_test_indices(self, X, y, groups): n_samples_per_group = np.bincount(group_idx) # Distribute the most frequent groups first - indices = np.argsort(n_samples_per_group)[::-1] + indices = np.argsort(n_samples_per_group, kind="stable")[::-1] n_samples_per_group = n_samples_per_group[indices] # Total weight of each fold @@ -780,7 +779,7 @@ def _make_test_folds(self, X, y=None): # we need the following explicit conversion: xp, is_array_api = get_namespace(y) if is_array_api: - y = _convert_to_numpy(y, xp) + y = move_to(y, xp=np, device="cpu") else: y = np.asarray(y) type_of_target_y = type_of_target(y) @@ -1045,6 +1044,14 @@ def _iter_test_indices(self, X, y, groups): _, groups_inv, groups_cnt = np.unique( groups, return_inverse=True, return_counts=True ) + n_groups = len(groups_cnt) + + if self.n_splits > n_groups: + raise ValueError( + f"Cannot have number of splits n_splits={self.n_splits} greater" + f" than the number of groups: {n_groups}." + ) + y_counts_per_group = np.zeros((len(groups_cnt), n_classes)) for class_idx, group_idx in zip(y_inv, groups_inv): y_counts_per_group[group_idx, class_idx] += 1 @@ -1063,7 +1070,7 @@ def _iter_test_indices(self, X, y, groups): # Stable sort to keep shuffled order for groups with the same # class distribution variance sorted_groups_idx = np.argsort( - -np.std(y_counts_per_group, axis=1), kind="mergesort" + -np.std(y_counts_per_group, axis=1), kind="stable" ) for group_idx in sorted_groups_idx: @@ -1325,7 +1332,7 @@ class LeaveOneGroupOut(GroupsConsumerMixin, BaseCrossValidator): Provides train/test indices to split data such that each training set is comprised of all samples except ones belonging to one specific group. - Arbitrary domain specific group information is provided as an array of integers + Arbitrary domain-specific group information is provided as an array of integers that encodes the group of each sample. For instance the groups could be the year of collection of the samples @@ -1442,7 +1449,7 @@ class LeavePGroupsOut(GroupsConsumerMixin, BaseCrossValidator): Provides train/test indices to split data according to a third-party provided group. This group information can be used to encode arbitrary - domain specific stratifications of the samples as integers. + domain-specific groupings of the samples as integers. For instance the groups could be the year of collection of the samples and thus allow for cross-validation against time-based splits. @@ -2082,7 +2089,7 @@ class GroupShuffleSplit(GroupsConsumerMixin, BaseShuffleSplit): Provides randomized train/test indices to split data according to a third-party provided group. This group information can be used to encode - arbitrary domain specific stratifications of the samples as integers. + arbitrary domain-specific groupings of the samples as integers. For instance the groups could be the year of collection of the samples and thus allow for cross-validation against time-based splits. @@ -2329,7 +2336,7 @@ def _iter_indices(self, X, y, groups=None): # `y` is probably never a very large array, which means that converting it # should be cheap xp, _ = get_namespace(y) - y = _convert_to_numpy(y, xp=xp) + y = move_to(y, xp=np, device="cpu") if y.ndim == 2: # for multi-label y, map each distinct row to a string repr @@ -2365,7 +2372,7 @@ def _iter_indices(self, X, y, groups=None): # Find the sorted list of instances for each class: # (np.unique above performs a sort, so code is O(n logn) already) class_indices = np.split( - np.argsort(y_indices, kind="mergesort"), np.cumsum(class_counts)[:-1] + np.argsort(y_indices, kind="stable"), np.cumsum(class_counts)[:-1] ) rng = check_random_state(self.random_state) @@ -2687,7 +2694,7 @@ def split(self, X=None, y=None, groups=None): yield train, test -def check_cv(cv=5, y=None, *, classifier=False): +def check_cv(cv=5, y=None, *, classifier=False, shuffle=False, random_state=None): """Input checker utility for building a cross-validator. Parameters @@ -2696,9 +2703,9 @@ def check_cv(cv=5, y=None, *, classifier=False): Determines the cross-validation splitting strategy. Possible inputs for cv are: - None, to use the default 5-fold cross validation, - - integer, to specify the number of folds. + - integer, to specify the number of folds, - :term:`CV splitter`, - - An iterable that generates (train, test) splits as arrays of indices. + - an iterable that generates (train, test) splits as arrays of indices. For integer/None inputs, if classifier is True and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all other @@ -2714,8 +2721,23 @@ def check_cv(cv=5, y=None, *, classifier=False): The target variable for supervised learning problems. classifier : bool, default=False - Whether the task is a classification task, in which case - stratified KFold will be used. + Whether the task is a classification task. When ``True`` and `cv` is an + integer or ``None``, :class:`StratifiedKFold` is used if ``y`` is binary + or multiclass; otherwise :class:`KFold` is used. Ignored if `cv` is a + cross-validator instance or iterable. + + shuffle : bool, default=False + Whether to shuffle the data before splitting into batches. Note that the samples + within each split will not be shuffled. Only applies if `cv` is an int or + `None`. If `cv` is a cross-validation generator or an iterable, `shuffle` is + ignored. + + random_state : int, RandomState instance or None, default=None + When `shuffle` is True and `cv` is an integer or `None`, `random_state` affects + the ordering of the indices, which controls the randomness of each fold. + Otherwise, this parameter has no effect. + Pass an int for reproducible output across multiple function calls. + See :term:`Glossary <random_state>`. Returns ------- @@ -2738,16 +2760,16 @@ def check_cv(cv=5, y=None, *, classifier=False): and (y is not None) and (type_of_target(y, input_name="y") in ("binary", "multiclass")) ): - return StratifiedKFold(cv) + return StratifiedKFold(cv, shuffle=shuffle, random_state=random_state) else: - return KFold(cv) + return KFold(cv, shuffle=shuffle, random_state=random_state) if not hasattr(cv, "split") or isinstance(cv, str): if not isinstance(cv, Iterable) or isinstance(cv, str): raise ValueError( - "Expected cv as an integer, cross-validation " - "object (from sklearn.model_selection) " - "or an iterable. Got %s." % cv + "Expected `cv` as an integer, a cross-validation object " + "(from sklearn.model_selection), or an iterable yielding (train, test) " + f"splits as arrays of indices. Got {cv}." ) return _CVIterableWrapper(cv) @@ -3011,9 +3033,7 @@ def _pprint(params, offset=0, printer=repr): def _build_repr(self): - # XXX This is copied from BaseEstimator's get_params - cls = self.__class__ - init = getattr(cls.__init__, "deprecated_original", cls.__init__) + init = self.__class__.__init__ # Ignore varargs, kw and default values and pop self init_signature = signature(init) # Consider the constructor parameters excluding 'self' diff --git a/sklearn/model_selection/_validation.py b/sklearn/model_selection/_validation.py index 3f7f424757bfa..827aa7b9e5d49 100644 --- a/sklearn/model_selection/_validation.py +++ b/sklearn/model_selection/_validation.py @@ -27,7 +27,6 @@ from sklearn.preprocessing import LabelEncoder from sklearn.utils import Bunch, _safe_indexing, check_random_state, indexable from sklearn.utils._array_api import ( - _convert_to_numpy, device, get_namespace, get_namespace_and_device, @@ -170,7 +169,7 @@ def cross_validate( - None, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -1320,7 +1319,7 @@ def _fit_and_predict(estimator, X, y, train, test, fit_params, method): # A 2D y array should be a binary label indicator matrix xp, _ = get_namespace(X, y) n_classes = ( - len(set(_convert_to_numpy(y, xp=xp))) if y.ndim == 1 else y.shape[1] + len(set(move_to(y, xp=np, device="cpu"))) if y.ndim == 1 else y.shape[1] ) predictions = _enforce_prediction_order( estimator.classes_, predictions, n_classes, method @@ -1504,7 +1503,7 @@ def permutation_test_score( - `None`, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For `int`/`None` inputs, if the estimator is a classifier and `y` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -1810,7 +1809,7 @@ def learning_curve( - None, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all @@ -2296,7 +2295,7 @@ def validation_curve( - None, to use the default 5-fold cross validation, - int, to specify the number of folds in a `(Stratified)KFold`, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. For int/None inputs, if the estimator is a classifier and ``y`` is either binary or multiclass, :class:`StratifiedKFold` is used. In all diff --git a/sklearn/model_selection/tests/test_search.py b/sklearn/model_selection/tests/test_search.py index 2678e1aa68d75..451c3596e2c46 100644 --- a/sklearn/model_selection/tests/test_search.py +++ b/sklearn/model_selection/tests/test_search.py @@ -83,7 +83,6 @@ ) from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from sklearn.utils._array_api import ( - _get_namespace_device_dtype_ids, yield_namespace_device_dtype_combinations, ) from sklearn.utils._mocking import CheckingClassifier, MockDataFrame @@ -1671,7 +1670,7 @@ def test_predict_proba_disabled(): # Test predict_proba when disabled on estimator. X = np.arange(20).reshape(5, -1) y = [0, 0, 1, 1, 1] - clf = SVC(probability=False) + clf = SVC() gs = GridSearchCV(clf, {}, cv=2).fit(X, y) assert not hasattr(gs, "predict_proba") @@ -2863,19 +2862,20 @@ def test_cv_results_multi_size_array(): @pytest.mark.parametrize( - "array_namespace, device, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("SearchCV", [GridSearchCV, RandomizedSearchCV]) -def test_array_api_search_cv_classifier(SearchCV, array_namespace, device, dtype): - xp = _array_api_for_tests(array_namespace, device) +def test_array_api_search_cv_classifier( + SearchCV, array_namespace, device_name, dtype_name +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X = np.arange(100).reshape((10, 10)) - X_np = X.astype(dtype) + X_np = X.astype(dtype_name) X_xp = xp.asarray(X_np, device=device) - # y should always be an integer, no matter what `dtype` is + # y should always be an integer, no matter what `dtype_name` is y_np = np.array([0] * 5 + [1] * 5) y_xp = xp.asarray(y_np, device=device) diff --git a/sklearn/model_selection/tests/test_split.py b/sklearn/model_selection/tests/test_split.py index a4b6b21470061..55fe4f2732caf 100644 --- a/sklearn/model_selection/tests/test_split.py +++ b/sklearn/model_selection/tests/test_split.py @@ -42,13 +42,12 @@ from sklearn.svm import SVC from sklearn.tests.metadata_routing_common import assert_request_is_empty from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, - get_namespace, - yield_namespace_device_dtype_combinations, + device as array_api_device, ) from sklearn.utils._array_api import ( - device as array_api_device, + get_namespace, + move_to, + yield_namespace_device_dtype_combinations, ) from sklearn.utils._mocking import MockDataFrame from sklearn.utils._testing import ( @@ -218,7 +217,7 @@ def test_2d_y(): StratifiedKFold(), RepeatedKFold(), RepeatedStratifiedKFold(), - StratifiedGroupKFold(), + StratifiedGroupKFold(n_splits=3), ShuffleSplit(), StratifiedShuffleSplit(test_size=0.5), GroupShuffleSplit(), @@ -249,7 +248,7 @@ def check_valid_split(train, test, n_samples=None): assert train.intersection(test) == set() if n_samples is not None: - # Check that the union of train an test split cover all the indices + # Check that the union of train and test split cover all the indices assert train.union(test) == set(range(n_samples)) @@ -1226,7 +1225,7 @@ def test_repeated_cv_repr(RepeatedCV): assert repeated_cv_repr == repr(repeated_cv) -def test_repeated_kfold_determinstic_split(): +def test_repeated_kfold_deterministic_split(): X = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]] random_state = 258173307 rkf = RepeatedKFold(n_splits=2, n_repeats=2, random_state=random_state) @@ -1271,7 +1270,7 @@ def test_get_n_splits_for_repeated_stratified_kfold(): assert expected_n_splits == rskf.get_n_splits() -def test_repeated_stratified_kfold_determinstic_split(): +def test_repeated_stratified_kfold_deterministic_split(): X = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]] y = [1, 1, 1, 0, 0] random_state = 1944695409 @@ -1342,9 +1341,8 @@ def test_train_test_split_default_test_size(train_size, exp_train, exp_test): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "shuffle,stratify", @@ -1356,9 +1354,9 @@ def test_train_test_split_default_test_size(train_size, exp_train, exp_test): ), ) def test_array_api_train_test_split( - shuffle, stratify, array_namespace, device, dtype_name + shuffle, stratify, array_namespace, device_name, dtype_name ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X = np.arange(100).reshape((10, 10)) y = np.arange(10) @@ -1400,11 +1398,11 @@ def test_array_api_train_test_split( assert y_test_xp.dtype == y_xp.dtype assert_allclose( - _convert_to_numpy(X_train_xp, xp=xp), + move_to(X_train_xp, xp=np, device="cpu"), X_train_np, ) assert_allclose( - _convert_to_numpy(X_test_xp, xp=xp), + move_to(X_test_xp, xp=np, device="cpu"), X_test_np, ) @@ -1621,7 +1619,8 @@ def test_check_cv(): cv = check_cv(3, y_multioutput, classifier=True) np.testing.assert_equal(list(KFold(3).split(X)), list(cv.split(X))) - with pytest.raises(ValueError): + msg = "Expected `cv` as an integer, a cross-validation object" + with pytest.raises(ValueError, match=msg): check_cv(cv="lolo") @@ -1784,7 +1783,7 @@ def test_group_kfold(kfold, shuffle, global_random_seed): groups = np.array([1, 1, 1, 2, 2]) X = y = np.ones(len(groups)) with pytest.raises(ValueError, match="Cannot have number of splits.*greater"): - next(GroupKFold(n_splits=3).split(X, y, groups)) + next(kfold(n_splits=3).split(X, y, groups)) def test_time_series_cv(): diff --git a/sklearn/model_selection/tests/test_validation.py b/sklearn/model_selection/tests/test_validation.py index 1ac11d8ccf716..19a9f47f384e8 100644 --- a/sklearn/model_selection/tests/test_validation.py +++ b/sklearn/model_selection/tests/test_validation.py @@ -84,8 +84,7 @@ from sklearn.utils import shuffle from sklearn.utils._array_api import ( _atol_for_type, - _convert_to_numpy, - _get_namespace_device_dtype_ids, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._mocking import CheckingClassifier, MockDataFrame @@ -96,6 +95,7 @@ assert_array_almost_equal, assert_array_equal, ) +from sklearn.utils.estimator_checks import _NotAnArray from sklearn.utils.fixes import COO_CONTAINERS, CSR_CONTAINERS from sklearn.utils.validation import _num_samples @@ -390,6 +390,14 @@ def test_cross_validate_invalid_scoring_param(): cross_validate(estimator, X, y, scoring={"foo": multiclass_scorer}) +def test_cross_validate_array_function_not_called(): + """Check that `__array_function__` (NEP18) is not called.""" + X = _NotAnArray([[1, 1], [1, 2], [1, 3], [1, 4], [2, 1], [2, 2], [2, 3], [2, 4]]) + y = _NotAnArray([1, 1, 1, 2, 2, 2, 1, 1]) + estimator = LogisticRegression(random_state=0) + cross_validate(estimator, X, y, cv=2) + + def test_cross_validate_nested_estimator(): # Non-regression test to ensure that nested # estimators are properly returned in a list @@ -2713,17 +2721,16 @@ def test_learning_curve_exploit_incremental_learning_routing(): ) @pytest.mark.parametrize("cv", [None, 3, 5]) @pytest.mark.parametrize( - "namespace, device_, dtype_name", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_cross_val_predict_array_api_compliance( - estimator, cv, namespace, device_, dtype_name + estimator, cv, namespace, device_name, dtype_name ): """Test that `cross_val_predict` functions correctly with the array API with both a classifier and a regressor.""" - xp = _array_api_for_tests(namespace, device_) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) if is_classifier(estimator): X, y = make_classification( n_samples=1000, n_features=5, n_classes=3, n_informative=3, random_state=42 @@ -2735,13 +2742,13 @@ def test_cross_val_predict_array_api_compliance( X_np = X.astype(dtype_name) y_np = y.astype(dtype_name) - X_xp = xp.asarray(X_np, device=device_) - y_xp = xp.asarray(y_np, device=device_) + X_xp = xp.asarray(X_np, device=device) + y_xp = xp.asarray(y_np, device=device) with config_context(array_api_dispatch=True): pred_xp = cross_val_predict(estimator, X_xp, y_xp, cv=cv) pred_np = cross_val_predict(estimator, X_np, y_np, cv=cv) assert_allclose( - _convert_to_numpy(pred_xp, xp), pred_np, atol=_atol_for_type(dtype_name) + move_to(pred_xp, xp=np, device="cpu"), pred_np, atol=_atol_for_type(dtype_name) ) diff --git a/sklearn/multiclass.py b/sklearn/multiclass.py index c01aad10dab3e..4a65fca807764 100644 --- a/sklearn/multiclass.py +++ b/sklearn/multiclass.py @@ -514,7 +514,7 @@ def predict(self, X): indices.extend(np.where(_predict_binary(e, X) > thresh)[0]) indptr.append(len(indices)) data = np.ones(len(indices), dtype=int) - indicator = sp.csc_matrix( + indicator = sp.csc_array( (data, indices, indptr), shape=(n_samples, len(self.estimators_)) ) return self.label_binarizer_.inverse_transform(indicator) @@ -1252,7 +1252,7 @@ def predict(self, X): """ check_is_fitted(self) # ArgKmin only accepts C-contiguous array. The aggregated predictions need to be - # transposed. We therefore create a F-contiguous array to avoid a copy and have + # transposed. We therefore create an F-contiguous array to avoid a copy and have # a C-contiguous array after the transpose operation. Y = np.array( [_predict_binary(e, X) for e in self.estimators_], diff --git a/sklearn/multioutput.py b/sklearn/multioutput.py index 34a93e9a63b72..18b2ddcc3e1a7 100644 --- a/sklearn/multioutput.py +++ b/sklearn/multioutput.py @@ -8,7 +8,6 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -import warnings from abc import ABCMeta, abstractmethod from numbers import Integral @@ -26,8 +25,9 @@ ) from sklearn.model_selection import cross_val_predict from sklearn.utils import Bunch, check_random_state, get_tags -from sklearn.utils._param_validation import HasMethods, Hidden, StrOptions +from sklearn.utils._param_validation import HasMethods, StrOptions from sklearn.utils._response import _get_response_values +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils._user_interface import _print_elapsed_time from sklearn.utils.metadata_routing import ( MetadataRouter, @@ -622,13 +622,13 @@ def __sklearn_tags__(self): def _available_if_base_estimator_has(attr): - """Return a function to check if `base_estimator` or `estimators_` has `attr`. + """Return a function to check if `estimator` or `estimators_` has `attr`. Helper for Chain implementations. """ def _check(self): - return hasattr(self._get_estimator(), attr) or all( + return hasattr(self.estimator, attr) or all( hasattr(est, attr) for est in self.estimators_ ) @@ -637,60 +637,28 @@ def _check(self): class _BaseChain(BaseEstimator, metaclass=ABCMeta): _parameter_constraints: dict = { - "base_estimator": [ - HasMethods(["fit", "predict"]), - StrOptions({"deprecated"}), - ], - "estimator": [ - HasMethods(["fit", "predict"]), - Hidden(None), - ], + "estimator": [HasMethods(["fit", "predict"])], "order": ["array-like", StrOptions({"random"}), None], "cv": ["cv_object", StrOptions({"prefit"})], "random_state": ["random_state"], "verbose": ["boolean"], } - # TODO(1.9): Remove base_estimator def __init__( self, - estimator=None, + estimator, *, order=None, cv=None, random_state=None, verbose=False, - base_estimator="deprecated", ): self.estimator = estimator - self.base_estimator = base_estimator self.order = order self.cv = cv self.random_state = random_state self.verbose = verbose - # TODO(1.9): This is a temporary getter method to validate input wrt deprecation. - # It was only included to avoid relying on the presence of self.estimator_ - def _get_estimator(self): - """Get and validate estimator.""" - - if self.estimator is not None and (self.base_estimator != "deprecated"): - raise ValueError( - "Both `estimator` and `base_estimator` are provided. You should only" - " pass `estimator`. `base_estimator` as a parameter is deprecated in" - " version 1.7, and will be removed in version 1.9." - ) - - if self.base_estimator != "deprecated": - warning_msg = ( - "`base_estimator` as an argument was deprecated in 1.7 and will be" - " removed in 1.9. Use `estimator` instead." - ) - warnings.warn(warning_msg, FutureWarning) - return self.base_estimator - else: - return self.estimator - def _log_message(self, *, estimator_idx, n_estimators, processing_msg): if not self.verbose: return None @@ -734,7 +702,7 @@ def _get_predictions(self, X, *, output_method): inv_order[self.order_] = np.arange(len(self.order_)) Y_output = Y_output_chain[:, inv_order] - return Y_output + return _align_api_if_sparse(Y_output) @abstractmethod def fit(self, X, Y, **fit_params): @@ -773,7 +741,7 @@ def fit(self, X, Y, **fit_params): elif sorted(self.order_) != list(range(Y.shape[1])): raise ValueError("invalid order") - self.estimators_ = [clone(self._get_estimator()) for _ in range(Y.shape[1])] + self.estimators_ = [clone(self.estimator) for _ in range(Y.shape[1])] if self.cv is None: Y_pred_chain = Y[:, self.order_] @@ -812,7 +780,7 @@ def fit(self, X, Y, **fit_params): if hasattr(self, "chain_method"): chain_method = _check_response_method( - self._get_estimator(), + self.estimator, self.chain_method, ).__name__ self.chain_method_ = chain_method @@ -837,7 +805,7 @@ def fit(self, X, Y, **fit_params): if self.cv is not None and chain_idx < len(self.estimators_) - 1: col_idx = X.shape[1] + chain_idx cv_result = cross_val_predict( - self._get_estimator(), + self.estimator, X_aug[:, :col_idx], y=y, cv=self.cv, @@ -870,7 +838,7 @@ def predict(self, X): def __sklearn_tags__(self): tags = super().__sklearn_tags__() - tags.input_tags.sparse = get_tags(self._get_estimator()).input_tags.sparse + tags.input_tags.sparse = get_tags(self.estimator).input_tags.sparse return tags @@ -920,7 +888,7 @@ class ClassifierChain(MetaEstimatorMixin, ClassifierMixin, _BaseChain): - None, to use true labels when fitting, - integer, to specify the number of folds in a (Stratified)KFold, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. chain_method : {'predict', 'predict_proba', 'predict_log_proba', \ 'decision_function'} or list of such str's, default='predict' @@ -949,13 +917,6 @@ class ClassifierChain(MetaEstimatorMixin, ClassifierMixin, _BaseChain): .. versionadded:: 1.2 - base_estimator : estimator, default="deprecated" - Use `estimator` instead. - - .. deprecated:: 1.7 - `base_estimator` is deprecated and will be removed in 1.9. - Use `estimator` instead. - Attributes ---------- classes_ : list @@ -1030,17 +991,15 @@ class labels for each estimator in the chain. ], } - # TODO(1.9): Remove base_estimator from __init__ def __init__( self, - estimator=None, + estimator, *, order=None, cv=None, chain_method="predict", random_state=None, verbose=False, - base_estimator="deprecated", ): super().__init__( estimator, @@ -1048,7 +1007,6 @@ def __init__( cv=cv, random_state=random_state, verbose=verbose, - base_estimator=base_estimator, ) self.chain_method = chain_method @@ -1150,7 +1108,7 @@ def get_metadata_routing(self): """ router = MetadataRouter(owner=self).add( - estimator=self._get_estimator(), + estimator=self.estimator, method_mapping=MethodMapping().add(caller="fit", callee="fit"), ) return router @@ -1205,7 +1163,7 @@ class RegressorChain(MetaEstimatorMixin, RegressorMixin, _BaseChain): - None, to use true labels when fitting, - integer, to specify the number of folds in a (Stratified)KFold, - :term:`CV splitter`, - - An iterable yielding (train, test) splits as arrays of indices. + - an iterable yielding (train, test) splits as arrays of indices. random_state : int, RandomState instance or None, optional (default=None) If ``order='random'``, determines random number generation for the @@ -1221,20 +1179,13 @@ class RegressorChain(MetaEstimatorMixin, RegressorMixin, _BaseChain): .. versionadded:: 1.2 - base_estimator : estimator, default="deprecated" - Use `estimator` instead. - - .. deprecated:: 1.7 - `base_estimator` is deprecated and will be removed in 1.9. - Use `estimator` instead. - Attributes ---------- estimators_ : list A list of clones of base_estimator. order_ : list - The order of labels in the classifier chain. + The order of labels in the regressor chain. n_features_in_ : int Number of features seen during :term:`fit`. Only defined if the @@ -1312,7 +1263,7 @@ def get_metadata_routing(self): """ router = MetadataRouter(owner=self).add( - estimator=self._get_estimator(), + estimator=self.estimator, method_mapping=MethodMapping().add(caller="fit", callee="fit"), ) return router diff --git a/sklearn/naive_bayes.py b/sklearn/naive_bayes.py index 54d8b710623d2..93806ce7ccb7d 100644 --- a/sklearn/naive_bayes.py +++ b/sklearn/naive_bayes.py @@ -18,12 +18,12 @@ from sklearn.preprocessing import LabelBinarizer, binarize, label_binarize from sklearn.utils._array_api import ( _average, - _convert_to_numpy, _find_matching_floating_dtype, _isin, _logsumexp, get_namespace, get_namespace_and_device, + move_to, size, ) from sklearn.utils._param_validation import Interval @@ -113,7 +113,7 @@ def predict(self, X): jll = self._joint_log_likelihood(X) pred_indices = xp.argmax(jll, axis=1) if isinstance(self.classes_[0], str): - pred_indices = _convert_to_numpy(pred_indices, xp=xp) + pred_indices = move_to(pred_indices, xp=np, device="cpu") return self.classes_[pred_indices] def predict_log_proba(self, X): diff --git a/sklearn/neighbors/_base.py b/sklearn/neighbors/_base.py index eeee7aa66bfe3..3929594b85077 100644 --- a/sklearn/neighbors/_base.py +++ b/sklearn/neighbors/_base.py @@ -12,7 +12,7 @@ import numpy as np from joblib import effective_n_jobs -from scipy.sparse import csr_matrix, issparse +from scipy.sparse import csr_array, issparse from sklearn.base import BaseEstimator, MultiOutputMixin, is_classifier from sklearn.exceptions import DataConversionWarning, EfficiencyWarning @@ -21,7 +21,7 @@ from sklearn.metrics.pairwise import PAIRWISE_DISTANCE_FUNCTIONS from sklearn.neighbors._ball_tree import BallTree from sklearn.neighbors._kd_tree import KDTree -from sklearn.utils import check_array, gen_even_slices, get_tags +from sklearn.utils import _align_api_if_sparse, check_array, gen_even_slices, get_tags from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.fixes import parse_version, sp_base_version from sklearn.utils.multiclass import check_classification_targets @@ -222,9 +222,9 @@ def sort_graph_by_row_values(graph, copy=False, warn_when_not_sorted=True): Examples -------- - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> from sklearn.neighbors import sort_graph_by_row_values - >>> X = csr_matrix( + >>> X = csr_array( ... [[0., 3., 1.], ... [3., 0., 2.], ... [1., 2., 0.]]) @@ -1013,7 +1013,7 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode="connectivity"): # check the input only in self.kneighbors - # construct CSR matrix representation of the k-NN graph + # construct CSR representation of the k-NN graph if mode == "connectivity": A_ind = self.kneighbors(X, n_neighbors, return_distance=False) n_queries = A_ind.shape[0] @@ -1034,11 +1034,11 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode="connectivity"): n_nonzero = n_queries * n_neighbors A_indptr = np.arange(0, n_nonzero + 1, n_neighbors) - kneighbors_graph = csr_matrix( + kneighbors_graph = csr_array( (A_data, A_ind.ravel(), A_indptr), shape=(n_queries, n_samples_fit) ) - return kneighbors_graph + return _align_api_if_sparse(kneighbors_graph) class RadiusNeighborsMixin: @@ -1389,7 +1389,8 @@ def radius_neighbors_graph( A_data = np.ones(len(A_ind)) A_indptr = np.concatenate((np.zeros(1, dtype=int), np.cumsum(n_neighbors))) - return csr_matrix((A_data, A_ind, A_indptr), shape=(n_queries, n_samples_fit)) + csr = csr_array((A_data, A_ind, A_indptr), shape=(n_queries, n_samples_fit)) + return _align_api_if_sparse(csr) def __sklearn_tags__(self): tags = super().__sklearn_tags__() diff --git a/sklearn/neighbors/_binary_tree.pxi.tp b/sklearn/neighbors/_binary_tree.pxi.tp index 80b5a273abd5f..daf9fe039d556 100644 --- a/sklearn/neighbors/_binary_tree.pxi.tp +++ b/sklearn/neighbors/_binary_tree.pxi.tp @@ -583,9 +583,9 @@ cdef class NeighborsHeap{{name_suffix}}: cdef intp_t row for row in range(self.distances.shape[0]): _simultaneous_sort( - dist=&self.distances[row, 0], - idx=&self.indices[row, 0], - size=self.distances.shape[1], + values=&self.distances[row, 0], + indices=&self.indices[row, 0], + n=self.distances.shape[1], ) return 0 diff --git a/sklearn/neighbors/_lof.py b/sklearn/neighbors/_lof.py index e7c417eb74ca4..16bd9e9032a5c 100644 --- a/sklearn/neighbors/_lof.py +++ b/sklearn/neighbors/_lof.py @@ -30,6 +30,8 @@ class LocalOutlierFactor(KNeighborsMixin, OutlierMixin, NeighborsBase): neighbors, one can identify samples that have a substantially lower density than their neighbors. These are considered outliers. + Read more in the :ref:`User Guide <local_outlier_factor>`. + .. versionadded:: 0.19 Parameters diff --git a/sklearn/neighbors/_quad_tree.pyx b/sklearn/neighbors/_quad_tree.pyx index 5f623bf6cbecd..f041858c7f780 100644 --- a/sklearn/neighbors/_quad_tree.pyx +++ b/sklearn/neighbors/_quad_tree.pyx @@ -342,7 +342,7 @@ cdef class _QuadTree: if not cell.is_leaf: # Compute the number of point in children and compare with - # its cummulative_size. + # its cumulative_size. n_points = 0 for idx in range(self.n_cells_per_cell): child_id = cell.children[idx] diff --git a/sklearn/neighbors/meson.build b/sklearn/neighbors/meson.build index 7993421896218..ee3b45b95ab9d 100644 --- a/sklearn/neighbors/meson.build +++ b/sklearn/neighbors/meson.build @@ -21,7 +21,7 @@ foreach name: name_list output: name + '.pyx', input: name + '.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [neighbors_cython_tree, utils_cython_tree, metrics_cython_tree], diff --git a/sklearn/neural_network/_multilayer_perceptron.py b/sklearn/neural_network/_multilayer_perceptron.py index 4a56d4fe43b69..17e6f2219158a 100644 --- a/sklearn/neural_network/_multilayer_perceptron.py +++ b/sklearn/neural_network/_multilayer_perceptron.py @@ -865,7 +865,7 @@ def _score_with_function(self, X, y, sample_weight, score_function): # Input validation would remove feature names, so we disable it y_pred = self._predict(X, check_input=False) - if np.isnan(y_pred).any() or np.isinf(y_pred).any(): + if np.issubdtype(y_pred.dtype, np.floating) and not np.isfinite(y_pred).all(): return np.nan return score_function(y, y_pred, sample_weight=sample_weight) diff --git a/sklearn/neural_network/tests/test_mlp.py b/sklearn/neural_network/tests/test_mlp.py index 72eac916aaeb0..4e1ca92f10c67 100644 --- a/sklearn/neural_network/tests/test_mlp.py +++ b/sklearn/neural_network/tests/test_mlp.py @@ -15,6 +15,7 @@ from sklearn.datasets import ( load_digits, load_iris, + make_classification, make_multilabel_classification, make_regression, ) @@ -831,6 +832,30 @@ def test_early_stopping_stratified(): mlp.fit(X, y) +def test_mlp_early_stopping_string_labels(): + """Check that labels can be strings when `early_stopping=True`. + + Non-regression test for: + https://github.com/scikit-learn/scikit-learn/issues/33760 + """ + X, y = make_classification( + n_samples=200, + n_features=10, + n_classes=3, + n_informative=5, + random_state=42, + ) + labels = np.array(["class_a", "class_b", "class_c"], dtype=object) + y = labels[y] + + mlp = MLPClassifier(early_stopping=True, max_iter=50, random_state=42) + mlp.fit(X, y) + + assert mlp.validation_scores_ is not None + assert len(mlp.validation_scores_) == mlp.n_iter_ + assert np.isfinite(mlp.validation_scores_).all() + + def test_mlp_classifier_dtypes_casting(): # Compare predictions for different dtypes mlp_64 = MLPClassifier( diff --git a/sklearn/pipeline.py b/sklearn/pipeline.py index c0652840ff862..ca5d1d0cf8e57 100644 --- a/sklearn/pipeline.py +++ b/sklearn/pipeline.py @@ -14,6 +14,7 @@ from sklearn.exceptions import NotFittedError from sklearn.preprocessing import FunctionTransformer from sklearn.utils import Bunch +from sklearn.utils._array_api import get_namespace, get_namespace_and_device from sklearn.utils._metadata_requests import METHODS from sklearn.utils._param_validation import HasMethods, Hidden from sklearn.utils._repr_html.estimator import _VisualBlock @@ -296,6 +297,7 @@ def _validate_steps(self): self._validate_names(names) # validate estimators + self._check_estimators_are_instances(estimators) transformers = estimators[:-1] estimator = estimators[-1] @@ -387,6 +389,11 @@ def _final_estimator(self): try: estimator = self.steps[-1][1] return "passthrough" if estimator is None else estimator + except IndexError: + # An empty pipeline has no final estimator + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '_final_estimator'" + ) except (ValueError, AttributeError, TypeError): # This condition happens when a call to a method is first calling # `_available_if` and `fit` did not validate `steps` yet. We @@ -1147,7 +1154,12 @@ def score(self, X, y=None, sample_weight=None, **params): @property def classes_(self): """The classes labels. Only exist if the last step is a classifier.""" - return self.steps[-1][1].classes_ + try: + return self.steps[-1][1].classes_ + except IndexError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute 'classes_'" + ) def __sklearn_tags__(self): tags = super().__sklearn_tags__() @@ -1219,13 +1231,23 @@ def get_feature_names_out(self, input_features=None): def n_features_in_(self): """Number of features seen during first step `fit` method.""" # delegate to first step (which will call check_is_fitted) - return self.steps[0][1].n_features_in_ + try: + return self.steps[0][1].n_features_in_ + except IndexError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute 'n_features_in_'" + ) @property def feature_names_in_(self): """Names of features seen during first step `fit` method.""" # delegate to first step (which will call check_is_fitted) - return self.steps[0][1].feature_names_in_ + try: + return self.steps[0][1].feature_names_in_ + except IndexError: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute 'feature_names_in_'" + ) def __sklearn_is_fitted__(self): """Indicate whether pipeline has been fit. @@ -1698,6 +1720,7 @@ def _validate_transformers(self): self._validate_names(names) # validate estimators + self._check_estimators_are_instances(transformers) for t in transformers: if t in ("drop", "passthrough"): continue @@ -1907,7 +1930,8 @@ def fit_transform(self, X, y=None, **params): results = self._parallel_func(X, y, _fit_transform_one, routed_params) if not results: # All transformers are None - return np.zeros((X.shape[0], 0)) + xp, _, device = get_namespace_and_device(X) + return xp.zeros((X.shape[0], 0), device=device) Xs, transformers = zip(*results) self._update_transformer_list(transformers) @@ -1977,11 +2001,13 @@ def transform(self, X, **params): ) if not Xs: # All transformers are None - return np.zeros((X.shape[0], 0)) + xp, _, device = get_namespace_and_device(X) + return xp.zeros((X.shape[0], 0), device=device) return self._hstack(Xs) def _hstack(self, Xs): + xp, _ = get_namespace(*Xs) # Check if Xs dimensions are valid for X, (name, _) in zip(Xs, self.transformer_list): if hasattr(X, "shape") and len(X.shape) != 2: @@ -1993,12 +2019,12 @@ def _hstack(self, Xs): adapter = _get_container_adapter("transform", self) if adapter and all(adapter.is_supported_container(X) for X in Xs): - return adapter.hstack(Xs) + return adapter.hstack(Xs, self.get_feature_names_out()) if any(sparse.issparse(f) for f in Xs): return sparse.hstack(Xs).tocsr() - return np.hstack(Xs) + return xp.concat(Xs, axis=1) def _update_transformer_list(self, transformers): transformers = iter(transformers) @@ -2073,6 +2099,12 @@ def __sklearn_tags__(self): for name, trans in self.transformer_list if trans not in {"passthrough", "drop"} ) + tags.array_api_support = all( + True + if trans in {"passthrough", "drop"} + else get_tags(trans).array_api_support + for name, trans in self.transformer_list + ) except Exception: # If `transformer_list` does not comply with our API (list of tuples) # then it will fail. In this case, we assume that `sparse` is False diff --git a/sklearn/preprocessing/_data.py b/sklearn/preprocessing/_data.py index 15a8948412806..b4deda955ac1a 100644 --- a/sklearn/preprocessing/_data.py +++ b/sklearn/preprocessing/_data.py @@ -34,6 +34,7 @@ StrOptions, validate_params, ) +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import _incremental_mean_and_var, row_norms from sklearn.utils.sparsefuncs import ( incr_mean_variance_axis, @@ -992,7 +993,7 @@ def partial_fit(self, X, y=None, sample_weight=None): "instead. See docstring for motivation and alternatives." ) sparse_constructor = ( - sparse.csr_matrix if X.format == "csr" else sparse.csc_matrix + sparse.csr_array if X.format == "csr" else sparse.csc_array ) if self.with_std: @@ -2629,7 +2630,8 @@ def add_dummy_feature(X, value=1.0): row = np.concatenate((np.arange(n_samples), X.row)) # Prepend the dummy feature n_samples times. data = np.concatenate((np.full(n_samples, value), X.data)) - return sparse.coo_matrix((data, (row, col)), shape) + result = sparse.coo_array((data, (row, col)), shape) + return _align_api_if_sparse(result) elif X.format == "csc": # Shift index pointers since we need to add n_samples elements. indptr = X.indptr + n_samples @@ -2639,10 +2641,10 @@ def add_dummy_feature(X, value=1.0): indices = np.concatenate((np.arange(n_samples), X.indices)) # Prepend the dummy feature n_samples times. data = np.concatenate((np.full(n_samples, value), X.data)) - return sparse.csc_matrix((data, indices, indptr), shape) - else: - klass = X.__class__ - return klass(add_dummy_feature(X.tocoo(), value)) + result = sparse.csc_array((data, indices, indptr), shape) + return _align_api_if_sparse(result) + else: # "csr" format + return _align_api_if_sparse(add_dummy_feature(X.tocoo(), value).tocsr()) else: return np.hstack((np.full((n_samples, 1), value), X)) @@ -2819,7 +2821,7 @@ def _sparse_fit(self, X, random_state): X : sparse matrix of shape (n_samples, n_features) The data used to scale along the features axis. The sparse matrix needs to be nonnegative. If a sparse matrix is provided, - it will be converted into a sparse ``csc_matrix``. + it will be converted into a SciPy sparse CSC matrix. """ n_samples, n_features = X.shape references = self.references_ * 100 @@ -2860,7 +2862,7 @@ def fit(self, X, y=None): X : {array-like, sparse matrix} of shape (n_samples, n_features) The data used to scale along the features axis. If a sparse matrix is provided, it will be converted into a sparse - ``csc_matrix``. Additionally, the sparse matrix needs to be + CSC matrix. Additionally, the sparse matrix needs to be nonnegative if `ignore_implicit_zeros` is False. y : None @@ -3032,7 +3034,7 @@ def transform(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The data used to scale along the features axis. If a sparse matrix is provided, it will be converted into a sparse - ``csc_matrix``. Additionally, the sparse matrix needs to be + CSC matrix. Additionally, the sparse matrix needs to be nonnegative if `ignore_implicit_zeros` is False. Returns @@ -3053,7 +3055,7 @@ def inverse_transform(self, X): X : {array-like, sparse matrix} of shape (n_samples, n_features) The data used to scale along the features axis. If a sparse matrix is provided, it will be converted into a sparse - ``csc_matrix``. Additionally, the sparse matrix needs to be + CSC_matrix. Additionally, the sparse matrix needs to be nonnegative if `ignore_implicit_zeros` is False. Returns @@ -3062,10 +3064,21 @@ def inverse_transform(self, X): The projected data. """ check_is_fitted(self) - X = self._check_inputs( - X, in_fit=False, accept_sparse_negative=True, copy=self.copy + X = check_array( + X, + accept_sparse="csc", + copy=self.copy, + dtype=FLOAT_DTYPES, + force_writeable=True, + ensure_all_finite="allow-nan", ) + if not X.shape[1] == self.n_features_in_: + raise ValueError( + f"X has {X.shape[1]} features, but QuantileTransformer " + f"is expecting {self.n_features_in_} features as input." + ) + return self._transform(X, inverse=True) def __sklearn_tags__(self): @@ -3392,7 +3405,7 @@ def _fit(self, X, y=None, force_transform=False): transform_function = { "box-cox": boxcox, - "yeo-johnson": self._yeo_johnson_transform, + "yeo-johnson": stats.yeojohnson, }[self.method] with np.errstate(invalid="ignore"): # hide NaN warnings @@ -3437,7 +3450,7 @@ def transform(self, X): transform_function = { "box-cox": boxcox, - "yeo-johnson": self._yeo_johnson_transform, + "yeo-johnson": stats.yeojohnson, }[self.method] for i, lmbda in enumerate(self.lambdas_): with np.errstate(invalid="ignore"): # hide NaN warnings @@ -3480,7 +3493,19 @@ def inverse_transform(self, X): The original data. """ check_is_fitted(self) - X = self._check_input(X, in_fit=False, check_shape=True) + X = check_array( + X, + copy=self.copy, + dtype=FLOAT_DTYPES, + force_writeable=True, + ensure_all_finite="allow-nan", + ) + + if not X.shape[1] == self.n_features_in_: + raise ValueError( + f"X has {X.shape[1]} features, but PowerTransformer " + f"is expecting {self.n_features_in_} features as input." + ) if self.standardize: X = self._scaler.inverse_transform(X) @@ -3528,28 +3553,6 @@ def _yeo_johnson_inverse_transform(self, x, lmbda): return x_inv - def _yeo_johnson_transform(self, x, lmbda): - """Return transformed input x following Yeo-Johnson transform with - parameter lambda. - """ - - out = np.zeros_like(x) - pos = x >= 0 # binary mask - - # when x >= 0 - if abs(lmbda) < np.spacing(1.0): - out[pos] = np.log1p(x[pos]) - else: # lmbda != 0 - out[pos] = (np.power(x[pos] + 1, lmbda) - 1) / lmbda - - # when x < 0 - if abs(lmbda - 2) > np.spacing(1.0): - out[~pos] = -(np.power(-x[~pos] + 1, 2 - lmbda) - 1) / (2 - lmbda) - else: # lmbda == 2 - out[~pos] = -np.log1p(-x[~pos]) - - return out - def _box_cox_optimize(self, x): """Find and return optimal lambda parameter of the Box-Cox transform by MLE, for observed data x. @@ -3572,25 +3575,6 @@ def _yeo_johnson_optimize(self, x): Like for Box-Cox, MLE is done via the brent optimizer. """ - x_tiny = np.finfo(np.float64).tiny - - def _neg_log_likelihood(lmbda): - """Return the negative log likelihood of the observed data x as a - function of lambda.""" - x_trans = self._yeo_johnson_transform(x, lmbda) - n_samples = x.shape[0] - x_trans_var = x_trans.var() - - # Reject transformed data that would raise a RuntimeWarning in np.log - if x_trans_var < x_tiny: - return np.inf - - log_var = np.log(x_trans_var) - loglike = -n_samples / 2 * log_var - loglike += (lmbda - 1) * (np.sign(x) * np.log1p(np.abs(x))).sum() - - return -loglike - # the computation of lambda is influenced by NaNs so we need to # get rid of them x = x[~np.isnan(x)] diff --git a/sklearn/preprocessing/_discretization.py b/sklearn/preprocessing/_discretization.py index 847c388599821..de473834ceb0d 100644 --- a/sklearn/preprocessing/_discretization.py +++ b/sklearn/preprocessing/_discretization.py @@ -59,7 +59,7 @@ class KBinsDiscretizer(TransformerMixin, BaseEstimator): quantile_method : {"inverted_cdf", "averaged_inverted_cdf", "closest_observation", "interpolated_inverted_cdf", "hazen", "weibull", "linear", "median_unbiased", "normal_unbiased"}, - default="linear" + default="averaged_inverted_cdf" Method to pass on to np.percentile calculation when using strategy="quantile". Only `averaged_inverted_cdf` and `inverted_cdf` support the use of `sample_weight != None` when subsampling is not @@ -67,6 +67,9 @@ class KBinsDiscretizer(TransformerMixin, BaseEstimator): .. versionadded:: 1.7 + .. versionchanged:: 1.9 + The default value changed from `"linear"` to `"averaged_inverted_cdf"`. + dtype : {np.float32, np.float64}, default=None The desired data-type for the output. If None, output dtype is consistent with input dtype. Only np.float32 and np.float64 are @@ -196,7 +199,6 @@ class KBinsDiscretizer(TransformerMixin, BaseEstimator): "quantile_method": [ StrOptions( { - "warn", "inverted_cdf", "averaged_inverted_cdf", "closest_observation", @@ -220,7 +222,7 @@ def __init__( *, encode="onehot", strategy="quantile", - quantile_method="warn", + quantile_method="averaged_inverted_cdf", dtype=None, subsample=200_000, random_state=None, @@ -297,20 +299,7 @@ def fit(self, X, y=None, sample_weight=None): bin_edges = np.zeros(n_features, dtype=object) - # TODO(1.9): remove and switch to quantile_method="averaged_inverted_cdf" - # by default. quantile_method = self.quantile_method - if self.strategy == "quantile" and quantile_method == "warn": - warnings.warn( - "The current default behavior, quantile_method='linear', will be " - "changed to quantile_method='averaged_inverted_cdf' in " - "scikit-learn version 1.9 to naturally support sample weight " - "equivalence properties by default. Pass " - "quantile_method='averaged_inverted_cdf' explicitly to silence this " - "warning.", - FutureWarning, - ) - quantile_method = "linear" if ( self.strategy == "quantile" diff --git a/sklearn/preprocessing/_encoders.py b/sklearn/preprocessing/_encoders.py index ffff091be5b98..c6223612ba4bb 100644 --- a/sklearn/preprocessing/_encoders.py +++ b/sklearn/preprocessing/_encoders.py @@ -14,12 +14,13 @@ TransformerMixin, _fit_context, ) -from sklearn.utils import _safe_indexing, check_array +from sklearn.utils import _align_api_if_sparse, _safe_indexing, check_array from sklearn.utils._encode import _check_unknown, _encode, _get_counts, _unique from sklearn.utils._mask import _get_mask from sklearn.utils._missing import is_scalar_nan from sklearn.utils._param_validation import Interval, RealNotInt, StrOptions from sklearn.utils._set_output import _get_output_config +from sklearn.utils.fixes import _ensure_sparse_index_int32 from sklearn.utils.validation import ( _check_feature_names_in, check_is_fitted, @@ -245,14 +246,20 @@ def _transform( # already called above. X_int[:, i] = _encode(Xi, uniques=self.categories_[i], check_unknown=False) if columns_with_unknown: - warnings.warn( - ( + if handle_unknown == "infrequent_if_exist": + msg = ( + "Found unknown categories in columns " + f"{columns_with_unknown} during transform. These " + "unknown categories will be encoded as the " + "infrequent category." + ) + else: + msg = ( "Found unknown categories in columns " f"{columns_with_unknown} during transform. These " "unknown categories will be encoded as all zeros" - ), - UserWarning, - ) + ) + warnings.warn(msg, UserWarning) self._map_infrequent_categories(X_int, X_mask, ignore_category_indices) return X_int, X_mask @@ -436,7 +443,7 @@ def _map_infrequent_categories(self, X_int, X_mask, ignore_category_indices): continue X_int[~X_mask[:, col_idx], col_idx] = infrequent_idx[0] - if self.handle_unknown == "infrequent_if_exist": + if self.handle_unknown in ("infrequent_if_exist", "warn"): # All the unknown values are now mapped to the # infrequent_idx[0], which makes the unknown values valid # This is needed in `transform` when the encoding is formed @@ -538,8 +545,8 @@ class OneHotEncoder(_BaseEncoder): Support for dropping infrequent categories. sparse_output : bool, default=True - When ``True``, it returns a :class:`scipy.sparse.csr_matrix`, - i.e. a sparse matrix in "Compressed Sparse Row" (CSR) format. + When ``True``, it returns a SciPy sparse matrix/array + in "Compressed Sparse Row" (CSR) format. .. versionadded:: 1.2 `sparse` was renamed to `sparse_output` @@ -1003,8 +1010,7 @@ def transform(self, X): """ Transform X using one-hot encoding. - If `sparse_output=True` (default), it returns an instance of - :class:`scipy.sparse._csr.csr_matrix` (CSR format). + If `sparse_output=True` (default), it returns a SciPy sparse in CSR format. If there are infrequent categories for a feature, set by specifying `max_categories` or `min_frequency`, the infrequent categories are @@ -1076,15 +1082,16 @@ def transform(self, X): np.cumsum(indptr[1:], out=indptr[1:]) data = np.ones(indptr[-1]) - out = sparse.csr_matrix( + out = sparse.csr_array( (data, indices, indptr), shape=(n_samples, feature_indices[-1]), dtype=self.dtype, ) - if not self.sparse_output: - return out.toarray() + if self.sparse_output: + _ensure_sparse_index_int32(out) + return _align_api_if_sparse(out) else: - return out + return out.toarray() def inverse_transform(self, X): """ diff --git a/sklearn/preprocessing/_function_transformer.py b/sklearn/preprocessing/_function_transformer.py index 7c56758d249a2..818bfb890727b 100644 --- a/sklearn/preprocessing/_function_transformer.py +++ b/sklearn/preprocessing/_function_transformer.py @@ -7,6 +7,7 @@ import numpy as np from sklearn.base import BaseEstimator, TransformerMixin, _fit_context +from sklearn.utils._dataframe import is_pandas_df, is_polars_df from sklearn.utils._param_validation import StrOptions from sklearn.utils._repr_html.estimator import _VisualBlock from sklearn.utils._set_output import _get_adapter_from_container, _get_output_config @@ -15,8 +16,6 @@ _allclose_dense_sparse, _check_feature_names_in, _get_feature_names, - _is_pandas_df, - _is_polars_df, check_array, validate_data, ) @@ -302,9 +301,9 @@ def transform(self, X): "a {0} DataFrame to follow the `set_output` API or `feature_names_out`" " should be defined." ) - if output_config == "pandas" and not _is_pandas_df(out): + if output_config == "pandas" and not is_pandas_df(out): warnings.warn(warn_msg.format("pandas")) - elif output_config == "polars" and not _is_polars_df(out): + elif output_config == "polars" and not is_polars_df(out): warnings.warn(warn_msg.format("polars")) return out @@ -382,7 +381,7 @@ def _transform(self, X, func=None, kw_args=None): return func(X, **(kw_args if kw_args else {})) def __sklearn_is_fitted__(self): - """Return True since FunctionTransfomer is stateless.""" + """Return True since FunctionTransformer is stateless.""" return True def __sklearn_tags__(self): @@ -395,8 +394,9 @@ def __sklearn_tags__(self): def set_output(self, *, transform=None): """Set output container. - See :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` - for an example on how to use the API. + Refer to the :ref:`user guide <df_output_transform>` for more details + and :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` for an + example on how to use the API. Parameters ---------- diff --git a/sklearn/preprocessing/_label.py b/sklearn/preprocessing/_label.py index 5c2ee8f5fce9f..1d0982dc3488f 100644 --- a/sklearn/preprocessing/_label.py +++ b/sklearn/preprocessing/_label.py @@ -11,8 +11,18 @@ import scipy.sparse as sp from sklearn.base import BaseEstimator, TransformerMixin, _fit_context -from sklearn.utils import column_or_1d -from sklearn.utils._array_api import device, get_namespace, xpx +from sklearn.utils import _align_api_if_sparse, column_or_1d +from sklearn.utils._array_api import ( + _find_matching_floating_dtype, + _is_numpy_namespace, + _isin, + device, + get_namespace, + get_namespace_and_device, + indexing_dtype, + move_to, + xpx, +) from sklearn.utils._encode import _encode, _unique from sklearn.utils._param_validation import Interval, validate_params from sklearn.utils.multiclass import type_of_target, unique_labels @@ -299,6 +309,15 @@ def fit(self, y): f"pos_label={self.pos_label} and neg_label={self.neg_label}" ) + xp, is_array_api = get_namespace(y) + + if is_array_api and self.sparse_output and not _is_numpy_namespace(xp): + raise ValueError( + "`sparse_output=True` is not supported for array API " + f"namespace {xp.__name__}. " + "Use `sparse_output=False` to return a dense array instead." + ) + self.y_type_ = type_of_target(y, input_name="y") if "multioutput" in self.y_type_: @@ -356,6 +375,15 @@ def transform(self, y): """ check_is_fitted(self) + xp, is_array_api = get_namespace(y) + + if is_array_api and self.sparse_output and not _is_numpy_namespace(xp): + raise ValueError( + "`sparse_output=True` is not supported for array API " + f"namespace {xp.__name__}. " + "Use `sparse_output=False` to return a dense array instead." + ) + y_is_multilabel = type_of_target(y).startswith("multilabel") if y_is_multilabel and not self.y_type_.startswith("multilabel"): raise ValueError("The object was not fitted with multilabel input.") @@ -402,18 +430,26 @@ def inverse_transform(self, Y, threshold=None): """ check_is_fitted(self) + xp, is_array_api = get_namespace(Y) + + if is_array_api and self.sparse_input_ and not _is_numpy_namespace(xp): + raise ValueError( + "`LabelBinarizer` was fitted on a sparse matrix, and therefore cannot " + f"inverse transform a {xp.__name__} array back to a sparse matrix." + ) + if threshold is None: threshold = (self.pos_label + self.neg_label) / 2.0 if self.y_type_ == "multiclass": - y_inv = _inverse_binarize_multiclass(Y, self.classes_) + y_inv = _inverse_binarize_multiclass(Y, self.classes_, xp=xp) else: y_inv = _inverse_binarize_thresholding( - Y, self.y_type_, self.classes_, threshold + Y, self.y_type_, self.classes_, threshold, xp=xp ) if self.sparse_input_: - y_inv = sp.csr_matrix(y_inv) + y_inv = _align_api_if_sparse(sp.csr_array(y_inv)) elif sp.issparse(y_inv): y_inv = y_inv.toarray() @@ -533,25 +569,54 @@ def label_binarize(y, *, classes, neg_label=0, pos_label=1, sparse_output=False) if y_type == "unknown": raise ValueError("The type of target data is not known") - n_samples = y.shape[0] if sp.issparse(y) else len(y) - n_classes = len(classes) - classes = np.asarray(classes) + xp, is_array_api, device_ = get_namespace_and_device(y) + + if is_array_api and sparse_output and not _is_numpy_namespace(xp): + raise ValueError( + "`sparse_output=True` is not supported for array API " + f"'namespace {xp.__name__}'. " + "Use `sparse_output=False` to return a dense array instead." + ) + + try: + classes = xp.asarray(classes, device=device_) + except (ValueError, TypeError) as e: + # `classes` contains an unsupported dtype for this namespace. + # For example, attempting to create torch.tensor(["yes", "no"]) will fail. + raise ValueError( + f"`classes` contains unsupported dtype for array API namespace " + f"'{xp.__name__}'." + ) from e + + n_samples = y.shape[0] if hasattr(y, "shape") else len(y) + n_classes = classes.shape[0] + + y_has_dtype = hasattr(y, "dtype") + if y_has_dtype and xp.isdtype(y.dtype, "signed integer"): + int_dtype_ = y.dtype + else: + int_dtype_ = indexing_dtype(xp) + + # Align `classes` dtype with integral `y` to ensure correct comparisons + # and avoid signed/unsigned dtype mismatches + if y_has_dtype and xp.isdtype(y.dtype, "integral"): + classes = xp.astype(classes, y.dtype, copy=False) if y_type == "binary": if n_classes == 1: if sparse_output: - return sp.csr_matrix((n_samples, 1), dtype=int) + return _align_api_if_sparse(sp.csr_array((n_samples, 1), dtype=int)) else: - Y = np.zeros((len(y), 1), dtype=int) + Y = xp.zeros((n_samples, 1), dtype=int_dtype_) Y += neg_label return Y - elif len(classes) >= 3: + elif n_classes >= 3: y_type = "multiclass" - sorted_class = np.sort(classes) + sorted_class = xp.sort(classes) if y_type == "multilabel-indicator": y_n_classes = y.shape[1] if hasattr(y, "shape") else len(y[0]) - if classes.size != y_n_classes: + if n_classes != y_n_classes: raise ValueError( "classes {0} mismatch with the labels {1} found in the data".format( classes, unique_labels(y) @@ -562,59 +627,83 @@ def label_binarize(y, *, classes, neg_label=0, pos_label=1, sparse_output=False) y = column_or_1d(y) # pick out the known labels from y - y_in_classes = np.isin(y, classes) + y_in_classes = _isin(y, classes, xp=xp) y_seen = y[y_in_classes] - indices = np.searchsorted(sorted_class, y_seen) - indptr = np.hstack((0, np.cumsum(y_in_classes))) + indices = xp.searchsorted(sorted_class, y_seen) + # cast `y_in_classes` to integer dtype for `xp.cumulative_sum` + y_in_classes = xp.astype(y_in_classes, int_dtype_) + indptr = xp.concat( + ( + xp.asarray([0], device=device_), + xp.cumulative_sum(y_in_classes, axis=0), + ) + ) + data = xp.full_like(indices, pos_label) + + # Use NumPy to construct the sparse matrix of one-hot labels + Y = sp.csr_array( + ( + move_to(data, xp=np, device="cpu"), + move_to(indices, xp=np, device="cpu"), + move_to(indptr, xp=np, device="cpu"), + ), + shape=(n_samples, n_classes), + ) + + if not sparse_output: + Y = xp.asarray(Y.toarray(), device=device_) - data = np.empty_like(indices) - data.fill(pos_label) - Y = sp.csr_matrix((data, indices, indptr), shape=(n_samples, n_classes)) elif y_type == "multilabel-indicator": - Y = sp.csr_matrix(y) - if pos_label != 1: - data = np.empty_like(Y.data) - data.fill(pos_label) - Y.data = data + if sparse_output: + Y = sp.csr_array(y) + if pos_label != 1: + data = xp.full_like(Y.data, pos_label) + Y.data = data + else: + if sp.issparse(y): + y = y.toarray() + + Y = xp.asarray(y, device=device_, copy=True) + if pos_label != 1: + Y[Y != 0] = pos_label + else: raise ValueError( "%s target data is not supported with label binarization" % y_type ) if not sparse_output: - Y = Y.toarray() - Y = Y.astype(int, copy=False) - if neg_label != 0: Y[Y == 0] = neg_label if pos_switch: Y[Y == pos_label] = 0 + + Y = xp.astype(Y, int_dtype_, copy=False) else: Y.data = Y.data.astype(int, copy=False) # preserve label ordering - if np.any(classes != sorted_class): - indices = np.searchsorted(sorted_class, classes) + if xp.any(classes != sorted_class): + indices = xp.searchsorted(sorted_class, classes) Y = Y[:, indices] if y_type == "binary": if sparse_output: Y = Y[:, [-1]] else: - Y = Y[:, -1].reshape((-1, 1)) + Y = xp.reshape(Y[:, -1], (-1, 1)) - return Y + return _align_api_if_sparse(Y) -def _inverse_binarize_multiclass(y, classes): +def _inverse_binarize_multiclass(y, classes, xp=None): """Inverse label binarization transformation for multiclass. Multiclass uses the maximal score instead of a threshold. """ - classes = np.asarray(classes) - if sp.issparse(y): + classes = np.asarray(classes) # Find the argmax for each row in y where y is a CSR matrix y = y.tocsr() @@ -647,21 +736,33 @@ def _inverse_binarize_multiclass(y, classes): return classes[y_i_argmax] else: - return classes.take(y.argmax(axis=1), mode="clip") + xp, _, device_ = get_namespace_and_device(y, xp=xp) + classes = xp.asarray(classes, device=device_) + indices = xp.argmax(y, axis=1) + indices = xp.clip(indices, 0, classes.shape[0] - 1) + return classes[indices] -def _inverse_binarize_thresholding(y, output_type, classes, threshold): + +def _inverse_binarize_thresholding(y, output_type, classes, threshold, xp=None): """Inverse label binarization transformation using thresholding.""" if output_type == "binary" and y.ndim == 2 and y.shape[1] > 2: raise ValueError("output_type='binary', but y.shape = {0}".format(y.shape)) - if output_type != "binary" and y.shape[1] != len(classes): + xp, _, device_ = get_namespace_and_device(y, xp=xp) + classes = xp.asarray(classes, device=device_) + + if output_type != "binary" and y.shape[1] != classes.shape[0]: raise ValueError( "The number of class is not equal to the number of dimension of y." ) - classes = np.asarray(classes) + dtype_ = _find_matching_floating_dtype(y, xp=xp) + if hasattr(y, "dtype") and xp.isdtype(y.dtype, "signed integer"): + int_dtype_ = y.dtype + else: + int_dtype_ = indexing_dtype(xp) # Perform thresholding if sp.issparse(y): @@ -671,9 +772,13 @@ def _inverse_binarize_thresholding(y, output_type, classes, threshold): y.data = np.array(y.data > threshold, dtype=int) y.eliminate_zeros() else: - y = np.array(y.toarray() > threshold, dtype=int) + y = xp.asarray(y.toarray() > threshold, dtype=int_dtype_, device=device_) else: - y = np.array(y > threshold, dtype=int) + y = xp.asarray( + xp.asarray(y, dtype=dtype_, device=device_) > threshold, + dtype=int_dtype_, + device=device_, + ) # Inverse transform data if output_type == "binary": @@ -682,10 +787,10 @@ def _inverse_binarize_thresholding(y, output_type, classes, threshold): if y.ndim == 2 and y.shape[1] == 2: return classes[y[:, 1]] else: - if len(classes) == 1: - return np.repeat(classes[0], len(y)) + if classes.shape[0] == 1: + return xp.repeat(classes[0], len(y)) else: - return classes[y.ravel()] + return classes[xp.reshape(y, (-1,))] elif output_type == "multilabel-indicator": return y @@ -702,6 +807,8 @@ class MultiLabelBinarizer(TransformerMixin, BaseEstimator, auto_wrap_output_keys intuitive format and the supported multilabel format: a (samples x classes) binary matrix indicating the presence of a class label. + Read more in the :ref:`User Guide <multilabelbinarizer>`. + Parameters ---------- classes : array-like of shape (n_classes,), default=None @@ -911,8 +1018,10 @@ def _transform(self, y, class_mapping): ) data = np.ones(len(indices), dtype=int) - return sp.csr_matrix( - (data, indices, indptr), shape=(len(indptr) - 1, len(class_mapping)) + return _align_api_if_sparse( + sp.csr_array( + (data, indices, indptr), shape=(len(indptr) - 1, len(class_mapping)) + ) ) def inverse_transform(self, yt): @@ -921,7 +1030,7 @@ def inverse_transform(self, yt): Parameters ---------- yt : {ndarray, sparse matrix} of shape (n_samples, n_classes) - A matrix containing only 1s ands 0s. + A matrix containing only 1s and 0s. Returns ------- diff --git a/sklearn/preprocessing/_polynomial.py b/sklearn/preprocessing/_polynomial.py index de20a037a9b73..4c887ab7f6a4b 100644 --- a/sklearn/preprocessing/_polynomial.py +++ b/sklearn/preprocessing/_polynomial.py @@ -21,7 +21,7 @@ _calc_total_nnz, _csr_polynomial_expansion, ) -from sklearn.utils import check_array +from sklearn.utils import _align_api_if_sparse, check_array from sklearn.utils._array_api import ( _is_numpy_namespace, get_namespace_and_device, @@ -80,10 +80,12 @@ def _create_expansion(X, interaction_only, deg, n_features, cumulative_size=0): interaction_only, deg, ) - return sparse.csr_matrix( - (expanded_data, expanded_indices, expanded_indptr), - shape=(X.indptr.shape[0] - 1, expanded_col), - dtype=X.dtype, + return _align_api_if_sparse( + sparse.csr_array( + (expanded_data, expanded_indices, expanded_indptr), + shape=(X.indptr.shape[0] - 1, expanded_col), + dtype=X.dtype, + ) ) @@ -416,8 +418,7 @@ def transform(self, X): XP : {ndarray, sparse matrix} of shape (n_samples, NP) The matrix of features, where `NP` is the number of polynomial features generated from the combination of inputs. If a sparse - matrix is provided, it will be converted into a sparse - `csr_matrix`. + matrix is provided, it will be converted into CSR format. """ check_is_fitted(self) xp, _, device_ = get_namespace_and_device(X) @@ -438,7 +439,7 @@ def transform(self, X): to_stack = [] if self.include_bias: to_stack.append( - sparse.csr_matrix(np.ones(shape=(n_samples, 1), dtype=X.dtype)) + sparse.csr_array(np.ones(shape=(n_samples, 1), dtype=X.dtype)) ) if self._min_degree <= 1 and self._max_degree > 0: to_stack.append(X) @@ -457,7 +458,7 @@ def transform(self, X): cumulative_size += expanded.shape[1] if len(to_stack) == 0: # edge case: deal with empty matrix - XP = sparse.csr_matrix((n_samples, 0), dtype=X.dtype) + XP = sparse.csr_array((n_samples, 0), dtype=X.dtype) else: XP = sparse.hstack(to_stack, dtype=X.dtype, format="csr") elif sparse.issparse(X) and X.format == "csc" and self._max_degree < 4: @@ -478,7 +479,7 @@ def transform(self, X): out_col = X[:, [col_idx]].multiply(out_col) columns.append(out_col) else: - bias = sparse.csc_matrix(np.ones((X.shape[0], 1))) + bias = sparse.csc_array(np.ones((X.shape[0], 1))) columns.append(bias) XP = sparse.hstack(columns, dtype=X.dtype).tocsc() else: @@ -520,7 +521,7 @@ def transform(self, X): current_col = 0 if self._max_degree == 0: - return XP + return _align_api_if_sparse(XP) # degree 1 term XP[:, current_col : current_col + n_features] = X @@ -573,7 +574,7 @@ def transform(self, X): else: Xout = xp.asarray(XP[:, n_XP - n_Xout :], copy=True) XP = Xout - return XP + return _align_api_if_sparse(XP) def __sklearn_tags__(self): tags = super().__sklearn_tags__() @@ -1118,8 +1119,7 @@ def transform(self, X): XBS_sparse = BSpline.design_matrix(x, spl.t, spl.k) # Note: Without converting to lil_matrix we would get: # scipy.sparse._base.SparseEfficiencyWarning: Changing the sparsity - # structure of a csr_matrix is expensive. lil_matrix is more - # efficient. + # structure of CSC is expensive. LIL is more efficient. if np.any(outside_range_mask): XBS_sparse = XBS_sparse.tolil() XBS_sparse[outside_range_mask, :] = 0 @@ -1241,6 +1241,8 @@ def transform(self, X): if self.sparse_output: XBS = sparse.hstack(output_list, format="csr") + XBS = _align_api_if_sparse(XBS) + if self.include_bias: return XBS else: diff --git a/sklearn/preprocessing/_target_encoder.py b/sklearn/preprocessing/_target_encoder.py index 5d8fc97f2a1bd..c83ac38b3091a 100644 --- a/sklearn/preprocessing/_target_encoder.py +++ b/sklearn/preprocessing/_target_encoder.py @@ -1,7 +1,8 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause -from numbers import Integral, Real +import warnings +from numbers import Real import numpy as np @@ -11,6 +12,14 @@ _fit_encoding_fast, _fit_encoding_fast_auto_smooth, ) +from sklearn.utils import Bunch, indexable +from sklearn.utils._metadata_requests import ( + MetadataRouter, + MethodMapping, + _raise_for_params, + _routing_enabled, + process_routing, +) from sklearn.utils._param_validation import Interval, StrOptions from sklearn.utils.multiclass import type_of_target from sklearn.utils.validation import ( @@ -41,7 +50,7 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder): that are not seen during :meth:`fit` are encoded with the target mean, i.e. `target_mean_`. - For a demo on the importance of the `TargetEncoder` internal cross-fitting, + For a demo on the importance of the `TargetEncoder` internal :term:`cross fitting`, see :ref:`sphx_glr_auto_examples_preprocessing_plot_target_encoder_cross_val.py`. For a comparison of different encoders, refer to @@ -94,14 +103,37 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder): more weight on the global target mean. If `"auto"`, then `smooth` is set to an empirical Bayes estimate. - cv : int, default=5 - Determines the number of folds in the :term:`cross fitting` strategy used in - :meth:`fit_transform`. For classification targets, `StratifiedKFold` is used - and for continuous targets, `KFold` is used. + cv : int, cross-validation generator or an iterable, default=None + Determines the splitting strategy used in the internal :term:`cross fitting` + during :meth:`fit_transform`. Splitters where each sample index doesn't appear + in the validation fold exactly once, raise a `ValueError`. + Possible inputs for cv are: + + - `None`, to use a 5-fold cross-validation chosen internally based on + `target_type`, + - integer, to specify the number of folds for the cross-validation chosen + internally based on `target_type`, + - :term:`CV splitter` that does not repeat samples across validation folds, + - an iterable yielding (train, test) splits as arrays of indices. + + For integer/None inputs, if `target_type` is `"continuous"`, :class:`KFold` is + used, otherwise :class:`StratifiedKFold` is used. + + Refer :ref:`User Guide <cross_validation>` for more information on + cross-validation strategies. + + .. versionchanged:: 1.9 + Cross-validation generators and iterables can also be passed as `cv`. shuffle : bool, default=True Whether to shuffle the data in :meth:`fit_transform` before splitting into - folds. Note that the samples within each split will not be shuffled. + folds. Note that the samples within each split will not be shuffled. Only + applies if `cv` is an int or `None`. If `cv` is a cross-validation generator or + an iterable, `shuffle` is ignored. + + .. deprecated:: 1.9 + `shuffle` is deprecated and will be removed in 1.11. Pass a cross-validation + generator as `cv` argument to specify the shuffling instead. random_state : int, RandomState instance or None, default=None When `shuffle` is True, `random_state` affects the ordering of the @@ -110,6 +142,11 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder): Pass an int for reproducible output across multiple function calls. See :term:`Glossary <random_state>`. + .. deprecated:: 1.9 + `random_state` is deprecated and will be removed in 1.11. Pass a + cross-validation generator as `cv` argument to specify the random state of + the shuffling instead. + Attributes ---------- encodings_ : list of shape (n_features,) or (n_features * n_classes) of \ @@ -193,19 +230,20 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder): "categories": [StrOptions({"auto"}), list], "target_type": [StrOptions({"auto", "continuous", "binary", "multiclass"})], "smooth": [StrOptions({"auto"}), Interval(Real, 0, None, closed="left")], - "cv": [Interval(Integral, 2, None, closed="left")], - "shuffle": ["boolean"], - "random_state": ["random_state"], + "cv": ["cv_object"], + "shuffle": ["boolean", StrOptions({"deprecated"})], + "random_state": ["random_state", StrOptions({"deprecated"})], } + # TODO(1.11) remove `shuffle` and `random_state` params, which had been deprecated def __init__( self, categories="auto", target_type="auto", smooth="auto", cv=5, - shuffle=True, - random_state=None, + shuffle="deprecated", + random_state="deprecated", ): self.categories = categories self.smooth = smooth @@ -243,7 +281,7 @@ def fit(self, X, y): return self @_fit_context(prefer_skip_nested_validation=True) - def fit_transform(self, X, y): + def fit_transform(self, X, y, **params): """Fit :class:`TargetEncoder` and transform `X` with the target encoding. This method uses a :term:`cross fitting` scheme to prevent target leakage @@ -263,28 +301,88 @@ def fit_transform(self, X, y): y : array-like of shape (n_samples,) The target data used to encode the categories. + **params : dict + Parameters to route to the internal CV object. + + Can only be used in conjunction with a cross-validation generator as CV + object. + + For instance, `groups` (array-like of shape `(n_samples,)`) can be routed to + a CV splitter that accepts `groups`, such as :class:`GroupKFold` or + :class:`StratifiedGroupKFold`. + + .. versionadded:: 1.9 + Only available if `enable_metadata_routing=True`, which can be + set by using ``sklearn.set_config(enable_metadata_routing=True)``. + See :ref:`Metadata Routing User Guide <metadata_routing>` for + more details. + Returns ------- X_trans : ndarray of shape (n_samples, n_features) or \ (n_samples, (n_features * n_classes)) Transformed input. """ - from sklearn.model_selection import ( # avoid circular import + # avoid circular imports + from sklearn.model_selection import ( + GroupKFold, KFold, + StratifiedGroupKFold, StratifiedKFold, ) + from sklearn.model_selection._split import check_cv + + _raise_for_params(params, self, "fit_transform") X_ordinal, X_known_mask, y_encoded, n_categories = self._fit_encodings_all(X, y) - # The cv splitter is voluntarily restricted to *KFold to enforce non - # overlapping validation folds, otherwise the fit_transform output will - # not be well-specified. - if self.target_type_ == "continuous": - cv = KFold(self.cv, shuffle=self.shuffle, random_state=self.random_state) - else: - cv = StratifiedKFold( - self.cv, shuffle=self.shuffle, random_state=self.random_state + # TODO(1.11): remove code block + if self.shuffle != "deprecated" or self.random_state != "deprecated": + warnings.warn( + "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are " + "deprecated in version 1.9 and will be removed in version 1.11. Pass a " + "cross-validation generator as `cv` argument to specify the shuffling " + "behaviour instead.", + FutureWarning, ) + shuffle = True if self.shuffle == "deprecated" else self.shuffle + cv_kwargs = {"shuffle": shuffle} + if self.random_state != "deprecated": + cv_kwargs["random_state"] = self.random_state + + # TODO(1.11): pass shuffle=True to keep backwards compatibility for default + # inputs (will be ignored in `check_cv` if a cv object is passed); + # `random_state` already defaults to `None` in `check_cv` and doesn't need to + # be passed here + cv = check_cv( + self.cv, + y, + classifier=self.target_type_ != "continuous", + **cv_kwargs, + ) + + if _routing_enabled(): + if params["groups"] is not None: + X, y, params["groups"] = indexable(X, y, params["groups"]) + routed_params = process_routing(self, "fit_transform", **params) + else: + routed_params = Bunch(splitter=Bunch(split={})) + + # The internal cross-fitting is only well-defined when each sample index + # appears in exactly one validation fold. Skip the validation check for + # known non-overlapping splitters in scikit-learn: + if not isinstance( + cv, (GroupKFold, KFold, StratifiedKFold, StratifiedGroupKFold) + ): + seen_count = np.zeros(X.shape[0]) + for _, test_idx in cv.split(X, y, **routed_params.splitter.split): + seen_count[test_idx] += 1 + if not np.all(seen_count == 1): + raise ValueError( + "Validation indices from `cv` must cover each sample index exactly " + "once with no overlap. Pass a splitter with non-overlapping " + "validation folds as `cv` or refer to the docs for other options." + ) # If 'multiclass' multiply axis=1 by num classes else keep shape the same if self.target_type_ == "multiclass": @@ -295,7 +393,7 @@ def fit_transform(self, X, y): else: X_out = np.empty_like(X_ordinal, dtype=np.float64) - for train_idx, test_idx in cv.split(X, y): + for train_idx, test_idx in cv.split(X, y, **routed_params.splitter.split): X_train, y_train = X_ordinal[train_idx, :], y_encoded[train_idx] y_train_mean = np.mean(y_train, axis=0) @@ -546,6 +644,33 @@ def get_feature_names_out(self, input_features=None): else: return feature_names + def get_metadata_routing(self): + """Get metadata routing of this object. + + Please check :ref:`User Guide <metadata_routing>` on how the routing + mechanism works. + + .. versionadded:: 1.9 + + Returns + ------- + routing : MetadataRouter + A :class:`~sklearn.utils.metadata_routing.MetadataRouter` encapsulating + routing information. + """ + + router = MetadataRouter(owner=self) + + router.add( + # This works, since none of {None, int, iterable} request any metadata + # and the machinery here would assign an empty MetadataRequest + # to it. + splitter=self.cv, + method_mapping=MethodMapping().add(caller="fit_transform", callee="split"), + ) + + return router + def __sklearn_tags__(self): tags = super().__sklearn_tags__() tags.target_tags.required = True diff --git a/sklearn/preprocessing/tests/test_data.py b/sklearn/preprocessing/tests/test_data.py index 8d9c6a5f454ab..d7ea7e2fd35d9 100644 --- a/sklearn/preprocessing/tests/test_data.py +++ b/sklearn/preprocessing/tests/test_data.py @@ -39,8 +39,7 @@ from sklearn.svm import SVR from sklearn.utils import gen_batches, shuffle from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import ( @@ -64,6 +63,7 @@ CSC_CONTAINERS, CSR_CONTAINERS, LIL_CONTAINERS, + _sparse_random_array, sp_version, ) from sklearn.utils.sparsefuncs import mean_variance_axis @@ -169,22 +169,21 @@ def test_standard_scaler_sample_weight(Xw, X, sample_weight, array_constructor): @pytest.mark.parametrize(["Xw", "X", "sample_weight"], _yield_xw_x_sampleweight()) @pytest.mark.parametrize( - "namespace, dev, dtype", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_standard_scaler_sample_weight_array_api( - Xw, X, sample_weight, namespace, dev, dtype + Xw, X, sample_weight, namespace, device_name, dtype_name ): # N.B. The sample statistics for Xw w/ sample_weight should match # the statistics of X w/ uniform sample_weight. - xp = _array_api_for_tests(namespace, dev) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) - X = np.array(X).astype(dtype, copy=False) - y = np.ones(X.shape[0]).astype(dtype, copy=False) - Xw = np.array(Xw).astype(dtype, copy=False) - yw = np.ones(Xw.shape[0]).astype(dtype, copy=False) - X_test = np.array([[1.5, 2.5, 3.5], [3.5, 4.5, 5.5]]).astype(dtype, copy=False) + X = np.array(X).astype(dtype_name, copy=False) + y = np.ones(X.shape[0]).astype(dtype_name, copy=False) + Xw = np.array(Xw).astype(dtype_name, copy=False) + yw = np.ones(Xw.shape[0]).astype(dtype_name, copy=False) + X_test = np.array([[1.5, 2.5, 3.5], [3.5, 4.5, 5.5]]).astype(dtype_name, copy=False) scaler = StandardScaler() scaler.fit(X, y) @@ -193,18 +192,18 @@ def test_standard_scaler_sample_weight_array_api( scaler_w.fit(Xw, yw, sample_weight=sample_weight) # Test array-api support and correctness. - X_xp = xp.asarray(X, device=dev) - y_xp = xp.asarray(y, device=dev) - Xw_xp = xp.asarray(Xw, device=dev) - yw_xp = xp.asarray(yw, device=dev) - X_test_xp = xp.asarray(X_test, device=dev) - sample_weight_xp = xp.asarray(sample_weight, device=dev) + X_xp = xp.asarray(X, device=device) + y_xp = xp.asarray(y, device=device) + Xw_xp = xp.asarray(Xw, device=device) + yw_xp = xp.asarray(yw, device=device) + X_test_xp = xp.asarray(X_test, device=device) + sample_weight_xp = xp.asarray(sample_weight, device=device) scaler_w_xp = StandardScaler() with config_context(array_api_dispatch=True): scaler_w_xp.fit(Xw_xp, yw_xp, sample_weight=sample_weight_xp) - w_mean = _convert_to_numpy(scaler_w_xp.mean_, xp=xp) - w_var = _convert_to_numpy(scaler_w_xp.var_, xp=xp) + w_mean = move_to(scaler_w_xp.mean_, xp=np, device="cpu") + w_var = move_to(scaler_w_xp.var_, xp=np, device="cpu") assert_allclose(scaler_w.mean_, w_mean) assert_allclose(scaler_w.var_, w_var) @@ -213,8 +212,8 @@ def test_standard_scaler_sample_weight_array_api( scaler_xp = StandardScaler() with config_context(array_api_dispatch=True): scaler_xp.fit(X_xp, y_xp) - uw_mean = _convert_to_numpy(scaler_xp.mean_, xp=xp) - uw_var = _convert_to_numpy(scaler_xp.var_, xp=xp) + uw_mean = move_to(scaler_xp.mean_, xp=np, device="cpu") + uw_var = move_to(scaler_xp.var_, xp=np, device="cpu") assert_allclose(scaler.mean_, uw_mean) assert_allclose(scaler.var_, uw_var) @@ -224,8 +223,8 @@ def test_standard_scaler_sample_weight_array_api( assert_allclose(uw_var, w_var) with config_context(array_api_dispatch=True): assert_allclose( - _convert_to_numpy(scaler_xp.transform(X_test_xp), xp=xp), - _convert_to_numpy(scaler_w_xp.transform(X_test_xp), xp=xp), + move_to(scaler_xp.transform(X_test_xp), xp=np, device="cpu"), + move_to(scaler_w_xp.transform(X_test_xp), xp=np, device="cpu"), ) @@ -697,7 +696,7 @@ def test_partial_fit_sparse_input(sample_weight, sparse_container): @pytest.mark.parametrize("sample_weight", [True, None]) -def test_standard_scaler_trasform_with_partial_fit(sample_weight): +def test_standard_scaler_transform_with_partial_fit(sample_weight): # Check some postconditions after applying partial_fit and transform X = X_2d[:100, :] @@ -763,9 +762,8 @@ def test_standard_check_array_of_inverse_transform(): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "check", @@ -788,16 +786,21 @@ def test_standard_check_array_of_inverse_transform(): ids=_get_check_estimator_ids, ) def test_preprocessing_array_api_compliance( - estimator, check, array_namespace, device, dtype_name + estimator, check, array_namespace, device_name, dtype_name ): name = estimator.__class__.__name__ - check(name, estimator, array_namespace, device=device, dtype_name=dtype_name) + check( + name, + estimator, + array_namespace, + device_name=device_name, + dtype_name=dtype_name, + ) @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "check", @@ -806,7 +809,7 @@ def test_preprocessing_array_api_compliance( ) @pytest.mark.parametrize("sample_weight", [True, None]) def test_standard_scaler_array_api_compliance( - check, sample_weight, array_namespace, device, dtype_name + check, sample_weight, array_namespace, device_name, dtype_name ): estimator = StandardScaler() name = estimator.__class__.__name__ @@ -814,7 +817,7 @@ def test_standard_scaler_array_api_compliance( name, estimator, array_namespace, - device=device, + device_name=device_name, dtype_name=dtype_name, check_sample_weight=sample_weight, ) @@ -2106,18 +2109,19 @@ def test_binarizer(constructor): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", yield_namespace_device_dtype_combinations() + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) -def test_binarizer_array_api_int(array_namespace, device, dtype_name): +def test_binarizer_array_api_int(array_namespace, device_name, dtype_name): # Checks that Binarizer works with integer elements and float threshold - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) for dtype_name_ in [dtype_name, "int32", "int64"]: X_np = np.reshape(np.asarray([0, 1, 2, 3, 4], dtype=dtype_name_), (-1, 1)) X_xp = xp.asarray(X_np, device=device) binarized_np = Binarizer(threshold=2.5).fit_transform(X_np) with config_context(array_api_dispatch=True): binarized_xp = Binarizer(threshold=2.5).fit_transform(X_xp) - assert_array_equal(_convert_to_numpy(binarized_xp, xp), binarized_np) + assert_array_equal(move_to(binarized_xp, xp=np, device="cpu"), binarized_np) def test_center_kernel(): @@ -2409,8 +2413,8 @@ def test_power_transformer_shape_exception(method): def test_power_transformer_lambda_zero(): - pt = PowerTransformer(method="box-cox", standardize=False) X = np.abs(X_2d)[:, 0:1] + pt = PowerTransformer(method="box-cox", standardize=False).fit(X) # Test the lambda = 0 case pt.lambdas_ = np.array([0]) @@ -2454,7 +2458,7 @@ def test_optimization_power_transformer(method, lmbda): # Clip the data here to make sure the inequality is valid. X = np.clip(X, -1 / lmbda + 1e-5, None) - pt = PowerTransformer(method=method, standardize=False) + pt = PowerTransformer(method=method, standardize=False).fit(np.abs(X)) pt.lambdas_ = [lmbda] X_inv = pt.inverse_transform(X) @@ -2468,7 +2472,7 @@ def test_optimization_power_transformer(method, lmbda): def test_invserse_box_cox(): # output nan if the input is invalid - pt = PowerTransformer(method="box-cox", standardize=False) + pt = PowerTransformer(method="box-cox", standardize=False).fit([[1.0], [2.0]]) pt.lambdas_ = [0.5] X_inv = pt.inverse_transform([[-2.1]]) assert np.isnan(X_inv) @@ -2593,7 +2597,7 @@ def test_power_transformer_box_cox_raise_all_nans_col(): @pytest.mark.parametrize( "X_2", - [sparse.random(10, 1, density=0.8, random_state=0)] + [_sparse_random_array((10, 1), density=0.8, rng=0)] + [ csr_container(np.full((10, 1), fill_value=np.nan)) for csr_container in CSR_CONTAINERS @@ -2602,7 +2606,7 @@ def test_power_transformer_box_cox_raise_all_nans_col(): def test_standard_scaler_sparse_partial_fit_finite_variance(X_2): # non-regression test for: # https://github.com/scikit-learn/scikit-learn/issues/16448 - X_1 = sparse.random(5, 1, density=0.8) + X_1 = _sparse_random_array((5, 1), density=0.8) scaler = StandardScaler(with_mean=False) scaler.fit(X_1).partial_fit(X_2) assert np.isfinite(scaler.var_[0]) @@ -2744,7 +2748,7 @@ def test_kernel_centerer_feature_names_out(): @pytest.mark.parametrize("standardize", [True, False]) def test_power_transformer_constant_feature(standardize): - """Check that PowerTransfomer leaves constant features unchanged.""" + """Check that PowerTransformer leaves constant features unchanged.""" X = [[-2, 0, 2], [-2, 0, 2], [-2, 0, 2]] pt = PowerTransformer(method="yeo-johnson", standardize=standardize).fit(X) @@ -2756,7 +2760,7 @@ def test_power_transformer_constant_feature(standardize): for Xt_ in [Xft, Xt]: if standardize: - assert_allclose(Xt_, np.zeros_like(X)) + assert_allclose(Xt_, np.zeros_like(X), atol=1e-14) else: assert_allclose(Xt_, X) @@ -2833,3 +2837,33 @@ def test_yeojohnson_for_different_scipy_version(): """Check that the results are consistent across different SciPy versions.""" pt = PowerTransformer(method="yeo-johnson").fit(X_1col) pt.lambdas_[0] == pytest.approx(0.99546157, rel=1e-7) + + +@pytest.mark.parametrize("TransformerClass", [PowerTransformer, QuantileTransformer]) +def test_transformer_inverse_transform_feature_names_warning(TransformerClass): + """Check that inverse_transform does not raise a warning about feature + names when fitted on a DataFrame and transforming a NumPy array. + + Non-regression test for issue #31947. + """ + pd = pytest.importorskip("pandas") + + X_df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]}) + transformer = TransformerClass() + transformer.fit(X_df) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + transformer.inverse_transform(X_df.to_numpy()) + + +@pytest.mark.parametrize("TransformerClass", [PowerTransformer, QuantileTransformer]) +def test_transformer_inverse_transform_shape_error(TransformerClass): + """Check that an informative error is raised when the input shape is incorrect.""" + X = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + transformer = TransformerClass().fit(X) + + X_wrong = np.array([[1.0], [2.0], [3.0]]) + msg = f"X has 1 features, but {TransformerClass.__name__} is expecting 2 features" + with pytest.raises(ValueError, match=msg): + transformer.inverse_transform(X_wrong) diff --git a/sklearn/preprocessing/tests/test_discretization.py b/sklearn/preprocessing/tests/test_discretization.py index 7463a8608291c..e9892719f92e8 100644 --- a/sklearn/preprocessing/tests/test_discretization.py +++ b/sklearn/preprocessing/tests/test_discretization.py @@ -22,13 +22,13 @@ [ ( "uniform", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [1, 1, 1, 0], [2, 2, 2, 1], [2, 2, 2, 2]], None, ), ( "kmeans", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]], None, ), @@ -40,13 +40,13 @@ ), ( "uniform", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [1, 1, 1, 0], [2, 2, 2, 1], [2, 2, 2, 2]], [1, 1, 2, 1], ), ( "uniform", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [1, 1, 1, 0], [2, 2, 2, 1], [2, 2, 2, 2]], [1, 1, 1, 1], ), @@ -70,13 +70,13 @@ ), ( "kmeans", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [1, 1, 1, 0], [1, 1, 1, 1], [2, 2, 2, 2]], [1, 0, 3, 1], ), ( "kmeans", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2]], [1, 1, 1, 1], ), @@ -145,13 +145,13 @@ def test_invalid_n_bins_array(): [ ( "uniform", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [0, 1, 1, 0], [1, 2, 2, 1], [1, 2, 2, 2]], None, ), ( "kmeans", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [1, 2, 2, 2]], None, ), @@ -187,7 +187,7 @@ def test_invalid_n_bins_array(): ), ( "kmeans", - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default [[0, 0, 0, 0], [0, 1, 1, 0], [1, 1, 1, 1], [1, 2, 2, 2]], [1, 0, 3, 1], ), @@ -328,8 +328,20 @@ def test_encode_options(): @pytest.mark.parametrize( "strategy, quantile_method, expected_2bins, expected_3bins, expected_5bins", [ - ("uniform", "warn", [0, 0, 0, 0, 1, 1], [0, 0, 0, 0, 2, 2], [0, 0, 1, 1, 4, 4]), - ("kmeans", "warn", [0, 0, 0, 0, 1, 1], [0, 0, 1, 1, 2, 2], [0, 0, 1, 2, 3, 4]), + ( + "uniform", + "averaged_inverted_cdf", + [0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 2, 2], + [0, 0, 1, 1, 4, 4], + ), + ( + "kmeans", + "averaged_inverted_cdf", + [0, 0, 0, 0, 1, 1], + [0, 0, 1, 1, 2, 2], + [0, 0, 1, 2, 3, 4], + ), ( "quantile", "averaged_inverted_cdf", @@ -377,7 +389,7 @@ def test_nonuniform_strategies( [0.5, 4.0, -1.5, 0.5], [0.5, 4.0, -1.5, 1.5], ], - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default ), ( "kmeans", @@ -387,7 +399,7 @@ def test_nonuniform_strategies( [-0.125, 3.375, -2.125, 0.5625], [0.75, 4.25, -1.25, 1.625], ], - "warn", # default, will not warn when strategy != "quantile" + "averaged_inverted_cdf", # default ), ( "quantile", @@ -452,7 +464,7 @@ def test_overwrite(): "strategy, expected_bin_edges, quantile_method", [ ("quantile", [0, 1.5, 3], "averaged_inverted_cdf"), - ("kmeans", [0, 1.5, 3], "warn"), + ("kmeans", [0, 1.5, 3], "averaged_inverted_cdf"), ], ) def test_redundant_bins(strategy, expected_bin_edges, quantile_method): @@ -634,20 +646,6 @@ def test_kbinsdiscretizer_subsample(strategy, global_random_seed): ) -def test_quantile_method_future_warnings(): - X = [[-2, 1, -4], [-1, 2, -3], [0, 3, -2], [1, 4, -1]] - with pytest.warns( - FutureWarning, - match="The current default behavior, quantile_method='linear', will be " - "changed to quantile_method='averaged_inverted_cdf' in " - "scikit-learn version 1.9 to naturally support sample weight " - "equivalence properties by default. Pass " - "quantile_method='averaged_inverted_cdf' explicitly to silence this " - "warning.", - ): - KBinsDiscretizer(strategy="quantile").fit(X) - - def test_invalid_quantile_method_with_sample_weight(): X = [[-2, 1, -4], [-1, 2, -3], [0, 3, -2], [1, 4, -1]] expected_msg = ( diff --git a/sklearn/preprocessing/tests/test_encoders.py b/sklearn/preprocessing/tests/test_encoders.py index f843a4f16d170..e172dcafa07b0 100644 --- a/sklearn/preprocessing/tests/test_encoders.py +++ b/sklearn/preprocessing/tests/test_encoders.py @@ -39,6 +39,16 @@ def test_one_hot_encoder_sparse_dense(): assert_array_equal(X_trans_sparse.toarray(), X_trans_dense) +def test_one_hot_encoder_sparse_index_array_int32(): + X = np.array([[3, 2, 1], [0, 1, 1]]) + enc_sparse = OneHotEncoder(sparse_output=True) + + X_trans_sparse = enc_sparse.fit_transform(X) + assert X_trans_sparse.format == "csr" + assert X_trans_sparse.indices.dtype == np.int32 + assert X_trans_sparse.indptr.dtype == np.int32 + + @pytest.mark.parametrize("handle_unknown", ["ignore", "infrequent_if_exist", "warn"]) def test_one_hot_encoder_handle_unknown(handle_unknown): X = np.array([[0, 2, 1], [1, 0, 3], [1, 0, 2]]) @@ -333,7 +343,7 @@ def test_one_hot_encoder_inverse_transform_raise_error_with_unknown( X, X_trans, sparse_ ): """Check that `inverse_transform` raise an error with unknown samples, no - dropped feature, and `handle_unknow="error`. + dropped feature, and `handle_unknown="error`. Non-regression test for: https://github.com/scikit-learn/scikit-learn/issues/14934 """ @@ -821,7 +831,8 @@ def test_ohe_handle_unknown_warn(drop): warn_msg = ( r"Found unknown categories in columns \[0\] during transform. " - r"These unknown categories will be encoded as all zeros" + r"These unknown categories will be encoded as the " + r"infrequent category." ) with pytest.warns(UserWarning, match=warn_msg): X_trans = ohe.transform(X_test) @@ -1383,7 +1394,7 @@ def test_ohe_infrequent_user_cats_unknown_training_errors(kwargs): @pytest.mark.parametrize( "input_dtype, category_dtype", ["OO", "OU", "UO", "UU", "SO", "SU", "SS"] ) -@pytest.mark.parametrize("array_type", ["list", "array", "dataframe"]) +@pytest.mark.parametrize("array_type", ["list", "array", "pandas"]) def test_encoders_string_categories(input_dtype, category_dtype, array_type): """Check that encoding work with object, unicode, and byte string dtypes. Non-regression test for: @@ -1520,11 +1531,18 @@ def test_ohe_drop_first_handle_unknown_ignore_warns(handle_unknown): X_test = [["c", 3]] X_expected = np.array([[0, 0, 0]]) - warn_msg = ( - r"Found unknown categories in columns \[0, 1\] during " - "transform. These unknown categories will be encoded as all " - "zeros" - ) + if handle_unknown == "ignore": + warn_msg = ( + r"Found unknown categories in columns \[0, 1\] during " + r"transform. These unknown categories will be encoded as all " + r"zeros" + ) + else: + warn_msg = ( + r"Found unknown categories in columns \[0, 1\] during " + r"transform. These unknown categories will be encoded as the " + r"infrequent category." + ) with pytest.warns(UserWarning, match=warn_msg): X_trans = ohe.transform(X_test) assert_allclose(X_trans, X_expected) @@ -1557,11 +1575,18 @@ def test_ohe_drop_if_binary_handle_unknown_ignore_warns(handle_unknown): X_test = [["c", 3]] X_expected = np.array([[0, 0, 0, 0]]) - warn_msg = ( - r"Found unknown categories in columns \[0, 1\] during " - "transform. These unknown categories will be encoded as all " - "zeros" - ) + if handle_unknown == "ignore": + warn_msg = ( + r"Found unknown categories in columns \[0, 1\] during " + r"transform. These unknown categories will be encoded as all " + r"zeros" + ) + else: + warn_msg = ( + r"Found unknown categories in columns \[0, 1\] during " + r"transform. These unknown categories will be encoded as the " + r"infrequent category." + ) with pytest.warns(UserWarning, match=warn_msg): X_trans = ohe.transform(X_test) assert_allclose(X_trans, X_expected) @@ -1589,10 +1614,17 @@ def test_ohe_drop_first_explicit_categories(handle_unknown): X_test = [["c", 1]] X_expected = np.array([[0, 0]]) - warn_msg = ( - r"Found unknown categories in columns \[0\] during transform. " - r"These unknown categories will be encoded as all zeros" - ) + if handle_unknown == "ignore": + warn_msg = ( + r"Found unknown categories in columns \[0\] during transform. " + r"These unknown categories will be encoded as all zeros" + ) + else: + warn_msg = ( + r"Found unknown categories in columns \[0\] during transform. " + r"These unknown categories will be encoded as the " + r"infrequent category." + ) with pytest.warns(UserWarning, match=warn_msg): X_trans = ohe.transform(X_test) assert_allclose(X_trans, X_expected) @@ -1920,7 +1952,7 @@ def test_ordinal_encoder_unknown_missing_interaction(): @pytest.mark.parametrize("with_pandas", [True, False]) def test_ordinal_encoder_encoded_missing_value_error(with_pandas): """Check OrdinalEncoder errors when encoded_missing_value is used by - an known category.""" + a known category.""" X = np.array([["a", "dog"], ["b", "cat"], ["c", np.nan]], dtype=object) # The 0-th feature has no missing values so it is not included in the list of @@ -2365,3 +2397,39 @@ def test_encoder_not_fitted(Encoder): encoder = Encoder(categories=[["A", "B", "C"]]) with pytest.raises(NotFittedError): encoder.transform(X) + + +def test_onehotencoder_handle_unknown_warn_maps_to_infrequent(): + """ + Check handle_unknown='warn' behave like 'infrequent_if_exist' and map + to the infrequent category. + """ + + train_data = train_data = np.array( + ["restaurant"] * 3 + ["shop"] * 3 + ["snack"] + ).reshape(-1, 1) + test_data = np.array(["restaurant", "snack", "casino"]).reshape(-1, 1) + + encoder_warn = OneHotEncoder( + handle_unknown="warn", sparse_output=False, min_frequency=2, drop="first" + ) + encoder_warn.fit(train_data) + + encoder_infreq = OneHotEncoder( + handle_unknown="infrequent_if_exist", + sparse_output=False, + min_frequency=2, + drop="first", + ) + + encoder_infreq.fit(train_data) + + warning_match = "unknown categories will be encoded as the infrequent category" + # The warning is raised because `drop is not None`. + with pytest.warns(UserWarning, match=warning_match): + result_infreq = encoder_infreq.transform(test_data) + + with pytest.warns(UserWarning, match=warning_match): + result_warn = encoder_warn.transform(test_data) + + assert_allclose(result_warn[2], result_infreq[2]) diff --git a/sklearn/preprocessing/tests/test_function_transformer.py b/sklearn/preprocessing/tests/test_function_transformer.py index 6bfb5d1367c8d..d6b42fd9dd6f9 100644 --- a/sklearn/preprocessing/tests/test_function_transformer.py +++ b/sklearn/preprocessing/tests/test_function_transformer.py @@ -192,7 +192,7 @@ def test_function_transformer_raise_error_with_mixed_dtype(X_type): dtype = "object" data = ["one", "two", "three", "one", "one", 5, 6] - data = _convert_container(data, X_type, columns_name=["value"], dtype=dtype) + data = _convert_container(data, X_type, column_names=["value"], dtype=dtype) def func(X): return np.array([mapping[X[i]] for i in range(X.size)], dtype=object) @@ -201,7 +201,7 @@ def inverse_func(X): return _convert_container( [inverse_mapping[x] for x in X], X_type, - columns_name=["value"], + column_names=["value"], dtype=dtype, ) @@ -214,7 +214,7 @@ def inverse_func(X): transformer.fit(data) -def test_function_transformer_support_all_nummerical_dataframes_check_inverse_True(): +def test_function_transformer_support_all_numerical_dataframes_check_inverse_True(): """Check support for dataframes with only numerical values.""" pd = pytest.importorskip("pandas") @@ -231,7 +231,7 @@ def test_function_transformer_support_all_nummerical_dataframes_check_inverse_Tr def test_function_transformer_with_dataframe_and_check_inverse_True(): """Check error is raised when check_inverse=True. - Non-regresion test for gh-25261. + Non-regression test for gh-25261. """ pd = pytest.importorskip("pandas") transformer = FunctionTransformer( @@ -468,7 +468,7 @@ def test_set_output_func(): assert isinstance(X_trans, pd.DataFrame) assert_array_equal(X_trans.columns, ["a", "b"]) - # Warning is raised when func returns a ndarray + # Warning is raised when func returns an ndarray ft_np = FunctionTransformer(lambda x: np.asarray(x)) for transform in ("pandas", "polars"): diff --git a/sklearn/preprocessing/tests/test_label.py b/sklearn/preprocessing/tests/test_label.py index 053b474e675bc..2b9f5f1b265ed 100644 --- a/sklearn/preprocessing/tests/test_label.py +++ b/sklearn/preprocessing/tests/test_label.py @@ -12,13 +12,20 @@ label_binarize, ) from sklearn.utils._array_api import ( + _atol_for_type, _convert_to_numpy, - _get_namespace_device_dtype_ids, + _is_numpy_namespace, get_namespace, + indexing_dtype, + move_to, yield_namespace_device_dtype_combinations, ) +from sklearn.utils._array_api import ( + device as array_api_device, +) from sklearn.utils._testing import ( _array_api_for_tests, + assert_allclose, assert_array_equal, ) from sklearn.utils.fixes import ( @@ -224,6 +231,85 @@ def test_label_binarizer_sparse_errors(csr_container): ) +@pytest.mark.parametrize( + "y, classes, expected", + [ + [[1, 0, 0, 1], [0, 1], [[1], [0], [0], [1]]], + [ + [1, 0, 2, 9], + [0, 1, 2, 9], + [[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], + ], + ], +) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_label_binarizer_array_api_compliance( + y, classes, expected, array_namespace, device_name, dtype_name +): + """Test that :class:`LabelBinarizer` works correctly with the array API for binary + and multi-class inputs for numerical labels and non-sparse outputs. + """ + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + + y_np = np.asarray(y) + + with config_context(array_api_dispatch=True): + y = xp.asarray(y, device=device) + + # `sparse_output=True` is not allowed for non-NumPy namespaces. + # Similarly, if `LabelBinarizer` is fitted on a sparse matrix, + # then inverse-transforming non-NumPy arrays is not allowed. + if not _is_numpy_namespace(xp): + sparse_output_msg = "`sparse_output=True` is not supported for array API" + + with pytest.raises(ValueError, match=sparse_output_msg): + LabelBinarizer(sparse_output=True).fit(y) + + lb_np = LabelBinarizer(sparse_output=True).fit(y_np) + with pytest.raises(ValueError, match=sparse_output_msg): + lb_np.transform(y) + + lb_sparse = LabelBinarizer().fit(y_np) + lb_sparse.sparse_input_ = True + sparse_input_msg = ( + "`LabelBinarizer` was fitted on a sparse matrix, and therefore cannot" + ) + with pytest.raises(ValueError, match=sparse_input_msg): + lb_sparse.inverse_transform(xp.asarray(expected, device=device)) + + # Shouldn't raise error in both `fit` and `transform` when `sparse_output=False` + lb_xp = LabelBinarizer() + + binarized = lb_xp.fit_transform(y) + assert get_namespace(binarized)[0].__name__ == xp.__name__ + assert "int" in str(binarized.dtype) + assert array_api_device(binarized) == array_api_device(y) + assert_array_equal( + move_to(binarized, xp=np, device="cpu"), np.asarray(expected) + ) + + fitted_classes = lb_xp.classes_ + assert get_namespace(fitted_classes)[0].__name__ == xp.__name__ + assert array_api_device(fitted_classes) == array_api_device(y) + assert "int" in str(fitted_classes.dtype) + assert_array_equal( + move_to(fitted_classes, xp=np, device="cpu"), np.asarray(classes) + ) + + expected_xp = xp.asarray(expected, device=device) + binarized_inverse = lb_xp.inverse_transform(expected_xp) + assert get_namespace(binarized_inverse)[0].__name__ == xp.__name__ + assert "int" in str(binarized_inverse.dtype) + assert array_api_device(binarized_inverse) == array_api_device(y) + assert_array_equal( + move_to(binarized_inverse, xp=np, device="cpu"), + move_to(y, xp=np, device="cpu"), + ) + + @pytest.mark.parametrize( "values, classes, unknown", [ @@ -673,6 +759,101 @@ def test_invalid_input_label_binarize(): label_binarize([[1, 3]], classes=[1, 2, 3]) +@pytest.mark.parametrize( + "y, classes, expected", + [ + [[1, 0, 0, 1], ["yes", "no"], [[0], [0], [0], [0]]], + [ + [1, 0, 2, 9], + ["bird", "cat", "dog"], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + ], + [[1, 0, 0, 1], [0, 1], [[1], [0], [0], [1]]], + [[1, 0, 2, 1], [0, 1, 2], [[0, 1, 0], [1, 0, 0], [0, 0, 1], [0, 1, 0]]], + ], +) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_label_binarize_array_api_compliance( + y, classes, expected, array_namespace, device_name, dtype_name +): + """Test that :func:`label_binarize` works correctly with the array API for binary + and multi-class inputs for numerical labels and non-sparse outputs. + """ + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + xp_is_numpy = _is_numpy_namespace(xp) + numeric_dtype = np.issubdtype(np.asarray(y).dtype, np.integer) and np.issubdtype( + np.asarray(classes).dtype, np.integer + ) + + with config_context(array_api_dispatch=True): + y = xp.asarray(y, device=device) + + if numeric_dtype: + # `sparse_output=True` is not allowed for non-NumPy namespaces + if not xp_is_numpy: + msg = "`sparse_output=True` is not supported for array API " + with pytest.raises(ValueError, match=msg): + label_binarize(y=y, classes=classes, sparse_output=True) + + # Numeric class labels should not raise any errors for non-NumPy namespaces + binarized = label_binarize(y, classes=classes) + expected = np.asarray(expected, dtype=int) + + assert get_namespace(binarized)[0].__name__ == xp.__name__ + assert array_api_device(binarized) == array_api_device(y) + assert "int" in str(binarized.dtype) + assert_array_equal(move_to(binarized, xp=np, device="cpu"), expected) + + if not xp_is_numpy and not numeric_dtype: + msg = "`classes` contains unsupported dtype for array API " + with pytest.raises(ValueError, match=msg): + label_binarize(y=y, classes=classes) + + +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.parametrize("classes", [[0, 1], [0, 1, 2]]) +def test_label_binarize_unsigned_integer_overflow( + array_namespace, device_name, dtype_name, classes +): + """Ensure label_binarize does not overflow when y has unsigned integer dtype. + + In particular, verify that label_binarize does not wrap -1 to the maximum value + of unsigned integer dtypes. + """ + xp, device = _array_api_for_tests(array_namespace, device_name) + y = classes * 10 + + with config_context(array_api_dispatch=True): + # Stable signed baseline + signed_dtype = indexing_dtype(xp) + y_signed = xp.asarray(y, dtype=signed_dtype, device=device) + desired = label_binarize(y_signed, classes=classes, pos_label=1, neg_label=-1) + + # All namespace support `unit8` dtype + uint_dtypes = [xp.uint8] + + # PyTorch doesn't fully support `uint16`, `uint32`, `uint64`. + # See https://github.com/pytorch/pytorch/issues/58734 + if "torch" not in xp.__name__: + uint_dtypes += [xp.uint16, xp.uint32, xp.uint64] + + for uint_dtype in uint_dtypes: + y_uint = xp.asarray(y, dtype=uint_dtype, device=device) + actual = label_binarize(y_uint, classes=classes, pos_label=1, neg_label=-1) + + assert_allclose( + _convert_to_numpy(actual, xp=xp), + _convert_to_numpy(desired, xp=xp), + atol=_atol_for_type(dtype_name), + ) + + @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_inverse_binarize_multiclass(csr_container): got = _inverse_binarize_multiclass( @@ -708,9 +889,8 @@ def test_label_encoders_do_not_have_set_output(encoder): @pytest.mark.parametrize( - "array_namespace, device, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "y", @@ -720,8 +900,10 @@ def test_label_encoders_do_not_have_set_output(encoder): np.array([3, 5, 9, 5, 9, 3]), ], ) -def test_label_encoder_array_api_compliance(y, array_namespace, device, dtype): - xp = _array_api_for_tests(array_namespace, device) +def test_label_encoder_array_api_compliance( + y, array_namespace, device_name, dtype_name +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) xp_y = xp.asarray(y, device=device) with config_context(array_api_dispatch=True): xp_label = LabelEncoder() @@ -734,9 +916,11 @@ def test_label_encoder_array_api_compliance(y, array_namespace, device, dtype): assert get_namespace(xp_transformed)[0].__name__ == xp.__name__ assert get_namespace(xp_inv_transformed)[0].__name__ == xp.__name__ assert get_namespace(xp_label.classes_)[0].__name__ == xp.__name__ - assert_array_equal(_convert_to_numpy(xp_transformed, xp), np_transformed) - assert_array_equal(_convert_to_numpy(xp_inv_transformed, xp), y) - assert_array_equal(_convert_to_numpy(xp_label.classes_, xp), np_label.classes_) + assert_array_equal(move_to(xp_transformed, xp=np, device="cpu"), np_transformed) + assert_array_equal(move_to(xp_inv_transformed, xp=np, device="cpu"), y) + assert_array_equal( + move_to(xp_label.classes_, xp=np, device="cpu"), np_label.classes_ + ) xp_label = LabelEncoder() np_label = LabelEncoder() @@ -744,5 +928,7 @@ def test_label_encoder_array_api_compliance(y, array_namespace, device, dtype): np_transformed = np_label.fit_transform(y) assert get_namespace(xp_transformed)[0].__name__ == xp.__name__ assert get_namespace(xp_label.classes_)[0].__name__ == xp.__name__ - assert_array_equal(_convert_to_numpy(xp_transformed, xp), np_transformed) - assert_array_equal(_convert_to_numpy(xp_label.classes_, xp), np_label.classes_) + assert_array_equal(move_to(xp_transformed, xp=np, device="cpu"), np_transformed) + assert_array_equal( + move_to(xp_label.classes_, xp=np, device="cpu"), np_label.classes_ + ) diff --git a/sklearn/preprocessing/tests/test_polynomial.py b/sklearn/preprocessing/tests/test_polynomial.py index b24ca11cafbfd..f0bba4e2cf109 100644 --- a/sklearn/preprocessing/tests/test_polynomial.py +++ b/sklearn/preprocessing/tests/test_polynomial.py @@ -20,13 +20,14 @@ _get_sizeof_LARGEST_INT_t, ) from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, _is_numpy_namespace, - device, get_namespace, + move_to, yield_namespace_device_dtype_combinations, ) +from sklearn.utils._array_api import ( + device as array_api_device, +) from sklearn.utils._mask import _get_mask from sklearn.utils._testing import ( _array_api_for_tests, @@ -1332,9 +1333,8 @@ def test_csr_polynomial_expansion_windows_fail(csr_container): @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("interaction_only", [True, False]) @pytest.mark.parametrize("include_bias", [True, False]) @@ -1345,14 +1345,14 @@ def test_polynomial_features_array_api_compliance( include_bias, interaction_only, array_namespace, - device_, + device_name, dtype_name, ): """Test array API compliance for PolynomialFeatures on 2 features up to degree 3.""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X, _ = two_features_degree3 X_np = X.astype(dtype_name) - X_xp = xp.asarray(X_np, device=device_) + X_xp = xp.asarray(X_np, device=device) with config_context(array_api_dispatch=True): tf_np = PolynomialFeatures( degree=degree, include_bias=include_bias, interaction_only=interaction_only @@ -1363,25 +1363,24 @@ def test_polynomial_features_array_api_compliance( ).fit(X_xp) out_np = tf_np.transform(X_np) out_xp = tf_xp.transform(X_xp) - assert_allclose(_convert_to_numpy(out_xp, xp=xp), out_np) + assert_allclose(move_to(out_xp, xp=np, device="cpu"), out_np) assert get_namespace(out_xp)[0].__name__ == xp.__name__ - assert device(out_xp) == device(X_xp) + assert array_api_device(out_xp) == array_api_device(X_xp) assert out_xp.dtype == X_xp.dtype @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_polynomial_features_array_api_raises_on_order_F( - array_namespace, device_, dtype_name + array_namespace, device_name, dtype_name ): """Test that PolynomialFeatures with order='F' raises ValueError on array API namespaces other than numpy.""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X = np.arange(6).reshape((3, 2)).astype(dtype_name) - X_xp = xp.asarray(X, device=device_) + X_xp = xp.asarray(X, device=device) msg = "PolynomialFeatures does not support order='F' for non-numpy arrays" with config_context(array_api_dispatch=True): pf = PolynomialFeatures(order="F").fit(X_xp) diff --git a/sklearn/preprocessing/tests/test_target_encoder.py b/sklearn/preprocessing/tests/test_target_encoder.py index 536f2e031bf77..427dececda9df 100644 --- a/sklearn/preprocessing/tests/test_target_encoder.py +++ b/sklearn/preprocessing/tests/test_target_encoder.py @@ -1,9 +1,11 @@ import re +import warnings import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal +from sklearn.datasets import make_regression from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import Ridge from sklearn.model_selection import ( @@ -20,6 +22,8 @@ LabelEncoder, TargetEncoder, ) +from sklearn.utils.fixes import parse_version +from sklearn.utils.multiclass import type_of_target def _encode_target(X_ordinal, y_numeric, n_categories, smooth): @@ -127,8 +131,7 @@ def test_encoding(categories, unknown_value, global_random_seed, smooth, target_ target_encoder = TargetEncoder( smooth=smooth, categories=categories, - cv=n_splits, - random_state=global_random_seed, + cv=cv, ) X_fit_transform = target_encoder.fit_transform(X_train, y_train) @@ -217,8 +220,7 @@ def test_encoding_multiclass( target_encoder = TargetEncoder( smooth=smooth, - cv=n_splits, - random_state=global_random_seed, + cv=cv, ) X_fit_transform = target_encoder.fit_transform(X_train, y_train) @@ -363,9 +365,10 @@ def test_feature_names_out_set_output(y, feature_names): X_df = pd.DataFrame({"A": ["a", "b"] * 10, "B": [1, 2] * 10}) - enc_default = TargetEncoder(cv=2, smooth=3.0, random_state=0) + cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True) + enc_default = TargetEncoder(cv=cv, smooth=3.0) enc_default.set_output(transform="default") - enc_pandas = TargetEncoder(cv=2, smooth=3.0, random_state=0) + enc_pandas = TargetEncoder(cv=cv, smooth=3.0) enc_pandas.set_output(transform="pandas") X_default = enc_default.fit_transform(X_df, y) @@ -449,7 +452,7 @@ def test_multiple_features_quick(to_pandas, smooth, target_type): dtype=np.float64, ) - enc = TargetEncoder(smooth=smooth, cv=2, random_state=0) + enc = TargetEncoder(smooth=smooth, cv=cv) X_fit_transform = enc.fit_transform(X_train, y_train) assert_allclose(X_fit_transform, expected_X_fit_transform) @@ -476,7 +479,11 @@ def test_constant_target_and_feature(y, y_mean, smooth): X = np.array([[1] * 20]).T n_samples = X.shape[0] - enc = TargetEncoder(cv=2, smooth=smooth, random_state=0) + if type_of_target(y) == "continuous": + cv = KFold(n_splits=2, random_state=0, shuffle=True) + else: + cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True) + enc = TargetEncoder(cv=cv, smooth=smooth) X_trans = enc.fit_transform(X, y) assert_allclose(X_trans, np.repeat([[y_mean]], n_samples, axis=0)) assert enc.encodings_[0][0] == pytest.approx(y_mean) @@ -501,10 +508,12 @@ def test_fit_transform_not_associated_with_y_if_ordinal_categorical_is_not( y_train = y_train[y_sorted_indices] X_train = X_train[y_sorted_indices] - target_encoder = TargetEncoder(shuffle=True, random_state=global_random_seed) + target_encoder = TargetEncoder( + cv=KFold(n_splits=2, random_state=global_random_seed, shuffle=True) + ) X_encoded_train_shuffled = target_encoder.fit_transform(X_train, y_train) - target_encoder = TargetEncoder(shuffle=False) + target_encoder = TargetEncoder(cv=KFold(n_splits=2, shuffle=False)) X_encoded_train_no_shuffled = target_encoder.fit_transform(X_train, y_train) # Check that no information about y_train has leaked into X_train: @@ -538,7 +547,7 @@ def test_smooth_zero(): X = np.array([[0, 0, 0, 0, 0, 1, 1, 1, 1, 1]]).T y = np.array([2.1, 4.3, 1.2, 3.1, 1.0, 9.0, 10.3, 14.2, 13.3, 15.0]) - enc = TargetEncoder(smooth=0.0, shuffle=False, cv=2) + enc = TargetEncoder(smooth=0.0, cv=KFold(n_splits=2, shuffle=False)) X_trans = enc.fit_transform(X, y) # With cv = 2, category 0 does not exist in the second half, thus @@ -575,7 +584,10 @@ def test_invariance_of_encoding_under_label_permutation(smooth, global_random_se X_train_permuted = permutated_labels[X_train.astype(np.int32)] X_test_permuted = permutated_labels[X_test.astype(np.int32)] - target_encoder = TargetEncoder(smooth=smooth, random_state=global_random_seed) + target_encoder = TargetEncoder( + smooth=smooth, + cv=KFold(n_splits=2, shuffle=True, random_state=global_random_seed), + ) X_train_encoded = target_encoder.fit_transform(X_train, y_train) X_test_encoded = target_encoder.transform(X_test) @@ -657,8 +669,9 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed): # Now do the same with target encoding using the internal CV mechanism # implemented when using fit_transform. + cv = KFold(shuffle=True, random_state=rng) model_with_cv = make_pipeline( - TargetEncoder(smooth=smooth, random_state=rng), linear_regression + TargetEncoder(smooth=smooth, cv=cv), linear_regression ).fit(X_train, y_train) # This model should be able to fit the data well and also generalise to the @@ -679,9 +692,7 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed): # Let's now disable the internal cross-validation by calling fit and then # transform separately on the training set: - target_encoder = TargetEncoder(smooth=smooth, random_state=rng).fit( - X_train, y_train - ) + target_encoder = TargetEncoder(smooth=smooth, cv=cv).fit(X_train, y_train) X_enc_no_cv_train = target_encoder.transform(X_train) X_enc_no_cv_test = target_encoder.transform(X_test) model_no_cv = linear_regression.fit(X_enc_no_cv_train, y_train) @@ -709,6 +720,55 @@ def test_pandas_copy_on_write(): Non-regression test for gh-27879. """ pd = pytest.importorskip("pandas", minversion="2.0") - with pd.option_context("mode.copy_on_write", True): + # Pandas currently warns that setting copy_on_write will be removed in pandas 4 + # (and copy-on-write will always be enabled). + # see https://github.com/scikit-learn/scikit-learn/issues/32829 + # TODO: remove this workaround when pandas 4 is our minimum version + if parse_version(pd.__version__) >= parse_version("4.0"): df = pd.DataFrame({"x": ["a", "b", "b"], "y": [4.0, 5.0, 6.0]}) TargetEncoder(target_type="continuous").fit(df[["x"]], df["y"]) + else: + with warnings.catch_warnings(): + expected_message = ( + ".*Copy-on-Write can no longer be disabled.*This option will" + r" be removed in pandas 4\.0" + ) + warnings.filterwarnings( + "ignore", + message=expected_message, + category=DeprecationWarning, + ) + with pd.option_context("mode.copy_on_write", True): + df = pd.DataFrame({"x": ["a", "b", "b"], "y": [4.0, 5.0, 6.0]}) + TargetEncoder(target_type="continuous").fit(df[["x"]], df["y"]) + + +def test_target_encoder_raises_cv_overlap(global_random_seed): + """ + Test that `TargetEncoder` raises if `cv` has overlapping splits. + """ + X, y = make_regression(n_samples=100, n_features=3, random_state=0) + + non_overlapping_iterable = KFold().split(X, y) + encoder = TargetEncoder(cv=non_overlapping_iterable) + encoder.fit_transform(X, y) + + overlapping_iterable = ShuffleSplit( + n_splits=5, random_state=global_random_seed + ).split(X, y) + encoder = TargetEncoder(cv=overlapping_iterable) + msg = "Validation indices from `cv` must cover each sample index exactly once" + with pytest.raises(ValueError, match=msg): + encoder.fit_transform(X, y) + + +# TODO(1.11): remove after deprecation +def test_target_encoder_shuffle_random_state_deprecated(): + X, y = make_regression(n_samples=100, n_features=3, random_state=0) + msg = "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are deprecated" + with pytest.warns(FutureWarning, match=msg): + encoder = TargetEncoder(shuffle=False) + encoder.fit_transform(X, y) + with pytest.warns(FutureWarning, match=msg): + encoder = TargetEncoder(random_state=0) + encoder.fit_transform(X, y) diff --git a/sklearn/random_projection.py b/sklearn/random_projection.py index 389d6da127f89..8aeec73c13f9b 100644 --- a/sklearn/random_projection.py +++ b/sklearn/random_projection.py @@ -40,7 +40,7 @@ _fit_context, ) from sklearn.exceptions import DataDimensionalityWarning -from sklearn.utils import check_random_state +from sklearn.utils import _align_api_if_sparse, check_random_state from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.extmath import safe_sparse_dot from sklearn.utils.random import sample_without_replacement @@ -297,9 +297,10 @@ def _sparse_random_matrix(n_components, n_features, density="auto", random_state data = rng.binomial(1, 0.5, size=np.size(indices)) * 2 - 1 # build the CSR structure by concatenating the rows - components = sp.csr_matrix( + components = sp.csr_array( (data, indices, indptr), shape=(n_components, n_features) ) + components = _align_api_if_sparse(components) return np.sqrt(1 / density) / np.sqrt(n_components) * components @@ -455,10 +456,10 @@ def inverse_transform(self, X): X = check_array(X, dtype=[np.float64, np.float32], accept_sparse=("csr", "csc")) if self.compute_inverse_components: - return X @ self.inverse_components_.T + return _align_api_if_sparse(X @ self.inverse_components_.T) inverse_components = self._compute_inverse_components() - return X @ inverse_components.T + return _align_api_if_sparse(X @ inverse_components.T) def __sklearn_tags__(self): tags = super().__sklearn_tags__() @@ -609,7 +610,7 @@ def transform(self, X): dtype=[np.float64, np.float32], ) - return X @ self.components_.T + return _align_api_if_sparse(X @ self.components_.T) class SparseRandomProjection(BaseRandomProjection): @@ -821,4 +822,6 @@ def transform(self, X): dtype=[np.float64, np.float32], ) - return safe_sparse_dot(X, self.components_.T, dense_output=self.dense_output) + return _align_api_if_sparse( + safe_sparse_dot(X, self.components_.T, dense_output=self.dense_output) + ) diff --git a/sklearn/semi_supervised/_label_propagation.py b/sklearn/semi_supervised/_label_propagation.py index 95dffd212dee0..6986bb1853a13 100644 --- a/sklearn/semi_supervised/_label_propagation.py +++ b/sklearn/semi_supervised/_label_propagation.py @@ -68,6 +68,7 @@ from sklearn.neighbors import NearestNeighbors from sklearn.utils._param_validation import Interval, StrOptions from sklearn.utils.extmath import safe_sparse_dot +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_12 from sklearn.utils.fixes import laplacian as csgraph_laplacian from sklearn.utils.multiclass import check_classification_targets from sklearn.utils.validation import check_is_fitted, validate_data @@ -461,14 +462,17 @@ def _build_graph(self): # handle spmatrix (make normalizer 1D) if sparse.isspmatrix(affinity_matrix): normalizer = np.ravel(normalizer) - # TODO: when SciPy 1.12+ is min dependence, replace up to ---- with: - # affinity_matrix /= normalizer[:, np.newaxis] - if sparse.issparse(affinity_matrix): - inv_normalizer = sparse.diags(1.0 / normalizer) - affinity_matrix = inv_normalizer @ affinity_matrix - else: # Dense affinity_matrix - affinity_matrix /= normalizer[:, np.newaxis] - # ---- + + if SCIPY_VERSION_BELOW_1_12: + if sparse.issparse(affinity_matrix): + inv_normalizer = sparse.diags(1.0 / normalizer) + affinity_matrix = inv_normalizer @ affinity_matrix + else: # Dense affinity_matrix + affinity_matrix /= normalizer[:, np.newaxis] + return affinity_matrix + + # same syntax for sparse or dense + affinity_matrix /= normalizer[:, np.newaxis] return affinity_matrix def fit(self, X, y): diff --git a/sklearn/semi_supervised/_self_training.py b/sklearn/semi_supervised/_self_training.py index 4b69e3defd405..4bdfc9a181dc5 100644 --- a/sklearn/semi_supervised/_self_training.py +++ b/sklearn/semi_supervised/_self_training.py @@ -137,13 +137,13 @@ class SelfTrainingClassifier(ClassifierMixin, MetaEstimatorMixin, BaseEstimator) >>> import numpy as np >>> from sklearn import datasets >>> from sklearn.semi_supervised import SelfTrainingClassifier - >>> from sklearn.svm import SVC + >>> from sklearn.linear_model import LogisticRegression >>> rng = np.random.RandomState(42) >>> iris = datasets.load_iris() >>> random_unlabeled_points = rng.rand(iris.target.shape[0]) < 0.3 >>> iris.target[random_unlabeled_points] = -1 - >>> svc = SVC(probability=True, gamma="auto") - >>> self_training_model = SelfTrainingClassifier(svc) + >>> clf = LogisticRegression() + >>> self_training_model = SelfTrainingClassifier(clf) >>> self_training_model.fit(iris.data, iris.target) SelfTrainingClassifier(...) """ diff --git a/sklearn/semi_supervised/tests/test_self_training.py b/sklearn/semi_supervised/tests/test_self_training.py index 26b6feff6ab2a..3004f49bda48d 100644 --- a/sklearn/semi_supervised/tests/test_self_training.py +++ b/sklearn/semi_supervised/tests/test_self_training.py @@ -5,6 +5,7 @@ from numpy.testing import assert_array_equal from sklearn.base import clone +from sklearn.calibration import CalibratedClassifierCV from sklearn.datasets import load_iris, make_blobs from sklearn.ensemble import StackingClassifier from sklearn.exceptions import NotFittedError @@ -116,7 +117,7 @@ def test_k_best(): def test_sanity_classification(): - estimator = SVC(gamma="scale", probability=True) + estimator = CalibratedClassifierCV(SVC(gamma="scale"), ensemble=False) estimator.fit(X_train[n_labeled_samples:], y_train[n_labeled_samples:]) st = SelfTrainingClassifier(estimator) @@ -142,7 +143,10 @@ def test_none_iter(): @pytest.mark.parametrize( "estimator", - [KNeighborsClassifier(), SVC(gamma="scale", probability=True, random_state=0)], + [ + KNeighborsClassifier(), + CalibratedClassifierCV(SVC(gamma="scale", random_state=0), ensemble=False), + ], ) @pytest.mark.parametrize("y", [y_train_missing_labels, y_train_missing_strings]) def test_zero_iterations(estimator, y): @@ -205,8 +209,8 @@ def test_no_unlabeled(): def test_early_stopping(): - svc = SVC(gamma="scale", probability=True) - st = SelfTrainingClassifier(svc) + lr = LogisticRegression() + st = SelfTrainingClassifier(lr) X_train_easy = [[1], [0], [1], [0.5]] y_train_easy = [1, 0, -1, -1] # X = [[0.5]] cannot be predicted on with a high confidence, so training @@ -294,10 +298,10 @@ def test_estimator_meta_estimator(): estimator = StackingClassifier( estimators=[ - ("svc_1", SVC(probability=True)), - ("svc_2", SVC(probability=True)), + ("clf_1", LogisticRegression()), + ("clf_2", LogisticRegression()), ], - final_estimator=SVC(probability=True), + final_estimator=LogisticRegression(), cv=2, ) @@ -308,10 +312,10 @@ def test_estimator_meta_estimator(): estimator = StackingClassifier( estimators=[ - ("svc_1", SVC(probability=False)), - ("svc_2", SVC(probability=False)), + ("svc_1", SVC()), + ("svc_2", SVC()), ], - final_estimator=SVC(probability=False), + final_estimator=SVC(), cv=2, ) @@ -332,7 +336,7 @@ def test_self_training_estimator_attribute_error(): # `SVC` with `probability=False` does not implement 'predict_proba' that # is required internally in `fit` of `SelfTrainingClassifier`. We expect # an AttributeError to be raised. - estimator = SVC(probability=False, gamma="scale") + estimator = SVC(gamma="scale") self_training = SelfTrainingClassifier(estimator) with pytest.raises(AttributeError, match="has no attribute 'predict_proba'"): diff --git a/sklearn/svm/_base.py b/sklearn/svm/_base.py index 693967182ec81..ec4da689dc1c2 100644 --- a/sklearn/svm/_base.py +++ b/sklearn/svm/_base.py @@ -22,9 +22,12 @@ check_random_state, column_or_1d, compute_class_weight, + deprecated, ) -from sklearn.utils._param_validation import Interval, StrOptions +from sklearn.utils._param_validation import Hidden, Interval, StrOptions +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.extmath import safe_sparse_dot +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_12 from sklearn.utils.metaestimators import available_if from sklearn.utils.multiclass import ( _ovr_decision_function, @@ -64,10 +67,16 @@ def _one_vs_one_coef(dual_coef, n_support, support_vectors): # SVs for class1: sv2 = support_vectors[sv_locs[class2] : sv_locs[class2 + 1], :] - # dual coef for class1 SVs: - alpha1 = dual_coef[class2 - 1, sv_locs[class1] : sv_locs[class1 + 1]] - # dual coef for class2 SVs: - alpha2 = dual_coef[class1, sv_locs[class2] : sv_locs[class2 + 1]] + if SCIPY_VERSION_BELOW_1_12: + # dual coef for class1 SVs: + alpha1 = dual_coef[[class2 - 1], sv_locs[class1] : sv_locs[class1 + 1]] + # dual coef for class2 SVs: + alpha2 = dual_coef[[class1], sv_locs[class2] : sv_locs[class2 + 1]] + else: + # dual coef for class1 SVs: + alpha1 = dual_coef[class2 - 1, sv_locs[class1] : sv_locs[class1 + 1]] + # dual coef for class2 SVs: + alpha2 = dual_coef[class1, sv_locs[class2] : sv_locs[class2 + 1]] # build weight for class1 vs class2 coef.append(safe_sparse_dot(alpha1, sv1) + safe_sparse_dot(alpha2, sv2)) @@ -98,7 +107,7 @@ class BaseLibSVM(BaseEstimator, metaclass=ABCMeta): "nu": [Interval(Real, 0.0, 1.0, closed="right")], "epsilon": [Interval(Real, 0.0, None, closed="left")], "shrinking": ["boolean"], - "probability": ["boolean"], + "probability": ["boolean", Hidden(StrOptions({"deprecated"}))], "cache_size": [Interval(Real, 0, None, closed="neither")], "class_weight": [StrOptions({"balanced"}), dict, None], "verbose": ["verbose"], @@ -187,7 +196,7 @@ def fit(self, X, y, sample_weight=None): Notes ----- If X and y are not C-ordered and contiguous arrays of np.float64 and - X is not a scipy.sparse.csr_matrix, X and/or y may be copied. + X is not a sparse CSR format, X and/or y may be copied. If X is a dense array, then the other methods will not support sparse matrices as input. @@ -219,6 +228,24 @@ def fit(self, X, y, sample_weight=None): ) solver_type = LIBSVM_IMPL.index(self._impl) + # TODO(1.11): remove probability + self._effective_probability = self.probability + if self._impl in ["c_svc", "nu_svc"]: + if self._impl == "nu_scv": + est_dep = "NuSVC" + else: + est_dep = "SVC" + if self.probability != "deprecated": + warnings.warn( + f"The `probability` parameter was deprecated in 1.9 and " + f"will be removed in version 1.11. " + f"Use `CalibratedClassifierCV({est_dep}(), ensemble=False)` " + f"instead of `{est_dep}(probability=True)`", + FutureWarning, + ) + else: + self._effective_probability = False + # input validation n_samples = _num_samples(X) if solver_type != 2 and n_samples != y.shape[0]: @@ -350,7 +377,7 @@ def _dense_fit(self, X, y, sample_weight, solver_type, kernel, random_seed): kernel=kernel, C=self.C, nu=self.nu, - probability=self.probability, + probability=self._effective_probability, degree=self.degree, shrinking=self.shrinking, tol=self.tol, @@ -401,7 +428,7 @@ def _sparse_fit(self, X, y, sample_weight, solver_type, kernel, random_seed): self.cache_size, self.epsilon, int(self.shrinking), - int(self.probability), + int(self._effective_probability), self.max_iter, random_seed, ) @@ -416,13 +443,16 @@ def _sparse_fit(self, X, y, sample_weight, solver_type, kernel, random_seed): dual_coef_indices = np.tile(np.arange(n_SV), n_class) if not n_SV: - self.dual_coef_ = sp.csr_matrix([]) + self.dual_coef_ = _align_api_if_sparse(sp.csr_array([[]])) else: dual_coef_indptr = np.arange( 0, dual_coef_indices.size + 1, dual_coef_indices.size / n_class ) - self.dual_coef_ = sp.csr_matrix( - (dual_coef_data, dual_coef_indices, dual_coef_indptr), (n_class, n_SV) + self.dual_coef_ = _align_api_if_sparse( + sp.csr_array( + (dual_coef_data, dual_coef_indices, dual_coef_indptr), + (n_class, n_SV), + ) ) def predict(self, X): @@ -480,7 +510,7 @@ def _dense_predict(self, X): ) def _sparse_predict(self, X): - # Precondition: X is a csr_matrix of dtype np.float64. + # Precondition: X is CSR sparse of dtype np.float64. kernel = self.kernel if callable(kernel): kernel = "precomputed" @@ -509,7 +539,7 @@ def _sparse_predict(self, X): self.nu, self.epsilon, self.shrinking, - self.probability, + self._effective_probability, self._n_support, self._probA, self._probB, @@ -609,7 +639,7 @@ def _sparse_decision_function(self, X): self.nu, self.epsilon, self.shrinking, - self.probability, + self._effective_probability, self._n_support, self._probA, self._probB, @@ -629,9 +659,9 @@ def _validate_for_predict(self, X): reset=False, ) - if self._sparse and not sp.issparse(X): - X = sp.csr_matrix(X) if self._sparse: + if not sp.issparse(X): + X = _align_api_if_sparse(sp.csr_array(X)) X.sort_indices() if sp.issparse(X) and not self._sparse and not callable(self.kernel): @@ -835,7 +865,7 @@ def predict(self, X): # probabilities are not available depending on a setting, introduce two # estimators. def _check_proba(self): - if not self.probability: + if self.probability == "deprecated" or not self.probability: raise AttributeError( "predict_proba is not available when probability=False" ) @@ -871,7 +901,7 @@ def predict_proba(self, X): datasets. """ X = self._validate_for_predict(X) - if self.probA_.size == 0 or self.probB_.size == 0: + if self._probA.size == 0 or self._probB.size == 0: raise NotFittedError( "predict_proba is not available when fitted with probability=False" ) @@ -966,7 +996,7 @@ def _sparse_predict_proba(self, X): self.nu, self.epsilon, self.shrinking, - self.probability, + self._effective_probability, self._n_support, self._probA, self._probB, @@ -988,6 +1018,11 @@ def _get_coef(self): return coef + @deprecated( # type: ignore[prop-decorator] + "Attribute `probA_` was deprecated in version 1.9 and will be removed in " + "1.11 as the `probability=True` option for SVC and NuSVC was deprecated " + "and will be removed in 1.11." + ) @property def probA_(self): """Parameter learned in Platt scaling when `probability=True`. @@ -998,6 +1033,11 @@ def probA_(self): """ return self._probA + @deprecated( # type: ignore[prop-decorator] + "Attribute `probB_` was deprecated in version 1.9 and will be removed in " + "1.11 as the `probability=True` option for SVC and NuSVC was deprecated " + "and will be removed in 1.11." + ) @property def probB_(self): """Parameter learned in Platt scaling when `probability=True`. diff --git a/sklearn/svm/_classes.py b/sklearn/svm/_classes.py index aa216fcc1b0f0..54ce5b4feec54 100644 --- a/sklearn/svm/_classes.py +++ b/sklearn/svm/_classes.py @@ -690,6 +690,11 @@ class SVC(BaseSVC): 5-fold cross-validation, and `predict_proba` may be inconsistent with `predict`. Read more in the :ref:`User Guide <scores_probabilities>`. + ..deprecated:: 1.9 + The `probability` parameter is deprecated and will be removed in 1.11. + Use `CalibratedClassifierCV(SVC(), ensemble=False)` instead of + `SVC(probability=True)`. + tol : float, default=1e-3 Tolerance for stopping criterion. @@ -806,16 +811,23 @@ class SVC(BaseSVC): Number of support vectors for each class. probA_ : ndarray of shape (n_classes * (n_classes - 1) / 2) - probB_ : ndarray of shape (n_classes * (n_classes - 1) / 2) If `probability=True`, it corresponds to the parameters learned in Platt scaling to produce probability estimates from decision values. If `probability=False`, it's an empty array. Platt scaling uses the logistic function + + probB_ : ndarray of shape (n_classes * (n_classes - 1) / 2) + If `probability=True`, it corresponds to the parameters learned in + Platt scaling. Platt scaling uses the logistic function ``1 / (1 + exp(decision_value * probA_ + probB_))`` where ``probA_`` and ``probB_`` are learned from the dataset [2]_. For more information on the multiclass case and training procedure see section 8 of [1]_. + .. deprecated:: 1.9 + The attributes `probA_` and `probB_` are deprecated in version 1.9 and will + be removed in 1.11. + shape_fit_ : tuple of int of shape (n_dimensions_of_X,) Array dimensions of training vector ``X``. @@ -867,7 +879,7 @@ def __init__( gamma="scale", coef0=0.0, shrinking=True, - probability=False, + probability="deprecated", tol=1e-3, cache_size=200, class_weight=None, @@ -951,6 +963,11 @@ class NuSVC(BaseSVC): 5-fold cross-validation, and `predict_proba` may be inconsistent with `predict`. Read more in the :ref:`User Guide <scores_probabilities>`. + ..deprecated:: 1.9 + The `probability` parameter is deprecated and will be removed in version 1.11. + Use `CalibratedClassifierCV(NuSVC(), ensemble=False)` + instead of `NuSVC(probability=True)`. + tol : float, default=1e-3 Tolerance for stopping criterion. @@ -1068,6 +1085,7 @@ class NuSVC(BaseSVC): 0 if correctly fitted, 1 if the algorithm did not converge. probA_ : ndarray of shape (n_classes * (n_classes - 1) / 2,) + If `probability=True`, parameters learned in Platt scaling. probB_ : ndarray of shape (n_classes * (n_classes - 1) / 2,) If `probability=True`, it corresponds to the parameters learned in @@ -1079,6 +1097,10 @@ class NuSVC(BaseSVC): more information on the multiclass case and training procedure see section 8 of [1]_. + .. deprecated:: 1.9 + The attributes `probA_` and `probB_` are deprecated in version 1.9 and will + be removed in 1.11. + shape_fit_ : tuple of int of shape (n_dimensions_of_X,) Array dimensions of training vector ``X``. @@ -1130,7 +1152,7 @@ def __init__( gamma="scale", coef0=0.0, shrinking=True, - probability=False, + probability="deprecated", tol=1e-3, cache_size=200, class_weight=None, diff --git a/sklearn/svm/_liblinear.pyx b/sklearn/svm/_liblinear.pyx index 4ca05d4b5c9d3..137ec2d288179 100644 --- a/sklearn/svm/_liblinear.pyx +++ b/sklearn/svm/_liblinear.pyx @@ -1,9 +1,10 @@ """ Wrapper for liblinear - -Author: fabian.pedregosa@inria.fr """ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + import numpy as np from sklearn.utils._cython_blas cimport _dot, _axpy, _scal, _nrm2 diff --git a/sklearn/svm/_libsvm_sparse.pyx b/sklearn/svm/_libsvm_sparse.pyx index 1e2c35e0f8dc7..df7ddb0d52823 100644 --- a/sklearn/svm/_libsvm_sparse.pyx +++ b/sklearn/svm/_libsvm_sparse.pyx @@ -1,6 +1,7 @@ import numpy as np from scipy import sparse from sklearn.utils._cython_blas cimport _dot +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils._typedefs cimport float64_t, int32_t, intp_t cdef extern from *: @@ -215,9 +216,9 @@ def libsvm_sparse_train (int n_features, model, n_features, ) - support_vectors_ = sparse.csr_matrix( + support_vectors_ = _align_api_if_sparse(sparse.csr_array( (SV_data, SV_indices, SV_indptr), (SV_len, n_features) - ) + )) # copy model.nSV # TODO: do only in classification diff --git a/sklearn/svm/src/libsvm/svm.cpp b/sklearn/svm/src/libsvm/svm.cpp index be05e7ece5539..4072c89edba32 100644 --- a/sklearn/svm/src/libsvm/svm.cpp +++ b/sklearn/svm/src/libsvm/svm.cpp @@ -3137,7 +3137,8 @@ const char *PREFIX(check_parameter)(const PREFIX(problem) *prob, const svm_param if(svm_type == C_SVC || svm_type == EPSILON_SVR || svm_type == NU_SVR || - svm_type == ONE_CLASS) + svm_type == ONE_CLASS || + svm_type == NU_SVC) { PREFIX(problem) newprob; // filter samples with negative and null weights diff --git a/sklearn/svm/tests/test_bounds.py b/sklearn/svm/tests/test_bounds.py index d226a2ae36aeb..dce08b0866bce 100644 --- a/sklearn/svm/tests/test_bounds.py +++ b/sklearn/svm/tests/test_bounds.py @@ -105,7 +105,7 @@ def test_newrand_bounded_rand_int(range_, n_pts): sample = [bounded_rand_int_wrap(range_) for _ in range(n_pts)] res = stats.kstest(sample, uniform_dist.cdf) ks_pvals.append(res.pvalue) - # Null hypothesis = samples come from an uniform distribution. + # Null hypothesis = samples come from a uniform distribution. # Under the null hypothesis, p-values should be uniformly distributed # and not concentrated on low values # (this may seem counter-intuitive but is backed by multiple refs) diff --git a/sklearn/svm/tests/test_sparse.py b/sklearn/svm/tests/test_sparse.py index 7b9012ded8aba..60aa74e344bb8 100644 --- a/sklearn/svm/tests/test_sparse.py +++ b/sklearn/svm/tests/test_sparse.py @@ -94,6 +94,7 @@ def check_svm_model_equal(dense_svm, X_train, y_train, X_test): # XXX: probability=True is not thread-safe: # https://github.com/scikit-learn/scikit-learn/issues/31885 +# TODO(1.11): remove probability=True and adapt check_svm_model_equal accordingly. @pytest.mark.thread_unsafe @skip_if_32bit @pytest.mark.parametrize( @@ -107,6 +108,7 @@ def check_svm_model_equal(dense_svm, X_train, y_train, X_test): ) @pytest.mark.parametrize("kernel", ["linear", "poly", "rbf", "sigmoid"]) @pytest.mark.parametrize("sparse_container", CSR_CONTAINERS + LIL_CONTAINERS) +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_svc(X_train, y_train, X_test, kernel, sparse_container): """Check that sparse SVC gives the same result as SVC.""" X_train = sparse_container(X_train) @@ -122,6 +124,8 @@ def test_svc(X_train, y_train, X_test, kernel, sparse_container): @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) +# TODO(1.11): remove probability=True and calls to predict_proba. +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_unsorted_indices(csr_container): # test that the result with sorted and unsorted indices in csr is the same # we use a subset of digits as iris, blobs or make_classification didn't @@ -458,6 +462,8 @@ def test_sparse_realdata(csr_container): @pytest.mark.parametrize("lil_container", LIL_CONTAINERS) +# TODO(1.11): remove probability=True and calls to predict_proba. +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_sparse_svc_clone_with_callable_kernel(lil_container): # Test that the "dense_fit" is called even though we use sparse input # meaning that everything works fine. @@ -479,9 +485,7 @@ def test_sparse_svc_clone_with_callable_kernel(lil_container): @pytest.mark.parametrize("lil_container", LIL_CONTAINERS) def test_timeout(lil_container): - sp = svm.SVC( - C=1, kernel=lambda x, y: x @ y.T, probability=True, random_state=0, max_iter=1 - ) + sp = svm.SVC(C=1, kernel=lambda x, y: x @ y.T, random_state=0, max_iter=1) warning_msg = ( r"Solver terminated early \(max_iter=1\). Consider pre-processing " r"your data with StandardScaler or MinMaxScaler." @@ -490,14 +494,11 @@ def test_timeout(lil_container): sp.fit(lil_container(X), Y) -# XXX: probability=True is not thread-safe: -# https://github.com/scikit-learn/scikit-learn/issues/31885 -@pytest.mark.thread_unsafe -def test_consistent_proba(): - a = svm.SVC(probability=True, max_iter=1, random_state=0) +def test_consistent_decision_function(): + a = svm.SVC(max_iter=1, random_state=0) with ignore_warnings(category=ConvergenceWarning): - proba_1 = a.fit(X, Y).predict_proba(X) - a = svm.SVC(probability=True, max_iter=1, random_state=0) + proba_1 = a.fit(X, Y).decision_function(X) + a = svm.SVC(max_iter=1, random_state=0) with ignore_warnings(category=ConvergenceWarning): - proba_2 = a.fit(X, Y).predict_proba(X) + proba_2 = a.fit(X, Y).decision_function(X) assert_allclose(proba_1, proba_2) diff --git a/sklearn/svm/tests/test_svm.py b/sklearn/svm/tests/test_svm.py index 1da2c74d3f07d..2a26450ed6007 100644 --- a/sklearn/svm/tests/test_svm.py +++ b/sklearn/svm/tests/test_svm.py @@ -66,6 +66,7 @@ def test_libsvm_parameters(): # XXX: this test is thread-unsafe because it uses _libsvm.cross_validation: # https://github.com/scikit-learn/scikit-learn/issues/31885 +# TODO: investigate why assertion on L148 fails. @pytest.mark.thread_unsafe def test_libsvm_iris(global_random_seed): # Check consistency on dataset iris. @@ -378,7 +379,9 @@ def test_tweak_params(): # XXX: this test is thread-unsafe because it uses probability=True: # https://github.com/scikit-learn/scikit-learn/issues/31885 +# TODO(1.11): remove this test entirely @pytest.mark.thread_unsafe +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_probability(global_random_seed): # Predict probabilities using SVC # This uses cross validation, so we use a slightly bigger testing set. @@ -593,7 +596,11 @@ def test_svm_equivalence_sample_weight_C(): "Estimator, err_msg", [ (svm.SVC, "Invalid input - all samples have zero or negative weights."), - (svm.NuSVC, "(negative dimensions are not allowed|nu is infeasible)"), + ( + svm.NuSVC, + "(Invalid input - all samples have zero or negative weights.|nu is" + " infeasible)", + ), (svm.SVR, "Invalid input - all samples have zero or negative weights."), (svm.NuSVR, "Invalid input - all samples have zero or negative weights."), (svm.OneClassSVM, "Invalid input - all samples have zero or negative weights."), @@ -764,18 +771,6 @@ def test_svc_nonfinite_params(global_random_seed): clf.fit(X, y) -def test_unicode_kernel(global_random_seed): - # Test that a unicode kernel name does not cause a TypeError - iris = get_iris_dataset(global_random_seed) - - clf = svm.SVC(kernel="linear", probability=True) - clf.fit(X, Y) - clf.predict_proba(T) - _libsvm.cross_validation( - iris.data, iris.target.astype(np.float64), 5, kernel="linear", random_seed=0 - ) - - @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_sparse_precomputed(csr_container): clf = svm.SVC(kernel="precomputed") @@ -1052,9 +1047,6 @@ def test_linearsvc_verbose(): os.dup2(stdout, 1) # restore original stdout -# XXX: this test is thread-unsafe because it uses probability=True: -# https://github.com/scikit-learn/scikit-learn/issues/31885 -@pytest.mark.thread_unsafe def test_svc_clone_with_callable_kernel(): iris = get_iris_dataset(42) @@ -1062,7 +1054,6 @@ def test_svc_clone_with_callable_kernel(): # as with built-in linear kernel svm_callable = svm.SVC( kernel=lambda x, y: np.dot(x, y.T), - probability=True, random_state=0, decision_function_shape="ovr", ) @@ -1072,21 +1063,16 @@ def test_svc_clone_with_callable_kernel(): svm_builtin = svm.SVC( kernel="linear", - probability=True, random_state=0, decision_function_shape="ovr", ) + svm_builtin.fit(iris.data, iris.target) assert_array_almost_equal(svm_cloned.dual_coef_, svm_builtin.dual_coef_) assert_array_almost_equal(svm_cloned.intercept_, svm_builtin.intercept_) assert_array_equal(svm_cloned.predict(iris.data), svm_builtin.predict(iris.data)) - assert_array_almost_equal( - svm_cloned.predict_proba(iris.data), - svm_builtin.predict_proba(iris.data), - decimal=4, - ) assert_array_almost_equal( svm_cloned.decision_function(iris.data), svm_builtin.decision_function(iris.data), @@ -1099,13 +1085,9 @@ def test_svc_bad_kernel(): svc.fit(X, Y) -# XXX: this test is thread-unsafe because it uses probability=True: -# https://github.com/scikit-learn/scikit-learn/issues/31885 -@pytest.mark.thread_unsafe def test_libsvm_convergence_warnings(global_random_seed): a = svm.SVC( kernel=lambda x, y: np.dot(x, y.T), - probability=True, random_state=global_random_seed, max_iter=2, ) @@ -1130,16 +1112,11 @@ def test_unfitted(): clf.predict(X) -# ignore convergence warnings from max_iter=1 -# XXX: this test is thread-unsafe because it uses probability=True: -# https://github.com/scikit-learn/scikit-learn/issues/31885 -@pytest.mark.thread_unsafe -@pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning") -def test_consistent_proba(global_random_seed): - a = svm.SVC(probability=True, max_iter=1, random_state=global_random_seed) - proba_1 = a.fit(X, Y).predict_proba(X) - a = svm.SVC(probability=True, max_iter=1, random_state=global_random_seed) - proba_2 = a.fit(X, Y).predict_proba(X) +def test_consistent_decision_function(global_random_seed): + a = svm.SVC(max_iter=1, random_state=global_random_seed) + proba_1 = a.fit(X, Y).decision_function(X) + a = svm.SVC(max_iter=1, random_state=global_random_seed) + proba_2 = a.fit(X, Y).decision_function(X) assert_array_almost_equal(proba_1, proba_2) @@ -1190,6 +1167,8 @@ def test_lsvc_intercept_scaling_zero(): assert lsvc.intercept_ == 0.0 +# TODO(1.11): remove test entirely. +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_hasattr_predict_proba(global_random_seed): iris = get_iris_dataset(global_random_seed) @@ -1534,3 +1513,25 @@ def test_svm_with_infinite_C(Estimator, make_dataset, C_inf, global_random_seed) estimator_C_large = Estimator(C=1e10).fit(X, y) assert_allclose(estimator_C_large.predict(X), estimator_C_inf.predict(X)) + + +@pytest.mark.parametrize( + "Estimator, name", + [(svm.SVC, "SVC"), (svm.NuSVC, "NuSVC")], +) +@pytest.mark.parametrize("probability", [True, False]) +def test_probability_raises_futurewarning(Estimator, name, probability): + X, y = make_classification() + with pytest.warns(FutureWarning, match="probability.+parameter.+deprecated"): + Estimator(probability=probability).fit(X, y) + + +@pytest.mark.parametrize("est", [svm.SVC, svm.NuSVC]) +def test_svc_nusvc_probA_probB_deprecated(est): + """Test that accessing probA_ and probB_ raises FutureWarning for SVC and NuSVC.""" + X, y = make_classification(n_samples=50, n_informative=5, random_state=0) + est = est().fit(X, y) + with pytest.warns(FutureWarning, match="Attribute `probA_` was deprecated"): + _ = est.probA_ + with pytest.warns(FutureWarning, match="Attribute `probB_` was deprecated"): + _ = est.probB_ diff --git a/sklearn/tests/metadata_routing_common.py b/sklearn/tests/metadata_routing_common.py index a0e2c07b5e07e..3c56dbca2da58 100644 --- a/sklearn/tests/metadata_routing_common.py +++ b/sklearn/tests/metadata_routing_common.py @@ -15,7 +15,7 @@ ) from sklearn.metrics._scorer import _Scorer, mean_squared_error from sklearn.model_selection import BaseCrossValidator -from sklearn.model_selection._split import GroupsConsumerMixin +from sklearn.model_selection._split import GroupKFold, GroupsConsumerMixin from sklearn.utils._metadata_requests import ( SIMPLE_METHODS, ) @@ -480,6 +480,11 @@ def _iter_test_indices(self, X=None, y=None, groups=None): yield train_indices +class ConsumingSplitterInheritingFromGroupKFold(ConsumingSplitter, GroupKFold): + """Helper class that can be used to test TargetEncoder, that only takes specific + splitters.""" + + class MetaRegressor(MetaEstimatorMixin, RegressorMixin, BaseEstimator): """A meta-regressor which is only a router.""" diff --git a/sklearn/tests/test_base.py b/sklearn/tests/test_base.py index cf55bb71c6987..2418270513d0f 100644 --- a/sklearn/tests/test_base.py +++ b/sklearn/tests/test_base.py @@ -189,6 +189,10 @@ def test_clone_empty_array(): clf2 = clone(clf) assert_array_equal(clf.empty.data, clf2.empty.data) + clf = MyEstimator(empty=sp.csr_array(np.array([[0]]))) + clf2 = clone(clf) + assert_array_equal(clf.empty.data, clf2.empty.data) + def test_clone_nan(): # Regression test for cloning estimators with default parameter as np.nan @@ -209,7 +213,8 @@ def test_clone_sparse_matrices(): sparse_matrix_classes = [ cls for name in dir(sp) - if name.endswith("_matrix") and type(cls := getattr(sp, name)) is type + if name.endswith("_matrix") or name.endswith("_array") + if type(cls := getattr(sp, name)) is type ] for cls in sparse_matrix_classes: @@ -237,6 +242,7 @@ def test_clone_class_rather_than_instance(): clone(MyEstimator) +# TODO (1.11): remove svc test for predict_proba after it is deprecated def test_conditional_attrs_not_in_dir(): # Test that __dir__ includes only relevant attributes. #28558 @@ -760,7 +766,7 @@ def transform(self, X): with pytest.raises(ValueError, match=msg): trans.transform(df_bad) - # warns when fitted on dataframe and transforming a ndarray + # warns when fitted on dataframe and transforming an ndarray msg = ( "X does not have valid feature names, but NoOpTransformer was " "fitted with feature names" @@ -768,7 +774,7 @@ def transform(self, X): with pytest.warns(UserWarning, match=msg): trans.transform(X_np) - # warns when fitted on a ndarray and transforming dataframe + # warns when fitted on an ndarray and transforming dataframe msg = "X has feature names, but NoOpTransformer was fitted without feature names" trans = NoOpTransformer().fit(X_np) with pytest.warns(UserWarning, match=msg): @@ -908,17 +914,17 @@ class Estimator(BaseEstimator, WithSlots): @pytest.mark.parametrize( "constructor_name, minversion", [ - ("dataframe", "1.5.0"), - ("pyarrow", "12.0.0"), + ("pandas", "1.5.0"), + ("pyarrow", "13.0.0"), ("polars", "0.20.23"), ], ) -def test_dataframe_protocol(constructor_name, minversion): - """Uses the dataframe exchange protocol to get feature names.""" +def test_feature_names_in_on_dataframes(constructor_name, minversion): + """Test that feature_names_in_ is correctly set for dataframe X.""" data = [[1, 4, 2], [3, 3, 6]] columns = ["col_0", "col_1", "col_2"] df = _convert_container( - data, constructor_name, columns_name=columns, minversion=minversion + data, constructor_name, column_names=columns, minversion=minversion ) class NoOpTransformer(TransformerMixin, BaseEstimator): @@ -940,7 +946,7 @@ def transform(self, X): assert_allclose(df, X_out) bad_names = ["a", "b", "c"] - df_bad = _convert_container(data, constructor_name, columns_name=bad_names) + df_bad = _convert_container(data, constructor_name, column_names=bad_names) with pytest.raises(ValueError, match="The feature names should match"): no_op.transform(df_bad) @@ -1005,6 +1011,21 @@ def test_get_params_html(): assert est._get_params_html().non_default == ("empty",) +def test_get_fitted_attr_html(): + """Check the behaviour of the `_get_fitted_attr_html` method.""" + X = np.array([[-1, -1], [-2, -1], [-3, -2]]) + pca = PCA().fit(X) + pca._not_a_fitted_attr = "x" + + fitted_attr_html = pca._get_fitted_attr_html() + assert fitted_attr_html["n_features_in_"] == {"type_name": "int", "value": 2} + assert pca._not_a_fitted_attr not in fitted_attr_html + assert len(fitted_attr_html) == 9 + assert fitted_attr_html["components_"]["type_name"] == ("ndarray") + assert fitted_attr_html["components_"]["shape"] == (2, 2) + assert_allclose(fitted_attr_html["components_"]["value"], pca.components_) + + def make_estimator_with_param(default_value): class DynamicEstimator(BaseEstimator): def __init__(self, param=default_value): diff --git a/sklearn/tests/test_build.py b/sklearn/tests/test_build.py index 40a960cba6283..1199e589556f7 100644 --- a/sklearn/tests/test_build.py +++ b/sklearn/tests/test_build.py @@ -1,12 +1,73 @@ +import importlib +import inspect import os +import pkgutil import textwrap import pytest +import sklearn from sklearn import __version__ from sklearn.utils._openmp_helpers import _openmp_parallelism_enabled +@pytest.mark.thread_unsafe # import side-effects +def test_extension_type_module(): + """Check that Cython extension types have a correct ``__module__``. + + When a subpackage containing Cython extension types has a misconfigured + ``meson.build`` (e.g. missing ``__init__.py`` in its Cython tree), Cython + cannot detect the package hierarchy and sets ``__module__`` to just the + submodule name (e.g. ``'_loss'``) instead of the fully qualified + ``'sklearn._loss._loss'``. This breaks downstream tools like skops that + rely on ``__module__`` for serialization. + """ + sklearn_path = [os.path.dirname(sklearn.__file__)] + failures = [] + for _, modname, ispkg in pkgutil.walk_packages( + path=sklearn_path, prefix="sklearn.", onerror=lambda _: None + ): + # Packages are directories, not modules that can hold extension + # types. ``tests``, ``externals`` (vendored third-party code) and + # ``_build_utils`` (build-time helpers that import ``Cython``, which + # is not installed in the wheel test environment) are out of scope + # for this check. + if ( + ispkg + or ".tests." in modname + or ".externals." in modname + or "._build_utils." in modname + ): + continue + mod = importlib.import_module(modname) + mod_file = getattr(mod, "__file__", "") or "" + # Only compiled extension modules can produce the misconfigured + # ``__module__`` this test guards against. Pure-Python modules get + # the correct ``__module__`` from the import system by construction. + if not mod_file.endswith((".so", ".pyd")): + continue + for name, cls in inspect.getmembers(mod, inspect.isclass): + try: + cls_file = inspect.getfile(cls) + except TypeError: # pragma: no cover + # Raised for built-in types (``object``, stdlib C types) that + # have no source file โ€” they were not defined in ``mod``. + continue # pragma: no cover + # Skip classes imported into ``mod`` from elsewhere (e.g. numpy, + # scipy, or another sklearn module). Only classes whose source + # file *is* this extension's .so are candidates for the bug. + if cls_file != mod_file: + continue + if cls.__module__ != modname: + failures.append( # pragma: no cover + f"{modname}.{name}.__module__ == {cls.__module__!r}, " + f"expected {modname!r}" + ) + assert not failures, "Extension types with incorrect __module__:\n" + "\n".join( + failures + ) + + def test_openmp_parallelism_enabled(): # Check that sklearn is built with OpenMP-based parallelism enabled. # This test can be skipped by setting the environment variable diff --git a/sklearn/tests/test_calibration.py b/sklearn/tests/test_calibration.py index d082b26b6e946..eb816d0a3126f 100644 --- a/sklearn/tests/test_calibration.py +++ b/sklearn/tests/test_calibration.py @@ -48,10 +48,11 @@ from sklearn.svm import LinearSVC from sklearn.tree import DecisionTreeClassifier from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, - device, + device as array_api_device, +) +from sklearn.utils._array_api import ( get_namespace, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._mocking import CheckingClassifier @@ -1086,12 +1087,12 @@ def fit(self, X, y, sample_weight=None, fit_param=None): ) +@pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning") def test_calibrated_classifier_cv_works_with_large_confidence_scores( global_random_seed, ): - """Test that :class:`CalibratedClassifierCV` works with large confidence - scores when using the `sigmoid` method, particularly with the - :class:`SGDClassifier`. + """Test that CalibratedClassifierCV works with large confidence scores when using + the sigmoid method, particularly with the SGDClassifier. Non-regression test for issue #26766. """ @@ -1104,12 +1105,13 @@ def test_calibrated_classifier_cv_works_with_large_confidence_scores( # Check that the decision function of SGDClassifier produces predicted # values that are quite large, for the data under consideration. - cv = check_cv(cv=None, y=y, classifier=True) + clf = SGDClassifier(loss="squared_hinge", tol=1e-2, random_state=global_random_seed) + cv = check_cv(cv=3, y=y, classifier=True) indices = cv.split(X, y) for train, test in indices: X_train, y_train = X[train], y[train] X_test = X[test] - sgd_clf = SGDClassifier(loss="squared_hinge", random_state=global_random_seed) + sgd_clf = clone(clf) sgd_clf.fit(X_train, y_train) predictions = sgd_clf.decision_function(X_test) assert (predictions > 1e4).any() @@ -1117,22 +1119,15 @@ def test_calibrated_classifier_cv_works_with_large_confidence_scores( # Compare the CalibratedClassifierCV using the sigmoid method with the # CalibratedClassifierCV using the isotonic method. The isotonic method # is used for comparison because it is numerically stable. - clf_sigmoid = CalibratedClassifierCV( - SGDClassifier(loss="squared_hinge", random_state=global_random_seed), - method="sigmoid", - ) + clf_sigmoid = CalibratedClassifierCV(clone(clf), method="sigmoid") score_sigmoid = cross_val_score(clf_sigmoid, X, y, scoring="roc_auc") - # The isotonic method is used for comparison because it is numerically - # stable. - clf_isotonic = CalibratedClassifierCV( - SGDClassifier(loss="squared_hinge", random_state=global_random_seed), - method="isotonic", - ) + # The isotonic method is used for comparison because it is numerically stable. + clf_isotonic = CalibratedClassifierCV(clone(clf), method="isotonic") score_isotonic = cross_val_score(clf_isotonic, X, y, scoring="roc_auc") - # The AUC score should be the same because it is invariant under - # strictly monotonic conditions + # The AUC score should be the same because it is invariant under strictly monotonic + # conditions assert_allclose(score_sigmoid, score_isotonic) @@ -1197,17 +1192,17 @@ def test_float32_predict_proba(data, use_sample_weight, method): else: sample_weight = None - class DummyClassifer32(DummyClassifier): + class DummyClassifier32(DummyClassifier): def predict_proba(self, X): return super().predict_proba(X).astype(np.float32) - model = DummyClassifer32() + model = DummyClassifier32() calibrator = CalibratedClassifierCV(model, method=method) # Does not raise an error. calibrator.fit(*data, sample_weight=sample_weight) # Check with frozen prefit model - model = DummyClassifer32().fit(*data, sample_weight=sample_weight) + model = DummyClassifier32().fit(*data, sample_weight=sample_weight) calibrator = CalibratedClassifierCV(FrozenEstimator(model), method=method) # Does not raise an error. calibrator.fit(*data, sample_weight=sample_weight) @@ -1227,17 +1222,16 @@ def test_error_less_class_samples_than_folds(): @pytest.mark.parametrize("ensemble", [False, True]) @pytest.mark.parametrize("use_sample_weight", [False, True]) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_temperature_scaling_array_api_compliance( - ensemble, use_sample_weight, array_namespace, device_, dtype_name + ensemble, use_sample_weight, array_namespace, device_name, dtype_name ): """Check that `CalibratedClassifierCV` with temperature scaling is compatible with the array API""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X, y = make_classification( n_samples=1000, n_features=10, @@ -1252,13 +1246,13 @@ def test_temperature_scaling_array_api_compliance( X_train = X_train.astype(dtype_name) y_train = y_train.astype(dtype_name) - X_train_xp = xp.asarray(X_train, device=device_) - y_train_xp = xp.asarray(y_train, device=device_) + X_train_xp = xp.asarray(X_train, device=device) + y_train_xp = xp.asarray(y_train, device=device) X_cal = X_cal.astype(dtype_name) y_cal = y_cal.astype(dtype_name) - X_cal_xp = xp.asarray(X_cal, device=device_) - y_cal_xp = xp.asarray(y_cal, device=device_) + X_cal_xp = xp.asarray(X_cal, device=device) + y_cal_xp = xp.asarray(y_cal, device=device) if use_sample_weight: sample_weight = np.ones_like(y_cal) @@ -1285,25 +1279,24 @@ def test_temperature_scaling_array_api_compliance( rtol = 1e-3 if dtype_name == "float32" else 1e-7 assert get_namespace(calibrator_xp.beta_)[0].__name__ == xp.__name__ assert calibrator_xp.beta_.dtype == X_cal_xp.dtype - assert device(calibrator_xp.beta_) == device(X_cal_xp) + assert array_api_device(calibrator_xp.beta_) == array_api_device(X_cal_xp) assert_allclose( - _convert_to_numpy(calibrator_xp.beta_, xp=xp), + move_to(calibrator_xp.beta_, xp=np, device="cpu"), calibrator_np.beta_, rtol=rtol, ) pred_xp = cal_clf_xp.predict(X_train_xp) - assert_allclose(_convert_to_numpy(pred_xp, xp=xp), pred_np) + assert_allclose(move_to(pred_xp, xp=np, device="cpu"), pred_np) @pytest.mark.parametrize("ensemble", [False, True]) @pytest.mark.parametrize("use_sample_weight", [False, True]) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_temperature_scaling_array_api_with_str_y_estimator_not_prefit( - ensemble, use_sample_weight, array_namespace, device_, dtype_name + ensemble, use_sample_weight, array_namespace, device_name, dtype_name ): """Check that `CalibratedClassifierCV` with temperature scaling is compatible with the array API when `y` is an ndarray of strings and the estimator is not @@ -1314,7 +1307,7 @@ def test_temperature_scaling_array_api_with_str_y_estimator_not_prefit( # the array API when `y` is an ndarray of strings and we fit # `LinearDiscriminantAnalysis` beforehand. In this regard # `LinearDiscriminantAnalysis` will also need modifications. - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X, y = make_classification( n_samples=500, n_features=10, @@ -1328,7 +1321,7 @@ def test_temperature_scaling_array_api_with_str_y_estimator_not_prefit( str_mapping = np.asarray(["a", "b", "c", "d", "e"]) X = X.astype(dtype_name) y_str = str_mapping[y] - X_xp = xp.asarray(X, device=device_) + X_xp = xp.asarray(X, device=device) if use_sample_weight: sample_weight = np.ones_like(y) @@ -1357,9 +1350,9 @@ def test_temperature_scaling_array_api_with_str_y_estimator_not_prefit( rtol = 1e-3 if dtype_name == "float32" else 1e-7 assert get_namespace(calibrator_xp.beta_)[0].__name__ == xp.__name__ assert calibrator_xp.beta_.dtype == X_xp.dtype - assert device(calibrator_xp.beta_) == device(X_xp) + assert array_api_device(calibrator_xp.beta_) == array_api_device(X_xp) assert_allclose( - _convert_to_numpy(calibrator_xp.beta_, xp=xp), + move_to(calibrator_xp.beta_, xp=np, device="cpu"), calibrator_np.beta_, rtol=rtol, ) diff --git a/sklearn/tests/test_common.py b/sklearn/tests/test_common.py index ea0a566fefbfe..ccff345ddb10e 100644 --- a/sklearn/tests/test_common.py +++ b/sklearn/tests/test_common.py @@ -45,6 +45,7 @@ ignore_warnings, ) from sklearn.utils.estimator_checks import ( + check_all_zero_sample_weights_error, check_dataframe_column_names_consistency, check_estimator, check_get_feature_names_out_error, @@ -59,6 +60,7 @@ check_transformer_get_feature_names_out_pandas, parametrize_with_checks, ) +from sklearn.utils.validation import has_fit_parameter @pytest.mark.thread_unsafe # import side-effects @@ -111,6 +113,7 @@ def test_get_check_estimator_ids(val, expected): assert _get_check_estimator_ids(val) == expected +@pytest.mark.no_check_spmatrix # pickle breaks check_spmatrix @parametrize_with_checks( list(_tested_estimators()), expected_failed_checks=_get_expected_failed_checks ) @@ -399,3 +402,17 @@ def test_check_inplace_ensure_writeable(estimator): estimator.set_params(kernel="precomputed") check_inplace_ensure_writeable(name, estimator) + + +ESTIMATORS_ACCEPTING_SAMPLE_WEIGHTS = [ + est for est in _tested_estimators() if has_fit_parameter(est, "sample_weight") +] + + +@pytest.mark.parametrize( + "estimator", ESTIMATORS_ACCEPTING_SAMPLE_WEIGHTS, ids=_get_check_estimator_ids +) +def test_check_all_zero_sample_weights_error(estimator): + name = estimator.__class__.__name__ + + check_all_zero_sample_weights_error(name, estimator) diff --git a/sklearn/tests/test_config.py b/sklearn/tests/test_config.py index bf35eee623c18..b7beda31c2fd5 100644 --- a/sklearn/tests/test_config.py +++ b/sklearn/tests/test_config.py @@ -21,6 +21,7 @@ def test_config_context(): "transform_output": "default", "enable_metadata_routing": False, "skip_parameter_validation": False, + "sparse_interface": "spmatrix", } # Not using as a context manager affects nothing @@ -39,6 +40,7 @@ def test_config_context(): "transform_output": "default", "enable_metadata_routing": False, "skip_parameter_validation": False, + "sparse_interface": "spmatrix", } assert get_config()["assume_finite"] is False @@ -74,6 +76,7 @@ def test_config_context(): "transform_output": "default", "enable_metadata_routing": False, "skip_parameter_validation": False, + "sparse_interface": "spmatrix", } # No positional arguments diff --git a/sklearn/tests/test_docstring_parameters.py b/sklearn/tests/test_docstring_parameters.py index ad90ec99e602e..250cb938c581e 100644 --- a/sklearn/tests/test_docstring_parameters.py +++ b/sklearn/tests/test_docstring_parameters.py @@ -55,7 +55,6 @@ "sklearn.utils.deprecation.load_mlcomp", "sklearn.pipeline.make_pipeline", "sklearn.pipeline.make_union", - "sklearn.utils.extmath.safe_sparse_dot", "HalfBinomialLoss", ] @@ -224,18 +223,16 @@ def test_fit_docstring_attributes(name, Estimator): elif Estimator.__name__ == "TSNE": # default raises an error, perplexity must be less than n_samples est.set_params(perplexity=2) - # TODO(1.9) remove - elif Estimator.__name__ == "KBinsDiscretizer": - # default raises a FutureWarning if quantile method is at default "warn" - est.set_params(quantile_method="averaged_inverted_cdf") # TODO(1.10) remove elif Estimator.__name__ == "MDS": # default raises a FutureWarning est.set_params(n_init=1, init="random") - # TODO(1.10) remove + # TODO(1.10) remove l1_ratios + # TODO(1.11) remove completely elif Estimator.__name__ == "LogisticRegressionCV": # default 'l1_ratios' value creates a FutureWarning - est.set_params(l1_ratios=(0,)) + # default 'scoring' value creates a FutureWarning + est.set_params(l1_ratios=(0,), scoring="neg_log_loss") # Low max iter to speed up tests: we are only interested in checking the existence # of fitted attributes. This should be invariant to whether it has converged or not. diff --git a/sklearn/tests/test_docstrings.py b/sklearn/tests/test_docstrings.py index ea625ac076a01..2b82255bfb2e0 100644 --- a/sklearn/tests/test_docstrings.py +++ b/sklearn/tests/test_docstrings.py @@ -51,7 +51,7 @@ def filter_errors(errors, method, Klass=None): # We ignore following error code, # - RT02: The first line of the Returns section # should contain only the type, .. - # (as we may need refer to the name of the returned + # (as we may need to refer to the name of the returned # object) # - GL01: Docstring text (summary) should start in the line # immediately after the opening quotes (not in the same line, @@ -155,6 +155,7 @@ def test_function_docstring(function_name, request): raise ValueError(msg) +@pytest.mark.no_check_spmatrix # no __module__ for check_spmatrix classes @pytest.mark.parametrize("Klass, method", get_all_methods()) def test_docstring(Klass, method, request): base_import_path = Klass.__module__ diff --git a/sklearn/tests/test_init.py b/sklearn/tests/test_init.py index 4df9c279030cb..6c80138b2b5ee 100644 --- a/sklearn/tests/test_init.py +++ b/sklearn/tests/test_init.py @@ -1,8 +1,7 @@ -# Basic unittests to test functioning of module's top-level - +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause -__author__ = "Yaroslav Halchenko" -__license__ = "BSD" +# Basic unittests to test functioning of module's top-level try: diff --git a/sklearn/tests/test_kernel_approximation.py b/sklearn/tests/test_kernel_approximation.py index a3b0c47adc3eb..79ffa7079e302 100644 --- a/sklearn/tests/test_kernel_approximation.py +++ b/sklearn/tests/test_kernel_approximation.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from sklearn._config import config_context from sklearn.datasets import make_classification from sklearn.kernel_approximation import ( AdditiveChi2Sampler, @@ -17,7 +18,17 @@ polynomial_kernel, rbf_kernel, ) +from sklearn.utils._array_api import ( + _atol_for_type, + get_namespace_and_device, + move_to, + yield_namespace_device_dtype_combinations, +) +from sklearn.utils._array_api import ( + device as array_device, +) from sklearn.utils._testing import ( + _array_api_for_tests, assert_allclose, assert_array_almost_equal, assert_array_equal, @@ -90,8 +101,8 @@ def test_polynomial_count_sketch_dense_sparse(gamma, degree, coef0, csr_containe assert_allclose(Yt_dense, Yt_sparse) -def _linear_kernel(X, Y): - return np.dot(X, Y.T) +def _linear_kernel(x, y): + return x @ y @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) @@ -338,6 +349,47 @@ def test_nystroem_approximation(): assert X_transformed.shape == (X.shape[0], 2) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +@pytest.mark.parametrize( + "kernel", list(kernel_metrics()) + [_linear_kernel, "precomputed"] +) +@pytest.mark.parametrize("n_components", [2, 100]) +def test_nystroem_approximation_array_api( + array_namespace, device_name, dtype_name, kernel, n_components +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + rnd = np.random.RandomState(0) + n_samples = 10 + # Ensure full-rank linear kernel to limit the impact of device-specific + # rounding discrepancies. + n_features = 2 * n_samples + X_np = rnd.uniform(size=(n_samples, n_features)).astype(dtype_name) + if kernel == "precomputed": + X_np = rbf_kernel(X_np[:n_components]) + + X_xp = xp.asarray(X_np, device=device) + + nystroem = Nystroem(n_components=n_components, kernel=kernel, random_state=0) + X_np_transformed = nystroem.fit_transform(X_np) + + with config_context(array_api_dispatch=True): + X_xp_transformed = nystroem.fit_transform(X_xp) + X_xp_transformed_np = move_to(X_xp_transformed, xp=np, device="cpu") + + for attribute_name in ["components_", "normalization_"]: + xp_attr, _, device_attr = get_namespace_and_device( + getattr(nystroem, attribute_name) + ) + assert xp_attr is xp + assert device_attr == array_device(X_xp) + + atol = _atol_for_type(dtype_name) + assert_allclose(X_np_transformed, X_xp_transformed_np, atol=atol) + + def test_nystroem_default_parameters(): rnd = np.random.RandomState(42) X = rnd.uniform(size=(10, 4)) diff --git a/sklearn/tests/test_metaestimators_metadata_routing.py b/sklearn/tests/test_metaestimators_metadata_routing.py index 02899ad32fb2b..70ed1ba237313 100644 --- a/sklearn/tests/test_metaestimators_metadata_routing.py +++ b/sklearn/tests/test_metaestimators_metadata_routing.py @@ -63,12 +63,14 @@ MultiOutputRegressor, RegressorChain, ) +from sklearn.preprocessing import TargetEncoder from sklearn.semi_supervised import SelfTrainingClassifier from sklearn.tests.metadata_routing_common import ( ConsumingClassifier, ConsumingRegressor, ConsumingScorer, ConsumingSplitter, + ConsumingSplitterInheritingFromGroupKFold, NonConsumingClassifier, NonConsumingRegressor, _Registry, @@ -135,7 +137,12 @@ }, { "metaestimator": LogisticRegressionCV, - "init_args": {"use_legacy_attributes": False, "l1_ratios": (0,)}, + # TODO(1.11): remove scoring because neg_log_loss is default now + "init_args": { + "use_legacy_attributes": False, + "l1_ratios": (0,), + "scoring": "neg_log_loss", + }, "X": X, "y": y, "scorer_name": "scoring", @@ -448,6 +455,13 @@ "X": X, "y": y, }, + { + "metaestimator": TargetEncoder, + "X": X, + "y": y, + "cv_name": "cv", + "cv_routing_methods": ["fit_transform"], + }, ] """List containing all metaestimators to be tested and their settings @@ -560,7 +574,10 @@ def get_init_args(metaestimator_info, sub_estimator_consumes): if "cv_name" in metaestimator_info: cv_name = metaestimator_info["cv_name"] cv_registry = _Registry() - cv = ConsumingSplitter(registry=cv_registry) + if metaestimator_info["metaestimator"] is TargetEncoder: + cv = ConsumingSplitterInheritingFromGroupKFold(registry=cv_registry) + else: + cv = ConsumingSplitter(registry=cv_registry) kwargs[cv_name] = cv return ( diff --git a/sklearn/tests/test_min_dependencies_readme.py b/sklearn/tests/test_min_dependencies_readme.py index 0f894103a8e27..3e7da7a713ce8 100644 --- a/sklearn/tests/test_min_dependencies_readme.py +++ b/sklearn/tests/test_min_dependencies_readme.py @@ -2,6 +2,7 @@ import os import re +import tomllib from collections import defaultdict from pathlib import Path @@ -11,18 +12,79 @@ from sklearn._min_dependencies import dependent_packages from sklearn.utils.fixes import parse_version -min_depencies_tag_to_packages_without_version = defaultdict(list) -for package, (min_version, extras) in dependent_packages.items(): - for extra in extras.split(", "): - min_depencies_tag_to_packages_without_version[extra].append(package) +# minimal dependencies and pyproject definitions for testing the pyproject tests -pyproject_section_to_min_dependencies_tag = { - "build-system.requires": "build", - "project.dependencies": "install", +TOY_MIN_DEPENDENCIES_PY_INFO = { + "joblib": ("1.3.0", "install"), + "scipy": ("1.10.0", "build, install"), + "conda-lock": ("3.0.1", "maintenance"), } -for tag in min_depencies_tag_to_packages_without_version: - section = f"project.optional-dependencies.{tag}" - pyproject_section_to_min_dependencies_tag[section] = tag + +TOY_MATCHING_PYPROJECT_SECTIONS = """ +[project] +dependencies = ["joblib>=1.3.0", "scipy>=1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0"] +install = ["joblib>=1.3.0", "scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0"] +""" + +TOY_MATCHING_PYPROJECT_SECTIONS_WITH_UPPER_BOUND = """ +[project] +dependencies = ["joblib>=1.3.0,<2.0", "scipy>=1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0,<1.19.0"] +install = ["joblib>=1.3.0,<2.0", "scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0,<1.19.0"] +""" + +TOY_WRONG_SYMBOL_PYPROJECT_SECTIONS = """ +[project] +dependencies = ["scipy<1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0"] +install = ["scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0"] +""" + +TOY_MISSING_PACKAGE_PYPROJECT_SECTIONS = """ +[project] +dependencies = ["scipy>=1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0"] +install = ["scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0"] +""" + +TOY_ADDITIONAL_PACKAGE_PYPROJECT_SECTIONS = """ +[project] +dependencies = ["joblib>=1.3.0", "scipy>=1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0", "package_not_in_min_dependencies_py_file>=4.2"] +install = ["joblib>=1.3.0", "scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0"] +""" + +TOY_NON_MATCHING_VERSION_PYPROJECT_SECTIONS = """ +[project] +dependencies = ["joblib>=1.42.0", "scipy>=1.10.0"] +[project.optional-dependencies] +build = ["scipy>=1.10.0"] +install = ["joblib>=1.3.0", "scipy>=1.10.0"] +maintenance = ["conda-lock==3.0.1"] +[build-system] +requires = ["scipy>=1.10.0"] +""" def test_min_dependencies_readme(): @@ -61,23 +123,84 @@ def test_min_dependencies_readme(): message = ( f"{package} has inconsistent minimum versions in README.rst and" - f" _min_depencies.py: {version} != {min_version}" + f" _min_dependencies.py: {version} != {min_version}" ) assert version == min_version, message -def check_pyproject_section( - pyproject_section, min_dependencies_tag, skip_version_check_for=None -): - # tomllib is available in Python 3.11 - tomllib = pytest.importorskip("tomllib") +def extract_packages_and_pyproject_tags(dependencies): + min_dependencies_tag_to_packages_without_version = defaultdict(list) + for package, (min_version, tags) in dependencies.items(): + for t in tags.split(", "): + min_dependencies_tag_to_packages_without_version[t].append(package) + + pyproject_section_to_min_dependencies_tag = { + "build-system.requires": "build", + "project.dependencies": "install", + } + for tag in min_dependencies_tag_to_packages_without_version: + section = f"project.optional-dependencies.{tag}" + pyproject_section_to_min_dependencies_tag[section] = tag + + return ( + min_dependencies_tag_to_packages_without_version, + pyproject_section_to_min_dependencies_tag, + ) + + +def check_pyproject_sections(pyproject_toml, min_dependencies): + packages, pyproject_tags = extract_packages_and_pyproject_tags(min_dependencies) + + for pyproject_section, min_dependencies_tag in pyproject_tags.items(): + # Special situation for numpy: we have numpy>=2 in + # build-system.requires to make sure we build wheels against numpy>=2. + # TODO remove this when our minimum supported numpy version is >=2. + skip_version_check_for = ( + ["numpy"] if pyproject_section == "build-system.requires" else [] + ) + + expected_packages = packages[min_dependencies_tag] + + pyproject_section_keys = pyproject_section.split(".") + info = pyproject_toml + # iterate through nested keys to get packages and version + for key in pyproject_section_keys: + info = info[key] + + pyproject_build_min_versions = {} + # Assuming pyproject.toml build section has something like "my-package>=2.3.0" + pattern = r"([\w-]+)\s*[>=]=\s*([\d\w.]+)" + for requirement in info: + match = re.search(pattern, requirement) + if match is None: + raise NotImplementedError( + f"{requirement} does not match expected regex {pattern!r}. " + "Only >= and == are supported for version requirements" + ) + + package, version = match.group(1), match.group(2) - if skip_version_check_for is None: - skip_version_check_for = [] + pyproject_build_min_versions[package] = version - expected_packages = min_depencies_tag_to_packages_without_version[ - min_dependencies_tag - ] + msg = f"Packages in {pyproject_section} differ from _min_dependencies.py" + + assert sorted(pyproject_build_min_versions) == sorted(expected_packages), msg + + for package, version in pyproject_build_min_versions.items(): + version = parse_version(version) + expected_min_version = parse_version(min_dependencies[package][0]) + if package in skip_version_check_for: + continue + + message = ( + f"{package} has inconsistent minimum versions in pyproject.toml and" + f" _min_dependencies.py: {version} != {expected_min_version}" + ) + assert version == expected_min_version, message + + +def test_min_dependencies_pyproject_toml(): + """Check versions in pyproject.toml is consistent with _min_dependencies.""" root_directory = Path(sklearn.__file__).parent.parent pyproject_toml_path = root_directory / "pyproject.toml" @@ -90,54 +213,53 @@ def check_pyproject_section( with pyproject_toml_path.open("rb") as f: pyproject_toml = tomllib.load(f) - pyproject_section_keys = pyproject_section.split(".") - info = pyproject_toml - for key in pyproject_section_keys: - info = info[key] - - pyproject_build_min_versions = {} - # Assuming pyproject.toml build section has something like "my-package>=2.3.0" - # Warning: if you try to modify this regex, bear in mind that there can be upper - # bounds in release branches so "my-package>=2.3.0,<2.5.0" - pattern = r"([\w-]+)\s*[>=]=\s*([\d\w.]+)" - for requirement in info: - match = re.search(pattern, requirement) - if match is None: - raise NotImplementedError( - f"{requirement} does not match expected regex {pattern!r}. " - "Only >= and == are supported for version requirements" - ) - - package, version = match.group(1), match.group(2) + check_pyproject_sections(pyproject_toml, dependent_packages) - pyproject_build_min_versions[package] = version - assert sorted(pyproject_build_min_versions) == sorted(expected_packages) +@pytest.mark.parametrize( + "example_pyproject", + [ + TOY_MATCHING_PYPROJECT_SECTIONS, + TOY_MATCHING_PYPROJECT_SECTIONS_WITH_UPPER_BOUND, + ], +) +def test_check_matching_pyproject_section(example_pyproject): + """Test the version check for matching packages.""" - for package, version in pyproject_build_min_versions.items(): - version = parse_version(version) - expected_min_version = parse_version(dependent_packages[package][0]) - if package in skip_version_check_for: - continue + pyproject_toml = tomllib.loads(example_pyproject) - message = ( - f"{package} has inconsistent minimum versions in pyproject.toml and" - f" _min_depencies.py: {version} != {expected_min_version}" - ) - assert version == expected_min_version, message + check_pyproject_sections(pyproject_toml, TOY_MIN_DEPENDENCIES_PY_INFO) @pytest.mark.parametrize( - "pyproject_section, min_dependencies_tag", - pyproject_section_to_min_dependencies_tag.items(), + "example_non_matching_pyproject, error_msg", + [ + ( + TOY_WRONG_SYMBOL_PYPROJECT_SECTIONS, + ".* does not match expected regex .*. " + "Only >= and == are supported for version requirements", + ), + ( + TOY_MISSING_PACKAGE_PYPROJECT_SECTIONS, + "Packages in .* differ from _min_dependencies.py", + ), + ( + TOY_ADDITIONAL_PACKAGE_PYPROJECT_SECTIONS, + "Packages in .* differ from _min_dependencies.py", + ), + ( + TOY_NON_MATCHING_VERSION_PYPROJECT_SECTIONS, + ".* has inconsistent minimum versions in pyproject.toml and" + " _min_dependencies.py: .* != .*", + ), + ], ) -def test_min_dependencies_pyproject_toml(pyproject_section, min_dependencies_tag): - """Check versions in pyproject.toml is consistent with _min_dependencies.""" - # NumPy is more complex because build-time (>=1.25) and run-time (>=1.19.5) - # requirement currently don't match - skip_version_check_for = ["numpy"] if min_dependencies_tag == "build" else None - check_pyproject_section( - pyproject_section, - min_dependencies_tag, - skip_version_check_for=skip_version_check_for, - ) +def test_check_non_matching_pyproject_section( + example_non_matching_pyproject, error_msg +): + """Test the version check for non-matching packages and versions.""" + + pyproject_toml = tomllib.loads(example_non_matching_pyproject) + + with pytest.raises(Exception, match=error_msg): + check_pyproject_sections(pyproject_toml, TOY_MIN_DEPENDENCIES_PY_INFO) diff --git a/sklearn/tests/test_multiclass.py b/sklearn/tests/test_multiclass.py index 66bbb039606f5..50c47857324b7 100644 --- a/sklearn/tests/test_multiclass.py +++ b/sklearn/tests/test_multiclass.py @@ -327,7 +327,10 @@ def conduct_test(base_clf, test_predict_proba=False): ): conduct_test(base_clf) - for base_clf in (MultinomialNB(), SVC(probability=True), LogisticRegression()): + for base_clf in ( + MultinomialNB(), + LogisticRegression(), + ): conduct_test(base_clf, test_predict_proba=True) @@ -404,21 +407,12 @@ def test_ovr_multilabel_predict_proba(): assert not hasattr(decision_only, "predict_proba") # Estimator with predict_proba disabled, depending on parameters. - decision_only = OneVsRestClassifier(svm.SVC(probability=False)) + decision_only = OneVsRestClassifier(svm.SVC()) assert not hasattr(decision_only, "predict_proba") decision_only.fit(X_train, Y_train) assert not hasattr(decision_only, "predict_proba") assert hasattr(decision_only, "decision_function") - # Estimator which can get predict_proba enabled after fitting - gs = GridSearchCV( - svm.SVC(probability=False), param_grid={"probability": [True]} - ) - proba_after_fit = OneVsRestClassifier(gs) - assert not hasattr(proba_after_fit, "predict_proba") - proba_after_fit.fit(X_train, Y_train) - assert hasattr(proba_after_fit, "predict_proba") - Y_pred = clf.predict(X_test) Y_proba = clf.predict_proba(X_test) diff --git a/sklearn/tests/test_multioutput.py b/sklearn/tests/test_multioutput.py index 83c35bb3a626b..a6d9ceb33df89 100644 --- a/sklearn/tests/test_multioutput.py +++ b/sklearn/tests/test_multioutput.py @@ -865,19 +865,3 @@ def test_multioutput_regressor_has_partial_fit(): msg = "This 'MultiOutputRegressor' has no attribute 'partial_fit'" with pytest.raises(AttributeError, match=msg): getattr(est, "partial_fit") - - -# TODO(1.9): remove when deprecated `base_estimator` is removed -@pytest.mark.parametrize("Estimator", [ClassifierChain, RegressorChain]) -def test_base_estimator_deprecation(Estimator): - """Check that we warn about the deprecation of `base_estimator`.""" - X = np.array([[1, 2], [3, 4]]) - y = np.array([[1, 0], [0, 1]]) - - estimator = LogisticRegression() - - with pytest.warns(FutureWarning): - Estimator(base_estimator=estimator).fit(X, y) - - with pytest.raises(ValueError): - Estimator(base_estimator=estimator, estimator=estimator).fit(X, y) diff --git a/sklearn/tests/test_naive_bayes.py b/sklearn/tests/test_naive_bayes.py index f18cabbcf01d8..3f310d911c8d6 100644 --- a/sklearn/tests/test_naive_bayes.py +++ b/sklearn/tests/test_naive_bayes.py @@ -16,9 +16,10 @@ MultinomialNB, ) from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, - device, + device as array_api_device, +) +from sklearn.utils._array_api import ( + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import ( @@ -980,7 +981,7 @@ def test_predict_joint_proba(Estimator, global_random_seed): jll = est.predict_joint_log_proba(X2) log_prob_x = logsumexp(jll, axis=1) log_prob_x_y = jll - np.atleast_2d(log_prob_x).T - assert_allclose(est.predict_log_proba(X2), log_prob_x_y) + assert_allclose(est.predict_log_proba(X2), log_prob_x_y, atol=1e-12) @pytest.mark.parametrize("Estimator", ALL_NAIVE_BAYES_CLASSES) @@ -995,23 +996,22 @@ def test_categorical_input_tag(Estimator): @pytest.mark.parametrize("use_str_y", [False, True]) @pytest.mark.parametrize("use_sample_weight", [False, True]) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_gnb_array_api_compliance( - use_str_y, use_sample_weight, array_namespace, device_, dtype_name + use_str_y, use_sample_weight, array_namespace, device_name, dtype_name ): """Tests that :class:`GaussianNB` works correctly with array API inputs.""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) X_np = X.astype(dtype_name) - X_xp = xp.asarray(X_np, device=device_) + X_xp = xp.asarray(X_np, device=device) if use_str_y: y_np = np.array(["a", "a", "a", "b", "b", "b"]) y_xp_or_np = np.array(["a", "a", "a", "b", "b", "b"]) else: y_np = y.astype(dtype_name) - y_xp_or_np = xp.asarray(y_np, device=device_) + y_xp_or_np = xp.asarray(y_np, device=device) if use_sample_weight: sample_weight = np.array([1, 2, 3, 1, 2, 3]) @@ -1028,24 +1028,24 @@ def test_gnb_array_api_compliance( xp_attr = getattr(clf_xp, fitted_attr) np_attr = getattr(clf_np, fitted_attr) assert xp_attr.dtype == X_xp.dtype - assert device(xp_attr) == device(X_xp) - assert_allclose(_convert_to_numpy(xp_attr, xp=xp), np_attr) + assert array_api_device(xp_attr) == array_api_device(X_xp) + assert_allclose(move_to(xp_attr, xp=np, device="cpu"), np_attr) y_pred_xp = clf_xp.predict(X_xp) if not use_str_y: - assert device(y_pred_xp) == device(X_xp) - y_pred_xp = _convert_to_numpy(y_pred_xp, xp=xp) + assert array_api_device(y_pred_xp) == array_api_device(X_xp) + y_pred_xp = move_to(y_pred_xp, xp=np, device="cpu") assert_array_equal(y_pred_xp, y_pred_np) assert y_pred_xp.dtype == y_pred_np.dtype y_pred_proba_xp = clf_xp.predict_proba(X_xp) assert y_pred_proba_xp.dtype == X_xp.dtype - assert device(y_pred_proba_xp) == device(X_xp) - assert_allclose(_convert_to_numpy(y_pred_proba_xp, xp=xp), y_pred_proba_np) + assert array_api_device(y_pred_proba_xp) == array_api_device(X_xp) + assert_allclose(move_to(y_pred_proba_xp, xp=np, device="cpu"), y_pred_proba_np) y_pred_log_proba_xp = clf_xp.predict_log_proba(X_xp) assert y_pred_log_proba_xp.dtype == X_xp.dtype - assert device(y_pred_log_proba_xp) == device(X_xp) + assert array_api_device(y_pred_log_proba_xp) == array_api_device(X_xp) assert_allclose( - _convert_to_numpy(y_pred_log_proba_xp, xp=xp), y_pred_log_proba_np + move_to(y_pred_log_proba_xp, xp=np, device="cpu"), y_pred_log_proba_np ) diff --git a/sklearn/tests/test_pipeline.py b/sklearn/tests/test_pipeline.py index b2eb7deb4a712..7c81ac4ac9a97 100644 --- a/sklearn/tests/test_pipeline.py +++ b/sklearn/tests/test_pipeline.py @@ -34,6 +34,7 @@ from sklearn.feature_extraction.text import CountVectorizer from sklearn.feature_selection import SelectKBest, f_classif from sklearn.impute import SimpleImputer +from sklearn.kernel_approximation import Nystroem from sklearn.linear_model import Lasso, LinearRegression, LogisticRegression from sklearn.metrics import accuracy_score, r2_score from sklearn.model_selection import train_test_split @@ -48,11 +49,18 @@ check_recorded_metadata, ) from sklearn.utils import get_tags +from sklearn.utils._array_api import ( + _atol_for_type, + get_namespace_and_device, + move_to, + yield_namespace_device_dtype_combinations, +) from sklearn.utils._metadata_requests import COMPOSITE_METHODS, METHODS from sklearn.utils._testing import ( MinimalClassifier, MinimalRegressor, MinimalTransformer, + _array_api_for_tests, assert_allclose, assert_array_almost_equal, assert_array_equal, @@ -282,6 +290,30 @@ def test_pipeline_invalid_parameters(): assert params == params2 +@pytest.mark.parametrize( + "meta_estimators, class_name", + [ + (Pipeline([("pca", PCA)]), "PCA"), + (Pipeline([("pca", PCA), ("ident", None)]), "PCA"), + (Pipeline([("passthrough", "passthrough"), ("pca", PCA)]), "PCA"), + (Pipeline([("passthrough", None), ("pca", PCA)]), "PCA"), + (Pipeline([("scale", StandardScaler), ("pca", PCA())]), "StandardScaler"), + (FeatureUnion([("pca", PCA), ("svd", TruncatedSVD())]), "PCA"), + (FeatureUnion([("pca", PCA()), ("svd", TruncatedSVD)]), "TruncatedSVD"), + (FeatureUnion([("drop", "drop"), ("svd", TruncatedSVD)]), "TruncatedSVD"), + (FeatureUnion([("pca", PCA), ("passthrough", "passthrough")]), "PCA"), + ], +) +def test_meta_estimator_raises_class_not_instance_error(meta_estimators, class_name): + # non-regression tests for https://github.com/scikit-learn/scikit-learn/issues/32719 + msg = re.escape( + f"Expected an estimator instance ({class_name}()), " + f"got estimator class instead ({class_name})." + ) + with pytest.raises(TypeError, match=msg): + meta_estimators.fit([[1]]) + + def test_empty_pipeline(): X = iris.data y = iris.target @@ -292,6 +324,13 @@ def test_empty_pipeline(): pipe.fit(X, y) +def test_empty_pipeline_dir(): + """Check that dir() works on an empty pipeline""" + pipe = Pipeline([]) + attrs = dir(pipe) + assert "steps" in attrs + + def test_pipeline_init_tuple(): # Pipeline accepts steps as tuple X = np.array([[1, 2]]) @@ -386,14 +425,14 @@ def test_pipeline_raise_set_params_error(): pipe.set_params(cls__invalid_param="nope") -def test_pipeline_methods_pca_svm(): +def test_pipeline_methods_pca_classifier(): # Test the various methods of the pipeline (pca + svm). X = iris.data y = iris.target - # Test with PCA + SVC - clf = SVC(probability=True, random_state=0) + # Test with PCA + LogisticRegression + clf = LogisticRegression() pca = PCA(svd_solver="full", n_components="mle", whiten=True) - pipe = Pipeline([("pca", pca), ("svc", clf)]) + pipe = Pipeline([("pca", pca), ("classifier", clf)]) pipe.fit(X, y) pipe.predict(X) pipe.predict_proba(X) @@ -434,7 +473,7 @@ def test_score_samples_on_pipeline_without_score_samples(): assert inner_msg in str(exec_info.value.__cause__) -def test_pipeline_methods_preprocessing_svm(): +def test_pipeline_methods_preprocessing_classifier(): # Test the various methods of the pipeline (preprocessing + svm). X = iris.data y = iris.target @@ -442,7 +481,7 @@ def test_pipeline_methods_preprocessing_svm(): n_classes = len(np.unique(y)) scaler = StandardScaler() pca = PCA(n_components=2, svd_solver="randomized", whiten=True) - clf = SVC(probability=True, random_state=0, decision_function_shape="ovr") + clf = LogisticRegression() for preprocessing in [scaler, pca]: pipe = Pipeline([("preprocess", preprocessing), ("svc", clf)]) @@ -1792,7 +1831,7 @@ def test_feature_union_check_if_fitted(): def test_pipeline_get_feature_names_out_passes_names_through(): """Check that pipeline passes names through. - Non-regresion test for #21349. + Non-regression test for #21349. """ X, y = iris.data, iris.target @@ -1826,20 +1865,22 @@ def test_pipeline_set_output_integration(): assert_array_equal(feature_names_in_, log_reg_feature_names) -def test_feature_union_set_output(): +@pytest.mark.parametrize("df_library", ["pandas", "polars"]) +def test_feature_union_set_output(df_library): """Test feature union with set_output API.""" - pd = pytest.importorskip("pandas") + lib = pytest.importorskip(df_library) X, _ = load_iris(as_frame=True, return_X_y=True) X_train, X_test = train_test_split(X, random_state=0) union = FeatureUnion([("scalar", StandardScaler()), ("pca", PCA())]) - union.set_output(transform="pandas") + union.set_output(transform=df_library) union.fit(X_train) X_trans = union.transform(X_test) - assert isinstance(X_trans, pd.DataFrame) + assert isinstance(X_trans, lib.DataFrame) assert_array_equal(X_trans.columns, union.get_feature_names_out()) - assert_array_equal(X_trans.index, X_test.index) + if df_library == "pandas": + assert_array_equal(X_trans.index, X_test.index) def test_feature_union_getitem(): @@ -1919,6 +1960,84 @@ def test_feature_union_1d_output(): ).fit_transform(X) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_feature_union_array_api_compliance(array_namespace, device_name, dtype_name): + """Test that FeatureUnion with Array API-compatible transformers works.""" + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + rnd = np.random.RandomState(0) + n_samples, n_features = 20, 10 + X_np = rnd.uniform(size=(n_samples, n_features)).astype(dtype_name) + X_xp = xp.asarray(X_np, device=device) + + n_components_1, n_components_2 = 5, 8 + union = FeatureUnion( + [ + ("nystroem1", Nystroem(n_components=n_components_1, random_state=0)), + ("nystroem2", Nystroem(n_components=n_components_2, random_state=1)), + ] + ) + + X_np_transformed = union.fit_transform(X_np) + + with config_context(array_api_dispatch=True): + X_xp_transformed = union.fit_transform(X_xp) + X_xp_transformed_np = move_to(X_xp_transformed, xp=np, device="cpu") + + for name, trans in union.transformer_list: + for attr in ["components_", "normalization_"]: + if hasattr(trans, attr): + trans_xp, _, trans_device = get_namespace_and_device( + getattr(trans, attr) + ) + assert trans_xp is xp + assert trans_device == get_namespace_and_device(X_xp)[2] + + atol = _atol_for_type(dtype_name) + assert_allclose(X_np_transformed, X_xp_transformed_np, atol=atol) + assert X_xp_transformed_np.shape == ( + n_samples, + n_components_1 + n_components_2, + ) + + +def test_feature_union_array_api_support_tag(): + """Check that FeatureUnion.array_api_support tag reflects its transformers.""" + # All transformers support Array API -> union supports it + union = FeatureUnion( + [ + ("scaler", StandardScaler()), + ("nystroem", Nystroem(n_components=5, random_state=0)), + ] + ) + assert get_tags(union).array_api_support is True + + # One transformer does not support Array API -> union does not + union = FeatureUnion( + [ + ("scaler", StandardScaler()), + ("svd", TruncatedSVD(n_components=2)), + ] + ) + assert get_tags(union).array_api_support is False + + # passthrough/drop are treated as supporting Array API + union = FeatureUnion( + [ + ("scaler", StandardScaler()), + ("pass", "passthrough"), + ("dropped", "drop"), + ] + ) + assert get_tags(union).array_api_support is True + + # Only drop and passthrough -> True + union = make_union("drop", "passthrough") + assert get_tags(union).array_api_support is True + + # transform_input tests # ===================== diff --git a/sklearn/tests/test_public_functions.py b/sklearn/tests/test_public_functions.py index 51e4e38a50c45..c19644fe7ffc7 100644 --- a/sklearn/tests/test_public_functions.py +++ b/sklearn/tests/test_public_functions.py @@ -264,6 +264,7 @@ def _check_function_param_validation( "sklearn.metrics.mean_squared_log_error", "sklearn.metrics.mean_tweedie_deviance", "sklearn.metrics.median_absolute_error", + "sklearn.metrics.metric_at_thresholds", "sklearn.metrics.multilabel_confusion_matrix", "sklearn.metrics.mutual_info_score", "sklearn.metrics.ndcg_score", diff --git a/sklearn/tree/_classes.py b/sklearn/tree/_classes.py index 8b43680e1f5ab..313072da4a9cc 100644 --- a/sklearn/tree/_classes.py +++ b/sklearn/tree/_classes.py @@ -8,6 +8,7 @@ import copy import numbers +import warnings from abc import ABCMeta, abstractmethod from math import ceil from numbers import Integral, Real @@ -24,7 +25,7 @@ clone, is_classifier, ) -from sklearn.tree import _criterion, _splitter, _tree +from sklearn.tree import _criterion, _splitter from sklearn.tree._criterion import Criterion from sklearn.tree._splitter import Splitter from sklearn.tree._tree import ( @@ -34,7 +35,6 @@ _build_pruned_tree_ccp, ccp_pruning_path, ) -from sklearn.tree._utils import _any_isnan_axis0 from sklearn.utils import ( Bunch, check_random_state, @@ -64,9 +64,6 @@ # Types and constants # ============================================================================= -DTYPE = _tree.DTYPE -DOUBLE = _tree.DOUBLE - CRITERIA_CLF = { "gini": _criterion.Gini, "log_loss": _criterion.Entropy, @@ -74,7 +71,6 @@ } CRITERIA_REG = { "squared_error": _criterion.MSE, - "friedman_mse": _criterion.FriedmanMSE, "absolute_error": _criterion.MAE, "poisson": _criterion.Poisson, } @@ -185,11 +181,7 @@ def get_n_leaves(self): return self.tree_.n_leaves def _support_missing_values(self, X): - return ( - not issparse(X) - and self.__sklearn_tags__().input_tags.allow_nan - and self.monotonic_cst is None - ) + return not issparse(X) and self.__sklearn_tags__().input_tags.allow_nan def _compute_missing_values_in_feature_mask(self, X, estimator_name=None): """Return boolean mask denoting if there are missing values for each feature. @@ -198,7 +190,7 @@ def _compute_missing_values_in_feature_mask(self, X, estimator_name=None): Parameter --------- - X : array-like of shape (n_samples, n_features), dtype=DOUBLE + X : array-like of shape (n_samples, n_features) Input data. estimator_name : str or None, default=None @@ -228,7 +220,7 @@ def _compute_missing_values_in_feature_mask(self, X, estimator_name=None): if not np.isnan(overall_sum): return None - missing_values_in_feature_mask = _any_isnan_axis0(X) + missing_values_in_feature_mask = np.isnan(X.sum(axis=0)) return missing_values_in_feature_mask def _fit( @@ -249,7 +241,7 @@ def _fit( # _compute_missing_values_in_feature_mask will check for finite values and # compute the missing mask if the tree supports missing values check_X_params = dict( - dtype=DTYPE, accept_sparse="csc", ensure_all_finite=False + dtype=np.float32, accept_sparse="csc", ensure_all_finite=False ) check_y_params = dict(ensure_2d=False, dtype=None) X, y = validate_data( @@ -317,8 +309,8 @@ def _fit( self.n_classes_ = np.array(self.n_classes_, dtype=np.intp) - if getattr(y, "dtype", None) != DOUBLE or not y.flags.contiguous: - y = np.ascontiguousarray(y, dtype=DOUBLE) + if getattr(y, "dtype", None) != np.float64 or not y.flags.contiguous: + y = np.ascontiguousarray(y, dtype=np.float64) max_depth = np.iinfo(np.int32).max if self.max_depth is None else self.max_depth @@ -361,7 +353,7 @@ def _fit( ) if sample_weight is not None: - sample_weight = _check_sample_weight(sample_weight, X, dtype=DOUBLE) + sample_weight = _check_sample_weight(sample_weight, X, dtype=np.float64) if expanded_class_weight is not None: if sample_weight is not None: @@ -412,10 +404,10 @@ def _fit( ) valid_constraints = np.isin(monotonic_cst, (-1, 0, 1)) if not np.all(valid_constraints): - unique_constaints_value = np.unique(monotonic_cst) + unique_constraints_value = np.unique(monotonic_cst) raise ValueError( "monotonic_cst must be None or an array-like of -1, 0 or 1, but" - f" got {unique_constaints_value}" + f" got {unique_constraints_value}" ) monotonic_cst = np.asarray(monotonic_cst, dtype=np.int8) if is_classifier(self): @@ -492,7 +484,7 @@ def _validate_X_predict(self, X, check_input): X = validate_data( self, X, - dtype=DTYPE, + dtype=np.float32, accept_sparse="csr", reset=False, ensure_all_finite=ensure_all_finite, @@ -850,8 +842,7 @@ class DecisionTreeClassifier(ClassifierMixin, BaseDecisionTree): Monotonicity constraints are not supported for: - multiclass classifications (i.e. when `n_classes > 2`), - - multioutput classifications (i.e. when `n_outputs_ > 1`), - - classifications trained on data with missing values. + - multioutput classifications (i.e. when `n_outputs_ > 1`). The constraints hold over the probability of the positive class. @@ -1099,15 +1090,10 @@ def predict_log_proba(self, X): def __sklearn_tags__(self): tags = super().__sklearn_tags__() - # XXX: nan is only supported for dense arrays, but we set this for + # XXX: nan values are only accepted in dense arrays, but we set this for # common test to pass, specifically: check_estimators_nan_inf - allow_nan = self.splitter in ("best", "random") and self.criterion in { - "gini", - "log_loss", - "entropy", - } + tags.input_tags.allow_nan = True tags.classifier_tags.multi_label = True - tags.input_tags.allow_nan = allow_nan return tags @@ -1118,16 +1104,14 @@ class DecisionTreeRegressor(RegressorMixin, BaseDecisionTree): Parameters ---------- - criterion : {"squared_error", "friedman_mse", "absolute_error", \ - "poisson"}, default="squared_error" + criterion : {"squared_error", "absolute_error", "poisson"}, default="squared_error" The function to measure the quality of a split. Supported criteria are "squared_error" for the mean squared error, which is equal to variance reduction as feature selection criterion and minimizes the L2 - loss using the mean of each terminal node, "friedman_mse", which uses - mean squared error with Friedman's improvement score for potential - splits, "absolute_error" for the mean absolute error, which minimizes - the L1 loss using the median of each terminal node, and "poisson" which - uses reduction in the half mean Poisson deviance to find splits. + loss using the mean of each terminal node, "absolute_error" for the mean + absolute error, which minimizes the L1 loss using the median of each terminal + node, and "poisson" which uses reduction in Poisson deviance to find splits, + also using the mean of each terminal node. .. versionadded:: 0.18 Mean Absolute Error (MAE) criterion. @@ -1135,6 +1119,9 @@ class DecisionTreeRegressor(RegressorMixin, BaseDecisionTree): .. versionadded:: 0.24 Poisson deviance criterion. + .. versionchanged:: 1.9 + Criterion `"friedman_mse"` was deprecated. + splitter : {"best", "random"}, default="best" The strategy used to choose the split at each node. Supported strategies are "best" to choose the best split and "random" to choose @@ -1248,8 +1235,7 @@ class DecisionTreeRegressor(RegressorMixin, BaseDecisionTree): If monotonic_cst is None, no constraints are applied. Monotonicity constraints are not supported for: - - multioutput regressions (i.e. when `n_outputs_ > 1`), - - regressions trained on data with missing values. + - multioutput regressions (i.e. when `n_outputs_ > 1`). Read more in the :ref:`User Guide <monotonic_cst_gbdt>`. @@ -1338,7 +1324,7 @@ class DecisionTreeRegressor(RegressorMixin, BaseDecisionTree): _parameter_constraints: dict = { **BaseDecisionTree._parameter_constraints, "criterion": [ - StrOptions({"squared_error", "friedman_mse", "absolute_error", "poisson"}), + StrOptions({"squared_error", "absolute_error", "poisson"}), Hidden(Criterion), ], } @@ -1359,6 +1345,16 @@ def __init__( ccp_alpha=0.0, monotonic_cst=None, ): + if isinstance(criterion, str) and criterion == "friedman_mse": + # TODO(1.11): remove support of "friedman_mse" criterion. + criterion = "squared_error" + warnings.warn( + 'Value `"friedman_mse"` for `criterion` is deprecated and will be ' + 'removed in 1.11. It maps to `"squared_error"` as both ' + 'were always equivalent. Use `criterion="squared_error"` ' + "to remove this warning.", + FutureWarning, + ) super().__init__( criterion=criterion, splitter=splitter, @@ -1429,7 +1425,7 @@ def _compute_partial_dependence_recursion(self, grid, target_features): averaged_predictions : ndarray of shape (n_samples,), dtype=np.float64 The value of the partial dependence function on each grid point. """ - grid = np.asarray(grid, dtype=DTYPE, order="C") + grid = np.asarray(grid, dtype=np.float32, order="C") averaged_predictions = np.zeros( shape=grid.shape[0], dtype=np.float64, order="C" ) @@ -1442,14 +1438,9 @@ def _compute_partial_dependence_recursion(self, grid, target_features): def __sklearn_tags__(self): tags = super().__sklearn_tags__() - # XXX: nan is only supported for dense arrays, but we set this for - # common test to pass, specifically: check_estimators_nan_inf - allow_nan = self.splitter in ("best", "random") and self.criterion in { - "squared_error", - "friedman_mse", - "poisson", - } - tags.input_tags.allow_nan = allow_nan + # XXX: nan values are only accepted in dense arrays, but we set this for + # for common test to pass, specifically: check_estimators_nan_inf + tags.input_tags.allow_nan = True return tags @@ -1601,8 +1592,7 @@ class ExtraTreeClassifier(DecisionTreeClassifier): Monotonicity constraints are not supported for: - multiclass classifications (i.e. when `n_classes > 2`), - - multioutput classifications (i.e. when `n_outputs_ > 1`), - - classifications trained on data with missing values. + - multioutput classifications (i.e. when `n_outputs_ > 1`). The constraints hold over the probability of the positive class. @@ -1730,13 +1720,9 @@ def __init__( def __sklearn_tags__(self): tags = super().__sklearn_tags__() - # XXX: nan is only supported for dense arrays, but we set this for the + # XXX: nan values are only accepted in dense arrays, but we set this for # common test to pass, specifically: check_estimators_nan_inf - allow_nan = self.splitter == "random" and self.criterion in { - "gini", - "log_loss", - "entropy", - } + allow_nan = self.splitter == "random" tags.classifier_tags.multi_label = True tags.input_tags.allow_nan = allow_nan return tags @@ -1758,16 +1744,14 @@ class ExtraTreeRegressor(DecisionTreeRegressor): Parameters ---------- - criterion : {"squared_error", "friedman_mse", "absolute_error", "poisson"}, \ - default="squared_error" + criterion : {"squared_error", "absolute_error", "poisson"}, default="squared_error" The function to measure the quality of a split. Supported criteria are "squared_error" for the mean squared error, which is equal to variance reduction as feature selection criterion and minimizes the L2 - loss using the mean of each terminal node, "friedman_mse", which uses - mean squared error with Friedman's improvement score for potential - splits, "absolute_error" for the mean absolute error, which minimizes - the L1 loss using the median of each terminal node, and "poisson" which - uses reduction in Poisson deviance to find splits. + loss using the mean of each terminal node, "absolute_error" for the mean + absolute error, which minimizes the L1 loss using the median of each terminal + node, and "poisson" which uses reduction in Poisson deviance to find splits, + also using the mean of each terminal node. .. versionadded:: 0.18 Mean Absolute Error (MAE) criterion. @@ -1775,6 +1759,9 @@ class ExtraTreeRegressor(DecisionTreeRegressor): .. versionadded:: 0.24 Poisson deviance criterion. + .. versionchanged:: 1.9 + Criterion `"friedman_mse"` was deprecated. + splitter : {"random", "best"}, default="random" The strategy used to choose the split at each node. Supported strategies are "best" to choose the best split and "random" to choose @@ -1880,8 +1867,7 @@ class ExtraTreeRegressor(DecisionTreeRegressor): If monotonic_cst is None, no constraints are applied. Monotonicity constraints are not supported for: - - multioutput regressions (i.e. when `n_outputs_ > 1`), - - regressions trained on data with missing values. + - multioutput regressions (i.e. when `n_outputs_ > 1`). Read more in the :ref:`User Guide <monotonic_cst_gbdt>`. @@ -1989,12 +1975,8 @@ def __init__( def __sklearn_tags__(self): tags = super().__sklearn_tags__() - # XXX: nan is only supported for dense arrays, but we set this for the + # XXX: nan values are only accepted in dense arrays, but we set this for # common test to pass, specifically: check_estimators_nan_inf - allow_nan = self.splitter == "random" and self.criterion in { - "squared_error", - "friedman_mse", - "poisson", - } + allow_nan = self.splitter == "random" tags.input_tags.allow_nan = allow_nan return tags diff --git a/sklearn/tree/_criterion.pxd b/sklearn/tree/_criterion.pxd index fa8583b85f4a2..f67516c11381f 100644 --- a/sklearn/tree/_criterion.pxd +++ b/sklearn/tree/_criterion.pxd @@ -18,8 +18,6 @@ cdef class Criterion: cdef intp_t start # samples[start:pos] are the samples in the left node cdef intp_t pos # samples[pos:end] are the samples in the right node cdef intp_t end - cdef intp_t n_missing # Number of missing values for the feature being evaluated - cdef bint missing_go_to_left # Whether missing values go to the left node cdef intp_t n_outputs # Number of outputs cdef intp_t n_samples # Number of samples @@ -28,7 +26,6 @@ cdef class Criterion: cdef float64_t weighted_n_node_samples # Weighted number of samples in the node cdef float64_t weighted_n_left # Weighted number of samples in the left node cdef float64_t weighted_n_right # Weighted number of samples in the right node - cdef float64_t weighted_n_missing # Weighted number of samples that are missing # The criterion object is maintained such that left and right collected # statistics correspond to samples[start:pos] and samples[pos:end]. @@ -43,8 +40,6 @@ cdef class Criterion: intp_t start, intp_t end ) except -1 nogil - cdef void init_sum_missing(self) - cdef void init_missing(self, intp_t n_missing) noexcept nogil cdef int reset(self) except -1 nogil cdef int reverse_reset(self) except -1 nogil cdef int update(self, intp_t new_pos) except -1 nogil @@ -96,7 +91,6 @@ cdef class ClassificationCriterion(Criterion): cdef float64_t[:, ::1] sum_total # The sum of the weighted count of each label. cdef float64_t[:, ::1] sum_left # Same as above, but for the left side of the split cdef float64_t[:, ::1] sum_right # Same as above, but for the right side of the split - cdef float64_t[:, ::1] sum_missing # Same as above, but for missing values in X cdef class RegressionCriterion(Criterion): """Abstract regression criterion.""" @@ -106,4 +100,3 @@ cdef class RegressionCriterion(Criterion): cdef float64_t[::1] sum_total # The sum of w*y. cdef float64_t[::1] sum_left # Same as above, but for the left side of the split cdef float64_t[::1] sum_right # Same as above, but for the right side of the split - cdef float64_t[::1] sum_missing # Same as above, but for missing values in X diff --git a/sklearn/tree/_criterion.pyx b/sklearn/tree/_criterion.pyx index 4124ee2c4e374..b4c13b99f37c7 100644 --- a/sklearn/tree/_criterion.pyx +++ b/sklearn/tree/_criterion.pyx @@ -1,9 +1,9 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause +from libc.math cimport INFINITY from libc.string cimport memcpy from libc.string cimport memset -from libc.math cimport INFINITY import numpy as np cimport numpy as cnp @@ -13,7 +13,7 @@ from scipy.special.cython_special cimport xlogy from sklearn.tree._utils cimport log from sklearn.tree._utils cimport WeightedFenwickTree -from sklearn.tree._partitioner cimport sort +from sklearn.utils._sorting cimport simultaneous_sort # EPSILON is used in the Poisson criterion cdef float64_t EPSILON = 10 * np.finfo('double').eps @@ -64,19 +64,6 @@ cdef class Criterion: """ pass - cdef void init_missing(self, intp_t n_missing) noexcept nogil: - """Initialize sum_missing if there are missing values. - - This method assumes that caller placed the missing samples in - self.sample_indices[-n_missing:] - - Parameters - ---------- - n_missing: intp_t - Number of missing values for specific feature. - """ - pass - cdef int reset(self) except -1 nogil: """Reset the criterion at pos=start. @@ -241,8 +228,6 @@ cdef class Criterion: ) return check_lower_bound & check_upper_bound & check_monotonic_cst - cdef void init_sum_missing(self): - """Init sum_missing to hold sums for missing values.""" cdef inline void _move_sums_classification( ClassificationCriterion criterion, @@ -250,40 +235,19 @@ cdef inline void _move_sums_classification( float64_t[:, ::1] sum_2, float64_t* weighted_n_1, float64_t* weighted_n_2, - bint put_missing_in_1, ) noexcept nogil: - """Distribute sum_total and sum_missing into sum_1 and sum_2. - - If there are missing values and: - - put_missing_in_1 is True, then missing values to go sum_1. Specifically: - sum_1 = sum_missing - sum_2 = sum_total - sum_missing - - - put_missing_in_1 is False, then missing values go to sum_2. Specifically: - sum_1 = 0 - sum_2 = sum_total + """Distribute sum_total into sum_1 and sum_2. """ - cdef intp_t k, c, n_bytes - if criterion.n_missing != 0 and put_missing_in_1: - for k in range(criterion.n_outputs): - n_bytes = criterion.n_classes[k] * sizeof(float64_t) - memcpy(&sum_1[k, 0], &criterion.sum_missing[k, 0], n_bytes) - - for k in range(criterion.n_outputs): - for c in range(criterion.n_classes[k]): - sum_2[k, c] = criterion.sum_total[k, c] - criterion.sum_missing[k, c] - - weighted_n_1[0] = criterion.weighted_n_missing - weighted_n_2[0] = criterion.weighted_n_node_samples - criterion.weighted_n_missing - else: - # Assigning sum_2 = sum_total for all outputs. - for k in range(criterion.n_outputs): - n_bytes = criterion.n_classes[k] * sizeof(float64_t) - memset(&sum_1[k, 0], 0, n_bytes) - memcpy(&sum_2[k, 0], &criterion.sum_total[k, 0], n_bytes) + cdef intp_t k, n_bytes - weighted_n_1[0] = 0.0 - weighted_n_2[0] = criterion.weighted_n_node_samples + # Assigning sum_2 = sum_total for all outputs. + for k in range(criterion.n_outputs): + n_bytes = criterion.n_classes[k] * sizeof(float64_t) + memset(&sum_1[k, 0], 0, n_bytes) + memcpy(&sum_2[k, 0], &criterion.sum_total[k, 0], n_bytes) + + weighted_n_1[0] = 0.0 + weighted_n_2[0] = criterion.weighted_n_node_samples cdef class ClassificationCriterion(Criterion): @@ -303,7 +267,6 @@ cdef class ClassificationCriterion(Criterion): self.start = 0 self.pos = 0 self.end = 0 - self.missing_go_to_left = 0 self.n_outputs = n_outputs self.n_samples = 0 @@ -311,7 +274,6 @@ cdef class ClassificationCriterion(Criterion): self.weighted_n_node_samples = 0.0 self.weighted_n_left = 0.0 self.weighted_n_right = 0.0 - self.weighted_n_missing = 0.0 self.n_classes = np.empty(n_outputs, dtype=np.intp) @@ -407,39 +369,6 @@ cdef class ClassificationCriterion(Criterion): self.reset() return 0 - cdef void init_sum_missing(self): - """Init sum_missing to hold sums for missing values.""" - self.sum_missing = np.zeros((self.n_outputs, self.max_n_classes), dtype=np.float64) - - cdef void init_missing(self, intp_t n_missing) noexcept nogil: - """Initialize sum_missing if there are missing values. - - This method assumes that caller placed the missing samples in - self.sample_indices[-n_missing:] - """ - cdef intp_t i, p, k, c - cdef float64_t w = 1.0 - - self.n_missing = n_missing - if n_missing == 0: - return - - memset(&self.sum_missing[0, 0], 0, self.max_n_classes * self.n_outputs * sizeof(float64_t)) - - self.weighted_n_missing = 0.0 - - # The missing samples are assumed to be in self.sample_indices[-n_missing:] - for p in range(self.end - n_missing, self.end): - i = self.sample_indices[p] - if self.sample_weight is not None: - w = self.sample_weight[i] - - for k in range(self.n_outputs): - c = <intp_t> self.y[i, k] - self.sum_missing[k, c] += w - - self.weighted_n_missing += w - cdef int reset(self) except -1 nogil: """Reset the criterion at pos=start. @@ -453,7 +382,6 @@ cdef class ClassificationCriterion(Criterion): self.sum_right, &self.weighted_n_left, &self.weighted_n_right, - self.missing_go_to_left, ) return 0 @@ -470,7 +398,6 @@ cdef class ClassificationCriterion(Criterion): self.sum_left, &self.weighted_n_right, &self.weighted_n_left, - not self.missing_go_to_left ) return 0 @@ -487,10 +414,7 @@ cdef class ClassificationCriterion(Criterion): child to the left child. """ cdef intp_t pos = self.pos - # The missing samples are assumed to be in - # self.sample_indices[-self.n_missing:] that is - # self.sample_indices[end_non_missing:self.end]. - cdef intp_t end_non_missing = self.end - self.n_missing + cdef intp_t i cdef intp_t p cdef intp_t k @@ -500,11 +424,11 @@ cdef class ClassificationCriterion(Criterion): # Update statistics up to new_pos # # Given that - # sum_left[x] + sum_right[x] = sum_total[x] + # sum_left[x] + sum_right[x] = sum_total[x] # and that sum_total is known, we are going to update # sum_left from the direction that require the least amount # of computations, i.e. from pos to new_pos or from end to new_po. - if (new_pos - pos) <= (end_non_missing - new_pos): + if (new_pos - pos) <= (self.end - new_pos): for p in range(pos, new_pos): i = self.sample_indices[p] @@ -519,7 +443,7 @@ cdef class ClassificationCriterion(Criterion): else: self.reverse_reset() - for p in range(end_non_missing - 1, new_pos - 1, -1): + for p in range(self.end - 1, new_pos - 1, -1): i = self.sample_indices[p] if self.sample_weight is not None: @@ -769,36 +693,15 @@ cdef inline void _move_sums_regression( float64_t[::1] sum_2, float64_t* weighted_n_1, float64_t* weighted_n_2, - bint put_missing_in_1, ) noexcept nogil: - """Distribute sum_total and sum_missing into sum_1 and sum_2. - - If there are missing values and: - - put_missing_in_1 is True, then missing values to go sum_1. Specifically: - sum_1 = sum_missing - sum_2 = sum_total - sum_missing + """Distribute sum_total into sum_1 and sum_2.""" + cdef intp_t n_bytes = criterion.n_outputs * sizeof(float64_t) - - put_missing_in_1 is False, then missing values go to sum_2. Specifically: - sum_1 = 0 - sum_2 = sum_total - """ - cdef: - intp_t i - intp_t n_bytes = criterion.n_outputs * sizeof(float64_t) - bint has_missing = criterion.n_missing != 0 - - if has_missing and put_missing_in_1: - memcpy(&sum_1[0], &criterion.sum_missing[0], n_bytes) - for i in range(criterion.n_outputs): - sum_2[i] = criterion.sum_total[i] - criterion.sum_missing[i] - weighted_n_1[0] = criterion.weighted_n_missing - weighted_n_2[0] = criterion.weighted_n_node_samples - criterion.weighted_n_missing - else: - memset(&sum_1[0], 0, n_bytes) - # Assigning sum_2 = sum_total for all outputs. - memcpy(&sum_2[0], &criterion.sum_total[0], n_bytes) - weighted_n_1[0] = 0.0 - weighted_n_2[0] = criterion.weighted_n_node_samples + memset(&sum_1[0], 0, n_bytes) + # Assigning sum_2 = sum_total for all outputs. + memcpy(&sum_2[0], &criterion.sum_total[0], n_bytes) + weighted_n_1[0] = 0.0 + weighted_n_2[0] = criterion.weighted_n_node_samples cdef class RegressionCriterion(Criterion): @@ -835,7 +738,6 @@ cdef class RegressionCriterion(Criterion): self.weighted_n_node_samples = 0.0 self.weighted_n_left = 0.0 self.weighted_n_right = 0.0 - self.weighted_n_missing = 0.0 self.sq_sum_total = 0.0 @@ -897,42 +799,6 @@ cdef class RegressionCriterion(Criterion): self.reset() return 0 - cdef void init_sum_missing(self): - """Init sum_missing to hold sums for missing values.""" - self.sum_missing = np.zeros(self.n_outputs, dtype=np.float64) - - cdef void init_missing(self, intp_t n_missing) noexcept nogil: - """Initialize sum_missing if there are missing values. - - This method assumes that caller placed the missing samples in - self.sample_indices[-n_missing:] - """ - cdef intp_t i, p, k - cdef float64_t y_ik - cdef float64_t w_y_ik - cdef float64_t w = 1.0 - - self.n_missing = n_missing - if n_missing == 0: - return - - memset(&self.sum_missing[0], 0, self.n_outputs * sizeof(float64_t)) - - self.weighted_n_missing = 0.0 - - # The missing samples are assumed to be in self.sample_indices[-n_missing:] - for p in range(self.end - n_missing, self.end): - i = self.sample_indices[p] - if self.sample_weight is not None: - w = self.sample_weight[i] - - for k in range(self.n_outputs): - y_ik = self.y[i, k] - w_y_ik = w * y_ik - self.sum_missing[k] += w_y_ik - - self.weighted_n_missing += w - cdef int reset(self) except -1 nogil: """Reset the criterion at pos=start.""" self.pos = self.start @@ -942,7 +808,6 @@ cdef class RegressionCriterion(Criterion): self.sum_right, &self.weighted_n_left, &self.weighted_n_right, - self.missing_go_to_left ) return 0 @@ -955,7 +820,6 @@ cdef class RegressionCriterion(Criterion): self.sum_left, &self.weighted_n_right, &self.weighted_n_left, - not self.missing_go_to_left ) return 0 @@ -963,10 +827,6 @@ cdef class RegressionCriterion(Criterion): """Updated statistics by moving sample_indices[pos:new_pos] to the left.""" cdef intp_t pos = self.pos - # The missing samples are assumed to be in - # self.sample_indices[-self.n_missing:] that is - # self.sample_indices[end_non_missing:self.end]. - cdef intp_t end_non_missing = self.end - self.n_missing cdef intp_t i cdef intp_t p cdef intp_t k @@ -979,7 +839,7 @@ cdef class RegressionCriterion(Criterion): # and that sum_total is known, we are going to update # sum_left from the direction that require the least amount # of computations, i.e. from pos to new_pos or from end to new_pos. - if (new_pos - pos) <= (end_non_missing - new_pos): + if (new_pos - pos) <= (self.end - new_pos): for p in range(pos, new_pos): i = self.sample_indices[p] @@ -993,7 +853,7 @@ cdef class RegressionCriterion(Criterion): else: self.reverse_reset() - for p in range(end_non_missing - 1, new_pos - 1, -1): + for p in range(self.end - 1, new_pos - 1, -1): i = self.sample_indices[p] if self.sample_weight is not None: @@ -1134,8 +994,6 @@ cdef class MSE(RegressionCriterion): cdef intp_t k cdef float64_t w = 1.0 - cdef intp_t end_non_missing - for p in range(start, pos): i = sample_indices[p] @@ -1146,22 +1004,6 @@ cdef class MSE(RegressionCriterion): y_ik = self.y[i, k] sq_sum_left += w * y_ik * y_ik - if self.missing_go_to_left: - # add up the impact of these missing values on the left child - # statistics. - # Note: this only impacts the square sum as the sum - # is modified elsewhere. - end_non_missing = self.end - self.n_missing - - for p in range(end_non_missing, self.end): - i = sample_indices[p] - if sample_weight is not None: - w = sample_weight[i] - - for k in range(self.n_outputs): - y_ik = self.y[i, k] - sq_sum_left += w * y_ik * y_ik - sq_sum_right = self.sq_sum_total - sq_sum_left impurity_left[0] = sq_sum_left / self.weighted_n_left @@ -1212,7 +1054,7 @@ cdef void precompute_absolute_errors( sample_indices : const intp_t[:] indices indicating which samples to use. Shape: (n_samples,) tree : WeightedFenwickTree - pre-instanciated tree + pre-instantiated tree start : intp_t Start index in `sample_indices` end : intp_t @@ -1296,7 +1138,7 @@ cdef inline void compute_ranks( cdef intp_t i for i in range(n): sorted_indices[i] = i - sort(sorted_y, sorted_indices, n) + simultaneous_sort(sorted_y, sorted_indices, n, use_three_way_partition=True) for i in range(n): ranks[sorted_indices[i]] = i @@ -1501,13 +1343,6 @@ cdef class MAE(Criterion): self.reset() return 0 - cdef void init_missing(self, intp_t n_missing) noexcept nogil: - """Raise error if n_missing != 0.""" - if n_missing == 0: - return - with gil: - raise ValueError("missing values is not supported for MAE.") - cdef int reset(self) except -1 nogil: """Reset the criterion at pos=start. @@ -1687,61 +1522,6 @@ cdef class MAE(Criterion): dest[0] = upper_bound -cdef class FriedmanMSE(MSE): - """Mean squared error impurity criterion with improvement score by Friedman. - - Uses the formula (35) in Friedman's original Gradient Boosting paper: - - diff = mean_left - mean_right - improvement = n_left * n_right * diff^2 / (n_left + n_right) - """ - - cdef float64_t proxy_impurity_improvement(self) noexcept nogil: - """Compute a proxy of the impurity reduction. - - This method is used to speed up the search for the best split. - It is a proxy quantity such that the split that maximizes this value - also maximizes the impurity improvement. It neglects all constant terms - of the impurity decrease for a given split. - - The absolute impurity improvement is only computed by the - impurity_improvement method once the best split has been found. - """ - cdef float64_t total_sum_left = 0.0 - cdef float64_t total_sum_right = 0.0 - - cdef intp_t k - cdef float64_t diff = 0.0 - - for k in range(self.n_outputs): - total_sum_left += self.sum_left[k] - total_sum_right += self.sum_right[k] - - diff = (self.weighted_n_right * total_sum_left - - self.weighted_n_left * total_sum_right) - - return diff * diff / (self.weighted_n_left * self.weighted_n_right) - - cdef float64_t impurity_improvement(self, float64_t impurity_parent, float64_t - impurity_left, float64_t impurity_right) noexcept nogil: - # Note: none of the arguments are used here - cdef float64_t total_sum_left = 0.0 - cdef float64_t total_sum_right = 0.0 - - cdef intp_t k - cdef float64_t diff = 0.0 - - for k in range(self.n_outputs): - total_sum_left += self.sum_left[k] - total_sum_right += self.sum_right[k] - - diff = (self.weighted_n_right * total_sum_left - - self.weighted_n_left * total_sum_right) / self.n_outputs - - return (diff * diff / (self.weighted_n_left * self.weighted_n_right * - self.weighted_n_node_samples)) - - cdef class Poisson(RegressionCriterion): """Half Poisson deviance as impurity criterion. diff --git a/sklearn/tree/_export.py b/sklearn/tree/_export.py index fef12fd194879..999c775da8f8a 100644 --- a/sklearn/tree/_export.py +++ b/sklearn/tree/_export.py @@ -28,6 +28,11 @@ from sklearn.utils.validation import check_array, check_is_fitted +def _rgb_to_hexstring(rgb): + """Convert 8bit integer rgb color to html hexstring""" + return "#%02x%02x%02x" % tuple(rgb) + + def _color_brew(n): """Generate n colors with equally spaced hues. @@ -261,7 +266,7 @@ def get_color(self, value): # compute the color as alpha against white color = [int(round(alpha * c + (1 - alpha) * 255, 0)) for c in color] # Return html color code in #RRGGBB format - return "#%2x%2x%2x" % tuple(color) + return _rgb_to_hexstring(color) def get_fill_color(self, tree, node_id): # Fetch appropriate color for node @@ -334,9 +339,7 @@ def node_to_str(self, tree, node_id, criterion): # Write impurity if self.impurity: - if isinstance(criterion, _criterion.FriedmanMSE): - criterion = "friedman_mse" - elif isinstance(criterion, _criterion.MSE) or criterion == "squared_error": + if isinstance(criterion, _criterion.MSE) or criterion == "squared_error": criterion = "squared_error" elif not isinstance(criterion, str): criterion = "impurity" diff --git a/sklearn/tree/_partitioner.pxd b/sklearn/tree/_partitioner.pxd index 6590b8ed585f1..d36ffc799c4d8 100644 --- a/sklearn/tree/_partitioner.pxd +++ b/sklearn/tree/_partitioner.pxd @@ -3,10 +3,8 @@ # See _partitioner.pyx for details. -from cython cimport floating - from sklearn.utils._typedefs cimport ( - float32_t, float64_t, int8_t, int32_t, intp_t, uint8_t, uint32_t + float32_t, float64_t, int32_t, intp_t, uint8_t ) from sklearn.tree._splitter cimport SplitRecord @@ -49,7 +47,8 @@ cdef const float32_t FEATURE_THRESHOLD = 1e-7 # cdef void next_p( # self, # intp_t* p_prev, -# intp_t* p +# intp_t* p, +# bint missing_go_to_left # ) noexcept nogil # cdef intp_t partition_samples( # self, @@ -57,10 +56,7 @@ cdef const float32_t FEATURE_THRESHOLD = 1e-7 # ) noexcept nogil # cdef void partition_samples_final( # self, -# intp_t best_pos, -# float64_t best_threshold, -# intp_t best_feature, -# intp_t n_missing, +# const SplitRecord* best_split, # ) noexcept nogil @@ -76,10 +72,12 @@ cdef class DensePartitioner: cdef intp_t end cdef intp_t n_missing cdef const uint8_t[::1] missing_values_in_feature_mask + cdef char[::1] swap_buffer cdef void sort_samples_and_feature_values( self, intp_t current_feature ) noexcept nogil + cdef void shift_missing_to_the_left(self) noexcept nogil cdef void init_node_split( self, intp_t start, @@ -94,18 +92,17 @@ cdef class DensePartitioner: cdef void next_p( self, intp_t* p_prev, - intp_t* p + intp_t* p, + bint missing_go_to_left ) noexcept nogil cdef intp_t partition_samples( self, - float64_t current_threshold + float64_t current_threshold, + bint missing_go_to_left ) noexcept nogil cdef void partition_samples_final( self, - intp_t best_pos, - float64_t best_threshold, - intp_t best_feature, - intp_t n_missing, + const SplitRecord* best_split, ) noexcept nogil @@ -134,6 +131,7 @@ cdef class SparsePartitioner: cdef void sort_samples_and_feature_values( self, intp_t current_feature ) noexcept nogil + cdef void shift_missing_to_the_left(self) noexcept nogil cdef void init_node_split( self, intp_t start, @@ -148,18 +146,17 @@ cdef class SparsePartitioner: cdef void next_p( self, intp_t* p_prev, - intp_t* p + intp_t* p, + bint missing_go_to_left ) noexcept nogil cdef intp_t partition_samples( self, - float64_t current_threshold + float64_t current_threshold, + bint missing_go_to_left, ) noexcept nogil cdef void partition_samples_final( self, - intp_t best_pos, - float64_t best_threshold, - intp_t best_feature, - intp_t n_missing, + const SplitRecord* best_split, ) noexcept nogil cdef void extract_nnz( @@ -168,16 +165,10 @@ cdef class SparsePartitioner: ) noexcept nogil cdef intp_t _partition( self, - float64_t threshold, - intp_t zero_pos + float64_t threshold ) noexcept nogil -cdef void shift_missing_values_to_left_if_required( - SplitRecord* best, - intp_t[::1] samples, - intp_t end, -) noexcept nogil - - -cdef void sort(floating* feature_values, intp_t* samples, intp_t n) noexcept nogil +ctypedef fused array_data_type: + intp_t + float32_t diff --git a/sklearn/tree/_partitioner.pyx b/sklearn/tree/_partitioner.pyx index c479988f0eac7..266928b1055aa 100644 --- a/sklearn/tree/_partitioner.pyx +++ b/sklearn/tree/_partitioner.pyx @@ -11,21 +11,22 @@ and sparse data stored in a Compressed Sparse Column (CSC) format. # SPDX-License-Identifier: BSD-3-Clause from cython cimport final -from libc.math cimport isnan, log2 +from libc.math cimport INFINITY, isnan, log2 from libc.stdlib cimport qsort -from libc.string cimport memcpy +from libc.string cimport memcpy, memmove import numpy as np +cimport numpy as cnp +cnp.import_array() from scipy.sparse import issparse +from sklearn.tree._splitter cimport SplitRecord +from sklearn.utils._sorting cimport simultaneous_sort # Constant to switch between algorithm non zero value extract algorithm # in SparsePartitioner cdef float32_t EXTRACT_NNZ_SWITCH = 0.1 -# Allow for 32 bit float comparisons -cdef float32_t INFINITY_32t = np.inf - @final cdef class DensePartitioner: @@ -44,6 +45,10 @@ cdef class DensePartitioner: self.samples = samples self.feature_values = feature_values self.missing_values_in_feature_mask = missing_values_in_feature_mask + buffer_size = samples.size * max(samples.itemsize, feature_values.itemsize) + self.swap_buffer = np.empty(buffer_size, dtype=np.uint8) + # TODO: As optimization we could make `swap_array_slices` always pick the smallest side + # to get copied in the buffer, which would allow to use a buffer twice smaller. cdef inline void init_node_split(self, intp_t start, intp_t end) noexcept nogil: """Initialize splitter at the beginning of node_split.""" @@ -95,9 +100,32 @@ cdef class DensePartitioner: for i in range(self.start, self.end): feature_values[i] = X[samples[i], current_feature] - sort(&feature_values[self.start], &samples[self.start], self.end - self.start - n_missing) + simultaneous_sort( + &feature_values[self.start], + &samples[self.start], + self.end - self.start - n_missing, + use_three_way_partition=True, + ) self.n_missing = n_missing + cdef void shift_missing_to_the_left(self) noexcept nogil: + """Moves missing values from the right to the left. + + All missing values are expected to be grouped at the right hand side of the + [self.start:self.end] slices of the self.samples and self.feature_values arrays + before calling this method. This will be the case for nominal use as + the splitter calls sort_samples_and_feature_values() first: + that method groups missing values on the right and sets self.n_missing. + shift_missing_to_the_left() is then called only for the second split search + pass when evaluating missing_go_to_left=True. + + Non-missing values are correspondingly moved from the left to the right while + preserving their inner ordering. + """ + cdef intp_t n_non_missing = self.end - self.start - self.n_missing + swap_array_slices(self.samples, self.start, self.end, n_non_missing, self.swap_buffer) + swap_array_slices(self.feature_values, self.start, self.end, n_non_missing, self.swap_buffer) + cdef inline void find_min_max( self, intp_t current_feature, @@ -110,161 +138,145 @@ cdef class DensePartitioner: values observed in feature_values is stored in self.n_missing. """ cdef: - intp_t p, current_end + intp_t p float32_t current_feature_value - const float32_t[:, :] X = self.X intp_t[::1] samples = self.samples - float32_t min_feature_value = INFINITY_32t - float32_t max_feature_value = -INFINITY_32t + float32_t min_feature_value = INFINITY + float32_t max_feature_value = -INFINITY float32_t[::1] feature_values = self.feature_values intp_t n_missing = 0 - const uint8_t[::1] missing_values_in_feature_mask = self.missing_values_in_feature_mask - - # We are copying the values into an array and finding min/max of the array in - # a manner which utilizes the cache more effectively. We need to also count - # the number of missing-values there are. - if missing_values_in_feature_mask is not None and missing_values_in_feature_mask[current_feature]: - p, current_end = self.start, self.end - 1 - # Missing values are placed at the end and do not participate in the - # min/max calculation. - while p <= current_end: - # Finds the right-most value that is not missing so that - # it can be swapped with missing values towards its left. - if isnan(X[samples[current_end], current_feature]): - n_missing += 1 - current_end -= 1 - continue - - # X[samples[current_end], current_feature] is a non-missing value - if isnan(X[samples[p], current_feature]): - samples[p], samples[current_end] = samples[current_end], samples[p] - n_missing += 1 - current_end -= 1 - - current_feature_value = X[samples[p], current_feature] - feature_values[p] = current_feature_value - if current_feature_value < min_feature_value: - min_feature_value = current_feature_value - elif current_feature_value > max_feature_value: - max_feature_value = current_feature_value - p += 1 - else: - min_feature_value = X[samples[self.start], current_feature] - max_feature_value = min_feature_value + bint seen_non_missing = False - feature_values[self.start] = min_feature_value - for p in range(self.start + 1, self.end): - current_feature_value = X[samples[p], current_feature] - feature_values[p] = current_feature_value + for p in range(self.start, self.end): + current_feature_value = self.X[samples[p], current_feature] + feature_values[p] = current_feature_value - if current_feature_value < min_feature_value: - min_feature_value = current_feature_value - elif current_feature_value > max_feature_value: - max_feature_value = current_feature_value + if isnan(current_feature_value): + n_missing += 1 + elif not seen_non_missing: + min_feature_value = current_feature_value + max_feature_value = current_feature_value + seen_non_missing = True + elif current_feature_value < min_feature_value: + min_feature_value = current_feature_value + elif current_feature_value > max_feature_value: + max_feature_value = current_feature_value min_feature_value_out[0] = min_feature_value max_feature_value_out[0] = max_feature_value self.n_missing = n_missing - cdef inline void next_p(self, intp_t* p_prev, intp_t* p) noexcept nogil: - """Compute the next p_prev and p for iterating over feature values. - - The missing values are not included when iterating through the feature values. + cdef inline void next_p( + self, + intp_t* p_prev, + intp_t* p, + bint missing_go_to_left, + ) noexcept nogil: """ - cdef intp_t end_non_missing = self.end - self.n_missing - - while ( - p[0] + 1 < end_non_missing and - self.feature_values[p[0] + 1] <= self.feature_values[p[0]] + FEATURE_THRESHOLD - ): + Compute the next p_prev and p for iterating over feature values. + + This method is used inside the best-split search function pass which starts + by setting p = start at the beginning of each search pass and calls + this method repeatedly with the same missing_go_to_left as for that pass. + The expected layout of self.feature_values[start:end] is: + - first pass (missing_go_to_left=False): after + sort_samples_and_feature_values(), non-missing values are sorted and + missing values are grouped at the right; + - second pass (missing_go_to_left=True): after + shift_missing_to_the_left(), missing values are grouped at the left. + + Given that layout, this method advances p to the next valid split + position while skipping ties up to FEATURE_THRESHOLD: + - if missing_go_to_left: iterate p in [start + n_missing + 1, end) + - otherwise: iterate p in [start, end - n_missing]. + The special case p == end - n_missing corresponds to "all non-missing + values on the left and all missing values on the right". The next + call then sets p to end to terminate the search loop. + """ + cdef intp_t end_non_missing = ( + self.end if missing_go_to_left + else self.end - self.n_missing) + + if p[0] == end_non_missing and not missing_go_to_left: + # skip the missing values up to the end + # (which will end the for loop in the best split function) + p[0] = self.end + p_prev[0] = self.end + else: + if missing_go_to_left and p[0] == self.start: + # skip the missing values up to the first non-missing value: + p[0] = self.start + self.n_missing p[0] += 1 - - p_prev[0] = p[0] - - # By adding 1, we have - # (feature_values[p] >= end) or (feature_values[p] > feature_values[p - 1]) - p[0] += 1 + while ( + p[0] < end_non_missing and + self.feature_values[p[0]] <= self.feature_values[p[0] - 1] + FEATURE_THRESHOLD + ): + p[0] += 1 + p_prev[0] = p[0] - 1 cdef inline intp_t partition_samples( self, - float64_t current_threshold + float64_t threshold, + bint missing_go_to_left ) noexcept nogil: - """Partition samples for feature_values at the current_threshold.""" - cdef: - intp_t p = self.start - intp_t partition_end = self.end - self.n_missing - intp_t[::1] samples = self.samples - float32_t[::1] feature_values = self.feature_values + """Partition self.samples and self.feature_values + on current self.feature_values for a given threshold. - while p < partition_end: - if feature_values[p] <= current_threshold: - p += 1 + Used while searching splits through random threshold sampling. + """ + cdef: + # Local invariance: start <= partition_start <= partition_end <= end + intp_t partition_start = self.start + intp_t partition_end = self.end + intp_t* samples = &self.samples[0] + float32_t* feature_values = &self.feature_values[0] + bint go_to_left + + while partition_start < partition_end: + go_to_left = ( + missing_go_to_left if isnan(feature_values[partition_start]) + else feature_values[partition_start] <= threshold + ) + if go_to_left: + partition_start += 1 else: partition_end -= 1 - - feature_values[p], feature_values[partition_end] = ( - feature_values[partition_end], feature_values[p] - ) - samples[p], samples[partition_end] = samples[partition_end], samples[p] + swap(feature_values, samples, partition_start, partition_end) return partition_end cdef inline void partition_samples_final( self, - intp_t best_pos, - float64_t best_threshold, - intp_t best_feature, - intp_t best_n_missing, + const SplitRecord* best_split, ) noexcept nogil: - """Partition samples for X at the best_threshold and best_feature. + """Partition self.samples according to the split described by best_split. - If missing values are present, this method partitions `samples` - so that the `best_n_missing` missing values' indices are in the - right-most end of `samples`, that is `samples[end_non_missing:end]`. + If missing values are present, this method partitions them accordingly + to the split strategy. """ cdef: - # Local invariance: start <= p <= partition_end <= end - intp_t start = self.start - intp_t p = start - intp_t end = self.end - 1 - intp_t partition_end = end - best_n_missing - intp_t[::1] samples = self.samples - const float32_t[:, :] X = self.X + # Local invariance: start <= partition_start <= partition_end <= end + intp_t partition_start = self.start + intp_t partition_end = self.end + intp_t* samples = &self.samples[0] + float64_t best_threshold = best_split[0].threshold + intp_t best_feature = best_split[0].feature + bint best_missing_go_to_left = best_split[0].missing_go_to_left float32_t current_value + bint go_to_left - if best_n_missing != 0: - # Move samples with missing values to the end while partitioning the - # non-missing samples - while p <= partition_end: - # Keep samples with missing values at the end - if isnan(X[samples[end], best_feature]): - end -= 1 - continue - - # Swap sample with missing values with the sample at the end - current_value = X[samples[p], best_feature] - if isnan(current_value): - samples[p], samples[end] = samples[end], samples[p] - end -= 1 - - # The swapped sample at the end is always a non-missing value, so - # we can continue the algorithm without checking for missingness. - current_value = X[samples[p], best_feature] - - # Partition the non-missing samples - if current_value <= best_threshold: - p += 1 - else: - samples[p], samples[partition_end] = samples[partition_end], samples[p] - partition_end -= 1 - else: - # Partitioning routine when there are no missing values - while p < partition_end: - if X[samples[p], best_feature] <= best_threshold: - p += 1 - else: - samples[p], samples[partition_end] = samples[partition_end], samples[p] - partition_end -= 1 + while partition_start < partition_end: + current_value = self.X[samples[partition_start], best_feature] + go_to_left = ( + best_missing_go_to_left if isnan(current_value) + else current_value <= best_threshold + ) + if go_to_left: + partition_start += 1 + else: + partition_end -= 1 + samples[partition_start], samples[partition_end] = ( + samples[partition_end], samples[partition_start]) @final @@ -324,12 +336,18 @@ cdef class SparsePartitioner: self.extract_nnz(current_feature) # Sort the positive and negative parts of `feature_values` - sort(&feature_values[self.start], &samples[self.start], self.end_negative - self.start) + simultaneous_sort( + &feature_values[self.start], + &samples[self.start], + self.end_negative - self.start, + use_three_way_partition=True, + ) if self.start_positive < self.end: - sort( + simultaneous_sort( &feature_values[self.start_positive], &samples[self.start_positive], - self.end - self.start_positive + self.end - self.start_positive, + use_three_way_partition=True, ) # Update index_to_samples to take into account the sort @@ -351,6 +369,9 @@ cdef class SparsePartitioner: # number of missing values for current_feature self.n_missing = 0 + cdef void shift_missing_to_the_left(self) noexcept nogil: + pass # Missing values are not supported for sparse data. + cdef inline void find_min_max( self, intp_t current_feature, @@ -394,8 +415,17 @@ cdef class SparsePartitioner: min_feature_value_out[0] = min_feature_value max_feature_value_out[0] = max_feature_value - cdef inline void next_p(self, intp_t* p_prev, intp_t* p) noexcept nogil: - """Compute the next p_prev and p for iterating over feature values.""" + cdef inline void next_p( + self, + intp_t* p_prev, + intp_t* p, + bint missing_go_to_left, + ) noexcept nogil: + """Compute the next p_prev and p for iterating over feature values. + + The missing_go_to_left argument is ignored for sparse data because + sparse partitioning does not currently support missing values. + """ cdef intp_t p_next if p[0] + 1 != self.end_negative: @@ -416,24 +446,28 @@ cdef class SparsePartitioner: cdef inline intp_t partition_samples( self, - float64_t current_threshold + float64_t current_threshold, + bint missing_go_to_left ) noexcept nogil: """Partition samples for feature_values at the current_threshold.""" - return self._partition(current_threshold, self.start_positive) + return self._partition(current_threshold) cdef inline void partition_samples_final( self, - intp_t best_pos, - float64_t best_threshold, - intp_t best_feature, - intp_t n_missing, + const SplitRecord* best_split, ) noexcept nogil: - """Partition samples for X at the best_threshold and best_feature.""" - self.extract_nnz(best_feature) - self._partition(best_threshold, best_pos) + """Partition samples for X according to the split described by best_split.""" + self.extract_nnz(best_split[0].feature) + self._partition(best_split[0].threshold) - cdef inline intp_t _partition(self, float64_t threshold, intp_t zero_pos) noexcept nogil: - """Partition samples[start:end] based on threshold.""" + cdef inline intp_t _partition(self, float64_t threshold) noexcept nogil: + """ + Partition samples[start:end] based on threshold. + Assume extract_nnz was called beforehand, and partitioned samples in: + - samples[start:end_negative] -> < 0 + - samples[end_negative:start_positive] -> zeros + - samples[end_negative:start_positive] -> > 0 + """ cdef: intp_t p, partition_end intp_t[::1] index_to_samples = self.index_to_samples @@ -447,8 +481,8 @@ cdef class SparsePartitioner: p = self.start_positive partition_end = self.end else: - # Data are already split - return zero_pos + # If threshold is 0, extract_nnz already did the necessary partitioning + return self.start_positive while p < partition_end: if feature_values[p] <= threshold: @@ -672,146 +706,45 @@ cdef inline void sparse_swap(intp_t[::1] index_to_samples, intp_t[::1] samples, index_to_samples[samples[pos_2]] = pos_2 -cdef inline void shift_missing_values_to_left_if_required( - SplitRecord* best, - intp_t[::1] samples, - intp_t end, -) noexcept nogil: - """Shift missing value sample indices to the left of the split if required. - - Note: this should always be called at the very end because it will - move samples around, thereby affecting the criterion. - This affects the computation of the children impurity, which affects - the computation of the next node. - """ - cdef intp_t i, p, current_end - # The partitioner partitions the data such that the missing values are in - # samples[-n_missing:] for the criterion to consume. If the missing values - # are going to the right node, then the missing values are already in the - # correct position. If the missing values go left, then we move the missing - # values to samples[best.pos:best.pos+n_missing] and update `best.pos`. - if best.n_missing > 0 and best.missing_go_to_left: - for p in range(best.n_missing): - i = best.pos + p - current_end = end - 1 - p - samples[i], samples[current_end] = samples[current_end], samples[i] - best.pos += best.n_missing - - -def _py_sort(float32_t[::1] feature_values, intp_t[::1] samples, intp_t n): - """Used for testing sort.""" - sort(&feature_values[0], &samples[0], n) - - -# Sort n-element arrays pointed to by feature_values and samples, simultaneously, -# by the values in feature_values. Algorithm: Introsort (Musser, SP&E, 1997). -cdef void sort(floating* feature_values, intp_t* samples, intp_t n) noexcept nogil: - if n == 0: - return - cdef intp_t maxd = 2 * <intp_t>log2(n) - introsort(feature_values, samples, n, maxd) - - -cdef inline void swap(floating* feature_values, intp_t* samples, +cdef inline void swap(float32_t* feature_values, intp_t* samples, intp_t i, intp_t j) noexcept nogil: - # Helper for sort feature_values[i], feature_values[j] = feature_values[j], feature_values[i] samples[i], samples[j] = samples[j], samples[i] -cdef inline floating median3(floating* feature_values, intp_t n) noexcept nogil: - # Median of three pivot selection, after Bentley and McIlroy (1993). - # Engineering a sort function. SP&E. Requires 8/3 comparisons on average. - cdef floating a = feature_values[0], b = feature_values[n / 2], c = feature_values[n - 1] - if a < b: - if b < c: - return b - elif a < c: - return c - else: - return a - elif b < c: - if a < c: - return a - else: - return c - else: - return b - - -# Introsort with median of 3 pivot selection and 3-way partition function -# (robust to repeated elements, e.g. lots of zero features). -cdef void introsort(floating* feature_values, intp_t *samples, - intp_t n, intp_t maxd) noexcept nogil: - cdef floating pivot - cdef intp_t i, l, r - - while n > 1: - if maxd <= 0: # max depth limit exceeded ("gone quadratic") - heapsort(feature_values, samples, n) - return - maxd -= 1 - - pivot = median3(feature_values, n) - - # Three-way partition. - i = l = 0 - r = n - while i < r: - if feature_values[i] < pivot: - swap(feature_values, samples, i, l) - i += 1 - l += 1 - elif feature_values[i] > pivot: - r -= 1 - swap(feature_values, samples, i, r) - else: - i += 1 - - introsort(feature_values, samples, l, maxd) - feature_values += r - samples += r - n -= r - - -cdef inline void sift_down(floating* feature_values, intp_t* samples, - intp_t start, intp_t end) noexcept nogil: - # Restore heap order in feature_values[start:end] by moving the max element to start. - cdef intp_t child, maxind, root - - root = start - while True: - child = root * 2 + 1 - - # find max of root, left child, right child - maxind = root - if child < end and feature_values[maxind] < feature_values[child]: - maxind = child - if child + 1 < end and feature_values[maxind] < feature_values[child + 1]: - maxind = child + 1 - - if maxind == root: - break - else: - swap(feature_values, samples, root, maxind) - root = maxind - - -cdef void heapsort(floating* feature_values, intp_t* samples, intp_t n) noexcept nogil: - cdef intp_t start, end +cdef void swap_array_slices( + array_data_type[::1] array, intp_t start, intp_t end, intp_t n, + char[::1] buffer +) noexcept nogil: + """Swaps the order of the slices array[start:start + n] and array[start + n:end]. - # heapify - start = (n - 2) / 2 - end = n - while True: - sift_down(feature_values, samples, start, end) - if start == 0: - break - start -= 1 - - # sort by shrinking the heap, putting the max element immediately after it - end = n - 1 - while end > 0: - swap(feature_values, samples, 0, end) - sift_down(feature_values, samples, 0, end) - end = end - 1 + Preserves the order within the slices. Works for any itemsize. + """ + if start >= end: + return + cdef size_t itemsize = sizeof(array[0]) + cdef intp_t n_rev = end - start - n + cdef char* arr = <char*> &array[0] + cdef char* buf = &buffer[0] + # Copy array[start + n : end] to temporary buffer + memcpy(buf, arr + (start + n) * itemsize, n_rev * itemsize) + # Move array[start : start + n] to array[start + n_rev : end] + # `memmove` is needed as the dest & source regions overlap + memmove(arr + (start + n_rev) * itemsize, arr + start * itemsize, n * itemsize) + # array[start : start + n_rev] = buffer[:n_rev] + memcpy(arr + start * itemsize, buf, n_rev * itemsize) + + +def _py_swap_array_slices(cnp.ndarray array, intp_t start, intp_t end, intp_t n): + """ + Python wrapper for swap_array_slices for testing. + `array` must be contiguous. + """ + buffer = np.empty(array.size * array.dtype.itemsize, dtype=np.uint8) + # Dispatch to the appropriate specialized version based on dtype + if array.dtype == np.intp: + swap_array_slices[intp_t](array, start, end, n, buffer) + elif array.dtype == np.float32: + swap_array_slices[float32_t](array, start, end, n, buffer) + else: + raise ValueError(f"Unsupported dtype: {array.dtype}. Expected np.intp or np.float32") diff --git a/sklearn/tree/_splitter.pxd b/sklearn/tree/_splitter.pxd index b3f458d8c5185..c988abb33996b 100644 --- a/sklearn/tree/_splitter.pxd +++ b/sklearn/tree/_splitter.pxd @@ -23,7 +23,7 @@ cdef struct SplitRecord: float64_t lower_bound # Lower bound on value of both children for monotonicity float64_t upper_bound # Upper bound on value of both children for monotonicity uint8_t missing_go_to_left # Controls if missing values go to the left node. - intp_t n_missing # Number of missing values for the feature being split on + cdef class Splitter: # The splitter searches in the input space for a feature and a threshold diff --git a/sklearn/tree/_splitter.pyx b/sklearn/tree/_splitter.pyx index bd80adcfe251c..bf4de11b2517a 100644 --- a/sklearn/tree/_splitter.pyx +++ b/sklearn/tree/_splitter.pyx @@ -20,13 +20,12 @@ of splitting strategies: # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause +from libc.math cimport INFINITY from libc.string cimport memcpy -from sklearn.utils._typedefs cimport int8_t from sklearn.tree._criterion cimport Criterion from sklearn.tree._partitioner cimport ( FEATURE_THRESHOLD, DensePartitioner, SparsePartitioner, - shift_missing_values_to_left_if_required ) from sklearn.tree._utils cimport RAND_R_MAX, rand_int, rand_uniform @@ -42,9 +41,6 @@ ctypedef fused Partitioner: SparsePartitioner -cdef float64_t INFINITY = np.inf - - cdef inline void _init_split(SplitRecord* self, intp_t start_pos) noexcept nogil: self.impurity_left = INFINITY self.impurity_right = INFINITY @@ -53,7 +49,6 @@ cdef inline void _init_split(SplitRecord* self, intp_t start_pos) noexcept nogil self.threshold = 0. self.improvement = -INFINITY self.missing_go_to_left = False - self.n_missing = 0 cdef class Splitter: """Abstract splitter class. @@ -194,8 +189,6 @@ cdef class Splitter: self.y = y self.sample_weight = sample_weight - if missing_values_in_feature_mask is not None: - self.criterion.init_sum_missing() return 0 cdef int node_reset( @@ -291,7 +284,6 @@ cdef inline int node_split_best( cdef intp_t n_left, n_right cdef bint missing_go_to_left - cdef intp_t[::1] samples = splitter.samples cdef intp_t[::1] features = splitter.features cdef intp_t[::1] constant_features = splitter.constant_features cdef intp_t n_features = splitter.n_features @@ -396,38 +388,35 @@ cdef inline int node_split_best( f_i -= 1 features[f_i], features[f_j] = features[f_j], features[f_i] has_missing = n_missing != 0 - criterion.init_missing(n_missing) # initialize even when n_missing == 0 # Evaluate all splits # If there are missing values, then we search twice for the most optimal split. - # The first search will have all the missing values going to the right node. + # The first search will have all the missing values going to the right node + # and the split with right node being only missing values is evaluated. # The second search will have all the missing values going to the left node. + # This logic is governed by the partitionner and used here, so there is a strong coupling. # If there are no missing values, then we search only once for the most # optimal split. n_searches = 2 if has_missing else 1 for i in range(n_searches): missing_go_to_left = i == 1 - criterion.missing_go_to_left = missing_go_to_left + if missing_go_to_left: + partitioner.shift_missing_to_the_left() + criterion.reset() p = start - while p < end_non_missing: - partitioner.next_p(&p_prev, &p) - - if p >= end_non_missing: + while p < end: + partitioner.next_p(&p_prev, &p, missing_go_to_left) + if p == end: continue - if missing_go_to_left: - n_left = p - start + n_missing - n_right = end_non_missing - p - else: - n_left = p - start - n_right = end_non_missing - p + n_missing - # Reject if min_samples_leaf is not guaranteed + n_left = p - start + n_right = end - p if n_left < min_samples_leaf or n_right < min_samples_leaf: continue @@ -455,21 +444,23 @@ cdef inline int node_split_best( if current_proxy_improvement > best_proxy_improvement: best_proxy_improvement = current_proxy_improvement - # sum of halves is used to avoid infinite value - current_split.threshold = ( - feature_values[p_prev] / 2.0 + feature_values[p] / 2.0 - ) - - if ( - current_split.threshold == feature_values[p] or - current_split.threshold == INFINITY or - current_split.threshold == -INFINITY - ): - current_split.threshold = feature_values[p_prev] - - current_split.n_missing = n_missing + if p == end_non_missing and not missing_go_to_left: + # Split with the right node being only the missing values. + # Note that partioner.next_p never considers candidate + # splits for which the left node would move only the + # the missing values as this would be redundant with the + # split that only send missing values to the right. + # We use inf as a threshold because nan <= inf is false + # according to IEEE 754. + current_split.threshold = INFINITY + else: + # Split between two non-missing values: sum of halves is + # used to avoid infinite value. + current_split.threshold = ( + feature_values[p_prev] / 2.0 + feature_values[p] / 2.0 + ) - # if there are no missing values in the training data, during + # If there are no missing values in the training data, during # test time, we send missing values to the branch that contains # the most samples during training time. if n_missing == 0: @@ -479,53 +470,24 @@ cdef inline int node_split_best( best_split = current_split # copy - # Evaluate when there are missing values and all missing values goes - # to the right node and non-missing values goes to the left node. - if has_missing: - n_left, n_right = end - start - n_missing, n_missing - p = end - n_missing - missing_go_to_left = 0 - - if not (n_left < min_samples_leaf or n_right < min_samples_leaf): - criterion.missing_go_to_left = missing_go_to_left - criterion.update(p) - - if not ((criterion.weighted_n_left < min_weight_leaf) or - (criterion.weighted_n_right < min_weight_leaf)): - current_proxy_improvement = criterion.proxy_impurity_improvement() - - if current_proxy_improvement > best_proxy_improvement: - best_proxy_improvement = current_proxy_improvement - current_split.threshold = INFINITY - current_split.missing_go_to_left = missing_go_to_left - current_split.n_missing = n_missing - current_split.pos = p - best_split = current_split - # Reorganize into samples[start:best_split.pos] + samples[best_split.pos:end] if best_split.pos < end: partitioner.partition_samples_final( - best_split.pos, - best_split.threshold, - best_split.feature, - best_split.n_missing + &best_split ) - criterion.init_missing(best_split.n_missing) - criterion.missing_go_to_left = best_split.missing_go_to_left criterion.reset() criterion.update(best_split.pos) criterion.children_impurity( &best_split.impurity_left, &best_split.impurity_right ) + best_split.improvement = criterion.impurity_improvement( impurity, best_split.impurity_left, best_split.impurity_right ) - shift_missing_values_to_left_if_required(&best_split, samples, end) - # Respect invariant for constant features: the original order of # element in features[:n_known_constants] must be preserved for sibling # and child nodes @@ -560,13 +522,11 @@ cdef inline int node_split_random( # Draw random splits and pick the best cdef intp_t start = splitter.start cdef intp_t end = splitter.end - cdef intp_t end_non_missing cdef intp_t n_missing = 0 cdef bint has_missing = 0 cdef intp_t n_left, n_right cdef bint missing_go_to_left - cdef intp_t[::1] samples = splitter.samples cdef intp_t[::1] features = splitter.features cdef intp_t[::1] constant_features = splitter.constant_features cdef intp_t n_features = splitter.n_features @@ -649,11 +609,10 @@ cdef inline int node_split_random( current_split.feature, &min_feature_value, &max_feature_value ) n_missing = partitioner.n_missing - end_non_missing = end - n_missing if ( # All values for this feature are missing, or - end_non_missing == start or + end - start == n_missing or # This feature is considered constant (max - min <= FEATURE_THRESHOLD) (max_feature_value <= min_feature_value + FEATURE_THRESHOLD and n_missing == 0) ): @@ -669,7 +628,6 @@ cdef inline int node_split_random( f_i -= 1 features[f_i], features[f_j] = features[f_j], features[f_i] has_missing = n_missing != 0 - criterion.init_missing(n_missing) # Draw a random threshold current_split.threshold = rand_uniform( @@ -691,22 +649,17 @@ cdef inline int node_split_random( missing_go_to_left = rand_int(0, 2, random_state) else: missing_go_to_left = 0 - criterion.missing_go_to_left = missing_go_to_left if current_split.threshold == max_feature_value: current_split.threshold = min_feature_value # Partition current_split.pos = partitioner.partition_samples( - current_split.threshold + current_split.threshold, missing_go_to_left ) - if missing_go_to_left: - n_left = current_split.pos - start + n_missing - n_right = end_non_missing - current_split.pos - else: - n_left = current_split.pos - start - n_right = end_non_missing - current_split.pos + n_missing + n_left = current_split.pos - start + n_right = end - current_split.pos # Reject if min_samples_leaf is not guaranteed if n_left < min_samples_leaf or n_right < min_samples_leaf: @@ -738,8 +691,6 @@ cdef inline int node_split_random( current_proxy_improvement = criterion.proxy_impurity_improvement() if current_proxy_improvement > best_proxy_improvement: - current_split.n_missing = n_missing - # if there are no missing values in the training data, during # test time, we send missing values to the branch that contains # the most samples during training time. @@ -755,13 +706,8 @@ cdef inline int node_split_random( if best_split.pos < end: if current_split.feature != best_split.feature: partitioner.partition_samples_final( - best_split.pos, - best_split.threshold, - best_split.feature, - best_split.n_missing + &best_split ) - criterion.init_missing(best_split.n_missing) - criterion.missing_go_to_left = best_split.missing_go_to_left criterion.reset() criterion.update(best_split.pos) @@ -774,8 +720,6 @@ cdef inline int node_split_random( best_split.impurity_right ) - shift_missing_values_to_left_if_required(&best_split, samples, end) - # Respect invariant for constant features: the original order of # element in features[:n_known_constants] must be preserved for sibling # and child nodes diff --git a/sklearn/tree/_tree.pxd b/sklearn/tree/_tree.pxd index 593f8d0c5f542..df56e53bcca86 100644 --- a/sklearn/tree/_tree.pxd +++ b/sklearn/tree/_tree.pxd @@ -6,10 +6,11 @@ import numpy as np cimport numpy as cnp -from sklearn.utils._typedefs cimport float32_t, float64_t, intp_t, int32_t, uint8_t, uint32_t +from sklearn.utils._typedefs cimport ( + float32_t, float64_t, intp_t, int32_t, uint8_t, uint32_t +) +from sklearn.tree._splitter cimport Splitter, SplitRecord -from sklearn.tree._splitter cimport Splitter -from sklearn.tree._splitter cimport SplitRecord cdef struct Node: # Base storage structure for the nodes in a Tree object diff --git a/sklearn/tree/_tree.pyx b/sklearn/tree/_tree.pyx index 7044673189fb6..11d7968949357 100644 --- a/sklearn/tree/_tree.pyx +++ b/sklearn/tree/_tree.pyx @@ -3,11 +3,11 @@ from cpython cimport Py_INCREF, PyObject, PyTypeObject +from libc.math cimport INFINITY, isnan from libc.stdlib cimport free from libc.string cimport memcpy from libc.string cimport memset from libc.stdint cimport INTPTR_MAX -from libc.math cimport isnan from libcpp.vector cimport vector from libcpp.algorithm cimport pop_heap from libcpp.algorithm cimport push_heap @@ -21,7 +21,9 @@ cimport numpy as cnp cnp.import_array() from scipy.sparse import issparse -from scipy.sparse import csr_matrix +from scipy.sparse import csr_array + +from sklearn.utils import _align_api_if_sparse from sklearn.tree._utils cimport safe_realloc from sklearn.tree._utils cimport sizet_ptr_to_ndarray @@ -37,10 +39,6 @@ cdef extern from "numpy/arrayobject.h": # Types and constants # ============================================================================= -from numpy import float32 as DTYPE -from numpy import float64 as DOUBLE - -cdef float64_t INFINITY = np.inf cdef float64_t EPSILON = np.finfo('double').eps # Some handy constants (BestFirstTreeBuilder) @@ -96,19 +94,19 @@ cdef class TreeBuilder: X = X.tocsc() X.sort_indices() - if X.data.dtype != DTYPE: - X.data = np.ascontiguousarray(X.data, dtype=DTYPE) + if X.data.dtype != np.float32: + X.data = np.ascontiguousarray(X.data, dtype=np.float32) if X.indices.dtype != np.int32 or X.indptr.dtype != np.int32: raise ValueError("No support for np.int64 index based " "sparse matrices") - elif X.dtype != DTYPE: + elif X.dtype != np.float32: # since we have to copy we will make it fortran for efficiency - X = np.asfortranarray(X, dtype=DTYPE) + X = np.asfortranarray(X, dtype=np.float32) if sample_weight is not None and not sample_weight.base.flags.contiguous: - sample_weight = np.asarray(sample_weight, dtype=DOUBLE, order="C") + sample_weight = np.asarray(sample_weight, dtype=np.float64, order="C") return X, y, sample_weight @@ -618,7 +616,7 @@ cdef class BestFirstTreeBuilder(TreeBuilder): if node_id == INTPTR_MAX: return -1 - # compute values also for split nodes (might become leafs later). + # compute values also for split nodes (might become leaves later). splitter.node_value(tree.value + node_id * tree.value_stride) if splitter.with_monotonic_cst: splitter.clip_node_value(tree.value + node_id * tree.value_stride, parent_record.lower_bound, parent_record.upper_bound) @@ -961,7 +959,7 @@ cdef class Tree: raise ValueError("X should be in np.ndarray format, got %s" % type(X)) - if X.dtype != DTYPE: + if X.dtype != np.float32: raise ValueError("X.dtype should be np.float32, got %s" % X.dtype) # Extract input @@ -1002,10 +1000,10 @@ cdef class Tree: """ # Check input if not (issparse(X) and X.format == 'csr'): - raise ValueError("X should be in csr_matrix format, got %s" + raise ValueError("X should be in CSR sparse format, got %s" % type(X)) - if X.dtype != DTYPE: + if X.dtype != np.float32: raise ValueError("X.dtype should be np.float32, got %s" % X.dtype) # Extract input @@ -1081,7 +1079,7 @@ cdef class Tree: raise ValueError("X should be in np.ndarray format, got %s" % type(X)) - if X.dtype != DTYPE: + if X.dtype != np.float32: raise ValueError("X.dtype should be np.float32, got %s" % X.dtype) # Extract input @@ -1127,20 +1125,20 @@ cdef class Tree: indices = indices[:indptr[n_samples]] cdef intp_t[:] data = np.ones(shape=len(indices), dtype=np.intp) - out = csr_matrix((data, indices, indptr), - shape=(n_samples, self.node_count)) + out = csr_array((data, indices, indptr), + shape=(n_samples, self.node_count)) - return out + return _align_api_if_sparse(out) cdef inline object _decision_path_sparse_csr(self, object X): """Finds the decision path (=node) for each sample in X.""" # Check input if not (issparse(X) and X.format == "csr"): - raise ValueError("X should be in csr_matrix format, got %s" + raise ValueError("X should be in CSR sparse format, got %s" % type(X)) - if X.dtype != DTYPE: + if X.dtype != np.float32: raise ValueError("X.dtype should be np.float32, got %s" % X.dtype) # Extract input @@ -1211,10 +1209,10 @@ cdef class Tree: indices = indices[:indptr[n_samples]] cdef intp_t[:] data = np.ones(shape=len(indices), dtype=np.intp) - out = csr_matrix((data, indices, indptr), - shape=(n_samples, self.node_count)) + out = csr_array((data, indices, indptr), + shape=(n_samples, self.node_count)) - return out + return _align_api_if_sparse(out) cpdef compute_node_depths(self): """Compute the depth of each node in a tree. diff --git a/sklearn/tree/_utils.pxd b/sklearn/tree/_utils.pxd index 97f8d60645b04..5aba8f532203d 100644 --- a/sklearn/tree/_utils.pxd +++ b/sklearn/tree/_utils.pxd @@ -6,7 +6,7 @@ cimport numpy as cnp from sklearn.tree._tree cimport Node from sklearn.neighbors._quad_tree cimport Cell -from sklearn.utils._typedefs cimport float32_t, float64_t, intp_t, uint8_t, int32_t, uint32_t +from sklearn.utils._typedefs cimport float32_t, float64_t, intp_t, uint8_t, uint32_t cdef enum: diff --git a/sklearn/tree/_utils.pyx b/sklearn/tree/_utils.pyx index 695a86e9a8f68..af60cdb44a975 100644 --- a/sklearn/tree/_utils.pyx +++ b/sklearn/tree/_utils.pyx @@ -4,10 +4,8 @@ from libc.stdlib cimport free from libc.stdlib cimport realloc from libc.math cimport log as ln -from libc.math cimport isnan from libc.string cimport memset -import numpy as np cimport numpy as cnp cnp.import_array() @@ -67,25 +65,6 @@ cdef inline float64_t log(float64_t x) noexcept nogil: return ln(x) / ln(2.0) -def _any_isnan_axis0(const float32_t[:, :] X): - """Same as np.any(np.isnan(X), axis=0)""" - cdef: - intp_t i, j - intp_t n_samples = X.shape[0] - intp_t n_features = X.shape[1] - uint8_t[::1] isnan_out = np.zeros(X.shape[1], dtype=np.bool_) - - with nogil: - for i in range(n_samples): - for j in range(n_features): - if isnan_out[j]: - continue - if isnan(X[i, j]): - isnan_out[j] = True - break - return np.asarray(isnan_out) - - cdef class WeightedFenwickTree: """ Fenwick tree (Binary Indexed Tree) specialized for maintaining: diff --git a/sklearn/tree/tests/test_export.py b/sklearn/tree/tests/test_export.py index ed1f171c7b7bf..433e387906333 100644 --- a/sklearn/tree/tests/test_export.py +++ b/sklearn/tree/tests/test_export.py @@ -11,7 +11,6 @@ from numpy.random import RandomState from sklearn.base import is_classifier -from sklearn.ensemble import GradientBoostingClassifier from sklearn.exceptions import NotFittedError from sklearn.tree import ( DecisionTreeClassifier, @@ -20,6 +19,10 @@ export_text, plot_tree, ) +from sklearn.tree._export import _rgb_to_hexstring + +CLF_CRITERIONS = ("gini", "log_loss") +REG_CRITERIONS = ("squared_error", "absolute_error", "poisson") # toy sample X = [[-2, -1], [-1, -1], [-1, -2], [1, 1], [1, 2], [2, 1]] @@ -29,6 +32,17 @@ y_degraded = [1, 1, 1, 1, 1, 1] +def test_rgb_to_hexstring(): + """ + Test that _rgb_to_hexstring correctly converts an RGB tuple to a hex color string. + + A previous bug caused incorrect hex color string generation for zero values + in the RGB tuple. + """ + + assert _rgb_to_hexstring((0, 255, 0)) == "#00ff00" + + def test_graphviz_toy(): # Check correctness of export_graphviz clf = DecisionTreeClassifier( @@ -389,19 +403,20 @@ def test_graphviz_errors(): export_graphviz(clf, out, class_names=[]) -def test_friedman_mse_in_graphviz(): - clf = DecisionTreeRegressor(criterion="friedman_mse", random_state=0) - clf.fit(X, y) +@pytest.mark.parametrize("criterion", CLF_CRITERIONS + REG_CRITERIONS) +def test_criterion_in_gradient_boosting_graphviz(criterion): dot_data = StringIO() - export_graphviz(clf, out_file=dot_data) - clf = GradientBoostingClassifier(n_estimators=2, random_state=0) - clf.fit(X, y) - for estimator in clf.estimators_: - export_graphviz(estimator[0], out_file=dot_data) + is_reg = criterion in REG_CRITERIONS + Tree = DecisionTreeRegressor if is_reg else DecisionTreeClassifier + clf = Tree(random_state=0, criterion=criterion) + # positive values for poisson criterion: + y_ = [yi + 2 for yi in y] if is_reg else y + clf.fit(X, y_) + export_graphviz(clf, out_file=dot_data) for finding in finditer(r"\[.*?samples.*?\]", dot_data.getvalue()): - assert "friedman_mse" in finding.group() + assert criterion in finding.group() def test_precision(): @@ -411,9 +426,7 @@ def test_precision(): (rng_reg.random_sample((5, 2)), rng_clf.random_sample((1000, 4))), (rng_reg.random_sample((5,)), rng_clf.randint(2, size=(1000,))), ( - DecisionTreeRegressor( - criterion="friedman_mse", random_state=0, max_depth=1 - ), + DecisionTreeRegressor(random_state=0, max_depth=1), DecisionTreeClassifier(max_depth=1, random_state=0), ), ): @@ -436,7 +449,7 @@ def test_precision(): if is_classifier(clf): pattern = r"gini = \d+\.\d+" else: - pattern = r"friedman_mse = \d+\.\d+" + pattern = r"squared_error = \d+\.\d+" # check impurity for finding in finditer(pattern, dot_data): diff --git a/sklearn/tree/tests/test_monotonic_tree.py b/sklearn/tree/tests/test_monotonic_tree.py index dfe39720df224..cce5e86d0c8c7 100644 --- a/sklearn/tree/tests/test_monotonic_tree.py +++ b/sklearn/tree/tests/test_monotonic_tree.py @@ -30,13 +30,22 @@ @pytest.mark.parametrize("TreeClassifier", TREE_BASED_CLASSIFIER_CLASSES) +@pytest.mark.parametrize( + "sparse_splitter, with_missing", + [ + (False, False), + (True, False), + (False, True), + ], + ids=["dense-without-missing", "sparse-without-missing", "dense-with-missing"], +) @pytest.mark.parametrize("depth_first_builder", (True, False)) -@pytest.mark.parametrize("sparse_splitter", (True, False)) @pytest.mark.parametrize("csc_container", CSC_CONTAINERS) def test_monotonic_constraints_classifications( TreeClassifier, - depth_first_builder, sparse_splitter, + depth_first_builder, + with_missing, global_random_seed, csc_container, ): @@ -72,9 +81,13 @@ def test_monotonic_constraints_classifications( max_leaf_nodes=n_samples_train, ) if hasattr(est, "random_state"): - est.set_params(**{"random_state": global_random_seed}) + est.set_params(random_state=global_random_seed) if hasattr(est, "n_estimators"): - est.set_params(**{"n_estimators": 5}) + est.set_params(n_estimators=5) + if with_missing: + generator = np.random.default_rng(seed=global_random_seed) + mask = generator.choice(2, size=X_train.shape).astype(bool) + X_train[mask] = np.nan if sparse_splitter: X_train = csc_container(X_train) est.fit(X_train, y_train) @@ -95,14 +108,23 @@ def test_monotonic_constraints_classifications( @pytest.mark.parametrize("TreeRegressor", TREE_BASED_REGRESSOR_CLASSES) +@pytest.mark.parametrize( + "sparse_splitter, with_missing", + [ + (False, False), + (True, False), + (False, True), + ], + ids=["dense-without-missing", "sparse-without-missing", "dense-with-missing"], +) @pytest.mark.parametrize("depth_first_builder", (True, False)) -@pytest.mark.parametrize("sparse_splitter", (True, False)) @pytest.mark.parametrize("criterion", ("absolute_error", "squared_error")) @pytest.mark.parametrize("csc_container", CSC_CONTAINERS) def test_monotonic_constraints_regressions( TreeRegressor, - depth_first_builder, sparse_splitter, + depth_first_builder, + with_missing, criterion, global_random_seed, csc_container, @@ -145,7 +167,11 @@ def test_monotonic_constraints_regressions( if hasattr(est, "random_state"): est.set_params(random_state=global_random_seed) if hasattr(est, "n_estimators"): - est.set_params(**{"n_estimators": 5}) + est.set_params(n_estimators=5) + if with_missing: + generator = np.random.default_rng(seed=global_random_seed) + mask = generator.choice(2, size=X_train.shape).astype(bool) + X_train[mask] = np.nan if sparse_splitter: X_train = csc_container(X_train) est.fit(X_train, y_train) @@ -190,29 +216,6 @@ def test_multiple_output_raises(TreeClassifier): est.fit(X, y) -@pytest.mark.parametrize( - "Tree", - [ - DecisionTreeClassifier, - DecisionTreeRegressor, - ExtraTreeClassifier, - ExtraTreeRegressor, - ], -) -def test_missing_values_raises(Tree): - X, y = make_classification( - n_samples=100, n_features=5, n_classes=2, n_informative=3, random_state=0 - ) - X[0, 0] = np.nan - monotonic_cst = np.zeros(X.shape[1]) - monotonic_cst[0] = 1 - est = Tree(max_depth=None, monotonic_cst=monotonic_cst, random_state=0) - - msg = "Input X contains NaN" - with pytest.raises(ValueError, match=msg): - est.fit(X, y) - - @pytest.mark.parametrize("TreeClassifier", TREE_BASED_CLASSIFIER_CLASSES) def test_bad_monotonic_cst_raises(TreeClassifier): X = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]] @@ -307,11 +310,17 @@ def test_1d_opposite_monotonicity_cst_data(TreeRegressor): @pytest.mark.parametrize("TreeRegressor", TREE_REGRESSOR_CLASSES) +@pytest.mark.parametrize("with_missing", (True, False)) @pytest.mark.parametrize("monotonic_sign", (-1, 1)) @pytest.mark.parametrize("depth_first_builder", (True, False)) @pytest.mark.parametrize("criterion", ("absolute_error", "squared_error")) def test_1d_tree_nodes_values( - TreeRegressor, monotonic_sign, depth_first_builder, criterion, global_random_seed + TreeRegressor, + with_missing, + monotonic_sign, + depth_first_builder, + criterion, + global_random_seed, ): # Adaptation from test_nodes_values in test_monotonic_constraints.py # in sklearn.ensemble._hist_gradient_boosting @@ -351,10 +360,15 @@ def test_1d_tree_nodes_values( criterion=criterion, random_state=global_random_seed, ) + if with_missing: + generator = np.random.default_rng(seed=global_random_seed) + mask = generator.choice(2, size=X.shape, p=[0.8, 0.2]).astype(bool) + X[mask] = np.nan clf.fit(X, y) assert_1d_reg_tree_children_monotonic_bounded(clf.tree_, monotonic_sign) - assert_1d_reg_monotonic(clf, monotonic_sign, np.min(X), np.max(X), 100) + min_x, max_x = np.nanmin(X), np.nanmax(X) + assert_1d_reg_monotonic(clf, monotonic_sign, min_x, max_x, 100) def assert_nd_reg_tree_children_monotonic_bounded(tree_, monotonic_cst): @@ -379,7 +393,7 @@ def assert_nd_reg_tree_children_monotonic_bounded(tree_, monotonic_cst): # Split node: check and update bounds for the children. i_left = tree_.children_left[i] i_right = tree_.children_right[i] - # unpack value from nx1x1 array + # unpack value from nx1x1 array (middle_value after clipping) middle_value = (tree_.value[i_left][0][0] + tree_.value[i_right][0][0]) / 2 if monotonic_cst[feature] == 0: @@ -460,11 +474,17 @@ def test_assert_nd_reg_tree_children_monotonic_bounded(): @pytest.mark.parametrize("TreeRegressor", TREE_REGRESSOR_CLASSES) +@pytest.mark.parametrize("with_missing", (True, False)) @pytest.mark.parametrize("monotonic_sign", (-1, 1)) @pytest.mark.parametrize("depth_first_builder", (True, False)) @pytest.mark.parametrize("criterion", ("absolute_error", "squared_error")) def test_nd_tree_nodes_values( - TreeRegressor, monotonic_sign, depth_first_builder, criterion, global_random_seed + TreeRegressor, + with_missing, + monotonic_sign, + depth_first_builder, + criterion, + global_random_seed, ): # Build tree with several features, and make sure the nodes # values respect the monotonicity constraints. @@ -508,5 +528,9 @@ def test_nd_tree_nodes_values( criterion=criterion, random_state=global_random_seed, ) + if with_missing: + generator = np.random.default_rng(seed=global_random_seed) + mask = generator.choice(2, size=X.shape, p=[0.8, 0.2]).astype(bool) + X[mask] = np.nan clf.fit(X, y) assert_nd_reg_tree_children_monotonic_bounded(clf.tree_, monotonic_cst) diff --git a/sklearn/tree/tests/test_reingold_tilford.py b/sklearn/tree/tests/test_reingold_tilford.py index bf0ce3ce2cffc..fdf25c261560f 100644 --- a/sklearn/tree/tests/test_reingold_tilford.py +++ b/sklearn/tree/tests/test_reingold_tilford.py @@ -43,7 +43,7 @@ def walk_tree(draw_tree): while True: x_at_this_depth = [node[0] for node in coordinates if node[1] == depth] if not x_at_this_depth: - # reached all leafs + # reached all leaves break assert len(np.unique(x_at_this_depth)) == len(x_at_this_depth) depth += 1 diff --git a/sklearn/tree/tests/test_split.py b/sklearn/tree/tests/test_split.py new file mode 100644 index 0000000000000..cd5a56eaf7601 --- /dev/null +++ b/sklearn/tree/tests/test_split.py @@ -0,0 +1,238 @@ +from dataclasses import dataclass +from itertools import product +from operator import itemgetter + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from scipy.sparse import csc_array +from scipy.special import xlogy + +from sklearn.metrics import mean_poisson_deviance +from sklearn.tree import ( + DecisionTreeClassifier, + DecisionTreeRegressor, + ExtraTreeClassifier, + ExtraTreeRegressor, +) +from sklearn.utils.stats import _weighted_percentile + +CLF_CRITERIONS = ("gini", "log_loss") + +REG_CRITERIONS = ("squared_error", "absolute_error", "poisson") + +CLF_TREES = { + "DecisionTreeClassifier": DecisionTreeClassifier, + "ExtraTreeClassifier": ExtraTreeClassifier, +} + +REG_TREES = { + "DecisionTreeRegressor": DecisionTreeRegressor, + "ExtraTreeRegressor": ExtraTreeRegressor, +} + + +@dataclass +class NaiveSplitter: + criterion: str + n_classes: int = 0 + + def compute_node_value_and_impurity(self, y, w): + sum_weights = np.sum(w) + if sum_weights < 1e-7: + return np.nan, np.inf # invalid split + if self.criterion in ["gini", "entropy", "log_loss"]: + pred = np.bincount(y, weights=w, minlength=self.n_classes) / sum_weights + if self.criterion == "gini": + # 1 - sum(pk^2) + loss = 1.0 - np.sum(pred**2) + else: + # -sum(pk * log2(pk)) + loss = -np.sum(xlogy(pred, pred)) / np.log(2) + elif self.criterion == "squared_error": + pred = np.average(y, weights=w) + loss = np.average((y - pred) ** 2, weights=w) + elif self.criterion == "absolute_error": + pred = _weighted_percentile(y, w, percentile_rank=50, average=True) + loss = np.average(np.abs(y - pred), weights=w) + elif self.criterion == "poisson": + pred = np.average(y, weights=w) + loss = mean_poisson_deviance(y, np.repeat(pred, y.size), sample_weight=w) + loss *= 1 / 2 + else: + raise ValueError(f"Unknown criterion: {self.criterion}") + return pred, loss * sum_weights + + def compute_split_nodes(self, X, y, w, feature, threshold=None, missing_left=False): + x = X[:, feature] + go_left = x <= threshold + if missing_left: + go_left |= np.isnan(x) + return ( + self.compute_node_value_and_impurity(y[go_left], w[go_left]), + self.compute_node_value_and_impurity(y[~go_left], w[~go_left]), + ) + + def compute_split_impurity( + self, X, y, w, feature, threshold=None, missing_left=False + ): + nodes = self.compute_split_nodes(X, y, w, feature, threshold, missing_left) + (_, left_impurity), (_, right_impurity) = nodes + return left_impurity + right_impurity + + def _generate_all_splits(self, X): + for f in range(X.shape[1]): + x = X[:, f] + nan_mask = np.isnan(x) + thresholds = np.unique(x[~nan_mask]) + for th in thresholds: + yield { + "feature": f, + "threshold": th, + "missing_left": False, + } + if not nan_mask.any(): + continue + for th in [*thresholds, -np.inf]: + # include -inf to test the split with only NaNs on the left node + yield { + "feature": f, + "threshold": th, + "missing_left": True, + } + + def best_split_naive(self, X, y, w): + splits = list(self._generate_all_splits(X)) + if len(splits) == 0: + return (np.inf, None) + + split_impurities = [ + self.compute_split_impurity(X, y, w, **split) for split in splits + ] + + return min(zip(split_impurities, splits), key=itemgetter(0)) + + +def make_simple_dataset( + n, + d, + with_nans, + is_sparse, + is_clf, + n_classes, + rng, +): + X_dense = rng.random((n, d)) + y = rng.random(n) + X_dense.sum(axis=1) + w = rng.integers(0, 5, size=n) if rng.uniform() < 0.5 else rng.random(n) + + with_duplicates = rng.integers(2) == 0 + if with_duplicates: + X_dense = X_dense.round(1 if n < 50 else 2) + if with_nans: + nan_density = rng.uniform(0.05, 0.8) + mask = rng.random(X_dense.shape) < nan_density + X_dense[mask] = np.nan + if is_sparse: + density = rng.uniform(0.05, 0.99) + X_dense -= 0.5 + mask = rng.random(X_dense.shape) > density + X_dense[mask] = 0 + X = csc_array(X_dense) + else: + X = X_dense + + if is_clf: + q = np.linspace(0, 1, num=n_classes + 1)[1:-1] + y = np.searchsorted(np.quantile(y, q), y) + + # Trees cast X to float32 internally; match that dtype here to avoid + # routing/impurity mismatches from rounding with `<=`. + return X_dense.astype("float32"), X, y, w + + +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") +@pytest.mark.parametrize( + "Tree, criterion", + [ + *product(REG_TREES.values(), REG_CRITERIONS), + *product(CLF_TREES.values(), CLF_CRITERIONS), + ], +) +@pytest.mark.parametrize( + "sparse, missing_values", + [(False, False), (True, False), (False, True)], + ids=["dense-without_missing", "sparse-without_missing", "dense-with_missing"], +) +def test_split_impurity(Tree, criterion, sparse, missing_values, global_random_seed): + is_clf = criterion in CLF_CRITERIONS + + rng = np.random.default_rng(global_random_seed) + + ns = [5] * 5 + [10] * 5 + [20, 30, 50, 100] + + for it, n in enumerate(ns): + d = rng.integers(1, 4) + n_classes = rng.integers(2, 5) # only used for classification + X_dense, X, y, w = make_simple_dataset( + n, d, missing_values, sparse, is_clf, n_classes, rng + ) + + naive_splitter = NaiveSplitter(criterion, n_classes) + + tree = Tree( + criterion=criterion, + max_depth=1, + random_state=global_random_seed, + ) + tree.fit(X, y, sample_weight=w) + actual_impurity = tree.tree_.impurity * tree.tree_.weighted_n_node_samples + actual_value = tree.tree_.value[:, 0] + + # Check root's impurity: + # The root is 0, left child is 1 and right child is 2. + root_val, root_impurity = naive_splitter.compute_node_value_and_impurity(y, w) + assert_allclose(root_impurity, actual_impurity[0], atol=1e-12) + assert_allclose(root_val, actual_value[0], atol=1e-12) + + if tree.tree_.node_count == 1: + # if no splits was made assert that either: + assert ( + "Extra" in Tree.__name__ + or root_impurity < 1e-12 # root impurity is 0 + # or no valid split can be made: + or naive_splitter.best_split_naive(X_dense, y, w)[0] == np.inf + ) + continue + + # Check children impurity: + actual_split = { + "feature": int(tree.tree_.feature[0]), + "threshold": tree.tree_.threshold[0], + "missing_left": bool(tree.tree_.missing_go_to_left[0]), + } + nodes = naive_splitter.compute_split_nodes(X_dense, y, w, **actual_split) + (left_val, left_impurity), (right_val, right_impurity) = nodes + assert_allclose(left_impurity, actual_impurity[1], atol=1e-12) + assert_allclose(right_impurity, actual_impurity[2], atol=1e-12) + assert_allclose(left_val, actual_value[1], atol=1e-12) + assert_allclose(right_val, actual_value[2], atol=1e-12) + + if "Extra" in Tree.__name__: + # The remainder of the test checks for optimality of the found split. + # However, randomized trees are not guaranteed to find an optimal split + # but only a "better-than-nothing" split. + # Therefore, end the test here for these models. + continue + + # Check that the selected split has the same impurity as the best split + # found by the naive splitter. Note that there could exist multiple splits + # with the same optimal impurity, so the assertion is made on the impurity + # value: the split value is only displayed to help debugging in case + # of assertion failure. + best_impurity, best_split = naive_splitter.best_split_naive(X_dense, y, w) + actual_split_impurity = actual_impurity[1:].sum() + assert np.isclose(best_impurity, actual_split_impurity), ( + best_split, + actual_split, + ) diff --git a/sklearn/tree/tests/test_swap.py b/sklearn/tree/tests/test_swap.py new file mode 100644 index 0000000000000..c465f2cb07ede --- /dev/null +++ b/sklearn/tree/tests/test_swap.py @@ -0,0 +1,43 @@ +import numpy as np +import pytest + +from sklearn.tree._partitioner import _py_swap_array_slices + + +@pytest.mark.parametrize("dtype", [np.float32, np.intp]) +def test_py_swap_array_slices_random(dtype, global_random_seed): + def swap_slices_np(arr, start, end, n): + """ + Swaps the two slices array[start:start + n] and + array[start + n:end] while preserving the order in the slices. + """ + arr = arr.copy() + arr[start:end] = np.concatenate([arr[start + n : end], arr[start : start + n]]) + return arr + + rng = np.random.default_rng(global_random_seed) + + for _ in range(20): + size = rng.integers(1, 101) + arr = rng.permutation(size).astype(dtype) + n = rng.integers(0, size) + start = rng.integers(0, size - n) + end = rng.integers(start + n, size) + # test the swap of arr[start:start + n] with arr[start + n:end] + expected = swap_slices_np(arr, start, end, n) + + _py_swap_array_slices(arr, start, end, n) + np.testing.assert_array_equal(arr, expected) + + # test some edge cases: + size = 30 + n = 10 + start = rng.integers(0, size - n) + arr = np.arange(size, dtype=dtype) + expected = arr.copy() + # n == end - start should be no-op: + _py_swap_array_slices(arr, start, start + n, n) + np.testing.assert_array_equal(arr, expected) + # n == 0 should be no-op: + _py_swap_array_slices(arr, start, size, 0) + np.testing.assert_array_equal(arr, expected) diff --git a/sklearn/tree/tests/test_tree.py b/sklearn/tree/tests/test_tree.py index 951e6e1f1e581..eb6d52c49a037 100644 --- a/sklearn/tree/tests/test_tree.py +++ b/sklearn/tree/tests/test_tree.py @@ -42,7 +42,6 @@ SPARSE_SPLITTERS, ) from sklearn.tree._criterion import _py_precompute_absolute_errors -from sklearn.tree._partitioner import _py_sort from sklearn.tree._tree import ( NODE_DTYPE, TREE_LEAF, @@ -267,6 +266,8 @@ def test_weighted_classification_toy(): assert_array_equal(clf.predict(T), true_result, "Failed with {0}".format(name)) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("Tree", REG_TREES.values()) @pytest.mark.parametrize("criterion", REG_CRITERIONS) def test_regression_toy(Tree, criterion): @@ -291,7 +292,7 @@ def test_regression_toy(Tree, criterion): def test_xor(): - # Check on a XOR problem + # Check on an XOR problem y = np.zeros((10, 10)) y[:5, :5] = 1 y[5:, 5:] = 1 @@ -329,6 +330,8 @@ def test_iris(): ) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("name, Tree", REG_TREES.items()) @pytest.mark.parametrize("criterion", REG_CRITERIONS) def test_diabetes_overfit(name, Tree, criterion): @@ -342,6 +345,8 @@ def test_diabetes_overfit(name, Tree, criterion): ) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("Tree", REG_TREES.values()) @pytest.mark.parametrize( "criterion, metric", @@ -615,7 +620,7 @@ def test_error(): def test_min_samples_split(): """Test min_samples_split parameter""" - X = np.asfortranarray(iris.data, dtype=tree._tree.DTYPE) + X = np.asfortranarray(iris.data, dtype=np.float32) y = iris.target # test both DepthFirstTreeBuilder and BestFirstTreeBuilder @@ -646,7 +651,7 @@ def test_min_samples_split(): def test_min_samples_leaf(): # Test if leaves contain more than leaf_count training examples - X = np.asfortranarray(iris.data, dtype=tree._tree.DTYPE) + X = np.asfortranarray(iris.data, dtype=np.float32) y = iris.target # test both DepthFirstTreeBuilder and BestFirstTreeBuilder @@ -823,76 +828,49 @@ def test_min_weight_fraction_leaf_with_min_samples_leaf_on_sparse_input( ) -def test_min_impurity_decrease(global_random_seed): +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") +@pytest.mark.parametrize( + "TreeEstimator, criterion", + [ + *product(REG_TREES.values(), REG_CRITERIONS), + *product(CLF_TREES.values(), CLF_CRITERIONS), + ], +) +def test_min_impurity_decrease(TreeEstimator, criterion, global_random_seed): # test if min_impurity_decrease ensure that a split is made only if # if the impurity decrease is at least that value X, y = datasets.make_classification(n_samples=100, random_state=global_random_seed) # test both DepthFirstTreeBuilder and BestFirstTreeBuilder # by setting max_leaf_nodes - for max_leaf_nodes, name in product((None, 1000), ALL_TREES.keys()): - TreeEstimator = ALL_TREES[name] - - # Check default value of min_impurity_decrease, 1e-7 - est1 = TreeEstimator(max_leaf_nodes=max_leaf_nodes, random_state=0) - # Check with explicit value of 0.05 - est2 = TreeEstimator( - max_leaf_nodes=max_leaf_nodes, min_impurity_decrease=0.05, random_state=0 - ) - # Check with a much lower value of 0.0001 - est3 = TreeEstimator( - max_leaf_nodes=max_leaf_nodes, min_impurity_decrease=0.0001, random_state=0 - ) - # Check with a much lower value of 0.1 - est4 = TreeEstimator( - max_leaf_nodes=max_leaf_nodes, min_impurity_decrease=0.1, random_state=0 - ) - - for est, expected_decrease in ( - (est1, 1e-7), - (est2, 0.05), - (est3, 0.0001), - (est4, 0.1), - ): - assert est.min_impurity_decrease <= expected_decrease, ( - "Failed, min_impurity_decrease = {0} > {1}".format( - est.min_impurity_decrease, expected_decrease - ) + for max_leaf_nodes in [None, 1000]: + for expected_decrease in [0.05, 0.0001, 0.1]: + est = TreeEstimator( + criterion=criterion, + max_leaf_nodes=max_leaf_nodes, + min_impurity_decrease=expected_decrease, + random_state=global_random_seed, ) est.fit(X, y) - for node in range(est.tree_.node_count): + tree = est.tree_ + weighted_impurity = ( + tree.impurity * tree.weighted_n_node_samples / X.shape[0] + ) + + for node in range(tree.node_count): # If current node is a not leaf node, check if the split was # justified w.r.t the min_impurity_decrease - if est.tree_.children_left[node] != TREE_LEAF: - imp_parent = est.tree_.impurity[node] - wtd_n_node = est.tree_.weighted_n_node_samples[node] - - left = est.tree_.children_left[node] - wtd_n_left = est.tree_.weighted_n_node_samples[left] - imp_left = est.tree_.impurity[left] - wtd_imp_left = wtd_n_left * imp_left + if tree.children_left[node] != TREE_LEAF: + left = tree.children_left[node] + right = tree.children_right[node] - right = est.tree_.children_right[node] - wtd_n_right = est.tree_.weighted_n_node_samples[right] - imp_right = est.tree_.impurity[right] - wtd_imp_right = wtd_n_right * imp_right - - wtd_avg_left_right_imp = wtd_imp_right + wtd_imp_left - wtd_avg_left_right_imp /= wtd_n_node - - fractional_node_weight = ( - est.tree_.weighted_n_node_samples[node] / X.shape[0] + actual_decrease = weighted_impurity[node] - ( + weighted_impurity[left] + weighted_impurity[right] ) - actual_decrease = fractional_node_weight * ( - imp_parent - wtd_avg_left_right_imp - ) - - assert actual_decrease >= expected_decrease, ( - "Failed with {0} expected min_impurity_decrease={1}".format( - actual_decrease, expected_decrease - ) - ) + # Allow a tiny slack to account for floating-point rounding errors: + assert actual_decrease > expected_decrease - 1e-10 def test_pickle(): @@ -946,6 +924,8 @@ def test_pickle(): ) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize( "Tree, criterion", [ @@ -1235,7 +1215,7 @@ def test_class_weight_errors(name): def test_max_leaf_nodes(): - # Test greedy trees with max_depth + 1 leafs. + # Test greedy trees with max_depth + 1 leaves. X, y = datasets.make_hastie_10_2(n_samples=100, random_state=1) k = 4 for name, TreeEstimator in ALL_TREES.items(): @@ -1287,7 +1267,7 @@ def test_almost_constant_feature(tree_cls): # Make sure that almost constant features are discarded. random_state = check_random_state(0) X = random_state.rand(10, 2) - # FEATURE_TRESHOLD=1e-7 is defined in sklearn/tree/_partitioner.pxd but not + # FEATURE_THRESHOLD=1e-7 is defined in sklearn/tree/_partitioner.pxd but not # accessible from Python feature_threshold = 1e-7 X[:, 0] *= feature_threshold # almost constant feature @@ -1490,6 +1470,8 @@ def test_sparse_parameters(tree_type, dataset, csc_container): assert_array_almost_equal(s.predict(X), d.predict(X)) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize( "tree_type, criterion", list(product([tree for tree in SPARSE_TREES if tree in REG_TREES], REG_CRITERIONS)) @@ -1639,7 +1621,7 @@ def test_min_weight_leaf_split_level(name, sparse_container): @pytest.mark.parametrize("name", ALL_TREES) def test_public_apply_all_trees(name): - X_small32 = X_small.astype(tree._tree.DTYPE, copy=False) + X_small32 = X_small.astype(np.float32, copy=False) est = ALL_TREES[name]() est.fit(X_small, y_small) @@ -1649,7 +1631,7 @@ def test_public_apply_all_trees(name): @pytest.mark.parametrize("name", SPARSE_TREES) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_public_apply_sparse_trees(name, csr_container): - X_small32 = csr_container(X_small.astype(tree._tree.DTYPE, copy=False)) + X_small32 = csr_container(X_small.astype(np.float32, copy=False)) est = ALL_TREES[name]() est.fit(X_small, y_small) @@ -2010,13 +1992,13 @@ def assert_is_subtree(tree, subtree): @pytest.mark.parametrize("sparse_container", [None] + CSC_CONTAINERS + CSR_CONTAINERS) def test_apply_path_readonly_all_trees(name, splitter, sparse_container): dataset = DATASETS["clf_small"] - X_small = dataset["X"].astype(tree._tree.DTYPE, copy=False) + X_small = dataset["X"].astype(np.float32, copy=False) if sparse_container is None: X_readonly = create_memmap_backed_data(X_small) else: X_readonly = sparse_container(dataset["X"]) - X_readonly.data = np.array(X_readonly.data, dtype=tree._tree.DTYPE) + X_readonly.data = np.array(X_readonly.data, dtype=np.float32) ( X_readonly.data, X_readonly.indices, @@ -2025,7 +2007,7 @@ def test_apply_path_readonly_all_trees(name, splitter, sparse_container): (X_readonly.data, X_readonly.indices, X_readonly.indptr) ) - y_readonly = create_memmap_backed_data(np.array(y_small, dtype=tree._tree.DTYPE)) + y_readonly = create_memmap_backed_data(np.array(y_small, dtype=np.float32)) est = ALL_TREES[name](splitter=splitter) est.fit(X_readonly, y_readonly) assert_array_equal(est.predict(X_readonly), est.predict(X_small)) @@ -2034,6 +2016,8 @@ def test_apply_path_readonly_all_trees(name, splitter, sparse_container): ) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("criterion", ["squared_error", "friedman_mse", "poisson"]) @pytest.mark.parametrize("Tree", REG_TREES.values()) def test_balance_property(criterion, Tree): @@ -2456,18 +2440,21 @@ def test_min_sample_split_1_error(Tree): tree.fit(X, y) -@pytest.mark.parametrize("criterion", ["squared_error", "friedman_mse"]) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") +@pytest.mark.parametrize("criterion", REG_CRITERIONS) def test_missing_values_best_splitter_on_equal_nodes_no_missing(criterion): """Check missing values goes to correct node during predictions.""" X = np.array([[0, 1, 2, 3, 8, 9, 11, 12, 15]]).T y = np.array([0.1, 0.2, 0.3, 0.2, 1.4, 1.4, 1.5, 1.6, 2.6]) + node_value_func = np.median if criterion == "absolute_error" else np.mean dtc = DecisionTreeRegressor(random_state=42, max_depth=1, criterion=criterion) dtc.fit(X, y) # Goes to right node because it has the most data points y_pred = dtc.predict([[np.nan]]) - assert_allclose(y_pred, [np.mean(y[-5:])]) + assert_allclose(y_pred, [node_value_func(y[-5:])]) # equal number of elements in both nodes X_equal = X[:-1] @@ -2479,11 +2466,13 @@ def test_missing_values_best_splitter_on_equal_nodes_no_missing(criterion): # Goes to right node because the implementation sets: # missing_go_to_left = n_left > n_right, which is False y_pred = dtc.predict([[np.nan]]) - assert_allclose(y_pred, [np.mean(y_equal[-4:])]) + assert_allclose(y_pred, [node_value_func(y_equal[-4:])]) +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("seed", range(3)) -@pytest.mark.parametrize("criterion", ["squared_error", "friedman_mse"]) +@pytest.mark.parametrize("criterion", REG_CRITERIONS) def test_missing_values_random_splitter_on_equal_nodes_no_missing(criterion, seed): """Check missing values go to the correct node during predictions for ExtraTree. @@ -2581,12 +2570,12 @@ def test_missing_values_best_splitter_missing_both_classes_has_nan(criterion): assert_array_equal(y_pred, [1, 0, 1]) -@pytest.mark.parametrize("sparse_container", [None] + CSR_CONTAINERS) +@pytest.mark.parametrize("sparse_container", CSR_CONTAINERS) @pytest.mark.parametrize( "tree", [ - DecisionTreeRegressor(criterion="absolute_error"), - ExtraTreeRegressor(criterion="absolute_error"), + DecisionTreeRegressor(), + ExtraTreeRegressor(), ], ) def test_missing_value_errors(sparse_container, tree): @@ -2595,8 +2584,7 @@ def test_missing_value_errors(sparse_container, tree): X = np.array([[1, 2, 3, 5, np.nan, 10, 20, 30, 60, np.nan]]).T y = np.array([0] * 5 + [1] * 5) - if sparse_container is not None: - X = sparse_container(X) + X = sparse_container(X) with pytest.raises(ValueError, match="Input X contains NaN"): tree.fit(X, y) @@ -2757,6 +2745,8 @@ def test_deterministic_pickle(): assert pickle1 == pickle2 +# TODO(1.11): remove the deprecated friedman_mse criterion parametrization +@pytest.mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning") @pytest.mark.parametrize("Tree", [DecisionTreeRegressor, ExtraTreeRegressor]) @pytest.mark.parametrize( "X", @@ -2769,29 +2759,31 @@ def test_deterministic_pickle(): np.array([1, 2, 3, np.nan, 6, np.nan]), ], ) -@pytest.mark.parametrize("criterion", ["squared_error", "friedman_mse"]) +@pytest.mark.parametrize("criterion", REG_CRITERIONS) def test_regression_tree_missing_values_toy(Tree, X, criterion, global_random_seed): - """Check that we properly handle missing values in regression trees using a toy - dataset. + """Check that regression trees correctly handle missing values in impurity + calculations. - The regression targeted by this test was that we were not reinitializing the - criterion when it comes to the number of missing values. Therefore, the value - of the critetion (i.e. MSE) was completely wrong. - - This test check that the MSE is null when there is a single sample in the leaf. + This test verifies that: + 1. Impurity is always non-negative + 2. Impurity is zero for leaves with a single sample + 3. For decision trees, impurity matches reference trees after the first split Non-regression test for: - https://github.com/scikit-learn/scikit-learn/issues/28254 - https://github.com/scikit-learn/scikit-learn/issues/28316 + - Missing values handling in regression criteria: + https://github.com/scikit-learn/scikit-learn/issues/28254 + https://github.com/scikit-learn/scikit-learn/issues/28316 + - Incorrect/negative impurites for the Poisson criterion with missing values: + https://github.com/scikit-learn/scikit-learn/issues/32870 """ X = X.reshape(-1, 1) - y = np.arange(6) + y = np.arange(1, 7) tree = Tree(criterion=criterion, random_state=global_random_seed).fit(X, y) tree_ref = clone(tree).fit(y.reshape(-1, 1), y) impurity = tree.tree_.impurity - assert all(impurity >= 0), impurity.min() # MSE should always be positive + assert all(impurity >= 0), impurity.min() # impurity should always be positive # Note: the impurity matches after the first split only on greedy trees # see https://github.com/scikit-learn/scikit-learn/issues/32125 @@ -2799,7 +2791,7 @@ def test_regression_tree_missing_values_toy(Tree, X, criterion, global_random_se # Check the impurity match after the first split assert_allclose(tree.tree_.impurity[:2], tree_ref.tree_.impurity[:2]) - # Find the leaves with a single sample where the MSE should be 0 + # Find the leaves with a single sample where the impurity should be 0 leaves_idx = np.flatnonzero( (tree.tree_.children_left == -1) & (tree.tree_.n_node_samples == 1) ) @@ -2916,28 +2908,6 @@ def test_build_pruned_tree_infinite_loop(): _build_pruned_tree_py(pruned_tree, tree.tree_, leave_in_subtree) -def test_sort_log2_build(): - """Non-regression test for gh-30554. - - Using log2 and log in sort correctly sorts feature_values, but the tie breaking is - different which can results in placing samples in a different order. - """ - rng = np.random.default_rng(75) - some = rng.normal(loc=0.0, scale=10.0, size=10).astype(np.float32) - feature_values = np.concatenate([some] * 5) - samples = np.arange(50, dtype=np.intp) - _py_sort(feature_values, samples, 50) - # fmt: off - # no black reformatting for this specific array - expected_samples = [ - 0, 40, 30, 20, 10, 29, 39, 19, 49, 9, 45, 15, 35, 5, 25, 11, 31, - 41, 1, 21, 22, 12, 2, 42, 32, 23, 13, 43, 3, 33, 6, 36, 46, 16, - 26, 4, 14, 24, 34, 44, 27, 47, 7, 37, 17, 8, 38, 48, 28, 18 - ] - # fmt: on - assert_array_equal(samples, expected_samples) - - def test_absolute_errors_precomputation_function(global_random_seed): """ Test the main bit of logic of the MAE(RegressionCriterion) class @@ -2974,8 +2944,8 @@ def assert_same_results(y, w, indices, reverse=False): if reverse: abs_errors_ = abs_errors_[::-1] medians_ = medians_[::-1] - assert_allclose(abs_errors, abs_errors_, atol=1e-12) - assert_allclose(medians, medians_, atol=1e-12) + assert_allclose(abs_errors, abs_errors_, atol=1e-11) + assert_allclose(medians, medians_, atol=1e-11) rng = np.random.default_rng(global_random_seed) @@ -3047,3 +3017,34 @@ def test_missing_values_and_constant_toy(): assert_array_equal(tree.predict(X), y) # with just one split (-> three nodes: the root + 2 leaves) assert tree.tree_.node_count == 3 + + +def test_friedman_mse_deprecation(): + with pytest.warns(FutureWarning, match="friedman_mse"): + _ = DecisionTreeRegressor(criterion="friedman_mse") + + +@pytest.mark.parametrize( + "X,y", + [ + (np.array([[np.nan], [0.0], [1.0], [2.0]]), np.array([0.0, 1.0, 2.0, 3.0])), + (np.array([[np.nan], [1.0], [np.nan]]), np.array([0.0, 1.0, 0.0])), + ], + ids=["multiple-non-missing", "single-non-missing"], +) +def test_random_splitter_missing_values_uses_non_missing_min_max(X, y): + """ + Check random-split thresholds are finite when the first sample is missing. + + Non-regression test for a subtle bug, see + https://github.com/scikit-learn/scikit-learn/pull/32119#issuecomment-3765288780 + """ + tree = ExtraTreeRegressor(max_depth=1, random_state=0) + tree.fit(X, y) + + assert tree.tree_.children_left[0] != TREE_LEAF + threshold = tree.tree_.threshold[0] + non_missing = X[~np.isnan(X)] + + assert np.isfinite(threshold) + assert non_missing.min() <= threshold <= non_missing.max() diff --git a/sklearn/utils/__init__.py b/sklearn/utils/__init__.py index 87f015ddaa267..ffe68252e0189 100644 --- a/sklearn/utils/__init__.py +++ b/sklearn/utils/__init__.py @@ -17,6 +17,7 @@ from sklearn.utils._mask import safe_mask from sklearn.utils._repr_html.base import _HTMLDocumentationLinkMixin # noqa: F401 from sklearn.utils._repr_html.estimator import estimator_html_repr +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils._tags import ( ClassifierTags, InputTags, @@ -53,6 +54,7 @@ "Tags", "TargetTags", "TransformerTags", + "_align_api_if_sparse", "_safe_indexing", "all_estimators", "as_float_array", diff --git a/sklearn/utils/_arpack.py b/sklearn/utils/_arpack.py index 04457b71db10a..227de76c006c0 100644 --- a/sklearn/utils/_arpack.py +++ b/sklearn/utils/_arpack.py @@ -7,7 +7,7 @@ def _init_arpack_v0(size, random_state): """Initialize the starting vector for iteration in ARPACK functions. - Initialize a ndarray with values sampled from the uniform distribution on + Initialize an ndarray with values sampled from the uniform distribution on [-1, 1]. This initialization model has been chosen to be consistent with the ARPACK one as another initialization can lead to convergence issues. diff --git a/sklearn/utils/_array_api.py b/sklearn/utils/_array_api.py index 46f4bb576fa44..0d79f84bf6e66 100644 --- a/sklearn/utils/_array_api.py +++ b/sklearn/utils/_array_api.py @@ -3,9 +3,12 @@ # Authors: The scikit-learn developers # SPDX-License-Identifier: BSD-3-Clause +import inspect import itertools import math import os +from collections import namedtuple +from functools import partial import numpy import scipy @@ -16,12 +19,20 @@ from sklearn.externals import array_api_compat from sklearn.externals import array_api_extra as xpx from sklearn.externals.array_api_compat import numpy as np_compat +from sklearn.utils._dataframe import is_df_or_series from sklearn.utils.fixes import parse_version # TODO: complete __all__ __all__ = ["xpx"] # we import xpx here just to re-export it, need this to appease ruff _NUMPY_NAMESPACE_NAMES = {"numpy", "sklearn.externals.array_api_compat.numpy"} +REMOVE_TYPES_DEFAULT = ( + str, + list, + tuple, +) + +NamespaceAndDevice = namedtuple("NamespaceAndDevice", ["xp", "device"]) def yield_namespaces(include_numpy_namespaces=True): @@ -49,6 +60,7 @@ def yield_namespaces(include_numpy_namespaces=True): "array_api_strict", "cupy", "torch", + "dpnp", ]: if not include_numpy_namespaces and array_namespace in _NUMPY_NAMESPACE_NAMES: continue @@ -56,11 +68,11 @@ def yield_namespaces(include_numpy_namespaces=True): def yield_namespace_device_dtype_combinations(include_numpy_namespaces=True): - """Yield supported namespace, device, dtype tuples for testing. + """Yield supported namespace, device_name, dtype_name tuples for testing. Use this to test that an estimator works with all combinations. - Use in conjunction with `ids=_get_namespace_device_dtype_ids` to give - clearer pytest parametrization ID names. + Pass the yielded values to `_array_api_for_tests` which returns (xp, device) + for array allocation. Parameters ---------- @@ -72,7 +84,7 @@ def yield_namespace_device_dtype_combinations(include_numpy_namespaces=True): array_namespace : str The name of the Array API namespace. - device : str + device_name : str or None The name of the device on which to allocate the arrays. Can be None to indicate that the default value should be used. @@ -84,43 +96,69 @@ def yield_namespace_device_dtype_combinations(include_numpy_namespaces=True): include_numpy_namespaces=include_numpy_namespaces ): if array_namespace == "torch": - for device, dtype in itertools.product( + for device_name, dtype in itertools.product( ("cpu", "cuda", "xpu"), ("float64", "float32") ): - yield array_namespace, device, dtype + yield array_namespace, device_name, dtype yield array_namespace, "mps", "float32" + elif array_namespace == "dpnp": # pragma: nocover + # XXX: add "accelerator" device type? + for device_name, dtype in itertools.product( + ("cpu", "gpu"), ("float64", "float32") + ): + yield array_namespace, device_name, dtype + elif array_namespace == "array_api_strict": - try: - import array_api_strict - - yield array_namespace, array_api_strict.Device("CPU_DEVICE"), "float64" - yield array_namespace, array_api_strict.Device("device1"), "float32" - except ImportError: - # Those combinations will typically be skipped by pytest if - # array_api_strict is not installed but we still need to see them in - # the test output. - yield array_namespace, "CPU_DEVICE", "float64" - yield array_namespace, "device1", "float32" + # Always yield strings for consistent parametrization; _array_api_for_tests + # creates Device objects when needed. + yield array_namespace, "CPU_DEVICE", "float64" + yield array_namespace, "device1", "float32" else: yield array_namespace, None, None -def _get_namespace_device_dtype_ids(param): - """Get pytest parametrization IDs for `yield_namespace_device_dtype_combinations`""" - # Gives clearer IDs for array-api-strict devices, see #31042 for details - try: - import array_api_strict - except ImportError: - # `None` results in the default pytest representation - return None - else: - if param == array_api_strict.Device("CPU_DEVICE"): - return "CPU_DEVICE" - if param == array_api_strict.Device("device1"): - return "device1" - if param == array_api_strict.Device("device2"): - return "device2" +def yield_mixed_namespace_input_permutations(): + """Yield mixed namespace and device inputs for testing. + + We do not test for all possible permutations of namespace/device from + `yield_namespace_device_dtype_combinations` (excluding dtype variations, this is + P(8,2)=56), to avoid slow testing and maintenance burden. + + The included selection ensures that the following conversions are tested: + + * non-NumPy to NumPy (including GPU to CPU) + * NumPy to non-NumPy (including CPU to GPU) + * non-NumPy to non-NumPy (GPU to GPU) + * array-api-strict to non-NumPy (this pair also has no special hardware + requirements to allow for local testing) + """ + yield ( + NamespaceAndDevice("cupy", None), + NamespaceAndDevice("torch", "cuda"), + "cupy to torch cuda", + ) + yield ( + NamespaceAndDevice("torch", "mps"), + NamespaceAndDevice("numpy", None), + "torch mps to numpy", + ) + yield ( + NamespaceAndDevice("numpy", None), + NamespaceAndDevice("torch", "cuda"), + "numpy to torch cuda", + ) + yield ( + NamespaceAndDevice("numpy", None), + NamespaceAndDevice("torch", "mps"), + "numpy to torch mps", + ) + + yield ( + NamespaceAndDevice("array_api_strict", "device1"), + NamespaceAndDevice("torch", "cpu"), + "array_api_strict to torch cpu", + ) def _check_array_api_dispatch(array_api_dispatch): @@ -166,7 +204,7 @@ def _single_array_device(array): return array.device -def device(*array_list, remove_none=True, remove_types=(str,)): +def device(*array_list, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT): """Hardware device where the array data resides on. If the hardware device is not the same for all arrays, an error is raised. @@ -179,7 +217,7 @@ def device(*array_list, remove_none=True, remove_types=(str,)): remove_none : bool, default=True Whether to ignore None objects passed in array_list. - remove_types : tuple or list, default=(str,) + remove_types : tuple or list, default=(str, list, tuple) Types to ignore in array_list. Returns @@ -231,17 +269,6 @@ def _is_numpy_namespace(xp): return xp.__name__ in _NUMPY_NAMESPACE_NAMES -def _union1d(a, b, xp): - if _is_numpy_namespace(xp): - # avoid circular import - from sklearn.utils._unique import cached_unique - - a_unique, b_unique = cached_unique(a, b, xp=xp) - return xp.asarray(numpy.union1d(a_unique, b_unique)) - assert a.ndim == b.ndim == 1 - return xp.unique_values(xp.concat([xp.unique_values(a), xp.unique_values(b)])) - - def supported_float_dtypes(xp, device=None): """Supported floating point types for the namespace. @@ -289,7 +316,7 @@ def supported_float_dtypes(xp, device=None): return tuple(valid_float_dtypes) -def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)): +def _remove_non_arrays(*arrays, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT): """Filter arrays to exclude None and/or specific types. Sparse arrays are always filtered out. @@ -302,7 +329,7 @@ def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)): remove_none : bool, default=True Whether to ignore None objects passed in arrays. - remove_types : tuple or list, default=(str,) + remove_types : tuple or list, default=(str, list, tuple) Types to ignore in the arrays. Returns @@ -320,12 +347,34 @@ def _remove_non_arrays(*arrays, remove_none=True, remove_types=(str,)): continue if sp.issparse(array): continue + if is_df_or_series(array): + continue filtered_arrays.append(array) return filtered_arrays -def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None): +def _unwrap_memoryviewslices(*arrays): + # Since _cyutility._memoryviewslice is an implementation detail of the + # Cython runtime, we would rather not introduce a possibly brittle + # import statement to run `isinstance`-based filtering, hence the + # attribute-based type inspection. + unwrapped = [] + for a in arrays: + a_type = type(a) + if ( + a_type.__module__ == "_cyutility" + and a_type.__name__ == "_memoryviewslice" + and hasattr(a, "base") + ): + a = a.base + unwrapped.append(a) + return unwrapped + + +def get_namespace( + *arrays, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT, xp=None +): """Get namespace of arrays. Introspect `arrays` arguments and return their common Array API compatible @@ -361,7 +410,7 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None): remove_none : bool, default=True Whether to ignore None objects passed in arrays. - remove_types : tuple or list, default=(str,) + remove_types : tuple or list, default=(str, list, tuple) Types to ignore in the arrays. xp : module, default=None @@ -396,12 +445,19 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None): remove_types=remove_types, ) + # get_namespace can be called by helper functions that are used both in + # array API compatible code and non-array API Cython related code. To + # support the latter on NumPy inputs without raising a TypeError, we + # unwrap potential Cython memoryview slices here. + arrays = _unwrap_memoryviewslices(*arrays) + if not arrays: return np_compat, False _check_array_api_dispatch(array_api_dispatch) - namespace, is_array_api_compliant = array_api_compat.get_namespace(*arrays), True + namespace = array_api_compat.get_namespace(*arrays) + is_array_api_compliant = True if namespace.__name__ == "array_api_strict" and hasattr( namespace, "set_array_api_strict_flags" @@ -412,7 +468,7 @@ def get_namespace(*arrays, remove_none=True, remove_types=(str,), xp=None): def get_namespace_and_device( - *array_list, remove_none=True, remove_types=(str,), xp=None + *array_list, remove_none=True, remove_types=REMOVE_TYPES_DEFAULT, xp=None ): """Combination into one single function of `get_namespace` and `device`. @@ -422,7 +478,7 @@ def get_namespace_and_device( Array objects. remove_none : bool, default=True Whether to ignore None objects passed in arrays. - remove_types : tuple or list, default=(str,) + remove_types : tuple or list, default=(str, list, tuple) Types to ignore in the arrays. xp : module, default=None Precomputed array namespace module. When passed, typically from a caller @@ -466,10 +522,10 @@ def move_to(*arrays, xp, device): Each array will be moved to the reference namespace and device if it is not already using it. Otherwise the array is left unchanged. - `array` may contain `None` entries, these are left unchanged. + `arrays` may contain `None` entries, these are left unchanged. Sparse arrays are accepted (as pass through) if the reference namespace is - Numpy, in which case they are returned unchanged. Otherwise a `TypeError` + NumPy, in which case they are returned unchanged. Otherwise a `TypeError` is raised. Parameters @@ -489,6 +545,12 @@ def move_to(*arrays, xp, device): Tuple of arrays with the same namespace and device as reference. Single array returned if only one `arrays` input. """ + if isinstance(device, str) and device == "xpu": # pragma: nocover + # XXX: Workaround for PyTorch XPU bug for `from_dlpack` calls with + # device strings that do not include any device number suffix. + # https://github.com/pytorch/pytorch/issues/181140 + device += ":0" + sparse_mask = [sp.issparse(array) for array in arrays] none_mask = [array is None for array in arrays] if any(sparse_mask) and not _is_numpy_namespace(xp): @@ -497,9 +559,19 @@ def move_to(*arrays, xp, device): "namespace is Numpy" ) - converted_arrays = [] + arrays_ = arrays + # Down cast float64 `arrays` when highest precision of `xp`/`device` is float32 + if _max_precision_float_dtype(xp, device) == xp.float32: + arrays_ = [] + for array in arrays: + xp_array, _ = get_namespace(array) + if getattr(array, "dtype", None) == xp_array.float64: + arrays_.append(xp_array.astype(array, xp_array.float32)) + else: + arrays_.append(array) - for array, is_sparse, is_none in zip(arrays, sparse_mask, none_mask): + converted_arrays = [] + for array, is_sparse, is_none in zip(arrays_, sparse_mask, none_mask): if is_none: converted_arrays.append(None) elif is_sparse: @@ -527,7 +599,15 @@ def move_to(*arrays, xp, device): # kwargs in the from_dlpack method and their expected # meaning by namespaces implementing the array API spec. # TODO: try removing this once DLPack v1 more widely supported - except (AttributeError, TypeError, NotImplementedError): + # TODO: ValueError not needed once min NumPy >=2.4.0: + # https://github.com/numpy/numpy/issues/30341 + except ( + AttributeError, + TypeError, + NotImplementedError, + BufferError, + ValueError, + ): # Converting to numpy is tricky, handle this via dedicated function if _is_numpy_namespace(xp): array_converted = _convert_to_numpy(array, xp_array) @@ -548,12 +628,33 @@ def move_to(*arrays, xp, device): ) -def _expit(X, xp=None): - xp, _ = get_namespace(X, xp=xp) +def _expit(x, out=None, xp=None): + # The out argument for exp and hence expit is only supported for numpy, + # but not in the Array API specification. + xp, _ = get_namespace(x, xp=xp) + if _is_numpy_namespace(xp): + return special.expit(x, out=out) + + return 1.0 / (1.0 + xp.exp(-x)) + + +def _logit(x, out=None, xp=None): + # The out argument for log and hence logit is only supported for numpy, + # but not in the Array API specification. + xp, _ = get_namespace(x, xp=xp) if _is_numpy_namespace(xp): - return xp.asarray(special.expit(numpy.asarray(X))) + return special.logit(x, out=out) + + # See https://github.com/scipy/xsf/blob/e0c4d22d6ae768b39efc69586f1e8d5560a32fc5/include/xsf/log_exp.h#L30 + def logit_v2(x): + s = 2 * (x - 0.5) + return xp.log1p(s) - xp.log1p(-s) - return 1.0 / (1.0 + xp.exp(-X)) + return xp.where( + xp.logical_or(x < 0.3, x > 0.65), + xp.log(x / (1 - x)), + logit_v2(x), + ) def _validate_diagonal_args(array, value, xp): @@ -641,14 +742,23 @@ def _is_xp_namespace(xp, name): def _max_precision_float_dtype(xp, device): - """Return the float dtype with the highest precision supported by the device.""" - # TODO: Update to use `__array_namespace__info__()` from array-api v2023.12 - # when/if that becomes more widespread. - if _is_xp_namespace(xp, "torch") and str(device).startswith( - "mps" - ): # pragma: no cover - return xp.float32 - return xp.float64 + """Return the float dtype with the highest precision supported by the device. + + Note that scikit-learn only considers float32 and float64 as suitable + floating point dtypes. + """ + if _is_numpy_namespace(xp): + # Special case NumPy for backward compat with older versions that do + # not implement __array_namespace_info__. + return xp.float64 + + floating_dtypes = xp.__array_namespace_info__().dtypes( + kind="real floating", device=device + ) + if "float64" in floating_dtypes: + return xp.float64 + + return xp.float32 def _find_matching_floating_dtype(*arrays, xp): @@ -760,9 +870,8 @@ def _median(x, axis=None, keepdims=False, xp=None): if hasattr(xp, "median"): return xp.median(x, axis=axis, keepdims=keepdims) - # Intended mostly for array-api-strict (which as no "median", as per the spec) - # as `_convert_to_numpy` does not necessarily work for all array types. - x_np = _convert_to_numpy(x, xp=xp) + # Intended mostly for array-api-strict (which has no "median", as per the spec). + x_np = move_to(x, xp=numpy, device="cpu") return xp.asarray(numpy.median(x_np, axis=axis, keepdims=keepdims), device=device) @@ -888,22 +997,31 @@ def _ravel(array, xp=None): def _convert_to_numpy(array, xp): - """Convert X into a NumPy ndarray on the CPU.""" + """Convert X into a NumPy ndarray on the CPU. + + This function uses library-specific methods to convert the array to a NumPy + ndarray on the CPU. It is only meant as a fallback when move_to fails to use the + DLPACK protocol. + + This function is not meant to be called directly and + `move_to(array, xp=np, device="cpu")` should be used instead. + """ if _is_xp_namespace(xp, "torch"): return array.cpu().numpy() elif _is_xp_namespace(xp, "cupy"): # pragma: nocover return array.get() elif _is_xp_namespace(xp, "array_api_strict"): return numpy.asarray(xp.asarray(array, device=xp.Device("CPU_DEVICE"))) + elif _is_xp_namespace(xp, "dpnp"): # pragma: nocover + return array.asnumpy() return numpy.asarray(array) def _estimator_with_converted_arrays(estimator, converter): - """Create new estimator which converting all attributes that are arrays. + """Create a new estimator with converted array attributes. - The converter is called on all NumPy arrays and arrays that support the - `DLPack interface <https://dmlc.github.io/dlpack/latest/>`__. + All attributes that are arrays will be converted using the provided converter. Parameters ---------- @@ -916,18 +1034,126 @@ def _estimator_with_converted_arrays(estimator, converter): Returns ------- new_estimator : Estimator - Convert estimator + A clone of the estimator with converted array attributes. """ + # Inline import to avoid circular import from sklearn.base import clone + # Because we call this function recursively `estimator` might actually be an + # attribute of an estimator and not an actual estimator object. + estimator_type = type(estimator) + + if hasattr(estimator, "__sklearn_array_api_convert__") and not inspect.isclass( + estimator + ): + return estimator.__sklearn_array_api_convert__(converter) + + if estimator_type is dict: + return { + k: _estimator_with_converted_arrays(v, converter) + for k, v in estimator.items() + } + + if estimator_type in (list, tuple, set, frozenset): + return estimator_type( + _estimator_with_converted_arrays(v, converter) for v in estimator + ) + + if hasattr(estimator, "__dlpack__") or isinstance( + estimator, (numpy.ndarray, numpy.generic) + ): + return converter(estimator) + + if not hasattr(estimator, "get_params") or isinstance(estimator, type): + return estimator + new_estimator = clone(estimator) for key, attribute in vars(estimator).items(): - if hasattr(attribute, "__dlpack__") or isinstance(attribute, numpy.ndarray): - attribute = converter(attribute) + attribute = _estimator_with_converted_arrays(attribute, converter) setattr(new_estimator, key, attribute) return new_estimator +def move_estimator_to(estimator, xp, device): + """Move estimator array attributes to the given namespace and device. + + Attributes which are not arrays are left unchanged. + + Parameters + ---------- + estimator : estimator object + The estimator whose attributes should be converted. + + xp : array namespace + The target array API namespace. + + device : device or None + The target device. + + Returns + ------- + new_estimator : estimator object + A clone of the estimator with array attributes moved. + """ + return _estimator_with_converted_arrays( + estimator, partial(move_to, xp=xp, device=device) + ) + + +def check_same_namespace(X, estimator, *, attribute, method): + """Check that estimator's fitted attribute is compatible with X. + + Use this to check that an estimator was fitted using the same array + namespace and device as ``X``. This is done by comparing the namespace and + device of ``X`` and the provided ``attribute``. + + Parameters + ---------- + X : array-like + The data passed to the fitted estimator's method, e.g. to ``predict``. + + estimator : estimator object + The fitted estimator. + + attribute : str + The name of the fitted attribute to check; for example it could be + ``"coef_"`` for a linear model. This function will check that ``X`` is + in a namespace and device that are consistent with the attribute. + + method : str + The name of the calling method (e.g. ``"predict"``). It is used to + write the error message if the check fails. + """ + if not get_config()["array_api_dispatch"]: + return + + attr = getattr(estimator, attribute) + a_xp, _, a_device = get_namespace_and_device(attr) + + X_xp, _, X_device = get_namespace_and_device(X) + + if X_xp == a_xp and X_device == a_device: + return + + if X_xp != a_xp: + msg = ( + f"Array namespaces used during fit ({a_xp.__name__}) " + f"and {method} ({X_xp.__name__}) differ." + ) + else: # pragma: no cover + msg = f"Devices used during fit ({a_device}) and {method} ({X_device}) differ." + + raise ValueError( + f"Inputs passed to {estimator.__class__.__name__}.{method}() " + "must use the same namespace and the same device as those passed to fit(). " + f"{msg} " + "You can move the estimator to the same namespace and device as X with: " + "'from sklearn.utils._array_api import move_estimator_to; " + "xp, _, device = get_namespace_and_device(X); " + "estimator = move_estimator_to(estimator, xp, device)'" + ) + + def _atol_for_type(dtype_or_dtype_name): """Return the absolute tolerance for a given numpy dtype.""" if dtype_or_dtype_name is None: @@ -1077,30 +1303,22 @@ def _modify_in_place_if_numpy(xp, func, *args, out=None, **kwargs): return out -def _bincount(array, weights=None, minlength=None, xp=None): +def _bincount(array, weights=None, minlength=0, xp=None): # TODO: update if bincount is ever adopted in a future version of the standard: # https://github.com/data-apis/array-api/issues/812 xp, _ = get_namespace(array, xp=xp) if hasattr(xp, "bincount"): return xp.bincount(array, weights=weights, minlength=minlength) - array_np = _convert_to_numpy(array, xp=xp) + array_np = move_to(array, xp=numpy, device="cpu") if weights is not None: - weights_np = _convert_to_numpy(weights, xp=xp) + weights_np = move_to(weights, xp=numpy, device="cpu") else: weights_np = None bin_out = numpy.bincount(array_np, weights=weights_np, minlength=minlength) return xp.asarray(bin_out, device=device(array)) -def _tolist(array, xp=None): - xp, _ = get_namespace(array, xp=xp) - if _is_numpy_namespace(xp): - return array.tolist() - array_np = _convert_to_numpy(array, xp=xp) - return [element.item() for element in array_np] - - def _logsumexp(array, axis=None, xp=None): # TODO replace by scipy.special.logsumexp when # https://github.com/scipy/scipy/pull/22683 is part of a release. @@ -1121,11 +1339,7 @@ def _logsumexp(array, axis=None, xp=None): i_max_dt = xp.astype(index_max, array.dtype) m = xp.sum(i_max_dt, axis=axis, keepdims=True, dtype=array.dtype) # Specifying device explicitly is the fix for https://github.com/scipy/scipy/issues/22680 - shift = xp.where( - xp.isfinite(array_max), - array_max, - xp.asarray(0, dtype=array_max.dtype, device=device), - ) + shift = xp.where(xp.isfinite(array_max), array_max, 0) exp = xp.exp(array - shift) s = xp.sum(exp, axis=axis, keepdims=True, dtype=exp.dtype) s = xp.where(s == 0, s, s / m) @@ -1160,3 +1374,14 @@ def _half_multinomial_loss(y, pred, sample_weight=None, xp=None): return float( _average(log_sum_exp - label_predictions, weights=sample_weight, xp=xp) ) + + +def _matching_numpy_dtype(X, xp=None): + xp, _ = get_namespace(X, xp=xp) + if _is_numpy_namespace(xp): + return X.dtype + + dtypes_dict = xp.__array_namespace_info__().dtypes() + reversed_dtypes_dict = {dtype: name for name, dtype in dtypes_dict.items()} + dtype_name = reversed_dtypes_dict[X.dtype] + return numpy.__array_namespace_info__().dtypes()[dtype_name] diff --git a/sklearn/utils/_bitset.pxd b/sklearn/utils/_bitset.pxd new file mode 100644 index 0000000000000..5b8b145c08d80 --- /dev/null +++ b/sklearn/utils/_bitset.pxd @@ -0,0 +1,21 @@ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +from sklearn.utils._typedefs cimport float64_t, uint8_t, uint32_t + +ctypedef uint32_t BITSET_INNER_DTYPE_C +ctypedef BITSET_INNER_DTYPE_C[8] BITSET_DTYPE_C + +cdef void init_bitset(BITSET_DTYPE_C bitset) noexcept nogil + +cdef void set_bitset(BITSET_DTYPE_C bitset, uint8_t val) noexcept nogil + +cdef uint8_t in_bitset(BITSET_DTYPE_C bitset, uint8_t val) noexcept nogil + +cpdef uint8_t in_bitset_memoryview( + const BITSET_INNER_DTYPE_C[:] bitset, uint8_t val +) noexcept nogil + +cdef uint8_t in_bitset_2d_memoryview( + const BITSET_INNER_DTYPE_C[:, :] bitset, uint8_t val, unsigned int row +) noexcept nogil diff --git a/sklearn/utils/_bitset.pyx b/sklearn/utils/_bitset.pyx new file mode 100644 index 0000000000000..4b275eba03e69 --- /dev/null +++ b/sklearn/utils/_bitset.pyx @@ -0,0 +1,63 @@ +""" +A bitset is a data structure used to represent sets of integers in [0, n]. For decision +trees, we use them to represent sets of features indices (e.g. features that go to the +left child, or features that are categorical). For familiarity with bitsets and bitwise +operations see: +https://en.wikipedia.org/wiki/Bit_array +https://en.wikipedia.org/wiki/Bitwise_operation +""" + +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + + +cdef inline void init_bitset(BITSET_DTYPE_C bitset) noexcept nogil: + cdef: + unsigned int i + + for i in range(8): + bitset[i] = 0 + + +cdef inline void set_bitset(BITSET_DTYPE_C bitset, uint8_t val) noexcept nogil: + bitset[val // 32] |= (1 << (val % 32)) + + +cdef inline uint8_t in_bitset(BITSET_DTYPE_C bitset, uint8_t val) noexcept nogil: + return (bitset[val // 32] >> (val % 32)) & 1 + + +cpdef inline uint8_t in_bitset_memoryview( + const BITSET_INNER_DTYPE_C[:] bitset, uint8_t val +) noexcept nogil: + return (bitset[val // 32] >> (val % 32)) & 1 + + +cdef inline uint8_t in_bitset_2d_memoryview( + const BITSET_INNER_DTYPE_C[:, :] bitset, uint8_t val, unsigned int row +) noexcept nogil: + # Same as above but works on 2d memory views to avoid the creation of 1d + # memory views. See https://github.com/scikit-learn/scikit-learn/issues/17299 + return (bitset[row, val // 32] >> (val % 32)) & 1 + + +cpdef inline void set_bitset_memoryview(BITSET_INNER_DTYPE_C[:] bitset, uint8_t val): + bitset[val // 32] |= (1 << (val % 32)) + + +def set_raw_bitset_from_binned_bitset( + BITSET_INNER_DTYPE_C[:] raw_bitset, + BITSET_INNER_DTYPE_C[:] binned_bitset, + float64_t[:] categories, +): + """Set the raw_bitset from the values of the binned bitset + + categories is a mapping from binned category value to raw category value. + """ + cdef: + int binned_cat_value + float64_t raw_cat_value + + for binned_cat_value, raw_cat_value in enumerate(categories): + if in_bitset_memoryview(binned_bitset, binned_cat_value): + set_bitset_memoryview(raw_bitset, <uint8_t>raw_cat_value) diff --git a/sklearn/utils/_blas_int.pxi.in b/sklearn/utils/_blas_int.pxi.in new file mode 100644 index 0000000000000..4e8f15f7c260b --- /dev/null +++ b/sklearn/utils/_blas_int.pxi.in @@ -0,0 +1,3 @@ +# This will be a plain `int` if scipy.linalg.cython_blas doesn't have `blas_int`, and +# `blas_int` if it does. See `meson.build` in this directory for the compile-time check. +@BLAS_INT_DEF@ diff --git a/sklearn/utils/_bunch.py b/sklearn/utils/_bunch.py index a11e80e366135..ed030f05033af 100644 --- a/sklearn/utils/_bunch.py +++ b/sklearn/utils/_bunch.py @@ -59,7 +59,7 @@ def __getattr__(self, key): raise AttributeError(key) def __setstate__(self, state): - # Bunch pickles generated with scikit-learn 0.16.* have an non + # Bunch pickles generated with scikit-learn 0.16.* have a non # empty __dict__. This causes a surprising behaviour when # loading these pickles scikit-learn 0.17: reading bunch.key # uses __dict__ but assigning to bunch.key use __setattr__ and diff --git a/sklearn/utils/_cython_blas.pyx b/sklearn/utils/_cython_blas.pyx index ac23d0c4000ff..04bba80dc561a 100644 --- a/sklearn/utils/_cython_blas.pyx +++ b/sklearn/utils/_cython_blas.pyx @@ -11,6 +11,7 @@ from scipy.linalg.cython_blas cimport srot, drot from scipy.linalg.cython_blas cimport sgemv, dgemv from scipy.linalg.cython_blas cimport sger, dger from scipy.linalg.cython_blas cimport sgemm, dgemm +include "sklearn/utils/_blas_int.pxi" ################ @@ -20,10 +21,11 @@ from scipy.linalg.cython_blas cimport sgemm, dgemm cdef floating _dot(int n, const floating *x, int incx, const floating *y, int incy) noexcept nogil: """x.T.y""" + cdef blas_int n_ = n, incx_ = incx, incy_ = incy if floating is float: - return sdot(&n, <float *> x, &incx, <float *> y, &incy) + return sdot(&n_, <float *> x, &incx_, <float *> y, &incy_) else: - return ddot(&n, <double *> x, &incx, <double *> y, &incy) + return ddot(&n_, <double *> x, &incx_, <double *> y, &incy_) cpdef _dot_memview(const floating[::1] x, const floating[::1] y): @@ -32,10 +34,11 @@ cpdef _dot_memview(const floating[::1] x, const floating[::1] y): cdef floating _asum(int n, const floating *x, int incx) noexcept nogil: """sum(|x_i|)""" + cdef blas_int n_ = n, incx_ = incx if floating is float: - return sasum(&n, <float *> x, &incx) + return sasum(&n_, <float *> x, &incx_) else: - return dasum(&n, <double *> x, &incx) + return dasum(&n_, <double *> x, &incx_) cpdef _asum_memview(const floating[::1] x): @@ -45,10 +48,11 @@ cpdef _asum_memview(const floating[::1] x): cdef void _axpy(int n, floating alpha, const floating *x, int incx, floating *y, int incy) noexcept nogil: """y := alpha * x + y""" + cdef blas_int n_ = n, incx_ = incx, incy_ = incy if floating is float: - saxpy(&n, &alpha, <float *> x, &incx, y, &incy) + saxpy(&n_, &alpha, <float *> x, &incx_, y, &incy_) else: - daxpy(&n, &alpha, <double *> x, &incx, y, &incy) + daxpy(&n_, &alpha, <double *> x, &incx_, y, &incy_) cpdef _axpy_memview(floating alpha, const floating[::1] x, floating[::1] y): @@ -57,10 +61,11 @@ cpdef _axpy_memview(floating alpha, const floating[::1] x, floating[::1] y): cdef floating _nrm2(int n, const floating *x, int incx) noexcept nogil: """sqrt(sum((x_i)^2))""" + cdef blas_int n_ = n, incx_ = incx if floating is float: - return snrm2(&n, <float *> x, &incx) + return snrm2(&n_, <float *> x, &incx_) else: - return dnrm2(&n, <double *> x, &incx) + return dnrm2(&n_, <double *> x, &incx_) cpdef _nrm2_memview(const floating[::1] x): @@ -69,10 +74,11 @@ cpdef _nrm2_memview(const floating[::1] x): cdef void _copy(int n, const floating *x, int incx, const floating *y, int incy) noexcept nogil: """y := x""" + cdef blas_int n_ = n, incx_ = incx, incy_ = incy if floating is float: - scopy(&n, <float *> x, &incx, <float *> y, &incy) + scopy(&n_, <float *> x, &incx_, <float *> y, &incy_) else: - dcopy(&n, <double *> x, &incx, <double *> y, &incy) + dcopy(&n_, <double *> x, &incx_, <double *> y, &incy_) cpdef _copy_memview(const floating[::1] x, const floating[::1] y): @@ -81,10 +87,11 @@ cpdef _copy_memview(const floating[::1] x, const floating[::1] y): cdef void _scal(int n, floating alpha, const floating *x, int incx) noexcept nogil: """x := alpha * x""" + cdef blas_int n_ = n, incx_ = incx if floating is float: - sscal(&n, &alpha, <float *> x, &incx) + sscal(&n_, &alpha, <float *> x, &incx_) else: - dscal(&n, &alpha, <double *> x, &incx) + dscal(&n_, &alpha, <double *> x, &incx_) cpdef _scal_memview(floating alpha, const floating[::1] x): @@ -107,10 +114,11 @@ cpdef _rotg_memview(floating a, floating b, floating c, floating s): cdef void _rot(int n, floating *x, int incx, floating *y, int incy, floating c, floating s) noexcept nogil: """Apply plane rotation""" + cdef blas_int n_ = n, incx_ = incx, incy_ = incy if floating is float: - srot(&n, x, &incx, y, &incy, &c, &s) + srot(&n_, x, &incx_, y, &incy_, &c, &s) else: - drot(&n, x, &incx, y, &incy, &c, &s) + drot(&n_, x, &incx_, y, &incy_, &c, &s) cpdef _rot_memview(floating[::1] x, floating[::1] y, floating c, floating s): @@ -125,22 +133,24 @@ cdef void _gemv(BLAS_Order order, BLAS_Trans ta, int m, int n, floating alpha, const floating *A, int lda, const floating *x, int incx, floating beta, floating *y, int incy) noexcept nogil: """y := alpha * op(A).x + beta * y""" - cdef char ta_ = ta + cdef: + char ta_ = ta + blas_int m_ = m, n_ = n, lda_ = lda, incx_ = incx, incy_ = incy if order == BLAS_Order.RowMajor: ta_ = BLAS_Trans.NoTrans if ta == BLAS_Trans.Trans else BLAS_Trans.Trans if floating is float: - sgemv(&ta_, &n, &m, &alpha, <float *> A, &lda, <float *> x, - &incx, &beta, y, &incy) + sgemv(&ta_, &n_, &m_, &alpha, <float *> A, &lda_, <float *> x, + &incx_, &beta, y, &incy_) else: - dgemv(&ta_, &n, &m, &alpha, <double *> A, &lda, <double *> x, - &incx, &beta, y, &incy) + dgemv(&ta_, &n_, &m_, &alpha, <double *> A, &lda_, <double *> x, + &incx_, &beta, y, &incy_) else: if floating is float: - sgemv(&ta_, &m, &n, &alpha, <float *> A, &lda, <float *> x, - &incx, &beta, y, &incy) + sgemv(&ta_, &m_, &n_, &alpha, <float *> A, &lda_, <float *> x, + &incx_, &beta, y, &incy_) else: - dgemv(&ta_, &m, &n, &alpha, <double *> A, &lda, <double *> x, - &incx, &beta, y, &incy) + dgemv(&ta_, &m_, &n_, &alpha, <double *> A, &lda_, <double *> x, + &incx_, &beta, y, &incy_) cpdef _gemv_memview(BLAS_Trans ta, floating alpha, const floating[:, :] A, @@ -160,16 +170,17 @@ cdef void _ger(BLAS_Order order, int m, int n, floating alpha, const floating *x, int incx, const floating *y, int incy, floating *A, int lda) noexcept nogil: """A := alpha * x.y.T + A""" + cdef blas_int m_ = m, n_ = n, incx_ = incx, incy_ = incy, lda_ = lda if order == BLAS_Order.RowMajor: if floating is float: - sger(&n, &m, &alpha, <float *> y, &incy, <float *> x, &incx, A, &lda) + sger(&n_, &m_, &alpha, <float *> y, &incy_, <float *> x, &incx_, A, &lda_) else: - dger(&n, &m, &alpha, <double *> y, &incy, <double *> x, &incx, A, &lda) + dger(&n_, &m_, &alpha, <double *> y, &incy_, <double *> x, &incx_, A, &lda_) else: if floating is float: - sger(&m, &n, &alpha, <float *> x, &incx, <float *> y, &incy, A, &lda) + sger(&m_, &n_, &alpha, <float *> x, &incx_, <float *> y, &incy_, A, &lda_) else: - dger(&m, &n, &alpha, <double *> x, &incx, <double *> y, &incy, A, &lda) + dger(&m_, &n_, &alpha, <double *> x, &incx_, <double *> y, &incy_, A, &lda_) cpdef _ger_memview(floating alpha, const floating[::1] x, @@ -198,20 +209,21 @@ cdef void _gemm(BLAS_Order order, BLAS_Trans ta, BLAS_Trans tb, int m, int n, cdef: char ta_ = ta char tb_ = tb + blas_int m_ = m, n_ = n, k_ = k, lda_ = lda, ldb_ = ldb, ldc_ = ldc if order == BLAS_Order.RowMajor: if floating is float: - sgemm(&tb_, &ta_, &n, &m, &k, &alpha, <float*>B, - &ldb, <float*>A, &lda, &beta, C, &ldc) + sgemm(&tb_, &ta_, &n_, &m_, &k_, &alpha, <float*>B, + &ldb_, <float*>A, &lda_, &beta, C, &ldc_) else: - dgemm(&tb_, &ta_, &n, &m, &k, &alpha, <double*>B, - &ldb, <double*>A, &lda, &beta, C, &ldc) + dgemm(&tb_, &ta_, &n_, &m_, &k_, &alpha, <double*>B, + &ldb_, <double*>A, &lda_, &beta, C, &ldc_) else: if floating is float: - sgemm(&ta_, &tb_, &m, &n, &k, &alpha, <float*>A, - &lda, <float*>B, &ldb, &beta, C, &ldc) + sgemm(&ta_, &tb_, &m_, &n_, &k_, &alpha, <float*>A, + &lda_, <float*>B, &ldb_, &beta, C, &ldc_) else: - dgemm(&ta_, &tb_, &m, &n, &k, &alpha, <double*>A, - &lda, <double*>B, &ldb, &beta, C, &ldc) + dgemm(&ta_, &tb_, &m_, &n_, &k_, &alpha, <double*>A, + &lda_, <double*>B, &ldb_, &beta, C, &ldc_) cpdef _gemm_memview(BLAS_Trans ta, BLAS_Trans tb, floating alpha, diff --git a/sklearn/utils/_dataframe.py b/sklearn/utils/_dataframe.py new file mode 100644 index 0000000000000..2d77e098aefbb --- /dev/null +++ b/sklearn/utils/_dataframe.py @@ -0,0 +1,123 @@ +"""Functions to determine if an object is a dataframe or series.""" + +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import sys + + +def is_df_or_series(X): + """Return True if the X is a dataframe or series. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a dataframe or series, False otherwise. + """ + return is_pandas_df_or_series(X) or is_polars_df_or_series(X) or is_pyarrow_data(X) + + +def is_pandas_df_or_series(X): + """Return True if the X is a pandas dataframe or series. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a pandas dataframe or series, False otherwise. + """ + try: + pd = sys.modules["pandas"] + except KeyError: + return False + return isinstance(X, (pd.DataFrame, pd.Series)) + + +def is_pandas_df(X): + """Return True if the X is a pandas dataframe. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a pandas dataframe, False otherwise. + """ + try: + pd = sys.modules["pandas"] + except KeyError: + return False + return isinstance(X, pd.DataFrame) + + +def is_pyarrow_data(X): + """Return True if the X is a pyarrow Table, RecordBatch, Array or ChunkedArray. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a pyarrow Table, RecordBatch, Array or ChunkedArray, + False otherwise. + """ + try: + pa = sys.modules["pyarrow"] + except KeyError: + return False + return isinstance(X, (pa.Table, pa.RecordBatch, pa.Array, pa.ChunkedArray)) + + +def is_polars_df_or_series(X): + """Return True if the X is a polars dataframe or series. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a polars dataframe or series, False otherwise. + """ + try: + pl = sys.modules["polars"] + except KeyError: + return False + return isinstance(X, (pl.DataFrame, pl.Series)) + + +def is_polars_df(X): + """Return True if the X is a polars dataframe. + + Parameters + ---------- + X : {array-like, dataframe} + The array-like or dataframe object to check. + + Returns + ------- + bool + True if the X is a polarsdataframe, False otherwise. + """ + try: + pl = sys.modules["polars"] + except KeyError: + return False + return isinstance(X, pl.DataFrame) diff --git a/sklearn/utils/_indexing.py b/sklearn/utils/_indexing.py index de983f2f3adb0..b912c5b45c99a 100644 --- a/sklearn/utils/_indexing.py +++ b/sklearn/utils/_indexing.py @@ -4,9 +4,9 @@ import numbers import sys import warnings -from collections import UserList from itertools import compress, islice +import narwhals.stable.v2 as nw import numpy as np from scipy.sparse import issparse @@ -16,16 +16,13 @@ get_namespace_and_device, move_to, ) +from sklearn.utils._dataframe import is_pyarrow_data from sklearn.utils._param_validation import Interval, validate_params from sklearn.utils.extmath import _approximate_mode -from sklearn.utils.fixes import PYARROW_VERSION_BELOW_17 +from sklearn.utils.fixes import SCIPY_VERSION_BELOW_1_12 from sklearn.utils.validation import ( _check_sample_weight, _is_arraylike_not_scalar, - _is_pandas_df, - _is_polars_df_or_series, - _is_pyarrow_data, - _use_interchange_protocol, check_array, check_consistent_length, check_random_state, @@ -36,15 +33,66 @@ def _array_indexing(array, key, key_dtype, axis): """Index an array or scipy.sparse consistently across NumPy version.""" xp, is_array_api, device_ = get_namespace_and_device(array) if is_array_api: - key = move_to(key, xp=xp, device=device_) - return xp.take(array, key, axis=axis) - if issparse(array) and key_dtype == "bool": - key = np.asarray(key) + if hasattr(key, "shape"): + key = move_to(key, xp=xp, device=device_) + elif isinstance(key, (int, slice)): + # Passthrough for valid __getitem__ inputs as noted in the array + # API spec. + pass + else: + key = xp.asarray(key, device=device_) + + if hasattr(key, "dtype"): + if xp.isdtype(key.dtype, "integral"): + return xp.take(array, key, axis=axis) + elif xp.isdtype(key.dtype, "bool"): + # Array API does not support boolean indexing for n-dim arrays + # yet hence the need to turn to equivalent integer indexing. + indices = xp.arange(array.shape[axis], device=device_) + return xp.take(array, indices[key], axis=axis) + + if issparse(array): + if key_dtype == "bool": + key = np.asarray(key) + elif SCIPY_VERSION_BELOW_1_12: + if isinstance(key, numbers.Integral): + key = [key] if isinstance(key, tuple): key = list(key) return array[key, ...] if axis == 0 else array[:, key] +def _narwhals_indexing(X, key, key_dtype, axis): + """Index a narwhals dataframe or series.""" + X = nw.from_native(X, allow_series=True) + if not (isinstance(key, (list, slice)) or key is None): + # Note that at least tuples should be converted to either list or ndarray as + # tuples in __getitem__ are special: x[(1, 2)] is equal to x[1, 2]. + # Also, not all backends of narwhals support ndarray, but all support lists. + key = np.asarray(key).tolist() + + if axis == 1: + if key_dtype == "bool": + subset = X.select(col for (col, select) in zip(X.columns, key) if select) + return subset.to_native() + return X[:, key].to_native() + + # From here on axis == 0: + if key_dtype == "bool": + X_indexed = X.filter(key) + else: + X_indexed = X[key] + + if np.isscalar(key): + if len(X.shape) <= 1: + return X_indexed + # TODO: `X_indexed` is a DataFrame with a single row; we return a Series to be + # consistent with pandas. Narwhals would return a dataframe which is + # advantageous if the columns have different dtypes. + return np.array([col.item(0) for col in X_indexed.iter_columns()]) + return X_indexed.to_native() + + def _pandas_indexing(X, key, key_dtype, axis): """Index a pandas dataframe or a series.""" if _is_arraylike_not_scalar(key): @@ -72,94 +120,6 @@ def _list_indexing(X, key, key_dtype): return [X[idx] for idx in key] -def _polars_indexing(X, key, key_dtype, axis): - """Index a polars dataframe or series.""" - # Polars behavior is more consistent with lists - if isinstance(key, np.ndarray): - # Convert each element of the array to a Python scalar - key = key.tolist() - elif not (np.isscalar(key) or isinstance(key, slice)): - key = list(key) - - if axis == 1: - # Here we are certain to have a polars DataFrame; which can be indexed with - # integer and string scalar, and list of integer, string and boolean - return X[:, key] - - if key_dtype == "bool": - # Boolean mask can be indexed in the same way for Series and DataFrame (axis=0) - return X.filter(key) - - # Integer scalar and list of integer can be indexed in the same way for Series and - # DataFrame (axis=0) - X_indexed = X[key] - if np.isscalar(key) and len(X.shape) == 2: - # `X_indexed` is a DataFrame with a single row; we return a Series to be - # consistent with pandas - pl = sys.modules["polars"] - return pl.Series(X_indexed.row(0)) - return X_indexed - - -def _pyarrow_indexing(X, key, key_dtype, axis): - """Index a pyarrow data.""" - scalar_key = np.isscalar(key) - if isinstance(key, slice): - if isinstance(key.stop, str): - start = X.column_names.index(key.start) - stop = X.column_names.index(key.stop) + 1 - else: - start = 0 if not key.start else key.start - stop = key.stop - step = 1 if not key.step else key.step - key = list(range(start, stop, step)) - - if axis == 1: - # Here we are certain that X is a pyarrow Table or RecordBatch. - if key_dtype == "int" and not isinstance(key, list): - # pyarrow's X.select behavior is more consistent with integer lists. - key = np.asarray(key).tolist() - if key_dtype == "bool": - key = np.asarray(key).nonzero()[0].tolist() - - if scalar_key: - return X.column(key) - - return X.select(key) - - # axis == 0 from here on - if scalar_key: - if hasattr(X, "shape"): - # X is a Table or RecordBatch - key = [key] - else: - return X[key].as_py() - elif not isinstance(key, list): - key = np.asarray(key) - - if key_dtype == "bool": - # TODO(pyarrow): remove version checking and following if-branch when - # pyarrow==17.0.0 is the minimal version, see pyarrow issue - # https://github.com/apache/arrow/issues/42013 for more info - if PYARROW_VERSION_BELOW_17: - import pyarrow - - if not isinstance(key, pyarrow.BooleanArray): - key = pyarrow.array(key, type=pyarrow.bool_()) - - X_indexed = X.filter(key) - - else: - X_indexed = X.take(key) - - if scalar_key and len(getattr(X, "shape", [0])) == 2: - # X_indexed is a dataframe-like with a single row; we return a Series to be - # consistent with pandas - pa = sys.modules["pyarrow"] - return pa.array(X_indexed.to_pylist()[0].values()) - return X_indexed - - def _determine_key_type(key, accept_slice=True): """Determine the data type of key. @@ -214,9 +174,7 @@ def _determine_key_type(key, accept_slice=True): if key_start_type is not None: return key_start_type return key_stop_type - # TODO(1.9) remove UserList when the force_int_remainder_cols param - # of ColumnTransformer is removed - if isinstance(key, (list, tuple, UserList)): + if isinstance(key, (list, tuple)): unique_key = set(key) key_type = {_determine_key_type(elt) for elt in unique_key} if not key_type: @@ -325,34 +283,33 @@ def _safe_indexing(X, indices, *, axis=0): if ( axis == 1 and indices_dtype == "str" - and not (_is_pandas_df(X) or _use_interchange_protocol(X)) + and not ( + nw.dependencies.is_into_dataframe(X) or nw.dependencies.is_into_series(X) + ) ): raise ValueError( "Specifying the columns using strings is only supported for dataframes." ) if hasattr(X, "iloc"): - # TODO: we should probably use _is_pandas_df_or_series(X) instead but: + # TODO: we should probably use is_pandas_df_or_series(X) instead but: # 1) Currently, it (probably) works for dataframes compliant to pandas' API. # 2) Updating would require updating some tests such as # test_train_test_split_mock_pandas. + # 3) Should also work with _narwhals_indexing, but + # test_safe_indexing_pandas_no_settingwithcopy_warning does not pass. return _pandas_indexing(X, indices, indices_dtype, axis=axis) - elif _is_polars_df_or_series(X): - return _polars_indexing(X, indices, indices_dtype, axis=axis) - elif _is_pyarrow_data(X): - return _pyarrow_indexing(X, indices, indices_dtype, axis=axis) - elif _use_interchange_protocol(X): # pragma: no cover - # Once the dataframe X is converted into its dataframe interchange protocol - # version by calling X.__dataframe__(), it becomes very hard to turn it back - # into its original type, e.g., a pyarrow.Table, see - # https://github.com/data-apis/dataframe-api/issues/85. - raise warnings.warn( - message="A data object with support for the dataframe interchange protocol" - "was passed, but scikit-learn does currently not know how to handle this " - "kind of data. Some array/list indexing will be tried.", - category=UserWarning, - ) - + elif nw.dependencies.is_into_dataframe(X) or nw.dependencies.is_into_series(X): + return _narwhals_indexing(X, indices, indices_dtype, axis=axis) + elif is_pyarrow_data(X): + # Narwhals Series are backed by ChunkedArray, not Array. + # To reuse `_narwhals_indexing`, we temporarily convert to `ChunkedArray`. + pa = sys.modules["pyarrow"] + X = pa.chunked_array(X) + ret = _narwhals_indexing(X, indices, indices_dtype, axis=axis) + if isinstance(ret, pa.ChunkedArray): + return ret.combine_chunks() + return ret if hasattr(X, "shape"): return _array_indexing(X, indices, indices_dtype, axis=axis) else: @@ -414,69 +371,31 @@ def _get_column_indices(X, key): :func:`_safe_indexing`. """ key_dtype = _determine_key_type(key) - if _use_interchange_protocol(X): - return _get_column_indices_interchange(X.__dataframe__(), key, key_dtype) - n_columns = X.shape[1] + if nw.dependencies.is_into_dataframe(X): + # Note: narwhals raises DuplicateError if column names are not unique. + df_nw = nw.from_native(X) + n_columns = df_nw.shape[1] + column_names = df_nw.columns + else: + n_columns = X.shape[1] + column_names = None + if isinstance(key, (list, tuple)) and not key: # we get an empty list return [] elif key_dtype in ("bool", "int"): return _get_column_indices_for_bool_or_int(key, n_columns) else: - try: - all_columns = X.columns - except AttributeError: + if column_names is None: raise ValueError( "Specifying the columns using strings is only supported for dataframes." ) - if isinstance(key, str): - columns = [key] - elif isinstance(key, slice): - start, stop = key.start, key.stop - if start is not None: - start = all_columns.get_loc(start) - if stop is not None: - # pandas indexing with strings is endpoint included - stop = all_columns.get_loc(stop) + 1 - else: - stop = n_columns + 1 - return list(islice(range(n_columns), start, stop)) - else: - columns = list(key) - - try: - column_indices = [] - for col in columns: - col_idx = all_columns.get_loc(col) - if not isinstance(col_idx, numbers.Integral): - raise ValueError( - f"Selected columns, {columns}, are not unique in dataframe" - ) - column_indices.append(col_idx) - - except KeyError as e: - raise ValueError("A given column is not a column of the dataframe") from e - - return column_indices - - -def _get_column_indices_interchange(X_interchange, key, key_dtype): - """Same as _get_column_indices but for X with __dataframe__ protocol.""" - - n_columns = X_interchange.num_columns() - - if isinstance(key, (list, tuple)) and not key: - # we get an empty list - return [] - elif key_dtype in ("bool", "int"): - return _get_column_indices_for_bool_or_int(key, n_columns) - else: - column_names = list(X_interchange.column_names()) if isinstance(key, slice): if key.step not in [1, None]: raise NotImplementedError("key.step must be 1 or None") + start, stop = key.start, key.stop if start is not None: start = column_names.index(start) @@ -486,13 +405,14 @@ def _get_column_indices_interchange(X_interchange, key, key_dtype): else: stop = n_columns + 1 return list(islice(range(n_columns), start, stop)) - - selected_columns = [key] if np.isscalar(key) else key - - try: - return [column_names.index(col) for col in selected_columns] - except ValueError as e: - raise ValueError("A given column is not a column of the dataframe") from e + else: + selected_columns = [key] if np.isscalar(key) else key + try: + return [column_names.index(col) for col in selected_columns] + except ValueError as e: + missing = {*selected_columns} - {*column_names} + msg = f"Some column names are not columns of the dataframe: {missing}" + raise ValueError(msg) from e @validate_params( @@ -575,8 +495,8 @@ def resample( >>> X = np.array([[1., 0.], [2., 1.], [0., 0.]]) >>> y = np.array([0, 1, 2]) - >>> from scipy.sparse import coo_matrix - >>> X_sparse = coo_matrix(X) + >>> from scipy.sparse import coo_array + >>> X_sparse = coo_array(X) >>> from sklearn.utils import resample >>> X, X_sparse, y = resample(X, X_sparse, y, random_state=0) @@ -586,7 +506,7 @@ def resample( [1., 0.]]) >>> X_sparse - <Compressed Sparse Row sparse matrix of dtype 'float64' + <Compressed Sparse Row sparse array of dtype 'float64' with 4 stored elements and shape (3, 2)> >>> X_sparse.toarray() @@ -733,8 +653,8 @@ def shuffle(*arrays, random_state=None, n_samples=None): >>> X = np.array([[1., 0.], [2., 1.], [0., 0.]]) >>> y = np.array([0, 1, 2]) - >>> from scipy.sparse import coo_matrix - >>> X_sparse = coo_matrix(X) + >>> from scipy.sparse import coo_array + >>> X_sparse = coo_array(X) >>> from sklearn.utils import shuffle >>> X, X_sparse, y = shuffle(X, X_sparse, y, random_state=0) @@ -744,7 +664,7 @@ def shuffle(*arrays, random_state=None, n_samples=None): [1., 0.]]) >>> X_sparse - <Compressed Sparse Row sparse matrix of dtype 'float64' + <Compressed Sparse Row sparse array of dtype 'float64' with 3 stored elements and shape (3, 2)> >>> X_sparse.toarray() diff --git a/sklearn/utils/_mask.py b/sklearn/utils/_mask.py index 83361743ce3e7..d6b44cea76d97 100644 --- a/sklearn/utils/_mask.py +++ b/sklearn/utils/_mask.py @@ -8,6 +8,7 @@ from sklearn.utils._missing import is_scalar_nan from sklearn.utils._param_validation import validate_params +from sklearn.utils._sparse import _align_api_if_sparse from sklearn.utils.fixes import _object_dtype_isnan @@ -59,12 +60,12 @@ def _get_mask(X, value_to_mask): Xt = _get_dense_mask(X.data, value_to_mask) - sparse_constructor = sp.csr_matrix if X.format == "csr" else sp.csc_matrix + sparse_constructor = sp.csr_array if X.format == "csr" else sp.csc_array Xt_sparse = sparse_constructor( (Xt, X.indices.copy(), X.indptr.copy()), shape=X.shape, dtype=bool ) - return Xt_sparse + return _align_api_if_sparse(Xt_sparse) @validate_params( @@ -93,8 +94,8 @@ def safe_mask(X, mask): Examples -------- >>> from sklearn.utils import safe_mask - >>> from scipy.sparse import csr_matrix - >>> data = csr_matrix([[1], [2], [3], [4], [5]]) + >>> from scipy.sparse import csr_array + >>> data = csr_array([[1], [2], [3], [4], [5]]) >>> condition = [False, True, True, False, True] >>> mask = safe_mask(data, condition) >>> data[mask].toarray() diff --git a/sklearn/utils/_metadata_requests.py b/sklearn/utils/_metadata_requests.py index c871471403afe..d8d4e229cb53f 100644 --- a/sklearn/utils/_metadata_requests.py +++ b/sklearn/utils/_metadata_requests.py @@ -558,13 +558,17 @@ def __str__(self): class MetadataRequest: - """Contains the metadata request info of a consumer. + """Container for storing metadata request info and an associated consumer (`owner`). Instances of `MethodMetadataRequest` are used in this class for each - available method under `metadatarequest.{method}`. + available method under `MetadataRequest(owner=obj).{method}`. - Consumer-only classes such as simple estimators return a serialized - version of this class as the output of `get_metadata_routing()`. + Every :term:`consumer` in scikit-learn has a `_metadata_request` attribute that is a + `MetadataRequest`. + + Read more on developing custom estimators that can route metadata in the + :ref:`Metadata Routing Developing Guide + <sphx_glr_auto_examples_miscellaneous_plot_metadata_routing.py>`. .. versionadded:: 1.3 @@ -572,6 +576,21 @@ class MetadataRequest: ---------- owner : object The object to which these requests belong. + + Examples + -------- + >>> from sklearn import set_config + >>> set_config(enable_metadata_routing=True) + >>> from pprint import pprint + >>> from sklearn.utils.metadata_routing import MetadataRequest + >>> r = MetadataRequest(owner="any_object") + >>> r.fit.add_request(param="sample_weight", alias=True) + {'sample_weight': True} + >>> r.score.add_request(param="sample_weight", alias=False) + {'sample_weight': False} + >>> pprint(r) + {'fit': {'sample_weight': True}, 'score': {'sample_weight': False}} + >>> set_config(enable_metadata_routing=False) """ # this is here for us to use this attribute's value instead of doing @@ -754,7 +773,7 @@ def __str__(self): class MethodMapping: - """Stores the mapping between caller and callee methods for a :term:`router`. + """Stores the mapping between `caller` and `callee` methods for a :term:`router`. This class is primarily used in a ``get_metadata_routing()`` of a router object when defining the mapping between the router's methods and a sub-object (a @@ -763,7 +782,17 @@ class MethodMapping: Iterating through an instance of this class yields ``MethodPair(caller, callee)`` instances. + Read more on developing custom estimators that can route metadata in the + :ref:`Metadata Routing Developing Guide + <sphx_glr_auto_examples_miscellaneous_plot_metadata_routing.py>`. + .. versionadded:: 1.3 + + Examples + -------- + >>> from sklearn.utils.metadata_routing import MethodMapping + >>> MethodMapping().add(caller="fit", callee="split") + [{'caller': 'fit', 'callee': 'split'}] """ def __init__(self): @@ -834,12 +863,40 @@ class MetadataRouter: :class:`~sklearn.utils.metadata_routing.MetadataRequest` or another :class:`~sklearn.utils.metadata_routing.MetadataRouter` instance. + Read more on developing custom estimators that can route metadata in the + :ref:`Metadata Routing Developing Guide + <sphx_glr_auto_examples_miscellaneous_plot_metadata_routing.py>`. + .. versionadded:: 1.3 Parameters ---------- owner : object The object to which these requests belong. + + Examples + -------- + >>> from pprint import pprint + >>> from sklearn import set_config + >>> from sklearn.feature_selection import SelectFromModel + >>> from sklearn.linear_model import LinearRegression + >>> from sklearn.utils.metadata_routing import MetadataRouter, MethodMapping + >>> set_config(enable_metadata_routing=True) + >>> meta_estimator = SelectFromModel( + ... estimator=LinearRegression().set_fit_request(sample_weight=True) + ... ) + >>> router = MetadataRouter(owner=meta_estimator).add( + ... estimator=meta_estimator.estimator, + ... method_mapping=MethodMapping() + ... .add(caller="partial_fit", callee="partial_fit") + ... .add(caller="fit", callee="fit"), + ... ) + >>> pprint(router) + {'estimator': {'mapping': [{'caller': 'partial_fit', 'callee': 'partial_fit'}, + {'caller': 'fit', 'callee': 'fit'}], + 'router': {'fit': {'sample_weight': True}, + 'score': {'sample_weight': None}}}} + >>> set_config(enable_metadata_routing=False) """ # this is here for us to use this attribute's value instead of doing @@ -1185,7 +1242,7 @@ def get_routing_for_object(obj=None): :class:`~sklearn.utils.metadata_routing.MetadataRouter` or a :class:`~sklearn.utils.metadata_routing.MetadataRequest` from the given input. - This function always returns a copy or an instance constructed from the + This function always returns a copy or a new instance constructed from the input, such that changing the output of this function will not change the original object. @@ -1208,6 +1265,26 @@ def get_routing_for_object(obj=None): obj : MetadataRequest or MetadataRouter A ``MetadataRequest`` or a ``MetadataRouter`` taken or created from the given object. + + Examples + -------- + >>> from sklearn.datasets import make_classification + >>> from sklearn.pipeline import Pipeline + >>> from sklearn.preprocessing import StandardScaler + >>> from sklearn.linear_model import LogisticRegressionCV + >>> from sklearn.utils.metadata_routing import get_routing_for_object + >>> X, y = make_classification() + >>> pipe = Pipeline( + ... [("scaler", StandardScaler()), ("lr_cv", LogisticRegressionCV())] + ... ) + >>> pipe.fit(X, y) # doctest: +SKIP + Pipeline(steps=[('scaler', StandardScaler()), ('lr_cv', LogisticRegressionCV())]) + >>> type(get_routing_for_object(pipe)) + <class 'sklearn.utils._metadata_requests.MetadataRouter'> + >>> type(get_routing_for_object(pipe.named_steps.scaler)) + <class 'sklearn.utils._metadata_requests.MetadataRequest'> + >>> type(get_routing_for_object(pipe.named_steps.lr_cv)) + <class 'sklearn.utils._metadata_requests.MetadataRouter'> """ # doing this instead of a try/except since an AttributeError could be raised # for other reasons. @@ -1583,10 +1660,24 @@ def process_routing(_obj, _method, /, **kwargs): a call to this function would be: ``process_routing(self, "fit", sample_weight=sample_weight, **fit_params)``. + Internally, the function uses the router's `MetadataRouter` object (as + returned by a call to its `get_metadata_routing` method) to validate + per method that the routed metadata had been requested by the underlying + estimator, and extracts a mapping of the given metadata to the requested + metadata based on the routing information defined by the `MetadataRouter`. + Note that if routing is not enabled and ``kwargs`` is empty, then it returns an empty routing where ``process_routing(...).ANYTHING.ANY_METHOD`` is always an empty dictionary. + The output of this function is a :class:`~sklearn.utils.Bunch` that has a key for + each consuming object and those hold keys for their consuming methods, which then + contain keys for the metadata which should be routed to them. + + Read more on developing custom estimators that can route metadata in the + :ref:`Metadata Routing Developing Guide + <sphx_glr_auto_examples_miscellaneous_plot_metadata_routing.py>`. + .. versionadded:: 1.3 Parameters @@ -1604,12 +1695,26 @@ def process_routing(_obj, _method, /, **kwargs): Returns ------- routed_params : Bunch - A :class:`~utils.Bunch` of the form ``{"object_name": {"method_name": - {metadata: value}}}`` which can be used to pass the required metadata to - A :class:`~sklearn.utils.Bunch` of the form ``{"object_name": {"method_name": - {metadata: value}}}`` which can be used to pass the required metadata to - corresponding methods or corresponding child objects. The object names - are those defined in `obj.get_metadata_routing()`. + A :class:`~sklearn.utils.Bunch` of the form ``{"object_name": + {"method_name": {metadata: value}}}`` which can be used to pass the + required metadata to corresponding methods or corresponding child objects. + The object names are those defined in `obj.get_metadata_routing()`. + + Examples + -------- + >>> import numpy as np + >>> from sklearn import set_config + >>> from sklearn.utils.metadata_routing import process_routing + >>> from sklearn.linear_model import Ridge + >>> from sklearn.feature_selection import SelectFromModel + >>> set_config(enable_metadata_routing=True) + >>> process_routing( + ... SelectFromModel(Ridge().set_fit_request(sample_weight=True)), + ... "fit", + ... sample_weight=np.array([1, 1, 2]), + ... ) + {'estimator': {'fit': {'sample_weight': array([1, 1, 2])}}} + >>> set_config(enable_metadata_routing=False) """ if not kwargs: # If routing is not enabled and kwargs are empty, then we don't have to diff --git a/sklearn/utils/_optional_dependencies.py b/sklearn/utils/_optional_dependencies.py index 5f0041285090a..10b56b1bea837 100644 --- a/sklearn/utils/_optional_dependencies.py +++ b/sklearn/utils/_optional_dependencies.py @@ -17,8 +17,8 @@ def check_matplotlib_support(caller_name): import matplotlib # noqa: F401 except ImportError as e: raise ImportError( - "{} requires matplotlib. You can install matplotlib with " - "`pip install matplotlib`".format(caller_name) + f"{caller_name} requires matplotlib. You can install matplotlib with " + "`pip install matplotlib`" ) from e @@ -43,4 +43,4 @@ def check_pandas_support(caller_name): return pandas except ImportError as e: - raise ImportError("{} requires pandas.".format(caller_name)) from e + raise ImportError(f"{caller_name} requires pandas.") from e diff --git a/sklearn/utils/_param_validation.py b/sklearn/utils/_param_validation.py index 24b0846508381..5a8c8733d2c97 100644 --- a/sklearn/utils/_param_validation.py +++ b/sklearn/utils/_param_validation.py @@ -11,7 +11,7 @@ from numbers import Integral, Real import numpy as np -from scipy.sparse import csr_matrix, issparse +from scipy.sparse import csr_array, issparse from sklearn._config import config_context, get_config from sklearn.utils.validation import _is_arraylike_not_scalar @@ -541,7 +541,7 @@ def is_satisfied_by(self, val): return issparse(val) def __str__(self): - return "a sparse matrix" + return "a sparse array or matrix" class _Callables(_Constraint): @@ -844,7 +844,7 @@ def generate_valid_param(constraint): return np.array([1, 2, 3]) if isinstance(constraint, _SparseMatrices): - return csr_matrix([[0, 1], [1, 0]]) + return csr_array([[0, 1], [1, 0]]) if isinstance(constraint, _RandomStates): return np.random.RandomState(42) diff --git a/sklearn/utils/_plotting.py b/sklearn/utils/_plotting.py index 3e247e5fc4a93..3d13c4aa66869 100644 --- a/sklearn/utils/_plotting.py +++ b/sklearn/utils/_plotting.py @@ -106,6 +106,19 @@ def _validate_from_cv_results_params( ) check_consistent_length(X, y, sample_weight) + @staticmethod + def _get_legend_metric(curve_kwargs, n_curves, metric): + """Generate legend information dictionary and expand `metric` if required.""" + if not isinstance(curve_kwargs, list) and n_curves > 1: + if metric: + legend_metric = {"mean": np.mean(metric), "std": np.std(metric)} + else: + legend_metric = {"mean": None, "std": None} + else: + metric = metric if metric is not None else [None] * n_curves + legend_metric = {"metric": metric} + return metric, legend_metric + @staticmethod def _get_legend_label(curve_legend_metric, curve_name, legend_metric_name): """Helper to get legend label using `name` and `legend_metric`""" @@ -128,6 +141,7 @@ def _validate_curve_kwargs( curve_kwargs, default_curve_kwargs=None, default_multi_curve_kwargs=None, + removed_version="1.9", **kwargs, ): """Get validated line kwargs for each curve. @@ -162,20 +176,24 @@ def _validate_curve_kwargs( Default curve kwargs for multi-curve plots. Individual kwargs are over-ridden by `curve_kwargs`, if kwarg also set in `curve_kwargs`. + removed_version : str, default="1.9" + Version in which `kwargs` will be removed. + **kwargs : dict Deprecated. Keyword arguments to be passed to matplotlib's `plot`. """ - # TODO(1.9): Remove deprecated **kwargs + # TODO: Remove once kwargs deprecated on all displays if curve_kwargs and kwargs: raise ValueError( "Cannot provide both `curve_kwargs` and `kwargs`. `**kwargs` is " - "deprecated in 1.7 and will be removed in 1.9. Pass all matplotlib " - "arguments to `curve_kwargs` as a dictionary." + f"deprecated and will be removed in {removed_version}. Pass all " + "matplotlib arguments to `curve_kwargs` as a dictionary." ) if kwargs: warnings.warn( - "`**kwargs` is deprecated and will be removed in 1.9. Pass all " - "matplotlib arguments to `curve_kwargs` as a dictionary instead.", + f"`**kwargs` is deprecated and will be removed in {removed_version}. " + "Pass all matplotlib arguments to `curve_kwargs` as a dictionary " + "instead.", FutureWarning, ) curve_kwargs = kwargs @@ -196,7 +214,7 @@ def _validate_curve_kwargs( "To avoid labeling individual curves that have the same appearance, " f"`curve_kwargs` should be a list of {n_curves} dictionaries. " "Alternatively, set `name` to `None` or a single string to label " - "a single legend entry with mean ROC AUC score of all curves." + "a single legend entry for all curves." ) # Ensure `name` is of the correct length @@ -427,7 +445,7 @@ def _check_param_lengths(required, optional, class_name): # TODO(1.10): remove after the end of the deprecation period of `y_pred` def _deprecate_y_pred_parameter(y_score, y_pred, version): - """Deprecate `y_pred` in favour of of `y_score`.""" + """Deprecate `y_pred` in favour of `y_score`.""" version = parse_version(version) version_remove = f"{version.major}.{version.minor + 2}" if y_score is not None and not (isinstance(y_pred, str) and y_pred == "deprecated"): diff --git a/sklearn/utils/_pprint.py b/sklearn/utils/_pprint.py index 936c93d6c7765..3d8067c4a6857 100644 --- a/sklearn/utils/_pprint.py +++ b/sklearn/utils/_pprint.py @@ -93,8 +93,7 @@ def _changed_params(estimator): estimator with non-default values.""" params = estimator.get_params(deep=False) - init_func = getattr(estimator.__init__, "deprecated_original", estimator.__init__) - init_params = inspect.signature(init_func).parameters + init_params = inspect.signature(estimator.__init__).parameters init_params = {name: param.default for name, param in init_params.items()} def has_changed(k, v): diff --git a/sklearn/utils/_random.pxd b/sklearn/utils/_random.pxd index ecb9f80361409..376446b066ad1 100644 --- a/sklearn/utils/_random.pxd +++ b/sklearn/utils/_random.pxd @@ -18,7 +18,7 @@ cdef enum: # rand_r replacement using a 32bit XorShift generator # See http://www.jstatsoft.org/v08/i14/paper for details cdef inline uint32_t our_rand_r(uint32_t* seed) nogil: - """Generate a pseudo-random np.uint32 from a np.uint32 seed""" + """Generate a pseudo-random np.uint32 from an np.uint32 seed""" # seed shouldn't ever be 0. if (seed[0] == 0): seed[0] = DEFAULT_SEED diff --git a/sklearn/utils/_repr_html/base.py b/sklearn/utils/_repr_html/base.py index 61e6862ee8623..36aeea254badd 100644 --- a/sklearn/utils/_repr_html/base.py +++ b/sklearn/utils/_repr_html/base.py @@ -8,6 +8,18 @@ from sklearn.utils.fixes import parse_version +class _IDCounter: + """Generate sequential ids with a prefix.""" + + def __init__(self, prefix): + self.prefix = prefix + self.count = 0 + + def get_id(self): + self.count += 1 + return f"{self.prefix}-{self.count}" + + class _HTMLDocumentationLinkMixin: """Mixin class allowing to generate a link to the API documentation. diff --git a/sklearn/utils/_repr_html/common.py b/sklearn/utils/_repr_html/common.py new file mode 100644 index 0000000000000..3d09c170e406c --- /dev/null +++ b/sklearn/utils/_repr_html/common.py @@ -0,0 +1,82 @@ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import html +import inspect +import re +from functools import lru_cache +from urllib.parse import quote + +from sklearn.externals._numpydoc import docscrape + + +def generate_link_to_param_doc(estimator_class, param_name, doc_link): + """URL to the relevant section of the docstring using a Text Fragment + + https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments + """ + docstring = estimator_class.__doc__ + + m = re.search(f"{param_name} : (.+)\\n", docstring or "") + + if m is None: + # No match found in the docstring, return None to indicate that we + # cannot link. + return None + + # Extract the whole line of the type information, up to the line break as + # disambiguation suffix to build the fragment + param_type = m.group(1).replace("`", "") + text_fragment = f"{quote(param_name)},-{quote(param_type)}" + + return f"{doc_link}#:~:text={text_fragment}" + + +@lru_cache +def scrape_estimator_docstring(docstring): + return docscrape.NumpyDocString(docstring) + + +def get_docstring(estimator_class, section_name, item): + """Extract and format docstring information for a specific item. + + Parses the estimator's docstring to retrieve documentation for a + specific parameter or attribute, formatting it as HTML-escaped text. + + Parameters + ---------- + estimator_class : type + The estimator class whose docstring will be parsed. + + section_name : str + The numpydoc section to search in (e.g., "Parameters", "Attributes"). + + item : str + The name of the parameter or attribute to retrieve documentation for. + + Returns + ------- + item_description : str or None + HTML-formatted docstring to be used as a tooltip. Returns None if the + estimator has no docstring or if the item is not found in the + specified section. + """ + estimator_class_docs = inspect.getdoc(estimator_class) + if estimator_class_docs and ( + structured_docstring := scrape_estimator_docstring(estimator_class_docs) + ): + docstring_map = { + item_docstring.name: item_docstring + for item_docstring in structured_docstring[section_name] + } + else: + docstring_map = {} + if item_numpydoc := docstring_map.get(item, None): + item_description = ( + f"{html.escape(item_numpydoc.name)}: " + f"{html.escape(item_numpydoc.type)}<br><br>" + f"{'<br>'.join(html.escape(line) for line in item_numpydoc.desc)}" + ) + else: + item_description = None + return item_description diff --git a/sklearn/utils/_repr_html/estimator.css b/sklearn/utils/_repr_html/estimator.css index 41d39aee91cf3..3c7f9aa2b4c2a 100644 --- a/sklearn/utils/_repr_html/estimator.css +++ b/sklearn/utils/_repr_html/estimator.css @@ -1,4 +1,4 @@ -#$id { +.sk-global { /* Definition of color scheme common for light and dark mode */ --sklearn-color-text: #000; --sklearn-color-text-muted: #666; @@ -15,7 +15,7 @@ --sklearn-color-fitted-level-3: cornflowerblue; } -#$id.light { +.sk-global.light { /* Specific color for light theme */ --sklearn-color-text-on-default-background: black; --sklearn-color-background: white; @@ -23,25 +23,24 @@ --sklearn-color-icon: #696969; } -#$id.dark { +.sk-global.dark { --sklearn-color-text-on-default-background: white; --sklearn-color-background: #111; --sklearn-color-border-box: white; --sklearn-color-icon: #878787; } -#$id { +.sk-global { color: var(--sklearn-color-text); } -#$id pre { +.sk-global pre { padding: 0; } -#$id input.sk-hidden--visually { +.sk-global input.sk-hidden--visually { border: 0; - clip: rect(1px 1px 1px 1px); - clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(100%); height: 1px; margin: -1px; overflow: hidden; @@ -50,7 +49,7 @@ width: 1px; } -#$id div.sk-dashed-wrapped { +.sk-global div.sk-dashed-wrapped { border: 1px dashed var(--sklearn-color-line); margin: 0 0.4em 0.5em 0.4em; box-sizing: border-box; @@ -58,7 +57,7 @@ background-color: var(--sklearn-color-background); } -#$id div.sk-container { +.sk-global div.sk-container { /* jupyter's `normalize.less` sets `[hidden] { display: none; }` but bootstrap.min.css set `[hidden] { display: none !important; }` so we also need the `!important` here to be able to override the @@ -68,7 +67,7 @@ position: relative; } -#$id div.sk-text-repr-fallback { +.sk-global div.sk-text-repr-fallback { display: none; } @@ -84,14 +83,14 @@ div.sk-item { /* Parallel-specific style estimator block */ -#$id div.sk-parallel-item::after { +.sk-global div.sk-parallel-item::after { content: ""; width: 100%; border-bottom: 2px solid var(--sklearn-color-text-on-default-background); flex-grow: 1; } -#$id div.sk-parallel { +.sk-global div.sk-parallel { display: flex; align-items: stretch; justify-content: center; @@ -99,28 +98,28 @@ div.sk-item { position: relative; } -#$id div.sk-parallel-item { +.sk-global div.sk-parallel-item { display: flex; flex-direction: column; } -#$id div.sk-parallel-item:first-child::after { +.sk-global div.sk-parallel-item:first-child::after { align-self: flex-end; width: 50%; } -#$id div.sk-parallel-item:last-child::after { +.sk-global div.sk-parallel-item:last-child::after { align-self: flex-start; width: 50%; } -#$id div.sk-parallel-item:only-child::after { +.sk-global div.sk-parallel-item:only-child::after { width: 0; } /* Serial-specific style estimator block */ -#$id div.sk-serial { +.sk-global div.sk-serial { display: flex; flex-direction: column; align-items: center; @@ -138,14 +137,14 @@ clickable and can be expanded/collapsed. /* Pipeline and ColumnTransformer style (default) */ -#$id div.sk-toggleable { +.sk-global div.sk-toggleable { /* Default theme specific background. It is overwritten whether we have a specific estimator or a Pipeline/ColumnTransformer */ background-color: var(--sklearn-color-background); } /* Toggleable label */ -#$id label.sk-toggleable__label { +.sk-global label.sk-toggleable__label { cursor: pointer; display: flex; width: 100%; @@ -158,13 +157,13 @@ clickable and can be expanded/collapsed. gap: 0.5em; } -#$id label.sk-toggleable__label .caption { +.sk-global label.sk-toggleable__label .caption { font-size: 0.6rem; font-weight: lighter; color: var(--sklearn-color-text-muted); } -#$id label.sk-toggleable__label-arrow:before { +.sk-global label.sk-toggleable__label-arrow:before { /* Arrow on the left of the label */ content: "โ–ธ"; float: left; @@ -172,25 +171,25 @@ clickable and can be expanded/collapsed. color: var(--sklearn-color-icon); } -#$id label.sk-toggleable__label-arrow:hover:before { +.sk-global label.sk-toggleable__label-arrow:hover:before { color: var(--sklearn-color-text); } /* Toggleable content - dropdown */ -#$id div.sk-toggleable__content { +.sk-global div.sk-toggleable__content { display: none; text-align: left; /* unfitted */ background-color: var(--sklearn-color-unfitted-level-0); } -#$id div.sk-toggleable__content.fitted { +.sk-global div.sk-toggleable__content.fitted { /* fitted */ background-color: var(--sklearn-color-fitted-level-0); } -#$id div.sk-toggleable__content pre { +.sk-global div.sk-toggleable__content pre { margin: 0.2em; border-radius: 0.25em; color: var(--sklearn-color-text); @@ -198,78 +197,78 @@ clickable and can be expanded/collapsed. background-color: var(--sklearn-color-unfitted-level-0); } -#$id div.sk-toggleable__content.fitted pre { +.sk-global div.sk-toggleable__content.fitted pre { /* unfitted */ background-color: var(--sklearn-color-fitted-level-0); } -#$id input.sk-toggleable__control:checked~div.sk-toggleable__content { +.sk-global input.sk-toggleable__control:checked~div.sk-toggleable__content { /* Expand drop-down */ display: block; width: 100%; overflow: visible; } -#$id input.sk-toggleable__control:checked~label.sk-toggleable__label-arrow:before { +.sk-global input.sk-toggleable__control:checked~label.sk-toggleable__label-arrow:before { content: "โ–พ"; } /* Pipeline/ColumnTransformer-specific style */ -#$id div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label { +.sk-global div.sk-label input.sk-toggleable__control:checked~label.sk-toggleable__label { color: var(--sklearn-color-text); background-color: var(--sklearn-color-unfitted-level-2); } -#$id div.sk-label.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label { +.sk-global div.sk-label.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label { background-color: var(--sklearn-color-fitted-level-2); } /* Estimator-specific style */ /* Colorize estimator box */ -#$id div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label { +.sk-global div.sk-estimator input.sk-toggleable__control:checked~label.sk-toggleable__label { /* unfitted */ background-color: var(--sklearn-color-unfitted-level-2); } -#$id div.sk-estimator.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label { +.sk-global div.sk-estimator.fitted input.sk-toggleable__control:checked~label.sk-toggleable__label { /* fitted */ background-color: var(--sklearn-color-fitted-level-2); } -#$id div.sk-label label.sk-toggleable__label, -#$id div.sk-label label { +.sk-global div.sk-label label.sk-toggleable__label, +.sk-global div.sk-label label { /* The background is the default theme color */ color: var(--sklearn-color-text-on-default-background); } /* On hover, darken the color of the background */ -#$id div.sk-label:hover label.sk-toggleable__label { +.sk-global div.sk-label:hover label.sk-toggleable__label { color: var(--sklearn-color-text); background-color: var(--sklearn-color-unfitted-level-2); } /* Label box, darken color on hover, fitted */ -#$id div.sk-label.fitted:hover label.sk-toggleable__label.fitted { +.sk-global div.sk-label.fitted:hover label.sk-toggleable__label.fitted { color: var(--sklearn-color-text); background-color: var(--sklearn-color-fitted-level-2); } /* Estimator label */ -#$id div.sk-label label { +.sk-global div.sk-label label { font-family: monospace; font-weight: bold; line-height: 1.2em; } -#$id div.sk-label-container { +.sk-global div.sk-label-container { text-align: center; } /* Estimator-specific */ -#$id div.sk-estimator { +.sk-global div.sk-estimator { font-family: monospace; border: 1px dotted var(--sklearn-color-border-box); border-radius: 0.25em; @@ -279,18 +278,18 @@ clickable and can be expanded/collapsed. background-color: var(--sklearn-color-unfitted-level-0); } -#$id div.sk-estimator.fitted { +.sk-global div.sk-estimator.fitted { /* fitted */ background-color: var(--sklearn-color-fitted-level-0); } /* on hover */ -#$id div.sk-estimator:hover { +.sk-global div.sk-estimator:hover { /* unfitted */ background-color: var(--sklearn-color-unfitted-level-2); } -#$id div.sk-estimator.fitted:hover { +.sk-global div.sk-estimator.fitted:hover { /* fitted */ background-color: var(--sklearn-color-fitted-level-2); } @@ -381,7 +380,7 @@ div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover, /* "?"-specific style due to the `<a>` HTML tag */ -#$id a.estimator_doc_link { +.sk-global a.estimator_doc_link { float: right; font-size: 1rem; line-height: 1em; @@ -396,7 +395,7 @@ div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover, border: var(--sklearn-color-unfitted-level-1) 1pt solid; } -#$id a.estimator_doc_link.fitted { +.sk-global a.estimator_doc_link.fitted { /* fitted */ background-color: var(--sklearn-color-fitted-level-0); border: var(--sklearn-color-fitted-level-1) 1pt solid; @@ -404,14 +403,22 @@ div.sk-label-container:hover .sk-estimator-doc-link.fitted:hover, } /* On hover */ -#$id a.estimator_doc_link:hover { +.sk-global a.estimator_doc_link:hover { /* unfitted */ background-color: var(--sklearn-color-unfitted-level-3); color: var(--sklearn-color-background); text-decoration: none; } -#$id a.estimator_doc_link.fitted:hover { +.sk-global a.estimator_doc_link.fitted:hover { /* fitted */ background-color: var(--sklearn-color-fitted-level-3); } + +.sk-top-container.sk-global { + /* pydata-sphinx-theme hides overflow, so scrolling is disabled. + We need to set it to !important and add tabindex="0" in the HTML + to allow keyboard-only users to navigate the display. */ + overflow-x: scroll !important; + max-width: 100%; +} diff --git a/sklearn/utils/_repr_html/estimator.js b/sklearn/utils/_repr_html/estimator.js index cf1bcd2cf23f8..289d599779e52 100644 --- a/sklearn/utils/_repr_html/estimator.js +++ b/sklearn/utils/_repr_html/estimator.js @@ -1,3 +1,7 @@ +/* Authors: The scikit-learn developers + SPDX-License-Identifier: BSD-3-Clause +*/ + function copyToClipboard(text, element) { // Get the parameter prefix from the closest toggleable content const toggleableContent = element.closest('.sk-toggleable__content'); @@ -35,6 +39,13 @@ function copyToClipboard(text, element) { document.querySelectorAll('.copy-paste-icon').forEach(function(element) { const toggleableContent = element.closest('.sk-toggleable__content'); const paramPrefix = toggleableContent ? toggleableContent.dataset.paramPrefix : ''; + + const parent = element.parentElement; + if (!parent || !parent.nextElementSibling) { + console.warn('Expected copy-paste icon is missing from the DOM structure'); + return; + } + const paramName = element.parentElement.nextElementSibling .textContent.trim().split(' ')[0]; const fullParamName = paramPrefix ? `${paramPrefix}${paramName}` : paramName; @@ -42,7 +53,51 @@ document.querySelectorAll('.copy-paste-icon').forEach(function(element) { element.setAttribute('title', fullParamName); }); +/** + * Copy the list of feature names formatted as a Python list. + * + * @param {HTMLElement} element - The copy button inside a `.features` block; its siblings + * contain a `details` element and a table containing feature named. + * @returns {boolean} Always returns `false` so callers can prevent the default click behavior. + */ +function copyFeatureNamesToClipboard(element) { + var detailsElem = element.closest('.features').querySelector('details'); + var wasOpen = detailsElem.open; + detailsElem.open = true; + var content = element.closest('.features').querySelector('tbody') + .innerText.trim(); + if (!wasOpen) detailsElem.open = false; + const rows = content.split('\n').map(row => ` "${row}"`); + const formattedText = `[\n${rows.join(',\n')},\n]`; + const originalHTML = element.innerHTML.replace('โœ”', ''); + const originalStyle = element.style; + const copyMark = document.createElement('span'); + copyMark.innerHTML = 'โœ”'; + copyMark.style.color = 'blue'; + copyMark.style.fontSize = '1em'; + + navigator.clipboard.writeText(formattedText) + .then(() => { + element.style.display = 'none'; + element.parentElement.appendChild(copyMark); + setTimeout(() => { + copyMark.remove(); + element.innerHTML = originalHTML; + element.style = originalStyle; + }, 1000); + }) + .catch(err => { + console.error('Failed to copy:', err); + element.style.color = 'orange'; + element.innerHTML = "Failed!"; + setTimeout(() => { + element.innerHTML = originalHTML; + element.style = originalStyle; + }, 1000); + }); + return false; +} /** * Adapted from Skrub * https://github.com/skrub-data/skrub/blob/403466d1d5d4dc76a7ef569b3f8228db59a31dc3/skrub/_reporting/_data/templates/report.js#L789 diff --git a/sklearn/utils/_repr_html/estimator.py b/sklearn/utils/_repr_html/estimator.py index cc62922713cf9..152e46f696ddd 100644 --- a/sklearn/utils/_repr_html/estimator.py +++ b/sklearn/utils/_repr_html/estimator.py @@ -6,31 +6,22 @@ from inspect import isclass from io import StringIO from pathlib import Path -from string import Template from sklearn import config_context - - -class _IDCounter: - """Generate sequential ids with a prefix.""" - - def __init__(self, prefix): - self.prefix = prefix - self.count = 0 - - def get_id(self): - self.count += 1 - return f"{self.prefix}-{self.count}" +from sklearn.utils._repr_html.base import _IDCounter +from sklearn.utils._repr_html.features import _features_html def _get_css_style(): estimator_css_file = Path(__file__).parent / "estimator.css" params_css_file = Path(__file__).parent / "params.css" + features_css_file = Path(__file__).parent / "features.css" estimator_css = estimator_css_file.read_text(encoding="utf-8") params_css = params_css_file.read_text(encoding="utf-8") + features_css = features_css_file.read_text(encoding="utf-8") - return f"{estimator_css}\n{params_css}" + return f"{estimator_css}\n{params_css}\n{features_css}" _CONTAINER_ID_COUNTER = _IDCounter("sk-container-id") @@ -108,10 +99,12 @@ def _sk_visual_block_(self): def _write_label_html( out, params, + attrs, name, name_details, name_caption=None, doc_link_label=None, + features=None, outer_class="sk-label-container", inner_class="sk-label", checked=False, @@ -130,6 +123,9 @@ def _write_label_html( If estimator has `get_params` method, this is the HTML representation of the estimator's parameters and their values. When the estimator does not have `get_params`, it is an empty string. + attrs: str + If estimator is fitted, this is the HTML representation of its + fitted attributes. name : str The label for the estimator. It corresponds either to the estimator class name for a simple estimator or in the case of a `Pipeline` and `ColumnTransformer`, @@ -184,10 +180,11 @@ def _write_label_html( f'<a class="sk-estimator-doc-link {is_fitted_css_class}"' f' rel="noreferrer" target="_blank" href="{doc_link}">?{doc_label}</a>' ) - + if name == "passthrough" or name_details == "[]": + name_caption = "" name_caption_div = ( "" - if name_caption is None + if name_caption is None or name_caption == "" else f'<div class="caption">{html.escape(name_caption)}</div>' ) name_caption_div = f"<div><div>{name}</div>{name_caption_div}</div>" @@ -196,28 +193,42 @@ def _write_label_html( if doc_link or is_fitted_icon else "" ) + label_arrow_class = ( + "" if name == "passthrough" else "sk-toggleable__label-arrow" + ) label_html = ( f'<label for="{est_id}" class="sk-toggleable__label {is_fitted_css_class} ' - f'sk-toggleable__label-arrow">{name_caption_div}{links_div}</label>' + f'{label_arrow_class}">{name_caption_div}{links_div}</label>' ) - fmt_str = ( - f'<input class="sk-toggleable__control sk-hidden--visually" id="{est_id}" ' + out.write( + f'<input class="sk-toggleable__control sk-hidden--visually ' + f'sk-global" id="{est_id}" ' f'type="checkbox" {checked_str}>{label_html}<div ' f'class="sk-toggleable__content {is_fitted_css_class}" ' f'data-param-prefix="{html.escape(param_prefix)}">' ) - if params: - fmt_str = "".join([fmt_str, f"{params}</div>"]) - elif name_details and ("Pipeline" not in name): - fmt_str = "".join([fmt_str, f"<pre>{name_details}</pre></div>"]) + out.write(params) + out.write(attrs) + if name_details and ("Pipeline" not in name) and not params: + if name == "passthrough" or name_details == "[]": + name_details = "" + out.write(f"<pre>{name_details}</pre>") + + out.write("</div>") + if features is None or len(features) == 0: + features_div = "" + else: + features_div = _features_html(features, is_fitted_css_class) + + out.write("</div></div>") + out.write(features_div) - out.write(fmt_str) else: out.write(f"<label>{name}</label>") - out.write("</div></div>") # outer_class inner_class + out.write("</div></div>") # outer_class inner_class def _get_visual_block(estimator): @@ -306,6 +317,8 @@ def _write_estimator_html( The prefix to prepend to parameter names for nested estimators. For example, in a pipeline this might be "pipeline__stepname__". """ + from sklearn.compose import ColumnTransformer + if first_call: est_block = _get_visual_block(estimator) else: @@ -317,11 +330,14 @@ def _write_estimator_html( doc_link = estimator._get_doc_link() else: doc_link = "" + + has_feature_names_out = hasattr(estimator, "get_feature_names_out") + is_not_pipeline_step = not hasattr(estimator, "steps") + if est_block.kind in ("serial", "parallel"): dashed_wrapped = first_call or est_block.dash_wrapped dash_cls = " sk-dashed-wrapped" if dashed_wrapped else "" out.write(f'<div class="sk-item{dash_cls}">') - if estimator_label: if hasattr(estimator, "get_params") and hasattr( estimator, "_get_params_html" @@ -329,13 +345,24 @@ def _write_estimator_html( params = estimator._get_params_html(False, doc_link)._repr_html_inner() else: params = "" + if ( + hasattr(estimator, "_get_fitted_attr_html") + and is_fitted_css_class == "fitted" + ): + fitted_attrs = estimator._get_fitted_attr_html(doc_link) + attrs = fitted_attrs._repr_html_inner() if len(fitted_attrs) > 0 else "" + + else: + attrs = "" _write_label_html( out, params, + attrs, estimator_label, estimator_label_details, doc_link=doc_link, + features=None, is_fitted_css_class=is_fitted_css_class, is_fitted_icon=is_fitted_icon, param_prefix=param_prefix, @@ -380,16 +407,59 @@ def _write_estimator_html( ) out.write("</div>") # sk-parallel-item - out.write("</div></div>") + out.write("</div>") + + is_column_transformer = isinstance(estimator, ColumnTransformer) + has_single_estimator = len(est_block.estimators) == 1 + if ( + is_fitted_css_class + and has_feature_names_out + and is_not_pipeline_step + and not (is_column_transformer and has_single_estimator) + ): + features_div = _features_html( + estimator.get_feature_names_out(), is_fitted_css_class + ) + total_output_features_item = ( + f"<div class='total_features'>{features_div}</div>" + ) + out.write(total_output_features_item) + + out.write("</div>") elif est_block.kind == "single": - if hasattr(estimator, "_get_params_html"): + if has_feature_names_out and is_not_pipeline_step and is_fitted_css_class: + try: + output_features = estimator.get_feature_names_out() + except Exception: + output_features = "" + else: + output_features = "" + + if est_block.names == "NoneType(...)": + est_block.names = "passthrough" + + if ( + hasattr(estimator, "_get_params_html") + and not est_block.names == "passthrough" + ): params = estimator._get_params_html(doc_link=doc_link)._repr_html_inner() else: params = "" + if ( + hasattr(estimator, "_get_fitted_attr_html") + and not est_block.names == "passthrough" + and is_fitted_css_class == "fitted" + ): + fitted_attrs = estimator._get_fitted_attr_html(doc_link) + attrs = fitted_attrs._repr_html_inner() if len(fitted_attrs) > 0 else "" + + else: + attrs = "" _write_label_html( out, params, + attrs, est_block.names, est_block.name_details, est_block.name_caption, @@ -398,6 +468,7 @@ def _write_estimator_html( inner_class="sk-estimator", checked=first_call, doc_link=doc_link, + features=output_features, is_fitted_css_class=is_fitted_css_class, is_fitted_icon=is_fitted_icon, param_prefix=param_prefix, @@ -405,7 +476,7 @@ def _write_estimator_html( def estimator_html_repr(estimator): - """Build a HTML representation of an estimator. + """Build an HTML representation of an estimator. Read more in the :ref:`User Guide <visualizing_composite_estimators>`. @@ -424,7 +495,7 @@ def estimator_html_repr(estimator): >>> from sklearn.utils._repr_html.estimator import estimator_html_repr >>> from sklearn.linear_model import LogisticRegression >>> estimator_html_repr(LogisticRegression()) - '<style>#sk-container-id...' + '<style>.sk-global...' """ from sklearn.exceptions import NotFittedError from sklearn.utils.validation import check_is_fitted @@ -447,8 +518,6 @@ def estimator_html_repr(estimator): ) with closing(StringIO()) as out: container_id = _CONTAINER_ID_COUNTER.get_id() - style_template = Template(_CSS_STYLE) - style_with_id = style_template.substitute(id=container_id) estimator_str = str(estimator) # The fallback message is shown by default and loading the CSS sets @@ -467,9 +536,10 @@ def estimator_html_repr(estimator): " with nbviewer.org." ) html_template = ( - f"<style>{style_with_id}</style>" + f"<style>{_CSS_STYLE}</style>" f"<body>" - f'<div id="{container_id}" class="sk-top-container">' + # we need tabindex="0" to make it 'focusable' + f'<div id="{container_id}" tabindex="0" class="sk-top-container sk-global">' '<div class="sk-text-repr-fallback">' f"<pre>{html.escape(estimator_str)}</pre><b>{fallback_msg}</b>" "</div>" diff --git a/sklearn/utils/_repr_html/features.css b/sklearn/utils/_repr_html/features.css new file mode 100644 index 0000000000000..8fb3c12b7a38a --- /dev/null +++ b/sklearn/utils/_repr_html/features.css @@ -0,0 +1,120 @@ +.features { + font-family: monospace; + cursor: pointer; + background-color: var(--sklearn-color-unfitted-level-0); + border: 1px dotted var(--sklearn-color-border-box); + border-radius: .20em; + margin-bottom: 0.5em; + font-size: inherit; /* Needed for jupyter */ +} + +.features.fitted { + background-color: var(--sklearn-color-fitted-level-0); +} + +.features summary { + cursor: pointer; + display: flex; + margin-bottom: 0; + text-align: center; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: .25em; +} + +.features details[open] > summary { + color: var(--sklearn-color-text); + background-color: var(--sklearn-color-unfitted-level-2); + border-radius: .20em 0 0 0; +} + +.features.fitted details[open] > summary { + background-color: var(--sklearn-color-fitted-level-2); + border-radius: .20em 0 0 0; +} + +.features details > summary .arrow::before { + content: "โ–ธ"; + color: grey; +} + +.features details[open] > summary .arrow::before { + content: "โ–พ"; +} + +.features details:hover > summary { + margin: 0; + background-color: var(--sklearn-color-unfitted-level-2); +} + +.features.fitted details:hover > summary { + margin: 0; + background-color: var(--sklearn-color-fitted-level-2); +} + +.features .features-container { + max-width: 15em; + max-height: 10em; + overflow: auto; + scrollbar-width: thin; + padding: .25em 0.1rem; + background-color: var(--sklearn-color-unfitted-level-0); + border-radius: 0 0 .5em .5em; +} + +.features.fitted .features-container { + background-color: var(--sklearn-color-fitted-level-0); +} + +.features .image-container { + block-size: 1em; + inline-size: 1em; + padding: 0; + margin: 0%; + display: flex; + justify-content: center; + align-items: center; +} + +.features .copy-paste-icon { + background-size: 1em 1em; + width: 1em; + height: 1em; + filter: grayscale(100%) opacity(60%); +} + +.features .features-container table { + width: 100%; + margin: 0.01em; +} + +.features .features-container table tr:nth-child(odd) { + background-color: #fff; +} + +.features .features-container table tr:nth-child(even) { + background-color: #f6f6f6; +} + +.features .features-container table tr:hover { + background-color: #e0e0e0; +} + +.features .features-container table { + table-layout: inherit; +} + +.features .features-container table td { + text-align: left; + padding: 0 0.5em; + border: 1px solid rgba(106, 105, 104, 0.232); + white-space: nowrap; + color: var(--sklearn-color-text); +} + +.total_features { + display: flex; + justify-content: center; + margin-top: 0.5em; +} diff --git a/sklearn/utils/_repr_html/features.py b/sklearn/utils/_repr_html/features.py new file mode 100644 index 0000000000000..855f3950fe705 --- /dev/null +++ b/sklearn/utils/_repr_html/features.py @@ -0,0 +1,61 @@ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import html + + +def _features_html(features, is_fitted_css_class=""): + """Generate HTML representation of feature names. + + Creates a collapsible HTML details element containing a table of feature + names with a summary line showing the total count. Includes a copy-to-clipboard + button for all feature names. + """ + FEATURES_TABLE_TEMPLATE = """ + <div class="features {is_fitted_css_class}"> + <details> + <summary> + <div class="arrow"></div> + <div>{total_features_line}</div> + <div class="image-container" title="Copy all output features"> + <i class="copy-paste-icon" + onclick=" + event.stopPropagation(); + event.preventDefault(); + copyFeatureNamesToClipboard(this); + " + > + </i> + </div> + </summary> + <div class="features-container"> + <table class="features-table"> + <tbody> + {rows} + </tbody> + </table> + </div> + </details> + </div> + """ + + FEATURES_ROW_TEMPLATE = """ + <tr> + <td>{feature}</td> + </tr> + + """ + total_features = len(features) + total_features_line = ( + f"{total_features} {'feature' if total_features == 1 else 'features'}" + ) + + rows = [ + FEATURES_ROW_TEMPLATE.format(feature=html.escape(feature)) + for feature in features + ] + return FEATURES_TABLE_TEMPLATE.format( + total_features_line=total_features_line, + is_fitted_css_class=html.escape(is_fitted_css_class), + rows="".join(rows), + ) diff --git a/sklearn/utils/_repr_html/fitted_attributes.py b/sklearn/utils/_repr_html/fitted_attributes.py new file mode 100644 index 0000000000000..9375baad5a811 --- /dev/null +++ b/sklearn/utils/_repr_html/fitted_attributes.py @@ -0,0 +1,166 @@ +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import html +import reprlib +from collections import UserDict + +import numpy as np + +from sklearn.utils._repr_html.base import ReprHTMLMixin +from sklearn.utils._repr_html.common import ( + generate_link_to_param_doc, + get_docstring, +) + + +def _read_fitted_attr(value): + if isinstance(value, np.ndarray): + value = np.array2string( + value, precision=2, separator=",", suppress_small=True, threshold=4 + ) + return html.escape(value) + + r = reprlib.Repr() + + for attr in ( + "maxlist", + "maxdict", + "maxtuple", + "maxset", + "maxfrozenset", + "maxdeque", + "maxarray", + ): + setattr(r, attr, 4) + r.maxstring = 9 + + if isinstance(value, float): + return f"{value:.4g}" + + return html.escape(r.repr(value)) + + +def _fitted_attr_html_repr(fitted_attributes): + """Generate HTML representation of estimator fitted attributes. + + Creates an HTML table with fitted attribute names and values + wrapped in a collapsible details element. When attributes are arrays, + shape and dtype are shown. + """ + + FITTED_ATTR_TEMPLATE = """ + <div class="estimator-table"> + <details> + <summary>Fitted attributes</summary> + <table class="parameters-table"> + <tbody> + <tr> + <th>Name</th> + <th>Type</th> + <th>Value</th> + </tr> + {rows} + </tbody> + </table> + </details> + </div> + """ + FITTED_ATTR_ROW_TEMPLATE = """ + <tr class="default"> + <td class="param">{fitted_attr_display}</td> + <td class="fitted-att-type">{0}</td> + <td>{1}</td> + + + </tr> + """ + + FITTED_ATTR_AVAILABLE_DOC_LINK_TEMPLATE = """ + <a class="param-doc-link" + style="anchor-name: --doc-link-{fitted_attr_name};" + rel="noreferrer" target="_blank" href="{link}"> + {fitted_attr_name} + <span class="param-doc-description" + style="position-anchor: --doc-link-{fitted_attr_name};"> + {fitted_attr_description}</span> + </a> + """ + + rows = [] + + for name, attr_info in fitted_attributes.items(): + link = generate_link_to_param_doc( + fitted_attributes.estimator_class, + name, + fitted_attributes.doc_link, + ) + fitted_attr_description = get_docstring( + fitted_attributes.estimator_class, "Attributes", name + ) + + if fitted_attributes.doc_link and link and fitted_attr_description: + # Create clickable attribute name with documentation link + fitted_attr_display = FITTED_ATTR_AVAILABLE_DOC_LINK_TEMPLATE.format( + link=link, + fitted_attr_name=html.escape(name), + fitted_attr_description=fitted_attr_description, + ) + else: + # Just show the attribute name without link + fitted_attr_display = ( + f'<a class="param-doc-link" style="text-decoration:none;">' + f"{html.escape(name)}</a>" + ) + + if len(attr_info) == 2: + html_row_values = ( + html.escape(str(attr_info["type_name"])), + _read_fitted_attr(attr_info["value"]), # All the attr info comes here + ) + else: # fitted attribute type is array-like + type_name = html.escape(attr_info["type_name"]) + dtype = html.escape(str(attr_info["dtype"])) + shape = html.escape(str(attr_info["shape"])) + + html_row_values = ( + f"{type_name}[{dtype}]{shape}", + _read_fitted_attr(attr_info["value"]), + ) + rows.append( + FITTED_ATTR_ROW_TEMPLATE.format( + *html_row_values, + fitted_attr_display=fitted_attr_display, + ) + ) + + return FITTED_ATTR_TEMPLATE.format(rows="\n".join(rows)) + + +class AttrsDict(ReprHTMLMixin, UserDict): + """Dictionary-like class to store and provide an HTML representation. + + It builds an HTML structure to be used with Jupyter notebooks or similar + environments. + + Parameters + ---------- + fitted_attrs : dict, default=None + Dictionary of fitted attributes and their values. + + estimator_class : type, default=None + The class of the estimator. It allows to find the online documentation + link for each parameter. + + doc_link : str, default="" + The base URL to the online documentation for the estimator class. + Used to generate parameter-specific documentation links in the HTML + representation. If empty, documentation links will not be generated. + """ + + _html_repr = _fitted_attr_html_repr + + def __init__(self, *, fitted_attrs=None, estimator_class=None, doc_link=""): + super().__init__(fitted_attrs or {}) + self.estimator_class = estimator_class + self.doc_link = doc_link diff --git a/sklearn/utils/_repr_html/params.css b/sklearn/utils/_repr_html/params.css index 10d1a0a79a68b..e7552310f6934 100644 --- a/sklearn/utils/_repr_html/params.css +++ b/sklearn/utils/_repr_html/params.css @@ -31,11 +31,11 @@ background-color: #f6f6f6; } -.estimator-table .parameters-table tr:hover { +.estimator-table .parameters-table tr:hover td { background-color: #e0e0e0; } -.estimator-table table td { +.estimator-table table :is(td, th) { border: 1px solid rgba(106, 105, 104, 0.232); } @@ -59,7 +59,7 @@ background-color: transparent; } -.default td { +.default td, .estimator-table th { color: black; text-align: left !important; } @@ -69,6 +69,10 @@ color: black; } +td.fitted-att-type { + white-space: preserve nowrap; +} + /* Styles for parameter documentation links We need styling for visited so jupyter doesn't overwrite it @@ -83,6 +87,14 @@ a.param-doc-link:visited { padding: .5em; } +@supports(anchor-name: --doc-link) { + a.param-doc-link, + a.param-doc-link:link, + a.param-doc-link:visited { + anchor-name: --doc-link; + } +} + /* "hack" to make the entire area of the cell containing the link clickable */ a.param-doc-link::before { position: absolute; @@ -109,6 +121,14 @@ a.param-doc-link::before { border: thin solid var(--sklearn-color-unfitted-level-3); } +@supports(position-area: center right) { + .param-doc-description { + position-area: center right; + position: fixed; + margin-left: 0; + } +} + /* Fitted state for parameter tooltips */ .fitted .param-doc-description { /* fitted */ diff --git a/sklearn/utils/_repr_html/params.py b/sklearn/utils/_repr_html/params.py index 011dde246198d..213a9feeb2029 100644 --- a/sklearn/utils/_repr_html/params.py +++ b/sklearn/utils/_repr_html/params.py @@ -2,37 +2,14 @@ # SPDX-License-Identifier: BSD-3-Clause import html -import inspect -import re import reprlib from collections import UserDict -from functools import lru_cache -from urllib.parse import quote -from sklearn.externals._numpydoc import docscrape from sklearn.utils._repr_html.base import ReprHTMLMixin - - -def _generate_link_to_param_doc(estimator_class, param_name, doc_link): - """URL to the relevant section of the docstring using a Text Fragment - - https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments - """ - docstring = estimator_class.__doc__ - - m = re.search(f"{param_name} : (.+)\\n", docstring or "") - - if m is None: - # No match found in the docstring, return None to indicate that we - # cannot link. - return None - - # Extract the whole line of the type information, up to the line break as - # disambiguation suffix to build the fragment - param_type = m.group(1) - text_fragment = f"{quote(param_name)},-{quote(param_type)}" - - return f"{doc_link}#:~:text={text_fragment}" +from sklearn.utils._repr_html.common import ( + generate_link_to_param_doc, + get_docstring, +) def _read_params(name, value, non_default_params): @@ -51,11 +28,6 @@ def _read_params(name, value, non_default_params): return {"param_type": param_type, "param_name": name, "param_value": cleaned_value} -@lru_cache -def _scrape_estimator_docstring(docstring): - return docscrape.NumpyDocString(docstring) - - def _params_html_repr(params): """Generate HTML representation of estimator parameters. @@ -89,32 +61,21 @@ def _params_html_repr(params): PARAM_AVAILABLE_DOC_LINK_TEMPLATE = """ <a class="param-doc-link" + style="anchor-name: --doc-link-{param_name};" rel="noreferrer" target="_blank" href="{link}"> {param_name} - <span class="param-doc-description">{param_description}</span> + <span class="param-doc-description" + style="position-anchor: --doc-link-{param_name};"> + {param_description}</span> </a> """ - estimator_class_docs = inspect.getdoc(params.estimator_class) - if estimator_class_docs and ( - structured_docstring := _scrape_estimator_docstring(estimator_class_docs) - ): - param_map = { - param_docstring.name: param_docstring - for param_docstring in structured_docstring["Parameters"] - } - else: - param_map = {} + rows = [] for row in params: param = _read_params(row, params[row], params.non_default) - link = _generate_link_to_param_doc(params.estimator_class, row, params.doc_link) - if param_numpydoc := param_map.get(row, None): - param_description = ( - f"{param_numpydoc.name}: {param_numpydoc.type}<br><br>" - f"{'<br>'.join(param_numpydoc.desc)}" - ) - else: - param_description = None + link = generate_link_to_param_doc(params.estimator_class, row, params.doc_link) + + param_description = get_docstring(params.estimator_class, "Parameters", row) if params.doc_link and link and param_description: # Create clickable parameter name with documentation link diff --git a/sklearn/utils/_repr_html/tests/test_attributes.py b/sklearn/utils/_repr_html/tests/test_attributes.py new file mode 100644 index 0000000000000..e599269e7e96e --- /dev/null +++ b/sklearn/utils/_repr_html/tests/test_attributes.py @@ -0,0 +1,185 @@ +import re + +import numpy as np +import pytest + +from sklearn import config_context +from sklearn.utils._repr_html.fitted_attributes import ( + AttrsDict, + _fitted_attr_html_repr, + _read_fitted_attr, +) + +fitted_attrs = AttrsDict( + fitted_attrs={ + "a": {"type_name": "int", "value": 6}, + "b": { + "type_name": "ndarray", + "shape": (1,), + "dtype": np.dtype("float64"), + "value": 8, + }, + } +) + + +def test_numpy_fitted_attr(): + html_fitted_attr_out = _fitted_attr_html_repr(fitted_attrs) + + expected_html_fitted_attr = ( + r'<div class="estimator-table">' + r"\s*<details>" + r"\s*<summary>Fitted attributes</summary>" + r'\s*<table class="parameters-table">' + r"\s*<tbody>" + r"\s*<tr>" + r"\s*<th>Name</th>" + r"\s*<th>Type</th>" + r"\s*<th>Value</th>" + r"\s*</tr>" + r'\s*<tr class="default">' + r'\s*<td class="param">' + r'\s*<a class="param-doc-link" style="text-decoration:none;">a</a>' + r"\s*</td>" + r'\s*<td class="fitted-att-type">int</td>' + r"\s*<td>6</td>" + r"\s*</tr>" + r'\s*<tr class="default">' + r'\s*<td class="param">' + r'\s*<a class="param-doc-link" style="text-decoration:none;">b</a>' + r"\s*</td>" + r'\s*<td class="fitted-att-type">ndarray\[float64\]\(1,\)</td>' + r"\s*<td>8</td>" + r"\s*</tr>" + r"\s*</tbody>" + r"\s*</table>" + r"\s*</details>" + r"\s*</div>" + ) + assert re.search(expected_html_fitted_attr, html_fitted_attr_out, flags=re.DOTALL) + + +def test_fitted_attrs_dict_repr_html_error(): + out = fitted_attrs._repr_html_() + assert "<summary>Fitted attributes</summary>" in out + assert '<td class="fitted-att-type">int</td>' in out + assert '<td class="fitted-att-type">ndarray[float64](1,)</td>' in out + + with config_context(display="text"): + msg = "_repr_html_ is only defined when" + with pytest.raises(AttributeError, match=msg): + fitted_attrs._repr_html_() + + +def test_fitted_attrs_dict_repr_mimebundle(): + out = fitted_attrs._repr_mimebundle_() + + assert "text/plain" in out + assert "text/html" in out + assert "<summary>Fitted attributes</summary>" in out["text/html"] + plain_text = ( + "{'a': {'type_name': 'int', 'value': 6}, " + "'b': {'type_name': 'ndarray', 'shape': (1,), " + "'dtype': dtype('float64'), 'value': 8}}" + ) + assert out["text/plain"] == plain_text + with config_context(display="text"): + out = fitted_attrs._repr_mimebundle_() + assert "text/plain" in out + assert "text/html" not in out + + +def test_fitted_attr_html_repr(): + out = _fitted_attr_html_repr(fitted_attrs) + assert "<summary>Fitted attributes</summary>" in out + assert '<table class="parameters-table">' in out + + +def test_pandas_column_names(): + pd = pytest.importorskip("pandas") + fitted_attrs_with_pandas_cols = AttrsDict( + fitted_attrs={ + "myabc_": { + "type_name": "DataFrame", + "shape": (3, 3), + "dtype": np.dtype("int64"), + "value": pd.DataFrame({"A": [0, 2, 4], "B": [3, 4, 5], "C": [3, 4, 4]}), + } + } + ) + html_fitted_attr_out = _fitted_attr_html_repr(fitted_attrs_with_pandas_cols) + expected_html_fitted_attr = ( + r'<div class="estimator-table">' + r"\s*<details>" + r"\s*<summary>Fitted attributes</summary>" + r'\s*<table class="parameters-table">' + r"\s*<tbody>" + r"\s*<tr>" + r"\s*<th>Name</th>" + r"\s*<th>Type</th>" + r"\s*<th>Value</th>" + r"\s*</tr>" + r"\s*<tr class=\"default\">" + r"\s*<td class=\"param\">" + r'\s*<a class="param-doc-link" style="text-decoration:none;">myabc_</a>' + r"\s*</td>" + r'\s*<td class="fitted-att-type">DataFrame\[int64\]\(3,\s*3\)</td>' + r"\s*<td>\s*A\s*B\s*C\s*0\s*\.\.\.\s*4\s*2\s*4\s*5\s*4\s*</td>" + r"\s*</tr>" + r"\s*</tbody>" + r"\s*</table>" + r"\s*</details>" + r"\s*</div>" + ) + assert re.search(expected_html_fitted_attr, html_fitted_attr_out, flags=re.DOTALL) + + +def test_pandas_series_fitted_attr(): + pd = pytest.importorskip("pandas") + fitted_attrs_with_series = AttrsDict( + fitted_attrs={ + "new_": { + "type_name": "Series", + "shape": (3,), + "dtype": np.dtype("int64"), + "value": pd.Series({"a": 1, "b": 2, "c": 3}), + } + } + ) + html_fitted_attr_out = _fitted_attr_html_repr(fitted_attrs_with_series) + expected_html_fitted_attr = ( + r'<div class="estimator-table">' + r"\s*<details>" + r"\s*<summary>Fitted attributes</summary>" + r'\s*<table class="parameters-table">' + r"\s*<tbody>" + r"\s*<tr>" + r"\s*<th>Name</th>" + r"\s*<th>Type</th>" + r"\s*<th>Value</th>" + r"\s*</tr>" + r'\s*<tr class="default">' + r'\s*<td class="param">' + r'\s*<a class="param-doc-link" style="text-decoration:none;">new_</a>' + r"\s*</td>" + r'\s*<td class="fitted-att-type">Series\[int64\]\(3,\)</td>' + r"\s*<td>a\s*1\s*b\s*2...3\s*dtype:\s*int64</td>" + r"\s*</tr>" + r"\s*</tbody>" + r"\s*</table>" + r"\s*</details>" + r"\s*</div>" + ) + assert re.search(expected_html_fitted_attr, html_fitted_attr_out, flags=re.DOTALL) + + +@pytest.mark.parametrize( + "value, expected", + [ + (123.456, "123.5"), + (0.00123456, "0.001235"), + (1234567.0, "1.235e+06"), + ], +) +def test_read_fitted_attr_float_formatting(value, expected): + assert _read_fitted_attr(value) == expected diff --git a/sklearn/utils/_repr_html/tests/test_estimator.py b/sklearn/utils/_repr_html/tests/test_estimator.py index 290a8cfaa504f..2684a6a589bd0 100644 --- a/sklearn/utils/_repr_html/tests/test_estimator.py +++ b/sklearn/utils/_repr_html/tests/test_estimator.py @@ -13,7 +13,11 @@ from sklearn import config_context from sklearn.base import BaseEstimator, clone from sklearn.cluster import AgglomerativeClustering, Birch -from sklearn.compose import ColumnTransformer, make_column_transformer +from sklearn.compose import ( + ColumnTransformer, + TransformedTargetRegressor, + make_column_transformer, +) from sklearn.datasets import load_iris from sklearn.decomposition import PCA, TruncatedSVD from sklearn.ensemble import StackingClassifier, StackingRegressor, VotingClassifier @@ -21,7 +25,7 @@ from sklearn.gaussian_process.kernels import ExpSineSquared from sklearn.impute import SimpleImputer from sklearn.kernel_ridge import KernelRidge -from sklearn.linear_model import LogisticRegression +from sklearn.linear_model import LinearRegression, LogisticRegression from sklearn.model_selection import RandomizedSearchCV from sklearn.multiclass import OneVsOneClassifier from sklearn.neural_network import MLPClassifier @@ -48,10 +52,11 @@ def test_write_label_html(checked): # Test checking logic and labeling name = "LogisticRegression" params = "" + attrs = "" tool_tip = "hello-world" with closing(StringIO()) as out: - _write_label_html(out, params, name, tool_tip, checked=checked) + _write_label_html(out, params, attrs, name, tool_tip, checked=checked) html_label = out.getvalue() p = ( @@ -141,6 +146,17 @@ def test_get_visual_block_column_transformer(): assert est_html_info.name_details == (["num1", "num2"], [0, 3]) +def test_get_visual_block_transformed_target_regressor(): + X = np.array([[0], [1], [2], [3], [4]]) + y = np.array([1.0, 7.3, 54.5, 403.5]) + tt = TransformedTargetRegressor(func=np.log, inverse_func=np.exp) + est_html_info = _get_visual_block(tt) + assert est_html_info.kind == "serial" + assert isinstance(est_html_info.estimators[0], LinearRegression) + assert est_html_info.names == ["regressor: LinearRegression"] + assert est_html_info.name_details == ["LinearRegression()"] + + def test_estimator_html_repr_an_empty_pipeline(): """Check that the representation of an empty Pipeline does not fail. diff --git a/sklearn/utils/_repr_html/tests/test_features.py b/sklearn/utils/_repr_html/tests/test_features.py new file mode 100644 index 0000000000000..80b8267660e7c --- /dev/null +++ b/sklearn/utils/_repr_html/tests/test_features.py @@ -0,0 +1,162 @@ +import numpy as np +import pytest + +from sklearn.compose import ColumnTransformer +from sklearn.decomposition import PCA, TruncatedSVD +from sklearn.feature_extraction.text import CountVectorizer +from sklearn.pipeline import FeatureUnion, Pipeline +from sklearn.preprocessing import Normalizer, StandardScaler +from sklearn.utils._repr_html.estimator import estimator_html_repr +from sklearn.utils._repr_html.features import _features_html +from sklearn.utils._testing import MinimalTransformer + +ct = ColumnTransformer([("norm1", Normalizer(), [0, 1])], remainder="passthrough") +ct2 = FeatureUnion( + [("pca", PCA(n_components=1)), ("svd", TruncatedSVD(n_components=2))] +) +rng = np.random.RandomState(42) + + +def test_n_features_not_fitted(): + out = estimator_html_repr(ct) + assert "2 features" not in out + assert "x0" not in out + assert "x1" not in out + assert "<div class='features fitted'>" not in out + + +def test_with_MinimalTransformer(): + """Test works with MinimalTransformer in a pipeline + (doesn't inherit from BaseEstimator)""" + X, y = np.array([[0, 1], [1, 1]]), np.array([[0, 1]]) + + model = Pipeline([("transformer", MinimalTransformer())]) + model.fit(X, y) + out = estimator_html_repr(model) + assert "MinimalTransformer" in out + + +@pytest.mark.parametrize( + "pandas, feature_cols", + [ + (True, ["Feature A", "Feature B"]), + (False, ["x0", "x1"]), + ], +) +def test_estimator_html_repr_col_names(pandas, feature_cols): + """Test features names are kept with pandas col names and generic.""" + if pandas: + pd = pytest.importorskip("pandas") + X = pd.DataFrame({"Feature A": [0, 2], "Feature B": [1, 1]}) + else: + X = np.array([[0, 2], [1, 1]]) + + ct.fit(X) + out = estimator_html_repr(ct) + assert feature_cols[0] in out + assert feature_cols[1] in out + + +@pytest.mark.parametrize( + "pandas, total_output_features", + [ + (True, ["norm1__A", "norm1__B", "remainder__C"]), + (False, ["norm1__x0", "norm1__x1", "remainder__x2"]), + ], +) +def test_estimator_html_repr_total_feature_names(pandas, total_output_features): + """Test features names are kept with pandas col names and generic.""" + if pandas: + pd = pytest.importorskip("pandas") + X = pd.DataFrame({"A": [0, 2, 3], "B": [1, 1, 3], "C": [3, 5, 4]}) + else: + X = np.array([[0, 2, 3], [1, 1, 3], [3, 5, 4]]) + + ct.fit(X) + out = estimator_html_repr(ct) + + assert "<div class='total_features'>" in out + assert "3 features</div>" in out + for feature_name in total_output_features: + assert feature_name in out + + +def test_estimator_html_col_names_featureunion(): + X = [[0.0, 1.0, 3], [2.0, 2.0, 5]] + ct2.fit_transform(X) + out = estimator_html_repr(ct2) + + assert "pca__pca0" in out + assert "svd__truncatedsvd0" in out + assert "2 features" in out + + +def test_features_html_with_pipeline(): + """Test works with MinimalTransformer in a pipeline and scaler + to test number of features.""" + + pipe = Pipeline([("minimal", MinimalTransformer()), ("scaler", StandardScaler())]) + + X = rng.randn(10, 3) + pipe.fit(X) + html = estimator_html_repr(pipe) + assert "3 features" in html + + +def test_countvectorizer_output_features(): + """Non-regression test for + https://github.com/scikit-learn/scikit-learn/issues/33772""" + corpus = [ + "cat", + "dog", + "mouse", + "bird", + ] + vectorizer = CountVectorizer() + vectorizer.fit_transform(corpus) + html = estimator_html_repr(vectorizer) + assert "4 features" in html + + +def test_features_html_empty_features(): + """Test that _features_html handles empty feature list.""" + features = [] + html = _features_html(features) + + assert "0 features" in html + assert "<tbody>" in html + + +def test_features_html_special_characters(): + """Test that special characters in feature names are properly escaped.""" + features = [ + "feature&1", + 'feature"2', + "feature'3", + "feature>4", + "feature<5", + "<script>alert('xss')</script>", + ] + html = _features_html(features) + + assert "&" in html + assert "<script>alert('xss')</script>" in html + assert ">" in html + assert "<" in html + assert "6 features" in html + + +def test_features_html_structure(): + """Test that HTML structure contains expected elements.""" + features = ["feat1", "feat2"] + html = _features_html(features) + + assert "<details>" in html + assert "<summary>" in html + assert "</summary>" in html + assert "</details>" in html + assert '<table class="features-table">' in html + assert "<tbody>" in html + assert "</tbody>" in html + assert '<i class="copy-paste-icon"' in html + assert "copyFeatureNamesToClipboard" in html diff --git a/sklearn/utils/_repr_html/tests/test_js.py b/sklearn/utils/_repr_html/tests/test_js.py index 69101b95eb0e0..6940d7caa51db 100644 --- a/sklearn/utils/_repr_html/tests/test_js.py +++ b/sklearn/utils/_repr_html/tests/test_js.py @@ -60,7 +60,7 @@ def log_message(self, format, *args): def _make_page(body): - """Helper to create a HTML page that includes `estimator.js` and the given body.""" + """Helper to create an HTML page that includes `estimator.js` and the given body.""" js_path = Path(__file__).parent.parent / "estimator.js" with open(js_path, "r", encoding="utf-8") as f: @@ -135,3 +135,49 @@ def test_force_theme(page, local_server, color, expected_theme): assert page.locator("#test").evaluate( f"el => el.classList.contains('{expected_theme}')" ) + + +FEATURE_NAMES_HTML = """ + <div class="features"> + <details> + <summary> + <div class="image-container" + title="Copy all output features"> + <i class="copy-paste-icon"></i> + </div> + </summary> + <div class="features-container"> + <table class="features-table"> + <tbody> + <tr><td>feature1</td></tr> + <tr><td>feature2</td></tr> + </tbody> + </table> + </div> + </details> + </div> + """ + + +def test_copy_paste_feature_names(page, local_server): + """Test that copyFeatureNamesToClipboard copies the right text to the clipboard. + + Test requires clipboard permissions, which are granted through page's context. + Assertion is done by reading back the clipboard content from the browser. + This is easier than writing a cross platform clipboard reader. + + Test adapted from test_copy_paste + """ + url, set_html_response = local_server + + copy_paste_html = _make_page(FEATURE_NAMES_HTML) + + set_html_response(copy_paste_html) + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + page.goto(url) + page.evaluate( + "copyFeatureNamesToClipboard(document.querySelector('.copy-paste-icon'))" + ) + clipboard_content = page.evaluate("navigator.clipboard.readText()") + + assert clipboard_content == '[\n "feature1",\n "feature2",\n]' diff --git a/sklearn/utils/_repr_html/tests/test_params.py b/sklearn/utils/_repr_html/tests/test_params.py index a2fe8d54c0a6d..a86d28250cd90 100644 --- a/sklearn/utils/_repr_html/tests/test_params.py +++ b/sklearn/utils/_repr_html/tests/test_params.py @@ -3,12 +3,8 @@ import pytest from sklearn import config_context -from sklearn.utils._repr_html.params import ( - ParamsDict, - _generate_link_to_param_doc, - _params_html_repr, - _read_params, -) +from sklearn.utils._repr_html.common import generate_link_to_param_doc +from sklearn.utils._repr_html.params import ParamsDict, _params_html_repr, _read_params def test_params_dict_content(): @@ -90,7 +86,8 @@ class MockEstimator: Parameters ---------- a : int - Description of a. + Description of a which can include `<formatted text + https://example.com>`_ that should not be confused with HTML tags. b : str """ @@ -108,11 +105,15 @@ class MockEstimator: html_param_a = ( r'<td class="param">' r'\s*<a class="param-doc-link"' + r'\s*style="anchor-name: --doc-link-a;"' r'\s*rel="noreferrer" target="_blank"' r'\shref="mock_module\.MockEstimator\.html#:~:text=a,-int">' r"\s*a" - r'\s*<span class="param-doc-description">a: int<br><br>' - r"Description of a\.</span>" + r'\s*<span class="param-doc-description"' + r'\s*style="position-anchor: --doc-link-a;">\s*a:' + r"\sint<br><br>" + r"Description of a which can include `<formatted text<br>" + r"https://example.com>`_ that should not be confused with HTML tags.</span>" r"\s*</a>" r"\s*</td>" ) @@ -120,10 +121,13 @@ class MockEstimator: html_param_b = ( r'<td class="param">' r'.*<a class="param-doc-link"' + r'\s*style="anchor-name: --doc-link-b;"' r'\s*rel="noreferrer" target="_blank"' r'\shref="mock_module\.MockEstimator\.html#:~:text=b,-str">' r"\s*b" - r'\s*<span class="param-doc-description">b: str<br><br></span>' + r'\s*<span class="param-doc-description"' + r'\s*style="position-anchor: --doc-link-b;">\s*b:' + r"\sstr<br><br></span>" r"\s*</a>" r"\s*</td>" ) @@ -174,10 +178,10 @@ class MockEstimator: """ doc_link = "mock_module.MockEstimator.html" - url = _generate_link_to_param_doc(MockEstimator, "alpha", doc_link) + url = generate_link_to_param_doc(MockEstimator, "alpha", doc_link) assert url == "mock_module.MockEstimator.html#:~:text=alpha,-float" - url = _generate_link_to_param_doc(MockEstimator, "beta", doc_link) + url = generate_link_to_param_doc(MockEstimator, "beta", doc_link) assert url == "mock_module.MockEstimator.html#:~:text=beta,-int" @@ -194,7 +198,7 @@ class MockEstimator: """ doc_link = "mock_module.MockEstimator.html" - url = _generate_link_to_param_doc(MockEstimator, "gamma", doc_link) + url = generate_link_to_param_doc(MockEstimator, "gamma", doc_link) assert url is None @@ -206,5 +210,27 @@ class MockEstimator: pass doc_link = "mock_module.MockEstimator.html" - url = _generate_link_to_param_doc(MockEstimator, "alpha", doc_link) + url = generate_link_to_param_doc(MockEstimator, "alpha", doc_link) assert url is None + + +def test_generate_link_to_param_doc_special_char(): + """Non-regression test for + https://github.com/scikit-learn/scikit-learn/issues/33830 + """ + + class MockEstimator: + """Mock class. + + Attributes + ---------- + weird_attr_ : ndarray of shape (`n_features_in_`,) + """ + + doc_link = "mock_module.MockEstimator.html" + url = generate_link_to_param_doc(MockEstimator, "weird_attr_", doc_link) + expected_url = ( + "mock_module.MockEstimator.html#:~:text=weird_attr_," + "-ndarray%20of%20shape%20%28n_features_in_%2C%29" + ) + assert url == expected_url diff --git a/sklearn/utils/_response.py b/sklearn/utils/_response.py index 16c0ff0f4cf68..fc97e5fe9da7c 100644 --- a/sklearn/utils/_response.py +++ b/sklearn/utils/_response.py @@ -41,7 +41,7 @@ def _process_predict_proba(*, y_pred, target_type, classes, pos_label): Class labels as reported by `estimator.classes_`. pos_label : int, float, bool or str - Only used with binary and multiclass targets. + Only used with binary targets. Returns ------- @@ -79,7 +79,8 @@ def _process_decision_function(*, y_pred, target_type, classes, pos_label): This function process the `y_pred` array in the binary and multi-label cases. In the binary case, it inverts the sign of the score if the positive label is not `classes[1]`. In the multi-label case, it stacks the predictions if - they are not in the "compressed" format `(n_samples, n_outputs)`. + the positive label is not `classes[1]`. `y_pred` is returned unchanged if + `target_type` is "multiclass" or "multilabel-indicator". Parameters ---------- @@ -100,7 +101,7 @@ def _process_decision_function(*, y_pred, target_type, classes, pos_label): Class labels as reported by `estimator.classes_`. pos_label : int, float, bool or str - Only used with binary and multiclass targets. + Only used with binary targets. Returns ------- @@ -120,15 +121,18 @@ def _get_response_values( pos_label=None, return_response_method_used=False, ): - """Compute the response values of a classifier, an outlier detector, or a regressor. + """Compute the response values of a classifier, an outlier detector, a regressor + or a clusterer. The response values are predictions such that it follows the following shape: - for binary classification, it is a 1d array of shape `(n_samples,)`; - - for multiclass classification, it is a 2d array of shape `(n_samples, n_classes)`; + - for multiclass classification + - with response_method="predict", it is a 1d array of shape `(n_samples,)`; + - otherwise, it is a 2d array of shape `(n_samples, n_classes)`; - for multilabel classification, it is a 2d array of shape `(n_samples, n_outputs)`; - - for outlier detection, it is a 1d array of shape `(n_samples,)`; - - for regression, it is a 1d array of shape `(n_samples,)`. + - for outlier detection, a regressor or a clusterer, it is a 1d array of shape + `(n_samples,)`. If `estimator` is a binary classifier, also return the label for the effective positive class. @@ -140,9 +144,9 @@ def _get_response_values( Parameters ---------- estimator : estimator instance - Fitted classifier, outlier detector, or regressor or a + Fitted classifier, outlier detector, regressor, clusterer or a fitted :class:`~sklearn.pipeline.Pipeline` in which the last estimator is a - classifier, an outlier detector, or a regressor. + classifier, an outlier detector, a regressor or a clusterer. X : {array-like, sparse matrix} of shape (n_samples, n_features) Input values. @@ -160,8 +164,8 @@ def _get_response_values( pos_label : int, float, bool or str, default=None The class considered as the positive class when computing - the metrics. If `None` and target is 'binary', `estimators.classes_[1]` is - considered as the positive class. + the response values. If `None` and target is 'binary', + `estimators.classes_[1]` is considered as the positive class. return_response_method_used : bool, default=False Whether to return the response method used to compute the response @@ -178,8 +182,8 @@ def _get_response_values( pos_label : int, float, bool, str or None The class considered as the positive class when computing - the metrics. Returns `None` if `estimator` is a regressor or an outlier - detector. + binary response values. Returns `None` if `estimator` is a regressor, an outlier + detector or a clusterer. response_method_used : str The response method used to compute the response values. Only returned @@ -192,23 +196,20 @@ def _get_response_values( ValueError If `pos_label` is not a valid label. If the shape of `y_pred` is not consistent for binary classifier. - If the response method can be applied to a classifier only and - `estimator` is a regressor. """ - from sklearn.base import is_classifier, is_outlier_detector + prediction_method = _check_response_method(estimator, response_method) if is_classifier(estimator): - prediction_method = _check_response_method(estimator, response_method) classes = estimator.classes_ target_type = type_of_target(classes) - if target_type in ("binary", "multiclass"): + if target_type == "binary": if pos_label is not None and pos_label not in classes.tolist(): raise ValueError( f"pos_label={pos_label} is not a valid label: It should be " f"one of {classes}" ) - elif pos_label is None and target_type == "binary": + elif pos_label is None: pos_label = classes[-1] y_pred = prediction_method(X) @@ -227,18 +228,7 @@ def _get_response_values( classes=classes, pos_label=pos_label, ) - elif is_outlier_detector(estimator): - prediction_method = _check_response_method(estimator, response_method) - y_pred, pos_label = prediction_method(X), None - else: # estimator is a regressor - if response_method != "predict": - raise ValueError( - f"{estimator.__class__.__name__} should either be a classifier to be " - f"used with response_method={response_method} or the response_method " - "should be 'predict'. Got a regressor with response_method=" - f"{response_method} instead." - ) - prediction_method = estimator.predict + else: y_pred, pos_label = prediction_method(X), None if return_response_method_used: diff --git a/sklearn/utils/_set_output.py b/sklearn/utils/_set_output.py index 3b4fb6b546a3c..ad2ae60078352 100644 --- a/sklearn/utils/_set_output.py +++ b/sklearn/utils/_set_output.py @@ -27,8 +27,11 @@ def get_columns(columns): if callable(columns): try: return columns() - except Exception: - return None + except AttributeError as e: + if "does not provide get_feature_names_out" in str(e): + return None + else: + raise return columns @@ -95,7 +98,7 @@ def rename_columns(self, X, columns): Container with new names. """ - def hstack(self, Xs): + def hstack(self, Xs, feature_names=None): """Stack containers horizontally (column-wise). Parameters @@ -103,6 +106,10 @@ def hstack(self, Xs): Xs : list of containers List of containers to stack. + feature_names : array-like of str, default=None + The feature names for the stacked container. If provided, the + columns of the result will be renamed to these names. + Returns ------- stacked_Xs : container @@ -147,9 +154,12 @@ def rename_columns(self, X, columns): X.columns = columns return X - def hstack(self, Xs): + def hstack(self, Xs, feature_names=None): pd = check_library_installed("pandas") - return pd.concat(Xs, axis=1) + result = pd.concat(Xs, axis=1) + if feature_names is not None: + self.rename_columns(result, feature_names) + return result class PolarsAdapter: @@ -178,8 +188,16 @@ def rename_columns(self, X, columns): X.columns = columns return X - def hstack(self, Xs): + def hstack(self, Xs, feature_names=None): pl = check_library_installed("polars") + if feature_names is not None: + # Rename columns in each X before concat to avoid duplicates + start = 0 + for X in Xs: + n_features = X.shape[1] + names = feature_names[start : start + n_features] + self.rename_columns(X, names) + start += n_features return pl.concat(Xs, how="horizontal") @@ -390,8 +408,9 @@ def __init_subclass__(cls, auto_wrap_output_keys=("transform",), **kwargs): def set_output(self, *, transform=None): """Set output container. - See :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` - for an example on how to use the API. + Refer to the :ref:`user guide <df_output_transform>` for more details + and :ref:`sphx_glr_auto_examples_miscellaneous_plot_set_output.py` for an + example on how to use the API. Parameters ---------- diff --git a/sklearn/utils/_sorting.pxd b/sklearn/utils/_sorting.pxd index 43b24dddad22f..ee900b6387429 100644 --- a/sklearn/utils/_sorting.pxd +++ b/sklearn/utils/_sorting.pxd @@ -2,8 +2,9 @@ from sklearn.utils._typedefs cimport intp_t from cython cimport floating -cdef int simultaneous_sort( - floating *dist, - intp_t *idx, - intp_t size, +cdef void simultaneous_sort( + floating* values, + intp_t* indices, + intp_t n, + bint use_three_way_partition=*, ) noexcept nogil diff --git a/sklearn/utils/_sorting.pyx b/sklearn/utils/_sorting.pyx index 13b2d872392b9..66187874f3275 100644 --- a/sklearn/utils/_sorting.pyx +++ b/sklearn/utils/_sorting.pyx @@ -1,93 +1,265 @@ -from cython cimport floating +from libc.math cimport log2 -cdef inline void dual_swap( - floating* darr, - intp_t *iarr, - intp_t a, - intp_t b, -) noexcept nogil: - """Swap the values at index a and b of both darr and iarr""" - cdef floating dtmp = darr[a] - darr[a] = darr[b] - darr[b] = dtmp +from cython cimport floating - cdef intp_t itmp = iarr[a] - iarr[a] = iarr[b] - iarr[b] = itmp +from sklearn.utils._typedefs cimport intp_t -cdef int simultaneous_sort( +cdef void simultaneous_sort( floating* values, intp_t* indices, - intp_t size, + intp_t n, + bint use_three_way_partition=False, ) noexcept nogil: - """ - Perform a recursive quicksort on the values array as to sort them ascendingly. - This simultaneously performs the swaps on both the values and the indices arrays. + """Sort values and indices simultaneously by values. The numpy equivalent is: + def simultaneous_sort(values, indices): + i = np.argsort(values) + return values[i], indices[i] - def simultaneous_sort(dist, idx): - i = np.argsort(dist) - return dist[i], idx[i] + Algorithm: Introsort (Musser, SP&E, 1997) with two variants for the + quicksort part: + + - If use_three_way_partition is True, use 3-way partitioning: + [x < pivot] [x == pivot] [x > pivot]. This variant is fast when + working with many duplicate values, otherwise it's slower. + - If use_three_way_partition is False, use 2-way partitioning: + [x <= pivot] [pivot] [x >= pivot]. There are three parts too, but the middle + part is only the selected pivot element, not all values equal to the pivot. Notes ----- - Arrays are manipulated via a pointer to there first element and their size - as to ease the processing of dynamically allocated buffers. + Arrays are manipulated via a pointer to their first element and their size + to ease the processing of dynamically allocated buffers. + + TODO: In order to support discrete distance metrics, we need to have a + simultaneous sort which breaks ties on indices when distances are + identical. The best might be using a std::stable_sort and a Comparator + which might need an Array of Structures (AoS) instead of the Structure of + Arrays (SoA) currently used. An alternative would be to implement a stable + sort ourselves, like the radix sort for instance. """ - # TODO: In order to support discrete distance metrics, we need to have a - # simultaneous sort which breaks ties on indices when distances are identical. - # The best might be using a std::stable_sort and a Comparator which might need - # an Array of Structures (AoS) instead of the Structure of Arrays (SoA) - # currently used. - cdef: - intp_t pivot_idx, i, store_idx - floating pivot_val - - # in the small-array case, do things efficiently - if size <= 1: - pass - elif size == 2: - if values[0] > values[1]: - dual_swap(values, indices, 0, 1) - elif size == 3: - if values[0] > values[1]: - dual_swap(values, indices, 0, 1) - if values[1] > values[2]: - dual_swap(values, indices, 1, 2) - if values[0] > values[1]: - dual_swap(values, indices, 0, 1) + if n == 0: + return + cdef intp_t maxd = 2 * <intp_t>log2(n) + if use_three_way_partition: + introsort_3way(values, indices, n, maxd) else: - # Determine the pivot using the median-of-three rule. - # The smallest of the three is moved to the beginning of the array, - # the middle (the pivot value) is moved to the end, and the largest - # is moved to the pivot index. - pivot_idx = size // 2 - if values[0] > values[size - 1]: - dual_swap(values, indices, 0, size - 1) - if values[size - 1] > values[pivot_idx]: - dual_swap(values, indices, size - 1, pivot_idx) - if values[0] > values[size - 1]: - dual_swap(values, indices, 0, size - 1) - pivot_val = values[size - 1] - - # Partition indices about pivot. At the end of this operation, - # pivot_idx will contain the pivot value, everything to the left - # will be smaller, and everything to the right will be larger. - store_idx = 0 - for i in range(size - 1): - if values[i] < pivot_val: - dual_swap(values, indices, i, store_idx) - store_idx += 1 - dual_swap(values, indices, store_idx, size - 1) - pivot_idx = store_idx - - # Recursively sort each side of the pivot - if pivot_idx > 1: - simultaneous_sort(values, indices, pivot_idx) - if pivot_idx + 2 < size: - simultaneous_sort(values + pivot_idx + 1, - indices + pivot_idx + 1, - size - pivot_idx - 1) - return 0 + introsort_2way(values, indices, n, maxd) + + +def _py_simultaneous_sort( + floating[::1] values, + intp_t[::1] indices, + intp_t n, + *, + bint use_three_way_partition, +): + """Python wrapper used for testing.""" + simultaneous_sort(&values[0], &indices[0], n, use_three_way_partition) + + +cdef void introsort_2way( + floating* values, + intp_t* indices, + intp_t n, + intp_t maxd, +) noexcept nogil: + cdef floating pivot + cdef intp_t pivot_idx, i, j + + while n > 15: + if maxd <= 0: # max depth limit exceeded ("gone quadratic") + heapsort(values, indices, n) + return + maxd -= 1 + + pivot = inplace_median3(values, indices, n) + + i = 1 # the median3 step ensures values[0] <= pivot + j = n - 2 # the median3 step ensures values[-1] >= pivot + while True: + # Find element >= pivot from left + while i <= j and values[i] < pivot: + i += 1 + # Find element <= pivot from right + while i <= j and values[j] > pivot: + j -= 1 + if i >= j: + break + swap(values, indices, i, j) + i += 1 + j -= 1 + + # Put pivot at pivot_idx + pivot_idx = i + swap(values, indices, pivot_idx, n - 1) + + # Recursively sort left side of the pivot + introsort_2way(values, indices, pivot_idx, maxd) + + # Continue with right side: + values += pivot_idx + 1 + indices += pivot_idx + 1 + n -= pivot_idx + 1 + + # in the small-array case, insertion sort is faster + insertion_sort(values, indices, n) + + +cdef void introsort_3way( + floating* values, intp_t *indices, + intp_t n, intp_t maxd +) noexcept nogil: + """ + Introsort with median of 3 pivot selection and 3-way partition function + (fast for repeated elements, e.g. lots of zeros). + """ + cdef floating pivot + cdef intp_t i, l, r + + while n > 15: + if maxd <= 0: # max depth limit exceeded ("gone quadratic") + heapsort(values, indices, n) + return + maxd -= 1 + + pivot = median3(values, n) + + i = l = 0 + r = n + while i < r: + if values[i] < pivot: + swap(values, indices, i, l) + i += 1 + l += 1 + elif values[i] > pivot: + r -= 1 + swap(values, indices, i, r) + else: + i += 1 + + # Three-way partition: + # - values[:l] contains elements < pivot + # - values[l:r] contains elements == pivot + # - values[r:] contains elements > pivot + + # Recursively sort left side: + introsort_3way(values, indices, l, maxd) + + # Continue with right side: + values += r + indices += r + n -= r + + # in the small-array case, insertion sort is faster + insertion_sort(values, indices, n) + +# ------------ HEAP SORT ------------- + +cdef void heapsort(floating* feature_values, intp_t* samples, intp_t n) noexcept nogil: + cdef intp_t start, end + + # heapify + start = (n - 2) / 2 + end = n + while True: + sift_down(feature_values, samples, start, end) + if start == 0: + break + start -= 1 + + # sort by shrinking the heap, putting the max element immediately after it + end = n - 1 + while end > 0: + swap(feature_values, samples, 0, end) + sift_down(feature_values, samples, 0, end) + end = end - 1 + + +cdef inline void sift_down(floating* feature_values, intp_t* samples, + intp_t start, intp_t end) noexcept nogil: + # Restore heap order in feature_values[start:end] by moving the max element to start. + cdef intp_t child, maxind, root + + root = start + while True: + child = root * 2 + 1 + + # find max of root, left child, right child + maxind = root + if child < end and feature_values[maxind] < feature_values[child]: + maxind = child + if child + 1 < end and feature_values[maxind] < feature_values[child + 1]: + maxind = child + 1 + + if maxind == root: + break + else: + swap(feature_values, samples, root, maxind) + root = maxind + + +# ------------ HELPERS ------------- + +cdef inline floating inplace_median3(floating* values, intp_t* indices, intp_t n) noexcept nogil: + # # Median of three pivot selection + # The smallest of the three is moved to the beginning of the array, + # the middle (the pivot value) is moved to the end, and the largest + # is moved to the pivot index. + pivot_idx = n // 2 + if values[0] > values[n - 1]: + swap(values, indices, 0, n - 1) + if values[n - 1] > values[pivot_idx]: + swap(values, indices, n - 1, pivot_idx) + if values[0] > values[n - 1]: + swap(values, indices, 0, n - 1) + return values[n - 1] + + +cdef inline floating median3(floating* feature_values, intp_t n) noexcept nogil: + # Median of three pivot selection, after Bentley and McIlroy (1993). + # Engineering a sort function. SP&E. Requires 8/3 comparisons on average. + cdef floating a = feature_values[0], b = feature_values[n / 2], c = feature_values[n - 1] + if a < b: + if b < c: + return b + elif a < c: + return c + else: + return a + elif b < c: + if a < c: + return a + else: + return c + else: + return b + + +cdef inline void swap(floating* values, intp_t* indices, + intp_t i, intp_t j) noexcept nogil: + # Helper for sort + values[i], values[j] = values[j], values[i] + indices[i], indices[j] = indices[j], indices[i] + + +cdef inline void insertion_sort( + floating* values, intp_t *indices, intp_t n +) noexcept nogil: + cdef intp_t i, j, temp_idx + cdef floating temp_val + + for i in range(1, n): + temp_val = values[i] + temp_idx = indices[i] + + j = i + while j > 0 and values[j - 1] > temp_val: + values[j] = values[j - 1] + indices[j] = indices[j - 1] + j -= 1 + + values[j] = temp_val + indices[j] = temp_idx diff --git a/sklearn/utils/_sparse.py b/sklearn/utils/_sparse.py new file mode 100644 index 0000000000000..8975f2264cdab --- /dev/null +++ b/sklearn/utils/_sparse.py @@ -0,0 +1,39 @@ +"""Control sparse interface based on config""" + +# Authors: The scikit-learn developers +# SPDX-License-Identifier: BSD-3-Clause + +import scipy as sp + +from sklearn._config import get_config + + +def _align_api_if_sparse(X): + """ + Convert to sparse interface as set in config. + + Input can be dense or sparse. + If sparse, convert to sparse_interface indicated by get_config. + Otherwise, return X unchanged. + """ + if not sp.sparse.issparse(X): + return X + + config_sparse_interface = get_config()["sparse_interface"] + + # there are only two sparse interfaces: sparray and spmatrix + if config_sparse_interface == "sparray": + if sp.sparse.isspmatrix(X): + # Fundamental code to switch to sparray in any format + return getattr(sp.sparse, X.format + "_array")(X) + return X + elif config_sparse_interface == "spmatrix": + if sp.sparse.isspmatrix(X): + return X + # Fundamental code to switch to spmatrix in any format + return getattr(sp.sparse, X.format + "_matrix")(X) + else: + raise ValueError( + f'Config "sparse_interface" is {config_sparse_interface}. ' + 'It should be either "sparray" or "spmatrix".' + ) diff --git a/sklearn/utils/_tags.py b/sklearn/utils/_tags.py index a87d34b4d54f3..5319fc692d449 100644 --- a/sklearn/utils/_tags.py +++ b/sklearn/utils/_tags.py @@ -271,6 +271,12 @@ def get_tags(estimator) -> Tags: The estimator tags. """ + if isinstance(estimator, type): + raise TypeError( + f"Expected an estimator instance ({estimator.__name__}()), got " + f"estimator class instead ({estimator.__name__})." + ) + try: tags = estimator.__sklearn_tags__() except AttributeError as exc: diff --git a/sklearn/utils/_test_common/instance_generator.py b/sklearn/utils/_test_common/instance_generator.py index 14f8090b96cf8..a1a6cc975a60d 100644 --- a/sklearn/utils/_test_common/instance_generator.py +++ b/sklearn/utils/_test_common/instance_generator.py @@ -46,7 +46,10 @@ SparsePCA, TruncatedSVD, ) -from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +from sklearn.discriminant_analysis import ( + LinearDiscriminantAnalysis, + QuadraticDiscriminantAnalysis, +) from sklearn.dummy import DummyClassifier from sklearn.ensemble import ( AdaBoostClassifier, @@ -79,6 +82,7 @@ SequentialFeatureSelector, ) from sklearn.frozen import FrozenEstimator +from sklearn.impute import SimpleImputer from sklearn.kernel_approximation import ( Nystroem, PolynomialCountSketch, @@ -147,6 +151,7 @@ MultiOutputRegressor, RegressorChain, ) +from sklearn.naive_bayes import GaussianNB from sklearn.neighbors import ( KernelDensity, KNeighborsClassifier, @@ -158,8 +163,14 @@ from sklearn.neural_network import BernoulliRBM, MLPClassifier, MLPRegressor from sklearn.pipeline import FeatureUnion, Pipeline from sklearn.preprocessing import ( + Binarizer, KBinsDiscretizer, + KernelCenterer, + LabelEncoder, + MinMaxScaler, + Normalizer, OneHotEncoder, + PolynomialFeatures, SplineTransformer, StandardScaler, TargetEncoder, @@ -345,7 +356,10 @@ LinearSVC: dict(max_iter=20), LinearSVR: dict(max_iter=20), LocallyLinearEmbedding: dict(max_iter=5), - LogisticRegressionCV: dict(max_iter=5, cv=3, use_legacy_attributes=False), + # TODO(1.11): remove scoring because it is default now + LogisticRegressionCV: dict( + max_iter=5, cv=3, use_legacy_attributes=False, scoring="neg_log_loss" + ), LogisticRegression: dict(max_iter=5), MDS: dict(n_init=2, max_iter=5), # In the case of check_fit2d_1sample, bandwidth is set to None and @@ -458,6 +472,10 @@ # Default "auto" parameter can lead to different ordering of eigenvalues on # windows: #24105 SpectralEmbedding: dict(eigen_tol=1e-05), + # SplineTransformer supports NaN only with handle_missing="zeros", so we + # need this additional parameter set for the allow_nan_estimators Sphinx + # directive to detect it. + SplineTransformer: [dict(), dict(handle_missing="zeros")], StackingClassifier: dict( estimators=[ ("est1", DecisionTreeClassifier(max_depth=3, random_state=0)), @@ -511,13 +529,11 @@ "check_sample_weight_equivalence_on_dense_data": [ dict(criterion="squared_error"), dict(criterion="absolute_error"), - dict(criterion="friedman_mse"), dict(criterion="poisson"), ], "check_sample_weight_equivalence_on_sparse_data": [ dict(criterion="squared_error"), dict(criterion="absolute_error"), - dict(criterion="friedman_mse"), dict(criterion="poisson"), ], }, @@ -559,12 +575,16 @@ dict(solver="lbfgs"), ], }, - GaussianMixture: {"check_dict_unchanged": dict(max_iter=5, n_init=2)}, + GaussianMixture: { + "check_dict_unchanged": dict(max_iter=5, n_init=2), + "check_array_api_input": dict( + max_iter=5, n_init=2, init_params="random_from_data" + ), + }, GaussianRandomProjection: {"check_dict_unchanged": dict(n_components=1)}, + GraphicalLasso: {"check_array_api_input": dict(max_iter=5, alpha=1.0)}, IncrementalPCA: {"check_dict_unchanged": dict(batch_size=10, n_components=1)}, Isomap: {"check_dict_unchanged": dict(n_components=1)}, - KMeans: {"check_dict_unchanged": dict(max_iter=5, n_clusters=1, n_init=2)}, - # TODO(1.9) simplify when averaged_inverted_cdf is the default KBinsDiscretizer: { "check_sample_weight_equivalence_on_dense_data": [ # Using subsample != None leads to a stochastic fit that is not @@ -579,23 +599,12 @@ # The "kmeans" strategy leads to a stochastic fit that is not # handled by the check_sample_weight_equivalence test. ], - "check_sample_weights_list": dict( - strategy="quantile", quantile_method="averaged_inverted_cdf" - ), - "check_sample_weights_pandas_series": dict( - strategy="quantile", quantile_method="averaged_inverted_cdf" - ), - "check_sample_weights_shape": dict( - strategy="quantile", quantile_method="averaged_inverted_cdf" - ), - "check_sample_weights_not_an_array": dict( - strategy="quantile", quantile_method="averaged_inverted_cdf" - ), - "check_sample_weights_not_overwritten": dict( - strategy="quantile", quantile_method="averaged_inverted_cdf" - ), }, - KernelPCA: {"check_dict_unchanged": dict(n_components=1)}, + KernelPCA: { + "check_dict_unchanged": dict(n_components=1), + "check_array_api_input": dict(fit_inverse_transform=True), + }, + KMeans: {"check_dict_unchanged": dict(max_iter=5, n_clusters=1, n_init=2)}, LassoLars: {"check_non_transformer_estimators_n_iter": dict(alpha=0.0)}, LatentDirichletAllocation: { "check_dict_unchanged": dict(batch_size=10, max_iter=5, n_components=1) @@ -693,6 +702,7 @@ dict(solver="highs-ipm"), ], }, + QuadraticDiscriminantAnalysis: {"check_array_api_input": dict(reg_param=1.0)}, RBFSampler: {"check_dict_unchanged": dict(n_components=1)}, Ridge: { "check_sample_weight_equivalence_on_dense_data": [ @@ -720,7 +730,9 @@ ], }, SkewedChi2Sampler: {"check_dict_unchanged": dict(n_components=1)}, + SimpleImputer: {"check_array_api_input": dict(add_indicator=True)}, SparseCoder: { + "check_array_api_input": dict(dictionary=rng.normal(size=(5, 10))), "check_estimators_dtypes": dict(dictionary=rng.normal(size=(5, 5))), "check_dtype_object": dict(dictionary=rng.normal(size=(5, 10))), "check_transformers_unfitted_stateless": dict( @@ -923,6 +935,9 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + Binarizer: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, BernoulliRBM: { "check_methods_subset_invariance": ("fails for the decision_function method"), "check_methods_sample_order_invariance": ("fails for the score_samples method"), @@ -936,6 +951,9 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + CalibratedClassifierCV: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + }, ColumnTransformer: { "check_estimators_empty_data_messages": "FIXME", "check_estimators_nan_inf": "FIXME", @@ -951,6 +969,9 @@ def _yield_instances_for_check(check, estimator_orig): "check_methods_sample_order_invariance": "fails for the predict method", }, FeatureUnion: { + # Fails because StandardScaler, which gets wrapped by FeatureUnion, supports + # array API but FeatureUnion itself does not + "check_array_api_same_namespace": "check_same_namespace not yet added", "check_estimators_overwrite_params": "FIXME", "check_estimators_nan_inf": "FIXME", "check_dont_overwrite_parameters": "FIXME", @@ -966,6 +987,13 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + GaussianMixture: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + }, + GaussianNB: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, GradientBoostingClassifier: { # TODO: investigate failure see meta-issue #16298 "check_sample_weight_equivalence_on_dense_data": ( @@ -989,6 +1017,7 @@ def _yield_instances_for_check(check, estimator_orig): "check_requires_y_none": "Doesn't fail gracefully", }, HalvingGridSearchCV: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", "check_fit2d_1sample": ( "Fail during parameter check since min/max resources requires more samples" ), @@ -999,6 +1028,7 @@ def _yield_instances_for_check(check, estimator_orig): "check_requires_y_none": "Doesn't fail gracefully", }, HalvingRandomSearchCV: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", "check_fit2d_1sample": ( "Fail during parameter check since min/max resources requires more samples" ), @@ -1035,6 +1065,9 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + KernelCenterer: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, KernelDensity: { "check_sample_weight_equivalence_on_dense_data": ( "sample_weight must have positive values" @@ -1052,6 +1085,12 @@ def _yield_instances_for_check(check, estimator_orig): KNeighborsTransformer: { "check_methods_sample_order_invariance": "check is not applicable." }, + LinearDiscriminantAnalysis: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + }, + LabelEncoder: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, LinearSVC: { # TODO: replace by a statistical test when _dual=True, see meta-issue #16298 "check_sample_weight_equivalence_on_dense_data": ( @@ -1082,6 +1121,9 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + MinMaxScaler: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, MiniBatchKMeans: { # TODO: replace by a statistical test, see meta-issue #16298 "check_sample_weight_equivalence_on_dense_data": ( @@ -1115,10 +1157,14 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + Normalizer: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, Nystroem: { + "check_array_api_same_namespace": "check_same_namespace not yet added", "check_transformer_preserves_dtypes": ( "dtypes are preserved but not at a close enough precision" - ) + ), }, OneClassSVM: { # TODO: fix sample_weight handling of this estimator, see meta-issue #16298 @@ -1129,6 +1175,12 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + PCA: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + # TODO: see gh-33205 for details + "check_array_api_input": "`linalg.inv` fails because input is singular", + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, Perceptron: { # TODO: replace by a statistical test, see meta-issue #16298 "check_sample_weight_equivalence_on_dense_data": ( @@ -1148,6 +1200,13 @@ def _yield_instances_for_check(check, estimator_orig): "Therefore this test is x-fail until we fix this." ), }, + PoissonRegressor: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, + PolynomialFeatures: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, RadiusNeighborsTransformer: { "check_methods_sample_order_invariance": "check is not applicable." }, @@ -1159,6 +1218,10 @@ def _yield_instances_for_check(check, estimator_orig): "check_sample_weight_equivalence_on_sparse_data": ( "sample_weight is not equivalent to removing/repeating samples." ), + # TODO: error raised by all zero sample weights will be addressed by PR #31529 + "check_classifiers_one_label_sample_weights": ( + "failed when fitted on one label after sample_weight trimming." + ), }, RandomForestRegressor: { # TODO: replace by a statistical test, see meta-issue #16298 @@ -1191,15 +1254,19 @@ def _yield_instances_for_check(check, estimator_orig): "sample_weight is not equivalent to removing/repeating samples." ), }, + RBFSampler: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, Ridge: { "check_non_transformer_estimators_n_iter": ( "n_iter_ cannot be easily accessed." ) }, RidgeClassifier: { + "check_array_api_mixed_inputs": "mixed array API input support not added yet", "check_non_transformer_estimators_n_iter": ( "n_iter_ cannot be easily accessed." - ) + ), }, SelfTrainingClassifier: { "check_non_transformer_estimators_n_iter": "n_iter_ can be 0." @@ -1254,6 +1321,9 @@ def _yield_instances_for_check(check, estimator_orig): "check_dont_overwrite_parameters": "empty array passed inside", "check_fit2d_predict1d": "empty array passed inside", }, + StandardScaler: { + "check_array_api_same_namespace": "check_same_namespace not yet added", + }, SVC: { # TODO: fix sample_weight handling of this estimator when probability=False # TODO: replace by a statistical test when probability=True diff --git a/sklearn/utils/_testing.py b/sklearn/utils/_testing.py index c3a1b5d6b73b7..f0b9a4b7f9acb 100644 --- a/sklearn/utils/_testing.py +++ b/sklearn/utils/_testing.py @@ -45,7 +45,10 @@ TargetTags, TransformerTags, ) -from sklearn.utils._array_api import _check_array_api_dispatch +from sklearn.utils._array_api import ( + _check_array_api_dispatch, + _max_precision_float_dtype, +) from sklearn.utils.fixes import ( _IS_32BIT, VisibleDeprecationWarning, @@ -964,7 +967,7 @@ def assert_run_python_script_without_output(source_code, pattern=".+", timeout=6 def _convert_container( container, constructor_name, - columns_name=None, + column_names=None, dtype=None, minversion=None, categorical_feature_names=None, @@ -975,13 +978,13 @@ def _convert_container( ---------- container : array-like The container to convert. - constructor_name : {"list", "tuple", "array", "sparse", "dataframe", \ + constructor_name : {"list", "tuple", "array", "sparse", \ "pandas", "series", "index", "slice", "sparse_csr", "sparse_csc", \ "sparse_csr_array", "sparse_csc_array", "pyarrow", "polars", \ "polars_series"} The type of the returned container. - columns_name : index or array-like, default=None - For pandas/polars container supporting `columns_names`, it will affect + column_names : index or array-like, default=None + For pandas/polars container supporting `column_names`, it will affect specific names. dtype : dtype, default=None Force the dtype of the container. Does not apply to `"slice"` @@ -1007,9 +1010,9 @@ def _convert_container( return tuple(np.asarray(container, dtype=dtype).tolist()) elif constructor_name == "array": return np.asarray(container, dtype=dtype) - elif constructor_name in ("pandas", "dataframe"): + elif constructor_name == "pandas": pd = pytest.importorskip("pandas", minversion=minversion) - result = pd.DataFrame(container, columns=columns_name, dtype=dtype, copy=False) + result = pd.DataFrame(container, columns=column_names, dtype=dtype, copy=False) if categorical_feature_names is not None: for col_name in categorical_feature_names: result[col_name] = result[col_name].astype("category") @@ -1018,9 +1021,9 @@ def _convert_container( pa = pytest.importorskip("pyarrow", minversion=minversion) array = np.asarray(container) array = array[:, None] if array.ndim == 1 else array - if columns_name is None: - columns_name = [f"col{i}" for i in range(array.shape[1])] - data = {name: array[:, i] for i, name in enumerate(columns_name)} + if column_names is None: + column_names = [f"col{i}" for i in range(array.shape[1])] + data = {name: array[:, i] for i, name in enumerate(column_names)} result = pa.Table.from_pydict(data) if categorical_feature_names is not None: for col_idx, col_name in enumerate(result.column_names): @@ -1031,7 +1034,7 @@ def _convert_container( return result elif constructor_name == "polars": pl = pytest.importorskip("polars", minversion=minversion) - result = pl.DataFrame(container, schema=columns_name, orient="row") + result = pl.DataFrame(container, schema=column_names, orient="row") if categorical_feature_names is not None: for col_name in categorical_feature_names: result = result.with_columns(pl.col(col_name).cast(pl.Categorical)) @@ -1302,7 +1305,25 @@ def __sklearn_tags__(self): ) -def _array_api_for_tests(array_namespace, device): +def _array_api_for_tests(array_namespace, device_name=None, dtype_name=None): + """Return (xp, device) for array API testing. + + Parameters + ---------- + array_namespace : str + The importable name of the array namespace module. + device_name : str or None, default=None + The device name for array allocation. Can be None for default device. + + Returns + ------- + xp : module + The module object for the requested array namespace. + device : object, str or None + The library specific device object that can be passed to + xp.asarray(..., device=device). This might be a string and not + a library specific device object. + """ try: array_mod = importlib.import_module(array_namespace) except (ModuleNotFoundError, ImportError): @@ -1319,14 +1340,26 @@ def _array_api_for_tests(array_namespace, device): # corresponding (compatibility wrapped) array namespace based on it. # This is because `cupy` is not the same as the compatibility wrapped # namespace of a CuPy array. + device = None xp = get_namespace(array_mod.asarray(1)) if ( array_namespace == "torch" - and device == "cuda" + and device_name == "cuda" and not xp.backends.cuda.is_built() ): raise SkipTest("PyTorch test requires cuda, which is not available") - elif array_namespace == "torch" and device == "mps": + elif array_namespace == "dpnp": # pragma: nocover + dpctl = pytest.importorskip("dpctl") + if device_name is None: + available_devices = dpctl.get_devices() + if not available_devices: + raise SkipTest("Skipping dpnp test because no SYCL devices found") + else: + device = available_devices[0] + elif not dpctl.get_devices(device_type=device_name): + raise SkipTest(f"Skipping dpnp test because no {device_name} device found") + + elif array_namespace == "torch" and device_name == "mps": if os.getenv("PYTORCH_ENABLE_MPS_FALLBACK") != "1": # For now we need PYTORCH_ENABLE_MPS_FALLBACK=1 for all estimators to work # when using the MPS device. @@ -1339,7 +1372,7 @@ def _array_api_for_tests(array_namespace, device): "MPS is not available because the current PyTorch install was not " "built with MPS enabled." ) - elif array_namespace == "torch" and device == "xpu": # pragma: nocover + elif array_namespace == "torch" and device_name == "xpu": # pragma: nocover if not hasattr(xp, "xpu"): # skip xpu testing for PyTorch <2.4 raise SkipTest( @@ -1355,7 +1388,25 @@ def _array_api_for_tests(array_namespace, device): if cupy.cuda.runtime.getDeviceCount() == 0: raise SkipTest("CuPy test requires cuda, which is not available") - return xp + elif array_namespace == "array_api_strict": + # device_name can be a string ("CPU_DEVICE", "device1") or a Device object + # from yield_mixed_namespace_input_permutations + if device_name is not None: + device = xp.Device(device_name) + + # Right now only array_api_strict uses a library specific device + # object. For all other libraries we return a string or `None`. + # This works because strings are accepted as arguments to + # xp.asarray(..., device=) in those libraries. + device = device_name if device is None else device + + if ( + dtype_name == "float64" and _max_precision_float_dtype(xp, device) != xp.float64 + ): # pragma: nocover + skip_msg = f"{array_namespace} does not support float64 on device {device}" + raise SkipTest(skip_msg) + + return xp, device def _get_warnings_filters_info_list(): @@ -1423,6 +1474,16 @@ def to_filterwarning_str(self): WarningInfo( "ignore", message="Attribute n is deprecated", category=DeprecationWarning ), + # numpy 2.5 DeprecationWarning in joblib, see + # https://github.com/joblib/joblib/issues/1772 + WarningInfo( + "ignore", + message=( + "Setting the shape on a NumPy array has been deprecated" + r" in NumPy 2.5" + ), + category=DeprecationWarning, + ), # Python 3.12 warnings from sphinx-gallery fixed in master but not # released yet, see # https://github.com/sphinx-gallery/sphinx-gallery/pull/1242 @@ -1432,6 +1493,14 @@ def to_filterwarning_str(self): WarningInfo( "ignore", message="Attribute s is deprecated", category=DeprecationWarning ), + # sphinx-gallery uses codecs.open(); deprecated in Python 3.14. Remove once + # a sphinx-gallery release includes + # https://github.com/sphinx-gallery/sphinx-gallery/pull/1594 + WarningInfo( + "ignore", + message=r"codecs\.open\(\) is deprecated", + category=DeprecationWarning, + ), # Plotly deprecated something which we're not using, but internally it's used # and needs to be fixed on their side. # https://github.com/plotly/plotly.py/issues/4997 @@ -1440,7 +1509,7 @@ def to_filterwarning_str(self): message=".+scattermapbox.+deprecated.+scattermap.+instead", category=DeprecationWarning, ), - # TODO(1.10): remove PassiveAgressive + # TODO(1.10): remove PassiveAggressive WarningInfo( "ignore", message="Class PassiveAggressive.+is deprecated", diff --git a/sklearn/utils/class_weight.py b/sklearn/utils/class_weight.py index 6f9c7f185043b..76e06e92e9820 100644 --- a/sklearn/utils/class_weight.py +++ b/sklearn/utils/class_weight.py @@ -6,6 +6,14 @@ import numpy as np from scipy import sparse +from sklearn.utils._array_api import ( + _bincount, + _is_numpy_namespace, + _isin, + get_namespace_and_device, + move_to, + size, +) from sklearn.utils._param_validation import StrOptions, validate_params from sklearn.utils.validation import _check_sample_weight @@ -64,35 +72,46 @@ def compute_class_weight(class_weight, *, classes, y, sample_weight=None): # Import error caused by circular imports. from sklearn.preprocessing import LabelEncoder - if set(y) - set(classes): + xp, _, device_ = get_namespace_and_device(y, classes) + unique_y = xp.unique_values(y) + if set(move_to(unique_y, xp=np, device="cpu")) - set( + move_to(classes, xp=np, device="cpu") + ): raise ValueError("classes should include all valid labels that can be in y") if class_weight is None or len(class_weight) == 0: # uniform class weights - weight = np.ones(classes.shape[0], dtype=np.float64, order="C") + weight = xp.ones(classes.shape[0], device=device_) elif class_weight == "balanced": # Find the weight of each class as present in y. le = LabelEncoder() y_ind = le.fit_transform(y) - if not all(np.isin(classes, le.classes_)): + if not all(_isin(classes, xp.astype(le.classes_, classes.dtype), xp=xp)): raise ValueError("classes should have valid labels that are in y") + if _is_numpy_namespace(xp) and sample_weight is not None: + sample_weight = move_to(sample_weight, xp=np, device="cpu") + sample_weight = _check_sample_weight(sample_weight, y) - weighted_class_counts = np.bincount(y_ind, weights=sample_weight) - recip_freq = weighted_class_counts.sum() / ( - len(le.classes_) * weighted_class_counts + weighted_class_counts = _bincount(y_ind, weights=sample_weight, xp=xp) + recip_freq = xp.sum(weighted_class_counts) / ( + size(le.classes_) * weighted_class_counts ) weight = recip_freq[le.transform(classes)] else: # user-defined dictionary - weight = np.ones(classes.shape[0], dtype=np.float64, order="C") + weight = xp.ones(size(classes), device=device_) unweighted_classes = [] for i, c in enumerate(classes): + try: + c = int(c) + except ValueError: # `classes` contains strings + c = str(c) if c in class_weight: weight[i] = class_weight[c] else: unweighted_classes.append(c) - n_weighted_classes = len(classes) - len(unweighted_classes) + n_weighted_classes = size(classes) - len(unweighted_classes) if unweighted_classes and n_weighted_classes != len(class_weight): unweighted_classes_user_friendly_str = np.array(unweighted_classes).tolist() raise ValueError( diff --git a/sklearn/utils/deprecation.py b/sklearn/utils/deprecation.py index b727ac172fbdf..26f3c3034a60b 100644 --- a/sklearn/utils/deprecation.py +++ b/sklearn/utils/deprecation.py @@ -77,7 +77,6 @@ def wrapped(cls, *args, **kwargs): cls.__new__ = wrapped wrapped.__name__ = "__new__" - wrapped.deprecated_original = new # Restore the original signature, see PEP 362. cls.__signature__ = sig diff --git a/sklearn/utils/estimator_checks.py b/sklearn/utils/estimator_checks.py index 84edd1ae838c5..07d70688b40ea 100644 --- a/sklearn/utils/estimator_checks.py +++ b/sklearn/utils/estimator_checks.py @@ -61,9 +61,12 @@ from sklearn.preprocessing import StandardScaler, scale from sklearn.utils import _safe_indexing, shuffle from sklearn.utils._array_api import ( + NamespaceAndDevice, _atol_for_type, - _convert_to_numpy, + _max_precision_float_dtype, get_namespace, + move_to, + yield_mixed_namespace_input_permutations, yield_namespace_device_dtype_combinations, ) from sklearn.utils._array_api import device as array_device @@ -130,7 +133,11 @@ def _yield_api_checks(estimator): ) tags = get_tags(estimator) - yield check_estimator_cloneable + # This is commented out since it's the first check both + # `parametrize_with_checks` and `check_estimator` do + # anyway. But leaving it here as commented out to know + # it's a part of the basic API. + # yield check_estimator_cloneable yield check_estimator_tags_renamed yield check_valid_tag_types yield check_estimator_repr @@ -157,6 +164,7 @@ def _yield_checks(estimator): yield check_sample_weights_pandas_series yield check_sample_weights_not_an_array yield check_sample_weights_list + yield check_all_zero_sample_weights_error if not tags.input_tags.pairwise: # We skip pairwise because the data is not pairwise yield check_sample_weights_shape @@ -196,9 +204,11 @@ def _yield_checks(estimator): yield check_estimators_pickle yield partial(check_estimators_pickle, readonly_memmap=True) - if tags.array_api_support: - for check in _yield_array_api_checks(estimator): - yield check + for check in _yield_array_api_checks( + estimator, + only_numpy=not tags.array_api_support, + ): + yield check yield check_f_contiguous_array_estimator @@ -237,9 +247,6 @@ def _yield_classifier_checks(classifier): # test if predict_proba is a monotonic transformation of decision_function yield check_decision_proba_consistency - if isinstance(classifier, LinearClassifierMixin): - if "class_weight" in classifier.get_params().keys(): - yield check_class_weight_balanced_linear_classifier if ( isinstance(classifier, LinearClassifierMixin) and "class_weight" in classifier.get_params().keys() @@ -336,17 +343,53 @@ def _yield_outliers_checks(estimator): yield check_non_transformer_estimators_n_iter -def _yield_array_api_checks(estimator): - for ( - array_namespace, - device, - dtype_name, - ) in yield_namespace_device_dtype_combinations(): +def _yield_array_api_checks(estimator, only_numpy=False): + # Note all tests run with array API dispatch enabled + if only_numpy: + # For estimators without explicit array API support; check that enabling + # array API dispatch and using NumPy inputs does not change results. + # Output checks are looser (expect_only_array_outputs=False). yield partial( check_array_api_input, - array_namespace=array_namespace, - dtype_name=dtype_name, - device=device, + array_namespace="numpy", + expect_only_array_outputs=False, + ) + else: + # 1. All inputs from the same namespace and device. + # Extended output checks should pass for all + # estimators that declare array API support in their tags. + for ( + array_namespace, + device_name, + dtype_name, + ) in yield_namespace_device_dtype_combinations(): + yield partial( + check_array_api_input, + array_namespace=array_namespace, + device_name=device_name, + dtype_name=dtype_name, + ) + # 2. Mixed namespace/device inputs: X uses one namespace/device + # y and sample_weight use another. + # We intend for all estimators that support array API to also support + # mixed namespace/device inputs. Some are in the process of adding mixed + # input support and are listed in PER_ESTIMATOR_XFAIL_CHECKS. + for ( + other_ns_and_device, + X_ns_and_device, + _, + ) in yield_mixed_namespace_input_permutations(): + yield partial( + check_array_api_mixed_inputs, + other_ns_and_device=other_ns_and_device, + X_ns_and_device=X_ns_and_device, + ) + # 3. Namespace/device consistency between fit and predict/transform + # Only test with one namespace to keep costs down + # There should be no dependency on the exact namespace used. + yield partial( + check_array_api_same_namespace, + array_namespace="array_api_strict", ) @@ -1040,71 +1083,79 @@ def check_supervised_y_no_nan(name, estimator_orig): estimator.fit(X, y) -def check_array_api_input( - name, +def _check_array_api_core( estimator_orig, - array_namespace, - device=None, - dtype_name="float64", + X_ns_and_device, + other_ns_and_device, + dtype_name=None, check_values=False, check_sample_weight=False, + expect_only_array_outputs=True, ): - """Check that the estimator can work consistently with the Array API - - By default, this just checks that the types and shapes of the arrays are - consistent with calling the same estimator with numpy arrays. - - When check_values is True, it also checks that calling the estimator on the - array_api Array gives the same results as ndarrays. + """Helper to check estimator attributes and method outputs.""" + xp_X, device_X = _array_api_for_tests( + X_ns_and_device.xp, X_ns_and_device.device, dtype_name + ) + xp_other, device_other = _array_api_for_tests( + other_ns_and_device.xp, other_ns_and_device.device + ) - When sample_weight is True, dummy sample weights are passed to the fit call. - """ - xp = _array_api_for_tests(array_namespace, device) + X, y = make_classification(n_samples=30, n_features=10, random_state=42) + if dtype_name is None: + max_float_dtype = _max_precision_float_dtype(xp_X, device_X) + # Convert to string, so it is accepted by NumPy (`X` is NumPy array) + dtype_name = "float32" if max_float_dtype == xp_X.float32 else "float64" - X, y = make_classification(random_state=42) X = X.astype(dtype_name, copy=False) X = _enforce_estimator_tags_X(estimator_orig, X) y = _enforce_estimator_tags_y(estimator_orig, y) est = clone(estimator_orig) + set_random_state(est) + + X_xp = xp_X.asarray(X, device=device_X) + y_xp = xp_other.asarray(y, device=device_other) - X_xp = xp.asarray(X, device=device) - y_xp = xp.asarray(y, device=device) fit_kwargs = {} fit_kwargs_xp = {} - if check_sample_weight: - fit_kwargs["sample_weight"] = np.ones(X.shape[0], dtype=X.dtype) - fit_kwargs_xp["sample_weight"] = xp.asarray( - fit_kwargs["sample_weight"], device=device + if check_sample_weight and has_fit_parameter(estimator_orig, "sample_weight"): + max_dtype_other = _max_precision_float_dtype(xp_other, device_other) + dtype_other = "float32" if max_dtype_other == xp_other.float32 else "float64" + fit_kwargs["sample_weight"] = np.ones(X.shape[0], dtype=dtype_other) + fit_kwargs_xp["sample_weight"] = xp_other.asarray( + fit_kwargs["sample_weight"], device=device_other ) est.fit(X, y, **fit_kwargs) - array_attributes = { - key: value for key, value in vars(est).items() if isinstance(value, np.ndarray) - } - est_xp = clone(est) with config_context(array_api_dispatch=True): est_xp.fit(X_xp, y_xp, **fit_kwargs_xp) - input_ns = get_namespace(X_xp)[0].__name__ - # Fitted attributes which are arrays must have the same - # namespace as the one of the training data. + X_ns = xp_X.__name__ + + array_attributes = { + key: value for key, value in vars(est).items() if isinstance(value, np.ndarray) + } + + # Fitted attributes which are arrays must have the same namespace as `X`, + # except `classes_`, to allow it to be string when `y` is string. for key, attribute in array_attributes.items(): est_xp_param = getattr(est_xp, key) with config_context(array_api_dispatch=True): attribute_ns = get_namespace(est_xp_param)[0].__name__ - assert attribute_ns == input_ns, ( - f"'{key}' attribute is in wrong namespace, expected {input_ns} " - f"got {attribute_ns}" - ) + if key != "classes_": + assert attribute_ns == X_ns, ( + f"'{key}' attribute is in wrong namespace, expected {X_ns} " + f"got {attribute_ns}" + ) with config_context(array_api_dispatch=True): - assert array_device(est_xp_param) == array_device(X_xp) + if key != "classes_": + assert array_device(est_xp_param) == array_device(X_xp) - est_xp_param_np = _convert_to_numpy(est_xp_param, xp=xp) + est_xp_param_np = move_to(est_xp_param, xp=np, device="cpu") if check_values: assert_allclose( attribute, @@ -1114,11 +1165,15 @@ def check_array_api_input( ) else: assert attribute.shape == est_xp_param_np.shape - if device == "mps" and np.issubdtype(est_xp_param_np.dtype, np.floating): - # for mps devices the maximum supported floating dtype is float32 - assert est_xp_param_np.dtype == np.float32 - else: - assert est_xp_param_np.dtype == attribute.dtype + expected_dtype = attribute.dtype + if np.issubdtype(attribute.dtype, np.floating): + max_float_dtype = _max_precision_float_dtype( + xp_X, device=X_ns_and_device.device + ) + # for some devices the maximum supported floating dtype is float32 + if max_float_dtype == xp_X.float32: + expected_dtype = np.float32 + assert est_xp_param_np.dtype == expected_dtype # Check estimator methods, if supported, give the same results methods = ( @@ -1141,7 +1196,7 @@ def check_array_api_input( # all the array API libraries (PyTorch, jax, CuPy) accept indexing with a # numpy array. This is probably not worth doing anything about for # now since array-api-strict seems a bit too strict ... - numpy_asarray_works = xp.__name__ != "array_api_strict" + numpy_asarray_works = xp_X.__name__ != "array_api_strict" except (TypeError, RuntimeError, ValueError): # PyTorch with CUDA device and CuPy raise TypeError consistently. @@ -1188,59 +1243,124 @@ def check_array_api_input( with config_context(array_api_dispatch=True): result_ns = get_namespace(result_xp)[0].__name__ - assert result_ns == input_ns, ( - f"'{method}' output is in wrong namespace, expected {input_ns}, " + assert result_ns == X_ns, ( + f"'{method}' output is in wrong namespace, expected {X_ns}, " f"got {result_ns}." ) - with config_context(array_api_dispatch=True): - assert array_device(result_xp) == array_device(X_xp) - - result_xp_np = _convert_to_numpy(result_xp, xp=xp) + if expect_only_array_outputs: + with config_context(array_api_dispatch=True): + assert array_device(result_xp) == array_device(X_xp) - if check_values: - assert_allclose( - result, - result_xp_np, - err_msg=f"{method} did not the return the same result", - atol=_atol_for_type(X.dtype), - ) - else: - if hasattr(result, "shape"): + result_xp_np = move_to(result_xp, xp=np, device="cpu") + if check_values: + assert_allclose( + result, + result_xp_np, + err_msg=f"{method} did not the return the same result", + atol=_atol_for_type(X.dtype), + ) + elif hasattr(result, "shape"): assert result.shape == result_xp_np.shape assert result.dtype == result_xp_np.dtype if method_name == "transform" and hasattr(est, "inverse_transform"): inverse_result = est.inverse_transform(result) with config_context(array_api_dispatch=True): - invese_result_xp = est_xp.inverse_transform(result_xp) - inverse_result_ns = get_namespace(invese_result_xp)[0].__name__ - assert inverse_result_ns == input_ns, ( - "'inverse_transform' output is in wrong namespace, expected" - f" {input_ns}, got {inverse_result_ns}." - ) + inverse_result_xp = est_xp.inverse_transform(result_xp) + + if expect_only_array_outputs: + with config_context(array_api_dispatch=True): + inverse_result_ns = get_namespace(inverse_result_xp)[0].__name__ + assert inverse_result_ns == X_ns, ( + "'inverse_transform' output is in wrong namespace, expected" + f" {X_ns}, got {inverse_result_ns}." + ) + with config_context(array_api_dispatch=True): + assert array_device(result_xp) == array_device(X_xp) + + inverse_result_xp_np = move_to(inverse_result_xp, xp=np, device="cpu") + if check_values: + assert_allclose( + inverse_result, + inverse_result_xp_np, + err_msg="inverse_transform did not the return the same result", + atol=_atol_for_type(X.dtype), + ) + elif hasattr(result, "shape"): + assert inverse_result.shape == inverse_result_xp_np.shape + assert inverse_result.dtype == inverse_result_xp_np.dtype - with config_context(array_api_dispatch=True): - assert array_device(invese_result_xp) == array_device(X_xp) - invese_result_xp_np = _convert_to_numpy(invese_result_xp, xp=xp) - if check_values: - assert_allclose( - inverse_result, - invese_result_xp_np, - err_msg="inverse_transform did not the return the same result", - atol=_atol_for_type(X.dtype), - ) - else: - assert inverse_result.shape == invese_result_xp_np.shape - assert inverse_result.dtype == invese_result_xp_np.dtype +def check_array_api_input( + name, + estimator_orig, + array_namespace, + device_name=None, + dtype_name=None, + check_values=False, + check_sample_weight=False, + expect_only_array_outputs=True, +): + """Check that the estimator can work consistently with the Array API. + + All inputs are of the same namespace/device. See `check_array_api_mixed_inputs` + for testing of inputs from different namespaces/devices. + + By default, this just checks that the types and shapes of the arrays are + consistent with calling the same estimator with numpy arrays. + + Parameters + ---------- + name : str + The name of the estimator. Used in error messages but ignored here. + + estimator_orig : estimator + Original (uncloned) estimator instance. + + array_namespace : str + The name of the Array API namespace of all estimator inputs. + + device_name : str, default=None + The name of the device on which to allocate the estimator input arrays. + + dtype_name : str, default=None + The name of the data type to use for arrays. If `None`, + `_max_precision_float_dtype` of namespace and device of + `X_ns_and_device` used. + + check_values : bool, default=False + Whether to check the values of attributes, method outputs (including + `inverse_transform`) obtained with array API inputs match that of all-NumPy + inputs. If `False` only the namespace, device, shape and dtype of attributes + and method outputs are checked. + + check_sample_weight : bool, default=False + Whether to pass dummy weights to the fit call. + + expect_only_array_outputs : bool, default=True + Whether to expect non-array outputs such as sparse data structures and lists. + If `False` the checks are looser; device, shape and dtype checks for method + outputs are skipped and only a smoke test is performed for `inverse_transform`. + """ + X_ns_and_device = NamespaceAndDevice(array_namespace, device_name) + _check_array_api_core( + estimator_orig, + X_ns_and_device=X_ns_and_device, + # Make all array inputs of the same namespace/device + other_ns_and_device=X_ns_and_device, + dtype_name=dtype_name, + check_values=check_values, + check_sample_weight=check_sample_weight, + expect_only_array_outputs=expect_only_array_outputs, + ) def check_array_api_input_and_values( name, estimator_orig, array_namespace, - device=None, + device_name=None, dtype_name="float64", check_sample_weight=False, ): @@ -1248,13 +1368,134 @@ def check_array_api_input_and_values( name, estimator_orig, array_namespace=array_namespace, - device=device, + device_name=device_name, dtype_name=dtype_name, check_values=True, check_sample_weight=check_sample_weight, ) +def check_array_api_mixed_inputs( + name, + estimator_orig, + X_ns_and_device, + other_ns_and_device, + check_values=False, + check_sample_weight=True, + expect_only_array_outputs=True, +): + """Check `estimator_orig` works with mixed namespace/device array API inputs. + + For this check the input `X` uses one namespace/device, and the other inputs + (`y` and `sample_weight`) use another namespace/device. The tested namespace + combinations are generated by `yield_mixed_namespace_input_permutations`. + The goal of the check is to make sure estimators move `y` and `sample_weight` + to the namespace/device of `X` when needed. + + See `check_array_api_input` for testing of inputs from the same + namespaces/devices. + + Note that the default of `check_sample_weight` is `True`, unlike + `check_array_api_input`. + + Parameters + ---------- + name : str + The name of the estimator. Used in error messages but ignored here. + + estimator_orig : estimator + Original (uncloned) estimator instance. + + X_ns_and_device : NamedTuple + Namespace and device of reference array API input: `X` as "everything + follows X". + + other_ns_and_device : NamedTuple + Namespace and device of other array API inputs. Used for `y` and + `sample_weight`. + + check_values : bool, default=False + Whether to check the values of attributes, method outputs (including + `inverse_transform`) obtained with array API inputs match that of all-NumPy + inputs. If `False` only the namespace, device, shape and dtype of attributes + and method outputs are checked. + + check_sample_weight : bool, default=True + Whether to pass dummy weights to the fit call. + + expect_only_array_outputs : bool, default=True + Whether to expect non-array outputs such as sparse data structures and lists. + If `False` the checks are looser; device, shape and dtype checks for method + outputs are skipped and only a smoke test is performed for `inverse_transform`. + """ + _check_array_api_core( + estimator_orig, + X_ns_and_device=X_ns_and_device, + other_ns_and_device=other_ns_and_device, + dtype_name=None, + check_values=check_values, + check_sample_weight=check_sample_weight, + expect_only_array_outputs=expect_only_array_outputs, + ) + + +def check_array_api_same_namespace( + name, estimator_orig, array_namespace, device_name=None +): + """Check that estimator raises when predict/transform namespace differs from fit. + + Array API compatible estimators should call ``check_same_namespace`` in + their ``predict``, ``transform``, and similar methods to verify that the + input arrays are from the same namespace and device as the fitted + attributes. + """ + xp, device = _array_api_for_tests(array_namespace, device_name, "float64") + + X, y = make_classification(n_samples=30, n_features=10, random_state=42) + X = X.astype("float64", copy=False) + + X = _enforce_estimator_tags_X(estimator_orig, X) + y = _enforce_estimator_tags_y(estimator_orig, y) + + est = clone(estimator_orig) + set_random_state(est) + + X_xp = xp.asarray(X, device=device) + y_xp = xp.asarray(y, device=device) + + with config_context(array_api_dispatch=True): + est.fit(X_xp, y_xp) + + methods = ( + "decision_function", + "predict", + "predict_log_proba", + "predict_proba", + "transform", + ) + + for method_name in methods: + method = getattr(est, method_name, None) + if method is None: + continue + + with config_context(array_api_dispatch=True): + try: + method(X) + except ValueError as e: + if "must use the same namespace" in str( + e + ) and f"{name}.{method_name}()" in str(e): + continue + raise + raise AssertionError( + f"{name}.{method_name}() did not raise when called with a " + f"different array namespace than the one used during fit. " + f"Add a call to check_same_namespace() at the start of " + f"{method_name} to fix this." + ) + + def check_estimator_sparse_tag(name, estimator_orig): """Check that estimator tag related with accepting sparse data is properly set.""" estimator = clone(estimator_orig) @@ -1483,6 +1724,28 @@ def check_sample_weights_list(name, estimator_orig): estimator.fit(X, y, sample_weight=sample_weight) +@ignore_warnings(category=FutureWarning) +def check_all_zero_sample_weights_error(name, estimator_orig): + """Check that estimator raises error when all sample weights are 0.""" + estimator = clone(estimator_orig) + + X, y = make_classification(random_state=42) + X = _enforce_estimator_tags_X(estimator, X) + y = _enforce_estimator_tags_y(estimator, y) + + sample_weight = np.zeros(_num_samples(X)) + + # The following estimators have custom error messages: + # - NuSVC: Invalid input - all samples have zero or negative weights. + # - Perceptron: The sample weights for validation set are all zero, consider using + # a different random state. + # - SGDClassifier: The sample weights for validation set are all zero, consider + # using a different random state. + # All other estimators: Sample weights must contain at least one non-zero number. + with raises(ValueError, match=r"(.*weight.*zero.*)|(.*zero.*weight.*)"): + estimator.fit(X, y, sample_weight=sample_weight) + + @ignore_warnings(category=FutureWarning) def check_sample_weights_shape(name, estimator_orig): # check that estimators raise an error if sample_weight @@ -1730,9 +1993,6 @@ def _is_public_parameter(attr): @ignore_warnings(category=FutureWarning) def check_dont_overwrite_parameters(name, estimator_orig): # check that fit method only changes or sets private attributes - if hasattr(estimator_orig.__init__, "deprecated_original"): - # to not check deprecated classes - return estimator = clone(estimator_orig) rnd = np.random.RandomState(0) X = 3 * rnd.uniform(size=(20, 3)) @@ -2308,7 +2568,7 @@ def check_transformer_preserve_dtypes(name, transformer_orig): for Xt, method in zip([X_trans1, X_trans2], ["fit_transform", "transform"]): if isinstance(Xt, tuple): - # cross-decompostion returns a tuple of (x_scores, y_scores) + # cross-decomposition returns a tuple of (x_scores, y_scores) # when given y with fit_transform; only check the first element Xt = Xt[0] @@ -3661,9 +3921,6 @@ def check_no_attributes_set_in_init(name, estimator_orig): f"Estimator {name} should store all parameters as an attribute during init." ) - if hasattr(type(estimator).__init__, "deprecated_original"): - return - init_params = _get_args(type(estimator).__init__) parents_init_params = [ param @@ -3832,8 +4089,7 @@ def check_parameters_default_constructible(name, estimator_orig): # We get the default parameters from init and then # compare these against the actual values of the attributes. - # this comes from getattr. Gets rid of deprecation decorator. - init = getattr(estimator.__init__, "deprecated_original", estimator.__init__) + init = estimator.__init__ try: @@ -4090,7 +4346,7 @@ def check_transformer_n_iter(name, estimator_orig): set_random_state(estimator, 0) estimator.fit(X, y_) - # These return a n_iter per component. + # These return an n_iter per component. if name in CROSS_DECOMPOSITION: for iter_ in estimator.n_iter_: assert iter_ >= 1 diff --git a/sklearn/utils/extmath.py b/sklearn/utils/extmath.py index 34fe2ba09006c..61520fd13d23e 100644 --- a/sklearn/utils/extmath.py +++ b/sklearn/utils/extmath.py @@ -152,7 +152,7 @@ def density(w): -------- >>> from scipy import sparse >>> from sklearn.utils.extmath import density - >>> X = sparse.random(10, 10, density=0.25, random_state=0) + >>> X = sparse.random_array((10, 10), density=0.25, rng=0) >>> density(X) 0.25 """ @@ -169,7 +169,9 @@ def safe_sparse_dot(a, b, *, dense_output=False): Parameters ---------- a : {ndarray, sparse matrix} + First operand of the dot product. b : {ndarray, sparse matrix} + Second operand of the dot product. dense_output : bool, default=False When False, ``a`` and ``b`` both being sparse will yield sparse output. When True, output will always be a dense array. @@ -181,9 +183,9 @@ def safe_sparse_dot(a, b, *, dense_output=False): Examples -------- - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> from sklearn.utils.extmath import safe_sparse_dot - >>> X = csr_matrix([[1, 2], [3, 4], [5, 6]]) + >>> X = csr_array([[1, 2], [3, 4], [5, 6]]) >>> dot_product = safe_sparse_dot(X, X.T) >>> dot_product.toarray() array([[ 5, 11, 17], @@ -216,10 +218,10 @@ def safe_sparse_dot(a, b, *, dense_output=False): dense_output and a.ndim == 2 and b.ndim == 2 - and a.dtype in (np.float32, np.float64) - and b.dtype in (np.float32, np.float64) and (sparse.issparse(a) and a.format in ("csc", "csr")) and (sparse.issparse(b) and b.format in ("csc", "csr")) + and a.dtype in (np.float32, np.float64) + and b.dtype in (np.float32, np.float64) ): # Use dedicated fast method for dense_C = sparse_A @ย sparse_B return sparse_matmul_to_dense(a, b) @@ -329,7 +331,7 @@ def _randomized_range_finder( # Note: we cannot combine the astype and to_device operations in one go # using xp.asarray(..., dtype=dtype, device=device) because downcasting # from float64 to float32 in asarray might not always be accepted as only - # casts following type promotion rules are guarateed to work. + # casts following type promotion rules are guaranteed to work. # https://github.com/data-apis/array-api/issues/647 if is_array_api_compliant: Q = xp.asarray(Q, device=device(A)) @@ -571,7 +573,7 @@ def _randomized_svd( if sparse.issparse(M) and M.format in ("lil", "dok"): warnings.warn( "Calculating SVD of a {} is expensive. " - "csr_matrix is more efficient.".format(type(M).__name__), + "CSR format is more efficient.".format(type(M).__name__), sparse.SparseEfficiencyWarning, ) diff --git a/sklearn/utils/fixes.py b/sklearn/utils/fixes.py index eebc640968a3b..5a57835eafc0d 100644 --- a/sklearn/utils/fixes.py +++ b/sklearn/utils/fixes.py @@ -1,4 +1,4 @@ -"""Compatibility fixes for older version of python, numpy and scipy +"""Compatibility fixes for older version of the dependencies If you add content to this file, please give the version of the package at which the fix is no longer needed. @@ -65,7 +65,17 @@ def _mode(a, axis=0): return mode -# TODO: Remove when SciPy 1.12 is the minimum supported version +# TODO: Remove when Scipy 1.12 is the minimum supported version +# Use git grep to see where this is used and update them too. +SCIPY_VERSION_BELOW_1_12 = sp_base_version < parse_version("1.12.0") + + +# TODO: Remove when Scipy 1.15 is the minimum supported version +# Use git grep to see where this is used and update them too. +SCIPY_VERSION_BELOW_1_15 = sp_base_version < parse_version("1.15.0") + + +# TODO: Remove when Scipy 1.12 is the minimum supported version if sp_base_version >= parse_version("1.12.0"): _sparse_linalg_cg = scipy.sparse.linalg.cg else: @@ -131,13 +141,13 @@ def _min_or_max_axis(X, axis, min_or_max): value = np.compress(mask, value) if axis == 0: - res = scipy.sparse.coo_matrix( + res = scipy.sparse.coo_array( (value, (np.zeros(len(value)), major_index)), dtype=X.dtype, shape=(1, M), ) else: - res = scipy.sparse.coo_matrix( + res = scipy.sparse.coo_array( (value, (major_index, np.zeros(len(value)))), dtype=X.dtype, shape=(M, 1), @@ -383,13 +393,155 @@ def _get_additional_lbfgs_options_dict(key, value): return {} if sp_version >= parse_version("1.15") else {key: value} -# TODO(pyarrow): Remove when minimum pyarrow version is 17.0.0 -PYARROW_VERSION_BELOW_17 = False -try: - import pyarrow +# TODO: Replace when Scipy 1.12 is the minimum supported version +# fixes for transitioning scipy.sparse function names +if not SCIPY_VERSION_BELOW_1_12: + _sparse_eye_array = scipy.sparse.eye_array + _sparse_diags_array = scipy.sparse.diags_array + + def _sparse_random_array( + shape, + *, + density=0.01, + format="coo", + dtype=None, + random_state=None, + rng=None, + data_sampler=None, + ): + X = scipy.sparse.random_array( + shape, + density=density, + format=format, + dtype=dtype, + random_state=rng or random_state, + data_sampler=data_sampler, + ) + _ensure_sparse_index_int32(X) + return X + +else: + + def _sparse_eye_array(m, n=None, *, k=0, dtype=float, format=None): + A = scipy.sparse.eye(m, n, k=k, dtype=dtype) + return scipy.sparse.dia_array(A).asformat(format) - pyarrow_version = parse_version(pyarrow.__version__) - if pyarrow_version < parse_version("17.0.0"): - PYARROW_VERSION_BELOW_17 = True -except ModuleNotFoundError: # pragma: no cover - pass + def _sparse_diags_array( + diagonals, /, *, offsets=0, shape=None, format=None, dtype=None + ): + A = scipy.sparse.diags(diagonals, offsets=offsets, shape=shape, dtype=dtype) + return scipy.sparse.dia_array(A).asformat(format) + + def _sparse_random_array( + shape, + *, + density=0.01, + format="coo", + dtype=None, + random_state=None, + rng=None, + data_sampler=None, + ): + A = scipy.sparse.random( + *shape, + density=density, + dtype=dtype, + random_state=rng or random_state, + data_rvs=data_sampler, + ) + return scipy.sparse.coo_array(A).asformat(format) + + +# TODO: remove when SciPy 1.15 is minimal supported version +# fix for casting index arrays +def _ensure_sparse_index_int32(A): + """Safely ensure that index arrays are int32.""" + if A.format in ("csc", "csr", "bsr"): + A.indices, A.indptr = _safely_cast_index_arrays(A) + elif A.format == "coo": + if hasattr(A, "coords"): + A.coords = _safely_cast_index_arrays(A) + elif hasattr(A, "indices"): + A.indices = _safely_cast_index_arrays(A) + else: + A.row, A.col = _safely_cast_index_arrays(A) + elif A.format == "dia": + A.offsets = _safely_cast_index_arrays(A) + + +# TODO: remove when SciPy 1.15 is minimal supported version +# (based on scipy.sparse._sputils.py function with same name) +def _safely_cast_index_arrays(A, idx_dtype=np.int32, msg=""): + """Safely cast sparse array indices to `idx_dtype`. + + Check the shape of `A` to determine if it is safe to cast its index + arrays to dtype `idx_dtype`. If any dimension in shape is larger than + fits in the dtype, casting is unsafe so raise ``ValueError``. + If safe, cast the index arrays to `idx_dtype` and return the result + without changing the input `A`. The caller can assign results to `A` + attributes if desired or use the recast index arrays directly. + + Unless downcasting is needed, the original index arrays are returned. + You can test e.g. ``A.indptr is new_indptr`` to see if downcasting occurred. + + See SciPy: scipy.sparse._sputils.py for more info on safely_cast_index_arrays() + """ + max_value = np.iinfo(idx_dtype).max + + if A.format in ("csc", "csr"): + if A.indptr[-1] > max_value: + raise ValueError(f"indptr values too large for {msg}") + # check shape vs dtype + if max(*A.shape) > max_value: + if (A.indices > max_value).any(): + raise ValueError(f"indices values too large for {msg}") + + indices = A.indices.astype(idx_dtype, copy=False) + indptr = A.indptr.astype(idx_dtype, copy=False) + return indices, indptr + + elif A.format == "coo": + coords = getattr(A, "coords", None) + if coords is None: + coords = getattr(A, "indices", None) + if coords is None: + coords = (A.row, A.col) + if max(*A.shape) > max_value: + if any((co > max_value).any() for co in coords): + raise ValueError(f"coords values too large for {msg}") + return tuple(co.astype(idx_dtype, copy=False) for co in coords) + + elif A.format == "dia": + if max(*A.shape) > max_value: + if (A.offsets > max_value).any(): + raise ValueError(f"offsets values too large for {msg}") + offsets = A.offsets.astype(idx_dtype, copy=False) + return offsets + + elif A.format == "bsr": + R, C = A.blocksize + if A.indptr[-1] * R > max_value: + raise ValueError("indptr values too large for {msg}") + if max(*A.shape) > max_value: + if (A.indices * C > max_value).any(): + raise ValueError(f"indices values too large for {msg}") + indices = A.indices.astype(idx_dtype, copy=False) + indptr = A.indptr.astype(idx_dtype, copy=False) + return indices, indptr + # DOK and LIL formats are not associated with index arrays. + + +# TODO remove when matplotlib 3.10 is the minimal supported version +# and replace usage with `mpl.color_sequences['petroff10']` +PETROFF_COLORS = [ + "#3f90da", + "#ffa90e", + "#bd1f01", + "#94a4a2", + "#832db6", + "#a96b59", + "#e76300", + "#b9ac70", + "#717581", + "#92dadd", +] diff --git a/sklearn/utils/graph.py b/sklearn/utils/graph.py index b28c2883e9499..cd68b4a5ad6ba 100644 --- a/sklearn/utils/graph.py +++ b/sklearn/utils/graph.py @@ -59,7 +59,7 @@ def single_source_shortest_path_length(graph, source, *, cutoff=None): if sparse.issparse(graph): graph = graph.tolil() else: - graph = sparse.lil_matrix(graph) + graph = sparse.lil_array(graph) seen = {} # level (number of hops) when seen in BFS level = 0 # the current level next_level = [source] # dict of nodes to check at next level diff --git a/sklearn/utils/meson.build b/sklearn/utils/meson.build index ae490e987a4ff..71b98c088d4c1 100644 --- a/sklearn/utils/meson.build +++ b/sklearn/utils/meson.build @@ -1,3 +1,22 @@ +# Check if scipy's cython_blas exports blas_int (ILP64 support). +scipy_has_blas_int = cython.compiles( + 'from scipy.linalg.cython_blas cimport blas_int', + name: 'scipy cython_blas blas_int' +) + +_blas_int_conf = configuration_data() +if scipy_has_blas_int + _blas_int_conf.set('BLAS_INT_DEF', 'from scipy.linalg.cython_blas cimport blas_int') +else + _blas_int_conf.set('BLAS_INT_DEF', 'ctypedef int blas_int') +endif + +_blas_int_pxi = configure_file( + input: '_blas_int.pxi.in', + output: '_blas_int.pxi', + configuration: _blas_int_conf, +) + # utils is cimported from other subpackages so this is needed for the cimport # to work utils_cython_tree = [ @@ -5,6 +24,7 @@ utils_cython_tree = [ # early in the build sklearn_root_cython_tree, fs.copyfile('__init__.py'), + fs.copyfile('_bitset.pxd'), fs.copyfile('_cython_blas.pxd'), fs.copyfile('_heap.pxd'), fs.copyfile('_openmp_helpers.pxd'), @@ -17,8 +37,9 @@ utils_cython_tree = [ utils_extension_metadata = { 'sparsefuncs_fast': {'sources': [cython_gen.process('sparsefuncs_fast.pyx')]}, - '_cython_blas': {'sources': [cython_gen.process('_cython_blas.pyx')]}, + '_cython_blas': {'sources': [cython_gen.process('_cython_blas.pyx'), _blas_int_pxi]}, 'arrayfuncs': {'sources': [cython_gen.process('arrayfuncs.pyx')]}, + '_bitset': {'sources': [cython_gen.process('_bitset.pyx')]}, 'murmurhash': { 'sources': [cython_gen.process('murmurhash.pyx'), 'src' / 'MurmurHash3.cpp'], }, @@ -61,7 +82,7 @@ foreach name: util_extension_names output: name + '.pyx', input: name + '.pyx.tp', command: [tempita, '@INPUT@', '-o', '@OUTDIR@'], - # TODO in principle this should go in py.exension_module below. This is + # TODO in principle this should go in py.extension_module below. This is # temporary work-around for dependency issue with .pyx.tp files. For more # details, see https://github.com/mesonbuild/meson/issues/13212 depends: [pxd, utils_cython_tree], diff --git a/sklearn/utils/metaestimators.py b/sklearn/utils/metaestimators.py index 1674972772b67..38b4a065f9029 100644 --- a/sklearn/utils/metaestimators.py +++ b/sklearn/utils/metaestimators.py @@ -100,6 +100,14 @@ def _validate_names(self, names): "Estimator names must not contain __: got {0!r}".format(invalid_names) ) + def _check_estimators_are_instances(self, estimators): + for estimator in estimators: + if isinstance(estimator, type): + raise TypeError( + f"Expected an estimator instance ({estimator.__name__}()), got " + f"estimator class instead ({estimator.__name__})." + ) + def _safe_split(estimator, X, y, indices, train_indices=None): """Create subset of dataset and properly handle kernels. diff --git a/sklearn/utils/multiclass.py b/sklearn/utils/multiclass.py index 0a5b173d3c9f2..966d8875852b7 100644 --- a/sklearn/utils/multiclass.py +++ b/sklearn/utils/multiclass.py @@ -10,7 +10,7 @@ import numpy as np from scipy.sparse import issparse -from sklearn.utils._array_api import get_namespace +from sklearn.utils._array_api import _is_numpy_namespace, get_namespace from sklearn.utils._unique import attach_unique, cached_unique from sklearn.utils.fixes import VisibleDeprecationWarning from sklearn.utils.validation import _assert_all_finite, _num_samples, check_array @@ -38,13 +38,13 @@ def _unique_indicator(y, xp=None): } -def unique_labels(*ys): +def unique_labels(*ys, ys_types=None): """Extract an ordered array of unique labels. We don't allow: - mix of multilabel and multiclass (single label) targets - - mix of label indicator matrix and anything else, - because there are no explicit labels) + - mix of label indicator matrix and anything else + (because there are no explicit labels) - mix of label indicator matrices of different sizes - mix of string and integer labels @@ -55,6 +55,10 @@ def unique_labels(*ys): *ys : array-likes Label values. + ys_types : set, default=None + Set of target types of `ys` (as determined by `type_of_target`), + with `{"binary", "multiclass"}` being amended to `{"multiclass"}`. + Returns ------- out : ndarray of shape (n_unique_labels,) @@ -74,15 +78,17 @@ def unique_labels(*ys): xp, is_array_api_compliant = get_namespace(*ys) if len(ys) == 0: raise ValueError("No argument has been passed.") - # Check that we don't mix label format - ys_types = set(type_of_target(x) for x in ys) - if ys_types == {"binary", "multiclass"}: - ys_types = {"multiclass"} + if ys_types is None: + ys_types = set(type_of_target(x) for x in ys) + if ys_types == {"binary", "multiclass"}: + ys_types = {"multiclass"} + # Check that we don't mix label format if len(ys_types) > 1: raise ValueError("Mix type of y not allowed, got types %s" % ys_types) + # We can't have more than one value in y_type => The set is no more needed label_type = ys_types.pop() # Check consistency for the indicator format @@ -104,8 +110,8 @@ def unique_labels(*ys): if not _unique_labels: raise ValueError("Unknown label type: %s" % repr(ys)) - if is_array_api_compliant: - # array_api does not allow for mixed dtypes + if is_array_api_compliant and not _is_numpy_namespace(xp): + # non-NumPy array API inputs do not allow for mixed dtypes unique_ys = xp.concat([_unique_labels(y, xp=xp) for y in ys]) return xp.unique_values(unique_ys) @@ -114,7 +120,10 @@ def unique_labels(*ys): ) # Check that we don't mix string type with number type if len(set(isinstance(label, str) for label in ys_labels)) > 1: - raise ValueError("Mix of label input types (string and number)") + msg_details = ( + "Got " + " and ".join([f"{xp.unique_values(y)}" for y in ys]) + "." + ) + raise ValueError(f"Mix of label input types (string and number); {msg_details}") return xp.asarray(sorted(ys_labels)) diff --git a/sklearn/utils/optimize.py b/sklearn/utils/optimize.py index 6eee5d4616bd5..2eb5d8d81dcc5 100644 --- a/sklearn/utils/optimize.py +++ b/sklearn/utils/optimize.py @@ -17,19 +17,79 @@ import warnings -import numpy as np import scipy -from scipy.optimize._linesearch import line_search_wolfe1, line_search_wolfe2 +from scipy.optimize._linesearch import ( + line_search_wolfe2, + scalar_search_wolfe1, +) from sklearn.exceptions import ConvergenceWarning +from sklearn.utils._array_api import get_namespace_and_device, size class _LineSearchError(RuntimeError): pass +# Copied from scipy +# https://github.com/scipy/scipy/blob/7a7fbca0b9baa1b709e4a5e0afaf9f94bd34941c/scipy/optimize/_linesearch.py#L37 +# Modified for array API compliance: np.dot(a, b) -> a @ b +# TODO: use the `line_search_wolfe1` from `scipy` when it is array API compliant. +# Reference: https://github.com/scipy/scipy/pull/25022 +def _line_search_wolfe1( + f, + fprime, + xk, + pk, + gfk=None, + old_fval=None, + old_old_fval=None, + args=(), + c1=1e-4, + c2=0.9, + amax=50, + amin=1e-8, + xtol=1e-14, +): + """ + Same as `scalar_search_wolfe1` but do a line search to direction `pk` + """ + if gfk is None: + gfk = fprime(xk, *args) + + gval = [gfk] + gc = [0] + fc = [0] + + def phi(s): + fc[0] += 1 + return f(xk + s * pk, *args) + + def derphi(s): + gval[0] = fprime(xk + s * pk, *args) + gc[0] += 1 + return gval[0] @ pk + + derphi0 = gfk @ pk + + stp, fval, old_fval = scalar_search_wolfe1( + phi, + derphi, + old_fval, + old_old_fval, + derphi0, + c1=c1, + c2=c2, + amax=amax, + amin=amin, + xtol=xtol, + ) + + return stp, fc[0], gc[0], fval, old_fval, gval[0] + + def _line_search_wolfe12( - f, fprime, xk, pk, gfk, old_fval, old_old_fval, verbose=0, **kwargs + f, fprime, xk, pk, gfk, old_fval, old_old_fval, xp, device, verbose=0, **kwargs ): """ Same as line_search_wolfe1, but fall back to line_search_wolfe2 if @@ -43,13 +103,13 @@ def _line_search_wolfe12( """ is_verbose = verbose >= 2 - eps = 16 * np.finfo(np.asarray(old_fval).dtype).eps + eps = 16 * xp.finfo(xk.dtype).eps if is_verbose: print(" Line Search") print(f" eps=16 * finfo.eps={eps}") print(" try line search wolfe1") - ret = line_search_wolfe1(f, fprime, xk, pk, gfk, old_fval, old_old_fval, **kwargs) + ret = _line_search_wolfe1(f, fprime, xk, pk, gfk, old_fval, old_old_fval, **kwargs) if is_verbose: _not_ = "not " if ret[0] is None else "" @@ -61,13 +121,13 @@ def _line_search_wolfe12( # Deal with relative loss differences around machine precision. args = kwargs.get("args", tuple()) fval = f(xk + pk, *args) - tiny_loss = np.abs(old_fval * eps) + tiny_loss = xp.abs(old_fval * eps) loss_improvement = fval - old_fval - check = np.abs(loss_improvement) <= tiny_loss + check = xp.abs(loss_improvement) <= tiny_loss if is_verbose: print( " check loss |improvement| <= eps * |loss_old|:" - f" {np.abs(loss_improvement)} <= {tiny_loss} {check}" + f" {xp.abs(loss_improvement)} <= {tiny_loss} {check}" ) if check: # 2.1 Check sum of absolute gradients as alternative condition. @@ -110,7 +170,7 @@ def _line_search_wolfe12( return ret -def _cg(fhess_p, fgrad, maxiter, tol, verbose=0): +def _cg(fhess_p, fgrad, maxiter, tol, xp, device, verbose=0): """ Solve iteratively the linear system 'fhess_p . xsupi = fgrad' with a conjugate gradient descent. @@ -135,28 +195,28 @@ def _cg(fhess_p, fgrad, maxiter, tol, verbose=0): xsupi : ndarray of shape (n_features,) or (n_features + 1,) Estimated solution. """ - eps = 16 * np.finfo(np.float64).eps - xsupi = np.zeros(len(fgrad), dtype=fgrad.dtype) - ri = np.copy(fgrad) # residual = fgrad - fhess_p @ xsupi + eps = 16 * xp.finfo(fgrad.dtype).eps + xsupi = xp.zeros(size(fgrad), dtype=fgrad.dtype, device=device) + ri = xp.asarray(fgrad, copy=True) # residual = fgrad - fhess_p @ xsupi psupi = -ri i = 0 - dri0 = np.dot(ri, ri) + dri0 = ri @ ri # We also keep track of |p_i|^2. psupi_norm2 = dri0 is_verbose = verbose >= 2 while i <= maxiter: - if np.sum(np.abs(ri)) <= tol: + if (norm1_re := xp.sum(xp.abs(ri))) <= tol: if is_verbose: print( f" Inner CG solver iteration {i} stopped with\n" - f" sum(|residuals|) <= tol: {np.sum(np.abs(ri))} <= {tol}" + f" sum(|residuals|) <= tol: {norm1_re} <= {tol}" ) break Ap = fhess_p(psupi) # check curvature - curv = np.dot(psupi, Ap) + curv = psupi @ Ap if 0 <= curv <= eps * psupi_norm2: # See https://arxiv.org/abs/1803.02924, Algo 1 Capped Conjugate Gradient. if is_verbose: @@ -184,17 +244,17 @@ def _cg(fhess_p, fgrad, maxiter, tol, verbose=0): alphai = dri0 / curv xsupi += alphai * psupi ri += alphai * Ap - dri1 = np.dot(ri, ri) + dri1 = ri @ ri betai = dri1 / dri0 psupi = -ri + betai * psupi # We use |p_i|^2 = |r_i|^2 + beta_i^2 |p_{i-1}|^2 psupi_norm2 = dri1 + betai**2 * psupi_norm2 i = i + 1 - dri0 = dri1 # update np.dot(ri,ri) for next time. + dri0 = dri1 # update ri @ri for next time. if is_verbose and i > maxiter: print( f" Inner CG solver stopped reaching maxiter={i - 1} with " - f"sum(|residuals|) = {np.sum(np.abs(ri))}" + f"sum(|residuals|) = {xp.sum(xp.abs(ri))}" ) return xsupi @@ -229,7 +289,7 @@ def _newton_cg( Should return the function value and the gradient. This is used by the linesearch functions. - x0 : array of float + x0 : array-like of float Initial guess. args : tuple, default=() @@ -254,11 +314,15 @@ def _newton_cg( Returns ------- - xk : ndarray of float + xk : array-like of float Estimated minimum. """ - x0 = np.asarray(x0).flatten() - xk = np.copy(x0) + xp, _, device = get_namespace_and_device(x0) + x0 = xp.asarray(x0, device=device) + if x0.ndim != 1: + msg = f"x0 must be 1-dimensional; got {x0.ndim=}" + raise ValueError(msg) + xk = xp.asarray(x0, copy=True) # np.copy(x0) k = 0 if line_search: @@ -275,8 +339,8 @@ def _newton_cg( # del2 f(xk) p = - fgrad f(xk) starting from 0. fgrad, fhess_p = grad_hess(xk, *args) - absgrad = np.abs(fgrad) - max_absgrad = np.max(absgrad) + absgrad = xp.abs(fgrad) + max_absgrad = xp.max(absgrad) check = max_absgrad <= tol if is_verbose: print(f"Newton-CG iter = {k}") @@ -285,13 +349,21 @@ def _newton_cg( if check: break - maggrad = np.sum(absgrad) - eta = min([0.5, np.sqrt(maggrad)]) + maggrad = xp.sum(absgrad) + eta = min([0.5, xp.sqrt(maggrad)]) termcond = eta * maggrad # Inner loop: solve the Newton update by conjugate gradient, to # avoid inverting the Hessian - xsupi = _cg(fhess_p, fgrad, maxiter=maxinner, tol=termcond, verbose=verbose) + xsupi = _cg( + fhess_p, + fgrad, + maxiter=maxinner, + tol=termcond, + xp=xp, + device=device, + verbose=verbose, + ) alphak = 1.0 @@ -305,6 +377,8 @@ def _newton_cg( fgrad, old_fval, old_old_fval, + xp=xp, + device=device, verbose=verbose, args=args, ) diff --git a/sklearn/utils/random.py b/sklearn/utils/random.py index 4da8f26894aa6..20ab006d702de 100644 --- a/sklearn/utils/random.py +++ b/sklearn/utils/random.py @@ -10,6 +10,7 @@ from sklearn.utils import check_random_state from sklearn.utils._random import sample_without_replacement +from sklearn.utils._sparse import _align_api_if_sparse __all__ = ["sample_without_replacement"] @@ -98,4 +99,5 @@ def _random_choice_csc(n_samples, classes, class_probability=None, random_state= data.extend(classes[j][classes_j_nonzero][classes_ind]) indptr.append(len(indices)) - return sp.csc_matrix((data, indices, indptr), (n_samples, len(classes)), dtype=int) + csc = sp.csc_array((data, indices, indptr), (n_samples, len(classes)), dtype=int) + return _align_api_if_sparse(csc) diff --git a/sklearn/utils/sparsefuncs.py b/sklearn/utils/sparsefuncs.py index 1b0f1bb3a389d..ce87313ada9b1 100644 --- a/sklearn/utils/sparsefuncs.py +++ b/sklearn/utils/sparsefuncs.py @@ -63,18 +63,18 @@ def inplace_csr_column_scale(X, scale): >>> indices = np.array([0, 1, 2, 2]) >>> data = np.array([8, 1, 2, 5]) >>> scale = np.array([2, 3, 2]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 1, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 1, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.inplace_csr_column_scale(csr, scale) >>> csr.todense() - matrix([[16, 3, 4], - [ 0, 0, 10], - [ 0, 0, 0], - [ 0, 0, 0]]) + array([[16, 3, 4], + [ 0, 0, 10], + [ 0, 0, 0], + [ 0, 0, 0]]) """ assert scale.shape[0] == X.shape[1] X.data *= scale.take(X.indices, mode="clip") @@ -143,12 +143,12 @@ def mean_variance_axis(X, axis, weights=None, return_sum_weights=False): >>> indices = np.array([0, 1, 2, 2]) >>> data = np.array([8, 1, 2, 5]) >>> scale = np.array([2, 3, 2]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 1, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 1, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.mean_variance_axis(csr, axis=0) (array([2. , 0.25, 1.75]), array([12. , 0.1875, 4.1875])) """ @@ -245,12 +245,12 @@ def incr_mean_variance_axis(X, *, axis, last_mean, last_var, last_n, weights=Non >>> indices = np.array([0, 1, 2, 2]) >>> data = np.array([8, 1, 2, 5]) >>> scale = np.array([2, 3, 2]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 1, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 1, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.incr_mean_variance_axis( ... csr, axis=0, last_mean=np.zeros(3), last_var=np.zeros(3), last_n=2 ... ) @@ -315,18 +315,18 @@ def inplace_column_scale(X, scale): >>> indices = np.array([0, 1, 2, 2]) >>> data = np.array([8, 1, 2, 5]) >>> scale = np.array([2, 3, 2]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 1, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 1, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.inplace_column_scale(csr, scale) >>> csr.todense() - matrix([[16, 3, 4], - [ 0, 0, 10], - [ 0, 0, 0], - [ 0, 0, 0]]) + array([[16, 3, 4], + [ 0, 0, 10], + [ 0, 0, 0], + [ 0, 0, 0]]) """ if sp.issparse(X) and X.format == "csc": inplace_csr_row_scale(X.T, scale) @@ -359,18 +359,18 @@ def inplace_row_scale(X, scale): >>> indices = np.array([0, 1, 2, 3, 3]) >>> data = np.array([8, 1, 2, 5, 6]) >>> scale = np.array([2, 3, 4, 5]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 1, 0, 0], - [0, 0, 2, 0], - [0, 0, 0, 5], - [0, 0, 0, 6]]) + array([[8, 1, 0, 0], + [0, 0, 2, 0], + [0, 0, 0, 5], + [0, 0, 0, 6]]) >>> sparsefuncs.inplace_row_scale(csr, scale) >>> csr.todense() - matrix([[16, 2, 0, 0], - [ 0, 0, 6, 0], - [ 0, 0, 0, 20], - [ 0, 0, 0, 30]]) + array([[16, 2, 0, 0], + [ 0, 0, 6, 0], + [ 0, 0, 0, 20], + [ 0, 0, 0, 30]]) """ if sp.issparse(X) and X.format == "csc": inplace_csr_column_scale(X.T, scale) @@ -496,18 +496,18 @@ def inplace_swap_row(X, m, n): >>> indptr = np.array([0, 2, 3, 3, 3]) >>> indices = np.array([0, 2, 2]) >>> data = np.array([8, 2, 5]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 0, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 0, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.inplace_swap_row(csr, 0, 1) >>> csr.todense() - matrix([[0, 0, 5], - [8, 0, 2], - [0, 0, 0], - [0, 0, 0]]) + array([[0, 0, 5], + [8, 0, 2], + [0, 0, 0], + [0, 0, 0]]) """ if sp.issparse(X) and X.format == "csc": inplace_swap_row_csc(X, m, n) @@ -541,18 +541,18 @@ def inplace_swap_column(X, m, n): >>> indptr = np.array([0, 2, 3, 3, 3]) >>> indices = np.array([0, 2, 2]) >>> data = np.array([8, 2, 5]) - >>> csr = sparse.csr_matrix((data, indices, indptr)) + >>> csr = sparse.csr_array((data, indices, indptr)) >>> csr.todense() - matrix([[8, 0, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[8, 0, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) >>> sparsefuncs.inplace_swap_column(csr, 0, 1) >>> csr.todense() - matrix([[0, 8, 2], - [0, 0, 5], - [0, 0, 0], - [0, 0, 0]]) + array([[0, 8, 2], + [0, 0, 5], + [0, 0, 0], + [0, 0, 0]]) """ if m < 0: m += X.shape[1] @@ -783,7 +783,7 @@ def sparse_matmul_to_dense(A, B, out=None): if out.shape[0] != n1 or out.shape[1] != n3: raise ValueError("Shape of out must be ({n1}, {n3}), got {out.shape}.") if out.dtype != A.data.dtype: - raise ValueError("Dtype of out must match that of input A..") + raise ValueError("Dtype of out must match that of input A.") transpose_out = False if A.format == "csc": diff --git a/sklearn/utils/sparsefuncs_fast.pyx b/sklearn/utils/sparsefuncs_fast.pyx index 0e9f75a18a542..cf4bdf4b9d946 100644 --- a/sklearn/utils/sparsefuncs_fast.pyx +++ b/sklearn/utils/sparsefuncs_fast.pyx @@ -8,6 +8,7 @@ from libc.stdint cimport intptr_t import numpy as np from cython cimport floating +from sklearn.utils.fixes import _ensure_sparse_index_int32 from sklearn.utils._typedefs cimport float64_t, int32_t, int64_t, intp_t, uint64_t @@ -50,7 +51,7 @@ def _sqeuclidean_row_norms_sparse( def csr_mean_variance_axis0(X, weights=None, return_sum_weights=False): """Compute mean and variance along axis 0 on a CSR matrix - Uses a np.float64 accumulator. + Uses an np.float64 accumulator. Parameters ---------- @@ -184,7 +185,7 @@ def _csr_mean_variance_axis0( def csc_mean_variance_axis0(X, weights=None, return_sum_weights=False): """Compute mean and variance along axis 0 on a CSC matrix - Uses a np.float64 accumulator. + Uses an np.float64 accumulator. Parameters ---------- @@ -371,6 +372,7 @@ def incr_mean_variance_axis0(X, last_mean, last_var, last_n, weights=None): if last_n.dtype not in [np.float32, np.float64]: last_n = last_n.astype(np.float64, copy=False) + _ensure_sparse_index_int32(X) return _incr_mean_variance_axis0(X.data, np.sum(weights), X.shape[1], @@ -491,13 +493,13 @@ def inplace_csr_row_normalize_l1(X): Examples -------- - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> from sklearn.utils.sparsefuncs_fast import inplace_csr_row_normalize_l1 >>> import numpy as np >>> indptr = np.array([0, 2, 3, 4]) >>> indices = np.array([0, 1, 2, 3]) >>> data = np.array([1.0, 2.0, 3.0, 4.0]) - >>> X = csr_matrix((data, indices, indptr), shape=(3, 4)) + >>> X = csr_array((data, indices, indptr), shape=(3, 4)) >>> X.toarray() array([[1., 2., 0., 0.], [0., 0., 3., 0.], @@ -553,13 +555,13 @@ def inplace_csr_row_normalize_l2(X): Examples -------- - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> from sklearn.utils.sparsefuncs_fast import inplace_csr_row_normalize_l2 >>> import numpy as np >>> indptr = np.array([0, 2, 3, 4]) >>> indices = np.array([0, 1, 2, 3]) >>> data = np.array([1.0, 2.0, 3.0, 4.0]) - >>> X = csr_matrix((data, indices, indptr), shape=(3, 4)) + >>> X = csr_array((data, indices, indptr), shape=(3, 4)) >>> X.toarray() array([[1., 2., 0., 0.], [0., 0., 3., 0.], @@ -615,7 +617,7 @@ def assign_rows_csr( Parameters ---------- - X : scipy.sparse.csr_matrix, shape=(n_samples, n_features) + X : scipy.sparse.csr_array, shape=(n_samples, n_features) X_rows : array, dtype=np.intp, shape=n_rows out_rows : array, dtype=np.intp, shape=n_rows out : array, shape=(arbitrary, n_features) diff --git a/sklearn/utils/stats.py b/sklearn/utils/stats.py index 453b0ab122c37..2d3a689e0e22b 100644 --- a/sklearn/utils/stats.py +++ b/sklearn/utils/stats.py @@ -97,6 +97,9 @@ def _weighted_percentile( sample_weight = xp.asarray(sample_weight, dtype=floating_dtype, device=device) percentile_rank = xp.asarray(percentile_rank, dtype=floating_dtype, device=device) + if xp.all(sample_weight == 0): + return xp.nan + n_dim = array.ndim if n_dim == 0: return array @@ -189,7 +192,7 @@ def _weighted_percentile( ) # Handle case where there are trailing 0 sample weight samples # and `percentile_indices` is already max index - if next_index >= max_idx: + if next_index > max_idx: # use original `percentile_indices` again next_index = percentile_indices[col_idx] diff --git a/sklearn/utils/tests/test_arpack.py b/sklearn/utils/tests/test_arpack.py index ab1d622d51a08..33a2a75980de0 100644 --- a/sklearn/utils/tests/test_arpack.py +++ b/sklearn/utils/tests/test_arpack.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize("seed", range(100)) def test_init_arpack_v0(seed): - # check that the initialization a sampling from an uniform distribution + # check that the initialization a sampling from a uniform distribution # where we can fix the random state size = 1000 v0 = _init_arpack_v0(size, seed) diff --git a/sklearn/utils/tests/test_array_api.py b/sklearn/utils/tests/test_array_api.py index 8bb17c8a4fa08..a2060468f78f6 100644 --- a/sklearn/utils/tests/test_array_api.py +++ b/sklearn/utils/tests/test_array_api.py @@ -6,6 +6,7 @@ import scipy import scipy.sparse as sp from numpy.testing import assert_allclose +from scipy.special import expit, logit from sklearn._config import config_context from sklearn._loss import HalfMultinomialLoss @@ -18,12 +19,14 @@ _convert_to_numpy, _count_nonzero, _estimator_with_converted_arrays, + _expit, _fill_diagonal, - _get_namespace_device_dtype_ids, _half_multinomial_loss, _is_numpy_namespace, _isin, + _logit, _logsumexp, + _matching_numpy_dtype, _max_precision_float_dtype, _median, _nanmax, @@ -31,26 +34,32 @@ _nanmin, _ravel, _validate_diagonal_args, - device, + check_same_namespace, get_namespace, get_namespace_and_device, indexing_dtype, + move_estimator_to, move_to, np_compat, supported_float_dtypes, + yield_mixed_namespace_input_permutations, yield_namespace_device_dtype_combinations, ) +from sklearn.utils._array_api import ( + device as array_api_device, +) from sklearn.utils._testing import ( SkipTest, _array_api_for_tests, + _convert_container, assert_array_equal, skip_if_array_api_compat_not_configured, ) from sklearn.utils.fixes import _IS_32BIT, CSR_CONTAINERS, np_version, parse_version -@pytest.mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3]]) -def test_get_namespace_ndarray_default(X): +@pytest.mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3], (1, 2, 3)]) +def test_get_namespace_ndarray_or_similar_default(X): """Check that get_namespace returns NumPy wrapper""" xp_out, is_array_api_compliant = get_namespace(X) assert xp_out is np_compat @@ -70,20 +79,46 @@ def test_get_namespace_ndarray_creation_device(): @skip_if_array_api_compat_not_configured -def test_get_namespace_ndarray_with_dispatch(): +@pytest.mark.parametrize("X", [numpy.asarray([1, 2, 3]), [1, 2, 3], (1, 2, 3)]) +def test_get_namespace_ndarray_or_similar_default_with_dispatch(X): """Test get_namespace on NumPy ndarrays.""" - X_np = numpy.asarray([[1, 2, 3]]) - with config_context(array_api_dispatch=True): - xp_out, is_array_api_compliant = get_namespace(X_np) - assert is_array_api_compliant + xp_out, is_array_api_compliant = get_namespace(X) + assert is_array_api_compliant == isinstance(X, numpy.ndarray) # In the future, NumPy should become API compliant library and we should have # assert xp_out is numpy assert xp_out is np_compat +@skip_if_array_api_compat_not_configured +@pytest.mark.parametrize("constructor_name", ["pyarrow", "pandas", "polars", "series"]) +def test_get_namespace_df_with_dispatch(constructor_name): + """Test get_namespace on dataframes and series.""" + + df = _convert_container([[1, 4, 2], [3, 3, 6]], constructor_name) + with config_context(array_api_dispatch=True): + xp_out, is_array_api_compliant = get_namespace(df) + assert not is_array_api_compliant + + # When operating on dataframes or series the Numpy namespace is + # the right thing to use. + assert xp_out is np_compat + + +@skip_if_array_api_compat_not_configured +def test_get_namespace_sparse_with_dispatch(): + """Test get_namespace on sparse arrays.""" + with config_context(array_api_dispatch=True): + xp_out, is_array_api_compliant = get_namespace(sp.csr_array([[1, 2, 3]])) + assert not is_array_api_compliant + + # When operating on sparse arrays the Numpy namespace is + # the right thing to use. + assert xp_out is np_compat + + @skip_if_array_api_compat_not_configured def test_get_namespace_array_api(monkeypatch): """Test get_namespace for ArrayAPI arrays.""" @@ -114,51 +149,35 @@ def mock_getenv(key): @pytest.mark.parametrize( "array_input, reference", [ - pytest.param(("cupy", None), ("torch", "cuda"), id="cupy to torch cuda"), - pytest.param(("torch", "mps"), ("numpy", None), id="torch mps to numpy"), - pytest.param(("numpy", None), ("torch", "cuda"), id="numpy to torch cuda"), - pytest.param(("numpy", None), ("torch", "mps"), id="numpy to torch mps"), - pytest.param( - ("array_api_strict", None), - ("torch", "mps"), - id="array_api_strict to torch mps", - ), + pytest.param(*args[:2], id=args[2]) + for args in yield_mixed_namespace_input_permutations() ], ) def test_move_to_array_api_conversions(array_input, reference): - """Check conversion between various namespace and devices.""" - if array_input[0] == "array_api_strict": - array_api_strict = pytest.importorskip( - "array_api_strict", reason="array-api-strict not available" - ) - xp = _array_api_for_tests(reference[0], reference[1]) - xp_array = _array_api_for_tests(array_input[0], array_input[1]) + """Check conversion between various namespace-device-pairs.""" + xp_to, device_to = _array_api_for_tests(reference.xp, device_name=reference.device) + xp_from, device_from = _array_api_for_tests( + array_input.xp, device_name=array_input.device + ) with config_context(array_api_dispatch=True): - device_ = device(xp.asarray([1], device=reference[1])) - - if array_input[0] == "array_api_strict": - array_device = array_api_strict.Device("CPU_DEVICE") - else: - array_device = array_input[1] - array = xp_array.asarray([1, 2, 3], device=array_device) - - array_out = move_to(array, xp=xp, device=device_) - assert get_namespace(array_out)[0] == xp - assert device(array_out) == device_ + array_in = xp_from.asarray([1, 2, 3], device=device_from) + device_reference = array_api_device(xp_to.asarray(1, device=device_to)) + array_out = move_to(array_in, xp=xp_to, device=device_reference) + assert get_namespace(array_out)[0] == xp_to + assert array_api_device(array_out) == device_reference def test_move_to_sparse(): """Check sparse inputs are handled correctly.""" - xp_numpy = _array_api_for_tests("numpy", None) - xp_torch = _array_api_for_tests("torch", "cpu") + xp_numpy, _ = _array_api_for_tests("numpy", device_name=None) + xp_torch, device = _array_api_for_tests("torch", device_name="cpu") sparse1 = sp.csr_array([0, 1, 2, 3]) - sparse2 = sp.csr_array([0, 1, 0, 1]) numpy_array = numpy.array([1, 2, 3]) with config_context(array_api_dispatch=True): - device_cpu = xp_torch.asarray([1]).device + device_cpu = device # sparse and None to NumPy result1, result2 = move_to(sparse1, None, xp=xp_numpy, device=None) @@ -186,9 +205,8 @@ def test_asarray_with_order(array_api): @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "weights, axis, normalize, expected", @@ -220,14 +238,14 @@ def test_asarray_with_order(array_api): ], ) def test_average( - array_namespace, device_, dtype_name, weights, axis, normalize, expected + array_namespace, device_name, dtype_name, weights, axis, normalize, expected ): - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array_in = numpy.asarray([[1, 2, 3], [4, 5, 6]], dtype=dtype_name) - array_in = xp.asarray(array_in, device=device_) + array_in = xp.asarray(array_in, device=device) if weights is not None: weights = numpy.asarray(weights, dtype=dtype_name) - weights = xp.asarray(weights, device=device_) + weights = xp.asarray(weights, device=device) with config_context(array_api_dispatch=True): result = _average(array_in, axis=axis, weights=weights, normalize=normalize) @@ -235,19 +253,18 @@ def test_average( if np_version < parse_version("2.0.0") or np_version >= parse_version("2.1.0"): # NumPy 2.0 has a problem with the device attribute of scalar arrays: # https://github.com/numpy/numpy/issues/26850 - assert device(array_in) == device(result) + assert array_api_device(array_in) == array_api_device(result) - result = _convert_to_numpy(result, xp) + result = move_to(result, xp=numpy, device="cpu") assert_allclose(result, expected, atol=_atol_for_type(dtype_name)) @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(include_numpy_namespaces=False), - ids=_get_namespace_device_dtype_ids, ) -def test_average_raises_with_wrong_dtype(array_namespace, device, dtype_name): - xp = _array_api_for_tests(array_namespace, device) +def test_average_raises_with_wrong_dtype(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array_in = numpy.asarray([2, 0], dtype=dtype_name) + 1j * numpy.asarray( [4, 3], dtype=dtype_name @@ -268,9 +285,8 @@ def test_average_raises_with_wrong_dtype(array_namespace, device, dtype_name): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(include_numpy_namespaces=True), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize( "axis, weights, error, error_msg", @@ -298,9 +314,9 @@ def test_average_raises_with_wrong_dtype(array_namespace, device, dtype_name): ), ) def test_average_raises_with_invalid_parameters( - array_namespace, device, dtype_name, axis, weights, error, error_msg + array_namespace, device_name, dtype_name, axis, weights, error, error_msg ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array_in = numpy.asarray([[1, 2, 3], [4, 5, 6]], dtype=dtype_name) array_in = xp.asarray(array_in, device=device) @@ -313,9 +329,9 @@ def test_average_raises_with_invalid_parameters( def test_device_none_if_no_input(): - assert device() is None + assert array_api_device() is None - assert device(None, "name") is None + assert array_api_device(None, "name") is None @skip_if_array_api_compat_not_configured @@ -348,22 +364,22 @@ def __init__(self, device_name): # early for different devices would prevent the np.asarray conversion to # happen. For example, `r2_score(np.ones(5), torch.ones(5))` should work # fine with array API disabled. - assert device(Array("cpu"), Array("mygpu")) is None + assert array_api_device(Array("cpu"), Array("mygpu")) is None # Test that ValueError is raised if on different devices and array API dispatch is # enabled. err_msg = "Input arrays use different devices: cpu, mygpu" with config_context(array_api_dispatch=True): with pytest.raises(ValueError, match=err_msg): - device(Array("cpu"), Array("mygpu")) + array_api_device(Array("cpu"), Array("mygpu")) # Test expected value is returned otherwise array1 = Array("device") array2 = Array("device") - assert array1.device == device(array1) - assert array1.device == device(array1, array2) - assert array1.device == device(array1, array1, array2) + assert array1.device == array_api_device(array1) + assert array1.device == array_api_device(array1, array2) + assert array1.device == array_api_device(array1, array1, array2) # TODO: add cupy to the list of libraries once the following upstream issue @@ -422,24 +438,23 @@ def test_nan_reductions(library, X, reduction, expected): with config_context(array_api_dispatch=True): result = reduction(xp.asarray(X)) - result = _convert_to_numpy(result, xp) + result = move_to(result, xp=numpy, device="cpu") assert_allclose(result, expected) @pytest.mark.parametrize( - "namespace, _device, _dtype", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_ravel(namespace, _device, _dtype): - xp = _array_api_for_tests(namespace, _device) +def test_ravel(namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) array = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]] - array_xp = xp.asarray(array, device=_device) + array_xp = xp.asarray(array, device=device) with config_context(array_api_dispatch=True): result = _ravel(array_xp) - result = _convert_to_numpy(result, xp) + result = move_to(result, xp=numpy, device="cpu") expected = numpy.ravel(array, order="C") assert_allclose(expected, result) @@ -479,9 +494,35 @@ def test_convert_to_numpy_cpu(): class SimpleEstimator(BaseEstimator): def fit(self, X, y=None): self.X_ = X + self.X_dict_ = {"X": X} + self.X_list_ = [X] self.n_features_ = X.shape[0] return self + def predict(self, X): + check_same_namespace(X, self, attribute="X_", method="predict") + return X + + +class SimpleEstimatorCustomLogic(BaseEstimator): + def fit(self, X, y=None): + self.X_ = X + self.X_dict_ = {"X": X} + self.n_features_ = X.shape[0] + return self + + def predict(self, X): + check_same_namespace(X, self, attribute="X_", method="predict") + return X + + def __sklearn_array_api_convert__(self, converter): + self.X_ = converter(self.X_) + self.X_dict_ = {k: converter(v) for k, v in self.X_dict_.items()} + # XXX Do we need this? What else could the custom logic do that wouldn't work + # with the default logic? + self.converted_ = True + return self + @skip_if_array_api_compat_not_configured @pytest.mark.parametrize( @@ -497,15 +538,18 @@ def test_convert_estimator_to_ndarray(array_namespace, converter): xp = pytest.importorskip(array_namespace) X = xp.asarray([[1.3, 4.5]]) - est = SimpleEstimator().fit(X) + with config_context(array_api_dispatch=True): + est = SimpleEstimator().fit(X) + est.predict(X) - new_est = _estimator_with_converted_arrays(est, converter) - assert isinstance(new_est.X_, numpy.ndarray) + new_est = _estimator_with_converted_arrays(est, converter) + assert isinstance(new_est.X_, numpy.ndarray) + new_est = move_estimator_to(est, numpy, device="cpu") + assert isinstance(new_est.X_, numpy.ndarray) @skip_if_array_api_compat_not_configured -def test_convert_estimator_to_array_api(): - """Convert estimator attributes to ArrayAPI arrays.""" +def test_convert_estimator_with_custom_logic(): xp = pytest.importorskip("array_api_strict") X_np = numpy.asarray([[1.3, 4.5]]) @@ -513,15 +557,62 @@ def test_convert_estimator_to_array_api(): new_est = _estimator_with_converted_arrays(est, lambda array: xp.asarray(array)) assert hasattr(new_est.X_, "__array_namespace__") + with config_context(array_api_dispatch=True): + new_est = move_estimator_to(est, xp, device=None) + + assert get_namespace(new_est.X_)[0] == xp + assert get_namespace(new_est.X_dict_["X"])[0] == xp + assert get_namespace(new_est.X_list_[0])[0] == xp + + +@skip_if_array_api_compat_not_configured +def test_custom_conversion_estimator_to_array_api_strict(): + xp = pytest.importorskip("array_api_strict") + + X_np = numpy.asarray([[1.3, 4.5]]) + est = SimpleEstimatorCustomLogic().fit(X_np) + + with config_context(array_api_dispatch=True): + new_est = move_estimator_to(est, xp, device=None) + + new_est.predict(xp.asarray([[1.3, 4.5]])) + + assert get_namespace(new_est.X_)[0] == xp + assert get_namespace(new_est.X_dict_["X"])[0] == xp + assert new_est.converted_ + + +@skip_if_array_api_compat_not_configured +def test_convert_estimator_to_array_api_strict(): + xp = pytest.importorskip("array_api_strict") + + X_np = numpy.asarray([[1.3, 4.5]]) + est = SimpleEstimator().fit(X_np) + + with config_context(array_api_dispatch=True): + new_est = move_estimator_to(est, xp, device=None) + + assert get_namespace(new_est.X_)[0] == xp + assert get_namespace(new_est.X_dict_["X"])[0] == xp + + +@skip_if_array_api_compat_not_configured +def test_check_fitted_attribute(): + xp = pytest.importorskip("array_api_strict") + + with config_context(array_api_dispatch=True): + est = SimpleEstimator().fit(xp.asarray([[1.3, 4.5]])) + + with pytest.raises(ValueError, match=".*must use the same namespace"): + est.predict(numpy.asarray([0])) @pytest.mark.parametrize( - "namespace, _device, _dtype", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_indexing_dtype(namespace, _device, _dtype): - xp = _array_api_for_tests(namespace, _device) +def test_indexing_dtype(namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) if _IS_32BIT: assert indexing_dtype(xp) == xp.int32 @@ -530,29 +621,40 @@ def test_indexing_dtype(namespace, _device, _dtype): @pytest.mark.parametrize( - "namespace, _device, _dtype", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_max_precision_float_dtype(namespace, _device, _dtype): - xp = _array_api_for_tests(namespace, _device) - expected_dtype = xp.float32 if _device == "mps" else xp.float64 - assert _max_precision_float_dtype(xp, _device) == expected_dtype +def test_max_precision_float_dtype(namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(namespace, device_name) + try: + xp.asarray([0.0], dtype=xp.float64, device=device) + expected_dtype = xp.float64 + except Exception: + # Some devices, such as MPS devices, PyTorch XPU devices and some Intel + # GPUs with dpnp, do not support float64. + expected_dtype = xp.float32 + + assert _max_precision_float_dtype(xp, device) == expected_dtype @pytest.mark.parametrize( - "array_namespace, device, _", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("invert", [True, False]) @pytest.mark.parametrize("assume_unique", [True, False]) @pytest.mark.parametrize("element_size", [6, 10, 14]) @pytest.mark.parametrize("int_dtype", ["int16", "int32", "int64", "uint8"]) def test_isin( - array_namespace, device, _, invert, assume_unique, element_size, int_dtype + array_namespace, + device_name, + dtype_name, + invert, + assume_unique, + element_size, + int_dtype, ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) r = element_size // 2 element = 2 * numpy.arange(element_size).reshape((r, 2)).astype(int_dtype) test_elements = numpy.array(numpy.arange(14), dtype=int_dtype) @@ -573,7 +675,7 @@ def test_isin( invert=invert, ) - assert_array_equal(_convert_to_numpy(result, xp=xp), expected) + assert_array_equal(move_to(result, xp=numpy, device="cpu"), expected) @pytest.mark.skipif( @@ -607,19 +709,23 @@ def test_get_namespace_and_device(): @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) @pytest.mark.parametrize("axis", [0, 1, None, -1, -2]) @pytest.mark.parametrize("sample_weight_type", [None, "int", "float"]) def test_count_nonzero( - array_namespace, device_, dtype_name, csr_container, axis, sample_weight_type + array_namespace, + device_name, + dtype_name, + csr_container, + axis, + sample_weight_type, ): from sklearn.utils.sparsefuncs import count_nonzero as sparse_count_nonzero - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array = numpy.array([[0, 3, 0], [2, -1, 0], [0, 0, 0], [9, 8, 7], [4, 0, 5]]) if sample_weight_type == "int": sample_weight = numpy.asarray([1, 2, 2, 3, 1]) @@ -630,19 +736,19 @@ def test_count_nonzero( expected = sparse_count_nonzero( csr_container(array), axis=axis, sample_weight=sample_weight ) - array_xp = xp.asarray(array, device=device_) + array_xp = xp.asarray(array, device=device) with config_context(array_api_dispatch=True): result = _count_nonzero( - array_xp, axis=axis, sample_weight=sample_weight, xp=xp, device=device_ + array_xp, axis=axis, sample_weight=sample_weight, xp=xp, device=device ) - assert_allclose(_convert_to_numpy(result, xp=xp), expected) + assert_allclose(move_to(result, xp=numpy, device="cpu"), expected) if np_version < parse_version("2.0.0") or np_version >= parse_version("2.1.0"): # NumPy 2.0 has a problem with the device attribute of scalar arrays: # https://github.com/numpy/numpy/issues/26850 - assert device(array_xp) == device(result) + assert array_api_device(array_xp) == array_api_device(result) @pytest.mark.parametrize( @@ -660,7 +766,7 @@ def test_count_nonzero( ) def test_validate_diagonal_args(array, value, match): """Check `_validate_diagonal_args` raises the correct errors.""" - xp = _array_api_for_tests("numpy", None) + xp, _ = _array_api_for_tests("numpy", device_name=None) with pytest.raises(ValueError, match=match): _validate_diagonal_args(array, value, xp) @@ -669,7 +775,7 @@ def test_validate_diagonal_args(array, value, match): @pytest.mark.parametrize("c_contiguity", [True, False]) def test_fill_and_add_to_diagonal(c_contiguity, function): """Check `_fill/add_to_diagonal` behaviour correct with numpy arrays.""" - xp = _array_api_for_tests("numpy", None) + xp, _ = _array_api_for_tests("numpy", device_name=None) if c_contiguity: array = numpy.zeros((3, 4)) else: @@ -702,50 +808,48 @@ def test_fill_and_add_to_diagonal(c_contiguity, function): @pytest.mark.parametrize("array", ["standard", "transposed", "non-contiguous"]) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_fill_diagonal(array, array_namespace, device_, dtype_name): +def test_fill_diagonal(array, array_namespace, device_name, dtype_name): """Check array API `_fill_diagonal` consistent with `numpy._fill_diagonal`.""" - xp = _array_api_for_tests(array_namespace, device_) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array_np = numpy.zeros((4, 5), dtype=dtype_name) if array == "transposed": - array_xp = xp.asarray(array_np.copy(), device=device_).T + array_xp = xp.asarray(array_np.copy(), device=device).T array_np = array_np.T elif array == "non-contiguous": - array_xp = xp.asarray(array_np.copy(), device=device_)[::2, ::2] + array_xp = xp.asarray(array_np.copy(), device=device)[::2, ::2] array_np = array_np[::2, ::2] else: - array_xp = xp.asarray(array_np.copy(), device=device_) + array_xp = xp.asarray(array_np.copy(), device=device) numpy.fill_diagonal(array_np, val=1) with config_context(array_api_dispatch=True): _fill_diagonal(array_xp, value=1, xp=xp) - assert_array_equal(_convert_to_numpy(array_xp, xp=xp), array_np) + assert_array_equal(move_to(array_xp, xp=numpy, device="cpu"), array_np) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_add_to_diagonal(array_namespace, device_, dtype_name): +def test_add_to_diagonal(array_namespace, device_name, dtype_name): """Check `_add_to_diagonal` consistent between array API xp and numpy namespace.""" - xp = _array_api_for_tests(array_namespace, device_) - np_xp = _array_api_for_tests("numpy", None) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + np_xp, _ = _array_api_for_tests("numpy", device_name=None) array_np = numpy.zeros((3, 4), dtype=dtype_name) - array_xp = xp.asarray(array_np.copy(), device=device_) + array_xp = xp.asarray(array_np.copy(), device=device) add_val = [1, 2, 3] _fill_diagonal(array_np, value=add_val, xp=np_xp) with config_context(array_api_dispatch=True): _fill_diagonal(array_xp, value=add_val, xp=xp) - assert_array_equal(_convert_to_numpy(array_xp, xp=xp), array_np) + assert_array_equal(move_to(array_xp, xp=numpy, device="cpu"), array_np) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) @@ -758,25 +862,24 @@ def test_sparse_device(csr_container, dispatch): if dispatch and os.environ.get("SCIPY_ARRAY_API") is None: raise SkipTest("SCIPY_ARRAY_API is not set: not checking array_api input") with config_context(array_api_dispatch=dispatch): - assert device(a, b) is None - assert device(a, np_arr) == expected_numpy_array_device + assert array_api_device(a, b) is None + assert array_api_device(a, np_arr) == expected_numpy_array_device assert get_namespace_and_device(a, b)[2] is None assert get_namespace_and_device(a, np_arr)[2] == expected_numpy_array_device @pytest.mark.parametrize( - "namespace, device, dtype_name", + "namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("axis", [None, 0, 1]) -def test_median(namespace, device, dtype_name, axis): +def test_median(namespace, device_name, dtype_name, axis): # Note: depending on the value of `axis`, this test will compare median # computations on arrays of even (4) or odd (5) numbers of elements, hence # will test for median computation with and without interpolation to check # that array API namespaces yield consistent results even when the median is # not mathematically uniquely defined. - xp = _array_api_for_tests(namespace, device) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) rng = numpy.random.RandomState(0) X_np = rng.uniform(low=0.0, high=1.0, size=(5, 4)).astype(dtype_name) @@ -791,15 +894,42 @@ def test_median(namespace, device, dtype_name, axis): # part of the Array API spec assert get_namespace(result_xp)[0] == xp assert result_xp.device == X_xp.device - assert_allclose(result_np, _convert_to_numpy(result_xp, xp=xp)) + assert_allclose(result_np, move_to(result_xp, xp=numpy, device="cpu")) @pytest.mark.parametrize( - "array_namespace, device_, dtype_name", yield_namespace_device_dtype_combinations() + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_expit_logit(namespace, device_name, dtype_name): + rtol = 1e-6 if "float32" in str(dtype_name) else 1e-12 + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + + with config_context(array_api_dispatch=True): + x_np = numpy.linspace(-20, 20, 1000).astype(dtype_name) + x_xp = xp.asarray(x_np, device=device) + assert_allclose( + move_to(_expit(x_xp), xp=numpy, device="cpu"), + expit(x_np), + rtol=rtol, + ) + + x_np = numpy.linspace(0, 1, 1000).astype(dtype_name) + x_xp = xp.asarray(x_np, device=device) + assert_allclose( + move_to(_logit(x_xp), xp=numpy, device="cpu"), + logit(x_np), + rtol=rtol, + ) + + +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) @pytest.mark.parametrize("axis", [0, 1, None]) -def test_logsumexp_like_scipy_logsumexp(array_namespace, device_, dtype_name, axis): - xp = _array_api_for_tests(array_namespace, device_) +def test_logsumexp_like_scipy_logsumexp(array_namespace, device_name, dtype_name, axis): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) array_np = numpy.asarray( [ [0, 3, 1000], @@ -810,7 +940,7 @@ def test_logsumexp_like_scipy_logsumexp(array_namespace, device_, dtype_name, ax ], dtype=dtype_name, ) - array_xp = xp.asarray(array_np, device=device_) + array_xp = xp.asarray(array_np, device=device) res_np = scipy.special.logsumexp(array_np, axis=axis) @@ -818,14 +948,14 @@ def test_logsumexp_like_scipy_logsumexp(array_namespace, device_, dtype_name, ax # if torch on CPU or array api strict on default device # check that _logsumexp works when array API dispatch is disabled - if (array_namespace == "torch" and device_ == "cpu") or ( - array_namespace == "array_api_strict" and "CPU" in str(device_) + if (array_namespace == "torch" and device_name == "cpu") or ( + array_namespace == "array_api_strict" and "CPU" in str(device_name) ): assert_allclose(_logsumexp(array_xp, axis=axis), res_np, rtol=rtol) with config_context(array_api_dispatch=True): res_xp = _logsumexp(array_xp, axis=axis) - res_xp = _convert_to_numpy(res_xp, xp) + res_xp = move_to(res_xp, xp=numpy, device="cpu") assert_allclose(res_np, res_xp, rtol=rtol) # Test with NaNs and +np.inf @@ -839,13 +969,13 @@ def test_logsumexp_like_scipy_logsumexp(array_namespace, device_, dtype_name, ax ], dtype=dtype_name, ) - array_xp_2 = xp.asarray(array_np_2, device=device_) + array_xp_2 = xp.asarray(array_np_2, device=device) res_np_2 = scipy.special.logsumexp(array_np_2, axis=axis) with config_context(array_api_dispatch=True): res_xp_2 = _logsumexp(array_xp_2, axis=axis) - res_xp_2 = _convert_to_numpy(res_xp_2, xp) + res_xp_2 = move_to(res_xp_2, xp=numpy, device="cpu") assert_allclose(res_np_2, res_xp_2, rtol=rtol) @@ -860,17 +990,18 @@ def test_logsumexp_like_scipy_logsumexp(array_namespace, device_, dtype_name, ax ], ) def test_supported_float_types(namespace, device_, expected_types): - xp = _array_api_for_tests(namespace, device_) - float_types = supported_float_dtypes(xp, device=device_) + xp, device = _array_api_for_tests(namespace, device_name=device_) + float_types = supported_float_dtypes(xp, device=device) expected = tuple(getattr(xp, dtype_name) for dtype_name in expected_types) assert float_types == expected @pytest.mark.parametrize("use_sample_weight", [False, True]) @pytest.mark.parametrize( - "namespace, device_, dtype_name", yield_namespace_device_dtype_combinations() + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) -def test_half_multinomial_loss(use_sample_weight, namespace, device_, dtype_name): +def test_half_multinomial_loss(use_sample_weight, namespace, device_name, dtype_name): """Check that the array API version of :func:`_half_multinomial_loss` works correctly and matches the results produced by :class:`HalfMultinomialLoss` of the private `_loss` module. @@ -880,13 +1011,13 @@ def test_half_multinomial_loss(use_sample_weight, namespace, device_, dtype_name rng = numpy.random.RandomState(42) y = rng.randint(0, n_classes, n_samples).astype(dtype_name) pred = rng.rand(n_samples, n_classes).astype(dtype_name) - xp = _array_api_for_tests(namespace, device_) - y_xp = xp.asarray(y, device=device_) - pred_xp = xp.asarray(pred, device=device_) + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + y_xp = xp.asarray(y, device=device) + pred_xp = xp.asarray(pred, device=device) if use_sample_weight: sample_weight = numpy.ones_like(y) sample_weight[1::2] = 2 - sample_weight_xp = xp.asarray(sample_weight, device=device_) + sample_weight_xp = xp.asarray(sample_weight, device=device) else: sample_weight, sample_weight_xp = None, None @@ -899,3 +1030,15 @@ def test_half_multinomial_loss(use_sample_weight, namespace, device_, dtype_name ) assert numpy.isclose(np_loss, xp_loss) + + +@pytest.mark.parametrize( + "namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_matching_numpy_dtype(namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(namespace, device_name, dtype_name) + X_np = numpy.arange(1000).astype(dtype_name) + X_xp = xp.asarray(X_np, device=device) + ret_dtype = _matching_numpy_dtype(X_xp, xp=xp) + assert ret_dtype == X_np.dtype diff --git a/sklearn/ensemble/_hist_gradient_boosting/tests/test_bitset.py b/sklearn/utils/tests/test_bitset.py similarity index 90% rename from sklearn/ensemble/_hist_gradient_boosting/tests/test_bitset.py rename to sklearn/utils/tests/test_bitset.py index c02d66b666f80..8a8277ba56dfb 100644 --- a/sklearn/ensemble/_hist_gradient_boosting/tests/test_bitset.py +++ b/sklearn/utils/tests/test_bitset.py @@ -2,12 +2,11 @@ import pytest from numpy.testing import assert_allclose -from sklearn.ensemble._hist_gradient_boosting._bitset import ( +from sklearn.utils._bitset import ( in_bitset_memoryview, set_bitset_memoryview, set_raw_bitset_from_binned_bitset, ) -from sklearn.ensemble._hist_gradient_boosting.common import X_DTYPE @pytest.mark.parametrize( @@ -49,7 +48,7 @@ def test_raw_bitset_from_binned_bitset( ): binned_bitset = np.zeros(2, dtype=np.uint32) raw_bitset = np.zeros(2, dtype=np.uint32) - raw_categories = np.asarray(raw_categories, dtype=X_DTYPE) + raw_categories = np.asarray(raw_categories, dtype=np.float64) for val in binned_cat_to_insert: set_bitset_memoryview(binned_bitset, val) diff --git a/sklearn/utils/tests/test_dataframe.py b/sklearn/utils/tests/test_dataframe.py new file mode 100644 index 0000000000000..fc626db52a96c --- /dev/null +++ b/sklearn/utils/tests/test_dataframe.py @@ -0,0 +1,84 @@ +"""Tests for dataframe detection functions.""" + +import numpy as np +import pytest + +from sklearn._min_dependencies import dependent_packages +from sklearn.utils._dataframe import is_df_or_series, is_pandas_df, is_polars_df +from sklearn.utils._testing import _convert_container + + +@pytest.mark.parametrize("constructor_name", ["pyarrow", "pandas", "polars"]) +def test_is_df_or_series(constructor_name): + df = _convert_container([[1, 4, 2], [3, 3, 6]], constructor_name) + + assert is_df_or_series(df) + assert not is_df_or_series(np.asarray([1, 2, 3])) + + +@pytest.mark.parametrize("constructor_name", ["pyarrow", "pandas", "polars"]) +def test_is_pandas_df_other_libraries(constructor_name): + df = _convert_container([[1, 4, 2], [3, 3, 6]], constructor_name) + if constructor_name in ("pyarrow", "polars"): + assert not is_pandas_df(df) + else: + assert is_pandas_df(df) + + +def test_is_pandas_df(): + """Check behavior of is_pandas_df when pandas is installed.""" + pd = pytest.importorskip("pandas") + df = pd.DataFrame([[1, 2, 3]]) + assert is_pandas_df(df) + assert not is_pandas_df(np.asarray([1, 2, 3])) + assert not is_pandas_df(1) + + +def test_is_pandas_df_pandas_not_installed(hide_available_pandas): + """Check is_pandas_df when pandas is not installed.""" + + assert not is_pandas_df(np.asarray([1, 2, 3])) + assert not is_pandas_df(1) + + +@pytest.mark.parametrize( + "constructor_name, minversion", + [ + ("pyarrow", dependent_packages["pyarrow"][0]), + ("pandas", dependent_packages["pandas"][0]), + ("polars", dependent_packages["polars"][0]), + ], +) +def test_is_polars_df_other_libraries(constructor_name, minversion): + df = _convert_container( + [[1, 4, 2], [3, 3, 6]], + constructor_name, + minversion=minversion, + ) + if constructor_name in ("pyarrow", "pandas"): + assert not is_polars_df(df) + else: + assert is_polars_df(df) + + +def test_is_polars_df_for_duck_typed_polars_dataframe(): + """Check is_polars_df for object that looks like a polars dataframe""" + + class NotAPolarsDataFrame: + def __init__(self): + self.columns = [1, 2, 3] + self.schema = "my_schema" + + not_a_polars_df = NotAPolarsDataFrame() + assert not is_polars_df(not_a_polars_df) + + +def test_is_polars_df(): + """Check that is_polars_df return False for non-dataframe objects.""" + + class LooksLikePolars: + def __init__(self): + self.columns = ["a", "b"] + self.schema = ["a", "b"] + + assert not is_polars_df(LooksLikePolars()) diff --git a/sklearn/utils/tests/test_estimator_checks.py b/sklearn/utils/tests/test_estimator_checks.py index 556cf42462ab1..0b7a5c2d2f633 100644 --- a/sklearn/utils/tests/test_estimator_checks.py +++ b/sklearn/utils/tests/test_estimator_checks.py @@ -1,5 +1,5 @@ # We can not use pytest here, because we run -# build_tools/azure/test_pytest_soft_dependency.sh on these +# build_tools/github/test_pytest_soft_dependency.sh on these # tests to make sure estimator_checks works without pytest. import importlib @@ -113,6 +113,14 @@ def _mark_thread_unsafe_if_pytest_imported(f): return f +def _mark_no_check_spmatrix_if_pytest_imported(f): + pytest = sys.modules.get("pytest") + if pytest is not None: + return pytest.mark.no_check_spmatrix + else: + return f + + class CorrectNotFittedError(ValueError): """Exception class to raise if estimator is used before fitting. @@ -808,6 +816,7 @@ def test_check_estimator_not_fail_fast(): assert any(item["status"] == "passed" for item in check_results) +@_mark_no_check_spmatrix_if_pytest_imported # pickle breaks check_spmatrix # Some estimator checks rely on warnings in deep functions calls. This is not # automatically detected by pytest-run-parallel shallow AST inspection, so we # need to mark the test function as thread-unsafe. @@ -912,6 +921,7 @@ def test_check_estimator_transformer_no_mixin(): check_estimator(BadTransformerWithoutMixin()) +@_mark_no_check_spmatrix_if_pytest_imported # pickle breaks check_spmatrix def test_check_estimator_clones(): # check that check_estimator doesn't modify the estimator it receives @@ -1727,7 +1737,15 @@ def test_estimator_with_set_output(): "check_array_api_input": ( "this check is expected to fail because pandas and polars" " are not compatible with the array api." - ) + ), + "check_array_api_mixed_inputs": ( + "this check is expected to fail because pandas and polars" + " are not compatible with the array api." + ), + "check_array_api_same_namespace": ( + "this check is expected to fail because pandas and polars" + " are not compatible with the array api." + ), }, ) diff --git a/sklearn/utils/tests/test_extmath.py b/sklearn/utils/tests/test_extmath.py index 5f3627972346f..228587a66d66e 100644 --- a/sklearn/utils/tests/test_extmath.py +++ b/sklearn/utils/tests/test_extmath.py @@ -14,10 +14,9 @@ from sklearn.utils import gen_batches from sklearn.utils._arpack import _init_arpack_v0 from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, _max_precision_float_dtype, get_namespace, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._array_api import ( @@ -56,6 +55,7 @@ DOK_CONTAINERS, LIL_CONTAINERS, _mode, + _sparse_random_array, ) @@ -512,7 +512,7 @@ def test_randomized_svd_sparse_warnings(sparse_container): X = sparse_container(X) warn_msg = ( - "Calculating SVD of a {} is expensive. csr_matrix is more efficient.".format( + "Calculating SVD of a {} is expensive. CSR format is more efficient.".format( sparse_container.__name__ ) ) @@ -702,18 +702,17 @@ def test_incremental_weighted_mean_and_variance_simple(dtype, as_list): @pytest.mark.parametrize( - "array_namespace, device, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) def test_incremental_weighted_mean_and_variance_array_api( - array_namespace, device, dtype + array_namespace, device_name, dtype_name ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(42) mult = 10 - X = rng.rand(1000, 20).astype(dtype) * mult - sample_weight = rng.rand(X.shape[0]).astype(dtype) * mult + X = rng.rand(1000, 20).astype(dtype_name) * mult + sample_weight = rng.rand(X.shape[0]).astype(dtype_name) * mult mean, var, _ = _incremental_mean_and_var(X, 0, 0, 0, sample_weight=sample_weight) X_xp = xp.asarray(X, device=device) @@ -731,8 +730,8 @@ def test_incremental_weighted_mean_and_variance_array_api( assert array_device(var_xp) == array_device(X_xp) assert var_xp.dtype == _max_precision_float_dtype(xp, device=device) - mean_xp = _convert_to_numpy(mean_xp, xp=xp) - var_xp = _convert_to_numpy(var_xp, xp=xp) + mean_xp = move_to(mean_xp, xp=np, device="cpu") + var_xp = move_to(var_xp, xp=np, device="cpu") assert_allclose(mean, mean_xp) assert_allclose(var, var_xp) @@ -1072,8 +1071,8 @@ def test_safe_sparse_dot_2d_1d(container): def test_safe_sparse_dot_dense_output(dense_output): rng = np.random.RandomState(0) - A = sparse.random(30, 10, density=0.1, random_state=rng) - B = sparse.random(10, 20, density=0.1, random_state=rng) + A = _sparse_random_array((30, 10), density=0.1, rng=rng) + B = _sparse_random_array((10, 20), density=0.1, rng=rng) expected = A.dot(B) actual = safe_sparse_dot(A, B, dense_output=dense_output) @@ -1103,18 +1102,17 @@ def test_approximate_mode(): @pytest.mark.parametrize( - "array_namespace, device, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_randomized_svd_array_api_compliance(array_namespace, device, dtype): - xp = _array_api_for_tests(array_namespace, device) +def test_randomized_svd_array_api_compliance(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) - X = rng.normal(size=(30, 10)).astype(dtype) + X = rng.normal(size=(30, 10)).astype(dtype_name) X_xp = xp.asarray(X, device=device) n_components = 5 - atol = 1e-5 if dtype == "float32" else 0 + atol = 1e-5 if dtype_name == "float32" else 0 with config_context(array_api_dispatch=True): u_np, s_np, vt_np = randomized_svd(X, n_components, random_state=0) @@ -1124,29 +1122,30 @@ def test_randomized_svd_array_api_compliance(array_namespace, device, dtype): assert get_namespace(s_xp)[0].__name__ == xp.__name__ assert get_namespace(vt_xp)[0].__name__ == xp.__name__ - assert_allclose(_convert_to_numpy(u_xp, xp), u_np, atol=atol) - assert_allclose(_convert_to_numpy(s_xp, xp), s_np, atol=atol) - assert_allclose(_convert_to_numpy(vt_xp, xp), vt_np, atol=atol) + assert_allclose(move_to(u_xp, xp=np, device="cpu"), u_np, atol=atol) + assert_allclose(move_to(s_xp, xp=np, device="cpu"), s_np, atol=atol) + assert_allclose(move_to(vt_xp, xp=np, device="cpu"), vt_np, atol=atol) @pytest.mark.parametrize( - "array_namespace, device, dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_randomized_range_finder_array_api_compliance(array_namespace, device, dtype): - xp = _array_api_for_tests(array_namespace, device) +def test_randomized_range_finder_array_api_compliance( + array_namespace, device_name, dtype_name +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) rng = np.random.RandomState(0) - X = rng.normal(size=(30, 10)).astype(dtype) + X = rng.normal(size=(30, 10)).astype(dtype_name) X_xp = xp.asarray(X, device=device) size = 5 n_iter = 10 - atol = 1e-5 if dtype == "float32" else 0 + atol = 1e-5 if dtype_name == "float32" else 0 with config_context(array_api_dispatch=True): Q_np = randomized_range_finder(X, size=size, n_iter=n_iter, random_state=0) Q_xp = randomized_range_finder(X_xp, size=size, n_iter=n_iter, random_state=0) assert get_namespace(Q_xp)[0].__name__ == xp.__name__ - assert_allclose(_convert_to_numpy(Q_xp, xp), Q_np, atol=atol) + assert_allclose(move_to(Q_xp, xp=np, device="cpu"), Q_np, atol=atol) diff --git a/sklearn/utils/tests/test_fixes.py b/sklearn/utils/tests/test_fixes.py index 2aa370df705a3..5fc5e415d0145 100644 --- a/sklearn/utils/tests/test_fixes.py +++ b/sklearn/utils/tests/test_fixes.py @@ -3,9 +3,14 @@ import numpy as np import pytest +import scipy as sp from sklearn.utils._testing import assert_array_equal -from sklearn.utils.fixes import _object_dtype_isnan, _smallest_admissible_index_dtype +from sklearn.utils.fixes import ( + _ensure_sparse_index_int32, + _object_dtype_isnan, + _smallest_admissible_index_dtype, +) @pytest.mark.parametrize("dtype, val", ([object, 1], [object, "a"], [float, 1])) @@ -158,3 +163,40 @@ def test_smallest_admissible_index_dtype_error(params, err_type, err_msg): """Check that we raise the proper error message.""" with pytest.raises(err_type, match=err_msg): _smallest_admissible_index_dtype(**params) + + +INDEX_CONSTRUCTORS = [ + sp.sparse.csc_array, + sp.sparse.csr_array, + sp.sparse.coo_array, + sp.sparse.csc_matrix, + sp.sparse.csr_matrix, + sp.sparse.coo_matrix, +] +NO_INDEX_TEST_CONSTRUCTORS = [ + sp.sparse.bsr_array, + sp.sparse.bsr_matrix, + sp.sparse.dia_array, + sp.sparse.dok_array, + sp.sparse.lil_array, + sp.sparse.dia_matrix, + sp.sparse.dok_matrix, + sp.sparse.lil_matrix, +] +SPARSE_CONSTRUCTORS = INDEX_CONSTRUCTORS + NO_INDEX_TEST_CONSTRUCTORS + + +@pytest.mark.parametrize("constructor", SPARSE_CONSTRUCTORS) +def test_ensure_sparse_index_int32(constructor): + A = constructor(np.array([[1.0, 2.0, 3.0], [3.0, 2.0, 1.0]])) + _ensure_sparse_index_int32(A) + + +@pytest.mark.parametrize("constructor", INDEX_CONSTRUCTORS) +def test_ensure_int32_raises(constructor): + with pytest.raises(ValueError, match="too large"): + rows, cols = [2, 0], [1, np.iinfo(np.int32).max + 1] + if "csc" in constructor.__name__: + rows, cols = cols, rows + A = sp.sparse.coo_array(([1.0, 2.0], (rows, cols))) + _ensure_sparse_index_int32(constructor(A)) diff --git a/sklearn/utils/tests/test_indexing.py b/sklearn/utils/tests/test_indexing.py index 8934b5ef5a98d..7426994d78f58 100644 --- a/sklearn/utils/tests/test_indexing.py +++ b/sklearn/utils/tests/test_indexing.py @@ -4,13 +4,17 @@ import numpy as np import pytest +from narwhals.exceptions import DuplicateError from scipy.stats import kstest import sklearn from sklearn.externals._packaging.version import parse as parse_version from sklearn.utils import _safe_indexing, resample, shuffle from sklearn.utils._array_api import ( - _get_namespace_device_dtype_ids, + device as array_api_device, +) +from sklearn.utils._array_api import ( + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._indexing import ( @@ -22,6 +26,7 @@ from sklearn.utils._testing import ( _array_api_for_tests, _convert_container, + assert_allclose, assert_allclose_dense_sparse, assert_array_equal, skip_if_array_api_compat_not_configured, @@ -108,22 +113,21 @@ def test_determine_key_type_slice_error(): @skip_if_array_api_compat_not_configured @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_determine_key_type_array_api(array_namespace, device, dtype_name): - xp = _array_api_for_tests(array_namespace, device) +def test_determine_key_type_array_api(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) with sklearn.config_context(array_api_dispatch=True): - int_array_key = xp.asarray([1, 2, 3]) + int_array_key = xp.asarray([1, 2, 3], device=device) assert _determine_key_type(int_array_key) == "int" - bool_array_key = xp.asarray([True, False, True]) + bool_array_key = xp.asarray([True, False, True], device=device) assert _determine_key_type(bool_array_key) == "bool" try: - complex_array_key = xp.asarray([1 + 1j, 2 + 2j, 3 + 3j]) + complex_array_key = xp.asarray([1 + 1j, 2 + 2j, 3 + 3j], device=device) except TypeError: # Complex numbers are not supported by all Array API libraries. complex_array_key = None @@ -133,8 +137,43 @@ def test_determine_key_type_array_api(array_namespace, device, dtype_name): _determine_key_type(complex_array_key) +@skip_if_array_api_compat_not_configured +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) @pytest.mark.parametrize( - "array_type", ["list", "array", "sparse", "dataframe", "polars", "pyarrow"] + "indexing_key", + ( + 0, + -1, + [1, 3], + np.array([1, 3]), + slice(1, 2), + [True, False, True, True], + np.asarray([False, False, False, False]), + ), +) +@pytest.mark.parametrize("axis", [0, 1]) +def test_safe_indexing_array_api_support( + array_namespace, device_name, dtype_name, indexing_key, axis +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) + + array_to_index_np = np.arange(16).reshape(4, 4) + expected_result = _safe_indexing(array_to_index_np, indexing_key, axis=axis) + array_to_index_xp = move_to(array_to_index_np, xp=xp, device=device) + + with sklearn.config_context(array_api_dispatch=True): + indexed_array_xp = _safe_indexing(array_to_index_xp, indexing_key, axis=axis) + assert array_api_device(indexed_array_xp) == array_api_device(array_to_index_xp) + assert indexed_array_xp.dtype == array_to_index_xp.dtype + + assert_allclose(move_to(indexed_array_xp, xp=np, device="cpu"), expected_result) + + +@pytest.mark.parametrize( + "array_type", ["list", "array", "sparse", "pandas", "polars", "pyarrow"] ) @pytest.mark.parametrize("indices_type", ["list", "tuple", "array", "series", "slice"]) def test_safe_indexing_2d_container_axis_0(array_type, indices_type): @@ -164,7 +203,7 @@ def test_safe_indexing_1d_container(array_type, indices_type): @pytest.mark.parametrize( - "array_type", ["array", "sparse", "dataframe", "polars", "pyarrow"] + "array_type", ["array", "sparse", "pandas", "polars", "pyarrow"] ) @pytest.mark.parametrize("indices_type", ["list", "tuple", "array", "series", "slice"]) @pytest.mark.parametrize("indices", [[1, 2], ["col_1", "col_2"]]) @@ -175,9 +214,9 @@ def test_safe_indexing_2d_container_axis_1(array_type, indices_type, indices): if indices_type == "slice" and isinstance(indices[1], int): indices_converted[1] += 1 - columns_name = ["col_0", "col_1", "col_2"] + column_names = ["col_0", "col_1", "col_2"] array = _convert_container( - [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, columns_name + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, column_names ) indices_converted = _convert_container(indices_converted, indices_type) @@ -197,7 +236,7 @@ def test_safe_indexing_2d_container_axis_1(array_type, indices_type, indices): @pytest.mark.parametrize("array_read_only", [True, False]) @pytest.mark.parametrize("indices_read_only", [True, False]) @pytest.mark.parametrize( - "array_type", ["array", "sparse", "dataframe", "polars", "pyarrow"] + "array_type", ["array", "sparse", "pandas", "polars", "pyarrow"] ) @pytest.mark.parametrize("indices_type", ["array", "series"]) @pytest.mark.parametrize( @@ -231,7 +270,7 @@ def test_safe_indexing_1d_container_mask(array_type, indices_type): @pytest.mark.parametrize( - "array_type", ["array", "sparse", "dataframe", "polars", "pyarrow"] + "array_type", ["array", "sparse", "pandas", "polars", "pyarrow"] ) @pytest.mark.parametrize("indices_type", ["list", "tuple", "array", "series"]) @pytest.mark.parametrize( @@ -239,9 +278,9 @@ def test_safe_indexing_1d_container_mask(array_type, indices_type): [(0, [[4, 5, 6], [7, 8, 9]]), (1, [[2, 3], [5, 6], [8, 9]])], ) def test_safe_indexing_2d_mask(array_type, indices_type, axis, expected_subset): - columns_name = ["col_0", "col_1", "col_2"] + column_names = ["col_0", "col_1", "col_2"] array = _convert_container( - [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, columns_name + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, column_names ) indices = [False, True, True] indices = _convert_container(indices, indices_type) @@ -258,7 +297,7 @@ def test_safe_indexing_2d_mask(array_type, indices_type, axis, expected_subset): ("list", "list"), ("array", "array"), ("sparse", "sparse"), - ("dataframe", "series"), + ("pandas", "series"), ("polars", "polars_series"), ("pyarrow", "pyarrow_array"), ], @@ -286,16 +325,16 @@ def test_safe_indexing_1d_scalar(array_type): [ ("array", "array"), ("sparse", "sparse"), - ("dataframe", "series"), + ("pandas", "series"), ("polars", "polars_series"), ("pyarrow", "pyarrow_array"), ], ) @pytest.mark.parametrize("indices", [2, "col_2"]) def test_safe_indexing_2d_scalar_axis_1(array_type, expected_output_type, indices): - columns_name = ["col_0", "col_1", "col_2"] + column_names = ["col_0", "col_1", "col_2"] array = _convert_container( - [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, columns_name + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], array_type, column_names ) if isinstance(indices, str) and array_type in ("array", "sparse"): @@ -401,7 +440,7 @@ def test_safe_indexing_list_axis_1_unsupported(indices): _safe_indexing(X, indices, axis=1) -@pytest.mark.parametrize("array_type", ["array", "sparse", "dataframe"]) +@pytest.mark.parametrize("array_type", ["array", "sparse", "pandas"]) def test_safe_assign(array_type): """Check that `_safe_assign` works as expected.""" rng = np.random.RandomState(0) @@ -439,7 +478,10 @@ def test_safe_assign(array_type): "key, err_msg", [ (10, r"all features must be in \[0, 2\]"), - ("whatever", "A given column is not a column of the dataframe"), + ( + "whatever", + r"Some column names are not columns of the dataframe: \{'whatever'\}", + ), (object(), "No valid specification of the columns"), ], ) @@ -460,40 +502,47 @@ def test_get_column_indices_pandas_nonunique_columns_error(key): columns = ["col1", "col1", "col2", "col3", "col2"] X = pd.DataFrame(toy, columns=columns) - err_msg = "Selected columns, {}, are not unique in dataframe".format(key) - with pytest.raises(ValueError) as exc_info: + err_msg = "Expected unique column names, got.*" + with pytest.raises(DuplicateError, match=err_msg): _get_column_indices(X, key) - assert str(exc_info.value) == err_msg - -def test_get_column_indices_interchange(): - """Check _get_column_indices for edge cases with the interchange""" - pl = pytest.importorskip("polars") - # Polars dataframes go down the interchange path. - df = pl.DataFrame([[1, 2, 3], [4, 5, 6]], schema=["a", "b", "c"]) +@pytest.mark.parametrize( + "constructor_name", ["array", "sparse", "pandas", "pyarrow", "polars"] +) +def test_get_column_indices_dataframes(constructor_name): + """Check _get_column_indices for edge cases with 2d input X.""" + df = _convert_container( + [[1, 2, 3], [4, 5, 6]], constructor_name, column_names=["a", "b", "c"] + ) key_results = [ - (slice(1, None), [1, 2]), - (slice(None, 2), [0, 1]), - (slice(1, 2), [1]), - (["b", "c"], [1, 2]), - (slice("a", "b"), [0, 1]), - (slice("a", None), [0, 1, 2]), - (slice(None, "a"), [0]), - (["c", "a"], [2, 0]), - ([], []), + (slice(1, None), [1, 2], False), + (slice(None, 2), [0, 1], False), + (slice(1, 2), [1], False), + (["b", "c"], [1, 2], True), + (slice("a", "b"), [0, 1], True), + (slice("a", None), [0, 1, 2], True), + (slice(None, "a"), [0], True), + (["c", "a"], [2, 0], True), + ([], [], False), ] - for key, result in key_results: - assert _get_column_indices(df, key) == result - - msg = "A given column is not a column of the dataframe" - with pytest.raises(ValueError, match=msg): - _get_column_indices(df, ["not_a_column"]) - - msg = "key.step must be 1 or None" - with pytest.raises(NotImplementedError, match=msg): - _get_column_indices(df, slice("a", None, 2)) + msg = "Specifying the columns using strings is only supported for dataframes" + for key, result, use_names in key_results: + if constructor_name in ("array", "sparse") and use_names: + with pytest.raises(ValueError, match=msg): + _get_column_indices(df, key) + else: + assert _get_column_indices(df, key) == result + + if constructor_name not in ("array", "sparse"): + msg = r"Some column names are not columns of the dataframe: \{'not_a_column'\}" + with pytest.raises(ValueError, match=msg): + _get_column_indices(df, ["not_a_column"]) + + msg = "key.step must be 1 or None" + with pytest.raises(NotImplementedError, match=msg): + _get_column_indices(df, slice("a", None, 2)) def test_resample(): diff --git a/sklearn/utils/tests/test_mocking.py b/sklearn/utils/tests/test_mocking.py index bd143855e6dcd..f464688af0921 100644 --- a/sklearn/utils/tests/test_mocking.py +++ b/sklearn/utils/tests/test_mocking.py @@ -77,7 +77,7 @@ def test_check_X_on_predict_fail(iris, pred_func): getattr(clf, pred_func)(X) -@pytest.mark.parametrize("input_type", ["list", "array", "sparse", "dataframe"]) +@pytest.mark.parametrize("input_type", ["list", "array", "sparse", "pandas"]) def test_checking_classifier(iris, input_type): # Check that the CheckingClassifier outputs what we expect X, y = iris diff --git a/sklearn/utils/tests/test_multiclass.py b/sklearn/utils/tests/test_multiclass.py index 825258ac3ea6f..09400610aa6b6 100644 --- a/sklearn/utils/tests/test_multiclass.py +++ b/sklearn/utils/tests/test_multiclass.py @@ -9,7 +9,8 @@ from sklearn.model_selection import ShuffleSplit from sklearn.svm import SVC from sklearn.utils._array_api import ( - _get_namespace_device_dtype_ids, + _atol_for_type, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._testing import ( @@ -18,6 +19,7 @@ assert_allclose, assert_array_almost_equal, assert_array_equal, + skip_if_array_api_compat_not_configured, ) from sklearn.utils.estimator_checks import _NotAnArray from sklearn.utils.fixes import ( @@ -267,7 +269,7 @@ def _generate_sparse( def test_unique_labels(): # Empty iterable - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="No argument has been passed"): unique_labels() # Multiclass problem @@ -287,13 +289,62 @@ def test_unique_labels(): assert_array_equal(unique_labels((0, 1, 2), (0,), (2, 1)), np.arange(3)) # Border line case with binary indicator matrix - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Mix type of y not allowed"): unique_labels([4, 0, 2], np.ones((5, 5))) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Multi-label binary indicator input with"): unique_labels(np.ones((5, 4)), np.ones((5, 5))) assert_array_equal(unique_labels(np.ones((4, 5)), np.ones((5, 5))), np.arange(5)) + # Mixed label input types + with pytest.raises( + ValueError, match=r"Mix of label input types \(string and number\)" + ): + unique_labels([4, 0, 2], ["a", "b", "c"]) + with pytest.raises( + ValueError, match=r"Mix of label input types \(string and number\)" + ): + # Note string array is NOT object dtype, but string 'U' + unique_labels(np.array([4, 0, 2]), np.array(["a", "b", "c"])) + + +@skip_if_array_api_compat_not_configured +def test_unique_labels_mixed_str_numerical_array_api(): + """Test error is raised for mixed string and numerical input and dispatch enabled. + + Mixed string and numerical NumPy input with array API dispatch enabled should raise + the correct error. + """ + y_string = np.array(["a", "b", "a", "a"]) + y_object = np.array(["a", "b", "a", "a"], dtype=object) + y_numerical = np.array([1, 0, 0, 1]) + + with config_context(array_api_dispatch=True): + with pytest.raises(ValueError, match="Mix of label input types"): + unique_labels(y_string, y_numerical) + with pytest.raises(ValueError, match="Mix of label input types"): + unique_labels(y_object, y_numerical) + + +@pytest.mark.parametrize( + "array_namespace, device, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_unique_labels_array_api(array_namespace, device, dtype_name): + """Check `unique_labels` compliance for array API.""" + xp, device_ = _array_api_for_tests(array_namespace, device) + y1_np = np.array([1, 2, 3], dtype=dtype_name) + y2_np = np.array([2, 3, 4], dtype=dtype_name) + + y1_xp = xp.asarray(y1_np, device=device_) + y2_xp = xp.asarray(y2_np, device=device_) + + labels_np = unique_labels(y1_np, y2_np) + with config_context(array_api_dispatch=True): + labels_xp = unique_labels(y1_xp, y2_xp) + labels_xp_np = move_to(labels_xp, xp=np, device="cpu") + assert_allclose(labels_np, labels_xp_np, atol=_atol_for_type(dtype_name)) + def test_check_classification_targets_too_many_unique_classes(): """Check that we raise a warning when the number of unique classes is greater than @@ -404,12 +455,11 @@ def test_is_multilabel(): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_is_multilabel_array_api_compliance(array_namespace, device, dtype_name): - xp = _array_api_for_tests(array_namespace, device) +def test_is_multilabel_array_api_compliance(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) for group, group_examples in ARRAY_API_EXAMPLES.items(): dense_exp = group == "multilabel-indicator" diff --git a/sklearn/utils/tests/test_optimize.py b/sklearn/utils/tests/test_optimize.py index f99f3a9131808..87ce6ec751e09 100644 --- a/sklearn/utils/tests/test_optimize.py +++ b/sklearn/utils/tests/test_optimize.py @@ -4,9 +4,11 @@ import pytest from scipy.optimize import fmin_ncg +from sklearn import config_context from sklearn.exceptions import ConvergenceWarning +from sklearn.utils._array_api import move_to, yield_namespace_device_dtype_combinations from sklearn.utils._bunch import Bunch -from sklearn.utils._testing import assert_allclose +from sklearn.utils._testing import _array_api_for_tests, assert_allclose from sklearn.utils.optimize import _check_optimize_result, _newton_cg @@ -40,6 +42,39 @@ def grad_hess(x): ) +@pytest.mark.parametrize( + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), +) +def test_newton_cg_array_api_compliance(array_namespace, device_name, dtype_name): + """Test that newton_cg works with Array API input.""" + xp, device = _array_api_for_tests(array_namespace, device_name) + A = xp.asarray(np.array([[3, -1], [-1, 1]]).astype(dtype_name), device=device) + y = xp.asarray(np.arange(2).astype(dtype_name), device=device) + x0 = xp.asarray(np.ones(2).astype(dtype_name), device=device) + + def func(x): + return 0.5 * (y - A @ x) @ (y - A @ x) + + def grad(x): + return A.T @ (A @ x - y) + + def hess(x, p): + return A.T @ (A @ p) + + def grad_hess(x): + return grad(x), lambda p: hess(x, p) + + with config_context(array_api_dispatch=True): + res = _newton_cg(grad_hess, func, grad, x0, tol=1e-10) + + assert_allclose( + move_to(res[0], xp=np, device="cpu"), + [1 / 2, 3 / 2], + atol=1e-10, + ) + + @pytest.mark.parametrize("verbose", [0, 1, 2]) def test_newton_cg_verbosity(capsys, verbose): """Test the std output of verbose newton_cg solver.""" @@ -85,7 +120,7 @@ def test_newton_cg_verbosity(capsys, verbose): b = np.array([1.0, 2.0]) # Note that scipy.optimize._linesearch LineSearchWarning inherits from # RuntimeWarning, but we do not want to import from non public APIs. - with pytest.warns(RuntimeWarning): + with pytest.warns((RuntimeWarning, UserWarning)): _newton_cg( grad_hess=lambda x: (A @ x - b, lambda z: A @ z), func=lambda x: 0.5 * x @ A @ x - b @ x, @@ -128,7 +163,7 @@ def test_newton_cg_verbosity(capsys, verbose): # curvature", but that is very hard to trigger. A = np.eye(2) b = np.array([-2.0, 1]) - with pytest.warns(RuntimeWarning): + with pytest.warns((RuntimeWarning, UserWarning)): _newton_cg( # Note the wrong sign in the hessian product. grad_hess=lambda x: (A @ x - b, lambda z: -A @ z), diff --git a/sklearn/utils/tests/test_param_validation.py b/sklearn/utils/tests/test_param_validation.py index a47eaace5b9a2..06bd866eac9fc 100644 --- a/sklearn/utils/tests/test_param_validation.py +++ b/sklearn/utils/tests/test_param_validation.py @@ -2,7 +2,7 @@ import numpy as np import pytest -from scipy.sparse import csr_matrix +from scipy.sparse import csr_array, csr_matrix from sklearn._config import config_context, get_config from sklearn.base import BaseEstimator, _fit_context @@ -406,6 +406,7 @@ def test_generate_valid_param(constraint): ("array-like", [[1, 2], [3, 4]]), ("array-like", np.array([[1, 2], [3, 4]])), ("sparse matrix", csr_matrix([[1, 2], [3, 4]])), + ("sparse matrix", csr_array([[1, 2], [3, 4]])), *[ ("sparse matrix", container([[1, 2], [3, 4]])) for container in CSR_CONTAINERS diff --git a/sklearn/utils/tests/test_plotting.py b/sklearn/utils/tests/test_plotting.py index f7a585824ff84..be123ace869b9 100644 --- a/sklearn/utils/tests/test_plotting.py +++ b/sklearn/utils/tests/test_plotting.py @@ -196,7 +196,7 @@ def test_get_legend_label(curve_legend_metric, curve_name, expected_label): assert label == expected_label -# TODO(1.9) : Remove +# TODO: Remove once kwargs deprecated on all displays @pytest.mark.parametrize("curve_kwargs", [{"alpha": 1.0}, None]) @pytest.mark.parametrize("kwargs", [{}, {"alpha": 1.0}]) def test_validate_curve_kwargs_deprecate_kwargs(curve_kwargs, kwargs): @@ -266,8 +266,7 @@ def test_validate_curve_kwargs_error(): @pytest.mark.parametrize("name", [None, "curve_name", ["curve_name"]]) @pytest.mark.parametrize( - "legend_metric", - [{"mean": 0.8, "std": 0.2}, {"mean": None, "std": None}], + "legend_metric", [{"mean": 0.8, "std": 0.2}, {"mean": None, "std": None}] ) @pytest.mark.parametrize("legend_metric_name", ["AUC", "AP"]) @pytest.mark.parametrize("curve_kwargs", [None, {"color": "red"}]) @@ -439,7 +438,7 @@ def test_validate_score_name(score_name, scoring, negate_score, expected_score_n ([1, 2, 5, 10, 20, 50], 20, 40), ], ) -def test_inverval_max_min_ratio(data, lower_bound, upper_bound): +def test_interval_max_min_ratio(data, lower_bound, upper_bound): assert lower_bound < _interval_max_min_ratio(data) < upper_bound diff --git a/sklearn/utils/tests/test_pprint.py b/sklearn/utils/tests/test_pprint.py index c8b2d9d195681..cb5eb7fffca60 100644 --- a/sklearn/utils/tests/test_pprint.py +++ b/sklearn/utils/tests/test_pprint.py @@ -280,7 +280,13 @@ def test_changed_only(): assert imputer.__repr__() == expected # make sure array parameters don't throw error (see #13583) - repr(LogisticRegressionCV(Cs=np.array([0.1, 1]), use_legacy_attributes=False)) + repr( + LogisticRegressionCV( + Cs=np.array([0.1, 1]), + use_legacy_attributes=False, + scoring="neg_log_loss", # TODO(1.11): remove because it is default now + ) + ) @config_context(print_changed_only=False) diff --git a/sklearn/utils/tests/test_response.py b/sklearn/utils/tests/test_response.py index 273279357e11c..869901e350227 100644 --- a/sklearn/utils/tests/test_response.py +++ b/sklearn/utils/tests/test_response.py @@ -4,23 +4,19 @@ import pytest from sklearn.base import clone +from sklearn.cluster import DBSCAN, KMeans from sklearn.datasets import ( load_iris, make_classification, make_multilabel_classification, - make_regression, ) from sklearn.ensemble import IsolationForest -from sklearn.linear_model import ( - LinearRegression, - LogisticRegression, -) +from sklearn.linear_model import LinearRegression, LogisticRegression from sklearn.multioutput import ClassifierChain from sklearn.preprocessing import scale from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor -from sklearn.utils._mocking import _MockEstimatorOnOffPrediction from sklearn.utils._response import _get_response_values, _get_response_values_binary -from sklearn.utils._testing import assert_allclose, assert_array_equal +from sklearn.utils._testing import assert_allclose X, y = load_iris(return_X_y=True) # scale the data to avoid ConvergenceWarning with LogisticRegression @@ -29,48 +25,51 @@ @pytest.mark.parametrize( - "response_method", ["decision_function", "predict_proba", "predict_log_proba"] + "estimator, response_method", + [ + (DecisionTreeRegressor(), "predict_proba"), + (DecisionTreeRegressor(), ["predict_proba", "decision_function"]), + (KMeans(n_clusters=2), "predict_proba"), + (KMeans(n_clusters=2), ["predict_proba", "decision_function"]), + (DBSCAN(), "predict"), + (IsolationForest(), "predict_proba"), + (IsolationForest(), ["predict_proba", "score"]), + ], ) -def test_get_response_values_regressor_error(response_method): - """Check the error message with regressor an not supported response - method.""" - my_estimator = _MockEstimatorOnOffPrediction(response_methods=[response_method]) - X = "mocking_data", "mocking_target" - err_msg = f"{my_estimator.__class__.__name__} should either be a classifier" - with pytest.raises(ValueError, match=err_msg): - _get_response_values(my_estimator, X, response_method=response_method) - - -@pytest.mark.parametrize("return_response_method_used", [True, False]) -def test_get_response_values_regressor(return_response_method_used): - """Check the behaviour of `_get_response_values` with regressor.""" - X, y = make_regression(n_samples=10, random_state=0) - regressor = LinearRegression().fit(X, y) - results = _get_response_values( - regressor, - X, - response_method="predict", - return_response_method_used=return_response_method_used, - ) - assert_array_equal(results[0], regressor.predict(X)) - assert results[1] is None - if return_response_method_used: - assert results[2] == "predict" +def test_estimator_unsupported_response(estimator, response_method): + """Check the error message with not supported response method.""" + X, y = np.random.RandomState(0).randn(10, 2), np.array([0, 1] * 5) + estimator = clone(estimator).fit(X, y) # clone to make test execution thread-safe + err_msg = "has none of the following attributes:" + with pytest.raises(AttributeError, match=err_msg): + _get_response_values( + estimator, + X, + response_method=response_method, + ) @pytest.mark.parametrize( - "response_method", - ["predict", "decision_function", ["decision_function", "predict"]], + "estimator, response_method", + [ + (LinearRegression(), "predict"), + (KMeans(n_clusters=2, random_state=0), "predict"), + (KMeans(n_clusters=2, random_state=0), "score"), + (KMeans(n_clusters=2, random_state=0), ["predict", "score"]), + (IsolationForest(random_state=0), "predict"), + (IsolationForest(random_state=0), "decision_function"), + (IsolationForest(random_state=0), ["decision_function", "predict"]), + ], ) @pytest.mark.parametrize("return_response_method_used", [True, False]) -def test_get_response_values_outlier_detection( - response_method, return_response_method_used +def test_estimator_get_response_values( + estimator, response_method, return_response_method_used ): - """Check the behaviour of `_get_response_values` with outlier detector.""" - X, y = make_classification(n_samples=50, random_state=0) - outlier_detector = IsolationForest(random_state=0).fit(X, y) + """Check the behaviour of `_get_response_values`.""" + X, y = np.random.RandomState(0).randn(10, 2), np.array([0, 1] * 5) + estimator = clone(estimator).fit(X, y) # clone to make test execution thread-safe results = _get_response_values( - outlier_detector, + estimator, X, response_method=response_method, return_response_method_used=return_response_method_used, @@ -78,8 +77,8 @@ def test_get_response_values_outlier_detection( chosen_response_method = ( response_method[0] if isinstance(response_method, list) else response_method ) - prediction_method = getattr(outlier_detector, chosen_response_method) - assert_array_equal(results[0], prediction_method(X)) + prediction_method = getattr(estimator, chosen_response_method) + assert_allclose(results[0], prediction_method(X)) assert results[1] is None if return_response_method_used: assert results[2] == chosen_response_method @@ -311,8 +310,7 @@ def test_get_response_values_multiclass(estimator, response_method): """Check that we can call `_get_response_values` with a multiclass estimator. It should return the predictions untouched. """ - estimator = clone(estimator) - estimator.fit(X, y) + estimator = clone(estimator).fit(X, y) # clone to make test execution thread-safe predictions, pos_label = _get_response_values( estimator, X, response_method=response_method ) @@ -394,3 +392,57 @@ def test_response_values_type_of_target_on_classes_no_warning(): warnings.simplefilter("error", UserWarning) _get_response_values(clf, X, response_method="predict_proba") + + +@pytest.mark.parametrize( + "estimator, response_method, target_type, expected_shape", + [ + (LogisticRegression(), "predict", "binary", (10,)), + (LogisticRegression(), "predict_proba", "binary", (10,)), + (LogisticRegression(), "decision_function", "binary", (10,)), + (LogisticRegression(), "predict", "multiclass", (10,)), + (LogisticRegression(), "predict_proba", "multiclass", (10, 4)), + (LogisticRegression(), "decision_function", "multiclass", (10, 4)), + (ClassifierChain(LogisticRegression()), "predict", "multilabel", (10, 2)), + (ClassifierChain(LogisticRegression()), "predict_proba", "multilabel", (10, 2)), + ( + ClassifierChain(LogisticRegression()), + "decision_function", + "multilabel", + (10, 2), + ), + (IsolationForest(), "predict", "binary", (10,)), + (IsolationForest(), "predict", "multiclass", (10,)), + (DecisionTreeRegressor(), "predict", "binary", (10,)), + (DecisionTreeRegressor(), "predict", "multiclass", (10,)), + (KMeans(n_clusters=2), "predict", "binary", (10,)), + (KMeans(n_clusters=4), "predict", "multiclass", (10,)), + ], +) +def test_response_values_output_shape_( + estimator, response_method, target_type, expected_shape +): + """ + Check that output shape corresponds to docstring description + + - for binary classification, it is a 1d array of shape `(n_samples,)`; + - for multiclass classification + - with response_method="predict", it is a 1d array of shape `(n_samples,)`; + - otherwise, it is a 2d array of shape `(n_samples, n_classes)`; + - for multilabel classification, it is a 2d array of shape `(n_samples, n_outputs)`; + - for outlier detection, regression and clustering, + it is a 1d array of shape `(n_samples,)`. + """ + X = np.random.RandomState(0).randn(10, 2) + if target_type == "binary": + y = np.array([0, 1] * 5) + elif target_type == "multiclass": + y = [0, 1, 2, 3, 0, 1, 2, 3, 3, 0] + else: # multilabel + y = np.array([[0, 1], [1, 0]] * 5) + + estimator = clone(estimator).fit(X, y) # clone to make test execution thread-safe + + y_pred, _ = _get_response_values(estimator, X, response_method=response_method) + + assert y_pred.shape == expected_shape diff --git a/sklearn/utils/tests/test_sorting.py b/sklearn/utils/tests/test_sorting.py new file mode 100644 index 0000000000000..6bd25a2778878 --- /dev/null +++ b/sklearn/utils/tests/test_sorting.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +from sklearn.utils._sorting import _py_simultaneous_sort + + +@pytest.mark.parametrize("kind", ["2-way", "3-way"]) +def test_simultaneous_sort_correctness(kind): + rng = np.random.default_rng(0) + for x in [ + rng.uniform(size=3), + rng.uniform(size=10), + rng.uniform(size=1000), + # with duplicates: + rng.geometric(0.2, size=100).astype("float32"), + rng.integers(0, 2, size=1000).astype("float32"), + ]: + n = x.size + ind = np.arange(n, dtype=np.intp) + x_sorted = x.copy() + _py_simultaneous_sort(x_sorted, ind, n, use_three_way_partition=kind == "3-way") + assert (x_sorted[:-1] <= x_sorted[1:]).all() + assert_array_equal(x[ind], x_sorted) + assert_array_equal(np.sort(ind), np.arange(n, dtype=np.intp)) + + +@pytest.mark.parametrize("kind", ["2-way", "3-way"]) +def test_simultaneous_sort_no_stackoverflow(kind): + """Check that worst case inputs do not exceed the recursion stack limit.""" + n = 1_000_000 + # worst case pattern (i.e. triggers the quadratic path) + # for naive 2-way partitioning quicksort: + values = np.zeros(n) + indices = np.arange(n, dtype=np.intp) + _py_simultaneous_sort( + values, indices, values.shape[0], use_three_way_partition=kind == "3-way" + ) + + # worst case pattern for the better (numpy-style) 2-way partitioning: + values = np.roll(np.arange(n), -1).astype(np.float32) + indices = np.arange(n, dtype=np.intp) + _py_simultaneous_sort( + values, indices, values.shape[0], use_three_way_partition=kind == "3-way" + ) + + # worst case pattern for the 3-way partitioning quicksort + # with median-of-3 pivot: + k = n // 2 + values = np.array( + [i if i % 2 == 1 else k + i - 1 for i in range(1, k + 1)] + + [i for i in range(1, 2 * k + 1) if i % 2 == 0] + ).astype(np.float64) + # (very unlikely in real-world non-adversarial data) + indices = np.arange(n, dtype=np.intp) + assert values.size == indices.size + _py_simultaneous_sort( + values, indices, values.shape[0], use_three_way_partition=kind == "3-way" + ) diff --git a/sklearn/utils/tests/test_sparse.py b/sklearn/utils/tests/test_sparse.py new file mode 100644 index 0000000000000..32d6a1666d493 --- /dev/null +++ b/sklearn/utils/tests/test_sparse.py @@ -0,0 +1,78 @@ +import numpy as np +import pytest +from scipy.sparse import csc_array, csc_matrix, csr_array, csr_matrix + +import sklearn + + +@pytest.mark.parametrize( + ["sparse_interface", "x", "result_type"], + [ + ("sparray", csr_array([[1, 2, 3]]), csr_array), + ("sparray", csr_matrix([[1, 2, 3]]), csr_array), + ("sparray", csc_array([[1, 2, 3]]), csc_array), + ("sparray", csc_matrix([[1, 2, 3]]), csc_array), + ("spmatrix", csr_array([[1, 2, 3]]), csr_matrix), + ("spmatrix", csr_matrix([[1, 2, 3]]), csr_matrix), + ("spmatrix", csc_array([[1, 2, 3]]), csc_matrix), + ("spmatrix", csc_matrix([[1, 2, 3]]), csc_matrix), + ], +) +def test_align_api_if_sparse(sparse_interface, x, result_type): + with sklearn.config_context(sparse_interface=sparse_interface): + result = sklearn.utils._align_api_if_sparse(x) + assert isinstance(result, result_type) + + +@pytest.mark.parametrize( + ["sparse_interface", "x", "result_type"], + [ + ("sparray", np.array([[1, 2, 3]]), np.ndarray), + ("spmatrix", np.array([[1, 2, 3]]), np.ndarray), + ], +) +def test_ndarray_align_api_if_sparse(sparse_interface, x, result_type): + with sklearn.config_context(sparse_interface=sparse_interface): + result = sklearn.utils._align_api_if_sparse(x) + assert isinstance(result, result_type) + + +@pytest.mark.parametrize( + ["sparse_interface", "result_type"], + [("sparray", csr_array), ("spmatrix", csr_matrix)], +) +def test_transform_returns_sparse(sparse_interface, result_type): + corpus = [ + "This is the first document.", + "This document is the second document.", + "And this is the third one.", + "Is this the first document?", + ] + with sklearn.config_context(sparse_interface=sparse_interface): + vectorizer = sklearn.feature_extraction.text.CountVectorizer() + X = vectorizer.fit_transform(corpus) + assert isinstance(X, result_type) + + +@pytest.mark.parametrize( + ["sparse_interface", "result_type"], + [("sparray", csr_array), ("spmatrix", csr_matrix)], +) +def test_function_returns_sparse(sparse_interface, result_type): + with sklearn.config_context(sparse_interface=sparse_interface): + X, y = sklearn.datasets.make_regression(n_features=2, random_state=0) + X = sklearn.manifold._locally_linear.barycenter_kneighbors_graph(X, 1) + assert isinstance(X, result_type) + + +@pytest.mark.parametrize( + ["sparse_interface", "result_type"], + [("sparray", csr_array), ("spmatrix", csr_matrix)], +) +def test_estimator_property_sparse(sparse_interface, result_type): + with sklearn.config_context(sparse_interface=sparse_interface): + X, y = sklearn.datasets.make_regression(n_features=2, random_state=0) + regr = sklearn.linear_model.ElasticNet(random_state=0) + regr.fit(X, y) + # check spec_coeff property + assert isinstance(regr.sparse_coef_, result_type) diff --git a/sklearn/utils/tests/test_sparsefuncs.py b/sklearn/utils/tests/test_sparsefuncs.py index 2753f48647a0c..2dff80f8e9ac1 100644 --- a/sklearn/utils/tests/test_sparsefuncs.py +++ b/sklearn/utils/tests/test_sparsefuncs.py @@ -7,7 +7,12 @@ from sklearn.datasets import make_classification from sklearn.utils._testing import assert_allclose -from sklearn.utils.fixes import CSC_CONTAINERS, CSR_CONTAINERS, LIL_CONTAINERS +from sklearn.utils.fixes import ( + CSC_CONTAINERS, + CSR_CONTAINERS, + LIL_CONTAINERS, + _sparse_random_array, +) from sklearn.utils.sparsefuncs import ( _implicit_column_offset, count_nonzero, @@ -437,15 +442,15 @@ def test_incr_mean_variance_axis_dim_mismatch(sparse_constructor): "X1, X2", [ ( - sp.random(5, 2, density=0.8, format="csr", random_state=0), - sp.random(13, 2, density=0.8, format="csr", random_state=0), + _sparse_random_array((5, 2), density=0.8, format="csr", rng=0), + _sparse_random_array((13, 2), density=0.8, format="csr", rng=0), ), ( - sp.random(5, 2, density=0.8, format="csr", random_state=0), + _sparse_random_array((5, 2), density=0.8, format="csr", rng=0), sp.hstack( [ np.full((13, 1), fill_value=np.nan), - sp.random(13, 1, density=0.8, random_state=42), + _sparse_random_array((13, 1), density=0.8, rng=42), ], format="csr", ), @@ -478,8 +483,8 @@ def test_incr_mean_variance_axis_equivalence_mean_variance(X1, X2, csr_container def test_incr_mean_variance_no_new_n(): # check the behaviour when we update the variance with an empty matrix axis = 0 - X1 = sp.random(5, 1, density=0.8, random_state=0).tocsr() - X2 = sp.random(0, 1, density=0.8, random_state=0).tocsr() + X1 = _sparse_random_array((5, 1), density=0.8, format="csr", rng=0) + X2 = _sparse_random_array((0, 1), density=0.8, format="csr", rng=0) last_mean, last_var = np.zeros(X1.shape[1]), np.zeros(X1.shape[1]) last_n = np.zeros(X1.shape[1], dtype=np.int64) last_mean, last_var, last_n = incr_mean_variance_axis( @@ -497,7 +502,7 @@ def test_incr_mean_variance_no_new_n(): def test_incr_mean_variance_n_float(): # check the behaviour when last_n is just a number axis = 0 - X = sp.random(5, 2, density=0.8, random_state=0).tocsr() + X = _sparse_random_array((5, 2), density=0.8, format="csr", rng=0) last_mean, last_var = np.zeros(X.shape[1]), np.zeros(X.shape[1]) last_n = 0 _, _, new_n = incr_mean_variance_axis( @@ -605,7 +610,7 @@ def test_densify_rows(csr_container): def test_inplace_column_scale(): rng = np.random.RandomState(0) - X = sp.random(100, 200, density=0.05) + X = _sparse_random_array((100, 200), density=0.05) Xr = X.tocsr() Xc = X.tocsc() XA = X.toarray() @@ -637,7 +642,7 @@ def test_inplace_column_scale(): def test_inplace_row_scale(): rng = np.random.RandomState(0) - X = sp.random(100, 200, density=0.05) + X = _sparse_random_array((100, 200), density=0.05) Xr = X.tocsr() Xc = X.tocsc() XA = X.toarray() @@ -910,7 +915,7 @@ def test_csc_row_median(csc_container, csr_container): ) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_inplace_normalize(csr_container, inplace_csr_row_normalize): - if csr_container is sp.csr_matrix: + if issubclass(sp.csr_matrix, csr_container): ones = np.ones((10, 1)) else: ones = np.ones(10) @@ -938,7 +943,7 @@ def test_inplace_normalize(csr_container, inplace_csr_row_normalize): def test_csr_row_norms(dtype): # checks that csr_row_norms returns the same output as # scipy.sparse.linalg.norm, and that the dype is the same as X.dtype. - X = sp.random(100, 10, format="csr", dtype=dtype, random_state=42) + X = _sparse_random_array((100, 10), format="csr", dtype=dtype, rng=42) scipy_norms = sp.linalg.norm(X, axis=1) ** 2 norms = csr_row_norms(X) @@ -953,10 +958,10 @@ def centered_matrices(request): """Returns equivalent tuple[sp.linalg.LinearOperator, np.ndarray].""" sparse_container = request.param - random_state = np.random.default_rng(42) + rng = np.random.default_rng(42) X_sparse = sparse_container( - sp.random(500, 100, density=0.1, format="csr", random_state=random_state) + _sparse_random_array((500, 100), density=0.1, format="csr", rng=rng) ) X_dense = X_sparse.toarray() mu = np.asarray(X_sparse.mean(axis=0)).ravel() diff --git a/sklearn/utils/tests/test_stats.py b/sklearn/utils/tests/test_stats.py index 830a08295024e..24df6fc1aed72 100644 --- a/sklearn/utils/tests/test_stats.py +++ b/sklearn/utils/tests/test_stats.py @@ -4,12 +4,12 @@ from pytest import approx from sklearn._config import config_context +from sklearn.utils._array_api import device as array_device from sklearn.utils._array_api import ( - _convert_to_numpy, get_namespace, + move_to, yield_namespace_device_dtype_combinations, ) -from sklearn.utils._array_api import device as array_device from sklearn.utils.estimator_checks import _array_api_for_tests from sklearn.utils.fixes import np_version, parse_version from sklearn.utils.stats import _weighted_percentile @@ -99,14 +99,12 @@ def test_weighted_percentile_equal(): assert approx(score) == 0 -# XXX: is this really what we want? Shouldn't we raise instead? -# https://github.com/scikit-learn/scikit-learn/issues/31032 def test_weighted_percentile_all_zero_weights(): - """Check `weighted_percentile` with all weights equal to 0 returns last index.""" + """Check `weighted_percentile` with all weights equal to 0 returns `np.nan`.""" y = np.arange(10) sw = np.zeros(10) value = _weighted_percentile(y, sw, 50) - assert approx(value) == 9.0 + assert np.isnan(value) @pytest.mark.parametrize("average", [True, False]) @@ -132,6 +130,13 @@ def test_weighted_percentile_ignores_zero_weight( assert approx(value[idx]) == expected_value +def test_weighted_percentile_average_zero_weight_plateau(): + """Check zero weights just before `max_index` handled correctly.""" + score_without_zeros = _weighted_percentile([1, 3], [3, 3], average=True) + score_with_zeros = _weighted_percentile([1, 2, 3], [3, 0, 3], average=True) + assert approx(score_without_zeros) == score_with_zeros + + @pytest.mark.parametrize("average", [True, False]) @pytest.mark.parametrize("percentile_rank", [20, 35, 50, 61]) def test_weighted_percentile_frequency_weight_semantics( @@ -254,7 +259,8 @@ def test_weighted_percentile_2d(global_random_seed, percentile_rank, average): @pytest.mark.parametrize( - "array_namespace, device, dtype_name", yield_namespace_device_dtype_combinations() + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) @pytest.mark.parametrize( "data, weights, percentile", @@ -284,10 +290,16 @@ def test_weighted_percentile_2d(global_random_seed, percentile_rank, average): ], ) def test_weighted_percentile_array_api_consistency( - global_random_seed, array_namespace, device, dtype_name, data, weights, percentile + global_random_seed, + array_namespace, + device_name, + dtype_name, + data, + weights, + percentile, ): """Check `_weighted_percentile` gives consistent results with array API.""" - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) # Skip test for percentile=0 edge case (#20528) on namespace/device where # xp.nextafter is broken. This is the case for torch with MPS device: @@ -312,7 +324,7 @@ def test_weighted_percentile_array_api_consistency( result_xp = _weighted_percentile(X_xp, weights_xp, percentile) assert array_device(result_xp) == array_device(X_xp) assert get_namespace(result_xp)[0] == get_namespace(X_xp)[0] - result_xp_np = _convert_to_numpy(result_xp, xp=xp) + result_xp_np = move_to(result_xp, xp=np, device="cpu") assert result_xp_np.dtype == result_np.dtype assert result_xp_np.shape == result_np.shape diff --git a/sklearn/utils/tests/test_tags.py b/sklearn/utils/tests/test_tags.py index 5d910537b26d7..073b8359803c4 100644 --- a/sklearn/utils/tests/test_tags.py +++ b/sklearn/utils/tests/test_tags.py @@ -1,3 +1,4 @@ +import re from dataclasses import dataclass, fields import numpy as np @@ -32,6 +33,21 @@ class EmptyRegressor(RegressorMixin, BaseEstimator): pass +def test_type_error_is_thrown_for_class_vs_instance(): + """Test that a clearer error is raised if a class is passed instead of an instance. + + Related to the discussion in + https://github.com/scikit-learn/scikit-learn/issues/32394#issuecomment-3375647854. + """ + estimator_class = EmptyClassifier + match = re.escape( + "Expected an estimator instance (EmptyClassifier()), " + "got estimator class instead (EmptyClassifier)." + ) + with pytest.raises(TypeError, match=match): + get_tags(estimator_class) + + @pytest.mark.parametrize( "estimator, value", [ diff --git a/sklearn/utils/tests/test_testing.py b/sklearn/utils/tests/test_testing.py index cc0094cf53f18..29bef9465051c 100644 --- a/sklearn/utils/tests/test_testing.py +++ b/sklearn/utils/tests/test_testing.py @@ -30,6 +30,8 @@ _IS_WASM, CSC_CONTAINERS, CSR_CONTAINERS, + _sparse_diags_array, + _sparse_random_array, ) from sklearn.utils.metaestimators import available_if @@ -57,7 +59,7 @@ def test_assert_allclose_dense_sparse(csr_container): with pytest.raises(ValueError, match="Can only compare two sparse"): assert_allclose_dense_sparse(x, y) - A = sparse.diags(np.ones(5), offsets=0).tocsr() + A = _sparse_diags_array(np.ones(5), offsets=0, format="csr") B = csr_container(np.ones((1, 5))) with pytest.raises(AssertionError, match="Arrays are not equal"): assert_allclose_dense_sparse(B, A) @@ -893,7 +895,7 @@ def test_create_memmap_backed_data(monkeypatch): # depending of the installed SciPy version *zip(["sparse_csr", "sparse_csr_array"], CSR_CONTAINERS), *zip(["sparse_csc", "sparse_csc_array"], CSC_CONTAINERS), - ("dataframe", lambda: pytest.importorskip("pandas").DataFrame), + ("pandas", lambda: pytest.importorskip("pandas").DataFrame), ("series", lambda: pytest.importorskip("pandas").Series), ("index", lambda: pytest.importorskip("pandas").Index), ("pyarrow", lambda: pytest.importorskip("pyarrow").Table), @@ -921,7 +923,7 @@ def test_convert_container( """Check that we convert the container to the right type of array with the right data type.""" if constructor_name in ( - "dataframe", + "pandas", "index", "polars", "polars_series", @@ -955,9 +957,7 @@ def test_convert_container( def test_convert_container_categories_pandas(): pytest.importorskip("pandas") - df = _convert_container( - [["x"]], "dataframe", ["A"], categorical_feature_names=["A"] - ) + df = _convert_container([["x"]], "pandas", ["A"], categorical_feature_names=["A"]) assert df.dtypes.iloc[0] == "category" @@ -1102,7 +1102,7 @@ def test_convert_container_sparse_to_sparse(constructor_name): """Non-regression test to check that we can still convert a sparse container from a given format to another format. """ - X_sparse = sparse.random(10, 10, density=0.1, format="csr") + X_sparse = _sparse_random_array((10, 10), density=0.1, format="csr") _convert_container(X_sparse, constructor_name) @@ -1119,6 +1119,9 @@ def check_warnings_as_errors(warning_info, warnings_as_errors): # Special treatment when regex is used if "Pyarrow" in message: message = "\nPyarrow will become a required dependency" + # Regex in _testing.py; emit the real Python 3.14 deprecation text. + elif message == r"codecs\.open\(\) is deprecated": + message = "codecs.open() is deprecated. Use open() instead." warnings.warn( message=message, diff --git a/sklearn/utils/tests/test_validation.py b/sklearn/utils/tests/test_validation.py index 3aafe4ce625b9..f74b0baa2eaab 100644 --- a/sklearn/utils/tests/test_validation.py +++ b/sklearn/utils/tests/test_validation.py @@ -10,11 +10,9 @@ import numpy as np import pytest import scipy.sparse as sp -from pytest import importorskip import sklearn from sklearn._config import config_context -from sklearn._min_dependencies import dependent_packages from sklearn.base import BaseEstimator from sklearn.datasets import make_blobs from sklearn.ensemble import RandomForestRegressor @@ -35,9 +33,8 @@ deprecated, ) from sklearn.utils._array_api import ( - _convert_to_numpy, - _get_namespace_device_dtype_ids, _is_numpy_namespace, + move_to, yield_namespace_device_dtype_combinations, ) from sklearn.utils._mocking import ( @@ -45,7 +42,6 @@ _MockEstimatorOnOffPrediction, ) from sklearn.utils._testing import ( - SkipTest, TempMemmap, _array_api_for_tests, _convert_container, @@ -62,10 +58,12 @@ CSR_CONTAINERS, DIA_CONTAINERS, DOK_CONTAINERS, + _sparse_random_array, ) from sklearn.utils.validation import ( FLOAT_DTYPES, _allclose_dense_sparse, + _check_categorical_features, _check_feature_names_in, _check_method_params, _check_pos_label_consistency, @@ -77,8 +75,6 @@ _estimator_has, _get_feature_names, _is_fitted, - _is_pandas_df, - _is_polars_df, _num_features, _num_samples, _to_object_array, @@ -147,6 +143,7 @@ def test_as_float_array(): # Test the copy parameter with some matrices matrices = [ sp.csc_matrix(np.arange(5)).toarray(), + sp.csc_array([np.arange(5)]).toarray(), _sparse_random_matrix(10, 10, density=0.10).toarray(), ] for M in matrices: @@ -156,7 +153,7 @@ def test_as_float_array(): @pytest.mark.parametrize( - "X", [np.random.random((10, 2)), sp.random(10, 2, format="csr")] + "X", [np.random.random((10, 2)), _sparse_random_array((10, 2), format="csr")] ) def test_as_float_array_nan(X): X = X.copy() @@ -172,6 +169,7 @@ def test_np_matrix(): assert not isinstance(as_float_array(X), np.matrix) assert not isinstance(as_float_array(sp.csc_matrix(X)), np.matrix) + assert not isinstance(as_float_array(sp.csc_array(X)), np.matrix) def test_memmap(): @@ -204,7 +202,7 @@ def test_ordering(): if copy: assert A is not B - X = sp.csr_matrix(X) + X = sp.csr_array(X) X.data = X.data[::-1] assert not X.data.flags["C_CONTIGUOUS"] @@ -213,7 +211,7 @@ def test_ordering(): "value, ensure_all_finite", [(np.inf, False), (np.nan, "allow-nan"), (np.nan, False)], ) -@pytest.mark.parametrize("retype", [np.asarray, sp.csr_matrix]) +@pytest.mark.parametrize("retype", [np.asarray, sp.csr_array, sp.csr_matrix]) def test_check_array_ensure_all_finite_valid(value, ensure_all_finite, retype): X = retype(np.arange(4).reshape(2, 2).astype(float)) X[0, 0] = value @@ -240,7 +238,7 @@ def test_check_array_ensure_all_finite_valid(value, ensure_all_finite, retype): (np.nan, "", 1, "Input contains NaN"), ], ) -@pytest.mark.parametrize("retype", [np.asarray, sp.csr_matrix]) +@pytest.mark.parametrize("retype", [np.asarray, sp.csr_array, sp.csr_matrix]) def test_check_array_ensure_all_finite_invalid( value, input_name, ensure_all_finite, match_msg, retype ): @@ -256,7 +254,7 @@ def test_check_array_ensure_all_finite_invalid( @pytest.mark.parametrize("input_name", ["X", "y", "sample_weight"]) -@pytest.mark.parametrize("retype", [np.asarray, sp.csr_matrix]) +@pytest.mark.parametrize("retype", [np.asarray, sp.csr_array, sp.csr_matrix]) def test_check_array_links_to_imputer_doc_only_for_X(input_name, retype): data = retype(np.arange(4).reshape(2, 2).astype(np.float64)) data[0, 0] = np.nan @@ -358,7 +356,7 @@ def test_check_array(): # accept_sparse == False # raise error on sparse inputs X = [[1, 2], [3, 4]] - X_csr = sp.csr_matrix(X) + X_csr = sp.csr_array(X) with pytest.raises(TypeError): check_array(X_csr) @@ -504,6 +502,40 @@ def test_check_array_numeric_error(X): check_array(X, dtype="numeric") +def test_check_array_pandas_string_dtype_numeric_error(): + """check_array raises an error for pandas StringDtype with dtype='numeric'. + + Non-regression test for pandas 3 where string columns use StringDtype + instead of object dtype. check_array should reject string data when + dtype='numeric' is requested. + """ + pd = pytest.importorskip("pandas") + + # DataFrame with all string columns + df_str = pd.DataFrame({"a": ["x", "y", "z"], "b": ["1", "2", "3"]}) + with pytest.raises(ValueError): + check_array(df_str, dtype="numeric") + + # DataFrame with mixed string/numeric columns + df_mixed = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": ["x", "y", "z"]}) + with pytest.raises(ValueError): + check_array(df_mixed, dtype="numeric") + + # Series with string dtype + s_str = pd.Series(["a", "b", "c"]) + with pytest.raises(ValueError): + check_array(s_str, dtype="numeric", ensure_2d=False) + + # String data with dtype=None should still work + result = check_array(df_str, dtype=None) + assert result.dtype == np.object_ + assert_array_equal(result, df_str.values) + + result = check_array(s_str, dtype=None, ensure_2d=False) + assert result.dtype == np.object_ + assert_array_equal(result, s_str.values) + + @pytest.mark.parametrize( "pd_dtype", ["Int8", "Int16", "UInt8", "UInt16", "Float32", "Float64"] ) @@ -622,9 +654,9 @@ def test_check_array_dtype_warning(): X_int_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] X_float32 = np.asarray(X_int_list, dtype=np.float32) X_int64 = np.asarray(X_int_list, dtype=np.int64) - X_csr_float32 = sp.csr_matrix(X_float32) - X_csc_float32 = sp.csc_matrix(X_float32) - X_csc_int32 = sp.csc_matrix(X_int64, dtype=np.int32) + X_csr_float32 = sp.csr_array(X_float32) + X_csc_float32 = sp.csc_array(X_float32) + X_csc_int32 = sp.csc_array(X_int64, dtype=np.int32) integer_data = [X_int64, X_csc_int32] float32_data = [X_float32, X_csr_float32, X_csc_float32] with warnings.catch_warnings(): @@ -663,7 +695,7 @@ def test_check_array_dtype_warning(): def test_check_array_accept_sparse_type_exception(): X = [[1, 2], [3, 4]] - X_csr = sp.csr_matrix(X) + X_csr = sp.csr_array(X) invalid_type = SVR() msg = ( @@ -694,7 +726,7 @@ def test_check_array_accept_sparse_type_exception(): def test_check_array_accept_sparse_no_exception(): X = [[1, 2], [3, 4]] - X_csr = sp.csr_matrix(X) + X_csr = sp.csr_array(X) check_array(X_csr, accept_sparse=True) check_array(X_csr, accept_sparse="csr") @@ -704,7 +736,7 @@ def test_check_array_accept_sparse_no_exception(): @pytest.fixture(params=["csr", "csc", "coo", "bsr"]) def X_64bit(request): - X = sp.random(20, 10, format=request.param) + X = _sparse_random_array((20, 10), format=request.param) if request.param == "coo": if hasattr(X, "coords"): @@ -834,7 +866,7 @@ def test_check_array_complex_data_error(): check_array(X) # sparse matrix - X = sp.coo_matrix([[0, 1 + 2j], [0, 0]]) + X = sp.coo_array([[0, 1 + 2j], [0, 0]]) with pytest.raises(ValueError, match="Complex data not supported"): check_array(X) @@ -868,12 +900,12 @@ def test_check_symmetric(): test_arrays = { "dense": arr_asym, - "dok": sp.dok_matrix(arr_asym), - "csr": sp.csr_matrix(arr_asym), - "csc": sp.csc_matrix(arr_asym), - "coo": sp.coo_matrix(arr_asym), - "lil": sp.lil_matrix(arr_asym), - "bsr": sp.bsr_matrix(arr_asym), + "dok": sp.dok_array(arr_asym), + "csr": sp.csr_array(arr_asym), + "csc": sp.csc_array(arr_asym), + "coo": sp.coo_array(arr_asym), + "lil": sp.lil_array(arr_asym), + "bsr": sp.bsr_array(arr_asym), } # check error for bad inputs @@ -1021,7 +1053,7 @@ def test_check_consistent_length(): input types trigger TypeErrors.""" check_consistent_length([1], [2], [3], [4], [5]) check_consistent_length([[1, 2], [[1, 2]]], [1, 2], ["a", "b"]) - check_consistent_length([1], (2,), np.array([3]), sp.csr_matrix((1, 2))) + check_consistent_length([1], (2,), np.array([3]), sp.csr_array((1, 2))) with pytest.raises(ValueError, match="inconsistent numbers of samples"): check_consistent_length([1, 2], [1]) with pytest.raises(TypeError, match=r"got <\w+ 'int'>"): @@ -1037,13 +1069,12 @@ def test_check_consistent_length(): @pytest.mark.parametrize( - "array_namespace, device, _", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) -def test_check_consistent_length_array_api(array_namespace, device, _): +def test_check_consistent_length_array_api(array_namespace, device_name, dtype_name): """Test that check_consistent_length works with different array types.""" - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) with config_context(array_api_dispatch=True): check_consistent_length( @@ -1057,21 +1088,19 @@ def test_check_consistent_length_array_api(array_namespace, device, _): with pytest.raises(ValueError, match="inconsistent numbers of samples"): check_consistent_length( - xp.asarray([1, 2], device=device), xp.asarray([1], device=device) + xp.asarray([1, 2], device=device), + xp.asarray([1], device=device), ) def test_check_dataframe_fit_attribute(): - # check pandas dataframe with 'fit' column does not raise error + # check pandas.DataFrame with 'fit' column does not raise error # https://github.com/scikit-learn/scikit-learn/issues/8415 - try: - import pandas as pd - - X = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - X_df = pd.DataFrame(X, columns=["a", "b", "fit"]) - check_consistent_length(X_df) - except ImportError: - raise SkipTest("Pandas not found") + # This essentially tests _num_samples. + pd = pytest.importorskip("pandas") + X = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + X_df = pd.DataFrame(X, columns=["a", "b", "fit"]) + check_consistent_length(X_df) def test_suppress_validation(): @@ -1087,7 +1116,7 @@ def test_suppress_validation(): def test_check_array_series(): # regression test that check_array works on pandas Series - pd = importorskip("pandas") + pd = pytest.importorskip("pandas") res = check_array(pd.Series([1, 2, 3]), ensure_2d=False) assert_array_equal(res, np.array([1, 2, 3])) @@ -1102,13 +1131,13 @@ def test_check_array_series(): ) @pytest.mark.parametrize("bool_dtype", ("bool", "boolean")) def test_check_dataframe_mixed_float_dtypes(dtype, bool_dtype): - # pandas dataframe will coerce a boolean into a object, this is a mismatch + # pandas.DataFrame will coerce a boolean into a object, this is a mismatch # with np.result_type which will return a float # check_array needs to explicitly check for bool dtype in a dataframe for # this situation # https://github.com/scikit-learn/scikit-learn/issues/15787 - pd = importorskip("pandas") + pd = pytest.importorskip("pandas") df = pd.DataFrame( { @@ -1128,8 +1157,8 @@ def test_check_dataframe_mixed_float_dtypes(dtype, bool_dtype): def test_check_dataframe_with_only_bool(): - """Check that dataframe with bool return a boolean arrays.""" - pd = importorskip("pandas") + """Check that pandas.DataFrame with bool return a boolean arrays.""" + pd = pytest.importorskip("pandas") df = pd.DataFrame({"bool": [True, False, True]}) array = check_array(df, dtype=None) @@ -1147,8 +1176,8 @@ def test_check_dataframe_with_only_bool(): def test_check_dataframe_with_only_boolean(): - """Check that dataframe with boolean return a float array with dtype=None""" - pd = importorskip("pandas") + """Check that pandas.DataFrame with boolean return a float array with dtype=None""" + pd = pytest.importorskip("pandas") df = pd.DataFrame({"bool": pd.Series([True, False, True], dtype="boolean")}) array = check_array(df, dtype=None) @@ -1299,6 +1328,13 @@ class MockEstimator: sp.bsr_matrix, sp.dok_matrix, sp.dia_matrix, + sp.csr_array, + sp.csc_array, + sp.coo_array, + sp.lil_array, + sp.bsr_array, + sp.dok_array, + sp.dia_array, ], ) def test_check_non_negative(retype): @@ -1326,7 +1362,9 @@ def test_check_X_y_informative_error(): check_X_y(X, y, estimator=RandomForestRegressor()) -def test_retrieve_samples_from_non_standard_shape(): +def test_num_samples_on_non_standard_shape(): + """Test _num_samples on different non standard input X.""" + class TestNonNumericShape: def __init__(self): self.shape = ("not numeric",) @@ -1345,6 +1383,37 @@ def __init__(self): with pytest.raises(TypeError, match="Expected sequence or array-like"): _num_samples(TestNoLenWeirdShape()) + class TestNoLenNoShapeButArrayProtocol: + def __init__(self, x): + self.x = x + + def __array__(self, dtype=None, copy=None): + return np.asarray(self.x, dtype=dtype) # copy needs numpy >= 2.0 + + X = TestNoLenNoShapeButArrayProtocol(np.arange(3)) + assert _num_samples(X) == 3 + X = TestNoLenNoShapeButArrayProtocol(np.arange(6).reshape(3, 2)) + assert _num_samples(X) == 3 + + +@pytest.mark.parametrize( + "constructor_name", ["list", "tuple", "array", "series", "polars_series"] +) +def test_num_samples_on_1d(constructor_name): + """Test _num_samples on different 1d input X.""" + X = _convert_container(list(range(3)), constructor_name) + assert _num_samples(X) == 3 + + +@pytest.mark.parametrize( + "constructor_name", + ["list", "tuple", "array", "sparse", "pandas", "pyarrow", "polars"], +) +def test_num_samples_on_dataframe_likes(constructor_name): + """Test _num_samples on different dataframe-like input X.""" + X = _convert_container([[1, 11], [2, 22], [3, 33]], constructor_name) + assert _num_samples(X) == 3 + @pytest.mark.parametrize("x", [2, 3, 2.5, 5]) def test_check_scalar_valid(x): @@ -1602,11 +1671,11 @@ def _check_sample_weight_common(xp): # for check_sample_weight # check None input sample_weight = _check_sample_weight(None, X=xp.ones((5, 2))) - assert_allclose(_convert_to_numpy(sample_weight, xp), np.ones(5)) + assert_allclose(move_to(sample_weight, xp=np, device="cpu"), np.ones(5)) # check numbers input sample_weight = _check_sample_weight(2.0, X=xp.ones((5, 2))) - assert_allclose(_convert_to_numpy(sample_weight, xp), 2 * np.ones(5)) + assert_allclose(move_to(sample_weight, xp=np, device="cpu"), 2 * np.ones(5)) # check wrong number of dimensions with pytest.raises(ValueError, match=r"Sample weights must be 1D array or scalar"): @@ -1631,6 +1700,13 @@ def _check_sample_weight_common(xp): with pytest.raises(ValueError, match=err_msg): _check_sample_weight(sample_weight, X, ensure_non_negative=True) + # check error raised when allow_all_zero_weights=False + X = xp.ones((5, 2)) + sample_weight = xp.zeros(_num_samples(X)) + err_msg = "Sample weights must contain at least one non-zero number." + with pytest.raises(ValueError, match=err_msg): + _check_sample_weight(sample_weight, X, allow_all_zero_weights=False) + def test_check_sample_weight(): # check array order @@ -1648,10 +1724,11 @@ def test_check_sample_weight(): @pytest.mark.parametrize( - "array_namespace,device,dtype", yield_namespace_device_dtype_combinations() + "array_namespace, device_name, dtype_name", + yield_namespace_device_dtype_combinations(), ) -def test_check_sample_weight_array_api(array_namespace, device, dtype): - xp = _array_api_for_tests(array_namespace, device) +def test_check_sample_weight_array_api(array_namespace, device_name, dtype_name): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) with config_context(array_api_dispatch=True): # check array order sample_weight = xp.ones(10)[::2] @@ -1670,13 +1747,14 @@ def test_check_pos_label_consistency(y_true): @pytest.mark.parametrize( - "array_namespace,device,dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("y_true", [[0], [0, 1], [-1, 1], [1, 1, 1], [-1, -1, -1]]) -def test_check_pos_label_consistency_array_api(array_namespace, device, dtype, y_true): - xp = _array_api_for_tests(array_namespace, device) +def test_check_pos_label_consistency_array_api( + array_namespace, device_name, dtype_name, y_true +): + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) with config_context(array_api_dispatch=True): arr = xp.asarray(y_true, device=device) assert _check_pos_label_consistency(None, arr) == 1 @@ -1691,15 +1769,14 @@ def test_check_pos_label_consistency_invalid(y_true): @pytest.mark.parametrize( - "array_namespace,device,dtype", + "array_namespace, device_name, dtype_name", yield_namespace_device_dtype_combinations(), - ids=_get_namespace_device_dtype_ids, ) @pytest.mark.parametrize("y_true", [[2, 3, 4], [-10], [0, -1]]) def test_check_pos_label_consistency_invalid_array_api( - array_namespace, device, dtype, y_true + array_namespace, device_name, dtype_name, y_true ): - xp = _array_api_for_tests(array_namespace, device) + xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name) with config_context(array_api_dispatch=True): arr = xp.asarray(y_true, device=device) with pytest.raises(ValueError, match="y_true takes value in"): @@ -1708,21 +1785,24 @@ def test_check_pos_label_consistency_invalid_array_api( assert _check_pos_label_consistency("a", arr) == "a" -@pytest.mark.parametrize("toarray", [np.array, sp.csr_matrix, sp.csc_matrix]) +CS_SPARSE = [sp.csr_array, sp.csr_matrix, sp.csc_array, sp.csc_matrix] + + +@pytest.mark.parametrize("toarray", [np.array] + CS_SPARSE) def test_allclose_dense_sparse_equals(toarray): base = np.arange(9).reshape(3, 3) x, y = toarray(base), toarray(base) assert _allclose_dense_sparse(x, y) -@pytest.mark.parametrize("toarray", [np.array, sp.csr_matrix, sp.csc_matrix]) +@pytest.mark.parametrize("toarray", [np.array] + CS_SPARSE) def test_allclose_dense_sparse_not_equals(toarray): base = np.arange(9).reshape(3, 3) x, y = toarray(base), toarray(base + 1) assert not _allclose_dense_sparse(x, y) -@pytest.mark.parametrize("toarray", [sp.csr_matrix, sp.csc_matrix]) +@pytest.mark.parametrize("toarray", CS_SPARSE) def test_allclose_dense_sparse_raise(toarray): x = np.arange(9).reshape(3, 3) y = toarray(x + 1) @@ -1800,8 +1880,10 @@ def test_check_method_params(indices): _params = { "list": [1, 2, 3, 4], "array": np.array([1, 2, 3, 4]), - "sparse-col": sp.csc_matrix([1, 2, 3, 4]).T, - "sparse-row": sp.csc_matrix([1, 2, 3, 4]), + "sparse-col2": sp.csc_matrix([[1, 2, 3, 4]]).T, + "sparse-row2": sp.csc_matrix([[1, 2, 3, 4]]), + "sparse-col": sp.csc_array([[1, 2, 3, 4]]).T, + "sparse-row": sp.csc_array([[1, 2, 3, 4]]), "scalar-int": 1, "scalar-str": "xxx", "None": None, @@ -1809,7 +1891,7 @@ def test_check_method_params(indices): result = _check_method_params(X, params=_params, indices=indices) indices_ = indices if indices is not None else list(range(X.shape[0])) - for key in ["sparse-row", "scalar-int", "scalar-str", "None"]: + for key in ["sparse-row", "sparse-row2", "scalar-int", "scalar-str", "None"]: assert result[key] is _params[key] assert result["list"] == _safe_indexing(_params["list"], indices_) @@ -1817,12 +1899,14 @@ def test_check_method_params(indices): assert_allclose_dense_sparse( result["sparse-col"], _safe_indexing(_params["sparse-col"], indices_) ) + assert_allclose_dense_sparse( + result["sparse-col2"], _safe_indexing(_params["sparse-col2"], indices_) + ) @pytest.mark.parametrize("sp_format", [True, "csr", "csc", "coo", "bsr"]) def test_check_sparse_pandas_sp_format(sp_format): - # check_array converts pandas dataframe with only sparse arrays into - # sparse matrix + # check_array converts pandas.DataFrame with only sparse arrays into sparse matrix pd = pytest.importorskip("pandas") sp_mat = _sparse_random_matrix(10, 3) @@ -1838,6 +1922,32 @@ def test_check_sparse_pandas_sp_format(sp_format): assert_allclose_dense_sparse(sp_mat, result) +def test_check_array_pd_sparse_dataframe_warning(): + """Test that check_array warns on pandas dataframe with sparse columns.""" + pd = pytest.importorskip("pandas") + + # Warning is raised only when some of the columns are sparse, not all of them. + # Construct a pandas.DataFrame with first column dense, all others sparse. + df = pd.DataFrame({"col_0": np.linspace(0, 1, 10)}) + for i in range(1, 4): + arr = np.zeros(10) + arr[:4] = np.arange(4) + arr = pd.arrays.SparseArray(arr, fill_value=0) + df[f"col_{i}"] = arr + + msg = "pandas.DataFrame with sparse columns found." + with pytest.warns(UserWarning, match=msg): + check_array(df, accept_sparse=True) + + # No warning when the whole dataframe is sparse + df = df.drop(columns="col_0") + assert hasattr(df, "sparse") + + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + check_array(df, accept_sparse=True) + + @pytest.mark.parametrize( "ntype1, ntype2", [ @@ -1853,7 +1963,7 @@ def test_check_sparse_pandas_sp_format(sp_format): ], ) def test_check_pandas_sparse_mixed_dtypes(ntype1, ntype2): - """Check that pandas dataframes having sparse extension arrays with mixed dtypes + """Check that pandas.DataFrame having sparse extension arrays with mixed dtypes works.""" pd = pytest.importorskip("pandas") df = pd.DataFrame( @@ -1901,7 +2011,7 @@ def test_check_pandas_sparse_valid(ntype1, ntype2, expected_subtype): @pytest.mark.parametrize( "constructor_name", - ["list", "tuple", "array", "dataframe", "sparse_csr", "sparse_csc"], + ["list", "tuple", "array", "pandas", "sparse_csr", "sparse_csc"], ) def test_num_features(constructor_name): """Check _num_features for array-likes.""" @@ -1956,7 +2066,7 @@ def test_num_features_errors_scalars(X): ids=["list-int", "range", "default", "MultiIndex"], ) def test_get_feature_names_pandas_with_ints_no_warning(names): - """Get feature names with pandas dataframes without warning. + """Get feature names with pandas.DataFrames without warning. Column names with consistent dtypes will not warn, such as int or MultiIndex. """ @@ -1969,89 +2079,22 @@ def test_get_feature_names_pandas_with_ints_no_warning(names): assert names is None -def test_get_feature_names_pandas(): - """Get feature names with pandas dataframes.""" - pd = pytest.importorskip("pandas") - columns = [f"col_{i}" for i in range(3)] - X = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=columns) - feature_names = _get_feature_names(X) - - assert_array_equal(feature_names, columns) - - @pytest.mark.parametrize( "constructor_name, minversion", - [("pyarrow", "12.0.0"), ("dataframe", "1.5.0"), ("polars", "0.18.2")], + [("pyarrow", "13.0.0"), ("pandas", "1.5.0"), ("polars", "0.18.2")], ) -def test_get_feature_names_dataframe_protocol(constructor_name, minversion): - """Uses the dataframe exchange protocol to get feature names.""" +def test_get_feature_names_4_dataframes(constructor_name, minversion): + """Test _get_features_names on dataframes.""" data = [[1, 4, 2], [3, 3, 6]] columns = ["col_0", "col_1", "col_2"] df = _convert_container( - data, constructor_name, columns_name=columns, minversion=minversion + data, constructor_name, column_names=columns, minversion=minversion ) feature_names = _get_feature_names(df) assert_array_equal(feature_names, columns) -@pytest.mark.parametrize("constructor_name", ["pyarrow", "dataframe", "polars"]) -def test_is_pandas_df_other_libraries(constructor_name): - df = _convert_container([[1, 4, 2], [3, 3, 6]], constructor_name) - if constructor_name in ("pyarrow", "polars"): - assert not _is_pandas_df(df) - else: - assert _is_pandas_df(df) - - -def test_is_pandas_df(): - """Check behavior of is_pandas_df when pandas is installed.""" - pd = pytest.importorskip("pandas") - df = pd.DataFrame([[1, 2, 3]]) - assert _is_pandas_df(df) - assert not _is_pandas_df(np.asarray([1, 2, 3])) - assert not _is_pandas_df(1) - - -def test_is_pandas_df_pandas_not_installed(hide_available_pandas): - """Check _is_pandas_df when pandas is not installed.""" - - assert not _is_pandas_df(np.asarray([1, 2, 3])) - assert not _is_pandas_df(1) - - -@pytest.mark.parametrize( - "constructor_name, minversion", - [ - ("pyarrow", dependent_packages["pyarrow"][0]), - ("dataframe", dependent_packages["pandas"][0]), - ("polars", dependent_packages["polars"][0]), - ], -) -def test_is_polars_df_other_libraries(constructor_name, minversion): - df = _convert_container( - [[1, 4, 2], [3, 3, 6]], - constructor_name, - minversion=minversion, - ) - if constructor_name in ("pyarrow", "dataframe"): - assert not _is_polars_df(df) - else: - assert _is_polars_df(df) - - -def test_is_polars_df_for_duck_typed_polars_dataframe(): - """Check _is_polars_df for object that looks like a polars dataframe""" - - class NotAPolarsDataFrame: - def __init__(self): - self.columns = [1, 2, 3] - self.schema = "my_schema" - - not_a_polars_df = NotAPolarsDataFrame() - assert not _is_polars_df(not_a_polars_df) - - def test_get_feature_names_numpy(): """Get feature names return None for numpy arrays.""" X = np.array([[1, 2, 3], [4, 5, 6]]) @@ -2063,9 +2106,9 @@ def test_get_feature_names_numpy(): "names, dtypes", [ (["a", 1], "['int', 'str']"), - (["pizza", ["a", "b"]], "['list', 'str']"), + (["pizza", ("a", "b")], "['str', 'tuple']"), ], - ids=["int-str", "list-str"], + ids=["str-int", "str-tuple"], ) def test_get_feature_names_invalid_dtypes(names, dtypes): """Get feature names errors when the feature names have mixed dtypes""" @@ -2096,36 +2139,34 @@ def get_feature_names_out(self, input_features=None): return _check_feature_names_in(self, input_features) -def test_check_feature_names_in(): +@pytest.mark.parametrize( + ["constructor_name", "feature_names", "msg"], + [ + ("array", ["x0", "x1", "x2"], "input_features should have length equal to"), + ("pandas", ["a", "b", "c"], "input_features is not equal to"), + ("polars", ["a", "b", "c"], "input_features is not equal to"), + ], +) +def test_check_feature_names_in(constructor_name, feature_names, msg): """Check behavior of check_feature_names_in for arrays.""" X = np.array([[0.0, 1.0, 2.0]]) + X = _convert_container(X, constructor_name, column_names=["a", "b", "c"]) est = PassthroughTransformer().fit(X) names = est.get_feature_names_out() - assert_array_equal(names, ["x0", "x1", "x2"]) + assert_array_equal(names, feature_names) - incorrect_len_names = ["x10", "x1"] - with pytest.raises(ValueError, match="input_features should have length equal to"): + incorrect_len_names = feature_names[:2] + with pytest.raises(ValueError, match=msg): est.get_feature_names_out(incorrect_len_names) # remove n_feature_in_ del est.n_features_in_ - with pytest.raises(ValueError, match="Unable to generate feature names"): - est.get_feature_names_out() - - -def test_check_feature_names_in_pandas(): - """Check behavior of check_feature_names_in for pandas dataframes.""" - pd = pytest.importorskip("pandas") - names = ["a", "b", "c"] - df = pd.DataFrame([[0.0, 1.0, 2.0]], columns=names) - est = PassthroughTransformer().fit(df) - - names = est.get_feature_names_out() - assert_array_equal(names, ["a", "b", "c"]) - - with pytest.raises(ValueError, match="input_features is not equal to"): - est.get_feature_names_out(["x1", "x2", "x3"]) + if constructor_name == "array": + with pytest.raises(ValueError, match="Unable to generate feature names"): + est.get_feature_names_out() + else: + assert_array_equal(est.get_feature_names_out(), feature_names) def test_check_response_method_unknown_method(): @@ -2181,7 +2222,7 @@ def test_check_response_method_list_str(): def test_boolean_series_remains_boolean(): """Regression test for gh-25145""" - pd = importorskip("pandas") + pd = pytest.importorskip("pandas") res = check_array(pd.Series([True, False]), ensure_2d=False) expected = np.array([True, False]) @@ -2195,7 +2236,7 @@ def test_pandas_array_returns_ndarray(input_values): Non-regression test for gh-25637. """ - pd = importorskip("pandas") + pd = pytest.importorskip("pandas") input_series = pd.array(input_values, dtype="Int32") result = check_array( input_series, @@ -2255,14 +2296,6 @@ def test_check_array_multiple_extensions( assert_array_equal(X_regular_checked, X_extension_checked) -def test_num_samples_dataframe_protocol(): - """Use the DataFrame interchange protocol to get n_samples from polars.""" - pl = pytest.importorskip("polars") - - df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) - assert _num_samples(df) == 3 - - @pytest.mark.parametrize( "sparse_container", CSR_CONTAINERS + CSC_CONTAINERS + COO_CONTAINERS + DIA_CONTAINERS, @@ -2322,17 +2355,6 @@ def test_column_or_1d(): column_or_1d(y) -def test__is_polars_df(): - """Check that _is_polars_df return False for non-dataframe objects.""" - - class LooksLikePolars: - def __init__(self): - self.columns = ["a", "b"] - self.schema = ["a", "b"] - - assert not _is_polars_df(LooksLikePolars()) - - def test_check_array_writeable_np(): """Check the behavior of check_array when a writeable array is requested without copy if possible, on numpy arrays. @@ -2426,3 +2448,76 @@ def test_check_array_on_sparse_inputs_with_array_api_enabled(): def test_check_array_allow_nd_errors(X, estimator, expected_error_message): with pytest.raises(ValueError, match=expected_error_message): check_array(X, estimator=estimator) + + +@pytest.mark.parametrize( + ["categorical_features", "expected_msg"], + [ + ( + [b"hello", b"world"], + re.escape( + "categorical_features must be an array-like of bool, int or str, " + "got: bytes40." + ), + ), + ( + np.array([b"hello", 1.3], dtype=object), + re.escape( + "categorical_features must be an array-like of bool, int or str, " + "got: bytes, float." + ), + ), + ( + [0, -1], + re.escape( + "categorical_features set as integer indices must be in " + "[0, n_features - 1]" + ), + ), + ( + [True, True, False, False, True], + re.escape( + "categorical_features set as a boolean mask must have shape " + "(n_features,)" + ), + ), + ], +) +def test_check_categorical_features_raises(categorical_features, expected_msg): + """Test that check_categorical_features raises expected errors.""" + rng = np.random.RandomState(0) + n_samples, n_features = 10, 10 + X = rng.randint(0, 3, size=(n_samples, n_features)) + + with pytest.raises(ValueError, match=expected_msg): + _check_categorical_features(X, categorical_features) + + +@pytest.mark.parametrize( + ["categorical_features", "on_array"], + [ + ([False, True, True, False], True), + ([1, 2], True), + (["b", "c"], False), + ("from_dtype", False), + ], +) +@pytest.mark.parametrize("constructor_name", ["array", "pandas", "polars"]) +def test_check_categorical_features(categorical_features, on_array, constructor_name): + """Test that check_categorical_features returns as expected on simple data.""" + rng = np.random.RandomState(0) + n_samples, n_features = 30, 4 + X = rng.randint(0, 3, size=(n_samples, n_features)) + if constructor_name == "array" and not on_array: + return + elif constructor_name == "polars": + X = X.astype(str) + X = _convert_container( + X, + constructor_name, + column_names=["a", "b", "c", "d"], + categorical_feature_names=["b", "c"], + ) + + result = _check_categorical_features(X, categorical_features) + assert_allclose(result, [False, True, True, False]) diff --git a/sklearn/utils/validation.py b/sklearn/utils/validation.py index ed9b5e20e40bb..c1812bd3c8d5b 100644 --- a/sklearn/utils/validation.py +++ b/sklearn/utils/validation.py @@ -5,7 +5,6 @@ import numbers import operator -import sys import warnings from collections.abc import Sequence from contextlib import suppress @@ -13,6 +12,7 @@ from inspect import Parameter, isclass, signature import joblib +import narwhals.stable.v2 as nw import numpy as np import scipy.sparse as sp @@ -24,12 +24,13 @@ ) from sklearn.utils._array_api import ( _asarray_with_order, - _convert_to_numpy, _is_numpy_namespace, _max_precision_float_dtype, get_namespace, get_namespace_and_device, + move_to, ) +from sklearn.utils._dataframe import is_pandas_df_or_series from sklearn.utils._isfinite import FiniteStatus, cy_isfinite from sklearn.utils._tags import get_tags from sklearn.utils.fixes import ( @@ -77,7 +78,7 @@ def inner_f(*args, **kwargs): # extra_args > 0 args_msg = [ - "{}={}".format(name, arg) + f"{name}={arg}" for name, arg in zip(kwonly_args[:extra_args], args[-extra_args:]) ] args_msg = ", ".join(args_msg) @@ -305,16 +306,6 @@ def _is_arraylike_not_scalar(array): return _is_arraylike(array) and not np.isscalar(array) -def _use_interchange_protocol(X): - """Use interchange protocol for non-pandas dataframes that follow the protocol. - - Note: at this point we chose not to use the interchange API on pandas dataframe - to ensure strict behavioral backward compatibility with older versions of - scikit-learn. - """ - return not _is_pandas_df(X) and hasattr(X, "__dataframe__") - - def _num_features(X): """Return the number of features in an array-like X. @@ -375,16 +366,6 @@ def _num_samples(x): # Don't get num_samples from an ensembles length! raise TypeError(message) - if _use_interchange_protocol(x): - return x.__dataframe__().num_rows() - - if not hasattr(x, "__len__") and not hasattr(x, "shape"): - if hasattr(x, "__array__"): - xp, _ = get_namespace(x) - x = xp.asarray(x) - else: - raise TypeError(message) - if hasattr(x, "shape") and x.shape is not None: if len(x.shape) == 0: raise TypeError( @@ -396,6 +377,16 @@ def _num_samples(x): if isinstance(x.shape[0], numbers.Integral): return x.shape[0] + if nw.dependencies.is_into_dataframe(x) or nw.dependencies.is_into_series(x): + return nw.from_native(x).shape[0] + + if not hasattr(x, "__len__") and not hasattr(x, "shape"): + if hasattr(x, "__array__"): + xp, _ = get_namespace(x) + x = xp.asarray(x) + else: + raise TypeError(message) + try: return len(x) except TypeError as type_error: @@ -437,7 +428,7 @@ def check_memory(memory): raise ValueError( "'memory' should be None, a string or have the same" " interface as joblib.Memory." - " Got memory='{}' instead.".format(memory) + f" Got memory='{memory}' instead." ) return memory @@ -508,10 +499,10 @@ def indexable(*iterables): Examples -------- >>> from sklearn.utils import indexable - >>> from scipy.sparse import csr_matrix + >>> from scipy.sparse import csr_array >>> import numpy as np >>> iterables = [ - ... [1, 2, 3], np.array([2, 3, 4]), None, csr_matrix([[5], [6], [7]]) + ... [1, 2, 3], np.array([2, 3, 4]), None, csr_array([[5], [6], [7]]) ... ] >>> indexable(*iterables) [[1, 2, 3], array([2, 3, 4]), None, <...Sparse...dtype 'int64'...shape (3, 1)>] @@ -570,6 +561,10 @@ def _ensure_sparse_format( .. versionchanged:: 0.23 Accepts `pd.NA` and converts it into `np.nan` + accept_large_sparse : bool + If a CSR, CSC, COO or BSR sparse matrix is supplied and accepted by + accept_sparse, accept_large_sparse will cause it to be accepted only + if its indices are stored with a 32-bit dtype. estimator_name : str, default=None The estimator name, used to construct the error message. @@ -717,6 +712,16 @@ def _pandas_dtype_needs_early_conversion(pd_dtype): return False +def _is_pandas_string_dtype(dtype): + """Return True if dtype is a pandas StringDtype.""" + try: + from pandas import StringDtype + + return isinstance(dtype, StringDtype) + except ImportError: + return False + + def _is_extension_array_dtype(array): # Pandas extension arrays have a dtype with an na_value return hasattr(array, "dtype") and hasattr(array.dtype, "na_value") @@ -894,8 +899,12 @@ def is_sparse(dtype): pandas_requires_conversion = any( _pandas_dtype_needs_early_conversion(i) for i in dtypes_orig ) + has_pandas_string = any(_is_pandas_string_dtype(d) for d in dtypes_orig) if all(isinstance(dtype_iter, np.dtype) for dtype_iter in dtypes_orig): dtype_orig = np.result_type(*dtypes_orig) + elif has_pandas_string: + # Force object if any of the dtypes is a StringDtype. + dtype_orig = object elif pandas_requires_conversion and any(d == object for d in dtypes_orig): # Force object if any of the dtypes is an object dtype_orig = object @@ -903,20 +912,23 @@ def is_sparse(dtype): elif (_is_extension_array_dtype(array) or hasattr(array, "iloc")) and hasattr( array, "dtype" ): - # array is a pandas series + # array is a pandas series or a pandas array. type_if_series = type(array) pandas_requires_conversion = _pandas_dtype_needs_early_conversion(array.dtype) if isinstance(array.dtype, np.dtype): dtype_orig = array.dtype + elif _is_pandas_string_dtype(array.dtype): + # pandas 3 uses StringDtype for string columns instead of object. + # Treat as object so that dtype_numeric detection works correctly. + dtype_orig = object else: # Set to None to let array.astype work out the best dtype dtype_orig = None if dtype_numeric: - if ( - dtype_orig is not None - and hasattr(dtype_orig, "kind") - and dtype_orig.kind == "O" + if dtype_orig is not None and ( + (hasattr(dtype_orig, "kind") and dtype_orig.kind == "O") + or dtype_orig == object ): # if input is object, convert to float. dtype = xp.float64 @@ -1129,7 +1141,7 @@ def is_sparse(dtype): # ensure that the output is writeable, even if avoidable, to not overwrite # the user's data by surprise. - if _is_pandas_df_or_series(array_orig): + if is_pandas_df_or_series(array_orig): try: # In pandas >= 3, np.asarray(df), called earlier in check_array, # returns a read-only intermediate array. It can be made writeable @@ -1437,7 +1449,7 @@ def column_or_1d(y, *, dtype=None, input_name="y", warn=False, device=None): def check_random_state(seed): - """Turn seed into a np.random.RandomState instance. + """Turn seed into an np.random.RandomState instance. Parameters ---------- @@ -1465,7 +1477,7 @@ def check_random_state(seed): if isinstance(seed, np.random.RandomState): return seed raise ValueError( - "%r cannot be used to seed a numpy.random.RandomState instance" % seed + f"{seed!r} cannot be used to seed a numpy.random.RandomState instance" ) @@ -1540,10 +1552,10 @@ def check_symmetric(array, *, tol=1e-10, raise_warning=True, raise_exception=Fal array([[0, 1, 2], [1, 0, 1], [2, 1, 0]]) - >>> from scipy.sparse import csr_matrix - >>> sparse_symmetric_array = csr_matrix(symmetric_array) + >>> from scipy.sparse import csr_array + >>> sparse_symmetric_array = csr_array(symmetric_array) >>> check_symmetric(sparse_symmetric_array) - <Compressed Sparse Row sparse matrix of dtype 'int64' + <Compressed Sparse Row sparse array of dtype 'int64' with 6 stored elements and shape (3, 3)> """ if (array.ndim != 2) or (array.shape[0] != array.shape[1]): @@ -1686,7 +1698,7 @@ def check_is_fitted(estimator, attributes=None, *, msg=None, all_or_any=all): >>> check_is_fitted(lr) """ if isclass(estimator): - raise TypeError("{} is a class, not an instance.".format(estimator)) + raise TypeError(f"{estimator} is a class, not an instance.") if msg is None: msg = ( "This %(name)s instance is not fitted yet. Call 'fit' with " @@ -1694,7 +1706,7 @@ def check_is_fitted(estimator, attributes=None, *, msg=None, all_or_any=all): ) if not hasattr(estimator, "fit"): - raise TypeError("%s is not an estimator instance." % (estimator)) + raise TypeError(f"{estimator} is not an estimator instance.") tags = get_tags(estimator) @@ -2085,6 +2097,7 @@ def _check_sample_weight( ensure_non_negative=False, ensure_same_device=True, copy=False, + allow_all_zero_weights=False, ): """Validate sample weights. @@ -2127,12 +2140,17 @@ def _check_sample_weight( copy : bool, default=False If True, a copy of sample_weight will be created. + allow_all_zero_weights : bool, default=False, + Whether or not to raise an error when sample weights are all zero. + Returns ------- sample_weight : ndarray of shape (n_samples,) Validated sample weight. It is guaranteed to be "C" contiguous. """ - xp, is_array_api, device = get_namespace_and_device(X, remove_types=(int, float)) + xp, is_array_api, device = get_namespace_and_device( + X, remove_types=(list, int, float) + ) n_samples = _num_samples(X) @@ -2151,7 +2169,7 @@ def _check_sample_weight( if force_float_dtype and dtype is None: dtype = float_dtypes if is_array_api and ensure_same_device: - sample_weight = xp.asarray(sample_weight, device=device) + sample_weight = move_to(sample_weight, xp=xp, device=device) sample_weight = check_array( sample_weight, accept_sparse=False, @@ -2175,6 +2193,12 @@ def _check_sample_weight( ) ) + if not allow_all_zero_weights: + if xp.all(sample_weight == 0): + raise ValueError( + "Sample weights must contain at least one non-zero number." + ) + if ensure_non_negative: check_non_negative(sample_weight, "`sample_weight`") @@ -2307,90 +2331,33 @@ def _check_method_params(X, params, indices=None): return method_params_validated -def _is_pandas_df_or_series(X): - """Return True if the X is a pandas dataframe or series.""" - try: - pd = sys.modules["pandas"] - except KeyError: - return False - return isinstance(X, (pd.DataFrame, pd.Series)) - - -def _is_pandas_df(X): - """Return True if the X is a pandas dataframe.""" - try: - pd = sys.modules["pandas"] - except KeyError: - return False - return isinstance(X, pd.DataFrame) - - -def _is_pyarrow_data(X): - """Return True if the X is a pyarrow Table, RecordBatch, Array or ChunkedArray.""" - try: - pa = sys.modules["pyarrow"] - except KeyError: - return False - return isinstance(X, (pa.Table, pa.RecordBatch, pa.Array, pa.ChunkedArray)) - - -def _is_polars_df_or_series(X): - """Return True if the X is a polars dataframe or series.""" - try: - pl = sys.modules["polars"] - except KeyError: - return False - return isinstance(X, (pl.DataFrame, pl.Series)) - - -def _is_polars_df(X): - """Return True if the X is a polars dataframe.""" - try: - pl = sys.modules["polars"] - except KeyError: - return False - return isinstance(X, pl.DataFrame) - - def _get_feature_names(X): """Get feature names from X. - Support for other array containers should place its implementation here. + Support for other (2d) data containers should place its implementation here. Parameters ---------- X : {ndarray, dataframe} of shape (n_samples, n_features) Array container to extract feature names. - - pandas dataframe : The columns will be considered to be feature - names. If the dataframe contains non-string feature names, `None` is - returned. + - narwhals compliant dataframe : The columns will be considered to be feature + names. - All other array containers will return `None`. Returns ------- names: ndarray or None - Feature names of `X`. Unrecognized array containers will return `None`. + Feature names of `X`. Unrecognized data containers will return `None`. """ feature_names = None - # extract feature names for support array containers - if _is_pandas_df(X): - # Make sure we can inspect columns names from pandas, even with - # versions too old to expose a working implementation of - # __dataframe__.column_names() and avoid introducing any - # additional copy. - # TODO: remove the pandas-specific branch once the minimum supported - # version of pandas has a working implementation of - # __dataframe__.column_names() that is guaranteed to not introduce any - # additional copy of the data without having to impose allow_copy=False - # that could fail with other libraries. Note: in the longer term, we - # could decide to instead rely on the __dataframe_namespace__ API once - # adopted by our minimally supported pandas version. - feature_names = np.asarray(X.columns, dtype=object) - elif hasattr(X, "__dataframe__"): - df_protocol = X.__dataframe__() - feature_names = np.asarray(list(df_protocol.column_names()), dtype=object) + # Extract feature names for supported data containers. + if nw.dependencies.is_into_dataframe(X): + # Note: Narwhals API says that the .columns property ist a list of strings, but + # this does not hold. If pandas has integer column names, .columns returns a + # list of integers, see https://github.com/narwhals-dev/narwhals/issues/3571. + feature_names = np.asarray(nw.from_native(X).columns, dtype=object) if feature_names is None or len(feature_names) == 0: return @@ -2501,6 +2468,129 @@ def _generate_get_feature_names_out(estimator, n_features_out, input_features=No ) +def _check_categorical_features(X, categorical_features): + """Check and validate categorical features in X + + Parameters + ---------- + X : {array-like, pandas DataFrame} of shape (n_samples, n_features) + Input data. + + categorical_features : array-like of {bool, int, str} of shape (n_features) \ + or shape (n_categorical_features,), default='from_dtype' + Indicates the categorical features in `X`. + + - None : no feature will be considered categorical. + - boolean array-like : boolean mask indicating categorical features. + - integer array-like : integer indices indicating categorical + features. + - str array-like: names of categorical features (assuming the training + data has feature names). + - `"from_dtype"`: dataframe columns with dtype "Categorical" and "Enum" are + considered to be categorical features. The input must be a dataframe that + is supported by narwhals (or supports it): :func:`narwhals.from_native` must + work. This is the case, for instance, for pandas and polars DataFrames. + + Return + ------ + is_categorical : ndarray of shape (n_features,) or None, dtype=bool + Indicates whether a feature is categorical. If no feature is + categorical, this is None. + """ + if nw.dependencies.is_into_dataframe(X): + X = nw.from_native(X) + dtypes = X.schema.dtypes() + X_is_dataframe = True + categorical_columns_mask = np.asarray( + [d in (nw.Categorical, nw.Enum) for d in dtypes] + ) + else: + X_is_dataframe = False + categorical_columns_mask = None + + categorical_by_dtype = ( + isinstance(categorical_features, str) and categorical_features == "from_dtype" + ) + no_categorical_dtype = categorical_features is None or ( + categorical_by_dtype and not X_is_dataframe + ) + + if no_categorical_dtype: + return None + + if categorical_by_dtype and X_is_dataframe: + categorical_features = categorical_columns_mask + else: + categorical_features = np.asarray(categorical_features) + + if categorical_features.size == 0: + return None + + if categorical_features.dtype.kind not in ("i", "b", "U", "O"): + raise ValueError( + "categorical_features must be an array-like of bool, int or " + f"str, got: {categorical_features.dtype.name}." + ) + + if categorical_features.dtype.kind == "O": + types = set(type(f) for f in categorical_features) + if types != {str}: + raise ValueError( + "categorical_features must be an array-like of bool, int or " + f"str, got: {', '.join(sorted(t.__name__ for t in types))}." + ) + + n_features = X.shape[1] + # At this point `validate_data` was not called yet because we use the original + # dtypes to discover the categorical features. Thus `feature_names_in_` + # is not defined yet. + feature_names_in_ = getattr(X, "columns", None) + + if categorical_features.dtype.kind in ("U", "O"): + # check for feature names + if feature_names_in_ is None: + raise ValueError( + "categorical_features should be passed as an array of " + "integers or as a boolean mask when the model is fitted " + "on data without feature names." + ) + is_categorical = np.zeros(n_features, dtype=bool) + feature_names = list(feature_names_in_) + for feature_name in categorical_features: + try: + is_categorical[feature_names.index(feature_name)] = True + except ValueError as e: + raise ValueError( + f"categorical_features has an item value '{feature_name}' " + "which is not a valid feature name of the training " + f"data. Observed feature names: {feature_names}" + ) from e + elif categorical_features.dtype.kind == "i": + # check for categorical features as indices + if ( + np.max(categorical_features) >= n_features + or np.min(categorical_features) < 0 + ): + raise ValueError( + "categorical_features set as integer " + "indices must be in [0, n_features - 1]" + ) + is_categorical = np.zeros(n_features, dtype=bool) + is_categorical[categorical_features] = True + else: + if categorical_features.shape[0] != n_features: + raise ValueError( + "categorical_features set as a boolean mask " + "must have shape (n_features,), got: " + f"{categorical_features.shape}" + ) + is_categorical = categorical_features + + if not np.any(is_categorical): + return None + return is_categorical + + def _check_monotonic_cst(estimator, monotonic_cst=None): """Check the monotonic constraints and return the corresponding array. @@ -2548,13 +2638,13 @@ def _check_monotonic_cst(estimator, monotonic_cst=None): set(original_monotonic_cst) - set(estimator.feature_names_in_) ) unexpected_feature_names.sort() # deterministic error message - n_unexpeced = len(unexpected_feature_names) + n_unexpected = len(unexpected_feature_names) if unexpected_feature_names: if len(unexpected_feature_names) > 5: unexpected_feature_names = unexpected_feature_names[:5] unexpected_feature_names.append("...") raise ValueError( - f"monotonic_cst contains {n_unexpeced} unexpected feature " + f"monotonic_cst contains {n_unexpected} unexpected feature " f"names: {unexpected_feature_names}." ) for feature_idx, feature_name in enumerate(estimator.feature_names_in_): @@ -2627,7 +2717,7 @@ def _check_pos_label_consistency(pos_label, y_true): or xp.all(classes == xp.asarray([1], device=device)) ) ): - classes = _convert_to_numpy(classes, xp=xp) + classes = move_to(classes, xp=np, device="cpu") classes_repr = ", ".join([repr(c) for c in classes.tolist()]) raise ValueError( f"y_true takes value in {{{classes_repr}}} and pos_label is not "